Home | History | Annotate | Download | only in vault
      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 
    106     private static String[] resolveRootProjection(String[] projection) {
    107         return projection != null ? projection : DEFAULT_ROOT_PROJECTION;
    108     }
    109 
    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
    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      */
    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      */
    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
    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 
    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      */
    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
    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
    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      */
    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
    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
    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      */
    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      */
    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
    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
    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
    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      */
    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      */
    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      */
    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 
    606         public ExtrasMatrixCursor(String[] columnNames) {
    607             super(columnNames);
    608         }
    609 
    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
    618         public Bundle getExtras() {
    619             return mExtras;
    620         }
    621     }
    622 }
    623