1 /*
2  * Copyright (C) 2019 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 package android.view.textclassifier.intent;
17 
18 import android.annotation.Nullable;
19 import android.app.PendingIntent;
20 import android.app.RemoteAction;
21 import android.content.ComponentName;
22 import android.content.Context;
23 import android.content.Intent;
24 import android.content.pm.PackageManager;
25 import android.content.pm.ResolveInfo;
26 import android.graphics.drawable.Icon;
27 import android.os.Bundle;
28 import android.text.TextUtils;
29 import android.view.textclassifier.ExtrasUtils;
30 import android.view.textclassifier.Log;
31 import android.view.textclassifier.TextClassification;
32 import android.view.textclassifier.TextClassifier;
33 
34 import com.android.internal.annotations.VisibleForTesting;
35 import com.android.internal.util.Preconditions;
36 
37 /**
38  * Helper class to store the information from which RemoteActions are built.
39  *
40  * @hide
41  */
42 @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE)
43 public final class LabeledIntent {
44     private static final String TAG = "LabeledIntent";
45     public static final int DEFAULT_REQUEST_CODE = 0;
46     private static final TitleChooser DEFAULT_TITLE_CHOOSER =
47             (labeledIntent, resolveInfo) -> {
48                 if (!TextUtils.isEmpty(labeledIntent.titleWithEntity)) {
49                     return labeledIntent.titleWithEntity;
50                 }
51                 return labeledIntent.titleWithoutEntity;
52             };
53 
54     @Nullable
55     public final String titleWithoutEntity;
56     @Nullable
57     public final String titleWithEntity;
58     public final String description;
59     @Nullable
60     public final String descriptionWithAppName;
61     // Do not update this intent.
62     public final Intent intent;
63     public final int requestCode;
64 
65     /**
66      * Initializes a LabeledIntent.
67      *
68      * <p>NOTE: {@code requestCode} is required to not be {@link #DEFAULT_REQUEST_CODE}
69      * if distinguishing info (e.g. the classified text) is represented in intent extras only.
70      * In such circumstances, the request code should represent the distinguishing info
71      * (e.g. by generating a hashcode) so that the generated PendingIntent is (somewhat)
72      * unique. To be correct, the PendingIntent should be definitely unique but we try a
73      * best effort approach that avoids spamming the system with PendingIntents.
74      */
75     // 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)76     public LabeledIntent(
77             @Nullable String titleWithoutEntity,
78             @Nullable String titleWithEntity,
79             String description,
80             @Nullable String descriptionWithAppName,
81             Intent intent,
82             int requestCode) {
83         if (TextUtils.isEmpty(titleWithEntity) && TextUtils.isEmpty(titleWithoutEntity)) {
84             throw new IllegalArgumentException(
85                     "titleWithEntity and titleWithoutEntity should not be both null");
86         }
87         this.titleWithoutEntity = titleWithoutEntity;
88         this.titleWithEntity = titleWithEntity;
89         this.description = Preconditions.checkNotNull(description);
90         this.descriptionWithAppName = descriptionWithAppName;
91         this.intent = Preconditions.checkNotNull(intent);
92         this.requestCode = requestCode;
93     }
94 
95     /**
96      * Return the resolved result.
97      *
98      * @param context the context to resolve the result's intent and action
99      * @param titleChooser for choosing an action title
100      * @param textLanguagesBundle containing language detection information
101      */
102     @Nullable
resolve( Context context, @Nullable TitleChooser titleChooser, @Nullable Bundle textLanguagesBundle)103     public Result resolve(
104             Context context,
105             @Nullable TitleChooser titleChooser,
106             @Nullable Bundle textLanguagesBundle) {
107         final PackageManager pm = context.getPackageManager();
108         final ResolveInfo resolveInfo = pm.resolveActivity(intent, 0);
109 
110         if (resolveInfo == null || resolveInfo.activityInfo == null) {
111             Log.w(TAG, "resolveInfo or activityInfo is null");
112             return null;
113         }
114         final String packageName = resolveInfo.activityInfo.packageName;
115         final String className = resolveInfo.activityInfo.name;
116         if (packageName == null || className == null) {
117             Log.w(TAG, "packageName or className is null");
118             return null;
119         }
120         Intent resolvedIntent = new Intent(intent);
121         resolvedIntent.putExtra(
122                 TextClassifier.EXTRA_FROM_TEXT_CLASSIFIER,
123                 getFromTextClassifierExtra(textLanguagesBundle));
124         boolean shouldShowIcon = false;
125         Icon icon = null;
126         if (!"android".equals(packageName)) {
127             // We only set the component name when the package name is not resolved to "android"
128             // to workaround a bug that explicit intent with component name == ResolverActivity
129             // can't be launched on keyguard.
130             resolvedIntent.setComponent(new ComponentName(packageName, className));
131             if (resolveInfo.activityInfo.getIconResource() != 0) {
132                 icon = Icon.createWithResource(
133                         packageName, resolveInfo.activityInfo.getIconResource());
134                 shouldShowIcon = true;
135             }
136         }
137         if (icon == null) {
138             // RemoteAction requires that there be an icon.
139             icon = Icon.createWithResource(
140                     "android", com.android.internal.R.drawable.ic_more_items);
141         }
142         final PendingIntent pendingIntent =
143                 TextClassification.createPendingIntent(context, resolvedIntent, requestCode);
144         titleChooser = titleChooser == null ? DEFAULT_TITLE_CHOOSER : titleChooser;
145         CharSequence title = titleChooser.chooseTitle(this, resolveInfo);
146         if (TextUtils.isEmpty(title)) {
147             Log.w(TAG, "Custom titleChooser return null, fallback to the default titleChooser");
148             title = DEFAULT_TITLE_CHOOSER.chooseTitle(this, resolveInfo);
149         }
150         final RemoteAction action =
151                 new RemoteAction(icon, title, resolveDescription(resolveInfo, pm), pendingIntent);
152         action.setShouldShowIcon(shouldShowIcon);
153         return new Result(resolvedIntent, action);
154     }
155 
resolveDescription(ResolveInfo resolveInfo, PackageManager packageManager)156     private String resolveDescription(ResolveInfo resolveInfo, PackageManager packageManager) {
157         if (!TextUtils.isEmpty(descriptionWithAppName)) {
158             // Example string format of descriptionWithAppName: "Use %1$s to open map".
159             String applicationName = getApplicationName(resolveInfo, packageManager);
160             if (!TextUtils.isEmpty(applicationName)) {
161                 return String.format(descriptionWithAppName, applicationName);
162             }
163         }
164         return description;
165     }
166 
167     @Nullable
getApplicationName( ResolveInfo resolveInfo, PackageManager packageManager)168     private String getApplicationName(
169             ResolveInfo resolveInfo, PackageManager packageManager) {
170         if (resolveInfo.activityInfo == null) {
171             return null;
172         }
173         if ("android".equals(resolveInfo.activityInfo.packageName)) {
174             return null;
175         }
176         if (resolveInfo.activityInfo.applicationInfo == null) {
177             return null;
178         }
179         return (String) packageManager.getApplicationLabel(
180                 resolveInfo.activityInfo.applicationInfo);
181     }
182 
getFromTextClassifierExtra(@ullable Bundle textLanguagesBundle)183     private Bundle getFromTextClassifierExtra(@Nullable Bundle textLanguagesBundle) {
184         if (textLanguagesBundle != null) {
185             final Bundle bundle = new Bundle();
186             ExtrasUtils.putTextLanguagesExtra(bundle, textLanguagesBundle);
187             return bundle;
188         } else {
189             return Bundle.EMPTY;
190         }
191     }
192 
193     /**
194      * Data class that holds the result.
195      */
196     public static final class Result {
197         public final Intent resolvedIntent;
198         public final RemoteAction remoteAction;
199 
Result(Intent resolvedIntent, RemoteAction remoteAction)200         public Result(Intent resolvedIntent, RemoteAction remoteAction) {
201             this.resolvedIntent = Preconditions.checkNotNull(resolvedIntent);
202             this.remoteAction = Preconditions.checkNotNull(remoteAction);
203         }
204     }
205 
206     /**
207      * An object to choose a title from resolved info.  If {@code null} is returned,
208      * {@link #titleWithEntity} will be used if it exists, {@link #titleWithoutEntity} otherwise.
209      */
210     public interface TitleChooser {
211         /**
212          * Picks a title from a {@link LabeledIntent} by looking into resolved info.
213          * {@code resolveInfo} is guaranteed to have a non-null {@code activityInfo}.
214          */
215         @Nullable
chooseTitle(LabeledIntent labeledIntent, ResolveInfo resolveInfo)216         CharSequence chooseTitle(LabeledIntent labeledIntent, ResolveInfo resolveInfo);
217     }
218 }
219