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)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 this( 138 context, 139 makeCopyButtonRunnable( 140 clipboardManager, 141 targetIntent, 142 referrerPackageName, 143 finishCallback, 144 log), 145 makeEditButtonRunnable( 146 getEditSharingTarget( 147 context, 148 targetIntent, 149 imageEditor), 150 firstVisibleImageQuery, 151 activityStarter, 152 log), 153 chooserActions, 154 onUpdateSharedTextIsExcluded, 155 log, 156 shareResultSender, 157 finishCallback); 158 159 } 160 161 @VisibleForTesting ChooserActionFactory( Context context, @Nullable Runnable copyButtonRunnable, @Nullable Runnable editButtonRunnable, List<ChooserAction> customActions, Consumer<Boolean> onUpdateSharedTextIsExcluded, EventLog log, @Nullable ShareResultSender shareResultSender, Consumer< Integer> finishCallback)162 ChooserActionFactory( 163 Context context, 164 @Nullable Runnable copyButtonRunnable, 165 @Nullable Runnable editButtonRunnable, 166 List<ChooserAction> customActions, 167 Consumer<Boolean> onUpdateSharedTextIsExcluded, 168 EventLog log, 169 @Nullable ShareResultSender shareResultSender, 170 Consumer</* @Nullable */ Integer> finishCallback) { 171 mContext = context; 172 mCopyButtonRunnable = copyButtonRunnable; 173 mEditButtonRunnable = editButtonRunnable; 174 mCustomActions = ImmutableList.copyOf(customActions); 175 mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; 176 mLog = log; 177 mShareResultSender = shareResultSender; 178 mFinishCallback = finishCallback; 179 180 if (mShareResultSender != null) { 181 if (mEditButtonRunnable != null) { 182 mEditButtonRunnable = () -> { 183 mShareResultSender.onActionSelected(ShareAction.SYSTEM_EDIT); 184 editButtonRunnable.run(); 185 }; 186 } 187 if (mCopyButtonRunnable != null) { 188 mCopyButtonRunnable = () -> { 189 mShareResultSender.onActionSelected(ShareAction.SYSTEM_COPY); 190 copyButtonRunnable.run(); 191 }; 192 } 193 } 194 } 195 196 @Override 197 @Nullable getEditButtonRunnable()198 public Runnable getEditButtonRunnable() { 199 return mEditButtonRunnable; 200 } 201 202 @Override 203 @Nullable getCopyButtonRunnable()204 public Runnable getCopyButtonRunnable() { 205 return mCopyButtonRunnable; 206 } 207 208 /** Create custom actions */ 209 @Override createCustomActions()210 public List<ActionRow.Action> createCustomActions() { 211 List<ActionRow.Action> actions = new ArrayList<>(); 212 for (int i = 0; i < mCustomActions.size(); i++) { 213 final int position = i; 214 ActionRow.Action actionRow = createCustomAction( 215 mContext, 216 mCustomActions.get(i), 217 () -> logCustomAction(position), 218 mShareResultSender, 219 mFinishCallback); 220 if (actionRow != null) { 221 actions.add(actionRow); 222 } 223 } 224 return actions; 225 } 226 227 /** 228 * <p> 229 * Creates an exclude-text action that can be called when the user changes shared text 230 * status in the Media + Text preview. 231 * </p> 232 * <p> 233 * <code>true</code> argument value indicates that the text should be excluded. 234 * </p> 235 */ 236 @Override getExcludeSharedTextAction()237 public Consumer<Boolean> getExcludeSharedTextAction() { 238 return mExcludeSharedTextAction; 239 } 240 241 @Nullable makeCopyButtonRunnable( ClipboardManager clipboardManager, Intent targetIntent, String referrerPackageName, Consumer<Integer> finishCallback, EventLog log)242 private static Runnable makeCopyButtonRunnable( 243 ClipboardManager clipboardManager, 244 Intent targetIntent, 245 String referrerPackageName, 246 Consumer<Integer> finishCallback, 247 EventLog log) { 248 final ClipData clipData; 249 try { 250 clipData = extractTextToCopy(targetIntent); 251 } catch (Throwable t) { 252 Log.e(TAG, "Failed to extract data to copy", t); 253 return null; 254 } 255 if (clipData == null) { 256 return null; 257 } 258 return () -> { 259 clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); 260 261 log.logActionSelected(EventLog.SELECTION_TYPE_COPY); 262 Log.d(TAG, "finish due to copy clicked"); 263 finishCallback.accept(Activity.RESULT_OK); 264 }; 265 } 266 267 @Nullable extractTextToCopy(Intent targetIntent)268 private static ClipData extractTextToCopy(Intent targetIntent) { 269 if (targetIntent == null) { 270 return null; 271 } 272 273 final String action = targetIntent.getAction(); 274 275 ClipData clipData = null; 276 if (Intent.ACTION_SEND.equals(action)) { 277 String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); 278 279 if (extraText != null) { 280 clipData = ClipData.newPlainText(null, extraText); 281 } else { 282 Log.w(TAG, "No data available to copy to clipboard"); 283 } 284 } else { 285 // expected to only be visible with ACTION_SEND (when a text is shared) 286 Log.d(TAG, "Action (" + action + ") not supported for copying to clipboard"); 287 } 288 return clipData; 289 } 290 291 @Nullable getEditSharingTarget( Context context, Intent originalIntent, Optional<ComponentName> imageEditor)292 private static TargetInfo getEditSharingTarget( 293 Context context, 294 Intent originalIntent, 295 Optional<ComponentName> imageEditor) { 296 297 final Intent resolveIntent = new Intent(originalIntent); 298 // Retain only URI permission grant flags if present. Other flags may prevent the scene 299 // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, 300 // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. 301 resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); 302 imageEditor.ifPresent(resolveIntent::setComponent); 303 resolveIntent.setAction(Intent.ACTION_EDIT); 304 resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET); 305 String originalAction = originalIntent.getAction(); 306 if (Intent.ACTION_SEND.equals(originalAction)) { 307 if (resolveIntent.getData() == null) { 308 Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); 309 if (uri != null) { 310 String mimeType = context.getContentResolver().getType(uri); 311 resolveIntent.setDataAndType(uri, mimeType); 312 } 313 } 314 } else { 315 Log.e(TAG, originalAction + " is not supported."); 316 return null; 317 } 318 final ResolveInfo ri = context.getPackageManager().resolveActivity( 319 resolveIntent, PackageManager.GET_META_DATA); 320 if (ri == null || ri.activityInfo == null) { 321 Log.e(TAG, "Device-specified editor (" + imageEditor + ") not available"); 322 return null; 323 } 324 325 final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( 326 originalIntent, 327 ri, 328 context.getString(R.string.screenshot_edit), 329 "", 330 resolveIntent); 331 dri.getDisplayIconHolder().setDisplayIcon( 332 context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); 333 return dri; 334 } 335 336 @Nullable makeEditButtonRunnable( @ullable TargetInfo editSharingTarget, Callable< View> firstVisibleImageQuery, ActionActivityStarter activityStarter, EventLog log)337 private static Runnable makeEditButtonRunnable( 338 @Nullable TargetInfo editSharingTarget, 339 Callable</* @Nullable */ View> firstVisibleImageQuery, 340 ActionActivityStarter activityStarter, 341 EventLog log) { 342 if (editSharingTarget == null) return null; 343 return () -> { 344 // Log share completion via edit. 345 log.logActionSelected(EventLog.SELECTION_TYPE_EDIT); 346 347 View firstImageView = null; 348 try { 349 firstImageView = firstVisibleImageQuery.call(); 350 } catch (Exception e) { /* ignore */ } 351 // Action bar is user-independent; always start as primary. 352 if (firstImageView == null || !isFullyVisible(firstImageView)) { 353 activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); 354 } else { 355 activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( 356 editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT); 357 } 358 }; 359 } 360 361 @Nullable 362 static ActionRow.Action createCustomAction( 363 Context context, 364 @Nullable ChooserAction action, 365 Runnable loggingRunnable, 366 ShareResultSender shareResultSender, 367 Consumer</* @Nullable */ Integer> finishCallback) { 368 if (action == null) { 369 return null; 370 } 371 Drawable icon = action.getIcon().loadDrawable(context); 372 if (icon == null && TextUtils.isEmpty(action.getLabel())) { 373 return null; 374 } 375 return new ActionRow.Action( 376 action.getLabel(), 377 icon, 378 () -> { 379 try { 380 action.getAction().send( 381 null, 382 0, 383 null, 384 null, 385 null, 386 null, 387 ActivityOptions.makeCustomAnimation( 388 context, 389 R.anim.slide_in_right, 390 R.anim.slide_out_left) 391 .toBundle()); 392 } catch (PendingIntent.CanceledException e) { 393 Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); 394 } 395 if (loggingRunnable != null) { 396 loggingRunnable.run(); 397 } 398 if (shareResultSender != null) { 399 shareResultSender.onActionSelected(ShareAction.APPLICATION_DEFINED); 400 } 401 Log.d(TAG, "finish due to custom action clicked"); 402 finishCallback.accept(Activity.RESULT_OK); 403 } 404 ); 405 } 406 407 void logCustomAction(int position) { 408 mLog.logCustomActionSelected(position); 409 } 410 } 411