読者です 読者をやめる 読者になる 読者になる

Android4.4 Storage Access Framework解説

Android 4.4 KitKat 冬コミ原稿リレーを開催」の11/10担当のtomorrowkeyです。

本記事ではAndroid 4.4で追加されたStorage Access Frameworkの解説と、どう実装すればいいのか実際にアプリケーションを作りながら学びます。

Googleさんに「お前はまだカナダにいる!」と判定されたためNexus5を発売日当日に購入することができませんでした。後日住所を変更して無事オーダーできまして、今日届くはずなんですが、前住んでいたところに配送されてしまい、まだ手にしていません。っていってるまに届いたうわああやったああああ
Nexus5届いだけけどエミュレータで動作確認しました。

サンプルコード

今回作成したコードはGitHubにて公開しています。

tomorrowkey/storage-access-framework-sample
https://github.com/tomorrowkey/storage-access-framework-sample

Storage Access Framework概要

Storage Access FrameworkはAndroid 4.4 API(Level 19)で新しく追加された機能です。 Storage Access Frameworkはアプリ間でのファイルのやりとりを助けます。

Android4.4以前でファイルのやり取りを行うにはACTION_PICKやACTION_GET_CONTENTを使ってIntentを発行し、ファイルを提供する側はエクスプローラのようなUIを提供し、ファイルを選択する必要がありました。 Storage Access Frameworkではファイルを選択するためのピッカーと呼ばれるUIがシステムから提供されます。 f:id:tomorrowkey:20150425102517p:plainストレージを提供するアプリ(例えばGoogle DriveDropboxなど)はDocumentsProviderと呼ばれる、ContentProviderを拡張した仕組みを用意するだけで使用できます。 ユーザはピッカーを使うことで、すべてのDocumentsProviderを横断的に使用することができます。

ストレージを提供するアプリにはGoogle DriveDropboxなどとオンラインストレージサービスを例に挙げましたが、他にも内部ストレージやSDカードやUSBに接続したドライブなど物理ストレージのプロバイダーも作成することができます。

Storage Access Frameworkを使ったサンプル

早速Storage Access Frameworkを使ったサンプルを見てみましょう。
とあるテキストエディターにStorage Access Frameworkを使ったファイル選択、保存、削除機能を追加します。 さらに仮想のオンラインストレージを提供するMyCloudというアプリも作ります。

テキストエディターをStorage Access Frameworkに対応する

テキストエディターをStorage Access Frameworkに対応する手順は以下のとおりです。

  • ファイルを選択する
  • ファイルの読み込み
  • メタデータの取得
  • 新しいファイルの作成
  • ファイルの書き込み
  • ファイルの削除

ファイルを選択する

ファイルを選択するにはIntent.ACTION_OPEN_DOCUMENT を使います。また、ファイルの種類を制限するためにMIMEタイプも指定します。

Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.setType("text/plain");
startActivityForResult(intent, OPEN_DOCUMENT_REQUEST);

このIntentを発行すると以下の様な画面になり、ファイルを選択することができます。
これがシステムから提供されるピッカーです。 Intent発行時にtext/plainとMIMEタイプを制限したので、選択できるファイルはテキストファイルのみに制限され、他のファイルタイプは選択することができません。

f:id:tomorrowkey:20150425102517p:plain

テキストファイルを開くとIntentにファイルのUriが設定されてonActivityResultに返ってきます。 onActivityResultには以下のように書くといいでしょう。

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == OPEN_DOCUMENT_REQUEST) {
        if (resultCode != RESULT_OK)
            return;

        Uri uri = data.getData();
        launchEditorActivity(uri);
    }
}

private void launchEditorActivity(Uri uri) {
    Intent intent = new Intent(this, EditorActivity.class);
    intent.setData(uri);
    startActivity(intent);
}

テキストの編集はEditorActivityクラスで行います。
onActivityResultでテキストファイルのUriを取得したら、launchEditorActivityメソッドにてEditorActivityを起動します。もちろん取得したUriを一緒に渡します。

