/*
|
* Copyright (C) 2016 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.server.pm;
|
|
import android.annotation.NonNull;
|
import android.annotation.Nullable;
|
import android.annotation.UserIdInt;
|
import android.content.ComponentName;
|
import android.content.Intent;
|
import android.content.pm.ActivityInfo;
|
import android.content.pm.ResolveInfo;
|
import android.content.pm.ShortcutInfo;
|
import android.content.res.TypedArray;
|
import android.content.res.XmlResourceParser;
|
import android.text.TextUtils;
|
import android.util.ArraySet;
|
import android.util.AttributeSet;
|
import android.util.Log;
|
import android.util.Slog;
|
import android.util.TypedValue;
|
import android.util.Xml;
|
|
import com.android.internal.R;
|
import com.android.internal.annotations.VisibleForTesting;
|
|
import org.xmlpull.v1.XmlPullParser;
|
import org.xmlpull.v1.XmlPullParserException;
|
|
import java.io.IOException;
|
import java.util.ArrayList;
|
import java.util.List;
|
import java.util.Set;
|
|
public class ShortcutParser {
|
private static final String TAG = ShortcutService.TAG;
|
|
private static final boolean DEBUG = ShortcutService.DEBUG || false; // DO NOT SUBMIT WITH TRUE
|
|
@VisibleForTesting
|
static final String METADATA_KEY = "android.app.shortcuts";
|
|
private static final String TAG_SHORTCUTS = "shortcuts";
|
private static final String TAG_SHORTCUT = "shortcut";
|
private static final String TAG_INTENT = "intent";
|
private static final String TAG_CATEGORIES = "categories";
|
private static final String TAG_SHARE_TARGET = "share-target";
|
private static final String TAG_DATA = "data";
|
private static final String TAG_CATEGORY = "category";
|
|
@Nullable
|
public static List<ShortcutInfo> parseShortcuts(ShortcutService service, String packageName,
|
@UserIdInt int userId, @NonNull List<ShareTargetInfo> outShareTargets)
|
throws IOException, XmlPullParserException {
|
if (ShortcutService.DEBUG) {
|
Slog.d(TAG, String.format("Scanning package %s for manifest shortcuts on user %d",
|
packageName, userId));
|
}
|
final List<ResolveInfo> activities = service.injectGetMainActivities(packageName, userId);
|
if (activities == null || activities.size() == 0) {
|
return null;
|
}
|
|
List<ShortcutInfo> result = null;
|
outShareTargets.clear();
|
|
try {
|
final int size = activities.size();
|
for (int i = 0; i < size; i++) {
|
final ActivityInfo activityInfoNoMetadata = activities.get(i).activityInfo;
|
if (activityInfoNoMetadata == null) {
|
continue;
|
}
|
|
final ActivityInfo activityInfoWithMetadata =
|
service.getActivityInfoWithMetadata(
|
activityInfoNoMetadata.getComponentName(), userId);
|
if (activityInfoWithMetadata != null) {
|
result = parseShortcutsOneFile(service, activityInfoWithMetadata, packageName,
|
userId, result, outShareTargets);
|
}
|
}
|
} catch (RuntimeException e) {
|
// Resource ID mismatch may cause various runtime exceptions when parsing XMLs,
|
// But we don't crash the device, so just swallow them.
|
service.wtf(
|
"Exception caught while parsing shortcut XML for package=" + packageName, e);
|
return null;
|
}
|
return result;
|
}
|
|
private static List<ShortcutInfo> parseShortcutsOneFile(
|
ShortcutService service,
|
ActivityInfo activityInfo, String packageName, @UserIdInt int userId,
|
List<ShortcutInfo> result, @NonNull List<ShareTargetInfo> outShareTargets)
|
throws IOException, XmlPullParserException {
|
if (ShortcutService.DEBUG) {
|
Slog.d(TAG, String.format(
|
"Checking main activity %s", activityInfo.getComponentName()));
|
}
|
|
XmlResourceParser parser = null;
|
try {
|
parser = service.injectXmlMetaData(activityInfo, METADATA_KEY);
|
if (parser == null) {
|
return result;
|
}
|
|
final ComponentName activity = new ComponentName(packageName, activityInfo.name);
|
|
final AttributeSet attrs = Xml.asAttributeSet(parser);
|
|
int type;
|
|
int rank = 0;
|
final int maxShortcuts = service.getMaxActivityShortcuts();
|
int numShortcuts = 0;
|
|
// We instantiate ShortcutInfo at <shortcut>, but we add it to the list at </shortcut>,
|
// after parsing <intent>. We keep the current one in here.
|
ShortcutInfo currentShortcut = null;
|
|
// We instantiate ShareTargetInfo at <share-target>, but add it to outShareTargets at
|
// </share-target>, after parsing <data> and <category>. We keep the current one here.
|
ShareTargetInfo currentShareTarget = null;
|
|
// Keeps parsed categories for both ShortcutInfo and ShareTargetInfo
|
Set<String> categories = null;
|
|
// Keeps parsed intents for ShortcutInfo
|
final ArrayList<Intent> intents = new ArrayList<>();
|
|
// Keeps parsed data fields for ShareTargetInfo
|
final ArrayList<ShareTargetInfo.TargetData> dataList = new ArrayList<>();
|
|
outer:
|
while ((type = parser.next()) != XmlPullParser.END_DOCUMENT
|
&& (type != XmlPullParser.END_TAG || parser.getDepth() > 0)) {
|
final int depth = parser.getDepth();
|
final String tag = parser.getName();
|
|
// When a shortcut tag is closing, publish.
|
if ((type == XmlPullParser.END_TAG) && (depth == 2) && (TAG_SHORTCUT.equals(tag))) {
|
if (currentShortcut == null) {
|
// Shortcut was invalid.
|
continue;
|
}
|
final ShortcutInfo si = currentShortcut;
|
currentShortcut = null; // Make sure to null out for the next iteration.
|
|
if (si.isEnabled()) {
|
if (intents.size() == 0) {
|
Log.e(TAG, "Shortcut " + si.getId() + " has no intent. Skipping it.");
|
continue;
|
}
|
} else {
|
// Just set the default intent to disabled shortcuts.
|
intents.clear();
|
intents.add(new Intent(Intent.ACTION_VIEW));
|
}
|
|
if (numShortcuts >= maxShortcuts) {
|
Log.e(TAG, "More than " + maxShortcuts + " shortcuts found for "
|
+ activityInfo.getComponentName() + ". Skipping the rest.");
|
return result;
|
}
|
|
// Same flag as what TaskStackBuilder adds.
|
intents.get(0).addFlags(
|
Intent.FLAG_ACTIVITY_NEW_TASK |
|
Intent.FLAG_ACTIVITY_CLEAR_TASK |
|
Intent.FLAG_ACTIVITY_TASK_ON_HOME);
|
try {
|
si.setIntents(intents.toArray(new Intent[intents.size()]));
|
} catch (RuntimeException e) {
|
// This shouldn't happen because intents in XML can't have complicated
|
// extras, but just in case Intent.parseIntent() supports such a thing one
|
// day.
|
Log.e(TAG, "Shortcut's extras contain un-persistable values. Skipping it.");
|
continue;
|
}
|
intents.clear();
|
|
if (categories != null) {
|
si.setCategories(categories);
|
categories = null;
|
}
|
|
if (result == null) {
|
result = new ArrayList<>();
|
}
|
result.add(si);
|
numShortcuts++;
|
rank++;
|
if (ShortcutService.DEBUG) {
|
Slog.d(TAG, "Shortcut added: " + si.toInsecureString());
|
}
|
continue;
|
}
|
|
// When a share-target tag is closing, publish.
|
if ((type == XmlPullParser.END_TAG) && (depth == 2)
|
&& (TAG_SHARE_TARGET.equals(tag))) {
|
if (currentShareTarget == null) {
|
// ShareTarget was invalid.
|
continue;
|
}
|
final ShareTargetInfo sti = currentShareTarget;
|
currentShareTarget = null; // Make sure to null out for the next iteration.
|
|
if (categories == null || categories.isEmpty() || dataList.isEmpty()) {
|
// Incomplete ShareTargetInfo.
|
continue;
|
}
|
|
final ShareTargetInfo newShareTarget = new ShareTargetInfo(
|
dataList.toArray(new ShareTargetInfo.TargetData[dataList.size()]),
|
sti.mTargetClass, categories.toArray(new String[categories.size()]));
|
outShareTargets.add(newShareTarget);
|
if (ShortcutService.DEBUG) {
|
Slog.d(TAG, "ShareTarget added: " + newShareTarget.toString());
|
}
|
categories = null;
|
dataList.clear();
|
}
|
|
// Otherwise, just look at start tags.
|
if (type != XmlPullParser.START_TAG) {
|
continue;
|
}
|
|
if (depth == 1 && TAG_SHORTCUTS.equals(tag)) {
|
continue; // Root tag.
|
}
|
if (depth == 2 && TAG_SHORTCUT.equals(tag)) {
|
final ShortcutInfo si = parseShortcutAttributes(
|
service, attrs, packageName, activity, userId, rank);
|
if (si == null) {
|
// Shortcut was invalid.
|
continue;
|
}
|
if (ShortcutService.DEBUG) {
|
Slog.d(TAG, "Shortcut found: " + si.toInsecureString());
|
}
|
if (result != null) {
|
for (int i = result.size() - 1; i >= 0; i--) {
|
if (si.getId().equals(result.get(i).getId())) {
|
Log.e(TAG, "Duplicate shortcut ID detected. Skipping it.");
|
continue outer;
|
}
|
}
|
}
|
currentShortcut = si;
|
categories = null;
|
continue;
|
}
|
if (depth == 2 && TAG_SHARE_TARGET.equals(tag)) {
|
final ShareTargetInfo sti = parseShareTargetAttributes(service, attrs);
|
if (sti == null) {
|
// ShareTarget was invalid.
|
continue;
|
}
|
currentShareTarget = sti;
|
categories = null;
|
dataList.clear();
|
continue;
|
}
|
if (depth == 3 && TAG_INTENT.equals(tag)) {
|
if ((currentShortcut == null)
|
|| !currentShortcut.isEnabled()) {
|
Log.e(TAG, "Ignoring excessive intent tag.");
|
continue;
|
}
|
|
final Intent intent = Intent.parseIntent(service.mContext.getResources(),
|
parser, attrs);
|
if (TextUtils.isEmpty(intent.getAction())) {
|
Log.e(TAG, "Shortcut intent action must be provided. activity=" + activity);
|
currentShortcut = null; // Invalidate the current shortcut.
|
continue;
|
}
|
intents.add(intent);
|
continue;
|
}
|
if (depth == 3 && TAG_CATEGORIES.equals(tag)) {
|
if ((currentShortcut == null)
|
|| (currentShortcut.getCategories() != null)) {
|
continue;
|
}
|
final String name = parseCategories(service, attrs);
|
if (TextUtils.isEmpty(name)) {
|
Log.e(TAG, "Empty category found. activity=" + activity);
|
continue;
|
}
|
|
if (categories == null) {
|
categories = new ArraySet<>();
|
}
|
categories.add(name);
|
continue;
|
}
|
if (depth == 3 && TAG_CATEGORY.equals(tag)) {
|
if ((currentShareTarget == null)) {
|
continue;
|
}
|
final String name = parseCategory(service, attrs);
|
if (TextUtils.isEmpty(name)) {
|
Log.e(TAG, "Empty category found. activity=" + activity);
|
continue;
|
}
|
|
if (categories == null) {
|
categories = new ArraySet<>();
|
}
|
categories.add(name);
|
continue;
|
}
|
if (depth == 3 && TAG_DATA.equals(tag)) {
|
if ((currentShareTarget == null)) {
|
continue;
|
}
|
final ShareTargetInfo.TargetData data = parseShareTargetData(service, attrs);
|
if (data == null) {
|
Log.e(TAG, "Invalid data tag found. activity=" + activity);
|
continue;
|
}
|
dataList.add(data);
|
continue;
|
}
|
|
Log.w(TAG, String.format("Invalid tag '%s' found at depth %d", tag, depth));
|
}
|
} finally {
|
if (parser != null) {
|
parser.close();
|
}
|
}
|
return result;
|
}
|
|
private static String parseCategories(ShortcutService service, AttributeSet attrs) {
|
final TypedArray sa = service.mContext.getResources().obtainAttributes(attrs,
|
R.styleable.ShortcutCategories);
|
try {
|
if (sa.getType(R.styleable.ShortcutCategories_name) == TypedValue.TYPE_STRING) {
|
return sa.getNonResourceString(R.styleable.ShortcutCategories_name);
|
} else {
|
Log.w(TAG, "android:name for shortcut category must be string literal.");
|
return null;
|
}
|
} finally {
|
sa.recycle();
|
}
|
}
|
|
private static ShortcutInfo parseShortcutAttributes(ShortcutService service,
|
AttributeSet attrs, String packageName, ComponentName activity,
|
@UserIdInt int userId, int rank) {
|
final TypedArray sa = service.mContext.getResources().obtainAttributes(attrs,
|
R.styleable.Shortcut);
|
try {
|
if (sa.getType(R.styleable.Shortcut_shortcutId) != TypedValue.TYPE_STRING) {
|
Log.w(TAG, "android:shortcutId must be string literal. activity=" + activity);
|
return null;
|
}
|
final String id = sa.getNonResourceString(R.styleable.Shortcut_shortcutId);
|
final boolean enabled = sa.getBoolean(R.styleable.Shortcut_enabled, true);
|
final int iconResId = sa.getResourceId(R.styleable.Shortcut_icon, 0);
|
final int titleResId = sa.getResourceId(R.styleable.Shortcut_shortcutShortLabel, 0);
|
final int textResId = sa.getResourceId(R.styleable.Shortcut_shortcutLongLabel, 0);
|
final int disabledMessageResId = sa.getResourceId(
|
R.styleable.Shortcut_shortcutDisabledMessage, 0);
|
|
if (TextUtils.isEmpty(id)) {
|
Log.w(TAG, "android:shortcutId must be provided. activity=" + activity);
|
return null;
|
}
|
if (titleResId == 0) {
|
Log.w(TAG, "android:shortcutShortLabel must be provided. activity=" + activity);
|
return null;
|
}
|
|
return createShortcutFromManifest(
|
service,
|
userId,
|
id,
|
packageName,
|
activity,
|
titleResId,
|
textResId,
|
disabledMessageResId,
|
rank,
|
iconResId,
|
enabled);
|
} finally {
|
sa.recycle();
|
}
|
}
|
|
private static ShortcutInfo createShortcutFromManifest(ShortcutService service,
|
@UserIdInt int userId, String id, String packageName, ComponentName activityComponent,
|
int titleResId, int textResId, int disabledMessageResId,
|
int rank, int iconResId, boolean enabled) {
|
|
final int flags =
|
(enabled ? ShortcutInfo.FLAG_MANIFEST : ShortcutInfo.FLAG_DISABLED)
|
| ShortcutInfo.FLAG_IMMUTABLE
|
| ((iconResId != 0) ? ShortcutInfo.FLAG_HAS_ICON_RES : 0);
|
final int disabledReason =
|
enabled ? ShortcutInfo.DISABLED_REASON_NOT_DISABLED
|
: ShortcutInfo.DISABLED_REASON_BY_APP;
|
|
// Note we don't need to set resource names here yet. They'll be set when they're about
|
// to be published.
|
return new ShortcutInfo(
|
userId,
|
id,
|
packageName,
|
activityComponent,
|
null, // icon
|
null, // title string
|
titleResId,
|
null, // title res name
|
null, // text string
|
textResId,
|
null, // text res name
|
null, // disabled message string
|
disabledMessageResId,
|
null, // disabled message res name
|
null, // categories
|
null, // intent
|
rank,
|
null, // extras
|
service.injectCurrentTimeMillis(),
|
flags,
|
iconResId,
|
null, // icon res name
|
null, // bitmap path
|
disabledReason,
|
null /* persons */,
|
null /* locusId */);
|
}
|
|
private static String parseCategory(ShortcutService service, AttributeSet attrs) {
|
final TypedArray sa = service.mContext.getResources().obtainAttributes(attrs,
|
R.styleable.IntentCategory);
|
try {
|
if (sa.getType(R.styleable.IntentCategory_name) != TypedValue.TYPE_STRING) {
|
Log.w(TAG, "android:name must be string literal.");
|
return null;
|
}
|
return sa.getString(R.styleable.IntentCategory_name);
|
} finally {
|
sa.recycle();
|
}
|
}
|
|
private static ShareTargetInfo parseShareTargetAttributes(ShortcutService service,
|
AttributeSet attrs) {
|
final TypedArray sa = service.mContext.getResources().obtainAttributes(attrs,
|
R.styleable.Intent);
|
try {
|
String targetClass = sa.getString(R.styleable.Intent_targetClass);
|
if (TextUtils.isEmpty(targetClass)) {
|
Log.w(TAG, "android:targetClass must be provided.");
|
return null;
|
}
|
return new ShareTargetInfo(null, targetClass, null);
|
} finally {
|
sa.recycle();
|
}
|
}
|
|
private static ShareTargetInfo.TargetData parseShareTargetData(ShortcutService service,
|
AttributeSet attrs) {
|
final TypedArray sa = service.mContext.getResources().obtainAttributes(attrs,
|
R.styleable.AndroidManifestData);
|
try {
|
if (sa.getType(R.styleable.AndroidManifestData_mimeType) != TypedValue.TYPE_STRING) {
|
Log.w(TAG, "android:mimeType must be string literal.");
|
return null;
|
}
|
String scheme = sa.getString(R.styleable.AndroidManifestData_scheme);
|
String host = sa.getString(R.styleable.AndroidManifestData_host);
|
String port = sa.getString(R.styleable.AndroidManifestData_port);
|
String path = sa.getString(R.styleable.AndroidManifestData_path);
|
String pathPattern = sa.getString(R.styleable.AndroidManifestData_pathPattern);
|
String pathPrefix = sa.getString(R.styleable.AndroidManifestData_pathPrefix);
|
String mimeType = sa.getString(R.styleable.AndroidManifestData_mimeType);
|
return new ShareTargetInfo.TargetData(scheme, host, port, path, pathPattern, pathPrefix,
|
mimeType);
|
} finally {
|
sa.recycle();
|
}
|
}
|
}
|