blob: 1fe55890a1cafe8d9682a61ecc8bf304a813b9f8 [file] [log] [blame]
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.intentresolver;
import android.annotation.Nullable;
import android.app.Activity;
import android.app.PendingIntent;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.PackageManager.NameNotFoundException;
import android.content.pm.ResolveInfo;
import android.content.res.Resources;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Bundle;
import android.service.chooser.ChooserAction;
import android.text.TextUtils;
import android.util.Log;
import android.view.View;
import com.android.intentresolver.chooser.DisplayResolveInfo;
import com.android.intentresolver.chooser.TargetInfo;
import com.android.intentresolver.flags.FeatureFlagRepository;
import com.android.intentresolver.flags.Flags;
import com.android.intentresolver.widget.ActionRow;
import com.android.internal.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application
* requirements of Sharesheet / {@link ChooserActivity}.
*/
public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory {
/** Delegate interface to launch activities when the actions are selected. */
public interface ActionActivityStarter {
/**
* Request an activity launch for the provided target. Implementations may choose to exit
* the current activity when the target is launched.
*/
void safelyStartActivityAsPersonalProfileUser(TargetInfo info);
/**
* Request an activity launch for the provided target, optionally employing the specified
* shared element transition. Implementations may choose to exit the current activity when
* the target is launched.
*/
default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
TargetInfo info, View sharedElement, String sharedElementName) {
safelyStartActivityAsPersonalProfileUser(info);
}
}
private static final String TAG = "ChooserActions";
private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
| Intent.FLAG_GRANT_PREFIX_URI_PERMISSION;
private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label";
private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon";
private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image";
private final Context mContext;
private final String mCopyButtonLabel;
private final Drawable mCopyButtonDrawable;
private final Runnable mOnCopyButtonClicked;
private final TargetInfo mEditSharingTarget;
private final Runnable mOnEditButtonClicked;
private final TargetInfo mNearbySharingTarget;
private final Runnable mOnNearbyButtonClicked;
private final ImmutableList<ChooserAction> mCustomActions;
private final PendingIntent mReselectionIntent;
private final Consumer<Boolean> mExcludeSharedTextAction;
private final Consumer</* @Nullable */ Integer> mFinishCallback;
/**
* @param context
* @param chooserRequest data about the invocation of the current Sharesheet session.
* @param featureFlagRepository feature flags that may control the eligibility of some actions.
* @param integratedDeviceComponents info about other components that are available on this
* device to implement the supported action types.
* @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text"
* setting is updated. The argument is whether the shared text is to be excluded.
* @param firstVisibleImageQuery a delegate that provides a reference to the first visible image
* View in the Sharesheet UI, if any, or null.
* @param activityStarter a delegate to launch activities when actions are selected.
* @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was
* completed).
*/
public ChooserActionFactory(
Context context,
ChooserRequestParameters chooserRequest,
FeatureFlagRepository featureFlagRepository,
ChooserIntegratedDeviceComponents integratedDeviceComponents,
ChooserActivityLogger logger,
Consumer<Boolean> onUpdateSharedTextIsExcluded,
Callable</* @Nullable */ View> firstVisibleImageQuery,
ActionActivityStarter activityStarter,
Consumer</* @Nullable */ Integer> finishCallback) {
this(
context,
context.getString(com.android.internal.R.string.copy),
context.getDrawable(com.android.internal.R.drawable.ic_menu_copy_material),
makeOnCopyRunnable(
context,
chooserRequest.getTargetIntent(),
chooserRequest.getReferrerPackageName(),
finishCallback,
logger),
getEditSharingTarget(
context,
chooserRequest.getTargetIntent(),
integratedDeviceComponents),
makeOnEditRunnable(
getEditSharingTarget(
context,
chooserRequest.getTargetIntent(),
integratedDeviceComponents),
firstVisibleImageQuery,
activityStarter,
logger),
getNearbySharingTarget(
context,
chooserRequest.getTargetIntent(),
integratedDeviceComponents),
makeOnNearbyShareRunnable(
getNearbySharingTarget(
context,
chooserRequest.getTargetIntent(),
integratedDeviceComponents),
activityStarter,
finishCallback,
logger),
chooserRequest.getChooserActions(),
(featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION)
? chooserRequest.getModifyShareAction() : null),
onUpdateSharedTextIsExcluded,
finishCallback);
}
@VisibleForTesting
ChooserActionFactory(
Context context,
String copyButtonLabel,
Drawable copyButtonDrawable,
Runnable onCopyButtonClicked,
TargetInfo editSharingTarget,
Runnable onEditButtonClicked,
TargetInfo nearbySharingTarget,
Runnable onNearbyButtonClicked,
List<ChooserAction> customActions,
@Nullable PendingIntent reselectionIntent,
Consumer<Boolean> onUpdateSharedTextIsExcluded,
Consumer</* @Nullable */ Integer> finishCallback) {
mContext = context;
mCopyButtonLabel = copyButtonLabel;
mCopyButtonDrawable = copyButtonDrawable;
mOnCopyButtonClicked = onCopyButtonClicked;
mEditSharingTarget = editSharingTarget;
mOnEditButtonClicked = onEditButtonClicked;
mNearbySharingTarget = nearbySharingTarget;
mOnNearbyButtonClicked = onNearbyButtonClicked;
mCustomActions = ImmutableList.copyOf(customActions);
mReselectionIntent = reselectionIntent;
mExcludeSharedTextAction = onUpdateSharedTextIsExcluded;
mFinishCallback = finishCallback;
}
/** Create an action that copies the share content to the clipboard. */
@Override
public ActionRow.Action createCopyButton() {
return new ActionRow.Action(
com.android.internal.R.id.chooser_copy_button,
mCopyButtonLabel,
mCopyButtonDrawable,
mOnCopyButtonClicked);
}
/** Create an action that opens the share content in a system-default editor. */
@Override
@Nullable
public ActionRow.Action createEditButton() {
if (mEditSharingTarget == null) {
return null;
}
return new ActionRow.Action(
com.android.internal.R.id.chooser_edit_button,
mEditSharingTarget.getDisplayLabel(),
mEditSharingTarget.getDisplayIconHolder().getDisplayIcon(),
mOnEditButtonClicked);
}
/** Create a "Share to Nearby" action. */
@Override
@Nullable
public ActionRow.Action createNearbyButton() {
if (mNearbySharingTarget == null) {
return null;
}
return new ActionRow.Action(
com.android.internal.R.id.chooser_nearby_button,
mNearbySharingTarget.getDisplayLabel(),
mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(),
mOnNearbyButtonClicked);
}
/** Create custom actions */
@Override
public List<ActionRow.Action> createCustomActions() {
return mCustomActions.stream()
.map(target -> createCustomAction(mContext, target, mFinishCallback))
.filter(action -> action != null)
.collect(Collectors.toList());
}
/**
* Provides a share modification action, if any.
*/
@Override
@Nullable
public Runnable getModifyShareAction() {
return (mReselectionIntent == null) ? null : createReselectionRunnable(mReselectionIntent);
}
private Runnable createReselectionRunnable(PendingIntent pendingIntent) {
return () -> {
try {
pendingIntent.send();
} catch (PendingIntent.CanceledException e) {
Log.d(TAG, "Payload reselection action has been cancelled");
}
// TODO: add reporting
mFinishCallback.accept(Activity.RESULT_OK);
};
}
/**
* <p>
* Creates an exclude-text action that can be called when the user changes shared text
* status in the Media + Text preview.
* </p>
* <p>
* <code>true</code> argument value indicates that the text should be excluded.
* </p>
*/
@Override
public Consumer<Boolean> getExcludeSharedTextAction() {
return mExcludeSharedTextAction;
}
private static Runnable makeOnCopyRunnable(
Context context,
Intent targetIntent,
String referrerPackageName,
Consumer<Integer> finishCallback,
ChooserActivityLogger logger) {
return () -> {
if (targetIntent == null) {
finishCallback.accept(null);
return;
}
final String action = targetIntent.getAction();
ClipData clipData = null;
if (Intent.ACTION_SEND.equals(action)) {
String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT);
Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM);
if (extraText != null) {
clipData = ClipData.newPlainText(null, extraText);
} else if (extraStream != null) {
clipData = ClipData.newUri(context.getContentResolver(), null, extraStream);
} else {
Log.w(TAG, "No data available to copy to clipboard");
return;
}
} else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) {
final ArrayList<Uri> streams = targetIntent.getParcelableArrayListExtra(
Intent.EXTRA_STREAM);
clipData = ClipData.newUri(context.getContentResolver(), null, streams.get(0));
for (int i = 1; i < streams.size(); i++) {
clipData.addItem(
context.getContentResolver(),
new ClipData.Item(streams.get(i)));
}
} else {
// expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE
// so warn about unexpected action
Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard");
return;
}
ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(
Context.CLIPBOARD_SERVICE);
clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName);
logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY);
finishCallback.accept(Activity.RESULT_OK);
};
}
private static TargetInfo getEditSharingTarget(
Context context,
Intent originalIntent,
ChooserIntegratedDeviceComponents integratedComponents) {
final ComponentName editorComponent = integratedComponents.getEditSharingComponent();
final Intent resolveIntent = new Intent(originalIntent);
// Retain only URI permission grant flags if present. Other flags may prevent the scene
// transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION,
// FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed.
resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS);
resolveIntent.setComponent(editorComponent);
resolveIntent.setAction(Intent.ACTION_EDIT);
String originalAction = originalIntent.getAction();
if (Intent.ACTION_SEND.equals(originalAction)) {
if (resolveIntent.getData() == null) {
Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM);
if (uri != null) {
String mimeType = context.getContentResolver().getType(uri);
resolveIntent.setDataAndType(uri, mimeType);
}
}
} else {
Log.e(TAG, originalAction + " is not supported.");
return null;
}
final ResolveInfo ri = context.getPackageManager().resolveActivity(
resolveIntent, PackageManager.GET_META_DATA);
if (ri == null || ri.activityInfo == null) {
Log.e(TAG, "Device-specified editor (" + editorComponent + ") not available");
return null;
}
final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
originalIntent,
ri,
context.getString(com.android.internal.R.string.screenshot_edit),
"",
resolveIntent,
null);
dri.getDisplayIconHolder().setDisplayIcon(
context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit));
return dri;
}
private static Runnable makeOnEditRunnable(
TargetInfo editSharingTarget,
Callable</* @Nullable */ View> firstVisibleImageQuery,
ActionActivityStarter activityStarter,
ChooserActivityLogger logger) {
return () -> {
// Log share completion via edit.
logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_EDIT);
View firstImageView = null;
try {
firstImageView = firstVisibleImageQuery.call();
} catch (Exception e) { /* ignore */ }
// Action bar is user-independent; always start as primary.
if (firstImageView == null) {
activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget);
} else {
activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition(
editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT);
}
};
}
private static TargetInfo getNearbySharingTarget(
Context context,
Intent originalIntent,
ChooserIntegratedDeviceComponents integratedComponents) {
final ComponentName cn = integratedComponents.getNearbySharingComponent();
if (cn == null) return null;
final Intent resolveIntent = new Intent(originalIntent);
resolveIntent.setComponent(cn);
final ResolveInfo ri = context.getPackageManager().resolveActivity(
resolveIntent, PackageManager.GET_META_DATA);
if (ri == null || ri.activityInfo == null) {
Log.e(TAG, "Device-specified nearby sharing component (" + cn
+ ") not available");
return null;
}
// Allow the nearby sharing component to provide a more appropriate icon and label
// for the chip.
CharSequence name = null;
Drawable icon = null;
final Bundle metaData = ri.activityInfo.metaData;
if (metaData != null) {
try {
final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn);
final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY);
name = pkgRes.getString(nameResId);
final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY);
icon = pkgRes.getDrawable(resId);
} catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ }
}
if (TextUtils.isEmpty(name)) {
name = ri.loadLabel(context.getPackageManager());
}
if (icon == null) {
icon = ri.loadIcon(context.getPackageManager());
}
final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo(
originalIntent, ri, name, "", resolveIntent, null);
dri.getDisplayIconHolder().setDisplayIcon(icon);
return dri;
}
private static Runnable makeOnNearbyShareRunnable(
TargetInfo nearbyShareTarget,
ActionActivityStarter activityStarter,
Consumer<Integer> finishCallback,
ChooserActivityLogger logger) {
return () -> {
logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY);
// Action bar is user-independent; always start as primary.
activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget);
};
}
@Nullable
private static ActionRow.Action createCustomAction(
Context context, ChooserAction action, Consumer<Integer> finishCallback) {
Drawable icon = action.getIcon().loadDrawable(context);
if (icon == null && TextUtils.isEmpty(action.getLabel())) {
return null;
}
return new ActionRow.Action(
action.getLabel(),
icon,
() -> {
try {
action.getAction().send();
} catch (PendingIntent.CanceledException e) {
Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled");
}
// TODO: add reporting
finishCallback.accept(Activity.RESULT_OK);
}
);
}
}