/* * Copyright (C) 2014 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 android.app; import android.annotation.IntDef; import android.annotation.NonNull; import android.annotation.Nullable; import android.content.ClipData; import android.content.ClipDescription; import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.os.Parcel; import android.os.Parcelable; import android.util.ArraySet; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.HashMap; import java.util.Map; import java.util.Set; /** * A {@code RemoteInput} object specifies input to be collected from a user to be passed along with * an intent inside a {@link android.app.PendingIntent} that is sent. * Always use {@link RemoteInput.Builder} to create instances of this class. *

See * Replying * to notifications for more information on how to use this class. * *

The following example adds a {@code RemoteInput} to a {@link Notification.Action}, * sets the result key as {@code quick_reply}, and sets the label as {@code Quick reply}. * Users are prompted to input a response when they trigger the action. The results are sent along * with the intent and can be retrieved with the result key (provided to the {@link Builder} * constructor) from the Bundle returned by {@link #getResultsFromIntent}. * *

 * public static final String KEY_QUICK_REPLY_TEXT = "quick_reply";
 * Notification.Action action = new Notification.Action.Builder(
 *         R.drawable.reply, "Reply", actionIntent)
 *         .addRemoteInput(new RemoteInput.Builder(KEY_QUICK_REPLY_TEXT)
 *                 .setLabel("Quick reply").build())
 *         .build();
* *

When the {@link android.app.PendingIntent} is fired, the intent inside will contain the * input results if collected. To access these results, use the {@link #getResultsFromIntent} * function. The result values will present under the result key passed to the {@link Builder} * constructor. * *

 * public static final String KEY_QUICK_REPLY_TEXT = "quick_reply";
 * Bundle results = RemoteInput.getResultsFromIntent(intent);
 * if (results != null) {
 *     CharSequence quickReplyResult = results.getCharSequence(KEY_QUICK_REPLY_TEXT);
 * }
*/ public final class RemoteInput implements Parcelable { /** Label used to denote the clip data type used for remote input transport */ public static final String RESULTS_CLIP_LABEL = "android.remoteinput.results"; /** Extra added to a clip data intent object to hold the text results bundle. */ public static final String EXTRA_RESULTS_DATA = "android.remoteinput.resultsData"; /** Extra added to a clip data intent object to hold the data results bundle. */ private static final String EXTRA_DATA_TYPE_RESULTS_DATA = "android.remoteinput.dataTypeResultsData"; /** Extra added to a clip data intent object identifying the {@link Source} of the results. */ private static final String EXTRA_RESULTS_SOURCE = "android.remoteinput.resultsSource"; /** @hide */ @IntDef(prefix = {"SOURCE_"}, value = {SOURCE_FREE_FORM_INPUT, SOURCE_CHOICE}) @Retention(RetentionPolicy.SOURCE) public @interface Source {} /** The user manually entered the data. */ public static final int SOURCE_FREE_FORM_INPUT = 0; /** The user selected one of the choices from {@link #getChoices}. */ public static final int SOURCE_CHOICE = 1; // Flags bitwise-ored to mFlags private static final int FLAG_ALLOW_FREE_FORM_INPUT = 0x1; // Default value for flags integer private static final int DEFAULT_FLAGS = FLAG_ALLOW_FREE_FORM_INPUT; private final String mResultKey; private final CharSequence mLabel; private final CharSequence[] mChoices; private final int mFlags; private final Bundle mExtras; private final ArraySet mAllowedDataTypes; private RemoteInput(String resultKey, CharSequence label, CharSequence[] choices, int flags, Bundle extras, ArraySet allowedDataTypes) { this.mResultKey = resultKey; this.mLabel = label; this.mChoices = choices; this.mFlags = flags; this.mExtras = extras; this.mAllowedDataTypes = allowedDataTypes; } /** * Get the key that the result of this input will be set in from the Bundle returned by * {@link #getResultsFromIntent} when the {@link android.app.PendingIntent} is sent. */ public String getResultKey() { return mResultKey; } /** * Get the label to display to users when collecting this input. */ public CharSequence getLabel() { return mLabel; } /** * Get possible input choices. This can be {@code null} if there are no choices to present. */ public CharSequence[] getChoices() { return mChoices; } /** * Get possible non-textual inputs that are accepted. * This can be {@code null} if the input does not accept non-textual values. * See {@link Builder#setAllowDataType}. */ public Set getAllowedDataTypes() { return mAllowedDataTypes; } /** * Returns true if the input only accepts data, meaning {@link #getAllowFreeFormInput} * is false, {@link #getChoices} is null or empty, and {@link #getAllowedDataTypes is * non-null and not empty. */ public boolean isDataOnly() { return !getAllowFreeFormInput() && (getChoices() == null || getChoices().length == 0) && !getAllowedDataTypes().isEmpty(); } /** * Get whether or not users can provide an arbitrary value for * input. If you set this to {@code false}, users must select one of the * choices in {@link #getChoices}. An {@link IllegalArgumentException} is thrown * if you set this to false and {@link #getChoices} returns {@code null} or empty. */ public boolean getAllowFreeFormInput() { return (mFlags & FLAG_ALLOW_FREE_FORM_INPUT) != 0; } /** * Get additional metadata carried around with this remote input. */ public Bundle getExtras() { return mExtras; } /** * Builder class for {@link RemoteInput} objects. */ public static final class Builder { private final String mResultKey; private final ArraySet mAllowedDataTypes = new ArraySet<>(); private final Bundle mExtras = new Bundle(); private CharSequence mLabel; private CharSequence[] mChoices; private int mFlags = DEFAULT_FLAGS; /** * Create a builder object for {@link RemoteInput} objects. * * @param resultKey the Bundle key that refers to this input when collected from the user */ public Builder(@NonNull String resultKey) { if (resultKey == null) { throw new IllegalArgumentException("Result key can't be null"); } mResultKey = resultKey; } /** * Set a label to be displayed to the user when collecting this input. * * @param label The label to show to users when they input a response * @return this object for method chaining */ @NonNull public Builder setLabel(@Nullable CharSequence label) { mLabel = Notification.safeCharSequence(label); return this; } /** * Specifies choices available to the user to satisfy this input. * *

Note: Starting in Android P, these choices will always be shown on phones if the app's * target SDK is >= P. However, these choices may also be rendered on other types of devices * regardless of target SDK. * * @param choices an array of pre-defined choices for users input. * You must provide a non-null and non-empty array if * you disabled free form input using {@link #setAllowFreeFormInput} * @return this object for method chaining */ @NonNull public Builder setChoices(@Nullable CharSequence[] choices) { if (choices == null) { mChoices = null; } else { mChoices = new CharSequence[choices.length]; for (int i = 0; i < choices.length; i++) { mChoices[i] = Notification.safeCharSequence(choices[i]); } } return this; } /** * Specifies whether the user can provide arbitrary values. This allows an input * to accept non-textual values. Examples of usage are an input that wants audio * or an image. * * @param mimeType A mime type that results are allowed to come in. * Be aware that text results (see {@link #setAllowFreeFormInput} * are allowed by default. If you do not want text results you will have to * pass false to {@code setAllowFreeFormInput} * @param doAllow Whether the mime type should be allowed or not * @return this object for method chaining */ @NonNull public Builder setAllowDataType(@NonNull String mimeType, boolean doAllow) { if (doAllow) { mAllowedDataTypes.add(mimeType); } else { mAllowedDataTypes.remove(mimeType); } return this; } /** * Specifies whether the user can provide arbitrary text values. * * @param allowFreeFormTextInput The default is {@code true}. * If you specify {@code false}, you must either provide a non-null * and non-empty array to {@link #setChoices}, or enable a data result * in {@code setAllowDataType}. Otherwise an * {@link IllegalArgumentException} is thrown * @return this object for method chaining */ @NonNull public Builder setAllowFreeFormInput(boolean allowFreeFormTextInput) { setFlag(mFlags, allowFreeFormTextInput); return this; } /** * Merge additional metadata into this builder. * *

Values within the Bundle will replace existing extras values in this Builder. * * @see RemoteInput#getExtras */ @NonNull public Builder addExtras(@NonNull Bundle extras) { if (extras != null) { mExtras.putAll(extras); } return this; } /** * Get the metadata Bundle used by this Builder. * *

The returned Bundle is shared with this Builder. */ @NonNull public Bundle getExtras() { return mExtras; } private void setFlag(int mask, boolean value) { if (value) { mFlags |= mask; } else { mFlags &= ~mask; } } /** * Combine all of the options that have been set and return a new {@link RemoteInput} * object. */ @NonNull public RemoteInput build() { return new RemoteInput( mResultKey, mLabel, mChoices, mFlags, mExtras, mAllowedDataTypes); } } private RemoteInput(Parcel in) { mResultKey = in.readString(); mLabel = in.readCharSequence(); mChoices = in.readCharSequenceArray(); mFlags = in.readInt(); mExtras = in.readBundle(); mAllowedDataTypes = (ArraySet) in.readArraySet(null); } /** * Similar as {@link #getResultsFromIntent} but retrieves data results for a * specific RemoteInput result. To retrieve a value use: *

     * {@code
     * Map results =
     *     RemoteInput.getDataResultsFromIntent(intent, REMOTE_INPUT_KEY);
     * if (results != null) {
     *   Uri data = results.get(MIME_TYPE_OF_INTEREST);
     * }
     * }
     * 
* @param intent The intent object that fired in response to an action or content intent * which also had one or more remote input requested. * @param remoteInputResultKey The result key for the RemoteInput you want results for. */ public static Map getDataResultsFromIntent( Intent intent, String remoteInputResultKey) { Intent clipDataIntent = getClipDataIntentFromIntent(intent); if (clipDataIntent == null) { return null; } Map results = new HashMap<>(); Bundle extras = clipDataIntent.getExtras(); for (String key : extras.keySet()) { if (key.startsWith(EXTRA_DATA_TYPE_RESULTS_DATA)) { String mimeType = key.substring(EXTRA_DATA_TYPE_RESULTS_DATA.length()); if (mimeType == null || mimeType.isEmpty()) { continue; } Bundle bundle = clipDataIntent.getBundleExtra(key); String uriStr = bundle.getString(remoteInputResultKey); if (uriStr == null || uriStr.isEmpty()) { continue; } results.put(mimeType, Uri.parse(uriStr)); } } return results.isEmpty() ? null : results; } /** * Get the remote input text results bundle from an intent. The returned Bundle will * contain a key/value for every result key populated with text by remote input collector. * Use the {@link Bundle#getCharSequence(String)} method to retrieve a value. For non-text * results use {@link #getDataResultsFromIntent}. * @param intent The intent object that fired in response to an action or content intent * which also had one or more remote input requested. */ public static Bundle getResultsFromIntent(Intent intent) { Intent clipDataIntent = getClipDataIntentFromIntent(intent); if (clipDataIntent == null) { return null; } return clipDataIntent.getExtras().getParcelable(EXTRA_RESULTS_DATA); } /** * Populate an intent object with the text results gathered from remote input. This method * should only be called by remote input collection services when sending results to a * pending intent. * @param remoteInputs The remote inputs for which results are being provided * @param intent The intent to add remote inputs to. The {@link ClipData} * field of the intent will be modified to contain the results. * @param results A bundle holding the remote input results. This bundle should * be populated with keys matching the result keys specified in * {@code remoteInputs} with values being the CharSequence results per key. */ public static void addResultsToIntent(RemoteInput[] remoteInputs, Intent intent, Bundle results) { Intent clipDataIntent = getClipDataIntentFromIntent(intent); if (clipDataIntent == null) { clipDataIntent = new Intent(); // First time we've added a result. } Bundle resultsBundle = clipDataIntent.getBundleExtra(EXTRA_RESULTS_DATA); if (resultsBundle == null) { resultsBundle = new Bundle(); } for (RemoteInput remoteInput : remoteInputs) { Object result = results.get(remoteInput.getResultKey()); if (result instanceof CharSequence) { resultsBundle.putCharSequence(remoteInput.getResultKey(), (CharSequence) result); } } clipDataIntent.putExtra(EXTRA_RESULTS_DATA, resultsBundle); intent.setClipData(ClipData.newIntent(RESULTS_CLIP_LABEL, clipDataIntent)); } /** * Same as {@link #addResultsToIntent} but for setting data results. This is used * for inputs that accept non-textual results (see {@link Builder#setAllowDataType}). * Only one result can be provided for every mime type accepted by the RemoteInput. * If multiple inputs of the same mime type are expected then multiple RemoteInputs * should be used. * * @param remoteInput The remote input for which results are being provided * @param intent The intent to add remote input results to. The {@link ClipData} * field of the intent will be modified to contain the results. * @param results A map of mime type to the Uri result for that mime type. */ public static void addDataResultToIntent(RemoteInput remoteInput, Intent intent, Map results) { Intent clipDataIntent = getClipDataIntentFromIntent(intent); if (clipDataIntent == null) { clipDataIntent = new Intent(); // First time we've added a result. } for (Map.Entry entry : results.entrySet()) { String mimeType = entry.getKey(); Uri uri = entry.getValue(); if (mimeType == null) { continue; } Bundle resultsBundle = clipDataIntent.getBundleExtra(getExtraResultsKeyForData(mimeType)); if (resultsBundle == null) { resultsBundle = new Bundle(); } resultsBundle.putString(remoteInput.getResultKey(), uri.toString()); clipDataIntent.putExtra(getExtraResultsKeyForData(mimeType), resultsBundle); } intent.setClipData(ClipData.newIntent(RESULTS_CLIP_LABEL, clipDataIntent)); } /** * Set the source of the RemoteInput results. This method should only be called by remote * input collection services (e.g. * {@link android.service.notification.NotificationListenerService}) * when sending results to a pending intent. * * @see #SOURCE_FREE_FORM_INPUT * @see #SOURCE_CHOICE * * @param intent The intent to add remote input source to. The {@link ClipData} * field of the intent will be modified to contain the source. * @param source The source of the results. */ public static void setResultsSource(Intent intent, @Source int source) { Intent clipDataIntent = getClipDataIntentFromIntent(intent); if (clipDataIntent == null) { clipDataIntent = new Intent(); // First time we've added a result. } clipDataIntent.putExtra(EXTRA_RESULTS_SOURCE, source); intent.setClipData(ClipData.newIntent(RESULTS_CLIP_LABEL, clipDataIntent)); } /** * Get the source of the RemoteInput results. * * @see #SOURCE_FREE_FORM_INPUT * @see #SOURCE_CHOICE * * @param intent The intent object that fired in response to an action or content intent * which also had one or more remote input requested. * @return The source of the results. If no source was set, {@link #SOURCE_FREE_FORM_INPUT} will * be returned. */ @Source public static int getResultsSource(Intent intent) { Intent clipDataIntent = getClipDataIntentFromIntent(intent); if (clipDataIntent == null) { return SOURCE_FREE_FORM_INPUT; } return clipDataIntent.getExtras().getInt(EXTRA_RESULTS_SOURCE, SOURCE_FREE_FORM_INPUT); } private static String getExtraResultsKeyForData(String mimeType) { return EXTRA_DATA_TYPE_RESULTS_DATA + mimeType; } @Override public int describeContents() { return 0; } @Override public void writeToParcel(Parcel out, int flags) { out.writeString(mResultKey); out.writeCharSequence(mLabel); out.writeCharSequenceArray(mChoices); out.writeInt(mFlags); out.writeBundle(mExtras); out.writeArraySet(mAllowedDataTypes); } public static final Creator CREATOR = new Creator() { @Override public RemoteInput createFromParcel(Parcel in) { return new RemoteInput(in); } @Override public RemoteInput[] newArray(int size) { return new RemoteInput[size]; } }; private static Intent getClipDataIntentFromIntent(Intent intent) { ClipData clipData = intent.getClipData(); if (clipData == null) { return null; } ClipDescription clipDescription = clipData.getDescription(); if (!clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_INTENT)) { return null; } if (!clipDescription.getLabel().equals(RESULTS_CLIP_LABEL)) { return null; } return clipData.getItemAt(0).getIntent(); } }