こんにちは。家にいる時間が長くなり、お菓子作りに力を入れ始めた@akatsukiです。今回はFamm Androidにフォルダ選択機能を実装した時の話をしようと思います。
※この話はpotatotips #69で話すもので、資料はこちらになります。
前提:Fammの写真アップロード機能
Fammでは写真・動画をアップロードして、家族間で共有することができます。今までこのアップロード画面は撮った日付の降順に写真・動画が並んでいるだけでした。が、フォルダごとに見られた方がいいよねということでフォルダ選択機能をつけることになりました。
完成品
こんなものが出来上がりました。
本物は動画も参照できたり「すべて」フォルダを作っていたりするのですがここではフォルダ選択に説明の重点を置きたいので、単純に写真フォルダを取得、表示する部分について話します。
サンプルコード
フォルダ情報を取得してListViewで表示するサンプルを作りました。全容を見てみたい方はこちらへどうぞ。
https://github.com/akatsuki174/PhotoGallery
実装(〜Android 9)
ContentResolverを使って写真情報を取得する処理は以下の通りです。
class PhotoDataSource(private val context: Context) { companion object { private val PHOTO_URI = MediaStore.Images.Media.EXTERNAL_CONTENT_URI private const val PHOTO_ID = MediaStore.Images.ImageColumns._ID private const val PHOTO_DATE_TAKEN = MediaStore.Images.ImageColumns.DATE_TAKEN private const val PHOTO_FILE_PATH = MediaStore.Images.ImageColumns.DATA private const val PHOTO_BUCKET_ID = MediaStore.Images.ImageColumns.BUCKET_ID // フォルダの識別子 private const val PHOTO_FOLDER_NAME = MediaStore.Images.ImageColumns.BUCKET_DISPLAY_NAME private val PHOTO_PROJECTION = arrayOf( PHOTO_BUCKET_ID, PHOTO_FOLDER_NAME, PHOTO_ID, PHOTO_DATE_TAKEN, PHOTO_FILE_PATH ) private const val PHOTO_SORT = "$PHOTO_DATE_TAKEN DESC, $PHOTO_ID ASC" private const val GROUP_BY = "1) GROUP BY 1,(2" } fun fetch(): ArrayList<FolderHolderData> { val holders = arrayListOf<FolderHolderData>() context.contentResolver.query( PHOTO_URI, // テーブル指定 PHOTO_PROJECTION, // 取得したいカラム群 GROUP_BY, // where句のようなもの null, // where句に変数を渡したいときに使う PHOTO_SORT // ソート指定 ).use { cursor -> if (cursor == null) { return@use } val idIndex = cursor.getColumnIndexOrThrow(PHOTO_ID) val dateTakenIndex = cursor.getColumnIndexOrThrow(PHOTO_DATE_TAKEN) val filePathIndex = cursor.getColumnIndexOrThrow(PHOTO_FILE_PATH) val bucketIdIndex = cursor.getColumnIndexOrThrow(PHOTO_BUCKET_ID) val nameIndex = cursor.getColumnIndexOrThrow(PHOTO_FOLDER_NAME) while (cursor.moveToNext()) { val id = cursor.getLong(idIndex) val dateTaken = cursor.getLong(dateTakenIndex) val filePath = cursor.getString(filePathIndex) val bucketId = cursor.getString(bucketIdIndex) val name = cursor.getString(nameIndex) if (TextUtils.isEmpty(filePath)) { // ファイルパスが取得できないケースがある continue } holders.add((FolderHolderData(bucketId = bucketId, uri = ContentUris.withAppendedId(PHOTO_URI, id), name = name, dateTaken = dateTaken))) } } return holders } }
普通にメディア情報を取得するときとほとんど変わりません。変わることと言えば BUCKET_ID
と BUCKET_DISPLAY_NAME
を取得しているくらいです。
表示部分はUriを渡しているだけです。ここは自分に合った方法で表示してください。
Glide.with(view) .load(item.uri) .apply( RequestOptions() .centerCrop() ) .into(view.thumbnail)
Android 10対応
フォルダ選択に限らずローカルからメディアを取得する場合に発生することですが、 COMPILE_SDK_VERSION = android-29
、 TARGET_SDK_VERSION = 29
にしているとクラッシュ、または警告が出ます。次の2点を修正していきます。
Group byを使わないようにする
Android 10で実行をすると、以下のログが出てクラッシュします。
Caused by: android.database.sqlite.SQLiteException: near "GROUP": syntax error (code 1 SQLITE_ERROR): , while compiling: SELECT bucket_id, bucket_display_name, _id, datetaken, _data FROM images WHERE ((is_pending=0) AND (is_trashed=0) AND (volume_name IN ( 'external_primary' ))) AND ((1) GROUP BY 1,(2)) ORDER BY datetaken DESC, _id ASC
Group句で怒られています。ググってみたところによると、Android 10では GROUP BY
、 COUNT
が使えなくなったとのこと。自前で何かしらの手段を使ってグルーピングする必要があります。
val bucketIds = arrayListOf<String>() context.contentResolver.query( PHOTO_URI, PHOTO_PROJECTION, null, // Group byをなくした null, PHOTO_SORT ).use { cursor -> // 中略 while (cursor.moveToNext()) { val id = cursor.getLong(idIndex) val dateTaken = cursor.getLong(dateTakenIndex) val bucketId = cursor.getString(bucketIdIndex) val name = cursor.getString(nameIndex) if (bucketIds.contains(bucketId)) { // 何らか自分でグループをハンドリング continue } else { holders.add((FolderHolderData(bucketId = bucketId, uri = ContentUris.withAppendedId(PHOTO_URI, id), name = name, dateTaken = dateTaken))) bucketIds.add(bucketId) } } }
MediaStore.Images.ImageColumns.DATAを使わないようにする
DATA
の宣言元を辿ってみるとこんな記述があります。
/** * Absolute filesystem path to the media item on disk. * <p> * Note that apps may not have filesystem permissions to directly access * this path. Instead of trying to open this path directly, apps should * use {@link ContentResolver#openFileDescriptor(Uri, String)} to gain * access. * * @deprecated Apps may not have filesystem permissions to directly * access this path. Instead of trying to open this path * directly, apps should use * {@link ContentResolver#openFileDescriptor(Uri, String)} * to gain access. */ @Deprecated @Column(Cursor.FIELD_TYPE_STRING) public static final String DATA = "_data";
そうですDeprecatedです。ということでDATAを使わなくていいように書き換えます(とはいえ今回の場合はDATAを使っているところをそのまま削除すれば問題ないので、単純に削除しました)。パス自体は以下で取れます。
uri = ContentUris.withAppendedId(PHOTO_URI, id)
まとめ
- フォルダ情報の取得はわりとサクッとできる
- Android 9以下、10以降では動きが異なるので注意
PR
子育て家族アプリFammを運営するTimers inc.では、現在エンジニアを積極採用中!
急成長中のサービスの技術の話を少しでも聞いてみたい方、スタートアップで働きたい方など、是非お気軽にご連絡ください!
採用HP: http://timers-inc.com/engineerings