ファイルの読み込み

テキストエディターなので選択されたファイルを表示する必要があります。 Uriからファイルを開くにはContentResolver.openInputStream(Uri) を使用します。

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_editor);

    Intent intent = getIntent();
    mUri = intent.getData();

    initEditText(mUri);
}

private void initEditText(Uri uri) {
    mEditText = (EditText) findViewById(R.id.edit_text);

    if (uri == null)
        return;

    InputStream inputStream = null;
    StringBuilder stringBuilder = new StringBuilder();

    try {
        inputStream = getContentResolver().openInputStream(mUri);
        BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
        String line;
        while ((line = reader.readLine()) != null) {
            stringBuilder.append(line);
        }

        mEditText.setText(stringBuilder.toString());
    } catch (FileNotFoundException e) {
        throw new RuntimeException(e);
    } catch (IOException e) {
        throw new RuntimeException(e);
    } finally {
        IOUtil.forceClose(inputStream);
    }

}

メタデータの取得

さらにファイル名をタイトル部分に表示すると親切です。
Uriからファイル名を取得するにはContentResolver.queryを使用します。

private void initTitle(Uri uri) {
    if (uri == null) {
        setTitle("Untitled");
        return;
    } else {
        Cursor cursor = getContentResolver().query(uri, null, null, null, null, null);
        try {
            cursor.moveToFirst();
            String displayName = cursor.getString(cursor
                    .getColumnIndex(OpenableColumns.DISPLAY_NAME));
            setTitle(displayName);
        } finally {
            IOUtil.forceClose(cursor);
        }
    }

}

ここではファイル名だけ取得していますが、その他にファイルサイズや最終更新日などが取得できます。

  • document_id
    • ドキュメントID(後述します)
  • mime_type
  • _display_name
    • ファイル名
  • summary
    • サマリ
  • last_modified
    • 最終降臨日
  • flags
    • フラグ
  • _size
    • ファイルサイズ

中でもflagsはStorage Access Frameworkを使ったアプリを作る上で重要です。
DocumentsContract.DocumentクラスにFLAG~ という名前で定義されている値が論理和で格納されます。
特にクライアントを作る上で重要なのが以下の2つです。

  • Document.FLAG_SUPPORTS_DELETE
    ファイルを削除することができる場合、flagsに含まれます。

  • Document.FLAG_SUPPORTS_WRITE
    ファイルに書き込みができる場合、flagsに含まれます。

例えばDocument.FLAG_SUPPORTS_DELETE が設定されていない場合は削除ボタンを無効にしてあげるなど対応が必要です。

新しいファイルの作成

新しいファイルの作成にはIntent.ACTION_CREATE_DOCUMENT を使用します。

private void createNewFile() {
    Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
    intent.setType("text/plain");
    intent.putExtra(Intent.EXTRA_TITLE, "Untitled.txt");
    startActivityForResult(intent, CREATE_DOCUMENT_REQUEST);
}

ファイルを開くときと同様、MIMEタイプを設定します。
またデフォルトのファイル名をIntent.EXTRA_TITLEキーでIntentに追加します。これはデフォルトのファイル名ですので、ピッカーにてユーザが任意のファイル名に変更することができます。

f:id:tomorrowkey:20150425102927p:plain

Saveボタンを押すと空ファイルが作成され、そのファイルのUriを呼び出し元に返します。

ファイルの書き込み

Intent.ACTION_CREATE_DOCUMENTのIntentを発行することで新しいファイルを作成することができました。
ですが、この新しいファイルは0Bytesでまだ中身はありません。中身はStreamを開いて書き込む必要があります。
Streamを取得するにはContentResolver.openOutputStream(Uri)を使用します。

private void save(Uri uri, String text) throws FileNotFoundException, IOException {
    OutputStream outputStream = null;
    try {
        outputStream = getContentResolver().openOutputStream(uri);
        BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream));
        writer.write(text);
        writer.flush();
    } finally {
        IOUtil.forceClose(outputStream);
    }
}

