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