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 android.annotation.Nullable; 20 import android.app.Activity; 21 import android.app.ActivityOptions; 22 import android.app.PendingIntent; 23 import android.content.ClipData; 24 import android.content.ClipboardManager; 25 import android.content.ComponentName; 26 import android.content.Context; 27 import android.content.Intent; 28 import android.content.pm.PackageManager; 29 import android.content.pm.PackageManager.NameNotFoundException; 30 import android.content.pm.ResolveInfo; 31 import android.content.res.Resources; 32 import android.graphics.drawable.Drawable; 33 import android.net.Uri; 34 import android.os.Bundle; 35 import android.service.chooser.ChooserAction; 36 import android.text.TextUtils; 37 import android.util.Log; 38 import android.view.View; 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.flags.FeatureFlagRepository; 44 import com.android.intentresolver.flags.Flags; 45 import com.android.intentresolver.widget.ActionRow; 46 import com.android.internal.annotations.VisibleForTesting; 47 48 import com.google.common.collect.ImmutableList; 49 50 import java.util.ArrayList; 51 import java.util.List; 52 import java.util.concurrent.Callable; 53 import java.util.function.Consumer; 54 55 /** 56 * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application 57 * requirements of Sharesheet / {@link ChooserActivity}. 58 */ 59 public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { 60 /** Delegate interface to launch activities when the actions are selected. */ 61 public interface ActionActivityStarter { 62 /** 63 * Request an activity launch for the provided target. Implementations may choose to exit 64 * the current activity when the target is launched. 65 */ safelyStartActivityAsPersonalProfileUser(TargetInfo info)66 void safelyStartActivityAsPersonalProfileUser(TargetInfo info); 67 68 /** 69 * Request an activity launch for the provided target, optionally employing the specified 70 * shared element transition. Implementations may choose to exit the current activity when 71 * the target is launched. 72 */ safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( TargetInfo info, View sharedElement, String sharedElementName)73 default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( 74 TargetInfo info, View sharedElement, String sharedElementName) { 75 safelyStartActivityAsPersonalProfileUser(info); 76 } 77 } 78 79 private static final String TAG = "ChooserActions"; 80 81 private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION 82 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 83 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION 84 | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; 85 86 private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; 87 private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; 88 89 private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; 90 91 private final Context mContext; 92 private final String mCopyButtonLabel; 93 private final Drawable mCopyButtonDrawable; 94 private final Runnable mOnCopyButtonClicked; 95 private final TargetInfo mEditSharingTarget; 96 private final Runnable mOnEditButtonClicked; 97 private final TargetInfo mNearbySharingTarget; 98 private final Runnable mOnNearbyButtonClicked; 99 private final ImmutableList<ChooserAction> mCustomActions; 100 private final Runnable mOnModifyShareClicked; 101 private final Consumer<Boolean> mExcludeSharedTextAction; 102 private final Consumer</* @Nullable */ Integer> mFinishCallback; 103 private final ChooserActivityLogger mLogger; 104 105 /** 106 * @param context 107 * @param chooserRequest data about the invocation of the current Sharesheet session. 108 * @param featureFlagRepository feature flags that may control the eligibility of some actions. 109 * @param integratedDeviceComponents info about other components that are available on this 110 * device to implement the supported action types. 111 * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" 112 * setting is updated. The argument is whether the shared text is to be excluded. 113 * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image 114 * View in the Sharesheet UI, if any, or null. 115 * @param activityStarter a delegate to launch activities when actions are selected. 116 * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was 117 * completed). 118 */ ChooserActionFactory( Context context, ChooserRequestParameters chooserRequest, FeatureFlagRepository featureFlagRepository, ChooserIntegratedDeviceComponents integratedDeviceComponents, ChooserActivityLogger logger, Consumer<Boolean> onUpdateSharedTextIsExcluded, Callable< View> firstVisibleImageQuery, ActionActivityStarter activityStarter, Consumer< Integer> finishCallback)119 public ChooserActionFactory( 120 Context context, 121 ChooserRequestParameters chooserRequest, 122 FeatureFlagRepository featureFlagRepository, 123 ChooserIntegratedDeviceComponents integratedDeviceComponents, 124 ChooserActivityLogger logger, 125 Consumer<Boolean> onUpdateSharedTextIsExcluded, 126 Callable</* @Nullable */ View> firstVisibleImageQuery, 127 ActionActivityStarter activityStarter, 128 Consumer</* @Nullable */ Integer> finishCallback) { 129 this( 130 context, 131 context.getString(com.android.internal.R.string.copy), 132 context.getDrawable(com.android.internal.R.drawable.ic_menu_copy_material), 133 makeOnCopyRunnable( 134 context, 135 chooserRequest.getTargetIntent(), 136 chooserRequest.getReferrerPackageName(), 137 finishCallback, 138 logger), 139 getEditSharingTarget( 140 context, 141 chooserRequest.getTargetIntent(), 142 integratedDeviceComponents), 143 makeOnEditRunnable( 144 getEditSharingTarget( 145 context, 146 chooserRequest.getTargetIntent(), 147 integratedDeviceComponents), 148 firstVisibleImageQuery, 149 activityStarter, 150 logger), 151 getNearbySharingTarget( 152 context, 153 chooserRequest.getTargetIntent(), 154 integratedDeviceComponents), 155 makeOnNearbyShareRunnable( 156 getNearbySharingTarget( 157 context, 158 chooserRequest.getTargetIntent(), 159 integratedDeviceComponents), 160 activityStarter, 161 finishCallback, 162 logger), 163 chooserRequest.getChooserActions(), 164 (featureFlagRepository.isEnabled(Flags.SHARESHEET_RESELECTION_ACTION) 165 ? createModifyShareRunnable( 166 chooserRequest.getModifyShareAction(), 167 finishCallback, 168 logger) 169 : null), 170 onUpdateSharedTextIsExcluded, 171 logger, 172 finishCallback); 173 } 174 175 @VisibleForTesting ChooserActionFactory( Context context, String copyButtonLabel, Drawable copyButtonDrawable, Runnable onCopyButtonClicked, TargetInfo editSharingTarget, Runnable onEditButtonClicked, TargetInfo nearbySharingTarget, Runnable onNearbyButtonClicked, List<ChooserAction> customActions, @Nullable Runnable onModifyShareClicked, Consumer<Boolean> onUpdateSharedTextIsExcluded, ChooserActivityLogger logger, Consumer< Integer> finishCallback)176 ChooserActionFactory( 177 Context context, 178 String copyButtonLabel, 179 Drawable copyButtonDrawable, 180 Runnable onCopyButtonClicked, 181 TargetInfo editSharingTarget, 182 Runnable onEditButtonClicked, 183 TargetInfo nearbySharingTarget, 184 Runnable onNearbyButtonClicked, 185 List<ChooserAction> customActions, 186 @Nullable Runnable onModifyShareClicked, 187 Consumer<Boolean> onUpdateSharedTextIsExcluded, 188 ChooserActivityLogger logger, 189 Consumer</* @Nullable */ Integer> finishCallback) { 190 mContext = context; 191 mCopyButtonLabel = copyButtonLabel; 192 mCopyButtonDrawable = copyButtonDrawable; 193 mOnCopyButtonClicked = onCopyButtonClicked; 194 mEditSharingTarget = editSharingTarget; 195 mOnEditButtonClicked = onEditButtonClicked; 196 mNearbySharingTarget = nearbySharingTarget; 197 mOnNearbyButtonClicked = onNearbyButtonClicked; 198 mCustomActions = ImmutableList.copyOf(customActions); 199 mOnModifyShareClicked = onModifyShareClicked; 200 mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; 201 mLogger = logger; 202 mFinishCallback = finishCallback; 203 } 204 205 /** Create an action that copies the share content to the clipboard. */ 206 @Override createCopyButton()207 public ActionRow.Action createCopyButton() { 208 return new ActionRow.Action( 209 com.android.internal.R.id.chooser_copy_button, 210 mCopyButtonLabel, 211 mCopyButtonDrawable, 212 mOnCopyButtonClicked); 213 } 214 215 /** Create an action that opens the share content in a system-default editor. */ 216 @Override 217 @Nullable createEditButton()218 public ActionRow.Action createEditButton() { 219 if (mEditSharingTarget == null) { 220 return null; 221 } 222 223 return new ActionRow.Action( 224 com.android.internal.R.id.chooser_edit_button, 225 mEditSharingTarget.getDisplayLabel(), 226 mEditSharingTarget.getDisplayIconHolder().getDisplayIcon(), 227 mOnEditButtonClicked); 228 } 229 230 /** Create a "Share to Nearby" action. */ 231 @Override 232 @Nullable createNearbyButton()233 public ActionRow.Action createNearbyButton() { 234 if (mNearbySharingTarget == null) { 235 return null; 236 } 237 238 return new ActionRow.Action( 239 com.android.internal.R.id.chooser_nearby_button, 240 mNearbySharingTarget.getDisplayLabel(), 241 mNearbySharingTarget.getDisplayIconHolder().getDisplayIcon(), 242 mOnNearbyButtonClicked); 243 } 244 245 /** Create custom actions */ 246 @Override createCustomActions()247 public List<ActionRow.Action> createCustomActions() { 248 List<ActionRow.Action> actions = new ArrayList<>(); 249 for (int i = 0; i < mCustomActions.size(); i++) { 250 ActionRow.Action actionRow = createCustomAction( 251 mContext, mCustomActions.get(i), mFinishCallback, i, mLogger); 252 if (actionRow != null) { 253 actions.add(actionRow); 254 } 255 } 256 return actions; 257 } 258 259 /** 260 * Provides a share modification action, if any. 261 */ 262 @Override 263 @Nullable getModifyShareAction()264 public Runnable getModifyShareAction() { 265 return mOnModifyShareClicked; 266 } 267 createModifyShareRunnable( PendingIntent pendingIntent, Consumer<Integer> finishCallback, ChooserActivityLogger logger)268 private static Runnable createModifyShareRunnable( 269 PendingIntent pendingIntent, 270 Consumer<Integer> finishCallback, 271 ChooserActivityLogger logger) { 272 if (pendingIntent == null) { 273 return null; 274 } 275 276 return () -> { 277 try { 278 pendingIntent.send(); 279 } catch (PendingIntent.CanceledException e) { 280 Log.d(TAG, "Payload reselection action has been cancelled"); 281 } 282 logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_MODIFY_SHARE); 283 finishCallback.accept(Activity.RESULT_OK); 284 }; 285 } 286 287 /** 288 * <p> 289 * Creates an exclude-text action that can be called when the user changes shared text 290 * status in the Media + Text preview. 291 * </p> 292 * <p> 293 * <code>true</code> argument value indicates that the text should be excluded. 294 * </p> 295 */ 296 @Override 297 public Consumer<Boolean> getExcludeSharedTextAction() { 298 return mExcludeSharedTextAction; 299 } 300 301 private static Runnable makeOnCopyRunnable( 302 Context context, 303 Intent targetIntent, 304 String referrerPackageName, 305 Consumer<Integer> finishCallback, 306 ChooserActivityLogger logger) { 307 return () -> { 308 if (targetIntent == null) { 309 finishCallback.accept(null); 310 return; 311 } 312 313 final String action = targetIntent.getAction(); 314 315 ClipData clipData = null; 316 if (Intent.ACTION_SEND.equals(action)) { 317 String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); 318 Uri extraStream = targetIntent.getParcelableExtra(Intent.EXTRA_STREAM); 319 320 if (extraText != null) { 321 clipData = ClipData.newPlainText(null, extraText); 322 } else if (extraStream != null) { 323 clipData = ClipData.newUri(context.getContentResolver(), null, extraStream); 324 } else { 325 Log.w(TAG, "No data available to copy to clipboard"); 326 return; 327 } 328 } else if (Intent.ACTION_SEND_MULTIPLE.equals(action)) { 329 final ArrayList<Uri> streams = targetIntent.getParcelableArrayListExtra( 330 Intent.EXTRA_STREAM); 331 clipData = ClipData.newUri(context.getContentResolver(), null, streams.get(0)); 332 for (int i = 1; i < streams.size(); i++) { 333 clipData.addItem( 334 context.getContentResolver(), 335 new ClipData.Item(streams.get(i))); 336 } 337 } else { 338 // expected to only be visible with ACTION_SEND or ACTION_SEND_MULTIPLE 339 // so warn about unexpected action 340 Log.w(TAG, "Action (" + action + ") not supported for copying to clipboard"); 341 return; 342 } 343 344 ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( 345 Context.CLIPBOARD_SERVICE); 346 clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); 347 348 logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_COPY); 349 finishCallback.accept(Activity.RESULT_OK); 350 }; 351 } 352 353 private static TargetInfo getEditSharingTarget( 354 Context context, 355 Intent originalIntent, 356 ChooserIntegratedDeviceComponents integratedComponents) { 357 final ComponentName editorComponent = integratedComponents.getEditSharingComponent(); 358 359 final Intent resolveIntent = new Intent(originalIntent); 360 // Retain only URI permission grant flags if present. Other flags may prevent the scene 361 // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, 362 // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. 363 resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); 364 resolveIntent.setComponent(editorComponent); 365 resolveIntent.setAction(Intent.ACTION_EDIT); 366 String originalAction = originalIntent.getAction(); 367 if (Intent.ACTION_SEND.equals(originalAction)) { 368 if (resolveIntent.getData() == null) { 369 Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); 370 if (uri != null) { 371 String mimeType = context.getContentResolver().getType(uri); 372 resolveIntent.setDataAndType(uri, mimeType); 373 } 374 } 375 } else { 376 Log.e(TAG, originalAction + " is not supported."); 377 return null; 378 } 379 final ResolveInfo ri = context.getPackageManager().resolveActivity( 380 resolveIntent, PackageManager.GET_META_DATA); 381 if (ri == null || ri.activityInfo == null) { 382 Log.e(TAG, "Device-specified editor (" + editorComponent + ") not available"); 383 return null; 384 } 385 386 final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( 387 originalIntent, 388 ri, 389 context.getString(com.android.internal.R.string.screenshot_edit), 390 "", 391 resolveIntent, 392 null); 393 dri.getDisplayIconHolder().setDisplayIcon( 394 context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); 395 return dri; 396 } 397 398 private static Runnable makeOnEditRunnable( 399 TargetInfo editSharingTarget, 400 Callable</* @Nullable */ View> firstVisibleImageQuery, 401 ActionActivityStarter activityStarter, 402 ChooserActivityLogger logger) { 403 return () -> { 404 // Log share completion via edit. 405 logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_EDIT); 406 407 View firstImageView = null; 408 try { 409 firstImageView = firstVisibleImageQuery.call(); 410 } catch (Exception e) { /* ignore */ } 411 // Action bar is user-independent; always start as primary. 412 if (firstImageView == null) { 413 activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); 414 } else { 415 activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( 416 editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT); 417 } 418 }; 419 } 420 421 private static TargetInfo getNearbySharingTarget( 422 Context context, 423 Intent originalIntent, 424 ChooserIntegratedDeviceComponents integratedComponents) { 425 final ComponentName cn = integratedComponents.getNearbySharingComponent(); 426 if (cn == null) { 427 return null; 428 } 429 430 final Intent resolveIntent = new Intent(originalIntent); 431 resolveIntent.setComponent(cn); 432 final ResolveInfo ri = context.getPackageManager().resolveActivity( 433 resolveIntent, PackageManager.GET_META_DATA); 434 if (ri == null || ri.activityInfo == null) { 435 Log.e(TAG, "Device-specified nearby sharing component (" + cn 436 + ") not available"); 437 return null; 438 } 439 440 // Allow the nearby sharing component to provide a more appropriate icon and label 441 // for the chip. 442 CharSequence name = null; 443 Drawable icon = null; 444 final Bundle metaData = ri.activityInfo.metaData; 445 if (metaData != null) { 446 try { 447 final Resources pkgRes = context.getPackageManager().getResourcesForActivity(cn); 448 final int nameResId = metaData.getInt(CHIP_LABEL_METADATA_KEY); 449 name = pkgRes.getString(nameResId); 450 final int resId = metaData.getInt(CHIP_ICON_METADATA_KEY); 451 icon = pkgRes.getDrawable(resId); 452 } catch (NameNotFoundException | Resources.NotFoundException ex) { /* ignore */ } 453 } 454 if (TextUtils.isEmpty(name)) { 455 name = ri.loadLabel(context.getPackageManager()); 456 } 457 if (icon == null) { 458 icon = ri.loadIcon(context.getPackageManager()); 459 } 460 461 final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( 462 originalIntent, ri, name, "", resolveIntent, null); 463 dri.getDisplayIconHolder().setDisplayIcon(icon); 464 return dri; 465 } 466 467 private static Runnable makeOnNearbyShareRunnable( 468 TargetInfo nearbyShareTarget, 469 ActionActivityStarter activityStarter, 470 Consumer<Integer> finishCallback, 471 ChooserActivityLogger logger) { 472 return () -> { 473 logger.logActionSelected(ChooserActivityLogger.SELECTION_TYPE_NEARBY); 474 // Action bar is user-independent; always start as primary. 475 activityStarter.safelyStartActivityAsPersonalProfileUser(nearbyShareTarget); 476 }; 477 } 478 479 @Nullable 480 private static ActionRow.Action createCustomAction( 481 Context context, 482 ChooserAction action, 483 Consumer<Integer> finishCallback, 484 int position, 485 ChooserActivityLogger logger) { 486 Drawable icon = action.getIcon().loadDrawable(context); 487 if (icon == null && TextUtils.isEmpty(action.getLabel())) { 488 return null; 489 } 490 return new ActionRow.Action( 491 action.getLabel(), 492 icon, 493 () -> { 494 try { 495 action.getAction().send( 496 null, 497 0, 498 null, 499 null, 500 null, 501 null, 502 ActivityOptions.makeCustomAnimation( 503 context, 504 R.anim.slide_in_right, 505 R.anim.slide_out_left) 506 .toBundle()); 507 } catch (PendingIntent.CanceledException e) { 508 Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); 509 } 510 logger.logCustomActionSelected(position); 511 finishCallback.accept(Activity.RESULT_OK); 512 } 513 ); 514 } 515 } 516