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