| /* |
| * 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); |
| } |
| } |