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
This commit is contained in:
Jeff Sharkey
2013-10-31 11:25:31 -07:00
parent 4e5bae3461
commit 93de411538
12 changed files with 1588 additions and 0 deletions

15
samples/Vault/Android.mk Normal file
View File

@@ -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)

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 954 B

View File

@@ -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>

View File

@@ -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));
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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)

View File

@@ -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>

View File

@@ -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"));
}
}

View File

@@ -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));
}
}
}