駆け出しエンジニアぱかぱかの成長記録

引くほど忘れっぽい新卒2年目駆け出しSEぱかぱかの備忘録です。

【Android】内部ストレージに画像を保存しリストで表示する①

こんにちは!ぱかぱかです!
1/4,5は有給を取得しているので10連休以上あるはずなのですが、あっという間に日が過ぎて行って怖いです…
1日中スウェットを着てPCの前で代わり映えのない日々を過ごしているからかもしれませんね…
しかし、このように十分な時間がある時でないとなかなかアプリ開発が前進しないので、チャンスと思って取り組んでいきたいと思います!

今日は現在開発を進めている飲食店記録アプリ「グルメノート」で、画像を内部ストレージに保存した後にそれを取得してリストに表示する実装についてまとめていきたいと思います。
長くなりそうなので記事を2つに分けたいと思います。

前回の記事

radish-se.hatenablog.com

DBのデータをLiveDataに格納しリストに表示できるようになった!
ただし画像はNo Image…

手作りNo Image画像

今回のゴール

・行った店登録画面から登録した画像をアプリの内部ストレージに保存
・DBにストレージのパス情報を保存

次回の記事
・行った店リストでDBのパス情報を元に内部ストレージから画像を取得して表示
radish-se.hatenablog.com

画像を内部ストレージに保存

色々なストレージ

アプリデータの保存方法には以下のような選択肢があります。

保存場所 保存するもの
アプリ固有のストレージ そのアプリのみで使用するファイル
共有ストレージ 他のアプリでも共有可能なファイル
設定 アプリの設定に関する情報(Key-Value
データベース 構造化データ

今回はこの中で共有ストレージから写真選択ツールで画像を取得し、アプリ固有ストレージである内部ストレージに保存します。
そして、保存したパス情報をデータベースに保存します。

写真選択ツールの表示

行った店登録画面の画像登録ボタンを押下した際に、まずは暗黙的インテントを使って写真選択ツールを表示したいと思います。

どうやらAndroid公式を見てみるとAndroid13からいい感じの写真選択ツールが追加されているみたいなのですが、私が動作確認に使っている実機がAndroid12なので今回は使わないことにしました。
写真選択ツール  |  デベロッパー向け Android  |  Android Developers

暗黙的インテント

暗黙的インテントには第一引数(action)に要求する動作、第二引数(uri)に関連付けるデータの詳細を指定します。

今回は以下を指定します。

action:ACTION_PICK
→ データから項目を選択し、選択された内容を返す
Intent  |  Android Developers

uriMediaStore.Images.Media.EXTERNAL_CONTENT_URI
→ 画像の共有ストレージ
MediaStore.Images.Media  |  Android Developers

val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)

Activity Result APIの実装

Activityの結果を取得するためにActivity Result APIregisterForActivityResultを使用します。
今回結果に当たるのは選択した「画像のURI」になります。
行った店登録画面の画像を登録ボタンを押したときの処理として以下のように実装しました。
result.resultCodeがRESULT_OKであればURIを取得し、Bitmapとして一旦保持しておきます。

ちなみに取得されたURIは以下のような感じでcontent://から始まります。
content://com.google.android.apps.photos.contentprovider/-1/1/content%3A%2F%2Fmedia%2Fexternal%2Fimages%2Fmedia%2F27/ORIGINAL/NONE/image%2Fjpeg/1357343246

val pickImage =
            registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
                if (result.resultCode == Activity.RESULT_OK) {
                    // 選択された画像のURIを取得
                    val data: Intent? = result.data
                    selectedImageUri = data?.data

                    // 選択した画像のUriからBitmapを取得し保持
                    this.visitedPhotoBitmap = getBitmapFromUri(selectedImageUri)
                    this.visitedPhotoBitmap ?: return@registerForActivityResult
                }
            }

        btImage.setOnClickListener {
            val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
            pickImage.launch(intent)
        }

    private fun getBitmapFromUri(uri: Uri?): Bitmap? {
        uri ?: return null

        val parcelFileDescriptor: ParcelFileDescriptor? =
            requireActivity().contentResolver.openFileDescriptor(uri, "r")
        parcelFileDescriptor ?: return null

        val fileDescriptor: FileDescriptor = parcelFileDescriptor.fileDescriptor
        val image: Bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor)
        parcelFileDescriptor.close()
        return image
    }

