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.ResolveInfo; 30 import android.graphics.drawable.Drawable; 31 import android.net.Uri; 32 import android.service.chooser.ChooserAction; 33 import android.text.TextUtils; 34 import android.util.Log; 35 import android.view.View; 36 37 import com.android.intentresolver.chooser.DisplayResolveInfo; 38 import com.android.intentresolver.chooser.TargetInfo; 39 import com.android.intentresolver.contentpreview.ChooserContentPreviewUi; 40 import com.android.intentresolver.logging.EventLog; 41 import com.android.intentresolver.widget.ActionRow; 42 import com.android.internal.annotations.VisibleForTesting; 43 44 import com.google.common.collect.ImmutableList; 45 46 import java.util.ArrayList; 47 import java.util.List; 48 import java.util.concurrent.Callable; 49 import java.util.function.Consumer; 50 51 /** 52 * Implementation of {@link ChooserContentPreviewUi.ActionFactory} specialized to the application 53 * requirements of Sharesheet / {@link ChooserActivity}. 54 */ 55 public final class ChooserActionFactory implements ChooserContentPreviewUi.ActionFactory { 56 /** Delegate interface to launch activities when the actions are selected. */ 57 public interface ActionActivityStarter { 58 /** 59 * Request an activity launch for the provided target. Implementations may choose to exit 60 * the current activity when the target is launched. 61 */ safelyStartActivityAsPersonalProfileUser(TargetInfo info)62 void safelyStartActivityAsPersonalProfileUser(TargetInfo info); 63 64 /** 65 * Request an activity launch for the provided target, optionally employing the specified 66 * shared element transition. Implementations may choose to exit the current activity when 67 * the target is launched. 68 */ safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( TargetInfo info, View sharedElement, String sharedElementName)69 default void safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( 70 TargetInfo info, View sharedElement, String sharedElementName) { 71 safelyStartActivityAsPersonalProfileUser(info); 72 } 73 } 74 75 private static final String TAG = "ChooserActions"; 76 77 private static final int URI_PERMISSION_INTENT_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION 78 | Intent.FLAG_GRANT_WRITE_URI_PERMISSION 79 | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION 80 | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; 81 82 // Boolean extra used to inform the editor that it may want to customize the editing experience 83 // for the sharesheet editing flow. 84 private static final String EDIT_SOURCE = "edit_source"; 85 private static final String EDIT_SOURCE_SHARESHEET = "sharesheet"; 86 87 private static final String CHIP_LABEL_METADATA_KEY = "android.service.chooser.chip_label"; 88 private static final String CHIP_ICON_METADATA_KEY = "android.service.chooser.chip_icon"; 89 90 private static final String IMAGE_EDITOR_SHARED_ELEMENT = "screenshot_preview_image"; 91 92 private final Context mContext; 93 94 @Nullable 95 private final Runnable mCopyButtonRunnable; 96 private final Runnable mEditButtonRunnable; 97 private final ImmutableList<ChooserAction> mCustomActions; 98 private final @Nullable ChooserAction mModifyShareAction; 99 private final Consumer<Boolean> mExcludeSharedTextAction; 100 private final Consumer</* @Nullable */ Integer> mFinishCallback; 101 private final EventLog mLogger; 102 103 /** 104 * @param context 105 * @param chooserRequest data about the invocation of the current Sharesheet session. 106 * @param integratedDeviceComponents info about other components that are available on this 107 * device to implement the supported action types. 108 * @param onUpdateSharedTextIsExcluded a delegate to be invoked when the "exclude shared text" 109 * setting is updated. The argument is whether the shared text is to be excluded. 110 * @param firstVisibleImageQuery a delegate that provides a reference to the first visible image 111 * View in the Sharesheet UI, if any, or null. 112 * @param activityStarter a delegate to launch activities when actions are selected. 113 * @param finishCallback a delegate to close the Sharesheet UI (e.g. because some action was 114 * completed). 115 */ ChooserActionFactory( Context context, ChooserRequestParameters chooserRequest, ChooserIntegratedDeviceComponents integratedDeviceComponents, EventLog logger, Consumer<Boolean> onUpdateSharedTextIsExcluded, Callable< View> firstVisibleImageQuery, ActionActivityStarter activityStarter, Consumer< Integer> finishCallback)116 public ChooserActionFactory( 117 Context context, 118 ChooserRequestParameters chooserRequest, 119 ChooserIntegratedDeviceComponents integratedDeviceComponents, 120 EventLog logger, 121 Consumer<Boolean> onUpdateSharedTextIsExcluded, 122 Callable</* @Nullable */ View> firstVisibleImageQuery, 123 ActionActivityStarter activityStarter, 124 Consumer</* @Nullable */ Integer> finishCallback) { 125 this( 126 context, 127 makeCopyButtonRunnable( 128 context, 129 chooserRequest.getTargetIntent(), 130 chooserRequest.getReferrerPackageName(), 131 finishCallback, 132 logger), 133 makeEditButtonRunnable( 134 getEditSharingTarget( 135 context, 136 chooserRequest.getTargetIntent(), 137 integratedDeviceComponents), 138 firstVisibleImageQuery, 139 activityStarter, 140 logger), 141 chooserRequest.getChooserActions(), 142 chooserRequest.getModifyShareAction(), 143 onUpdateSharedTextIsExcluded, 144 logger, 145 finishCallback); 146 } 147 148 @VisibleForTesting ChooserActionFactory( Context context, @Nullable Runnable copyButtonRunnable, Runnable editButtonRunnable, List<ChooserAction> customActions, @Nullable ChooserAction modifyShareAction, Consumer<Boolean> onUpdateSharedTextIsExcluded, EventLog logger, Consumer< Integer> finishCallback)149 ChooserActionFactory( 150 Context context, 151 @Nullable Runnable copyButtonRunnable, 152 Runnable editButtonRunnable, 153 List<ChooserAction> customActions, 154 @Nullable ChooserAction modifyShareAction, 155 Consumer<Boolean> onUpdateSharedTextIsExcluded, 156 EventLog logger, 157 Consumer</* @Nullable */ Integer> finishCallback) { 158 mContext = context; 159 mCopyButtonRunnable = copyButtonRunnable; 160 mEditButtonRunnable = editButtonRunnable; 161 mCustomActions = ImmutableList.copyOf(customActions); 162 mModifyShareAction = modifyShareAction; 163 mExcludeSharedTextAction = onUpdateSharedTextIsExcluded; 164 mLogger = logger; 165 mFinishCallback = finishCallback; 166 } 167 168 @Override 169 @Nullable getEditButtonRunnable()170 public Runnable getEditButtonRunnable() { 171 return mEditButtonRunnable; 172 } 173 174 @Override 175 @Nullable getCopyButtonRunnable()176 public Runnable getCopyButtonRunnable() { 177 return mCopyButtonRunnable; 178 } 179 180 /** Create custom actions */ 181 @Override createCustomActions()182 public List<ActionRow.Action> createCustomActions() { 183 List<ActionRow.Action> actions = new ArrayList<>(); 184 for (int i = 0; i < mCustomActions.size(); i++) { 185 final int position = i; 186 ActionRow.Action actionRow = createCustomAction( 187 mContext, 188 mCustomActions.get(i), 189 mFinishCallback, 190 () -> { 191 mLogger.logCustomActionSelected(position); 192 } 193 ); 194 if (actionRow != null) { 195 actions.add(actionRow); 196 } 197 } 198 return actions; 199 } 200 201 /** 202 * Provides a share modification action, if any. 203 */ 204 @Override 205 @Nullable getModifyShareAction()206 public ActionRow.Action getModifyShareAction() { 207 return createCustomAction( 208 mContext, 209 mModifyShareAction, 210 mFinishCallback, 211 () -> { 212 mLogger.logActionSelected(EventLog.SELECTION_TYPE_MODIFY_SHARE); 213 }); 214 } 215 216 /** 217 * <p> 218 * Creates an exclude-text action that can be called when the user changes shared text 219 * status in the Media + Text preview. 220 * </p> 221 * <p> 222 * <code>true</code> argument value indicates that the text should be excluded. 223 * </p> 224 */ 225 @Override getExcludeSharedTextAction()226 public Consumer<Boolean> getExcludeSharedTextAction() { 227 return mExcludeSharedTextAction; 228 } 229 230 @Nullable makeCopyButtonRunnable( Context context, Intent targetIntent, String referrerPackageName, Consumer<Integer> finishCallback, EventLog logger)231 private static Runnable makeCopyButtonRunnable( 232 Context context, 233 Intent targetIntent, 234 String referrerPackageName, 235 Consumer<Integer> finishCallback, 236 EventLog logger) { 237 final ClipData clipData; 238 try { 239 clipData = extractTextToCopy(targetIntent); 240 } catch (Throwable t) { 241 Log.e(TAG, "Failed to extract data to copy", t); 242 return null; 243 } 244 if (clipData == null) { 245 return null; 246 } 247 return () -> { 248 ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService( 249 Context.CLIPBOARD_SERVICE); 250 clipboardManager.setPrimaryClipAsPackage(clipData, referrerPackageName); 251 252 logger.logActionSelected(EventLog.SELECTION_TYPE_COPY); 253 finishCallback.accept(Activity.RESULT_OK); 254 }; 255 } 256 257 @Nullable 258 private static ClipData extractTextToCopy(Intent targetIntent) { 259 if (targetIntent == null) { 260 return null; 261 } 262 263 final String action = targetIntent.getAction(); 264 265 ClipData clipData = null; 266 if (Intent.ACTION_SEND.equals(action)) { 267 String extraText = targetIntent.getStringExtra(Intent.EXTRA_TEXT); 268 269 if (extraText != null) { 270 clipData = ClipData.newPlainText(null, extraText); 271 } else { 272 Log.w(TAG, "No data available to copy to clipboard"); 273 } 274 } else { 275 // expected to only be visible with ACTION_SEND (when a text is shared) 276 Log.d(TAG, "Action (" + action + ") not supported for copying to clipboard"); 277 } 278 return clipData; 279 } 280 281 private static TargetInfo getEditSharingTarget( 282 Context context, 283 Intent originalIntent, 284 ChooserIntegratedDeviceComponents integratedComponents) { 285 final ComponentName editorComponent = integratedComponents.getEditSharingComponent(); 286 287 final Intent resolveIntent = new Intent(originalIntent); 288 // Retain only URI permission grant flags if present. Other flags may prevent the scene 289 // transition animation from running (i.e FLAG_ACTIVITY_NO_ANIMATION, 290 // FLAG_ACTIVITY_NEW_TASK, FLAG_ACTIVITY_NEW_DOCUMENT) but also not needed. 291 resolveIntent.setFlags(originalIntent.getFlags() & URI_PERMISSION_INTENT_FLAGS); 292 resolveIntent.setComponent(editorComponent); 293 resolveIntent.setAction(Intent.ACTION_EDIT); 294 resolveIntent.putExtra(EDIT_SOURCE, EDIT_SOURCE_SHARESHEET); 295 String originalAction = originalIntent.getAction(); 296 if (Intent.ACTION_SEND.equals(originalAction)) { 297 if (resolveIntent.getData() == null) { 298 Uri uri = resolveIntent.getParcelableExtra(Intent.EXTRA_STREAM); 299 if (uri != null) { 300 String mimeType = context.getContentResolver().getType(uri); 301 resolveIntent.setDataAndType(uri, mimeType); 302 } 303 } 304 } else { 305 Log.e(TAG, originalAction + " is not supported."); 306 return null; 307 } 308 final ResolveInfo ri = context.getPackageManager().resolveActivity( 309 resolveIntent, PackageManager.GET_META_DATA); 310 if (ri == null || ri.activityInfo == null) { 311 Log.e(TAG, "Device-specified editor (" + editorComponent + ") not available"); 312 return null; 313 } 314 315 final DisplayResolveInfo dri = DisplayResolveInfo.newDisplayResolveInfo( 316 originalIntent, 317 ri, 318 context.getString(R.string.screenshot_edit), 319 "", 320 resolveIntent, 321 null); 322 dri.getDisplayIconHolder().setDisplayIcon( 323 context.getDrawable(com.android.internal.R.drawable.ic_screenshot_edit)); 324 return dri; 325 } 326 327 private static Runnable makeEditButtonRunnable( 328 TargetInfo editSharingTarget, 329 Callable</* @Nullable */ View> firstVisibleImageQuery, 330 ActionActivityStarter activityStarter, 331 EventLog logger) { 332 return () -> { 333 // Log share completion via edit. 334 logger.logActionSelected(EventLog.SELECTION_TYPE_EDIT); 335 336 View firstImageView = null; 337 try { 338 firstImageView = firstVisibleImageQuery.call(); 339 } catch (Exception e) { /* ignore */ } 340 // Action bar is user-independent; always start as primary. 341 if (firstImageView == null) { 342 activityStarter.safelyStartActivityAsPersonalProfileUser(editSharingTarget); 343 } else { 344 activityStarter.safelyStartActivityAsPersonalProfileUserWithSharedElementTransition( 345 editSharingTarget, firstImageView, IMAGE_EDITOR_SHARED_ELEMENT); 346 } 347 }; 348 } 349 350 @Nullable 351 private static ActionRow.Action createCustomAction( 352 Context context, 353 ChooserAction action, 354 Consumer<Integer> finishCallback, 355 Runnable loggingRunnable) { 356 if (action == null || action.getAction() == null) { 357 return null; 358 } 359 Drawable icon = action.getIcon().loadDrawable(context); 360 if (icon == null && TextUtils.isEmpty(action.getLabel())) { 361 return null; 362 } 363 return new ActionRow.Action( 364 action.getLabel(), 365 icon, 366 () -> { 367 try { 368 action.getAction().send( 369 null, 370 0, 371 null, 372 null, 373 null, 374 null, 375 ActivityOptions.makeCustomAnimation( 376 context, 377 R.anim.slide_in_right, 378 R.anim.slide_out_left) 379 .toBundle()); 380 } catch (PendingIntent.CanceledException e) { 381 Log.d(TAG, "Custom action, " + action.getLabel() + ", has been cancelled"); 382 } 383 if (loggingRunnable != null) { 384 loggingRunnable.run(); 385 } 386 finishCallback.accept(Activity.RESULT_OK); 387 } 388 ); 389 } 390 } 391