• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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