ファイルの削除

ファイルの削除にはDocumentsContract.deleteDocument(ContentResolver, Uri) を使用します。
戻り値には削除できたかどうかが返ってきます。

private boolean delete(Uri uri) {
    boolean result = DocumentsContract.deleteDocument(getContentResolver(), uri);
    return result;
}

クライアントのStorage Access Framework対応完了

これでテキストエディターをStorage Access Frameworkに対応させることができました。
Storage Access Frameworkには他にもPersist permissionsなどの機能があります。詳しくは公式ドキュメントをご覧ください。

Storage Access Framework | Android Developers
https://developer.android.com/guide/topics/providers/document-provider.html

続いてDocumentProviderを作成します。

DocumentProviderアプリケーションを作る

MyCloudというアプリを作ります。MyCloudはMyCloudProviderというDocumentProviderを提供します。MyCloudはオンラインストレージサービスを想定して名前をつけましたが、さすがにWebサービスまで作りこむわけにはいかないので、アプリケーションのデータディレクトリ(通常であれば/data/data/com.example.mycloud/files/となります)をオンラインストレージとして見立てます。
通常、このディレクトリ内は他のアプリケーションからアクセスできませんが、Storage Access Frameworkを使うことによってアクセスできるようになります。

Storage Access Frame/workのDocumentProviderを作成する手順は以下の通りです。

  • DocumentProviderを継承する
  • onCreateを実装する
  • queryRootsを実装する
  • queryDocumentを実装する
  • queryChildDocumentsを実装する
  • openDocumentを実装する
  • AndroidManifestにContentProviderの追加する

DocumentProviderを継承する

まずはDocumentProviderを継承します。

DocumentsProvider | Android Developers
https://developer.android.com/reference/android/provider/DocumentsProvider.html

DocumentProviderは抽象クラスなので、以下のメソッドを実装する必要があります。

  • onCreate
  • queryRoots
  • queryDocument
  • queryChildDocuments
  • openDocument

DocumentProviderとして機能を増やすには、他のメソッドを実装する必要がありますが、最低限の実装としてはこれで問題ありません。

実際のコードは以下のようになります。

import android.database.Cursor;
import android.os.CancellationSignal;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsProvider;

import java.io.FileNotFoundException;

public class MyCloudProvider extends DocumentsProvider {

    @Override
    public boolean onCreate() {
        return false;
    }

    @Override
    public Cursor queryRoots(String[] projection) throws FileNotFoundException {
        return null;
    }

    @Override
    public Cursor queryDocument(String documentId, String[] projection)
            throws FileNotFoundException {
        return null;
    }

    @Override
    public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder)
            throws FileNotFoundException {
        return null;
    }

    @Override
    public ParcelFileDescriptor openDocument(String documentId, String mode,
            CancellationSignal signal) throws FileNotFoundException {
        return null;
    }

}

1つずつ実装していきましょう。

onCreateを実装する

DocumentProvider.onCreateメソッドはContentProvider.onCreateと同じです。 ContentProviderとしての初期化処理をここに書きます。
初期化に成功した場合はtrueを返します。 今回は特に初期化をする必要はないので、trueを返すだけにしましょう。

@Override
public boolean onCreate() {
    return true;
}

queryRootsを実装する

どのようなルートなのかという情報を返します。ピッカーが起動した時やDocumentProviderを提供するアプリがアップデートされた際に呼ばれます。
ルートという概念が初めてでてきたので、解説しましょう。 ルートはひとつのドキュメントを指していて、そのドキュメントからいわゆるファイルツリーが連なっていきます。一つのDocumentProviderはひ1つ以上のルートをもっています。 つまり、ルートは一つのファイルツリーを表す言葉です。

f:id:tomorrowkey:20150425102948p:plain

ピッカーでは左側にルートの一覧が表示されます。

f:id:tomorrowkey:20150425103048p:plain

