まえふり
何年前だっけ?Android 4.4(KitKat)以降だと思いますが、内部ストレージや外部ストレージ、プライマリーもセカンダリーもアクセスできるようになるStorage Access Framework(以下SAF)という新しい仕組みが追加になりました。
当初はファイルを1つアクセスするのに毎回パーミッションの要求が必要になるう○こ仕様だったわけですが、Android 5以降ではディレクトリ単位でのパーミッション付与が可能になりパーミッションを取得できたディレクトリとその内部のディレクトリ/ファイルには追加のパーミッション要求をすることなくアクセスできるようになりました。
ここらへんの話は、以前にも端末のSDカードに直接動画を保存した〜い(^o^)/という記事にも書きました。
以前の記事はタイトルの通りSDカードに直接動画を保存するにはどうすりゃいいんじゃ?ということでゴニョゴニョすりゃいいんじゃぁという身もふたもない話を書いてしまったわけですが、Android8(API>=26)以降ではMediaMuxerに新しくFileDescriptorを引数にとるコンストラクタが追加になったので、新しい端末に関しては簡単にSDカードへも直接動画を保存できるようになりました。
SDカードに保存できたけど、それだけで大丈夫かい?
保存すると当然のことながらファイルが出来て空き容量が減っていきます。通常のファイルシステムであれば、例えば読み取りアクセス可能なディレクトリへのパスに対してFileやStatFsクラスを使うことで合計容量や使用可能容量を取得することが出来ます。
- File#getTotalSpaceやFile#getUsableSpace
- StatFs#getTotalBytesやStatFs#getAvailableBytes
しかしながら残念なことにDocumentFileにはディレクトリの合計容量や空き容量を取得するメソッドが無いのです。DocumentFileがファイルを指し示しているときにファイルサイズを取得することができるだけです。つまりSDカードに録画できたぁ\(^o^)/と調子に乗っていっぱい保存していると、あるとき突然空き容量がないから書き込めんのんじゃぁと怒られることになります?
これはSDファイルへの書き込みだけではなく、DocumentFileを使って内蔵ストレージへアクセスしている時も同じことが起こります。
はっきり言ってこのままではアプリに組み込むのはすごぉーく不安です。と言うのもAndroidで動画を保存する場合にはMP4フォーマットを使うことがほとんどだと思いますが、MP4フォーマットでは動画のデータの出力が全て終わって初めてmoovボックス(moovアトムとも)を出力できるのです。しかもこのmoovボックスが完全に出力されない限りMP4ファイルは再生できないのです?
もちろん5分や10分で空き容量がなくなることは殆ど無いでしょうし、それぐらいの短時間であればもう一度取り直すことも可能な場合が多いでしょう。しかし数十分以上の長時間の録画をしようとする場合、出力途中で空き容量が足りなくなってmoovボックスを書き込めなければたとえそれまで1時間でも2時間でも正常に書き込んでいたデータが全てパーになってしまうのです(´・ω・`)
ですので通常であれば空き容量とファイルサイズを一定時間毎に確認しながら保存してmoovボックスが確実に保存できるようにしないといけません。
しかぁーし、先ほども書いたとおりDocumentFileにはディレクトリの合計容量や空き容量を取得するメソッドが無いのです(しつこい)。DocumentFileが指し示しているディレクトリがたまたまプライマリーストレージや外部(セカンダリース)トレージにあればアクセスするすべもありますが、SDカードのようにSAFの経由でしかアクセスできないければ簡単には空き容量を取得できないのです。
ということでちゃんちゃん(TT)/~~~
では終われないので、DocumentFileでもいくつか前提条件を設けることで空き容量を取得できるようにしてみました。
前提条件
まずは前提条件です。
- DocumentFileが指し示しているディレクトリの実体が端末内のローカルファイルシステム上に存在していること
- FileまたはStatFsクラスを用いてDocumentFileが指し示しているディレクトリの状態の読み込みが可能であること
DocumentFileが指し示しているディレクトリが端末内のローカルファイルシステム上に存在していること
DocumentFileが指し示すディレクトリやファイルには大きく分けて3種類あります。
- DocumentsProvider(ContentProviderの下位クラス)経由でアクセスするローカルファイルシステム上のディレクトリ/ファイル。SDカード上のファイルなど
- DocumentFile#fromFile(File)メソッドで作られたローカルファイルシステム上のディレクトリ/ファイル。
- DocumentsProvider(ContentProviderの下位クラス)経由でアクセスするローカルファイルシステム以外に存在するディレクトリ/ファイル。オンラインストレージサービスなど
今回は上記1,2を対象にします。オンラインストレージなど3つ目はそれぞれのサービス固有の機能を使って別途取得する必要があり汎用的には作成できないので今回は除外です。
FileまたはStatFsクラスを用いてDocumentFileが指し示しているディレクトリの状態の読み込みが可能であること
最初に書きましたが、DocumentFile自体にディレクトリの空き容量や合計容量を取得するメソッドはありません。API >= 9ではStorageManager/StorageVolumeなるクラスがAPIにありもしや使えるのか?と思いましたがDocumentFileを元にアクセスすることは出来ませんでした(´・ω・`)
となると、なんとかしてDocumentFileが指し示しているディレクトリ/ファイルへのパスを生成してFileまたはStatFsでアクセスしてみる以外に選択肢がないのです。
ということで試してみた
まずはDocumentFileからなんとかしてローカルファイルシステム上の実ファイルへのファイルパスをでっちあげないといけません。以前の記事にもちらっと書きましたが細々追加したのがこちら。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 |
public static String getPath(final Context context, final Uri uri) { if (DEBUG) Log.i(TAG, "getPath:uri=" + uri); if (BuildCheck.isKitKat() && DocumentsContract.isDocumentUri(context, uri)) { // DocumentProvider if (DEBUG) Log.i(TAG, "getPath:isDocumentUri,getAuthority=" + uri.getAuthority()); // ExternalStorageProvider if (isExternalStorageDocument(uri)) { final String docId = DocumentsContract.getDocumentId(uri); if (DEBUG) Log.i(TAG, "getPath:isDocumentUri,docId=" + docId); if (BuildCheck.isLollipop() && DEBUG) { Log.i(TAG, "getPath:isDocumentUri,getTreeDocumentId=" + DocumentsContract.getTreeDocumentId(uri)); } final String[] split = docId.split(":"); final String type = split[0]; if (DEBUG) Log.i(TAG, "getPath:type=" + type); if (type != null) { if ("primary".equalsIgnoreCase(type)) { final String path = Environment.getExternalStorageDirectory() + "/"; return (split.length > 1) ? path + split[1] : path; } else if ("home".equalsIgnoreCase(type)) { if ((split.length > 1) && isStandardDirectory(split[1])) { return Environment.getExternalStoragePublicDirectory( split[1]) + "/"; } final String path = Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_DOCUMENTS) + "/"; return (split.length > 1) ? path + split[1] : path; } else { // プライマリストレージ以外の時は前から順に探す final String primary = Environment.getExternalStorageDirectory().getAbsolutePath(); if (DEBUG) Log.i(TAG, "getPath:primary=" + primary); final File[] dirs = context.getExternalFilesDirs(null); final int n = dirs != null ? dirs.length : 0; final StringBuilder sb = new StringBuilder(); for (int i = 0; i < n; i++) { final File dir = dirs[i]; if (DEBUG) Log.i(TAG, "getPath:" + i + ")dir=" + dir); if ((dir != null) && dir.getAbsolutePath().startsWith(primary)) { // プライマリストレージはスキップ continue; } final String dir_path = dir != null ? dir.getAbsolutePath() : null; if (!TextUtils.isEmpty(dir_path)) { final String[] dir_elements = dir_path.split("/"); final int m = dir_elements.length; if ((m > 2) && "storage".equalsIgnoreCase(dir_elements[1]) && type.equalsIgnoreCase(dir_elements[2])) { boolean found = false; sb.setLength(0); sb.append('/').append(dir_elements[1]); for (int j = 2; j < m; j++) { if ("Android".equalsIgnoreCase(dir_elements[j])) { found = true; break; } sb.append('/').append(dir_elements[j]); } if (found) { final File path = new File(new File(sb.toString()), split[1]); if (DEBUG) Log.i(TAG, "getPath:path=" + path); // 見つかったパスが読み込みまたは読み書きできるかどうかは関知しない return path.getAbsolutePath(); } } } } } } else { Log.w(TAG, "unexpectedly type is null"); } } else if (isDownloadsDocument(uri)) { // DownloadsProvider final String id = DocumentsContract.getDocumentId(uri); final Uri contentUri = ContentUris.withAppendedId( Uri.parse("content://downloads/public_downloads"), Long.valueOf(id)); return getDataColumn(context, contentUri, null, null); } else if (isMediaDocument(uri)) { // MediaProvider final String docId = DocumentsContract.getDocumentId(uri); final String[] split = docId.split(":"); final String type = split[0]; Uri contentUri = null; if ("image".equals(type)) { contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI; } else if ("video".equals(type)) { contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI; } else if ("audio".equals(type)) { contentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI; } final String selection = "_id=?"; final String[] selectionArgs = new String[] { split[1] }; return getDataColumn(context, contentUri, selection, selectionArgs); } } else if ("content".equalsIgnoreCase(uri.getScheme())) { // MediaStore (and general) if (isGooglePhotosUri(uri)) { return uri.getLastPathSegment(); } return getDataColumn(context, uri, null, null); } else if ("file".equalsIgnoreCase(uri.getScheme())) { // File return uri.getPath(); } Log.w(TAG, "unexpectedly not found,uri=" + uri); return null; } /** * Get the value of the data column for this Uri. This is useful for * MediaStore Uris, and other file-based ContentProviders. * * @param context The context. * @param uri The Uri to query. * @param selection (Optional) Filter used in the query. * @param selectionArgs (Optional) Selection arguments used in the query. * @return The value of the _data column, which is typically a file path. */ public static String getDataColumn(final Context context, final Uri uri, final String selection, final String[] selectionArgs) { Cursor cursor = null; final String column = "_data"; final String[] projection = { column }; try { cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null); if ((cursor != null) && cursor.moveToFirst()) { final int column_index = cursor.getColumnIndexOrThrow(column); return cursor.getString(column_index); } } finally { if (cursor != null) { cursor.close(); } } return null; } /** * @param uri The Uri to check. * @return Whether the Uri authority is ExternalStorageProvider. */ public static boolean isExternalStorageDocument(final Uri uri) { return "com.android.externalstorage.documents".equals(uri.getAuthority()); } /** * @param uri The Uri to check. * @return Whether the Uri authority is DownloadsProvider. */ public static boolean isDownloadsDocument(final Uri uri) { return "com.android.providers.downloads.documents".equals(uri.getAuthority()); } /** * @param uri The Uri to check. * @return Whether the Uri authority is MediaProvider. */ public static boolean isMediaDocument(final Uri uri) { return "com.android.providers.media.documents".equals(uri.getAuthority()); } public static boolean isGooglePhotosUri(final Uri uri) { return "com.google.android.apps.photos.content".equals(uri.getAuthority()); } |
どりゃぁーと。
はっきり言ってもっとスマートな方法があるんじゃないかと思うんだけど…まぁとりあえずAndroid 8.1.0のNexus 6pでもAndroid 5のNexus7(2013)でも動くので勘弁しておくなさい。
#getPathにContextとDocumentFile#getUriを渡して呼び出せば絶対ファイルパス文字列が返ってくる…はずなのでそれをFileまたはStatFsに突っ込めばあとは分かるよね?File#getTotalSpaceやFile#getUsableSpaceあるいは、StatFs#getTotalBytesやStatFs#getAvailableBytesを呼び出せば合計容量と空き容量を取得できます。
元のDocumentFileがSAF経由でアクセスできないディレクトリやファイルを指し示している時にどうなるかは知りませんが、SAF経由でアクセス可能なディレクトリやファイルの場合には、少なくともAndroid 8.1.0までは(ファイルの内容自体の読み書きは出来なくとも)空き容量等を取得可能でした\(^o^)/
SDカードはアクセススピードがわからない(今時Class2やClass4ということは無いでしょうが、実際の書き込み速度は動かしてみないとわかりません)ですし、物理的に引っこ抜かれる可能性もあるのでおすすめではないですが、空き容量が確認できるようになったのでリスクを明示した上で内蔵ストレージに対する容量面でのアドバンテージを選択するというのであればまぁ使ってもいいかなぁ(。・_・。)?
ということで今度こそお疲れ様でした
(^.^)/~~~