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