• 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 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