blob: 3b4eda0bbee5ea803deb03e04b248ebad332f9b7 [file] [log] [blame]
/*
* 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.car.carlauncher;
import static android.car.settings.CarSettings.Secure.KEY_PACKAGES_DISABLED_ON_RESOURCE_OVERUSE;
import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.app.Activity;
import android.app.ActivityOptions;
import android.car.Car;
import android.car.CarNotConnectedException;
import android.car.content.pm.CarPackageManager;
import android.car.media.CarMediaManager;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.LauncherActivityInfo;
import android.content.pm.LauncherApps;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.Resources;
import android.content.res.XmlResourceParser;
import android.os.Process;
import android.os.UserHandle;
import android.provider.Settings;
import android.service.media.MediaBrowserService;
import android.text.TextUtils;
import android.util.ArraySet;
import android.util.Log;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import java.io.IOException;
import java.lang.annotation.Retention;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Predicate;
/**
* Util class that contains helper method used by app launcher classes.
*/
public class AppLauncherUtils {
private static final String TAG = "AppLauncherUtils";
@Retention(SOURCE)
@IntDef({APP_TYPE_LAUNCHABLES, APP_TYPE_MEDIA_SERVICES})
@interface AppTypes {}
static final int APP_TYPE_LAUNCHABLES = 1;
static final int APP_TYPE_MEDIA_SERVICES = 2;
private static final String TAG_AUTOMOTIVE_APP = "automotiveApp";
private static final String TAG_USES = "uses";
private static final String ATTRIBUTE_NAME = "name";
private static final String TYPE_VIDEO = "video";
static final String PACKAGES_DISABLED_ON_RESOURCE_OVERUSE_SEPARATOR = ";";
// Max no. of uses tags in automotiveApp XML. This is an arbitrary limit to be defensive
// to bad input.
private static final int MAX_APP_TYPES = 64;
private AppLauncherUtils() {
}
/**
* Comparator for {@link AppMetaData} that sorts the list
* by the "displayName" property in ascending order.
*/
static final Comparator<AppMetaData> ALPHABETICAL_COMPARATOR = Comparator
.comparing(AppMetaData::getDisplayName, String::compareToIgnoreCase);
/**
* Helper method that launches the app given the app's AppMetaData.
*
* @param app the requesting app's AppMetaData
*/
static void launchApp(Context context, Intent intent) {
ActivityOptions options = ActivityOptions.makeBasic();
options.setLaunchDisplayId(context.getDisplayId());
context.startActivity(intent, options.toBundle());
}
/** Bundles application and services info. */
static class LauncherAppsInfo {
/*
* Map of all car launcher components' (including launcher activities and media services)
* metadata keyed by ComponentName.
*/
private final Map<ComponentName, AppMetaData> mLaunchables;
/** Map of all the media services keyed by ComponentName. */
private final Map<ComponentName, ResolveInfo> mMediaServices;
LauncherAppsInfo(@NonNull Map<ComponentName, AppMetaData> launchablesMap,
@NonNull Map<ComponentName, ResolveInfo> mediaServices) {
mLaunchables = launchablesMap;
mMediaServices = mediaServices;
}
/** Returns true if all maps are empty. */
boolean isEmpty() {
return mLaunchables.isEmpty() && mMediaServices.isEmpty();
}
/**
* Returns whether the given componentName is a media service.
*/
boolean isMediaService(ComponentName componentName) {
return mMediaServices.containsKey(componentName);
}
/** Returns the {@link AppMetaData} for the given componentName. */
@Nullable
AppMetaData getAppMetaData(ComponentName componentName) {
return mLaunchables.get(componentName);
}
/** Returns a new list of all launchable components' {@link AppMetaData}. */
@NonNull
List<AppMetaData> getLaunchableComponentsList() {
return new ArrayList<>(mLaunchables.values());
}
}
private final static LauncherAppsInfo EMPTY_APPS_INFO = new LauncherAppsInfo(
Collections.emptyMap(), Collections.emptyMap());
/*
* Gets the media source in a given package. If there are multiple sources in the package,
* returns the first one.
*/
static ComponentName getMediaSource(@NonNull PackageManager packageManager,
@NonNull String packageName) {
Intent mediaIntent = new Intent();
mediaIntent.setPackage(packageName);
mediaIntent.setAction(MediaBrowserService.SERVICE_INTERFACE);
List<ResolveInfo> mediaServices = packageManager.queryIntentServices(mediaIntent,
PackageManager.GET_RESOLVED_FILTER);
if (mediaServices == null || mediaServices.isEmpty()) {
return null;
}
String defaultService = mediaServices.get(0).serviceInfo.name;
if (!TextUtils.isEmpty(defaultService)) {
return new ComponentName(packageName, defaultService);
}
return null;
}
/**
* Gets all the components that we want to see in the launcher in unsorted order, including
* launcher activities and media services.
*
* @param appsToHide A (possibly empty) list of apps (package names) to hide
* @param customMediaComponents A (possibly empty) list of media components (component names)
* that shouldn't be shown in Launcher because their applications'
* launcher activities will be shown
* @param appTypes Types of apps to show (e.g.: all, or media sources only)
* @param openMediaCenter Whether launcher should navigate to media center when the
* user selects a media source.
* @param launcherApps The {@link LauncherApps} system service
* @param carPackageManager The {@link CarPackageManager} system service
* @param packageManager The {@link PackageManager} system service
* @param videoAppPredicate Predicate that checks if a given {@link ResolveInfo} resolves
* to a video app. See {@link #VideoAppPredicate}. Media-services
* of such apps are always excluded.
* @param carMediaManager The {@link CarMediaManager} system service
* @return a new {@link LauncherAppsInfo}
*/
@NonNull
static LauncherAppsInfo getLauncherApps(
Context context,
@NonNull Set<String> appsToHide,
@NonNull Set<String> customMediaComponents,
@AppTypes int appTypes,
boolean openMediaCenter,
LauncherApps launcherApps,
CarPackageManager carPackageManager,
PackageManager packageManager,
@NonNull Predicate<ResolveInfo> videoAppPredicate,
CarMediaManager carMediaManager) {
if (launcherApps == null || carPackageManager == null || packageManager == null
|| carMediaManager == null) {
return EMPTY_APPS_INFO;
}
// Using new list since we require a mutable list to do removeIf.
List<ResolveInfo> mediaServices = new ArrayList<>();
mediaServices.addAll(
packageManager.queryIntentServices(
new Intent(MediaBrowserService.SERVICE_INTERFACE),
PackageManager.GET_RESOLVED_FILTER));
// Exclude Media Services from Video apps from being considered. These apps should offer a
// normal Launcher Activity as an entry point.
mediaServices.removeIf(videoAppPredicate);
List<LauncherActivityInfo> availableActivities =
launcherApps.getActivityList(null, Process.myUserHandle());
int launchablesSize = mediaServices.size() + availableActivities.size();
Map<ComponentName, AppMetaData> launchablesMap = new HashMap<>(launchablesSize);
Map<ComponentName, ResolveInfo> mediaServicesMap = new HashMap<>(mediaServices.size());
Set<String> mEnabledPackages = new ArraySet<>(launchablesSize);
// Process media services
if ((appTypes & APP_TYPE_MEDIA_SERVICES) != 0) {
for (ResolveInfo info : mediaServices) {
String packageName = info.serviceInfo.packageName;
String className = info.serviceInfo.name;
ComponentName componentName = new ComponentName(packageName, className);
mediaServicesMap.put(componentName, info);
mEnabledPackages.add(packageName);
if (shouldAddToLaunchables(componentName, appsToHide, customMediaComponents,
appTypes, APP_TYPE_MEDIA_SERVICES)) {
final boolean isDistractionOptimized = true;
Intent intent = new Intent(Car.CAR_INTENT_ACTION_MEDIA_TEMPLATE);
intent.putExtra(Car.CAR_EXTRA_MEDIA_COMPONENT, componentName.flattenToString());
AppMetaData appMetaData = new AppMetaData(
info.serviceInfo.loadLabel(packageManager),
componentName,
info.serviceInfo.loadIcon(packageManager),
isDistractionOptimized,
contextArg -> {
if (openMediaCenter) {
AppLauncherUtils.launchApp(contextArg, intent);
} else {
selectMediaSourceAndFinish(contextArg, componentName,
carMediaManager);
}
},
contextArg -> {
// getLaunchIntentForPackage looks for a main activity in the category
// Intent.CATEGORY_INFO, then Intent.CATEGORY_LAUNCHER, and returns null
// if neither are found
Intent packageLaunchIntent =
packageManager.getLaunchIntentForPackage(packageName);
AppLauncherUtils.launchApp(contextArg,
packageLaunchIntent != null ? packageLaunchIntent : intent);
});
launchablesMap.put(componentName, appMetaData);
}
}
}
// Process activities
if ((appTypes & APP_TYPE_LAUNCHABLES) != 0) {
for (LauncherActivityInfo info : availableActivities) {
ComponentName componentName = info.getComponentName();
String packageName = componentName.getPackageName();
mEnabledPackages.add(packageName);
if (shouldAddToLaunchables(componentName, appsToHide, customMediaComponents,
appTypes, APP_TYPE_LAUNCHABLES)) {
boolean isDistractionOptimized =
isActivityDistractionOptimized(carPackageManager, packageName,
info.getName());
Intent intent = new Intent(Intent.ACTION_MAIN)
.setComponent(componentName)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
AppMetaData appMetaData = new AppMetaData(
info.getLabel(),
componentName,
info.getBadgedIcon(0),
isDistractionOptimized,
contextArg -> AppLauncherUtils.launchApp(contextArg, intent),
null);
launchablesMap.put(componentName, appMetaData);
}
}
List<ResolveInfo> disabledActivities = getDisabledActivities(context, packageManager,
mEnabledPackages);
for (ResolveInfo info : disabledActivities) {
String packageName = info.activityInfo.packageName;
String className = info.activityInfo.name;
ComponentName componentName = new ComponentName(packageName, className);
if (!shouldAddToLaunchables(componentName, appsToHide, customMediaComponents,
appTypes, APP_TYPE_LAUNCHABLES)) {
continue;
}
boolean isDistractionOptimized =
isActivityDistractionOptimized(carPackageManager, packageName, className);
Intent intent = new Intent(Intent.ACTION_MAIN)
.setComponent(componentName)
.addCategory(Intent.CATEGORY_LAUNCHER)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
AppMetaData appMetaData = new AppMetaData(
info.activityInfo.loadLabel(packageManager),
componentName,
info.activityInfo.loadIcon(packageManager),
isDistractionOptimized,
contextArg -> {
packageManager.setApplicationEnabledSetting(packageName,
PackageManager.COMPONENT_ENABLED_STATE_ENABLED, 0);
/* Fetch the current enabled setting to make sure the setting is synced
* before launching the activity. Otherwise, the activity may not
* launch.
*/
if (packageManager.getApplicationEnabledSetting(packageName)
!= PackageManager.COMPONENT_ENABLED_STATE_ENABLED) {
throw new IllegalStateException(
"Failed to enable the disabled package [" + packageName
+ "]");
}
Log.i(TAG, "Successfully enabled package [" + packageName + "]");
AppLauncherUtils.launchApp(contextArg, intent);
},
null);
launchablesMap.put(componentName, appMetaData);
}
}
return new LauncherAppsInfo(launchablesMap, mediaServicesMap);
}
/**
* Predicate that can be used to check if a given {@link ResolveInfo} resolves to a Video app.
*/
static class VideoAppPredicate implements Predicate<ResolveInfo> {
private final PackageManager mPackageManager;
VideoAppPredicate(PackageManager packageManager) {
mPackageManager = packageManager;
}
@Override
public boolean test(ResolveInfo resolveInfo) {
String packageName = resolveInfo != null ? getPackageName(resolveInfo) : null;
if (packageName == null) {
Log.w(TAG, "Unable to determine packageName from resolveInfo");
return false;
}
List<String> automotiveAppTypes =
getAutomotiveAppTypes(mPackageManager, getPackageName(resolveInfo));
return automotiveAppTypes.contains(TYPE_VIDEO);
}
protected String getPackageName(ResolveInfo resolveInfo) {
// A valid ResolveInfo should have exactly one of these set.
if (resolveInfo.activityInfo != null) {
return resolveInfo.activityInfo.packageName;
}
if (resolveInfo.serviceInfo != null) {
return resolveInfo.serviceInfo.packageName;
}
if (resolveInfo.providerInfo != null) {
return resolveInfo.providerInfo.packageName;
}
// Unexpected case.
return null;
}
}
/**
* Returns whether app identified by {@code packageName} declares itself as a video app.
*/
public static boolean isVideoApp(PackageManager packageManager, String packageName) {
return getAutomotiveAppTypes(packageManager, packageName).contains(TYPE_VIDEO);
}
/**
* Queries an app manifest and resources to determine the types of AAOS app it declares itself
* as.
*
* @param packageManager {@link PackageManager} to query.
* @param packageName App package.
* @return List of AAOS app-types from XML resources.
*/
public static List<String> getAutomotiveAppTypes(PackageManager packageManager,
String packageName) {
ApplicationInfo appInfo;
Resources appResources;
try {
appInfo = packageManager.getApplicationInfo(packageName, PackageManager.GET_META_DATA);
appResources = packageManager.getResourcesForApplication(appInfo);
} catch (PackageManager.NameNotFoundException e) {
Log.w(TAG, "Unexpected package not found for: " + packageName, e);
return new ArrayList<>();
}
int resourceId =
appInfo.metaData != null
? appInfo.metaData.getInt("com.android.automotive", -1) : -1;
if (resourceId == -1) {
return new ArrayList<>();
}
try (XmlResourceParser parser = appResources.getXml(resourceId)) {
return parseAutomotiveAppTypes(parser);
}
}
@VisibleForTesting
static List<String> parseAutomotiveAppTypes(XmlPullParser parser) {
try {
// This pattern for parsing can be seen in Javadocs for XmlPullParser.
List<String> appTypes = new ArrayList<>();
ArrayDeque<String> tagStack = new ArrayDeque<>();
int eventType = parser.getEventType();
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG) {
String tag = parser.getName();
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Start tag " + tag);
}
tagStack.addFirst(tag);
if (!validTagStack(tagStack)) {
Log.w(TAG, "Invalid XML; tagStack: " + tagStack);
return new ArrayList<>();
}
if (TAG_USES.equals(tag)) {
String nameValue =
parser.getAttributeValue(/* namespace= */ null , ATTRIBUTE_NAME);
if (TextUtils.isEmpty(nameValue)) {
Log.w(TAG, "Invalid XML; uses tag with missing/empty name attribute");
return new ArrayList<>();
}
appTypes.add(nameValue);
if (appTypes.size() > MAX_APP_TYPES) {
Log.w(TAG, "Too many uses tags in automotiveApp tag");
return new ArrayList<>();
}
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "Found appType: " + nameValue);
}
}
} else if (eventType == XmlPullParser.END_TAG) {
if (Log.isLoggable(TAG, Log.VERBOSE)) {
Log.v(TAG, "End tag " + parser.getName());
}
tagStack.removeFirst();
}
eventType = parser.next();
}
return appTypes;
} catch (XmlPullParserException | IOException e) {
Log.w(TAG, "Unexpected exception whiling parsing XML resource", e);
return new ArrayList<>();
}
}
private static boolean validTagStack(ArrayDeque<String> tagStack) {
// Expected to be called after a new tag is pushed on this stack.
// Ensures that XML is of form:
// <automotiveApp>
// <uses/>
// <uses/>
// ....
// </automotiveApp>
switch (tagStack.size()) {
case 1:
return TAG_AUTOMOTIVE_APP.equals(tagStack.peekFirst());
case 2:
return TAG_USES.equals(tagStack.peekFirst());
default:
return false;
}
}
private static List<ResolveInfo> getDisabledActivities(Context context,
PackageManager packageManager, Set<String> enabledPackages) {
ContentResolver contentResolverForUser = context.createContextAsUser(
UserHandle.getUserHandleForUid(Process.myUid()), /* flags= */ 0)
.getContentResolver();
String settingsValue = Settings.Secure.getString(contentResolverForUser,
KEY_PACKAGES_DISABLED_ON_RESOURCE_OVERUSE);
Set<String> disabledPackages = TextUtils.isEmpty(settingsValue) ? new ArraySet<>()
: new ArraySet<>(Arrays.asList(settingsValue.split(
PACKAGES_DISABLED_ON_RESOURCE_OVERUSE_SEPARATOR)));
if (disabledPackages.isEmpty()) {
return Collections.emptyList();
}
List<ResolveInfo> allActivities = packageManager.queryIntentActivities(
new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER),
PackageManager.ResolveInfoFlags.of(PackageManager.GET_RESOLVED_FILTER
| PackageManager.MATCH_DISABLED_UNTIL_USED_COMPONENTS));
List<ResolveInfo> disabledActivities = new ArrayList<>();
for (int i = 0; i < allActivities.size(); ++i) {
ResolveInfo info = allActivities.get(i);
if (!enabledPackages.contains(info.activityInfo.packageName)
&& disabledPackages.contains(info.activityInfo.packageName)) {
disabledActivities.add(info);
}
}
return disabledActivities;
}
private static boolean shouldAddToLaunchables(@NonNull ComponentName componentName,
@NonNull Set<String> appsToHide,
@NonNull Set<String> customMediaComponents,
@AppTypes int appTypesToShow,
@AppTypes int componentAppType) {
if (appsToHide.contains(componentName.getPackageName())) {
return false;
}
switch (componentAppType) {
// Process media services
case APP_TYPE_MEDIA_SERVICES:
// For a media service in customMediaComponents, if its application's launcher
// activity will be shown in the Launcher, don't show the service's icon in the
// Launcher.
if (customMediaComponents.contains(componentName.flattenToString())
&& (appTypesToShow & APP_TYPE_LAUNCHABLES) != 0) {
return false;
}
return true;
// Process activities
case APP_TYPE_LAUNCHABLES:
return true;
default:
Log.e(TAG, "Invalid componentAppType : " + componentAppType);
return false;
}
}
private static void selectMediaSourceAndFinish(Context context, ComponentName componentName,
CarMediaManager carMediaManager) {
try {
carMediaManager.setMediaSource(componentName, CarMediaManager.MEDIA_SOURCE_MODE_BROWSE);
if (context instanceof Activity) {
((Activity) context).finish();
}
} catch (CarNotConnectedException e) {
Log.e(TAG, "Car not connected", e);
}
}
/**
* Gets if an activity is distraction optimized.
*
* @param carPackageManager The {@link CarPackageManager} system service
* @param packageName The package name of the app
* @param activityName The requested activity name
* @return true if the supplied activity is distraction optimized
*/
static boolean isActivityDistractionOptimized(
CarPackageManager carPackageManager, String packageName, String activityName) {
boolean isDistractionOptimized = false;
// try getting distraction optimization info
try {
if (carPackageManager != null) {
isDistractionOptimized =
carPackageManager.isActivityDistractionOptimized(packageName, activityName);
}
} catch (CarNotConnectedException e) {
Log.e(TAG, "Car not connected when getting DO info", e);
}
return isDistractionOptimized;
}
}