org.sufficientlysecure.privacybox.VaultProvider.java Source code

Java tutorial

Introduction

Here is the source code for org.sufficientlysecure.privacybox.VaultProvider.java

Source

/*
 * Copyright (C) 2014 Dominik Schrmann <dominik@dominikschuermann.de>
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package org.sufficientlysecure.privacybox;

import android.content.Context;
import android.content.Intent;
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.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.Messenger;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.provider.DocumentsContract.Document;
import android.provider.DocumentsContract.Root;
import android.provider.DocumentsProvider;
import android.util.Log;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import org.openintents.openpgp.IOpenPgpService;
import org.openintents.openpgp.util.OpenPgpServiceConnection;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.security.GeneralSecurityException;

import static org.sufficientlysecure.privacybox.Utils.closeQuietly;
import static org.sufficientlysecure.privacybox.Utils.closeWithErrorQuietly;

/**
 * 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.
 */
public class VaultProvider extends DocumentsProvider {
    public static final String TAG = "PrivacyBox";

    static final String AUTHORITY = "org.sufficientlysecure.privacybox.provider";

    static final String DEFAULT_ROOT_ID = "privacybox";
    static final String DEFAULT_DOCUMENT_ID = "0";

    /**
     * JSON key storing array of all children documents in a directory.
     */
    public static final String KEY_CHILDREN = "privacybox:children";

    /**
     * Key pointing to next available document ID.
     */
    private static final String PREF_NEXT_ID = "next_id";

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

    private final Object mUILock = new Object();

    private OpenPgpServiceConnection mServiceConnection;

    private static final String OPEN_KEYCHAIN_PACKAGE = "org.sufficientlysecure.keychain";

    /**
     * Directory where all encrypted documents are stored.
     */
    private File mDocumentsDir;

    @Override
    public boolean onCreate() {
        Log.d(TAG, "VaultProvider.onCreate");

        mDocumentsDir = new File(getContext().getFilesDir(), "documents");
        mDocumentsDir.mkdirs();

        mServiceConnection = new OpenPgpServiceConnection(getContext(), OPEN_KEYCHAIN_PACKAGE,
                new OpenPgpServiceConnection.OnBound() {
                    @Override
                    public void onBound(IOpenPgpService service) {
                        Log.d(TAG, "onBound");

                        try {
                            // ensure that our root document is ready.
                            initDocument(Long.parseLong(DEFAULT_DOCUMENT_ID), Document.MIME_TYPE_DIR, "root");
                        } catch (IOException e) {
                            throw new IllegalStateException(e);
                        } catch (GeneralSecurityException e) {
                            throw new IllegalStateException(e);
                        }
                    }

                    @Override
                    public void onError(Exception e) {
                        Log.e(TAG, "exception when binding to service!", e);
                    }
                });

        mServiceConnection.bindToService();

        // TODO
        // I was not able to do bindToService to the provider via a thread and wait/notify...
        // maybe binding does not work from every thread???
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return true;
    }

    /**
     * Used for testing.
     */
    void wipeAllContents() throws IOException, GeneralSecurityException {
        for (File f : mDocumentsDir.listFiles()) {
            f.delete();
        }
    }

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

        row.add(Root.COLUMN_SUMMARY, "todo: display user id?");

        return result;
    }

    private EncryptedDocument getDocument(long docId) throws GeneralSecurityException {
        return new EncryptedDocument(docId, mDocumentsDir, getContext(), mServiceConnection);
    }

    /**
     * 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));

        // TODO: Extra inf below? not for special root folder!
        //        result.putString(DocumentsContract.EXTRA_INFO, "bla");

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

    Integer openResult;

    @Override
    public ParcelFileDescriptor openDocument(String documentId, String mode, final CancellationSignal signal)
            throws FileNotFoundException {
        final long docId = Long.parseLong(documentId);
        try {
            final EncryptedDocument doc = getDocument(docId);

            if ("r".equals(mode)) {
                /*
                 * READ MODE
                 */

                JSONObject meta = doc.readMetadata();
                String filename = "no filename";
                try {
                    filename = meta.getString(Document.COLUMN_DISPLAY_NAME);
                } catch (JSONException e) {
                    Log.e(TAG, "JSONException getting filename", e);
                }

                openResult = -1;
                Handler handler = new Handler(Looper.getMainLooper(), new Handler.Callback() {
                    @Override
                    public boolean handleMessage(Message msg) {
                        synchronized (mUILock) {
                            openResult = msg.what;
                            mUILock.notify();
                        }
                        return true;
                    }
                });

                // start dialog activity and wait here for it finishing...
                Intent dialog = new Intent(getContext(), OpenDialogActivity.class);
                dialog.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                dialog.putExtra(OpenDialogActivity.EXTRA_MESSENGER, new Messenger(handler));
                dialog.putExtra(OpenDialogActivity.EXTRA_FILENAME, filename);

                getContext().startActivity(dialog);

                Log.d(TAG, "mUILock.wait");
                synchronized (mUILock) {
                    try {
                        mUILock.wait();
                    } catch (InterruptedException e) {
                        Log.e(TAG, "interrupt", e);
                    }
                }
                Log.d(TAG, "after mUILock.wait");

                Log.d(TAG, "openResult:" + openResult);
                switch (openResult) {
                case OpenDialogActivity.MSG_DECRYPT_OPEN:
                    Log.d(TAG, "MSG_DECRYPT_OPEN");

                    // TODO
                    return startRead(doc);
                case OpenDialogActivity.MSG_GET_ENCRYPTED:
                    Log.d(TAG, "MSG_GET_ENCRYPTED");

                    // TODO
                    return startRead(doc);
                case OpenDialogActivity.MSG_CANCEL:
                    Log.d(TAG, "MSG_CANCEL");
                    return null;
                default:
                    Log.e(TAG, "should not happen");
                    return null;
                }

            } else if ("w".equals(mode) || "wt".equals(mode)) {
                /*
                 * WRITE 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 {
                    // get already written metadata from file
                    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;
        }
    }
}