1 /*
2  * Copyright (C) 2013 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 com.example.android.vault;
18 
19 import static com.example.android.vault.EncryptedDocument.DATA_KEY_LENGTH;
20 import static com.example.android.vault.EncryptedDocument.MAC_KEY_LENGTH;
21 import static com.example.android.vault.Utils.closeQuietly;
22 import static com.example.android.vault.Utils.closeWithErrorQuietly;
23 import static com.example.android.vault.Utils.readFully;
24 import static com.example.android.vault.Utils.writeFully;
25 
26 import android.content.Context;
27 import android.content.SharedPreferences;
28 import android.database.Cursor;
29 import android.database.MatrixCursor;
30 import android.database.MatrixCursor.RowBuilder;
31 import android.os.Bundle;
32 import android.os.CancellationSignal;
33 import android.os.ParcelFileDescriptor;
34 import android.provider.DocumentsContract;
35 import android.provider.DocumentsContract.Document;
36 import android.provider.DocumentsContract.Root;
37 import android.provider.DocumentsProvider;
38 import android.security.KeyChain;
39 import android.text.TextUtils;
40 import android.util.Log;
41 
42 import org.json.JSONArray;
43 import org.json.JSONException;
44 import org.json.JSONObject;
45 
46 import java.io.File;
47 import java.io.FileNotFoundException;
48 import java.io.IOException;
49 import java.nio.charset.StandardCharsets;
50 import java.security.GeneralSecurityException;
51 import java.security.KeyStore;
52 import java.security.SecureRandom;
53 
54 import javax.crypto.Mac;
55 import javax.crypto.SecretKey;
56 import javax.crypto.spec.SecretKeySpec;
57 
58 /**
59  * Provider that encrypts both metadata and contents of documents stored inside.
60  * Each document is stored as described by {@link EncryptedDocument} with
61  * separate metadata and content sections. Directories are just
62  * {@link EncryptedDocument} instances without a content section, and a list of
63  * child documents included in the metadata section.
64  * <p>
65  * All content is encrypted/decrypted on demand through pipes, using
66  * {@link ParcelFileDescriptor#createReliablePipe()} to detect and recover from
67  * remote crashes and errors.
68  * <p>
69  * Our symmetric encryption key is stored on disk only after using
70  * {@link SecretKeyWrapper} to "wrap" it using another public/private key pair
71  * stored in the platform {@link KeyStore}. This allows us to protect our
72  * symmetric key with hardware-backed keys, if supported. Devices without
73  * hardware support still encrypt their keys while at rest, and the platform
74  * always requires a user to present a PIN, password, or pattern to unlock the
75  * KeyStore before use.
76  */
77 public class VaultProvider extends DocumentsProvider {
78     public static final String TAG = "Vault";
79 
80     static final String AUTHORITY = "com.example.android.vault.provider";
81 
82     static final String DEFAULT_ROOT_ID = "vault";
83     static final String DEFAULT_DOCUMENT_ID = "0";
84 
85     /** JSON key storing array of all children documents in a directory. */
86     private static final String KEY_CHILDREN = "vault:children";
87 
88     /** Key pointing to next available document ID. */
89     private static final String PREF_NEXT_ID = "next_id";
90 
91     /** Blob used to derive {@link #mDataKey} from our secret key. */
92     private static final byte[] BLOB_DATA = "DATA".getBytes(StandardCharsets.UTF_8);
93     /** Blob used to derive {@link #mMacKey} from our secret key. */
94     private static final byte[] BLOB_MAC = "MAC".getBytes(StandardCharsets.UTF_8);
95 
96     private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
97             Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE,
98             Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES, Root.COLUMN_SUMMARY
99     };
100 
101     private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] {
102             Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME,
103             Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE,
104     };
105 
resolveRootProjection(String[] projection)106     private static String[] resolveRootProjection(String[] projection) {
107         return projection != null ? projection : DEFAULT_ROOT_PROJECTION;
108     }
109 
resolveDocumentProjection(String[] projection)110     private static String[] resolveDocumentProjection(String[] projection) {
111         return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION;
112     }
113 
114     private final Object mIdLock = new Object();
115 
116     /**
117      * Flag indicating that the {@link SecretKeyWrapper} public/private key is
118      * hardware-backed. A software keystore is more vulnerable to offline
119      * attacks if the device is compromised.
120      */
121     private boolean mHardwareBacked;
122 
123     /** File where wrapped symmetric key is stored. */
124     private File mKeyFile;
125     /** Directory where all encrypted documents are stored. */
126     private File mDocumentsDir;
127 
128     private SecretKey mDataKey;
129     private SecretKey mMacKey;
130 
131     @Override
onCreate()132     public boolean onCreate() {
133         mHardwareBacked = KeyChain.isBoundKeyAlgorithm("RSA");
134 
135         mKeyFile = new File(getContext().getFilesDir(), "vault.key");
136         mDocumentsDir = new File(getContext().getFilesDir(), "documents");
137         mDocumentsDir.mkdirs();
138 
139         try {
140             // Load secret key and ensure our root document is ready.
141             loadOrGenerateKeys(getContext(), mKeyFile);
142             initDocument(Long.parseLong(DEFAULT_DOCUMENT_ID), Document.MIME_TYPE_DIR, null);
143 
144         } catch (IOException e) {
145             throw new IllegalStateException(e);
146         } catch (GeneralSecurityException e) {
147             throw new IllegalStateException(e);
148         }
149 
150         return true;
151     }
152 
153     /**
154      * Used for testing.
155      */
wipeAllContents()156     void wipeAllContents() throws IOException, GeneralSecurityException {
157         for (File f : mDocumentsDir.listFiles()) {
158             f.delete();
159         }
160 
161         initDocument(Long.parseLong(DEFAULT_DOCUMENT_ID), Document.MIME_TYPE_DIR, null);
162     }
163 
164     /**
165      * Load our symmetric secret key and use it to derive two different data and
166      * MAC keys. The symmetric secret key is stored securely on disk by wrapping
167      * it with a public/private key pair, possibly backed by hardware.
168      */
loadOrGenerateKeys(Context context, File keyFile)169     private void loadOrGenerateKeys(Context context, File keyFile)
170             throws GeneralSecurityException, IOException {
171         final SecretKeyWrapper wrapper = new SecretKeyWrapper(context, TAG);
172 
173         // Generate secret key if none exists
174         if (!keyFile.exists()) {
175             final byte[] raw = new byte[DATA_KEY_LENGTH];
176             new SecureRandom().nextBytes(raw);
177 
178             final SecretKey key = new SecretKeySpec(raw, "AES");
179             final byte[] wrapped = wrapper.wrap(key);
180 
181             writeFully(keyFile, wrapped);
182         }
183 
184         // Even if we just generated the key, always read it back to ensure we
185         // can read it successfully.
186         final byte[] wrapped = readFully(keyFile);
187         final SecretKey key = wrapper.unwrap(wrapped);
188 
189         final Mac mac = Mac.getInstance("HmacSHA256");
190         mac.init(key);
191 
192         // Derive two different keys for encryption and authentication.
193         final byte[] rawDataKey = new byte[DATA_KEY_LENGTH];
194         final byte[] rawMacKey = new byte[MAC_KEY_LENGTH];
195 
196         System.arraycopy(mac.doFinal(BLOB_DATA), 0, rawDataKey, 0, rawDataKey.length);
197         System.arraycopy(mac.doFinal(BLOB_MAC), 0, rawMacKey, 0, rawMacKey.length);
198 
199         mDataKey = new SecretKeySpec(rawDataKey, "AES");
200         mMacKey = new SecretKeySpec(rawMacKey, "HmacSHA256");
201     }
202 
203     @Override
queryRoots(String[] projection)204     public Cursor queryRoots(String[] projection) throws FileNotFoundException {
205         final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection));
206         final RowBuilder row = result.newRow();
207         row.add(Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID);
208         row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_LOCAL_ONLY
209                 | Root.FLAG_SUPPORTS_IS_CHILD);
210         row.add(Root.COLUMN_TITLE, getContext().getString(R.string.app_label));
211         row.add(Root.COLUMN_DOCUMENT_ID, DEFAULT_DOCUMENT_ID);
212         row.add(Root.COLUMN_ICON, R.drawable.ic_lock_lock);
213 
214         // Notify user in storage UI when key isn't hardware-backed
215         if (!mHardwareBacked) {
216             row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.info_software));
217         }
218 
219         return result;
220     }
221 
getDocument(long docId)222     private EncryptedDocument getDocument(long docId) throws GeneralSecurityException {
223         final File file = new File(mDocumentsDir, String.valueOf(docId));
224         return new EncryptedDocument(docId, file, mDataKey, mMacKey);
225     }
226 
227     /**
228      * Include metadata for a document in the given result cursor.
229      */
includeDocument(MatrixCursor result, long docId)230     private void includeDocument(MatrixCursor result, long docId)
231             throws IOException, GeneralSecurityException {
232         final EncryptedDocument doc = getDocument(docId);
233         if (!doc.getFile().exists()) {
234             throw new FileNotFoundException("Missing document " + docId);
235         }
236 
237         final JSONObject meta = doc.readMetadata();
238 
239         int flags = 0;
240 
241         final String mimeType = meta.optString(Document.COLUMN_MIME_TYPE);
242         if (Document.MIME_TYPE_DIR.equals(mimeType)) {
243             flags |= Document.FLAG_DIR_SUPPORTS_CREATE;
244         } else {
245             flags |= Document.FLAG_SUPPORTS_WRITE;
246         }
247         flags |= Document.FLAG_SUPPORTS_RENAME;
248         flags |= Document.FLAG_SUPPORTS_DELETE;
249 
250         final RowBuilder row = result.newRow();
251         row.add(Document.COLUMN_DOCUMENT_ID, meta.optLong(Document.COLUMN_DOCUMENT_ID));
252         row.add(Document.COLUMN_DISPLAY_NAME, meta.optString(Document.COLUMN_DISPLAY_NAME));
253         row.add(Document.COLUMN_SIZE, meta.optLong(Document.COLUMN_SIZE));
254         row.add(Document.COLUMN_MIME_TYPE, mimeType);
255         row.add(Document.COLUMN_FLAGS, flags);
256         row.add(Document.COLUMN_LAST_MODIFIED, meta.optLong(Document.COLUMN_LAST_MODIFIED));
257     }
258 
259     @Override
isChildDocument(String parentDocumentId, String documentId)260     public boolean isChildDocument(String parentDocumentId, String documentId) {
261         if (TextUtils.equals(parentDocumentId, documentId)) {
262             return true;
263         }
264 
265         try {
266             final long parentDocId = Long.parseLong(parentDocumentId);
267             final EncryptedDocument parentDoc = getDocument(parentDocId);
268 
269             // Recursively search any children
270             // TODO: consider building an index to optimize this check
271             final JSONObject meta = parentDoc.readMetadata();
272             if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) {
273                 final JSONArray children = meta.getJSONArray(KEY_CHILDREN);
274                 for (int i = 0; i < children.length(); i++) {
275                     final String childDocumentId = children.getString(i);
276                     if (isChildDocument(childDocumentId, documentId)) {
277                         return true;
278                     }
279                 }
280             }
281         } catch (IOException e) {
282             throw new IllegalStateException(e);
283         } catch (GeneralSecurityException e) {
284             throw new IllegalStateException(e);
285         } catch (JSONException e) {
286             throw new IllegalStateException(e);
287         }
288 
289         return false;
290     }
291 
292     @Override
createDocument(String parentDocumentId, String mimeType, String displayName)293     public String createDocument(String parentDocumentId, String mimeType, String displayName)
294             throws FileNotFoundException {
295         final long parentDocId = Long.parseLong(parentDocumentId);
296 
297         // Allocate the next available ID
298         final long childDocId;
299         synchronized (mIdLock) {
300             final SharedPreferences prefs = getContext()
301                     .getSharedPreferences(PREF_NEXT_ID, Context.MODE_PRIVATE);
302             childDocId = prefs.getLong(PREF_NEXT_ID, 1);
303             if (!prefs.edit().putLong(PREF_NEXT_ID, childDocId + 1).commit()) {
304                 throw new IllegalStateException("Failed to allocate document ID");
305             }
306         }
307 
308         try {
309             initDocument(childDocId, mimeType, displayName);
310 
311             // Update parent to reference new child
312             final EncryptedDocument parentDoc = getDocument(parentDocId);
313             final JSONObject parentMeta = parentDoc.readMetadata();
314             parentMeta.accumulate(KEY_CHILDREN, childDocId);
315             parentDoc.writeMetadataAndContent(parentMeta, null);
316 
317             return String.valueOf(childDocId);
318 
319         } catch (IOException e) {
320             throw new IllegalStateException(e);
321         } catch (GeneralSecurityException e) {
322             throw new IllegalStateException(e);
323         } catch (JSONException e) {
324             throw new IllegalStateException(e);
325         }
326     }
327 
328     /**
329      * Create document on disk, writing an initial metadata section. Someone
330      * might come back later to write contents.
331      */
initDocument(long docId, String mimeType, String displayName)332     private void initDocument(long docId, String mimeType, String displayName)
333             throws IOException, GeneralSecurityException {
334         final EncryptedDocument doc = getDocument(docId);
335         if (doc.getFile().exists()) return;
336 
337         try {
338             final JSONObject meta = new JSONObject();
339             meta.put(Document.COLUMN_DOCUMENT_ID, docId);
340             meta.put(Document.COLUMN_MIME_TYPE, mimeType);
341             meta.put(Document.COLUMN_DISPLAY_NAME, displayName);
342             if (Document.MIME_TYPE_DIR.equals(mimeType)) {
343                 meta.put(KEY_CHILDREN, new JSONArray());
344             }
345 
346             doc.writeMetadataAndContent(meta, null);
347         } catch (JSONException e) {
348             throw new IOException(e);
349         }
350     }
351 
352     @Override
renameDocument(String documentId, String displayName)353     public String renameDocument(String documentId, String displayName)
354             throws FileNotFoundException {
355         final long docId = Long.parseLong(documentId);
356 
357         try {
358             final EncryptedDocument doc = getDocument(docId);
359             final JSONObject meta = doc.readMetadata();
360 
361             meta.put(Document.COLUMN_DISPLAY_NAME, displayName);
362             doc.writeMetadataAndContent(meta, null);
363 
364             return null;
365 
366         } catch (IOException e) {
367             throw new IllegalStateException(e);
368         } catch (GeneralSecurityException e) {
369             throw new IllegalStateException(e);
370         } catch (JSONException e) {
371             throw new IllegalStateException(e);
372         }
373     }
374 
375     @Override
deleteDocument(String documentId)376     public void deleteDocument(String documentId) throws FileNotFoundException {
377         final long docId = Long.parseLong(documentId);
378 
379         try {
380             // Delete given document, any children documents under it, and any
381             // references to it from parents.
382             deleteDocumentTree(docId);
383             deleteDocumentReferences(docId);
384 
385         } catch (IOException e) {
386             throw new IllegalStateException(e);
387         } catch (GeneralSecurityException e) {
388             throw new IllegalStateException(e);
389         }
390     }
391 
392     /**
393      * Recursively delete the given document and any children under it.
394      */
deleteDocumentTree(long docId)395     private void deleteDocumentTree(long docId) throws IOException, GeneralSecurityException {
396         final EncryptedDocument doc = getDocument(docId);
397         final JSONObject meta = doc.readMetadata();
398         try {
399             if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) {
400                 final JSONArray children = meta.getJSONArray(KEY_CHILDREN);
401                 for (int i = 0; i < children.length(); i++) {
402                     final long childDocId = children.getLong(i);
403                     deleteDocumentTree(childDocId);
404                 }
405             }
406         } catch (JSONException e) {
407             throw new IOException(e);
408         }
409 
410         if (!doc.getFile().delete()) {
411             throw new IOException("Failed to delete " + docId);
412         }
413     }
414 
415     /**
416      * Remove any references to the given document, usually when included as a
417      * child of another directory.
418      */
deleteDocumentReferences(long docId)419     private void deleteDocumentReferences(long docId) {
420         for (String name : mDocumentsDir.list()) {
421             try {
422                 final long parentDocId = Long.parseLong(name);
423                 final EncryptedDocument parentDoc = getDocument(parentDocId);
424                 final JSONObject meta = parentDoc.readMetadata();
425 
426                 if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) {
427                     final JSONArray children = meta.getJSONArray(KEY_CHILDREN);
428                     if (maybeRemove(children, docId)) {
429                         Log.d(TAG, "Removed " + docId + " reference from " + name);
430                         parentDoc.writeMetadataAndContent(meta, null);
431 
432                         getContext().getContentResolver().notifyChange(
433                                 DocumentsContract.buildChildDocumentsUri(AUTHORITY, name), null,
434                                 false);
435                     }
436                 }
437             } catch (NumberFormatException ignored) {
438             } catch (IOException e) {
439                 Log.w(TAG, "Failed to examine " + name, e);
440             } catch (GeneralSecurityException e) {
441                 Log.w(TAG, "Failed to examine " + name, e);
442             } catch (JSONException e) {
443                 Log.w(TAG, "Failed to examine " + name, e);
444             }
445         }
446     }
447 
448     @Override
queryDocument(String documentId, String[] projection)449     public Cursor queryDocument(String documentId, String[] projection)
450             throws FileNotFoundException {
451         final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection));
452         try {
453             includeDocument(result, Long.parseLong(documentId));
454         } catch (GeneralSecurityException e) {
455             throw new IllegalStateException(e);
456         } catch (IOException e) {
457             throw new IllegalStateException(e);
458         }
459         return result;
460     }
461 
462     @Override
queryChildDocuments( String parentDocumentId, String[] projection, String sortOrder)463     public Cursor queryChildDocuments(
464             String parentDocumentId, String[] projection, String sortOrder)
465             throws FileNotFoundException {
466         final ExtrasMatrixCursor result = new ExtrasMatrixCursor(
467                 resolveDocumentProjection(projection));
468         result.setNotificationUri(getContext().getContentResolver(),
469                 DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId));
470 
471         // Notify user in storage UI when key isn't hardware-backed
472         if (!mHardwareBacked) {
473             result.putString(DocumentsContract.EXTRA_INFO,
474                     getContext().getString(R.string.info_software_detail));
475         }
476 
477         try {
478             final EncryptedDocument doc = getDocument(Long.parseLong(parentDocumentId));
479             final JSONObject meta = doc.readMetadata();
480             final JSONArray children = meta.getJSONArray(KEY_CHILDREN);
481             for (int i = 0; i < children.length(); i++) {
482                 final long docId = children.getLong(i);
483                 includeDocument(result, docId);
484             }
485 
486         } catch (IOException e) {
487             throw new IllegalStateException(e);
488         } catch (GeneralSecurityException e) {
489             throw new IllegalStateException(e);
490         } catch (JSONException e) {
491             throw new IllegalStateException(e);
492         }
493 
494         return result;
495     }
496 
497     @Override
openDocument( String documentId, String mode, CancellationSignal signal)498     public ParcelFileDescriptor openDocument(
499             String documentId, String mode, CancellationSignal signal)
500             throws FileNotFoundException {
501         final long docId = Long.parseLong(documentId);
502 
503         try {
504             final EncryptedDocument doc = getDocument(docId);
505             if ("r".equals(mode)) {
506                 return startRead(doc);
507             } else if ("w".equals(mode) || "wt".equals(mode)) {
508                 return startWrite(doc);
509             } else {
510                 throw new IllegalArgumentException("Unsupported mode: " + mode);
511             }
512         } catch (IOException e) {
513             throw new IllegalStateException(e);
514         } catch (GeneralSecurityException e) {
515             throw new IllegalStateException(e);
516         }
517     }
518 
519     /**
520      * Kick off a thread to handle a read request for the given document.
521      * Internally creates a pipe and returns the read end for returning to a
522      * remote process.
523      */
startRead(final EncryptedDocument doc)524     private ParcelFileDescriptor startRead(final EncryptedDocument doc) throws IOException {
525         final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
526         final ParcelFileDescriptor readEnd = pipe[0];
527         final ParcelFileDescriptor writeEnd = pipe[1];
528 
529         new Thread() {
530             @Override
531             public void run() {
532                 try {
533                     doc.readContent(writeEnd);
534                     Log.d(TAG, "Success reading " + doc);
535                     closeQuietly(writeEnd);
536                 } catch (IOException e) {
537                     Log.w(TAG, "Failed reading " + doc, e);
538                     closeWithErrorQuietly(writeEnd, e.toString());
539                 } catch (GeneralSecurityException e) {
540                     Log.w(TAG, "Failed reading " + doc, e);
541                     closeWithErrorQuietly(writeEnd, e.toString());
542                 }
543             }
544         }.start();
545 
546         return readEnd;
547     }
548 
549     /**
550      * Kick off a thread to handle a write request for the given document.
551      * Internally creates a pipe and returns the write end for returning to a
552      * remote process.
553      */
startWrite(final EncryptedDocument doc)554     private ParcelFileDescriptor startWrite(final EncryptedDocument doc) throws IOException {
555         final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
556         final ParcelFileDescriptor readEnd = pipe[0];
557         final ParcelFileDescriptor writeEnd = pipe[1];
558 
559         new Thread() {
560             @Override
561             public void run() {
562                 try {
563                     final JSONObject meta = doc.readMetadata();
564                     doc.writeMetadataAndContent(meta, readEnd);
565                     Log.d(TAG, "Success writing " + doc);
566                     closeQuietly(readEnd);
567                 } catch (IOException e) {
568                     Log.w(TAG, "Failed writing " + doc, e);
569                     closeWithErrorQuietly(readEnd, e.toString());
570                 } catch (GeneralSecurityException e) {
571                     Log.w(TAG, "Failed writing " + doc, e);
572                     closeWithErrorQuietly(readEnd, e.toString());
573                 }
574             }
575         }.start();
576 
577         return writeEnd;
578     }
579 
580     /**
581      * Maybe remove the given value from a {@link JSONArray}.
582      *
583      * @return if the array was mutated.
584      */
maybeRemove(JSONArray array, long value)585     private static boolean maybeRemove(JSONArray array, long value) throws JSONException {
586         boolean mutated = false;
587         int i = 0;
588         while (i < array.length()) {
589             if (value == array.getLong(i)) {
590                 array.remove(i);
591                 mutated = true;
592             } else {
593                 i++;
594             }
595         }
596         return mutated;
597     }
598 
599     /**
600      * Simple extension of {@link MatrixCursor} that makes it easy to provide a
601      * {@link Bundle} of extras.
602      */
603     private static class ExtrasMatrixCursor extends MatrixCursor {
604         private Bundle mExtras;
605 
ExtrasMatrixCursor(String[] columnNames)606         public ExtrasMatrixCursor(String[] columnNames) {
607             super(columnNames);
608         }
609 
putString(String key, String value)610         public void putString(String key, String value) {
611             if (mExtras == null) {
612                 mExtras = new Bundle();
613             }
614             mExtras.putString(key, value);
615         }
616 
617         @Override
getExtras()618         public Bundle getExtras() {
619             return mExtras;
620         }
621     }
622 }
623