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