通常1つのDocumentProviderは1つのルートを持っていますが、中には複数持つ場合もあります。 例えばとあるオンラインストレージアプリでは複数のアカウントでログインできるという仕様だった場合、ユーザAでのルートと、ユーザBでのルートの2つのルートを提供することになります。

以上を踏まえて実装してみます。

@Override
public Cursor queryRoots(String[] projection) throws FileNotFoundException {
    final MatrixCursor cursor = new MatrixCursor(resolveRootProjection(projection));

    {
        final MatrixCursor.RowBuilder row = cursor.newRow();
        row.add(Root.COLUMN_ROOT_ID, MyCloudProvider.class.getName() + ".tomorrowkey");
        row.add(Root.COLUMN_SUMMARY, "tomorrowkey");
        row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH);
        row.add(Root.COLUMN_TITLE, "MyCloud");
        row.add(Root.COLUMN_DOCUMENT_ID, "/tomorrowkey");
        row.add(Root.COLUMN_MIME_TYPES, "*/*");
        row.add(Root.COLUMN_AVAILABLE_BYTES, Integer.MAX_VALUE);
        row.add(Root.COLUMN_ICON, R.drawable.ic_launcher);
    }

    {
        final MatrixCursor.RowBuilder row = cursor.newRow();
        row.add(Root.COLUMN_ROOT_ID, MyCloudProvider.class.getName() + ".guest");
        row.add(Root.COLUMN_SUMMARY, "Guest");
        row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_SEARCH);
        row.add(Root.COLUMN_TITLE, "MyCloud");
        row.add(Root.COLUMN_DOCUMENT_ID, "/guest");
        row.add(Root.COLUMN_MIME_TYPES, "*/*");
        row.add(Root.COLUMN_AVAILABLE_BYTES, Integer.MAX_VALUE);
        row.add(Root.COLUMN_ICON, R.drawable.ic_launcher);
    }

    return cursor;
}

private String[] resolveRootProjection(String[] projection) {

    if (projection == null || projection.length == 0) {
        return new String[] {
                Root.COLUMN_ROOT_ID,
                Root.COLUMN_MIME_TYPES,
                Root.COLUMN_FLAGS,
                Root.COLUMN_ICON,
                Root.COLUMN_TITLE,
                Root.COLUMN_SUMMARY,
                Root.COLUMN_DOCUMENT_ID,
                Root.COLUMN_AVAILABLE_BYTES,
        };
    } else {
        return projection;
    }
}

ここではtomorrowkeyというユーザとGuestというユーザのルートをそれぞれ作りました。
実際にピッカーにはこのように表示されます。

f:id:tomorrowkey:20150425103009p:plain

queryRootsの戻り値はCursorです。自由に内容を作れるMatrixCursorを使ってCursorを作っていきます。
引数のprojectionには、ピッカーがほしい列名が入っています。 すべての列名がほしい場合、nullが入っていることがあるので、nullだった場合はすべての列名を含むようにします。 すべての列名はRootクラスに定義されていますので、それを使用します。

DocumentsContract.Root | Android Developers
http://developer.android.com/reference/android/provider/DocumentsContract.Root.html

