Vault example documents provider.

Example provider that encrypts both metadata and contents of
documents stored inside.  It shows advanced usage of new storage
access APIs and hardware-backed key chain.

Change-Id: I2cdf4e949be8471c3d8b4f45ec0681c9248ea09c
diff --git a/samples/Vault/Android.mk b/samples/Vault/Android.mk
new file mode 100644
index 0000000..b4de298
--- /dev/null
+++ b/samples/Vault/Android.mk
@@ -0,0 +1,15 @@
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+LOCAL_MODULE_TAGS := optional
+LOCAL_MODULE_PATH := $(TARGET_OUT_DATA_APPS)
+
+LOCAL_SRC_FILES := $(call all-subdir-java-files)
+
+LOCAL_SDK_VERSION := current
+
+LOCAL_PROGUARD_ENABLED := disabled
+
+LOCAL_PACKAGE_NAME := Vault
+
+include $(BUILD_PACKAGE)
diff --git a/samples/Vault/AndroidManifest.xml b/samples/Vault/AndroidManifest.xml
new file mode 100644
index 0000000..8f36172
--- /dev/null
+++ b/samples/Vault/AndroidManifest.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.example.android.vault">
+
+    <application
+        android:label="@string/app_label"
+        android:icon="@drawable/ic_lock_lock">
+        <provider
+            android:name=".VaultProvider"
+            android:authorities="com.example.android.vault.provider"
+            android:exported="true"
+            android:grantUriPermissions="true"
+            android:permission="android.permission.MANAGE_DOCUMENTS">
+            <intent-filter>
+                <action android:name="android.content.action.DOCUMENTS_PROVIDER" />
+            </intent-filter>
+        </provider>
+    </application>
+
+</manifest>
diff --git a/samples/Vault/res/drawable-xhdpi/ic_lock_lock.png b/samples/Vault/res/drawable-xhdpi/ic_lock_lock.png
new file mode 100644
index 0000000..086a0ca
--- /dev/null
+++ b/samples/Vault/res/drawable-xhdpi/ic_lock_lock.png
Binary files differ
diff --git a/samples/Vault/res/values/strings.xml b/samples/Vault/res/values/strings.xml
new file mode 100644
index 0000000..e050d25
--- /dev/null
+++ b/samples/Vault/res/values/strings.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2013 The Android Open Source Project
+
+     Licensed under the Apache License, Version 2.0 (the "License");
+     you may not use this file except in compliance with the License.
+     You may obtain a copy of the License at
+
+          http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing, software
+     distributed under the License is distributed on an "AS IS" BASIS,
+     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     See the License for the specific language governing permissions and
+     limitations under the License.
+-->
+
+<resources>
+    <string name="app_label">Vault</string>
+    <string name="info_software">Software-backed</string>
+    <string name="info_software_detail">Encryption key is software-backed, which is less secure.</string>
+</resources>
diff --git a/samples/Vault/src/com/example/android/vault/EncryptedDocument.java b/samples/Vault/src/com/example/android/vault/EncryptedDocument.java
new file mode 100644
index 0000000..59a22ba
--- /dev/null
+++ b/samples/Vault/src/com/example/android/vault/EncryptedDocument.java
@@ -0,0 +1,402 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.android.vault;
+
+import static com.example.android.vault.VaultProvider.TAG;
+
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract.Document;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.io.RandomAccessFile;
+import java.net.ProtocolException;
+import java.nio.charset.StandardCharsets;
+import java.security.DigestException;
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
+
+/**
+ * Represents a single encrypted document stored on disk. Handles encryption,
+ * decryption, and authentication of the document when requested.
+ * <p>
+ * Encrypted documents are stored on disk as a magic number, followed by an
+ * encrypted metadata section, followed by an encrypted content section. The
+ * content section always starts at a specific offset {@link #CONTENT_OFFSET} to
+ * allow metadata updates without rewriting the entire file.
+ * <p>
+ * Each section is encrypted using AES-128 with a random IV, and authenticated
+ * with SHA-256. Data encrypted and authenticated like this can be safely stored
+ * on untrusted storage devices, as long as the keys are stored securely.
+ * <p>
+ * Not inherently thread safe.
+ */
+public class EncryptedDocument {
+
+    /**
+     * Magic number to identify file; "AVLT".
+     */
+    private static final int MAGIC_NUMBER = 0x41564c54;
+
+    /**
+     * Offset in file at which content section starts. Magic and metadata
+     * section must fully fit before this offset.
+     */
+    private static final int CONTENT_OFFSET = 4096;
+
+    private static final boolean DEBUG_METADATA = true;
+
+    /** Key length for AES-128 */
+    public static final int DATA_KEY_LENGTH = 16;
+    /** Key length for SHA-256 */
+    public static final int MAC_KEY_LENGTH = 32;
+
+    private final SecureRandom mRandom;
+    private final Cipher mCipher;
+    private final Mac mMac;
+
+    private final long mDocId;
+    private final File mFile;
+    private final SecretKey mDataKey;
+    private final SecretKey mMacKey;
+
+    /**
+     * Create an encrypted document.
+     *
+     * @param docId the expected {@link Document#COLUMN_DOCUMENT_ID} to be
+     *            validated when reading metadata.
+     * @param file location on disk where the encrypted document is stored. May
+     *            not exist yet.
+     */
+    public EncryptedDocument(long docId, File file, SecretKey dataKey, SecretKey macKey)
+            throws GeneralSecurityException {
+        mRandom = new SecureRandom();
+        mCipher = Cipher.getInstance("AES/CTR/NoPadding");
+        mMac = Mac.getInstance("HmacSHA256");
+
+        if (dataKey.getEncoded().length != DATA_KEY_LENGTH) {
+            throw new IllegalArgumentException("Expected data key length " + DATA_KEY_LENGTH);
+        }
+        if (macKey.getEncoded().length != MAC_KEY_LENGTH) {
+            throw new IllegalArgumentException("Expected MAC key length " + MAC_KEY_LENGTH);
+        }
+
+        mDocId = docId;
+        mFile = file;
+        mDataKey = dataKey;
+        mMacKey = macKey;
+    }
+
+    public File getFile() {
+        return mFile;
+    }
+
+    @Override
+    public String toString() {
+        return mFile.getName();
+    }
+
+    /**
+     * Decrypt and return parsed metadata section from this document.
+     *
+     * @throws DigestException if metadata fails MAC check, or if
+     *             {@link Document#COLUMN_DOCUMENT_ID} recorded in metadata is
+     *             unexpected.
+     */
+    public JSONObject readMetadata() throws IOException, GeneralSecurityException {
+        final RandomAccessFile f = new RandomAccessFile(mFile, "r");
+        try {
+            assertMagic(f);
+
+            // Only interested in metadata section
+            final ByteArrayOutputStream metaOut = new ByteArrayOutputStream();
+            readSection(f, metaOut);
+
+            final String rawMeta = metaOut.toString(StandardCharsets.UTF_8.name());
+            if (DEBUG_METADATA) {
+                Log.d(TAG, "Found metadata for " + mDocId + ": " + rawMeta);
+            }
+
+            final JSONObject meta = new JSONObject(rawMeta);
+
+            // Validate that metadata belongs to requested file
+            if (meta.getLong(Document.COLUMN_DOCUMENT_ID) != mDocId) {
+                throw new DigestException("Unexpected document ID");
+            }
+
+            return meta;
+
+        } catch (JSONException e) {
+            throw new IOException(e);
+        } finally {
+            f.close();
+        }
+    }
+
+    /**
+     * Decrypt and read content section of this document, writing it into the
+     * given pipe.
+     * <p>
+     * Pipe is left open, so caller is responsible for calling
+     * {@link ParcelFileDescriptor#close()} or
+     * {@link ParcelFileDescriptor#closeWithError(String)}.
+     *
+     * @param contentOut write end of a pipe.
+     * @throws DigestException if content fails MAC check. Some or all content
+     *             may have already been written to the pipe when the MAC is
+     *             validated.
+     */
+    public void readContent(ParcelFileDescriptor contentOut)
+            throws IOException, GeneralSecurityException {
+        final RandomAccessFile f = new RandomAccessFile(mFile, "r");
+        try {
+            assertMagic(f);
+
+            if (f.length() <= CONTENT_OFFSET) {
+                throw new IOException("Document has no content");
+            }
+
+            // Skip over metadata section
+            f.seek(CONTENT_OFFSET);
+            readSection(f, new FileOutputStream(contentOut.getFileDescriptor()));
+
+        } finally {
+            f.close();
+        }
+    }
+
+    /**
+     * Encrypt and write both the metadata and content sections of this
+     * document, reading the content from the given pipe. Internally uses
+     * {@link ParcelFileDescriptor#checkError()} to verify that content arrives
+     * without errors. Writes to temporary file to keep atomic view of contents,
+     * swapping into place only when write is successful.
+     * <p>
+     * Pipe is left open, so caller is responsible for calling
+     * {@link ParcelFileDescriptor#close()} or
+     * {@link ParcelFileDescriptor#closeWithError(String)}.
+     *
+     * @param contentIn read end of a pipe.
+     */
+    public void writeMetadataAndContent(JSONObject meta, ParcelFileDescriptor contentIn)
+            throws IOException, GeneralSecurityException {
+        // Write into temporary file to provide an atomic view of existing
+        // contents during write, and also to recover from failed writes.
+        final String tempName = mFile.getName() + ".tmp_" + Thread.currentThread().getId();
+        final File tempFile = new File(mFile.getParentFile(), tempName);
+
+        RandomAccessFile f = new RandomAccessFile(tempFile, "rw");
+        try {
+            // Truncate any existing data
+            f.setLength(0);
+
+            // Write content first to detect size
+            if (contentIn != null) {
+                f.seek(CONTENT_OFFSET);
+                final int plainLength = writeSection(
+                        f, new FileInputStream(contentIn.getFileDescriptor()));
+                meta.put(Document.COLUMN_SIZE, plainLength);
+
+                // Verify that remote side of pipe finished okay; if they
+                // crashed or indicated an error then this throws and we
+                // leave the original file intact and clean up temp below.
+                contentIn.checkError();
+            }
+
+            meta.put(Document.COLUMN_DOCUMENT_ID, mDocId);
+            meta.put(Document.COLUMN_LAST_MODIFIED, System.currentTimeMillis());
+
+            // Rewind and write metadata section
+            f.seek(0);
+            f.writeInt(MAGIC_NUMBER);
+
+            final ByteArrayInputStream metaIn = new ByteArrayInputStream(
+                    meta.toString().getBytes(StandardCharsets.UTF_8));
+            writeSection(f, metaIn);
+
+            if (f.getFilePointer() > CONTENT_OFFSET) {
+                throw new IOException("Metadata section was too large");
+            }
+
+            // Everything written fine, atomically swap new data into place.
+            // fsync() before close would be overkill, since rename() is an
+            // atomic barrier.
+            f.close();
+            tempFile.renameTo(mFile);
+
+        } catch (JSONException e) {
+            throw new IOException(e);
+        } finally {
+            // Regardless of what happens, always try cleaning up.
+            f.close();
+            tempFile.delete();
+        }
+    }
+
+    /**
+     * Read and decrypt the section starting at the current file offset.
+     * Validates MAC of decrypted data, throwing if mismatch. When finished,
+     * file offset is at the end of the entire section.
+     */
+    private void readSection(RandomAccessFile f, OutputStream out)
+            throws IOException, GeneralSecurityException {
+        final long start = f.getFilePointer();
+
+        final Section section = new Section();
+        section.read(f);
+
+        final IvParameterSpec ivSpec = new IvParameterSpec(section.iv);
+        mCipher.init(Cipher.DECRYPT_MODE, mDataKey, ivSpec);
+        mMac.init(mMacKey);
+
+        byte[] inbuf = new byte[8192];
+        byte[] outbuf;
+        int n;
+        while ((n = f.read(inbuf, 0, (int) Math.min(section.length, inbuf.length))) != -1) {
+            section.length -= n;
+            mMac.update(inbuf, 0, n);
+            outbuf = mCipher.update(inbuf, 0, n);
+            if (outbuf != null) {
+                out.write(outbuf);
+            }
+            if (section.length == 0) break;
+        }
+
+        section.assertMac(mMac.doFinal());
+
+        outbuf = mCipher.doFinal();
+        if (outbuf != null) {
+            out.write(outbuf);
+        }
+    }
+
+    /**
+     * Encrypt and write the given stream as a full section. Writes section
+     * header and encrypted data starting at the current file offset. When
+     * finished, file offset is at the end of the entire section.
+     */
+    private int writeSection(RandomAccessFile f, InputStream in)
+            throws IOException, GeneralSecurityException {
+        final long start = f.getFilePointer();
+
+        // Write header; we'll come back later to finalize details
+        final Section section = new Section();
+        section.write(f);
+
+        final long dataStart = f.getFilePointer();
+
+        mRandom.nextBytes(section.iv);
+
+        final IvParameterSpec ivSpec = new IvParameterSpec(section.iv);
+        mCipher.init(Cipher.ENCRYPT_MODE, mDataKey, ivSpec);
+        mMac.init(mMacKey);
+
+        int plainLength = 0;
+        byte[] inbuf = new byte[8192];
+        byte[] outbuf;
+        int n;
+        while ((n = in.read(inbuf)) != -1) {
+            plainLength += n;
+            outbuf = mCipher.update(inbuf, 0, n);
+            if (outbuf != null) {
+                mMac.update(outbuf);
+                f.write(outbuf);
+            }
+        }
+
+        outbuf = mCipher.doFinal();
+        if (outbuf != null) {
+            mMac.update(outbuf);
+            f.write(outbuf);
+        }
+
+        section.setMac(mMac.doFinal());
+
+        final long dataEnd = f.getFilePointer();
+        section.length = dataEnd - dataStart;
+
+        // Rewind and update header
+        f.seek(start);
+        section.write(f);
+        f.seek(dataEnd);
+
+        return plainLength;
+    }
+
+    /**
+     * Header of a single file section.
+     */
+    private static class Section {
+        long length;
+        final byte[] iv = new byte[DATA_KEY_LENGTH];
+        final byte[] mac = new byte[MAC_KEY_LENGTH];
+
+        public void read(RandomAccessFile f) throws IOException {
+            length = f.readLong();
+            f.readFully(iv);
+            f.readFully(mac);
+        }
+
+        public void write(RandomAccessFile f) throws IOException {
+            f.writeLong(length);
+            f.write(iv);
+            f.write(mac);
+        }
+
+        public void setMac(byte[] mac) {
+            if (mac.length != this.mac.length) {
+                throw new IllegalArgumentException("Unexpected MAC length");
+            }
+            System.arraycopy(mac, 0, this.mac, 0, this.mac.length);
+        }
+
+        public void assertMac(byte[] mac) throws DigestException {
+            if (mac.length != this.mac.length) {
+                throw new IllegalArgumentException("Unexpected MAC length");
+            }
+            byte result = 0;
+            for (int i = 0; i < mac.length; i++) {
+                result |= mac[i] ^ this.mac[i];
+            }
+            if (result != 0) {
+                throw new DigestException();
+            }
+        }
+    }
+
+    private static void assertMagic(RandomAccessFile f) throws IOException {
+        final int magic = f.readInt();
+        if (magic != MAGIC_NUMBER) {
+            throw new ProtocolException("Bad magic number: " + Integer.toHexString(magic));
+        }
+    }
+}
diff --git a/samples/Vault/src/com/example/android/vault/SecretKeyWrapper.java b/samples/Vault/src/com/example/android/vault/SecretKeyWrapper.java
new file mode 100644
index 0000000..cb8efde
--- /dev/null
+++ b/samples/Vault/src/com/example/android/vault/SecretKeyWrapper.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.android.vault;
+
+import android.content.Context;
+import android.security.KeyPairGeneratorSpec;
+
+import java.io.IOException;
+import java.math.BigInteger;
+import java.security.GeneralSecurityException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.util.Calendar;
+import java.util.GregorianCalendar;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.security.auth.x500.X500Principal;
+
+/**
+ * Wraps {@link SecretKey} instances using a public/private key pair stored in
+ * the platform {@link KeyStore}. This allows us to protect symmetric keys with
+ * hardware-backed crypto, if provided by the device.
+ * <p>
+ * See <a href="http://en.wikipedia.org/wiki/Key_Wrap">key wrapping</a> for more
+ * details.
+ * <p>
+ * Not inherently thread safe.
+ */
+public class SecretKeyWrapper {
+    private final Cipher mCipher;
+    private final KeyPair mPair;
+
+    /**
+     * Create a wrapper using the public/private key pair with the given alias.
+     * If no pair with that alias exists, it will be generated.
+     */
+    public SecretKeyWrapper(Context context, String alias)
+            throws GeneralSecurityException, IOException {
+        mCipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
+
+        final KeyStore keyStore = KeyStore.getInstance("AndroidKeyStore");
+        keyStore.load(null);
+
+        if (!keyStore.containsAlias(alias)) {
+            generateKeyPair(context, alias);
+        }
+
+        // Even if we just generated the key, always read it back to ensure we
+        // can read it successfully.
+        final KeyStore.PrivateKeyEntry entry = (KeyStore.PrivateKeyEntry) keyStore.getEntry(
+                alias, null);
+        mPair = new KeyPair(entry.getCertificate().getPublicKey(), entry.getPrivateKey());
+    }
+
+    private static void generateKeyPair(Context context, String alias)
+            throws GeneralSecurityException {
+        final Calendar start = new GregorianCalendar();
+        final Calendar end = new GregorianCalendar();
+        end.add(Calendar.YEAR, 100);
+
+        final KeyPairGeneratorSpec spec = new KeyPairGeneratorSpec.Builder(context)
+                .setAlias(alias)
+                .setSubject(new X500Principal("CN=" + alias))
+                .setSerialNumber(BigInteger.ONE)
+                .setStartDate(start.getTime())
+                .setEndDate(end.getTime())
+                .build();
+
+        final KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA", "AndroidKeyStore");
+        gen.initialize(spec);
+        gen.generateKeyPair();
+    }
+
+    /**
+     * Wrap a {@link SecretKey} using the public key assigned to this wrapper.
+     * Use {@link #unwrap(byte[])} to later recover the original
+     * {@link SecretKey}.
+     *
+     * @return a wrapped version of the given {@link SecretKey} that can be
+     *         safely stored on untrusted storage.
+     */
+    public byte[] wrap(SecretKey key) throws GeneralSecurityException {
+        mCipher.init(Cipher.WRAP_MODE, mPair.getPublic());
+        return mCipher.wrap(key);
+    }
+
+    /**
+     * Unwrap a {@link SecretKey} using the private key assigned to this
+     * wrapper.
+     *
+     * @param blob a wrapped {@link SecretKey} as previously returned by
+     *            {@link #wrap(SecretKey)}.
+     */
+    public SecretKey unwrap(byte[] blob) throws GeneralSecurityException {
+        mCipher.init(Cipher.UNWRAP_MODE, mPair.getPrivate());
+        return (SecretKey) mCipher.unwrap(blob, "AES", Cipher.SECRET_KEY);
+    }
+}
diff --git a/samples/Vault/src/com/example/android/vault/Utils.java b/samples/Vault/src/com/example/android/vault/Utils.java
new file mode 100644
index 0000000..d4694b1
--- /dev/null
+++ b/samples/Vault/src/com/example/android/vault/Utils.java
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.android.vault;
+
+import android.os.ParcelFileDescriptor;
+
+import java.io.ByteArrayOutputStream;
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+public class Utils {
+    public static void closeQuietly(Closeable closable) {
+        if (closable != null) {
+            try {
+                closable.close();
+            } catch (IOException ignored) {
+            }
+        }
+    }
+
+    public static void closeWithErrorQuietly(ParcelFileDescriptor pfd, String msg) {
+        if (pfd != null) {
+            try {
+                pfd.closeWithError(msg);
+            } catch (IOException ignored) {
+            }
+        }
+    }
+
+    public static void writeFully(File file, byte[] data) throws IOException {
+        final OutputStream out = new FileOutputStream(file);
+        try {
+            out.write(data);
+        } finally {
+            out.close();
+        }
+    }
+
+    public static byte[] readFully(File file) throws IOException {
+        final InputStream in = new FileInputStream(file);
+        try {
+            ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+            byte[] buffer = new byte[1024];
+            int count;
+            while ((count = in.read(buffer)) != -1) {
+                bytes.write(buffer, 0, count);
+            }
+            return bytes.toByteArray();
+        } finally {
+            in.close();
+        }
+    }
+}
diff --git a/samples/Vault/src/com/example/android/vault/VaultProvider.java b/samples/Vault/src/com/example/android/vault/VaultProvider.java
new file mode 100644
index 0000000..597f7d3
--- /dev/null
+++ b/samples/Vault/src/com/example/android/vault/VaultProvider.java
@@ -0,0 +1,565 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.android.vault;
+
+import static com.example.android.vault.EncryptedDocument.DATA_KEY_LENGTH;
+import static com.example.android.vault.EncryptedDocument.MAC_KEY_LENGTH;
+import static com.example.android.vault.Utils.closeQuietly;
+import static com.example.android.vault.Utils.closeWithErrorQuietly;
+import static com.example.android.vault.Utils.readFully;
+import static com.example.android.vault.Utils.writeFully;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.database.MatrixCursor.RowBuilder;
+import android.os.Bundle;
+import android.os.CancellationSignal;
+import android.os.ParcelFileDescriptor;
+import android.provider.DocumentsContract;
+import android.provider.DocumentsContract.Document;
+import android.provider.DocumentsContract.Root;
+import android.provider.DocumentsProvider;
+import android.security.KeyChain;
+import android.util.Log;
+
+import com.android.vault.R;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+
+import javax.crypto.Mac;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Provider that encrypts both metadata and contents of documents stored inside.
+ * Each document is stored as described by {@link EncryptedDocument} with
+ * separate metadata and content sections. Directories are just
+ * {@link EncryptedDocument} instances without a content section, and a list of
+ * child documents included in the metadata section.
+ * <p>
+ * All content is encrypted/decrypted on demand through pipes, using
+ * {@link ParcelFileDescriptor#createReliablePipe()} to detect and recover from
+ * remote crashes and errors.
+ * <p>
+ * Our symmetric encryption key is stored on disk only after using
+ * {@link SecretKeyWrapper} to "wrap" it using another public/private key pair
+ * stored in the platform {@link KeyStore}. This allows us to protect our
+ * symmetric key with hardware-backed keys, if supported. Devices without
+ * hardware support still encrypt their keys while at rest, and the platform
+ * always requires a user to present a PIN, password, or pattern to unlock the
+ * KeyStore before use.
+ */
+public class VaultProvider extends DocumentsProvider {
+    public static final String TAG = "Vault";
+
+    static final String AUTHORITY = "com.example.android.vault.provider";
+
+    static final String DEFAULT_ROOT_ID = "vault";
+    static final String DEFAULT_DOCUMENT_ID = "0";
+
+    /** JSON key storing array of all children documents in a directory. */
+    private static final String KEY_CHILDREN = "vault:children";
+
+    /** Key pointing to next available document ID. */
+    private static final String PREF_NEXT_ID = "next_id";
+
+    /** Blob used to derive {@link #mDataKey} from our secret key. */
+    private static final byte[] BLOB_DATA = "DATA".getBytes(StandardCharsets.UTF_8);
+    /** Blob used to derive {@link #mMacKey} from our secret key. */
+    private static final byte[] BLOB_MAC = "MAC".getBytes(StandardCharsets.UTF_8);
+
+    private static final String[] DEFAULT_ROOT_PROJECTION = new String[] {
+            Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE,
+            Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES, Root.COLUMN_SUMMARY
+    };
+
+    private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] {
+            Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME,
+            Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE,
+    };
+
+    private static String[] resolveRootProjection(String[] projection) {
+        return projection != null ? projection : DEFAULT_ROOT_PROJECTION;
+    }
+
+    private static String[] resolveDocumentProjection(String[] projection) {
+        return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION;
+    }
+
+    private final Object mIdLock = new Object();
+
+    /**
+     * Flag indicating that the {@link SecretKeyWrapper} public/private key is
+     * hardware-backed. A software keystore is more vulnerable to offline
+     * attacks if the device is compromised.
+     */
+    private boolean mHardwareBacked;
+
+    /** File where wrapped symmetric key is stored. */
+    private File mKeyFile;
+    /** Directory where all encrypted documents are stored. */
+    private File mDocumentsDir;
+
+    private SecretKey mDataKey;
+    private SecretKey mMacKey;
+
+    @Override
+    public boolean onCreate() {
+        mHardwareBacked = KeyChain.isBoundKeyAlgorithm("RSA");
+
+        mKeyFile = new File(getContext().getFilesDir(), "vault.key");
+        mDocumentsDir = new File(getContext().getFilesDir(), "documents");
+        mDocumentsDir.mkdirs();
+
+        try {
+            // Load secret key and ensure our root document is ready.
+            loadOrGenerateKeys(getContext(), mKeyFile);
+            initDocument(Long.parseLong(DEFAULT_DOCUMENT_ID), Document.MIME_TYPE_DIR, null);
+
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        } catch (GeneralSecurityException e) {
+            throw new IllegalStateException(e);
+        }
+
+        return true;
+    }
+
+    /**
+     * Used for testing.
+     */
+    void wipeAllContents() throws IOException, GeneralSecurityException {
+        for (File f : mDocumentsDir.listFiles()) {
+            f.delete();
+        }
+
+        initDocument(Long.parseLong(DEFAULT_DOCUMENT_ID), Document.MIME_TYPE_DIR, null);
+    }
+
+    /**
+     * Load our symmetric secret key and use it to derive two different data and
+     * MAC keys. The symmetric secret key is stored securely on disk by wrapping
+     * it with a public/private key pair, possibly backed by hardware.
+     */
+    private void loadOrGenerateKeys(Context context, File keyFile)
+            throws GeneralSecurityException, IOException {
+        final SecretKeyWrapper wrapper = new SecretKeyWrapper(context, TAG);
+
+        // Generate secret key if none exists
+        if (!keyFile.exists()) {
+            final byte[] raw = new byte[DATA_KEY_LENGTH];
+            new SecureRandom().nextBytes(raw);
+
+            final SecretKey key = new SecretKeySpec(raw, "AES");
+            final byte[] wrapped = wrapper.wrap(key);
+
+            writeFully(keyFile, wrapped);
+        }
+
+        // Even if we just generated the key, always read it back to ensure we
+        // can read it successfully.
+        final byte[] wrapped = readFully(keyFile);
+        final SecretKey key = wrapper.unwrap(wrapped);
+
+        final Mac mac = Mac.getInstance("HmacSHA256");
+        mac.init(key);
+
+        // Derive two different keys for encryption and authentication.
+        final byte[] rawDataKey = new byte[DATA_KEY_LENGTH];
+        final byte[] rawMacKey = new byte[MAC_KEY_LENGTH];
+
+        System.arraycopy(mac.doFinal(BLOB_DATA), 0, rawDataKey, 0, rawDataKey.length);
+        System.arraycopy(mac.doFinal(BLOB_MAC), 0, rawMacKey, 0, rawMacKey.length);
+
+        mDataKey = new SecretKeySpec(rawDataKey, "AES");
+        mMacKey = new SecretKeySpec(rawMacKey, "HmacSHA256");
+    }
+
+    @Override
+    public Cursor queryRoots(String[] projection) throws FileNotFoundException {
+        final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection));
+        final RowBuilder row = result.newRow();
+        row.add(Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID);
+        row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_LOCAL_ONLY);
+        row.add(Root.COLUMN_TITLE, getContext().getString(R.string.app_label));
+        row.add(Root.COLUMN_DOCUMENT_ID, DEFAULT_DOCUMENT_ID);
+        row.add(Root.COLUMN_ICON, R.drawable.ic_lock_lock);
+
+        // Notify user in storage UI when key isn't hardware-backed
+        if (!mHardwareBacked) {
+            row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.info_software));
+        }
+
+        return result;
+    }
+
+    private EncryptedDocument getDocument(long docId) throws GeneralSecurityException {
+        final File file = new File(mDocumentsDir, String.valueOf(docId));
+        return new EncryptedDocument(docId, file, mDataKey, mMacKey);
+    }
+
+    /**
+     * Include metadata for a document in the given result cursor.
+     */
+    private void includeDocument(MatrixCursor result, long docId)
+            throws IOException, GeneralSecurityException {
+        final EncryptedDocument doc = getDocument(docId);
+        if (!doc.getFile().exists()) {
+            throw new FileNotFoundException("Missing document " + docId);
+        }
+
+        final JSONObject meta = doc.readMetadata();
+
+        int flags = 0;
+
+        final String mimeType = meta.optString(Document.COLUMN_MIME_TYPE);
+        if (Document.MIME_TYPE_DIR.equals(mimeType)) {
+            flags |= Document.FLAG_DIR_SUPPORTS_CREATE;
+        } else {
+            flags |= Document.FLAG_SUPPORTS_WRITE;
+        }
+        flags |= Document.FLAG_SUPPORTS_DELETE;
+
+        final RowBuilder row = result.newRow();
+        row.add(Document.COLUMN_DOCUMENT_ID, meta.optLong(Document.COLUMN_DOCUMENT_ID));
+        row.add(Document.COLUMN_DISPLAY_NAME, meta.optString(Document.COLUMN_DISPLAY_NAME));
+        row.add(Document.COLUMN_SIZE, meta.optLong(Document.COLUMN_SIZE));
+        row.add(Document.COLUMN_MIME_TYPE, mimeType);
+        row.add(Document.COLUMN_FLAGS, flags);
+        row.add(Document.COLUMN_LAST_MODIFIED, meta.optLong(Document.COLUMN_LAST_MODIFIED));
+    }
+
+    @Override
+    public String createDocument(String parentDocumentId, String mimeType, String displayName)
+            throws FileNotFoundException {
+        final long parentDocId = Long.parseLong(parentDocumentId);
+
+        // Allocate the next available ID
+        final long childDocId;
+        synchronized (mIdLock) {
+            final SharedPreferences prefs = getContext()
+                    .getSharedPreferences(PREF_NEXT_ID, Context.MODE_PRIVATE);
+            childDocId = prefs.getLong(PREF_NEXT_ID, 1);
+            if (!prefs.edit().putLong(PREF_NEXT_ID, childDocId + 1).commit()) {
+                throw new IllegalStateException("Failed to allocate document ID");
+            }
+        }
+
+        try {
+            initDocument(childDocId, mimeType, displayName);
+
+            // Update parent to reference new child
+            final EncryptedDocument parentDoc = getDocument(parentDocId);
+            final JSONObject parentMeta = parentDoc.readMetadata();
+            parentMeta.accumulate(KEY_CHILDREN, childDocId);
+            parentDoc.writeMetadataAndContent(parentMeta, null);
+
+            return String.valueOf(childDocId);
+
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        } catch (GeneralSecurityException e) {
+            throw new IllegalStateException(e);
+        } catch (JSONException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    /**
+     * Create document on disk, writing an initial metadata section. Someone
+     * might come back later to write contents.
+     */
+    private void initDocument(long docId, String mimeType, String displayName)
+            throws IOException, GeneralSecurityException {
+        final EncryptedDocument doc = getDocument(docId);
+        if (doc.getFile().exists()) return;
+
+        try {
+            final JSONObject meta = new JSONObject();
+            meta.put(Document.COLUMN_DOCUMENT_ID, docId);
+            meta.put(Document.COLUMN_MIME_TYPE, mimeType);
+            meta.put(Document.COLUMN_DISPLAY_NAME, displayName);
+            if (Document.MIME_TYPE_DIR.equals(mimeType)) {
+                meta.put(KEY_CHILDREN, new JSONArray());
+            }
+
+            doc.writeMetadataAndContent(meta, null);
+        } catch (JSONException e) {
+            throw new IOException(e);
+        }
+    }
+
+    @Override
+    public void deleteDocument(String documentId) throws FileNotFoundException {
+        final long docId = Long.parseLong(documentId);
+
+        try {
+            // Delete given document, any children documents under it, and any
+            // references to it from parents.
+            deleteDocumentTree(docId);
+            deleteDocumentReferences(docId);
+
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        } catch (GeneralSecurityException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    /**
+     * Recursively delete the given document and any children under it.
+     */
+    private void deleteDocumentTree(long docId) throws IOException, GeneralSecurityException {
+        final EncryptedDocument doc = getDocument(docId);
+        final JSONObject meta = doc.readMetadata();
+        try {
+            if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) {
+                final JSONArray children = meta.getJSONArray(KEY_CHILDREN);
+                for (int i = 0; i < children.length(); i++) {
+                    final long childDocId = children.getLong(i);
+                    deleteDocumentTree(childDocId);
+                }
+            }
+        } catch (JSONException e) {
+            throw new IOException(e);
+        }
+
+        if (!doc.getFile().delete()) {
+            throw new IOException("Failed to delete " + docId);
+        }
+    }
+
+    /**
+     * Remove any references to the given document, usually when included as a
+     * child of another directory.
+     */
+    private void deleteDocumentReferences(long docId) {
+        for (String name : mDocumentsDir.list()) {
+            try {
+                final long parentDocId = Long.parseLong(name);
+                final EncryptedDocument parentDoc = getDocument(parentDocId);
+                final JSONObject meta = parentDoc.readMetadata();
+
+                if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) {
+                    final JSONArray children = meta.getJSONArray(KEY_CHILDREN);
+                    if (maybeRemove(children, docId)) {
+                        Log.d(TAG, "Removed " + docId + " reference from " + name);
+                        parentDoc.writeMetadataAndContent(meta, null);
+
+                        getContext().getContentResolver().notifyChange(
+                                DocumentsContract.buildChildDocumentsUri(AUTHORITY, name), null,
+                                false);
+                    }
+                }
+            } catch (NumberFormatException ignored) {
+            } catch (IOException e) {
+                Log.w(TAG, "Failed to examine " + name, e);
+            } catch (GeneralSecurityException e) {
+                Log.w(TAG, "Failed to examine " + name, e);
+            } catch (JSONException e) {
+                Log.w(TAG, "Failed to examine " + name, e);
+            }
+        }
+    }
+
+    @Override
+    public Cursor queryDocument(String documentId, String[] projection)
+            throws FileNotFoundException {
+        final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection));
+        try {
+            includeDocument(result, Long.parseLong(documentId));
+        } catch (GeneralSecurityException e) {
+            throw new IllegalStateException(e);
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        }
+        return result;
+    }
+
+    @Override
+    public Cursor queryChildDocuments(
+            String parentDocumentId, String[] projection, String sortOrder)
+            throws FileNotFoundException {
+        final ExtrasMatrixCursor result = new ExtrasMatrixCursor(
+                resolveDocumentProjection(projection));
+        result.setNotificationUri(getContext().getContentResolver(),
+                DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId));
+
+        // Notify user in storage UI when key isn't hardware-backed
+        if (!mHardwareBacked) {
+            result.putString(DocumentsContract.EXTRA_INFO,
+                    getContext().getString(R.string.info_software_detail));
+        }
+
+        try {
+            final EncryptedDocument doc = getDocument(Long.parseLong(parentDocumentId));
+            final JSONObject meta = doc.readMetadata();
+            final JSONArray children = meta.getJSONArray(KEY_CHILDREN);
+            for (int i = 0; i < children.length(); i++) {
+                final long docId = children.getLong(i);
+                includeDocument(result, docId);
+            }
+
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        } catch (GeneralSecurityException e) {
+            throw new IllegalStateException(e);
+        } catch (JSONException e) {
+            throw new IllegalStateException(e);
+        }
+
+        return result;
+    }
+
+    @Override
+    public ParcelFileDescriptor openDocument(
+            String documentId, String mode, CancellationSignal signal)
+            throws FileNotFoundException {
+        final long docId = Long.parseLong(documentId);
+
+        try {
+            final EncryptedDocument doc = getDocument(docId);
+            if ("r".equals(mode)) {
+                return startRead(doc);
+            } else if ("w".equals(mode) || "wt".equals(mode)) {
+                return startWrite(doc);
+            } else {
+                throw new IllegalArgumentException("Unsupported mode: " + mode);
+            }
+        } catch (IOException e) {
+            throw new IllegalStateException(e);
+        } catch (GeneralSecurityException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+
+    /**
+     * Kick off a thread to handle a read request for the given document.
+     * Internally creates a pipe and returns the read end for returning to a
+     * remote process.
+     */
+    private ParcelFileDescriptor startRead(final EncryptedDocument doc) throws IOException {
+        final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
+        final ParcelFileDescriptor readEnd = pipe[0];
+        final ParcelFileDescriptor writeEnd = pipe[1];
+
+        new Thread() {
+            @Override
+            public void run() {
+                try {
+                    doc.readContent(writeEnd);
+                    Log.d(TAG, "Success reading " + doc);
+                    closeQuietly(writeEnd);
+                } catch (IOException e) {
+                    Log.w(TAG, "Failed reading " + doc, e);
+                    closeWithErrorQuietly(writeEnd, e.toString());
+                } catch (GeneralSecurityException e) {
+                    Log.w(TAG, "Failed reading " + doc, e);
+                    closeWithErrorQuietly(writeEnd, e.toString());
+                }
+            }
+        }.start();
+
+        return readEnd;
+    }
+
+    /**
+     * Kick off a thread to handle a write request for the given document.
+     * Internally creates a pipe and returns the write end for returning to a
+     * remote process.
+     */
+    private ParcelFileDescriptor startWrite(final EncryptedDocument doc) throws IOException {
+        final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
+        final ParcelFileDescriptor readEnd = pipe[0];
+        final ParcelFileDescriptor writeEnd = pipe[1];
+
+        new Thread() {
+            @Override
+            public void run() {
+                try {
+                    final JSONObject meta = doc.readMetadata();
+                    doc.writeMetadataAndContent(meta, readEnd);
+                    Log.d(TAG, "Success writing " + doc);
+                    closeQuietly(readEnd);
+                } catch (IOException e) {
+                    Log.w(TAG, "Failed writing " + doc, e);
+                    closeWithErrorQuietly(readEnd, e.toString());
+                } catch (GeneralSecurityException e) {
+                    Log.w(TAG, "Failed writing " + doc, e);
+                    closeWithErrorQuietly(readEnd, e.toString());
+                }
+            }
+        }.start();
+
+        return writeEnd;
+    }
+
+    /**
+     * Maybe remove the given value from a {@link JSONArray}.
+     *
+     * @return if the array was mutated.
+     */
+    private static boolean maybeRemove(JSONArray array, long value) throws JSONException {
+        boolean mutated = false;
+        int i = 0;
+        while (i < array.length()) {
+            if (value == array.getLong(i)) {
+                array.remove(i);
+                mutated = true;
+            } else {
+                i++;
+            }
+        }
+        return mutated;
+    }
+
+    /**
+     * Simple extension of {@link MatrixCursor} that makes it easy to provide a
+     * {@link Bundle} of extras.
+     */
+    private static class ExtrasMatrixCursor extends MatrixCursor {
+        private Bundle mExtras;
+
+        public ExtrasMatrixCursor(String[] columnNames) {
+            super(columnNames);
+        }
+
+        public void putString(String key, String value) {
+            if (mExtras == null) {
+                mExtras = new Bundle();
+            }
+            mExtras.putString(key, value);
+        }
+
+        @Override
+        public Bundle getExtras() {
+            return mExtras;
+        }
+    }
+}
diff --git a/samples/Vault/tests/Android.mk b/samples/Vault/tests/Android.mk
new file mode 100644
index 0000000..552ace2
--- /dev/null
+++ b/samples/Vault/tests/Android.mk
@@ -0,0 +1,13 @@
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+LOCAL_MODULE_TAGS := tests
+
+LOCAL_JAVA_LIBRARIES := android.test.runner
+
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+
+LOCAL_PACKAGE_NAME := VaultTests
+LOCAL_INSTRUMENTATION_FOR := Vault
+
+include $(BUILD_PACKAGE)
diff --git a/samples/Vault/tests/AndroidManifest.xml b/samples/Vault/tests/AndroidManifest.xml
new file mode 100644
index 0000000..8bdf682
--- /dev/null
+++ b/samples/Vault/tests/AndroidManifest.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.example.android.vault.tests">
+
+    <application>
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation
+            android:name="android.test.InstrumentationTestRunner"
+            android:targetPackage="com.example.android.vault"
+            android:label="Vault tests" />
+
+</manifest>
diff --git a/samples/Vault/tests/src/com/example/android/vault/EncryptedDocumentTest.java b/samples/Vault/tests/src/com/example/android/vault/EncryptedDocumentTest.java
new file mode 100644
index 0000000..54754fb
--- /dev/null
+++ b/samples/Vault/tests/src/com/example/android/vault/EncryptedDocumentTest.java
@@ -0,0 +1,251 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.android.vault;
+
+import android.os.ParcelFileDescriptor;
+import android.test.AndroidTestCase;
+import android.test.MoreAsserts;
+import android.test.suitebuilder.annotation.MediumTest;
+
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.RandomAccessFile;
+import java.nio.charset.StandardCharsets;
+import java.security.DigestException;
+import java.util.Arrays;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Tests for {@link EncryptedDocument}.
+ */
+@MediumTest
+public class EncryptedDocumentTest extends AndroidTestCase {
+
+    private File mFile;
+
+    private SecretKey mDataKey = new SecretKeySpec(new byte[] {
+            0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
+            0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01 }, "AES");
+
+    private SecretKey mMacKey = new SecretKeySpec(new byte[] {
+            0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02,
+            0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02,
+            0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02,
+            0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02, 0x02 }, "AES");
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mFile = new File(getContext().getFilesDir(), "meow");
+    }
+
+    @Override
+    protected void tearDown() throws Exception {
+        super.tearDown();
+
+        for (File f : getContext().getFilesDir().listFiles()) {
+            f.delete();
+        }
+    }
+
+    public void testEmptyFile() throws Exception {
+        mFile.createNewFile();
+        final EncryptedDocument doc = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+
+        try {
+            doc.readMetadata();
+            fail("expected metadata to throw");
+        } catch (IOException expected) {
+        }
+
+        try {
+            final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
+            doc.readContent(pipe[1]);
+            fail("expected content to throw");
+        } catch (IOException expected) {
+        }
+    }
+
+    public void testNormalMetadataAndContents() throws Exception {
+        final byte[] content = "KITTENS".getBytes(StandardCharsets.UTF_8);
+        testMetadataAndContents(content);
+    }
+
+    public void testGiantMetadataAndContents() throws Exception {
+        // try with content size of prime number >1MB
+        final byte[] content = new byte[1298047];
+        Arrays.fill(content, (byte) 0x42);
+        testMetadataAndContents(content);
+    }
+
+    private void testMetadataAndContents(byte[] content) throws Exception {
+        final EncryptedDocument doc = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+        final byte[] beforeContent = content;
+
+        final ParcelFileDescriptor[] beforePipe = ParcelFileDescriptor.createReliablePipe();
+        new Thread() {
+            @Override
+            public void run() {
+                final FileOutputStream os = new FileOutputStream(beforePipe[1].getFileDescriptor());
+                try {
+                    os.write(beforeContent);
+                    beforePipe[1].close();
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        }.start();
+
+        // fully write metadata and content
+        final JSONObject before = new JSONObject();
+        before.put("meow", "cake");
+        doc.writeMetadataAndContent(before, beforePipe[0]);
+
+        // now go back and verify we can read
+        final JSONObject after = doc.readMetadata();
+        assertEquals("cake", after.getString("meow"));
+
+        final CountDownLatch latch = new CountDownLatch(1);
+        final ParcelFileDescriptor[] afterPipe = ParcelFileDescriptor.createReliablePipe();
+        final byte[] afterContent = new byte[beforeContent.length];
+        new Thread() {
+            @Override
+            public void run() {
+                final FileInputStream is = new FileInputStream(afterPipe[0].getFileDescriptor());
+                try {
+                    int i = 0;
+                    while (i < afterContent.length) {
+                        int n = is.read(afterContent, i, afterContent.length - i);
+                        i += n;
+                    }
+                    afterPipe[0].close();
+                    latch.countDown();
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        }.start();
+
+        doc.readContent(afterPipe[1]);
+        latch.await(5, TimeUnit.SECONDS);
+
+        MoreAsserts.assertEquals(beforeContent, afterContent);
+    }
+
+    public void testNormalMetadataOnly() throws Exception {
+        final EncryptedDocument doc = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+
+        // write only metadata
+        final JSONObject before = new JSONObject();
+        before.put("lol", "wut");
+        doc.writeMetadataAndContent(before, null);
+
+        // verify we can read
+        final JSONObject after = doc.readMetadata();
+        assertEquals("wut", after.getString("lol"));
+
+        final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
+        try {
+            doc.readContent(pipe[1]);
+            fail("found document content");
+        } catch (IOException expected) {
+        }
+    }
+
+    public void testCopiedFile() throws Exception {
+        final EncryptedDocument doc1 = new EncryptedDocument(1, mFile, mDataKey, mMacKey);
+        final EncryptedDocument doc4 = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+
+        // write values for doc1 into file
+        final JSONObject meta1 = new JSONObject();
+        meta1.put("key1", "value1");
+        doc1.writeMetadataAndContent(meta1, null);
+
+        // now try reading as doc4, which should fail
+        try {
+            doc4.readMetadata();
+            fail("somehow read without checking docid");
+        } catch (DigestException expected) {
+        }
+    }
+
+    public void testBitTwiddle() throws Exception {
+        final EncryptedDocument doc = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+
+        // write some metadata
+        final JSONObject before = new JSONObject();
+        before.put("twiddle", "twiddle");
+        doc.writeMetadataAndContent(before, null);
+
+        final RandomAccessFile f = new RandomAccessFile(mFile, "rw");
+        f.seek(f.length() - 4);
+        f.write(0x00);
+        f.close();
+
+        try {
+            doc.readMetadata();
+            fail("somehow passed hmac");
+        } catch (DigestException expected) {
+        }
+    }
+
+    public void testErrorAbortsWrite() throws Exception {
+        final EncryptedDocument doc = new EncryptedDocument(4, mFile, mDataKey, mMacKey);
+
+        // write initial metadata
+        final JSONObject init = new JSONObject();
+        init.put("color", "red");
+        doc.writeMetadataAndContent(init, null);
+
+        // try writing with a pipe that reports failure
+        final byte[] content = "KITTENS".getBytes(StandardCharsets.UTF_8);
+        final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe();
+        new Thread() {
+            @Override
+            public void run() {
+                final FileOutputStream os = new FileOutputStream(pipe[1].getFileDescriptor());
+                try {
+                    os.write(content);
+                    pipe[1].closeWithError("ZOMG");
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+        }.start();
+
+        final JSONObject second = new JSONObject();
+        second.put("color", "blue");
+        try {
+            doc.writeMetadataAndContent(second, pipe[0]);
+            fail("somehow wrote without error");
+        } catch (IOException ignored) {
+        }
+
+        // verify that original metadata still in place
+        final JSONObject after = doc.readMetadata();
+        assertEquals("red", after.getString("color"));
+    }
+}
diff --git a/samples/Vault/tests/src/com/example/android/vault/VaultProviderTest.java b/samples/Vault/tests/src/com/example/android/vault/VaultProviderTest.java
new file mode 100644
index 0000000..79e5b33
--- /dev/null
+++ b/samples/Vault/tests/src/com/example/android/vault/VaultProviderTest.java
@@ -0,0 +1,101 @@
+/*
+ * Copyright (C) 2013 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.example.android.vault;
+
+import static com.example.android.vault.VaultProvider.AUTHORITY;
+import static com.example.android.vault.VaultProvider.DEFAULT_DOCUMENT_ID;
+
+import android.content.ContentProviderClient;
+import android.database.Cursor;
+import android.provider.DocumentsContract.Document;
+import android.test.AndroidTestCase;
+
+import java.util.HashSet;
+
+/**
+ * Tests for {@link VaultProvider}.
+ */
+public class VaultProviderTest extends AndroidTestCase {
+
+    private static final String MIME_TYPE_DEFAULT = "text/plain";
+
+    private ContentProviderClient mClient;
+    private VaultProvider mProvider;
+
+    @Override
+    protected void setUp() throws Exception {
+        super.setUp();
+
+        mClient = getContext().getContentResolver().acquireContentProviderClient(AUTHORITY);
+        mProvider = (VaultProvider) mClient.getLocalContentProvider();
+        mProvider.wipeAllContents();
+    }
+
+    @Override
+    protected void tearDown() throws Exception {
+        super.tearDown();
+
+        mClient.release();
+    }
+
+    public void testDeleteDirectory() throws Exception {
+        Cursor c;
+
+        final String file = mProvider.createDocument(
+                DEFAULT_DOCUMENT_ID, MIME_TYPE_DEFAULT, "file");
+        final String dir = mProvider.createDocument(
+                DEFAULT_DOCUMENT_ID, Document.MIME_TYPE_DIR, "dir");
+
+        final String dirfile = mProvider.createDocument(
+                dir, MIME_TYPE_DEFAULT, "dirfile");
+        final String dirdir = mProvider.createDocument(
+                dir, Document.MIME_TYPE_DIR, "dirdir");
+
+        final String dirdirfile = mProvider.createDocument(
+                dirdir, MIME_TYPE_DEFAULT, "dirdirfile");
+
+        // verify everything is in place
+        c = mProvider.queryChildDocuments(DEFAULT_DOCUMENT_ID, null, null);
+        assertContains(c, "file", "dir");
+        c = mProvider.queryChildDocuments(dir, null, null);
+        assertContains(c, "dirfile", "dirdir");
+
+        // should remove children and parent ref
+        mProvider.deleteDocument(dir);
+
+        c = mProvider.queryChildDocuments(DEFAULT_DOCUMENT_ID, null, null);
+        assertContains(c, "file");
+
+        mProvider.queryDocument(file, null);
+
+        try { mProvider.queryDocument(dir, null); } catch (Exception expected) { }
+        try { mProvider.queryDocument(dirfile, null); } catch (Exception expected) { }
+        try { mProvider.queryDocument(dirdir, null); } catch (Exception expected) { }
+        try { mProvider.queryDocument(dirdirfile, null); } catch (Exception expected) { }
+    }
+
+    private static void assertContains(Cursor c, String... docs) {
+        final HashSet<String> set = new HashSet<String>();
+        while (c.moveToNext()) {
+            set.add(c.getString(c.getColumnIndex(Document.COLUMN_DISPLAY_NAME)));
+        }
+
+        for (String doc : docs) {
+            assertTrue(doc, set.contains(doc));
+        }
+    }
+}