はじめに
今回は、Storage Access Framework について詳細に見ていきたいと思います。
このフレームワークは、ファイルの作成・読み・書きをカプセル化しています。また、実際のファイル入出力処理をユーザが意識することはないため、オンラインストレージのファイルへのアクセスも、ローカルのファイルを扱っているのと同じ様に扱うことが出来るようになっています。
Android では、ファイルを選択し使用する場合にはインテントを受け取る側のアプリで、独自にファイルを選択する画面を作成する必要が有りました。しかし、Storage Access Framework では、ファイルを選択する際の UI も用意されているため、自作する必要がありません。また、アプリを横断して統一した UI を使用することが出来るため、ユーザにとっても分かりやすく使いやすいものになると思います。
まだ登場したばかりの機能なので、対応しているアプリケーションは多くないと思いますが、これから先対応するアプリケーションが増えていくほどに便利に感じることの出来るフレームワークになると思われます。
概要
Storage Access Framework には、大きく分けて2つの使い方があります。
- ファイルの作成・読み・書きなどの操作を行うクライアントとしての用途
- クライアントのファイル操作要求に応じて、実際にファイルの転送などの処理を行うストレージとしての用途
クライアントとして使用する場合には、システムから提供される「ピッカー」と呼ばれるインターフェースを使用することができます。このインターフェースによって、ファイルへのアクセスを従来よりもかなり容易に行うことができます。
また、ストレージとして使用する場合は、Storage Access Framework で使用する Intent に応答する独自のプロバイダーを作成することができます。これにより、内部ストレージや独自のサーバとのファイル連携を行うことも出来るようになります。
今回は、2つの用途について順番に説明したいと思います。このフレームワークは、クライアントとしての用途のみでも十分な恩恵を受けることができます。なので、まずはシンプルにクライアントとしてのみ実装してみてはいかがでしょうか。
Step 1 ピッカーを使用してストレージにアクセスしてみよう!
フレームワークを使用する第一段階として、実際にピッカーを使用することでファイルにアクセスしてみたいと思います。
今回作成したサンプルでは以下のようなファイル操作を実現します。
- 別のアプリケーションが提供する画像の取得
- 取得画像を任意の名前で保存
- 任意の画像を選択し、削除
今回はエミュレータを使用して確認を行いました。
画像を任意のアプリケーションから取得する
まずは、ピッカーを使用して画像を取得してみましょう。
ピッカーを使用するには、画像を取得したいタイミングで以下のインテントを投げます。
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); intent.setType("*/*"); startActivityForResult(intent, OPEN_DOCUMENT_REQUEST);
Intent.ACTION_OPEN_DOCUMENT というのがピッカーを使用して、ファイルを選択するための Intent になります。
setType で MIME タイプを指定することで、取得するファイルの形式を制限することができます。このインテントを投げると、以下の様なインターフェースが表示されます。
また、この時に左上のメニューボタン(横三本線の部分)をタップするとストレージを選択することが出来ます。
この画面には、先ほど説明したACTION_OPEN_DOCUMENTに応答する全てのストレージアプリケーションが表示されるため、このピッカーの画面によってアプリケーションを横断して使用することが出来ます。
ファイルを選択した結果はonActivityResult に返ってくる事になります。そのため、 startActivityForResultの引数として、どのリクエストを送ったのかわかるように値を入れておきましょう。今回指定したOPEN_DOCUMENT_REQUEST の実態は、int 型として定義したクラス変数です。
インテントを送ったのなら、onActivityResult を実装して結果を取得しましょう。
private ImageView mOpenImage; private Bitmap mBitmap; @Override protected void onActivityResult(int requestCode,int resultCode,Intent data){ if(resultCode != RESULT_OK) return; else{ switch (requestCode){ case OPEN_DOCUMENT_REQUEST: Uri open_file = data.getData(); BitmapFactory.Options mOptions = new BitmapFactory.Options(); mOptions.inSampleSize = 10; InputStream is; try { is = getContentResolver().openInputStream(open_file); // mBitmap は Bitmap 型として定義したインスタンス変数 mBitmap = BitmapFactory.decodeStream(is); is.close(); // mOpenImage は Bitmap を表示させるための ImageView のインスタンス mOpenImage.setImageBitmap(mBitmap); } catch (Exception e) { e.printStackTrace(); } break; default : break; } } }
Intent に選択したファイルの Uri が入っていますので、この Uri からファイルの情報を取得することが出来ます。
取得画像を任意の名前で保存
先ほど取得した画像を、任意の名前に変更して保存してみたいと思います。
保存も、インテントを投げることで行います。
Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.setType("*/*"); intent.putExtra(Intent.EXTRA_TITLE, "untitle.png"); startActivityForResult(intent, CREATE_DOCUMENT_REQUEST);
Intent.ACTION_CREATE_DOCUMENTというのが、ピッカーを使用してファイルを作成するための Intent になります。
取得した時と同様に MIME タイプを設定することができます。
Intent.EXTRA_TITLEで、デフォルトのファイル名を指定することが出来ます。
このインテントの結果も取得した時と同様にonActivityResult に返ってくる事になります。そのため、 同じアクティビティ内で取得と保存を行う際にstartActivityForResultに渡す引数によって分岐する必要があります。今回指定しているCREATE_DOCUMENT_REQUESTも、実態は、int 型として定義したクラス変数です。
これで無事に、任意の名前でファイルを作成することが出来ました。しかし、作成しただけであってこの時点ではまだファイルの中身は空の状態になっていますのでその中身を作成する必要が有ります。
@Override protected void onActivityResult(int requestCode,int resultCode,Intent data){ if(resultCode != RESULT_OK) return; else{ switch (requestCode){ case CREATE_DOCUMENT_REQUEST: Uri create_file = data.getData(); OutputStream os; try { os = getContentResolver().openOutputStream(create_file); //mBitmap 画像を取得した際に保持したインスタンス変数 mBitmap.compress(Bitmap.CompressFormat.PNG, 100, os); } catch(Exception e){} break; default: break; } } }
作成時の onActivityResult で渡る Intent に、今作成したファイルの Uri が入っているので、この Uri が指すファイルに対してデータを書き出すことでファイルの保存が完了します。
ファイルを削除する
取得と保存ができたので、最後は削除を行ってみたいと思います。
boolean result = DocumentsContract.deleteDocument(getContentResolver(), uri);
ファイルを削除するために DocumentsContract.deleteDocument(ContentResolver, Uri) を使用します。
引数に渡す Uri が指すファイルを削除することが出来ます。
戻り値には、削除できたかどうかの結果が返ってきます。
Step 2 独自のストレージを作成してみよう!
次は、アプリケーションが独自のストレージを提供することにチャレンジしてみたいと思います。今から説明するサンプルでは、内部ストレージを使用していますが、入出力処理さえ実装すれば、オンラインのストレージへファイルをアップロードすることも可能でしょう。
ストレージと連携するためには、DocumentsProvider というクラスを継承します。この DocumentsProvider 自体も ContentProvider を継承したサブクラスになっています。
実際にアプリケーションを作成するためには以下のメソッドを実装する必要が有ります。
メソッド名 | 説明 |
---|---|
onCreate | 初期化処理を書くメソッド。アプリ起動時に、毎回初めに呼び出されます。 |
queryRoots | このアプリケーションがサポートするルートを定義します。初めて、Strage Access Framework が応答するインテントが投げられた時に呼び出されます。 |
queryDocument | ファイルツリーのルートになるトップドキュメントを定義します。定義した Root がピッカーにより選択される度に呼び出されます。 |
queryChildDocuments | ファイルツリーに存在するファイルの情報を更新します。トップドキュメントの定義が終わり、ファイルツリーを更新するタイミングで呼び出されます。 |
openDocument | ファイルを開く処理を担当します。ACTION_OPEN_DOCUMENT に応答したピッカーにより、ファイルが選択されたタイミングで呼び出されます。 |
createDocument | ファイルを作成する処理を担当します。ACTION_CREATE_DOCUMENT に応答したピッカーにより、ファイルを保存するタイミングで呼びだされます。 |
deleteDocument | ファイルを削除する処理を担当します。DocumentsContract.deleteDocument が叩かれたタイミングで呼び出されます。 |
createDocument と deleteDocument は、必須ではありませんが、ドキュメントの作成と、削除を行うためには必要になるメソッドなので一緒に実装しておきましょう。
それではそれぞれの実装方法について見て行きたいと思います。
事前準備
DocumentsProvider としてアプリケーションを動作させるためには、ManifestFile に Provider であることを定義する必要があります。
DocumentsProvider は ContentProvider を継承したクラスになるので、定義の方法は ContentProvider と同じように行います。
<provider android:name="com.example.mybox.ASFMyStorageProvider" android:authorities="com.example.mybox.documents" 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>
この時に指定している各値についての説明は以下のとおりになります。
name
provider を実装しているクラス名です。authorities
プロバイダを実装しているコンテンツプロバイダのサブクラスの名前などを入れます。exported
外部アプリから呼び出せるようにする場合は、true を設定します。grantUriPermissions
他アプリがコンテンツにアクセスする許可を与える場合は true を設定します。permission
このプロバイダーへアクセスするためのパーミッションを設定します。
ピッカーは、 MANAGE_DOCUMENTSというパーミッションを持っているのでこれを指定しておけばいいでしょう。
また、Intent に応答するために intent-filter として、android.content.action.DOCUMENTS_PROVIDERを指定しておきます。
継承と初期化
はじめに DocumentsProvider を継承したクラスを作成します。その後、先に紹介したメソッドを実装しておきます。
この必須メソッドの中のonCreateを使用してクラスで使用する変数などの初期化を行います。今回は特に初期化するものも無いので、以下の様に true を返す様にしておきましょう。
@Override public boolean onCreate() { return true; }
ピッカーに表示する情報を定義
ピッカーには、アプリケーションの情報を表示する部分があります。ここに表示している情報のことを Root と呼びます。この Root の情報を定義するためのメソッドがqueryRootsになります。
このメソッドは、アプリをインストール後初めて、Storage Access Framework が応答するインテントが投げられた時に呼び出されます。
以下の様に実装します。
@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,ASFMyStorageProvider.class.getName() + ".mybox2"); row.add(Root.COLUMN_SUMMARY,"mybox2"); row.add(Root.COLUMN_FLAGS,Root.FLAG_SUPPORTS_CREATE); row.add(Root.COLUMN_TITLE,"MyBox"); row.add(Root.COLUMN_DOCUMENT_ID,"/"); 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) { //もし null だったら、全ての列情報を含める。 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; } }
このメソッドの戻り値は Cursor になっているため、自由に拡張することのできる MatrixCursor クラスを利用することで作成します。
引数の projection には、システムが要求する項目名が入っていますが、全ての列名を必要としている場合に null が入っていることがあります。その時は、resolveRootProjection メソッドで行っているように、全ての列名を含めます。
Root を定義するために含めた列名にはそれぞれ次のような値を設定します。
COLUMN_ROOT_ID
ルートを一意に決定するためのIDです。
DocumentsProvider が解釈できる値であり、クライアントアプリケーションからは解りにくいような値を設定するようにと公式ドキュメントに書いてあります。
この値は必須項目です。COLUMN_MIME_TYPES
このルートが対応する MIME タイプを設定します。
複数のタイプに対応する場合にはaudio/* \n application/x-flacの様に改行コードで区切ることができます。
この値はオプションであり、指定しない場合は全てのタイプに対応します。COLUMN_FLAGS
ルートの属性を設定します。
この値は必須項目です。
以下のフラグから選択します。複数のフラグを含める際には論理和でまとめます。FLAG_LOCAL_ONLY
ローカルストレージのみを使用していることを表します。FLAG_SUPPORTS_CREATE
新規ファイルの作成に対応していることを表します。FLAG_SUPPORTS_RECENTS
最近使用したファイルのリストを使用することができることを表します。FLAG_SUPPORTS_SEARCH
検索機能に対応していることを表します。
COLUMN_ICON
ピッカーに表示させるアイコンのリソース ID を設定します。
この値は必須項目です。COLUMN_TITLE
ユーザに表示するルートのタイトルになります。
ユーザごとにストレージを分けるために、ルートを複数持つアプリケーションを想定している場合、サービス名にする必要が有ります。
この値は必須項目です。COLUMN_SUMMARY
ユーザに表示するルートの概要で、タイトルの下に表示される事になります。
ルートを複数持つアプリケーションを想定している場合、サービスに対するログインIDなどを指定します。
この値はオプションです。COLUMN_DOCUMENT_ID
ルートのトップディレクトリであるドキュメントの名前を指定します。
この値は必須項目です。COLUMN_ABAILABLE_BYTES
ルートで使用することの出来るバイト数を指定します。
この値はオプションで、無制限もしくは分からない場合は null にします。
ルートに紐づくトップドキュメントを定義
実際にピッカーで表示されるものではありませんが、ここで定義したドキュメントがファイルツリーのルートになります。そのトップドキュメントを作成するためにqueryDocumentを使用します。
このメソッドは、先ほど定義した Root がピッカーにより選択され、ファイルの一覧を表示するタイミングで毎回呼び出されます。ここでファイルツリーのルート情報を更新することになります。
実装の方法は以下になります。
@Override public Cursor queryDocument(String documentId, String[] projection)throws FileNotFoundException { MatrixCursor cursor = new MatrixCursor(resulveDocumentProjection(projection)); includeFile(cursor,documentId); return cursor; } private String[] resulveDocumentProjection(String[] projection) { //もし null なら、以下の列を含める 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; } } //後に queryChildDocuments メソッドでも使用できるように、汎用化 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{ row.add(Document.COLUMN_MIME_TYPE,"*/*"); row.add(Document.COLUMN_FLAGS,Document.FLAG_SUPPORTS_DELETE | Document.FLAG_SUPPORTS_WRITE); row.add(Document.COLUMN_SIZE,file.length()); } row.add(Document.COLUMN_DISPLAY_NAME,file.getName()); row.add(Document.COLUMN_LAST_MODIFIED,file.lastModified()); }
戻り値が Cursor クラスなので、Root と同様の方法で作成します。 引数の projection も Root と同様に null の場合があります。その時は、全ての列名を含める必要があるので resulveDocumentProjection メソッドで指定します。
その後に使用しているincludeFileメソッドは後述する queryChildDocuments でも使用できるように汎用化しています。
この includeFile でドキュメントの定義を行っています。ここで定義する各列名に入れるべき情報は以下の通りになります。
COLUMN_DOCUMENT_ID
ドキュメントを一意に決定するためのIDです。
この値は必須項目です。COLUMN_MIME_TYPE
そのドキュメントの MIME タイプを格納します。
ドキュメントがディレクトリの場合にはDocument.MIME_TYPE_DIR を指定します。
この値は必須項目です。COLUMN_FLAGS
ルートの属性を設定します。
この値は必須項目です。
以下のフラグから選択します。複数のフラグを含める際には論理和でまとめます。FLAG_DIR_PREFERS_GRID
そのディレクトリは、グリッド表示にすることが好ましい事を示します。
表示するドキュメントのほとんどが写真である場合に適します。
MIME タイプが MIME_TYPE_DIR の時に設定するフラグです。FLAG_DIR_PREFERS_LAST_MODIFIED
そのディレクトリは、COLUMN_LAST_MODIFIED でソートすることが好ましい事を示します。
MIME タイプが MIME_TYPE_DIR の時に設定するフラグです。FLAG_DIR_SUPPORTS_CREATE
そのディレクトリに、新しくファイルを作成することをサポートしている事を示します。
MIME タイプが MIME_TYPE_DIR の時に設定するフラグです。FLAG_SUPPORTS_DELETE
そのドキュメントが削除できることを示します。FLAG_SUPPORTS_THUMBNAIL
そのドキュメントがサムネイル表示することが出来ることを示します。FLAG_SUPPORTS_WRITE
そのドキュメントに書き込みをすることが出来るということを示す。
COLUMN_SIZE
このドキュメントのバイト数を示します。もしわからない場合には null を設定します。
この値は必須項目です。COLUMN_DISPLAY_NAME
ユーザに表示するファイルの名前になります。
この値は必須項目です。COLUMN_LAST_MODIFIED
このドキュメントが最後に更新された UnixTime でのタイムスタンプです。
わからない場合には null を設定します。
この値は必須項目です。COLUMN_ICON
このドキュメントに表示するアイコンのリソース ID を格納します。
この値はオプションです。
ファイルツリー内に存在するドキュメントを定義
前項で定義したトップドキュメント配下に存在するドキュメントのリストを定義します。このリストを作成するためにqueryChildDocumentsメソッドを使用します。
このメソッドは、トップドキュメントの定義が終わり、ファイルツリーを更新するタイミングで呼び出されます。基本的には queryDocument の処理が終わるタイミングです。
実装の方法は以下になります。
@Override public Cursor queryChildDocuments(String parentDocumentId,String[] projection, String sortOrder) throws FileNotFoundException { MatrixCursor cursor = new MatrixCursor(resulveDocumentProjection(projection)); //ステップ1 : トップドキュメントのパス情報を元に全てのファイルを取得する String parentDocumenPath = getContext().getFilesDir().getPath() + "/" + parentDocumentId; File dir = new File(parentDocumenPath); //ステップ2 : ファイル情報分 ドキュメントを作成し、cursor に追加 for(File file : dir.listFiles()){ String documentId = parentDocumentId+"/"+file.getName(); includeFile(cursor, documentId); } return cursor; }
戻り値は例によって、Cursor クラスになるので、Root やトップドキュメントを作成した時と同様の方法で作成していきます。
ドキュメントを定義するには、まず前項で設定したトップドキュメントのパスの情報を元に、中に入っている全てのファイル情報を取得します。(ステップ1)
その後、ファイル情報の数分、トップドキュメントを定義した際に行ったのと同じ処理を行い、cursor にドキュメントを追加します。(ステップ2)
ドキュメントを開く
選択されたドキュメントを開くためには openDocumentメソッドを実装する必要があります。 このメソッドは、ACTION_OPEN_DOCUMENT に応答したピッカーにより、ファイルが選択されたタイミングで呼び出されます。
@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; return ParcelFileDescriptor.open(file, accessMode); } else { int accessMode = ParcelFileDescriptor.MODE_READ_ONLY; return ParcelFileDescriptor.open(file, accessMode); } }
ContentProvider 経由でファイルへアクセスするためには ParcelFileDescriptor を使用します。この時に、ParcelFileDescriptor.open(File file, int mode) メソッドを使用することで、ファイルへのアクセス情報をクライアント側のアプリケーションに返すことが出来ます。この時に渡す引数は
- file : アクセスするファイルデータ
- mode : アクセスモード
となっていて、主にこのタイミングで使用するアクセスモードは以下になります。
mode | 説明 |
---|---|
MODE_READ_WRITE | ファイルへの読み書きの権限を与えるモード |
MODE_READ_ONLY | ファイルの読み込みだけの権限を与えるモード |
またファイルを開くときに、ParcelFileDescriptor.OnCloseListener を使用すると、ファイルを閉じた際のイベントを取ることが出来ます。ファイルが閉じられたタイミングで、サーバへアップロードするようなサービスを考えた時には、これを使用するとよいでしょう。
ドキュメントを作成する
新しいドキュメントを作成するためには、createDocumentメソッドを実装する必要があります。
このメソッドは、ACTION_CREATE_DOCUMENT に応答したピッカーにより、ファイルを保存するタイミングで呼び出されます。
@Override public String createDocument(String parentDocumentId, String mimeType, String displayName) throws FileNotFoundException { //新しいドキュメントのファイルパスを作成する String filePath = getContext().getFilesDir().getPath() + "/" + parentDocumentId + "/" + displayName; try { boolean result = new File(filePath).createNewFile(); if (!result){ throw new RuntimeException("Failed to make new file"); } return parentDocumentId + "/" + displayName; } catch (IOException e) { throw new RuntimeException(e); } }
渡された情報を元に新しいファイルを作成します。今回はローカルストレージにファイルを作成しているので、標準的なファイル操作方法になっています。
オンラインストレージへファイルを作成するときは、このメソッド内で API を叩いてあげれば良いと思います。
ドキュメントを削除する
選択されたドキュメントを削除するためには、deleteDocumentメソッドを実装する必要があります。
このメソッドは、DocumentsContract.deleteDocument が叩かれたタイミングで呼び出されます。
@Override public void deleteDocument(String documentId) throws FileNotFoundException { String filePath = getContext().getFilesDir().getPath() + "/" + documentId; File file = new File(filePath); boolean result = file.delete(); if (!result){ throw new RuntimeException("Failed to delete the file, file path=" + filePath); } }
ファイルを作成した時と同様に、渡される情報を元にファイルを削除します。ローカルストレージなので、標準的なファイル操作方法になっています。
これで自作のストレージアプリケーションの実装は終わりです。クライアントの作成よりは、比較的複雑になっていますが、そこまで難しいものでは無いと思いますので是非チャレンジしてみてください。
ストレージアプリとクライアントアプリは別プロジェクトで
ストレージアプリケーションとクライアントアプリケーションを同じプロジェクト内に作成すると、一つ問題があります。それは、同じプロジェクト内に作成したストレージアプリケーションのファイルを開くとき、権限が読み取り専用になってしまうということです。他のストレージアプリケーション内のファイルは問題なく書き込み権限を取得出来るのですが、何故か同じプロジェクト内に実装したストレージ内のファイルは書き込み権限を取得できません。
現状では、クライアントアプリケーションとストレージアプリケーションを別々のプロジェクトで作成し、実装することによってこの問題を保留にしています。
おわりに
さて、今回の特集はいかがだったでしょうか。ファイルを取得する部分を全てまかなうことの出来る、このフレームワークの便利さをお分かりいただけましたか?
今までは独自でファイルを選択する画面を実装しなくてはいけなかったという手間が、一切なくなるわけです。これにより、他の部分へ開発の時間をかけることができるので、より良いアプリを開発する手助けになってくれる機能ではないでしょうか!
それでは次回もお楽しみに!!