Timers Tech Blog

グローバルな家族アプリFammを運営するTimers inc (タイマーズ) の公式Tech Blogです。弊社のエンジニアリングを支える記事を随時公開。エンジニア絶賛採用中!→ https://timers-inc.com/engineering

Androidで写真動画のフォルダ選択に対応する #Android

f:id:akatsuki174:20200426192404j:plain:w600

こんにちは。家にいる時間が長くなり、お菓子作りに力を入れ始めた@akatsukiです。今回はFamm Androidにフォルダ選択機能を実装した時の話をしようと思います。

※この話はpotatotips #69で話すもので、資料はこちらになります。

前提:Fammの写真アップロード機能

Fammでは写真・動画をアップロードして、家族間で共有することができます。今までこのアップロード画面は撮った日付の降順に写真・動画が並んでいるだけでした。が、フォルダごとに見られた方がいいよねということでフォルダ選択機能をつけることになりました。

完成品

こんなものが出来上がりました。

f:id:akatsuki174:20200426191531g:plain

本物は動画も参照できたり「すべて」フォルダを作っていたりするのですがここではフォルダ選択に説明の重点を置きたいので、単純に写真フォルダを取得、表示する部分について話します。

サンプルコード

フォルダ情報を取得してListViewで表示するサンプルを作りました。全容を見てみたい方はこちらへどうぞ。

https://github.com/akatsuki174/PhotoGallery

f:id:akatsuki174:20200426214900p:plain:w320

実装(〜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_IDBUCKET_DISPLAY_NAME を取得しているくらいです。

表示部分はUriを渡しているだけです。ここは自分に合った方法で表示してください。

Glide.with(view)
    .load(item.uri)
    .apply(
        RequestOptions()
        .centerCrop()
    )
    .into(view.thumbnail)

Android 10対応

フォルダ選択に限らずローカルからメディアを取得する場合に発生することですが、 COMPILE_SDK_VERSION = android-29TARGET_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 BYCOUNT が使えなくなったとのこと。自前で何かしらの手段を使ってグルーピングする必要があります。

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

Timersでは各職種を積極採用中!

急成長スタートアップで、最高のものづくりをしよう。

募集の詳細をみる