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