/* * Copyright (C) 2008 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.intentresolver; import android.content.ComponentName; import android.content.Intent; import android.content.IntentFilter; import android.content.IntentSender; import android.net.Uri; import android.os.Bundle; import android.os.Parcelable; import android.os.PatternMatcher; import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; import android.text.TextUtils; import android.util.Log; import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.android.intentresolver.util.UriFilters; import com.google.common.collect.ImmutableList; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.stream.Collector; import java.util.stream.Collectors; import java.util.stream.Stream; /** * Utility to parse and validate parameters from the client-supplied {@link Intent} that launched * the Sharesheet {@link ChooserActivity}. The validated parameters are stored as immutable ivars. * * TODO: field nullability in this class reflects legacy use, and typically would indicate that the * client's intent didn't provide the respective data. In some cases we may be able to provide * defaults instead of nulls -- especially for methods that return nullable lists or arrays, if the * client code could instead handle empty collections equally well. * * TODO: some of these fields (especially getTargetIntent() and any other getters that delegate to * it internally) differ from the legacy model because they're computed directly from the initial * Chooser intent, where in the past they've been relayed up to ResolverActivity and then retrieved * through methods on the base class. The base always seems to return them exactly as they were * provided, so this should be safe -- and clients can reasonably switch to retrieving through these * parameters instead. For now, the other convention is still used in some places. Ideally we'd like * to normalize on a single source of truth, but we'll have to clean up the delegation up to the * resolver (or perhaps this needs to be a subclass of some `ResolverRequestParameters` class?). */ public class ChooserRequestParameters { private static final String TAG = "ChooserActivity"; private static final int LAUNCH_FLAGS_FOR_SEND_ACTION = Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK; private static final int MAX_CHOOSER_ACTIONS = 5; private final Intent mTarget; private final String mReferrerPackageName; private final Pair mTitleSpec; private final Intent mReferrerFillInIntent; private final ImmutableList mFilteredComponentNames; private final ImmutableList mCallerChooserTargets; private final @NonNull ImmutableList mChooserActions; private final ChooserAction mModifyShareAction; private final boolean mRetainInOnStop; @Nullable private final ImmutableList mAdditionalTargets; @Nullable private final Bundle mReplacementExtras; @Nullable private final ImmutableList mInitialIntents; @Nullable private final IntentSender mChosenComponentSender; @Nullable private final IntentSender mRefinementIntentSender; @Nullable private final String mSharedText; @Nullable private final IntentFilter mTargetIntentFilter; @Nullable private final CharSequence mMetadataText; public ChooserRequestParameters( final Intent clientIntent, String referrerPackageName, final Uri referrer) { final Intent requestedTarget = parseTargetIntentExtra( clientIntent.getParcelableExtra(Intent.EXTRA_INTENT)); mTarget = intentWithModifiedLaunchFlags(requestedTarget); mReferrerPackageName = referrerPackageName; mAdditionalTargets = intentsWithModifiedLaunchFlagsFromExtraIfPresent( clientIntent, Intent.EXTRA_ALTERNATE_INTENTS); mReplacementExtras = clientIntent.getBundleExtra(Intent.EXTRA_REPLACEMENT_EXTRAS); mTitleSpec = makeTitleSpec( clientIntent.getCharSequenceExtra(Intent.EXTRA_TITLE), isSendAction(mTarget.getAction())); mInitialIntents = intentsWithModifiedLaunchFlagsFromExtraIfPresent( clientIntent, Intent.EXTRA_INITIAL_INTENTS); mReferrerFillInIntent = new Intent().putExtra(Intent.EXTRA_REFERRER, referrer); mChosenComponentSender = Optional.ofNullable( clientIntent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT_INTENT_SENDER, IntentSender.class)) .orElse(clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_RESULT_INTENT_SENDER, IntentSender.class)); mRefinementIntentSender = clientIntent.getParcelableExtra( Intent.EXTRA_CHOOSER_REFINEMENT_INTENT_SENDER); ComponentName[] filteredComponents = clientIntent.getParcelableArrayExtra( Intent.EXTRA_EXCLUDE_COMPONENTS, ComponentName.class); mFilteredComponentNames = filteredComponents != null ? ImmutableList.copyOf(filteredComponents) : ImmutableList.of(); mCallerChooserTargets = parseCallerTargetsFromClientIntent(clientIntent); mRetainInOnStop = clientIntent.getBooleanExtra( ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP, false); mSharedText = mTarget.getStringExtra(Intent.EXTRA_TEXT); mTargetIntentFilter = getTargetIntentFilter(mTarget); mChooserActions = getChooserActions(clientIntent); mModifyShareAction = getModifyShareAction(clientIntent); if (android.service.chooser.Flags.enableSharesheetMetadataExtra()) { mMetadataText = clientIntent.getCharSequenceExtra(Intent.EXTRA_METADATA_TEXT); } else { mMetadataText = null; } } public Intent getTargetIntent() { return mTarget; } @Nullable public String getTargetAction() { return getTargetIntent().getAction(); } public boolean isSendActionTarget() { return isSendAction(getTargetAction()); } @Nullable public String getTargetType() { return getTargetIntent().getType(); } public String getReferrerPackageName() { return mReferrerPackageName; } @Nullable public CharSequence getTitle() { return mTitleSpec.first; } public int getDefaultTitleResource() { return mTitleSpec.second; } public Intent getReferrerFillInIntent() { return mReferrerFillInIntent; } public ImmutableList getFilteredComponentNames() { return mFilteredComponentNames; } public ImmutableList getCallerChooserTargets() { return mCallerChooserTargets; } @NonNull public ImmutableList getChooserActions() { return mChooserActions; } @Nullable public ChooserAction getModifyShareAction() { return mModifyShareAction; } /** * Whether the {@link ChooserActivity#EXTRA_PRIVATE_RETAIN_IN_ON_STOP} behavior was requested. */ public boolean shouldRetainInOnStop() { return mRetainInOnStop; } /** * TODO: this returns a nullable array for convenience, but if the legacy APIs can be * refactored, returning {@link #mAdditionalTargets} directly is simpler and safer. */ @Nullable public Intent[] getAdditionalTargets() { return (mAdditionalTargets == null) ? null : mAdditionalTargets.toArray(new Intent[0]); } @Nullable public Bundle getReplacementExtras() { return mReplacementExtras; } /** * TODO: this returns a nullable array for convenience, but if the legacy APIs can be * refactored, returning {@link #mInitialIntents} directly is simpler and safer. */ @Nullable public Intent[] getInitialIntents() { return (mInitialIntents == null) ? null : mInitialIntents.toArray(new Intent[0]); } @Nullable public IntentSender getChosenComponentSender() { return mChosenComponentSender; } @Nullable public IntentSender getRefinementIntentSender() { return mRefinementIntentSender; } @Nullable public String getSharedText() { return mSharedText; } @Nullable public IntentFilter getTargetIntentFilter() { return mTargetIntentFilter; } @Nullable public CharSequence getMetadataText() { return mMetadataText; } private static boolean isSendAction(@Nullable String action) { return (Intent.ACTION_SEND.equals(action) || Intent.ACTION_SEND_MULTIPLE.equals(action)); } private static Intent parseTargetIntentExtra(@Nullable Parcelable targetParcelable) { if (targetParcelable instanceof Uri) { try { targetParcelable = Intent.parseUri(targetParcelable.toString(), Intent.URI_INTENT_SCHEME); } catch (URISyntaxException ex) { throw new IllegalArgumentException("Failed to parse EXTRA_INTENT from URI", ex); } } if (!(targetParcelable instanceof Intent)) { throw new IllegalArgumentException( "EXTRA_INTENT is neither an Intent nor a Uri: " + targetParcelable); } return ((Intent) targetParcelable); } private static Intent intentWithModifiedLaunchFlags(Intent intent) { if (isSendAction(intent.getAction())) { intent.addFlags(LAUNCH_FLAGS_FOR_SEND_ACTION); } return intent; } /** * Build a pair of values specifying the title to use from the client request. The first * ({@link CharSequence}) value is the client-specified title, if there was one and their * requested target wasn't a send action; otherwise it is null. The second value is * the resource ID of a default title string; this is nonzero only if the first value is null. * * TODO: change the API for how these are passed up to {@link ResolverActivity#onCreate}, or * create a real type (not {@link Pair}) to express the semantics described in this comment. */ private static Pair makeTitleSpec( @Nullable CharSequence requestedTitle, boolean hasSendActionTarget) { if (hasSendActionTarget && (requestedTitle != null)) { // Do not allow the title to be changed when sharing content Log.w(TAG, "Ignoring intent's EXTRA_TITLE, deprecated in P. You may wish to set a" + " preview title by using EXTRA_TITLE property of the wrapped" + " EXTRA_INTENT."); requestedTitle = null; } int defaultTitleRes = (requestedTitle == null) ? R.string.chooseActivity : 0; return Pair.create(requestedTitle, defaultTitleRes); } private static ImmutableList parseCallerTargetsFromClientIntent( Intent clientIntent) { return streamParcelableArrayExtra( clientIntent, Intent.EXTRA_CHOOSER_TARGETS, ChooserTarget.class, true, true) .collect(toImmutableList()); } @NonNull private static ImmutableList getChooserActions(Intent intent) { return streamParcelableArrayExtra( intent, Intent.EXTRA_CHOOSER_CUSTOM_ACTIONS, ChooserAction.class, true, true) .filter(UriFilters::hasValidIcon) .limit(MAX_CHOOSER_ACTIONS) .collect(toImmutableList()); } @Nullable private static ChooserAction getModifyShareAction(Intent intent) { try { return intent.getParcelableExtra( Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION, ChooserAction.class); } catch (Throwable t) { Log.w( TAG, "Unable to retrieve Intent.EXTRA_CHOOSER_MODIFY_SHARE_ACTION argument", t); return null; } } private static Collector> toImmutableList() { return Collectors.collectingAndThen(Collectors.toList(), ImmutableList::copyOf); } @Nullable private static ImmutableList intentsWithModifiedLaunchFlagsFromExtraIfPresent( Intent clientIntent, String extra) { Stream intents = streamParcelableArrayExtra(clientIntent, extra, Intent.class, true, false); if (intents == null) { return null; } return intents .map(ChooserRequestParameters::intentWithModifiedLaunchFlags) .collect(toImmutableList()); } /** * Make a {@link Stream} of the {@link Parcelable} objects given in the provided {@link Intent} * as the optional parcelable array extra with key {@code extra}. The stream elements, if any, * are all of the type specified by {@code clazz}. * * @param intent The intent that may contain the optional extras. * @param extra The extras key to identify the parcelable array. * @param clazz A class that is assignable from any elements in the result stream. * @param warnOnTypeError Whether to log a warning (and ignore) if the client extra doesn't have * the required type. If false, throw an {@link IllegalArgumentException} if the extra is * non-null but can't be assigned to variables of type {@code T}. * @param streamEmptyIfNull Whether to return an empty stream if the optional extra isn't * present in the intent (or if it had the wrong type, but warnOnTypeError is true). * If false, return null in these cases, and only return an empty stream if the intent * explicitly provided an empty array for the specified extra. */ @Nullable private static Stream streamParcelableArrayExtra( final Intent intent, String extra, @NonNull Class clazz, boolean warnOnTypeError, boolean streamEmptyIfNull) { T[] result = null; try { result = getParcelableArrayExtraIfPresent(intent, extra, clazz); } catch (IllegalArgumentException e) { if (warnOnTypeError) { Log.w(TAG, "Ignoring client-requested " + extra, e); } else { throw e; } } if (result != null) { return Arrays.stream(result); } else if (streamEmptyIfNull) { return Stream.empty(); } else { return null; } } /** * If the specified {@code extra} is provided in the {@code intent}, cast it to type {@code T[]} * or throw an {@code IllegalArgumentException} if the cast fails. If the {@code extra} isn't * present in the {@code intent}, return null. */ @Nullable private static T[] getParcelableArrayExtraIfPresent( final Intent intent, String extra, @NonNull Class clazz) throws IllegalArgumentException { if (!intent.hasExtra(extra)) { return null; } T[] castResult = intent.getParcelableArrayExtra(extra, clazz); if (castResult == null) { Parcelable[] actualExtrasArray = intent.getParcelableArrayExtra(extra); if (actualExtrasArray != null) { throw new IllegalArgumentException( String.format( "%s is not of type %s[]: %s", extra, clazz.getSimpleName(), Arrays.toString(actualExtrasArray))); } else if (intent.getParcelableExtra(extra) != null) { throw new IllegalArgumentException( String.format( "%s is not of type %s[] (or any array type): %s", extra, clazz.getSimpleName(), intent.getParcelableExtra(extra))); } else { throw new IllegalArgumentException( String.format( "%s is not of type %s (or any Parcelable type): %s", extra, clazz.getSimpleName(), intent.getExtras().get(extra))); } } return castResult; } private static IntentFilter getTargetIntentFilter(final Intent intent) { try { String dataString = intent.getDataString(); if (intent.getType() == null) { if (!TextUtils.isEmpty(dataString)) { return new IntentFilter(intent.getAction(), dataString); } Log.e(TAG, "Failed to get target intent filter: intent data and type are null"); return null; } IntentFilter intentFilter = new IntentFilter(intent.getAction(), intent.getType()); List contentUris = new ArrayList<>(); if (Intent.ACTION_SEND.equals(intent.getAction())) { Uri uri = (Uri) intent.getParcelableExtra(Intent.EXTRA_STREAM); if (uri != null) { contentUris.add(uri); } } else { List uris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM); if (uris != null) { contentUris.addAll(uris); } } for (Uri uri : contentUris) { intentFilter.addDataScheme(uri.getScheme()); intentFilter.addDataAuthority(uri.getAuthority(), null); intentFilter.addDataPath(uri.getPath(), PatternMatcher.PATTERN_LITERAL); } return intentFilter; } catch (Exception e) { Log.e(TAG, "Failed to get target intent filter", e); return null; } } }