1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.intentresolver; 18 19 import static com.android.intentresolver.widget.ViewExtensionsKt.isFullyVisible; 20 21 import android.app.Activity; 22 import android.app.ActivityOptions; 23 import android.app.PendingIntent; 24 import android.content.ClipData; 25 import android.content.ClipboardManager; 26 import android.content.ComponentName; 27 import android.content.Context; 28 import android.content.Intent; 29 import android.content.pm.PackageManager; 30 import android.content.pm.ResolveInfo; 31 import android.graphics.drawable.Drawable; 32 import android.net.Uri; 33 import android.service.chooser.ChooserAction; 34 import android.text.TextUtils; 35 import android.util.Log; 36 import android.view.View; 37 38 import androidx.annotation.Nullable; 39 40 import com.android.intentresolver.chooser.DisplayResolveInfo; 41 import com.android.intentresolver.chooser.TargetInfo; 42 import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; 43 import com.android.intentresolver.logging.EventLog; 44 import com.android.intentresolver.ui.ShareResultSender; 45 import com.android.intentresolver.ui.model.ShareAction; 46 import com.android.intentresolver.widget.ActionRow; 47 import com.android.internal.annotations.VisibleForTesting; 48 49 import com.google.common.collect.ImmutableList; 50 51 import java.util.ArrayList; 52 import java.util.List; 53 import java.util.Optional; 54 import java.util.concurrent.Callable; 55 import java.util.function.Consumer; 56 57 /** 58 * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application 59 * requirements of Sharesheet / {@link ChooserActivity}. 60 */ 61 @SuppressWarnings("OptionalUsedAsFieldOrParameterType") 62 public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { 63 /** 64 * Delegate interface to launch activities when the actions are selected. 65 */ 66 public interface ActionActivityStarter { 67 /** 68 * Request an activity launch for the provided target. Implementations may choose to exit 69 * the current activity when the target is launched. 70 */ safelyStartActivityAsPersonalProfileUser(TargetInfo info)71 void safelyStartActivityAsPersonalProfileUser(TargetInfo info); 72 73 /** 74 * Request an activity launch for the provided target, optionally employing the specified 75 * shared element transition. Implementations may choose to exit the current activity when 76 * the target is launched. 77 */ safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( TargetInfo info, View sharedElement, String sharedElementName)78 default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( 79 TargetInfo info, View sharedElement, String sharedElementName) { 80 safelyStartActivityAsPersonalProfileUser(info); 81 } 82 } 83 84 private static final String TAG = "ChooserActions"; 85 86 private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION 87 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 88 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION 89 | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; 90 91 // Boolean extra used to inform the editor that it may want to customize the editing experience 92 // for the sharesheet editing flow. 93 // Note: EDIT_SOURCE is also used as a signal to avoid sending a 'Component Selected' 94 // ShareResult for this intent when sent via ChooserActivity#safelyStartActivityAsUser 95 static final String EDIT_SOURCE = "edit_source"; 96 private static final String EDIT_SOURCE_SHARESHEET = "sharesheet"; 97 98 private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; 99 private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; 100 101 private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; 102 103 private final Context mContext; 104 105 @Nullable private Runnable mCopyButtonRunnable; 106 @Nullable private Runnable mEditButtonRunnable; 107 private final ImmutableList<ChooserAction> mCustomActions; 108 private final Consumer<Boolean> mExcludeSharedTextAction; 109 @Nullable private final ShareResultSender mShareResultSender; 110 private final Consumer</* @Nullable */ Integer> mFinishCallback; 111 private final EventLog mLog; 112 113 /** 114 * @param context 115 * @param imageEditor an explicit Activity to launch for editing images 116 * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" 117 * setting is updated. The argument is whether the shared text is to be excluded. 118 * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image 119 * View in the Sharesheet UI, if any, or null. 120 * @param activityStarter a delegate to launch activities when actions are selected. 121 * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was 122 * completed). 123 */ ChooserActionFactory( Context context, Intent targetIntent, String referrerPackageName, List<ChooserAction> chooserActions, Optional<ComponentName> imageEditor, EventLog log, Consumer<Boolean> onUpdateSharedTextIsExcluded, Callable< View> firstVisibleImageQuery, ActionActivityStarter activityStarter, @Nullable ShareResultSender shareResultSender, Consumer< Integer> finishCallback, ClipboardManager clipboardManager, FeatureFlags featureFlags)124 public ChooserActionFactory( 125 Context context, 126 Intent targetIntent, 127 String referrerPackageName, 128 List<ChooserAction> chooserActions, 129 Optional<ComponentName> imageEditor, 130 EventLog log, 131 Consumer<Boolean> onUpdateSharedTextIsExcluded, 132 Callable</* @Nullable */ View> firstVisibleImageQuery, 133 ActionActivityStarter activityStarter, 134 @Nullable ShareResultSender shareResultSender, 135 Consumer</* @Nullable */ Integer> finishCallback, 136 ClipboardManager clipboardManager, 137 FeatureFlags featureFlags) { 138 this( 139 context, 140 makeCopyButtonRunnable( 141 clipboardManager, 142 targetIntent, 143 referrerPackageName, 144 finishCallback, 145 log), 146 makeEditButtonRunnable( 147 getEditSharingTarget( 148 context, 149 targetIntent, 150 imageEditor), 151 firstVisibleImageQuery, 152 activityStarter, 153 log, 154 featureFlags.fixPartialImageEditTransition()), 155 chooserActions, 156 onUpdateSharedTextIsExcluded, 157 log, 158 shareResultSender, 159 finishCallback); 160 161 } 162 163 @VisibleForTesting ChooserActionFactory( Context context, @Nullable Runnable copyButtonRunnable, @Nullable Runnable editButtonRunnable, List<ChooserAction> customActions, Consumer<Boolean> onUpdateSharedTextIsExcluded, EventLog log, @Nullable ShareResultSender shareResultSender, Consumer< Integer> finishCallback)164 ChooserActionFactory( 165 Context context, 166 @Nullable Runnable copyButtonRunnable, 167 @Nullable Runnable editButtonRunnable, 168 List<ChooserAction> customActions, 169 Consumer<Boolean> onUpdateSharedTextIsExcluded, 170 EventLog log, 171 @Nullable ShareResultSender shareResultSender, 172 Consumer</* @Nullable */ Integer> finishCallback) { 173 mContext = context; 174 mCopyButtonRunnable = copyButtonRunnable; 175 mEditButtonRunnable = editButtonRunnable; 176 mCustomActions = ImmutableList.copyOf(customActions); 177 mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; 178 mLog = log; 179 mShareResultSender = shareResultSender; 180 mFinishCallback = finishCallback; 181 182 if (mShareResultSender != null) { 183 if (mEditButtonRunnable != null) { 184 mEditButtonRunnable = () -> { 185 mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT); 186 editButtonRunnable.run(); 187 }; 188 } 189 if (mCopyButtonRunnable != null) { 190 mCopyButtonRunnable = () -> { 191 mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY); 192 copyButtonRunnable.run(); 193 }; 194 } 195 } 196 } 197 198 @Override 199 @Nullable getEditButtonRunnable()200 public Runnable getEditButtonRunnable() { 201 return mEditButtonRunnable; 202 } 203 204 @Override 205 @Nullable getCopyButtonRunnable()206 public Runnable getCopyButtonRunnable() { 207 return mCopyButtonRunnable; 208 } 209 210 /** Create custom actions */ 211 @Override createCustomActions()212 public List<ActionRow.Action> createCustomActions() { 213 List<ActionRow.Action> actions = new ArrayList<>(); 214 for (int i = 0; i < mCustomActions.size(); i++) { 215 final int position = i; 216 ActionRow.Action actionRow = createCustomAction( 217 mContext, 218 mCustomActions.get(i), 219 () -> logCustomAction(position), 220 mShareResultSender, 221 mFinishCallback); 222 if (actionRow != null) { 223 actions.add(actionRow); 224 } 225 } 226 return actions; 227 } 228 229 /** 230 * <p> 231 * Creates an exclude-text action that can be called when the user changes shared text 232 * status in the Media + Text preview. 233 * </p> 234 * <p> 235 * <code>true</code> argument value indicates that the text should be excluded. 236 * </p> 237 */ 238 @Override getExcludeSharedTextAction()239 public Consumer<Boolean> getExcludeSharedTextAction() { 240 return mExcludeSharedTextAction; 241 } 242 243 @Nullable makeCopyButtonRunnable( ClipboardManager clipboardManager, Intent targetIntent, String referrerPackageName, Consumer<Integer> finishCallback, EventLog log)244 private static Runnable makeCopyButtonRunnable( 245 ClipboardManager clipboardManager, 246 Intent targetIntent, 247 String referrerPackageName, 248 Consumer<Integer> finishCallback, 249 EventLog log) { 250 final ClipData clipData; 251 try { 252 clipData = extractTextToCopy(targetIntent); 253 } catch (Throwable t) { 254 Log.e(TAG, "Failed to extract data to copy", t); 255 return null; 256 } 257 if (clipData == null) { 258 return null; 259 } 260 return () -> { 261 clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); 262 263 log.logActionSelected(EventLog.SELECTION_TYPE_COPY); 264 Log.d(TAG, "finish due to copy clicked"); 265 finishCallback.accept(Activity.RESULT_OK); 266 }; 267 } 268 269 @Nullable extractTextToCopy(Intent targetIntent)270 private static ClipData extractTextToCopy(Intent targetIntent) { 271 if (targetIntent == null) { 272 return null; 273 } 274 275 final String action = targetIntent.getAction(); 276 277 ClipData clipData = null; 278 if (Intent.ACTION_SEND.equals(action)) { 279 String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); 280 281 if (extraText != null) { 282 clipData = ClipData.newPlainText(null, extraText); 283 } else { 284 Log.w(TAG, "No data available to copy to clipboard"); 285 } 286 } else { 287 // expected to only be visible with ACTION_SEND (when a text is shared) 288 Log.d(TAG, "Action (" + action + ") not supported for copying to clipboard"); 289 } 290 return clipData; 291 } 292 293 @Nullable getEditSharingTarget( Context context, Intent originalIntent, Optional<ComponentName> imageEditor)294 private static TargetInfo getEditSharingTarget( 295 Context context, 296 Intent originalIntent, 297 Optional<ComponentName> imageEditor) { 298 299 final Intent resolveIntent = new Intent(originalIntent); 300 // Retain only URI permission grant flags if present. Other flags may prevent the scene 301 // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, 302 // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. 303 resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); 304 imageEditor.ifPresent(resolveIntent::setComponent); 305 resolveIntent.setAction(Intent.ACTION_EDIT); 306 resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET); 307 String originalAction = originalIntent.getAction(); 308 if (Intent.ACTION_SEND.equals(originalAction)) { 309 if (resolveIntent.getData() == null) { 310 Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); 311 if (uri != null) { 312 String mimeType = context.getContentResolver().getType(uri); 313 resolveIntent.setDataAndType(uri, mimeType); 314 } 315 } 316 } else { 317 Log.e(TAG, originalAction + " is not supported."); 318 return null; 319 } 320 final ResolveInfo ri = context.getPackageManager().resolveActivity( 321 resolveIntent, PackageManager.GET_META_DATA); 322 if (ri == null || ri.activityInfo == null) { 323 Log.e(TAG, "Device-specified editor (" + imageEditor + ") not available"); 324 return null; 325 } 326 327 final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( 328 originalIntent, 329 ri, 330 context.getString(R.string.screenshot_edit), 331 "", 332 resolveIntent); 333 dri.getDisplayIconHolder().setDisplayIcon( 334 context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); 335 return dri; 336 } 337 338 @Nullable makeEditButtonRunnable( @ullable TargetInfo editSharingTarget, Callable< View> firstVisibleImageQuery, ActionActivityStarter activityStarter, EventLog log, boolean requireFullVisibility)339 private static Runnable makeEditButtonRunnable( 340 @Nullable TargetInfo editSharingTarget, 341 Callable</* @Nullable */ View> firstVisibleImageQuery, 342 ActionActivityStarter activityStarter, 343 EventLog log, 344 boolean requireFullVisibility) { 345 if (editSharingTarget == null) return null; 346 return () -> { 347 // Log share completion via edit. 348 log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); 349 350 View firstImageView = null; 351 try { 352 firstImageView = firstVisibleImageQuery.call(); 353 } catch (Exception e) { /* ignore */ } 354 // Action bar is user-independent; always start as primary. 355 if (firstImageView == null 356 || (requireFullVisibility && !isFullyVisible(firstImageView))) { 357 activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); 358 } else { 359 activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( 360 editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT); 361 } 362 }; 363 } 364 365 @Nullable 366 static ActionRow.Action createCustomAction( 367 Context context, 368 @Nullable ChooserAction action, 369 Runnable loggingRunnable, 370 ShareResultSender shareResultSender, 371 Consumer</* @Nullable */ Integer> finishCallback) { 372 if (action == null) { 373 return null; 374 } 375 Drawable icon = action.getIcon().loadDrawable(context); 376 if (icon == null && TextUtils.isEmpty(action.getLabel())) { 377 return null; 378 } 379 return new ActionRow.Action( 380 action.getLabel(), 381 icon, 382 () -> { 383 try { 384 action.getAction().send( 385 null, 386 0, 387 null, 388 null, 389 null, 390 null, 391 ActivityOptions.makeCustomAnimation( 392 context, 393 R.anim.slide_in_right, 394 R.anim.slide_out_left) 395 .toBundle()); 396 } catch (PendingIntent.CanceledException e) { 397 Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); 398 } 399 if (loggingRunnable != null) { 400 loggingRunnable.run(); 401 } 402 if (shareResultSender != null) { 403 shareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); 404 } 405 Log.d(TAG, "finish due to custom action clicked"); 406 finishCallback.accept(Activity.RESULT_OK); 407 } 408 ); 409 } 410 411 void logCustomAction(int position) { 412 mLog.logCustomActionSelected(position); 413 } 414 } 415