• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2018 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.textclassifier.common.intent;
18 
19 import android.app.PendingIntent;
20 import android.content.ComponentName;
21 import android.content.Context;
22 import android.content.Intent;
23 import android.content.pm.ActivityInfo;
24 import android.content.pm.PackageManager;
25 import android.content.pm.ResolveInfo;
26 import android.text.TextUtils;
27 import androidx.annotation.DrawableRes;
28 import androidx.core.app.RemoteActionCompat;
29 import androidx.core.content.ContextCompat;
30 import androidx.core.graphics.drawable.IconCompat;
31 import com.android.textclassifier.common.base.TcLog;
32 import com.google.common.base.Objects;
33 import com.google.common.base.Preconditions;
34 import javax.annotation.Nullable;
35 
36 /** Helper class to store the information from which RemoteActions are built. */
37 public final class LabeledIntent {
38   private static final String TAG = "LabeledIntent";
39   public static final int DEFAULT_REQUEST_CODE = 0;
40   private static final TitleChooser DEFAULT_TITLE_CHOOSER =
41       (labeledIntent, resolveInfo) -> {
42         if (!TextUtils.isEmpty(labeledIntent.titleWithEntity)) {
43           return labeledIntent.titleWithEntity;
44         }
45         return labeledIntent.titleWithoutEntity;
46       };
47 
48   @Nullable public final String titleWithoutEntity;
49   @Nullable public final String titleWithEntity;
50   public final String description;
51   @Nullable public final String descriptionWithAppName;
52   // Do not update this intent.
53   public final Intent intent;
54   public final int requestCode;
55 
56   /**
57    * Initializes a LabeledIntent.
58    *
59    * <p>NOTE: {@code requestCode} is required to not be {@link #DEFAULT_REQUEST_CODE} if
60    * distinguishing info (e.g. the classified text) is represented in intent extras only. In such
61    * circumstances, the request code should represent the distinguishing info (e.g. by generating a
62    * hashcode) so that the generated PendingIntent is (somewhat) unique. To be correct, the
63    * PendingIntent should be definitely unique but we try a best effort approach that avoids
64    * spamming the system with PendingIntents.
65    */
66   // TODO: Fix the issue mentioned above so the behaviour is correct.
LabeledIntent( @ullable String titleWithoutEntity, @Nullable String titleWithEntity, String description, @Nullable String descriptionWithAppName, Intent intent, int requestCode)67   public LabeledIntent(
68       @Nullable String titleWithoutEntity,
69       @Nullable String titleWithEntity,
70       String description,
71       @Nullable String descriptionWithAppName,
72       Intent intent,
73       int requestCode) {
74     if (TextUtils.isEmpty(titleWithEntity) && TextUtils.isEmpty(titleWithoutEntity)) {
75       throw new IllegalArgumentException(
76           "titleWithEntity and titleWithoutEntity should not be both null");
77     }
78     this.titleWithoutEntity = titleWithoutEntity;
79     this.titleWithEntity = titleWithEntity;
80     this.description = Preconditions.checkNotNull(description);
81     this.descriptionWithAppName = descriptionWithAppName;
82     this.intent = Preconditions.checkNotNull(intent);
83     this.requestCode = requestCode;
84   }
85 
86   /**
87    * Return the resolved result.
88    *
89    * @param context the context to resolve the result's intent and action
90    * @param titleChooser for choosing an action title
91    */
92   @Nullable
resolve(Context context, @Nullable TitleChooser titleChooser)93   public Result resolve(Context context, @Nullable TitleChooser titleChooser) {
94     final PackageManager pm = context.getPackageManager();
95     final ResolveInfo resolveInfo = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
96 
97     if (resolveInfo == null || resolveInfo.activityInfo == null) {
98       // Failed to resolve the intent. It could be because there are no apps to handle
99       // the intent. It could be also because the calling app has no visibility to the target app
100       // due to the app visibility feature introduced on R. For privacy reason, we don't want to
101       // force users of our library to ask for the visibility to the http/https view intent.
102       // Getting visibility to this intent effectively means getting visibility of ~70% of apps.
103       // This defeats the purpose of the app visibility feature. Practically speaking, all devices
104       // are very likely to have a browser installed. Thus, if it is a web intent, we assume we
105       // failed to resolve the intent just because of the app visibility feature. In which case, we
106       // return an implicit intent without an icon.
107       if (isWebIntent()) {
108         IconCompat icon = IconCompat.createWithResource(context, android.R.drawable.ic_menu_more);
109         RemoteActionCompat action =
110             createRemoteAction(
111                 context, intent, icon, /* shouldShowIcon= */ false, resolveInfo, titleChooser);
112         // Create a clone so that the client does not modify the original intent.
113         return new Result(new Intent(intent), action);
114       } else {
115         TcLog.w(TAG, "resolveInfo or activityInfo is null");
116         return null;
117       }
118     }
119     if (!hasPermission(context, resolveInfo.activityInfo)) {
120       TcLog.d(TAG, "No permission to access: " + resolveInfo.activityInfo);
121       return null;
122     }
123 
124     final String packageName = resolveInfo.activityInfo.packageName;
125     final String className = resolveInfo.activityInfo.name;
126     if (packageName == null || className == null) {
127       TcLog.w(TAG, "packageName or className is null");
128       return null;
129     }
130     Intent resolvedIntent = new Intent(intent);
131     boolean shouldShowIcon = false;
132     IconCompat icon = null;
133     if (!"android".equals(packageName)) {
134       // We only set the component name when the package name is not resolved to "android"
135       // to workaround a bug that explicit intent with component name == ResolverActivity
136       // can't be launched on keyguard.
137       resolvedIntent.setComponent(new ComponentName(packageName, className));
138       if (resolveInfo.activityInfo.getIconResource() != 0) {
139         icon =
140             createIconFromPackage(context, packageName, resolveInfo.activityInfo.getIconResource());
141         shouldShowIcon = true;
142       }
143     }
144     if (icon == null) {
145       // RemoteAction requires that there be an icon.
146       icon = IconCompat.createWithResource(context, android.R.drawable.ic_menu_more);
147     }
148     RemoteActionCompat action =
149         createRemoteAction(
150             context, resolvedIntent, icon, shouldShowIcon, resolveInfo, titleChooser);
151     return new Result(resolvedIntent, action);
152   }
153 
createRemoteAction( Context context, Intent resolvedIntent, IconCompat icon, boolean shouldShowIcon, @Nullable ResolveInfo resolveInfo, @Nullable TitleChooser titleChooser)154   private RemoteActionCompat createRemoteAction(
155       Context context,
156       Intent resolvedIntent,
157       IconCompat icon,
158       boolean shouldShowIcon,
159       @Nullable ResolveInfo resolveInfo,
160       @Nullable TitleChooser titleChooser) {
161     final PendingIntent pendingIntent = createPendingIntent(context, resolvedIntent, requestCode);
162     titleChooser = titleChooser == null ? DEFAULT_TITLE_CHOOSER : titleChooser;
163     CharSequence title = titleChooser.chooseTitle(this, resolveInfo);
164     if (TextUtils.isEmpty(title)) {
165       TcLog.w(TAG, "Custom titleChooser return null, fallback to the default titleChooser");
166       title = DEFAULT_TITLE_CHOOSER.chooseTitle(this, resolveInfo);
167     }
168     final RemoteActionCompat action =
169         new RemoteActionCompat(
170             icon,
171             title,
172             resolveDescription(resolveInfo, context.getPackageManager()),
173             pendingIntent);
174     action.setShouldShowIcon(shouldShowIcon);
175     return action;
176   }
177 
isWebIntent()178   private boolean isWebIntent() {
179     if (!Intent.ACTION_VIEW.equals(intent.getAction())) {
180       return false;
181     }
182     final String scheme = intent.getScheme();
183     return Objects.equal(scheme, "http") || Objects.equal(scheme, "https");
184   }
185 
resolveDescription( @ullable ResolveInfo resolveInfo, PackageManager packageManager)186   private String resolveDescription(
187       @Nullable ResolveInfo resolveInfo, PackageManager packageManager) {
188     if (!TextUtils.isEmpty(descriptionWithAppName)) {
189       // Example string format of descriptionWithAppName: "Use %1$s to open map".
190       String applicationName = getApplicationName(resolveInfo, packageManager);
191       if (!TextUtils.isEmpty(applicationName)) {
192         return String.format(descriptionWithAppName, applicationName);
193       }
194     }
195     return description;
196   }
197 
198   @Nullable
createIconFromPackage( Context context, String packageName, @DrawableRes int iconRes)199   private static IconCompat createIconFromPackage(
200       Context context, String packageName, @DrawableRes int iconRes) {
201     try {
202       Context packageContext = context.createPackageContext(packageName, 0);
203       return IconCompat.createWithResource(packageContext, iconRes);
204     } catch (PackageManager.NameNotFoundException e) {
205       TcLog.e(TAG, "createIconFromPackage: failed to create package context", e);
206     }
207     return null;
208   }
209 
createPendingIntent( final Context context, final Intent intent, int requestCode)210   private static PendingIntent createPendingIntent(
211       final Context context, final Intent intent, int requestCode) {
212     return PendingIntent.getActivity(
213         context,
214         requestCode,
215         intent,
216         PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
217   }
218 
219   @Nullable
getApplicationName( @ullable ResolveInfo resolveInfo, PackageManager packageManager)220   private static String getApplicationName(
221       @Nullable ResolveInfo resolveInfo, PackageManager packageManager) {
222     if (resolveInfo == null || resolveInfo.activityInfo == null) {
223       return null;
224     }
225     if ("android".equals(resolveInfo.activityInfo.packageName)) {
226       return null;
227     }
228     if (resolveInfo.activityInfo.applicationInfo == null) {
229       return null;
230     }
231     return packageManager.getApplicationLabel(resolveInfo.activityInfo.applicationInfo).toString();
232   }
233 
hasPermission(Context context, ActivityInfo info)234   private static boolean hasPermission(Context context, ActivityInfo info) {
235     if (!info.exported) {
236       return false;
237     }
238     if (info.permission == null) {
239       return true;
240     }
241     return ContextCompat.checkSelfPermission(context, info.permission)
242         == PackageManager.PERMISSION_GRANTED;
243   }
244 
245   /** Data class that holds the result. */
246   public static final class Result {
247     public final Intent resolvedIntent;
248     public final RemoteActionCompat remoteAction;
249 
Result(Intent resolvedIntent, RemoteActionCompat remoteAction)250     public Result(Intent resolvedIntent, RemoteActionCompat remoteAction) {
251       this.resolvedIntent = Preconditions.checkNotNull(resolvedIntent);
252       this.remoteAction = Preconditions.checkNotNull(remoteAction);
253     }
254   }
255 
256   /**
257    * An object to choose a title from resolved info. If {@code null} is returned, {@link
258    * #titleWithEntity} will be used if it exists, {@link #titleWithoutEntity} otherwise.
259    */
260   public interface TitleChooser {
261     /**
262      * Picks a title from a {@link LabeledIntent} by looking into resolved info. {@code resolveInfo}
263      * is guaranteed to have a non-null {@code activityInfo}.
264      */
265     @Nullable
chooseTitle(LabeledIntent labeledIntent, @Nullable ResolveInfo resolveInfo)266     CharSequence chooseTitle(LabeledIntent labeledIntent, @Nullable ResolveInfo resolveInfo);
267   }
268 }
269