言いたいことはそれだけか

KotlinとかAndroidとかが好きです。調べたことをメモします。٩( 'ω' )و

Scoped Storageでパス指定してファイルにアクセスできなくなった

はじめに

見て見ぬふりをしてきたScoped Storageにハマったのでメモ書きを残す。

Android Q (10) から導入されたScoped Storage。「端末内部のfileやらのアクセス権限周りや保存領域が変わったんだろうなー」くらいの雑な理解で見て見ぬふりを続けてきたが思わぬところでハマってしまった。

Glideで画像を表示できなくなった

以前は動いていたコードが動かなくなった。具体的にはContentResolver経由で MediaStore.Video.Media.DATA のStringを取ってきて、Glideのinto()に渡してもpermissionがないというerrorが出て画像が表示されない。

val PROJECTION = listOf(
    MediaStore.Video.Media.DATA,
)

val cursor = context.contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION.toTypedArray(),
        null, null, MediaStore.Video.Media.DATE_MODIFIED)

if (cursor.moveToLast()) {
    do {
        val data = cursor.getString(cursor.getColumnIndex(PROJECTION[0]))
    } while (cursor.moveToPrevious())
}
cursor.close()
Glide.with(context.applicationContext)
    .load(data)
    .into(imageView) // 表示されない

AndroidManifestに android:requestLegacyExternalStorage="true" をつけてやると上記のコードでも動くので、Scoped Storage周りで問題が起きているのは間違いない。ちなみに、android:requestLegacyExternalStorage="true"でとりあえずこの問題は回避できるが、

警告: 来年度のメジャー プラットフォーム リリースでは、ターゲット SDK レベルに関係なく、すべてのアプリで対象範囲別ストレージが必須となります。

とあるので早めに対処することをお勧めする。

developer.android.com

^ 上記ページはなぜか言語設定を英語にすると別のページに転送される。同じ内容の英語リソースが見つけられなかった。

対処法

Scoped Storageが導入に伴い、MediaStore.Video.Media.DATAがdeprecatedになった。代わりにBaseColumns._IDを取得し、ContentUris.withAppendedId()を使ってUriを取得すれば行ける。

参考

medium.com

以下コード例

val PROJECTION = listOf(
        BaseColumns._ID,
)

val cursor = context.contentResolver.query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, PROJECTION.toTypedArray(),
        null, null, MediaStore.Video.Media.DATE_MODIFIED)

if (cursor.moveToLast()) {
    do {
        val idColumnIndex = cursor.getColumnIndex(PROJECTION[0])
        val contentUri = ContentUris.withAppendedId(
                MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
                cursor.getLong(idColumnIndex)
        )
    } while (cursor.moveToPrevious())
}
cursor.close()
Glide.with(context.applicationContext)
    .load(contentUri.toString) // 同じString型を渡しても今後は表示される
    .into(imageView)

何が起きているのか

せっかくなのでGlideの内部実装を追ってみた。(version: 4.11.0)

into()がコールされた後に画像のロードが走るが、その際にload()で渡されたstringのパターンを見ている。stringが '/'で始まるかどうかで処理が分岐する。

public class StringLoader<Data> implements ModelLoader<String, Data> {

  @Nullable
  private static Uri parseUri(String model) {
    Uri uri;
    if (TextUtils.isEmpty(model)) {
      return null;
      // See https://pmd.github.io/pmd-6.0.0/pmd_rules_java_performance.html#simplifystartswith
    } else if (model.charAt(0) == '/') {
      uri = toFileUri(model);
    } else {
      uri = Uri.parse(model);
      String scheme = uri.getScheme();
      if (scheme == null) {
        uri = toFileUri(model);
      }
    }
    return uri;
  }

  private static Uri toFileUri(String path) {
    return Uri.fromFile(new File(path));
  }

MediaStore.Video.Media.DATA でStringを取ってくる場合は/で始まるパスになっている。
例:

/storage/emulated/0/DCIM/Camera/VID_20200821_00000001.mp4

一方、BaseColumns._IDからUriを取得する場合は(当たり前だが)パスではなくUriになっている。
例:

content://media/external/video/media/12345

ここでの処理の分岐で画像が出たり出なかったりする。

前者はパスを指定してFileオブジェクトを生成しようとするが、ここでpermissionがないとしてerrorになる。下記リンクは先ほどと同じページだが、パス経由でファイルにアクセスできないと明記してある。繰り返しになるが、この情報は英語リソースで見つけることができない。誰か見つけたらリンク教えてください…

注: 対象範囲別ストレージするアプリは、「/sdcard/DCIM/IMG1024.JPG」のようなパスに対する直接カーネル アクセス権限を有していません。アプリがこのようなファイルにアクセスするには、MediaStore を使用して、openFile() などのメソッドを呼び出す必要があります。

developer.android.com