画像保存先のパスをDBに登録

行った店登録ボタンを押したときにの処理を以下のように実装しました。
DBには保存した画像のファイルパスを保存しておきます。
("/"区切りのパスを保存したらうまく行かなかったので、結局ファイル名の部分だけ保存しておいて読み込む時にディレクトリのパスと組み合わせることにしました。)
画像のBitmapが保持されていない場合は画像登録無しとして一旦文字列を保存し、表示時にNoImage画像を出すよう判定します。(なんかここ微妙ですがとりあえず)

ContextWrappergetDirを使うことでアプリの内部ストレージディレクトリにアクセス、また必要に応じて作成することができます。
第一引数(name)にはディレクトリ名、第二引数にはモードを指定します。
今回はディレクトリ名に"image"、モードはMODE_PRIVATEを設定しています。
ContextWrapper  |  Android Developers

// 行った店登録ボタン
val registrationButton = registVisitedRestFragment.findViewById<Button>(R.id.registration_button)

        registrationButton.setOnClickListener {

        // EditTextに入力した情報を諸々ViewModelに突っ込む処理
        ...

            if (this.visitedPhotoBitmap == null) {
                // 画像登録がない場合は文字列を登録し表示時にNoImage画像を表示するよう判定
                _registrationVisitedRestaurantViewModel.visitedPhoto = "no_image"
            } else {
                // 画像登録がある場合はその画像を内部ストレージに保存しファイルパスをDB登録する

                // ディレクトリ名指定
                val directory = ContextWrapper(context).getDir(
                    "image",
                    Context.MODE_PRIVATE
                )

                // ユニークなファイル名を指定する必要があるので日時を利用してファイル名設定
                val fileName = this.getFileName()
                val file = File(directory, fileName)

                // 内部ストレージに保存
                FileOutputStream(file).use { stream ->
                    visitedPhotoBitmap!!.compress(Bitmap.CompressFormat.PNG, 100, stream)
                }

                _registrationVisitedRestaurantViewModel.visitedPhoto = fileName
            }

            lifecycleScope.launch {
                _registrationVisitedRestaurantViewModel.addVisitedRestaurant()
            }
        }

    private fun getFileName(): String {
        val dateFormat = SimpleDateFormat("yyyyMMddHHmmss", Locale.JAPANESE)
        val now = Date()
        val dateStr = dateFormat.format(now)
        // タイムスタンプを利用したファイル名の指定
        return "Photo_${dateStr}.png"
    }

いざ実践

店登録画面で画像登録ボタンを押すと

写真選択ツールが表示されるので、そこからフォルダを開いて画像を選択します。


DB確認

その後行った店登録ボタンを押すとrestaurantsテーブルとvisited_recordsテーブルに情報が登録されますが…

visited_recordsテーブルのvisited_photoにちゃんと画像のファイル名が保存されていました。


内部ストレージ確認

内部ストレージの証跡を載せるのを忘れていたので後日追記しています。
Device File Explolerを見るとラーメンの写真が以下パスに格納されていました。
/data/data/com.example.gourmetnote/app_image/Photo_20240106152703.png
ディレクトリ名として「image」を指定すると「app_image」というディレクトリ名が作られるようです。
(ちょっとこの原理はよくわかっていません)

おいしそう…

そういえば今更なんですけどアプリのドメインexample.comってダサすぎ…?
まあいいか…

次回はこのパスを元に画像を内部ストレージから取得し表示したいと思います。
ではまた!