1 /* 2 * Copyright (C) 2014 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package androidx.core.app; 18 19 import android.content.ClipData; 20 import android.content.ClipDescription; 21 import android.content.Intent; 22 import android.net.Uri; 23 import android.os.Build; 24 import android.os.Bundle; 25 import android.util.Log; 26 27 import androidx.annotation.RequiresApi; 28 29 import java.util.HashMap; 30 import java.util.HashSet; 31 import java.util.Map; 32 import java.util.Set; 33 34 /** 35 * Helper for using the {@link android.app.RemoteInput}. 36 */ 37 public final class RemoteInput { 38 private static final String TAG = "RemoteInput"; 39 40 /** Label used to denote the clip data type used for remote input transport */ 41 public static final String RESULTS_CLIP_LABEL = "android.remoteinput.results"; 42 43 /** Extra added to a clip data intent object to hold the text results bundle. */ 44 public static final String EXTRA_RESULTS_DATA = "android.remoteinput.resultsData"; 45 46 /** Extra added to a clip data intent object to hold the data results bundle. */ 47 private static final String EXTRA_DATA_TYPE_RESULTS_DATA = 48 "android.remoteinput.dataTypeResultsData"; 49 50 private final String mResultKey; 51 private final CharSequence mLabel; 52 private final CharSequence[] mChoices; 53 private final boolean mAllowFreeFormTextInput; 54 private final Bundle mExtras; 55 private final Set<String> mAllowedDataTypes; 56 57 RemoteInput(String resultKey, CharSequence label, CharSequence[] choices, 58 boolean allowFreeFormTextInput, Bundle extras, Set<String> allowedDataTypes) { 59 this.mResultKey = resultKey; 60 this.mLabel = label; 61 this.mChoices = choices; 62 this.mAllowFreeFormTextInput = allowFreeFormTextInput; 63 this.mExtras = extras; 64 this.mAllowedDataTypes = allowedDataTypes; 65 } 66 67 /** 68 * Get the key that the result of this input will be set in from the Bundle returned by 69 * {@link #getResultsFromIntent} when the {@link android.app.PendingIntent} is sent. 70 */ 71 public String getResultKey() { 72 return mResultKey; 73 } 74 75 /** 76 * Get the label to display to users when collecting this input. 77 */ 78 public CharSequence getLabel() { 79 return mLabel; 80 } 81 82 /** 83 * Get possible input choices. This can be {@code null} if there are no choices to present. 84 */ 85 public CharSequence[] getChoices() { 86 return mChoices; 87 } 88 89 public Set<String> getAllowedDataTypes() { 90 return mAllowedDataTypes; 91 } 92 93 /** 94 * Returns true if the input only accepts data, meaning {@link #getAllowFreeFormInput} 95 * is false, {@link #getChoices} is null or empty, and {@link #getAllowedDataTypes is 96 * non-null and not empty. 97 */ 98 public boolean isDataOnly() { 99 return !getAllowFreeFormInput() 100 && (getChoices() == null || getChoices().length == 0) 101 && getAllowedDataTypes() != null 102 && !getAllowedDataTypes().isEmpty(); 103 } 104 105 /** 106 * Get whether or not users can provide an arbitrary value for 107 * input. If you set this to {@code false}, users must select one of the 108 * choices in {@link #getChoices}. An {@link IllegalArgumentException} is thrown 109 * if you set this to false and {@link #getChoices} returns {@code null} or empty. 110 */ 111 public boolean getAllowFreeFormInput() { 112 return mAllowFreeFormTextInput; 113 } 114 115 /** 116 * Get additional metadata carried around with this remote input. 117 */ 118 public Bundle getExtras() { 119 return mExtras; 120 } 121 122 /** 123 * Builder class for {@link androidx.core.app.RemoteInput} objects. 124 */ 125 public static final class Builder { 126 private final String mResultKey; 127 private CharSequence mLabel; 128 private CharSequence[] mChoices; 129 private boolean mAllowFreeFormTextInput = true; 130 private Bundle mExtras = new Bundle(); 131 private final Set<String> mAllowedDataTypes = new HashSet<>(); 132 133 /** 134 * Create a builder object for {@link androidx.core.app.RemoteInput} objects. 135 * @param resultKey the Bundle key that refers to this input when collected from the user 136 */ 137 public Builder(String resultKey) { 138 if (resultKey == null) { 139 throw new IllegalArgumentException("Result key can't be null"); 140 } 141 mResultKey = resultKey; 142 } 143 144 /** 145 * Set a label to be displayed to the user when collecting this input. 146 * @param label The label to show to users when they input a response. 147 * @return this object for method chaining 148 */ 149 public Builder setLabel(CharSequence label) { 150 mLabel = label; 151 return this; 152 } 153 154 /** 155 * Specifies choices available to the user to satisfy this input. 156 * @param choices an array of pre-defined choices for users input. 157 * You must provide a non-null and non-empty array if 158 * you disabled free form input using {@link #setAllowFreeFormInput}. 159 * @return this object for method chaining 160 */ 161 public Builder setChoices(CharSequence[] choices) { 162 mChoices = choices; 163 return this; 164 } 165 166 /** 167 * Specifies whether the user can provide arbitrary values. 168 * 169 * @param mimeType A mime type that results are allowed to come in. 170 * Be aware that text results (see {@link #setAllowFreeFormInput} 171 * are allowed by default. If you do not want text results you will have to 172 * pass false to {@code setAllowFreeFormInput}. 173 * @param doAllow Whether the mime type should be allowed or not. 174 * @return this object for method chaining 175 */ 176 public Builder setAllowDataType(String mimeType, boolean doAllow) { 177 if (doAllow) { 178 mAllowedDataTypes.add(mimeType); 179 } else { 180 mAllowedDataTypes.remove(mimeType); 181 } 182 return this; 183 } 184 185 /** 186 * Specifies whether the user can provide arbitrary text values. 187 * 188 * @param allowFreeFormTextInput The default is {@code true}. 189 * If you specify {@code false}, you must either provide a non-null 190 * and non-empty array to {@link #setChoices}, or enable a data result 191 * in {@code setAllowDataType}. Otherwise an 192 * {@link IllegalArgumentException} is thrown. 193 * @return this object for method chaining 194 */ 195 public Builder setAllowFreeFormInput(boolean allowFreeFormTextInput) { 196 mAllowFreeFormTextInput = allowFreeFormTextInput; 197 return this; 198 } 199 200 /** 201 * Merge additional metadata into this builder. 202 * 203 * <p>Values within the Bundle will replace existing extras values in this Builder. 204 * 205 * @see RemoteInput#getExtras 206 */ 207 public Builder addExtras(Bundle extras) { 208 if (extras != null) { 209 mExtras.putAll(extras); 210 } 211 return this; 212 } 213 214 /** 215 * Get the metadata Bundle used by this Builder. 216 * 217 * <p>The returned Bundle is shared with this Builder. 218 */ 219 public Bundle getExtras() { 220 return mExtras; 221 } 222 223 /** 224 * Combine all of the options that have been set and return a new 225 * {@link androidx.core.app.RemoteInput} object. 226 */ 227 public RemoteInput build() { 228 return new RemoteInput( 229 mResultKey, 230 mLabel, 231 mChoices, 232 mAllowFreeFormTextInput, 233 mExtras, 234 mAllowedDataTypes); 235 } 236 } 237 238 /** 239 * Similar as {@link #getResultsFromIntent} but retrieves data results for a 240 * specific RemoteInput result. To retrieve a value use: 241 * <pre> 242 * {@code 243 * Map<String, Uri> results = 244 * RemoteInput.getDataResultsFromIntent(intent, REMOTE_INPUT_KEY); 245 * if (results != null) { 246 * Uri data = results.get(MIME_TYPE_OF_INTEREST); 247 * } 248 * } 249 * </pre> 250 * @param intent The intent object that fired in response to an action or content intent 251 * which also had one or more remote input requested. 252 * @param remoteInputResultKey The result key for the RemoteInput you want results for. 253 */ 254 public static Map<String, Uri> getDataResultsFromIntent( 255 Intent intent, String remoteInputResultKey) { 256 if (Build.VERSION.SDK_INT >= 26) { 257 return android.app.RemoteInput.getDataResultsFromIntent(intent, remoteInputResultKey); 258 } else if (Build.VERSION.SDK_INT >= 16) { 259 Intent clipDataIntent = getClipDataIntentFromIntent(intent); 260 if (clipDataIntent == null) { 261 return null; 262 } 263 Map<String, Uri> results = new HashMap<>(); 264 Bundle extras = clipDataIntent.getExtras(); 265 for (String key : extras.keySet()) { 266 if (key.startsWith(EXTRA_DATA_TYPE_RESULTS_DATA)) { 267 String mimeType = key.substring(EXTRA_DATA_TYPE_RESULTS_DATA.length()); 268 if (mimeType.isEmpty()) { 269 continue; 270 } 271 Bundle bundle = clipDataIntent.getBundleExtra(key); 272 String uriStr = bundle.getString(remoteInputResultKey); 273 if (uriStr == null || uriStr.isEmpty()) { 274 continue; 275 } 276 results.put(mimeType, Uri.parse(uriStr)); 277 } 278 } 279 return results.isEmpty() ? null : results; 280 } else { 281 Log.w(TAG, "RemoteInput is only supported from API Level 16"); 282 return null; 283 } 284 } 285 286 /** 287 * Get the remote input text results bundle from an intent. The returned Bundle will 288 * contain a key/value for every result key populated by remote input collector. 289 * Use the {@link Bundle#getCharSequence(String)} method to retrieve a value. For data results 290 * use {@link #getDataResultsFromIntent}. 291 * @param intent The intent object that fired in response to an action or content intent 292 * which also had one or more remote input requested. 293 */ 294 public static Bundle getResultsFromIntent(Intent intent) { 295 if (Build.VERSION.SDK_INT >= 20) { 296 return android.app.RemoteInput.getResultsFromIntent(intent); 297 } else if (Build.VERSION.SDK_INT >= 16) { 298 Intent clipDataIntent = getClipDataIntentFromIntent(intent); 299 if (clipDataIntent == null) { 300 return null; 301 } 302 return clipDataIntent.getExtras().getParcelable(RemoteInput.EXTRA_RESULTS_DATA); 303 } else { 304 Log.w(TAG, "RemoteInput is only supported from API Level 16"); 305 return null; 306 } 307 } 308 309 /** 310 * Populate an intent object with the results gathered from remote input. This method 311 * should only be called by remote input collection services when sending results to a 312 * pending intent. 313 * @param remoteInputs The remote inputs for which results are being provided 314 * @param intent The intent to add remote inputs to. The {@link android.content.ClipData} 315 * field of the intent will be modified to contain the results. 316 * @param results A bundle holding the remote input results. This bundle should 317 * be populated with keys matching the result keys specified in 318 * {@code remoteInputs} with values being the result per key. 319 */ 320 public static void addResultsToIntent(RemoteInput[] remoteInputs, Intent intent, 321 Bundle results) { 322 if (Build.VERSION.SDK_INT >= 26) { 323 android.app.RemoteInput.addResultsToIntent(fromCompat(remoteInputs), intent, results); 324 } else if (Build.VERSION.SDK_INT >= 20) { 325 // Implementations of RemoteInput#addResultsToIntent prior to SDK 26 don't actually add 326 // results, they wipe out old results and insert the new one. Work around that by 327 // preserving old results. 328 Bundle existingTextResults = 329 androidx.core.app.RemoteInput.getResultsFromIntent(intent); 330 if (existingTextResults == null) { 331 existingTextResults = results; 332 } else { 333 existingTextResults.putAll(results); 334 } 335 for (RemoteInput input : remoteInputs) { 336 // Data results are also wiped out. So grab them and add them back in. 337 Map<String, Uri> existingDataResults = 338 androidx.core.app.RemoteInput.getDataResultsFromIntent( 339 intent, input.getResultKey()); 340 RemoteInput[] arr = new RemoteInput[1]; 341 arr[0] = input; 342 android.app.RemoteInput.addResultsToIntent( 343 fromCompat(arr), intent, existingTextResults); 344 if (existingDataResults != null) { 345 RemoteInput.addDataResultToIntent(input, intent, existingDataResults); 346 } 347 } 348 } else if (Build.VERSION.SDK_INT >= 16) { 349 Intent clipDataIntent = getClipDataIntentFromIntent(intent); 350 if (clipDataIntent == null) { 351 clipDataIntent = new Intent(); // First time we've added a result. 352 } 353 Bundle resultsBundle = clipDataIntent.getBundleExtra(RemoteInput.EXTRA_RESULTS_DATA); 354 if (resultsBundle == null) { 355 resultsBundle = new Bundle(); 356 } 357 for (RemoteInput remoteInput : remoteInputs) { 358 Object result = results.get(remoteInput.getResultKey()); 359 if (result instanceof CharSequence) { 360 resultsBundle.putCharSequence( 361 remoteInput.getResultKey(), (CharSequence) result); 362 } 363 } 364 clipDataIntent.putExtra(RemoteInput.EXTRA_RESULTS_DATA, resultsBundle); 365 intent.setClipData(ClipData.newIntent(RemoteInput.RESULTS_CLIP_LABEL, clipDataIntent)); 366 } else { 367 Log.w(TAG, "RemoteInput is only supported from API Level 16"); 368 } 369 } 370 371 /** 372 * Same as {@link #addResultsToIntent} but for setting data results. 373 * @param remoteInput The remote input for which results are being provided 374 * @param intent The intent to add remote input results to. The 375 * {@link android.content.ClipData} field of the intent will be 376 * modified to contain the results. 377 * @param results A map of mime type to the Uri result for that mime type. 378 */ 379 public static void addDataResultToIntent(RemoteInput remoteInput, Intent intent, 380 Map<String, Uri> results) { 381 if (Build.VERSION.SDK_INT >= 26) { 382 android.app.RemoteInput.addDataResultToIntent(fromCompat(remoteInput), intent, results); 383 } else if (Build.VERSION.SDK_INT >= 16) { 384 Intent clipDataIntent = getClipDataIntentFromIntent(intent); 385 if (clipDataIntent == null) { 386 clipDataIntent = new Intent(); // First time we've added a result. 387 } 388 for (Map.Entry<String, Uri> entry : results.entrySet()) { 389 String mimeType = entry.getKey(); 390 Uri uri = entry.getValue(); 391 if (mimeType == null) { 392 continue; 393 } 394 Bundle resultsBundle = 395 clipDataIntent.getBundleExtra(getExtraResultsKeyForData(mimeType)); 396 if (resultsBundle == null) { 397 resultsBundle = new Bundle(); 398 } 399 resultsBundle.putString(remoteInput.getResultKey(), uri.toString()); 400 clipDataIntent.putExtra(getExtraResultsKeyForData(mimeType), resultsBundle); 401 } 402 intent.setClipData(ClipData.newIntent(RemoteInput.RESULTS_CLIP_LABEL, clipDataIntent)); 403 } else { 404 Log.w(TAG, "RemoteInput is only supported from API Level 16"); 405 } 406 } 407 408 private static String getExtraResultsKeyForData(String mimeType) { 409 return EXTRA_DATA_TYPE_RESULTS_DATA + mimeType; 410 } 411 412 @RequiresApi(20) 413 static android.app.RemoteInput[] fromCompat(RemoteInput[] srcArray) { 414 if (srcArray == null) { 415 return null; 416 } 417 android.app.RemoteInput[] result = new android.app.RemoteInput[srcArray.length]; 418 for (int i = 0; i < srcArray.length; i++) { 419 result[i] = fromCompat(srcArray[i]); 420 } 421 return result; 422 } 423 424 @RequiresApi(20) 425 static android.app.RemoteInput fromCompat(RemoteInput src) { 426 return new android.app.RemoteInput.Builder(src.getResultKey()) 427 .setLabel(src.getLabel()) 428 .setChoices(src.getChoices()) 429 .setAllowFreeFormInput(src.getAllowFreeFormInput()) 430 .addExtras(src.getExtras()) 431 .build(); 432 } 433 434 @RequiresApi(16) 435 private static Intent getClipDataIntentFromIntent(Intent intent) { 436 ClipData clipData = intent.getClipData(); 437 if (clipData == null) { 438 return null; 439 } 440 ClipDescription clipDescription = clipData.getDescription(); 441 if (!clipDescription.hasMimeType(ClipDescription.MIMETYPE_TEXT_INTENT)) { 442 return null; 443 } 444 if (!clipDescription.getLabel().equals(RemoteInput.RESULTS_CLIP_LABEL)) { 445 return null; 446 } 447 return clipData.getItemAt(0).getIntent(); 448 } 449 } 450