どれにどのような値を入れるべきかについては以下の通りです。

  • root_id
    ルートを一意に識別する値です。
    公式ドキュメントにはどのように一意にするべきか記載されていませんが、パッケージ名+ルート名とすると自然と一意になり良いのではないでしょうか。 この値は必須です。

  • mime_types
    このルートがサポートするMIMEタイプです。
    例えば写真ライブラリを提供するルートであれば"image/" を指定しておきましょう。そうするとテキストファイルを開きたくてピッカーを起動した時に、このルートは表示されません。逆に写真を開きたくてStorage Access Frameworkを起動すればこのルートが表示されます。 改行で区切れば複数MIMEタイプをサポートすることができます。
    この値はオプションです。もしnullを入れた場合は"
    /*"を指定した場合と同じですべてのMIMEタイプをサポートすることになります。

  • flags ルートの属性を表します。以下の定数を論理和で格納します。
    この値は必須です。

    • Root.FLAG_LOCAL_ONLY
      ローカルストレージのみで構成されていることを表します。ネットワークを使用しません。

    • Root.FLAG_SUPPORTS_CREATE
      新規ドキュメント作成をサポートすることを表します。

    • Root.FLAG_SUPPORTS_RECENTS
      最近開かれたドキュメントリストを提供できることを表します。
      DocumentsProvider.queryRecentDocuments を実装する必要があります。

    • Root.FLAG_SUPPORTS_SEARCH
      検索に対応していることを表します。
      DocumentsProvider.querySearchDocuments を実装する必要があります。

  • icon
    ピッカーに表示されるアイコンのリソースIDを格納します。
    この値は必須です。

  • title
    ピッカーに表示されるルートの名前を格納します。
    この値にはサービスの名前が格納されるのが望ましいです。
    この値は必須です。

  • summary
    ピッカーに表示されるルートの概要を格納します。
    ルートを解説した際にも触れましたが、複数アカウントのファイルツリーを提供するために、DocumentProviderが複数のルートを持っている場合があります。この値にはそれぞれのアカウント名を格納するのが望ましいです。
    この値はオプションです。

  • document_id
    ルートに結びついている最上位のドキュメントの名前を格納します。
    この値は必須です。

  • available_bytes
    このルートで使える容量を格納します。
    この値はオプションです。

queryDocumentを実装する

特定のドキュメントの情報を返します。 以下のように実装します。

@Override
public Cursor queryDocument(String documentId, String[] projection)
        throws FileNotFoundException {
    MatrixCursor cursor = new MatrixCursor(resolveDocumentProjection(projection));
    includeFile(cursor, documentId);

    return cursor;
}

private String[] resolveDocumentProjection(String[] projection) {
    if (projection == null || projection.length == 0) {
        return new String[] {
                Document.COLUMN_DOCUMENT_ID,
                Document.COLUMN_MIME_TYPE,
                Document.COLUMN_DISPLAY_NAME,
                Document.COLUMN_LAST_MODIFIED,
                Document.COLUMN_FLAGS,
                Document.COLUMN_SIZE,
        };
    } else {
        return projection;
    }
}

private void includeFile(MatrixCursor cursor, String documentId) {
    String filePath = getContext().getFilesDir().getPath() + "/" + documentId;
    File file = new File(filePath);

    RowBuilder row = cursor.newRow();
    row.add(Document.COLUMN_DOCUMENT_ID, documentId);
    if (file.isDirectory()) {
        row.add(Document.COLUMN_MIME_TYPE, Document.MIME_TYPE_DIR);
        row.add(Document.COLUMN_FLAGS, Document.FLAG_DIR_SUPPORTS_CREATE);
        row.add(Document.COLUMN_SIZE, 0);
    } else if (file.getName().endsWith(".txt")) {
        row.add(Document.COLUMN_MIME_TYPE, "text/plain");
        row.add(Document.COLUMN_FLAGS, Document.FLAG_SUPPORTS_DELETE
                | Document.FLAG_SUPPORTS_WRITE);
        row.add(Document.COLUMN_SIZE, file.length());
    } else {
        throw new RuntimeException("Unknown file type, file name=" + file.getName());
    }
    row.add(Document.COLUMN_DISPLAY_NAME, file.getName());
    row.add(Document.COLUMN_LAST_MODIFIED, file.lastModified());
}

queryDocumentもqueryRootsと同じくCursorにDocumentの情報を格納して返します。
引数のprojectionには、ピッカーがほしい列名が入っています。
すべての値がほしい場合、nullが入っていることがあります。nullだった場合はすべての列名を含むようにします。 すべての列名はDocumentsContract.Documentクラスに定義されているので、それを使用します。

DocumentsContract.Document | Android Developers
http://developer.android.com/reference/android/provider/DocumentsContract.Document.html

どの列にどのような値を入れるべきかについては以下の通りです。

  • _display_name
    ファイル名を格納します。
    この値は必須です。

  • document_id
    ドキュメントIDを格納します。
    ドキュメントIDとは、DocumentProvider内で一意にドキュメントを識別できる値です。ここではアプリのデータディレクトリ配下のパスをドキュメントIDとして使っています。
    この値は必須です。

  • flags
    ドキュメントの属性を表します。
    以下の定数を論理和で格納します。
    この値は必須です。

    • Document.FLAG_SUPPORTS_WRITE
      このドキュメントへの書き込みに対応していることを表します。

    • Document.FLAG_SUPPORTS_DELETE
      このドキュメントの削除に対応していることを表します。

    • Document.FLAG_SUPPORTS_THUMBNAIL
      このドキュメントのサムネイルがあることを表します。
      写真などはこのフラグを設定しておくと、ユーザはピッカーでサムネイルを見ながらドキュメントを選択できます。
      DocumentProvider.openDocumentThumbnail を実装する必要があります。

    • Document.FLAG_DIR_PREFERS_GRID
      ドキュメントがディレクトリの時に設定できます。
      このディレクトリ内の表示はグリッド表示が望ましいことを表します。
      例えばディレクトリ内のほとんどを写真が占めている場合、このフラグを設定するとよいでしょう。

    • Document.FLAG_DIR_PREFERS_LAST_MODIFIED
      ドキュメントがディレクトリの時に設定できます。
      このディレクトリ内のファイルは最終更新日順で表示されることが望ましいことを表します。

  • icon
    このドキュメントのアイコンのリソースIDを格納します。
    この値はオプションです。

  • last_modified
    このドキュメントの最終更新日を格納します。形式はUNIX Timeです。  最終更新日がわからない場合はnullを格納します。
    この値は必須です。

  • mime_type
    "image/png"や"application/pdf"などMIMEタイプを格納します。
    ドキュメントがディレクトリの場合はDocument.MIME_TYPE_DIR を指定します。
    この値は必須です。

  • _size
    ドキュメントのファイルサイズを格納します。
    ファイルサイズが分からなかった場合、nullとします。
    この値は必須です。

  • summary
    ドキュメントの説明を格納します。
    この値はピッカーを通してユーザに表示されます。
    この値はオプションです。

queryChildDocumentsを実装する

特定のディレクトリ配下のドキュメントリストを返します。
ディレクトリ内のドキュメントの数に依存するので、0個以上の結果になります。
以下のように実装します。

@Override
public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder)
        throws FileNotFoundException {
    MatrixCursor cursor = new MatrixCursor(resolveDocumentProjection(projection));

    String parentDocumentPath = getContext().getFilesDir().getPath() + "/" + parentDocumentId;
    File dir = new File(parentDocumentPath);
    for (File file : dir.listFiles()) {
        String documentId = parentDocumentId + "/" + file.getName();
        includeFile(cursor, documentId);
    }

    return cursor;
}

このメソッドも例によってCursorを返します。
引数にはディレクトリを表すparentDocumentIdと、必要な列名を表すprojectionと、ソート順を表すsortOrderがあります。 Cursorに格納する情報はqueryDocument と同じです。

openDocumentを実装する

ファイルにアクセスするためのオブジェクトを返します。
以下のように実装します。

@Override
public ParcelFileDescriptor openDocument(final String documentId, String mode,
        CancellationSignal signal) throws FileNotFoundException {

    final File file = new File(getContext().getFilesDir().getPath() + "/" + documentId);

    boolean isWrite = (mode.indexOf('w') != -1);
    if (isWrite) {
        int accessMode = ParcelFileDescriptor.MODE_READ_WRITE;
        try {
            Handler handler = new Handler(getContext().getMainLooper());
            return ParcelFileDescriptor.open(file, accessMode,
                    handler, new ParcelFileDescriptor.OnCloseListener() {
                        @Override
                        public void onClose(IOException e) {
                            Log.i(LOG_TAG, "A file with id " + documentId
                                    + " has been closed! Time to " + "update the server.");
                        }

                    });
        } catch (IOException e) {
            throw new FileNotFoundException("Failed to open document with id "
                    + documentId + " and mode " + mode);
        }
    } else {
        int accessMode = ParcelFileDescriptor.MODE_READ_ONLY;
        return ParcelFileDescriptor.open(file, accessMode);
    }
}

ContentProvider経由でファイルへアクセスするにはParcelFileDescriptorを使います。
ParcelFileDescriptor.open(File file, int mode) を使えば、ひと通りのアクセスを渡せますが、ParcelFileDescriptor.OnCloseListenerを使うことで、ファイルを閉じたときのイベントを拾うことができます。これができると何が嬉しいかというと、例えばDropboxなどのオンラインストレージの場合、閉じられたことを契機にファイルのアップロードをすることが可能なのです。

AndroidManifestにContentProviderの追加する

最後にAndroidManifestにDocumentProviderを定義します。 DocumentProviderはContentProviderのサブクラスなので、ContentProviderと同様に定義します。

<provider android:name="com.example.mycloud.MyCloudProvider"
    android:authorities="com.example.mycloud.documents"
    android:exported="true"
    android:grantUriPermissions="true"
    android:permission="android.permission.MANAGE_DOCUMENTS" >
    <intent -filter>
        <action android:name="android.content.action.DOCUMENTS_PROVIDER"></action>
    </intent>
</provider>

ポイントとしては以下のものがあります。

  • exported
    外部アプリから呼び出せなければいけないので、trueを設定します。

  • grantUriPermissions
    他アプリからコンテンツにアクセスするために許可を与えるためにtrueを設定します。

  • permission
    このプロバイダーへアクセスするためのパーミッションを指定します。
    ピッカーはandroid.permission.MANAGE_DOCUMENTS というパーミッションを持っているので、これを指定します。そうすることで、例えば誰かが悪意をもってピッカーを装ってアクセスしようとしたとしても、ブロックすることができます。

  • intent-filter
    ピッカーがDocumentProviderを見つけるためにintent-filterでandroid.content.action.DOCUMENTS_PROVIDER を指定します。

DocumentProvider作成の完了

これでDocumentoProviderを最低限動かすための実装が完了しました。
すこし触れましたが、DocumentProviderには他にも検索に対応させるためのメソッドや最近使用したファイルに対応するためのメソッドなどがあります。 詳しくは公式ドキュメントをご覧ください。

終わりに

いままではファイルの保存やファイルを開くという機能を実装しようとしたとき、自前でエクスプローラを作る必要がありましたが、これからは作る必要はありません。また各オンラインストレージサービスが対応さえすればローカルストレージだけではなくオンラインにファイルを保存するという連携も簡単にすることができます。そうすると例えばAndroidからパソコンや他のAndroidなど端末が変わったとしてもファイルの場所を意識する必要がなくなってきます。 これはiOSでいうところのiCloudのような使いやすさになるんじゃないかと思っています。自分の好きなオンラインストレージを使えるという点ではiCloudよりメリットがあるかもしれません。 既存のオンラインストレージサービスが既存のUIを捨てて完全にStorage Access Frameworkに依存するようになるとは考えづらいですが、既存のUIを提供しつつ、DocumentProviderも提供することは自然な流れなのではないかと思います。
これからアプリを作り、ファイルの保存などをしたいということであれば、ぜひStorage Access Frameworkに対応しましょう。

参考リンク

Storage Access Framework | Android Developers
https://developer.android.com/guide/topics/providers/document-provider.html

DevBytes: Android 4.4 Storage Access Framework: Client - YouTube
http://www.youtube.com/watch?v=UFj9AEz0DHQ&list=PLWz5rJ2EKKc_XOgcRukSoKKjewFJZrKV0&index=4

DevBytes: Android 4.4 Storage Access Framework: Provider - YouTube
http://www.youtube.com/watch?v=zxHVeXbK1P4&list=PLWz5rJ2EKKc_XOgcRukSoKKjewFJZrKV0&index=5