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