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