summaryrefslogtreecommitdiff
path: root/java/src/com/android/textclassifier/common/intent/LabeledIntent.java
blob: abc879d71bf4edce70115da435f7d5f88e944a2b (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.textclassifier.common.intent;

import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.text.TextUtils;
import androidx.annotation.DrawableRes;
import androidx.core.app.RemoteActionCompat;
import androidx.core.content.ContextCompat;
import androidx.core.graphics.drawable.IconCompat;
import com.android.textclassifier.common.base.TcLog;
import com.google.common.base.Objects;
import com.google.common.base.Preconditions;
import javax.annotation.Nullable;

/** Helper class to store the information from which RemoteActions are built. */
public final class LabeledIntent {
  private static final String TAG = "LabeledIntent";
  public static final int DEFAULT_REQUEST_CODE = 0;
  private static final TitleChooser DEFAULT_TITLE_CHOOSER =
      (labeledIntent, resolveInfo) -> {
        if (!TextUtils.isEmpty(labeledIntent.titleWithEntity)) {
          return labeledIntent.titleWithEntity;
        }
        return labeledIntent.titleWithoutEntity;
      };

  @Nullable public final String titleWithoutEntity;
  @Nullable public final String titleWithEntity;
  public final String description;
  @Nullable public final String descriptionWithAppName;
  // Do not update this intent.
  public final Intent intent;
  public final int requestCode;

  /**
   * Initializes a LabeledIntent.
   *
   * <p>NOTE: {@code requestCode} is required to not be {@link #DEFAULT_REQUEST_CODE} if
   * distinguishing info (e.g. the classified text) is represented in intent extras only. In such
   * circumstances, the request code should represent the distinguishing info (e.g. by generating a
   * hashcode) so that the generated PendingIntent is (somewhat) unique. To be correct, the
   * PendingIntent should be definitely unique but we try a best effort approach that avoids
   * spamming the system with PendingIntents.
   */
  // TODO: Fix the issue mentioned above so the behaviour is correct.
  public LabeledIntent(
      @Nullable String titleWithoutEntity,
      @Nullable String titleWithEntity,
      String description,
      @Nullable String descriptionWithAppName,
      Intent intent,
      int requestCode) {
    if (TextUtils.isEmpty(titleWithEntity) && TextUtils.isEmpty(titleWithoutEntity)) {
      throw new IllegalArgumentException(
          "titleWithEntity and titleWithoutEntity should not be both null");
    }
    this.titleWithoutEntity = titleWithoutEntity;
    this.titleWithEntity = titleWithEntity;
    this.description = Preconditions.checkNotNull(description);
    this.descriptionWithAppName = descriptionWithAppName;
    this.intent = Preconditions.checkNotNull(intent);
    this.requestCode = requestCode;
  }

  /**
   * Return the resolved result.
   *
   * @param context the context to resolve the result's intent and action
   * @param titleChooser for choosing an action title
   */
  @Nullable
  public Result resolve(Context context, @Nullable TitleChooser titleChooser) {
    final PackageManager pm = context.getPackageManager();
    final ResolveInfo resolveInfo = pm.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);

    if (resolveInfo == null || resolveInfo.activityInfo == null) {
      // Failed to resolve the intent. It could be because there are no apps to handle
      // the intent. It could be also because the calling app has no visibility to the target app
      // due to the app visibility feature introduced on R. For privacy reason, we don't want to
      // force users of our library to ask for the visibility to the http/https view intent.
      // Getting visibility to this intent effectively means getting visibility of ~70% of apps.
      // This defeats the purpose of the app visibility feature. Practically speaking, all devices
      // are very likely to have a browser installed. Thus, if it is a web intent, we assume we
      // failed to resolve the intent just because of the app visibility feature. In which case, we
      // return an implicit intent without an icon.
      if (isWebIntent()) {
        IconCompat icon = IconCompat.createWithResource(context, android.R.drawable.ic_menu_more);
        RemoteActionCompat action =
            createRemoteAction(
                context, intent, icon, /* shouldShowIcon= */ false, resolveInfo, titleChooser);
        // Create a clone so that the client does not modify the original intent.
        return new Result(new Intent(intent), action);
      } else {
        TcLog.w(TAG, "resolveInfo or activityInfo is null");
        return null;
      }
    }
    if (!hasPermission(context, resolveInfo.activityInfo)) {
      TcLog.d(TAG, "No permission to access: " + resolveInfo.activityInfo);
      return null;
    }

    final String packageName = resolveInfo.activityInfo.packageName;
    final String className = resolveInfo.activityInfo.name;
    if (packageName == null || className == null) {
      TcLog.w(TAG, "packageName or className is null");
      return null;
    }
    Intent resolvedIntent = new Intent(intent);
    boolean shouldShowIcon = false;
    IconCompat icon = null;
    if (!"android".equals(packageName)) {
      // We only set the component name when the package name is not resolved to "android"
      // to workaround a bug that explicit intent with component name == ResolverActivity
      // can't be launched on keyguard.
      resolvedIntent.setComponent(new ComponentName(packageName, className));
      if (resolveInfo.activityInfo.getIconResource() != 0) {
        icon =
            createIconFromPackage(context, packageName, resolveInfo.activityInfo.getIconResource());
        shouldShowIcon = true;
      }
    }
    if (icon == null) {
      // RemoteAction requires that there be an icon.
      icon = IconCompat.createWithResource(context, android.R.drawable.ic_menu_more);
    }
    RemoteActionCompat action =
        createRemoteAction(
            context, resolvedIntent, icon, shouldShowIcon, resolveInfo, titleChooser);
    return new Result(resolvedIntent, action);
  }

  private RemoteActionCompat createRemoteAction(
      Context context,
      Intent resolvedIntent,
      IconCompat icon,
      boolean shouldShowIcon,
      @Nullable ResolveInfo resolveInfo,
      @Nullable TitleChooser titleChooser) {
    final PendingIntent pendingIntent = createPendingIntent(context, resolvedIntent, requestCode);
    titleChooser = titleChooser == null ? DEFAULT_TITLE_CHOOSER : titleChooser;
    CharSequence title = titleChooser.chooseTitle(this, resolveInfo);
    if (TextUtils.isEmpty(title)) {
      TcLog.w(TAG, "Custom titleChooser return null, fallback to the default titleChooser");
      title = DEFAULT_TITLE_CHOOSER.chooseTitle(this, resolveInfo);
    }
    final RemoteActionCompat action =
        new RemoteActionCompat(
            icon,
            title,
            resolveDescription(resolveInfo, context.getPackageManager()),
            pendingIntent);
    action.setShouldShowIcon(shouldShowIcon);
    return action;
  }

  private boolean isWebIntent() {
    if (!Intent.ACTION_VIEW.equals(intent.getAction())) {
      return false;
    }
    final String scheme = intent.getScheme();
    return Objects.equal(scheme, "http") || Objects.equal(scheme, "https");
  }

  private String resolveDescription(
      @Nullable ResolveInfo resolveInfo, PackageManager packageManager) {
    if (!TextUtils.isEmpty(descriptionWithAppName)) {
      // Example string format of descriptionWithAppName: "Use %1$s to open map".
      String applicationName = getApplicationName(resolveInfo, packageManager);
      if (!TextUtils.isEmpty(applicationName)) {
        return String.format(descriptionWithAppName, applicationName);
      }
    }
    return description;
  }

  @Nullable
  private static IconCompat createIconFromPackage(
      Context context, String packageName, @DrawableRes int iconRes) {
    try {
      Context packageContext = context.createPackageContext(packageName, 0);
      return IconCompat.createWithResource(packageContext, iconRes);
    } catch (PackageManager.NameNotFoundException e) {
      TcLog.e(TAG, "createIconFromPackage: failed to create package context", e);
    }
    return null;
  }

  private static PendingIntent createPendingIntent(
      final Context context, final Intent intent, int requestCode) {
    return PendingIntent.getActivity(
        context,
        requestCode,
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
  }

  @Nullable
  private static String getApplicationName(
      @Nullable ResolveInfo resolveInfo, PackageManager packageManager) {
    if (resolveInfo == null || resolveInfo.activityInfo == null) {
      return null;
    }
    if ("android".equals(resolveInfo.activityInfo.packageName)) {
      return null;
    }
    if (resolveInfo.activityInfo.applicationInfo == null) {
      return null;
    }
    return packageManager.getApplicationLabel(resolveInfo.activityInfo.applicationInfo).toString();
  }

  private static boolean hasPermission(Context context, ActivityInfo info) {
    if (!info.exported) {
      return false;
    }
    if (info.permission == null) {
      return true;
    }
    return ContextCompat.checkSelfPermission(context, info.permission)
        == PackageManager.PERMISSION_GRANTED;
  }

  /** Data class that holds the result. */
  public static final class Result {
    public final Intent resolvedIntent;
    public final RemoteActionCompat remoteAction;

    public Result(Intent resolvedIntent, RemoteActionCompat remoteAction) {
      this.resolvedIntent = Preconditions.checkNotNull(resolvedIntent);
      this.remoteAction = Preconditions.checkNotNull(remoteAction);
    }
  }

  /**
   * An object to choose a title from resolved info. If {@code null} is returned, {@link
   * #titleWithEntity} will be used if it exists, {@link #titleWithoutEntity} otherwise.
   */
  public interface TitleChooser {
    /**
     * Picks a title from a {@link LabeledIntent} by looking into resolved info. {@code resolveInfo}
     * is guaranteed to have a non-null {@code activityInfo}.
     */
    @Nullable
    CharSequence chooseTitle(LabeledIntent labeledIntent, @Nullable ResolveInfo resolveInfo);
  }
}