1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

パーミッションを追加しない範囲でできる画像データの扱い

Last updated at Posted at 2025-03-09

Androidにおいて、ユーザーが撮影した画像を扱いたい場合、カメラやストレージへのパーミッションを取得して、カメラ撮影機能や、ファイルマネージャ機能をしっかり実装する方法もありますが、最低限のアクセスができれば良い場合、それらを実装するのはのはコストが高すぎ効果に見合わないことがあります。
追加のパーミッションもなしで実装できる範囲はなんだろう、と調べて見ました。

画像ファイルの選択

Intentを使って、ユーザーにファイルを選択してもらい、そのファイルのデータを読み出す。という方法であれば、パーミッションは不要です。以前は Intent.ACTION_GET_CONTENT 、ActivityResultContractでは GetContent を使っていましたが、この方法ではOSバージョンによってUIが異なるため、少し扱いづらい側面がありました。

今から実装するのであれば、PhotoPickerを利用するのが良いでしょう

使い方は以下のように、contractとしてPickVisualMediaを利用し、launcherを作成します。
onResultにはコンテンツへのuriが渡されるので、そのuriをContentResolverを使ってInputStreamに変換して読み出すなどで画像データにアクセスできます。

val selectImageLauncher = rememberLauncherForActivityResult(
    contract = PickVisualMedia(),
    onResult = {
        it ?: return@rememberLauncherForActivityResult
        contentResolver.openInputStream(it).use {
            drawable = BitmapDrawable(resources, it)
        }
    }
)

本題では無いため安直に読み出していますが、ユーザーが選択したファイルは、そもそも画像ファイルとして読み出せない可能性もありますし、非常に高解像度で展開すると一気にメモリを食い潰すような巨大ファイルである可能性もあるので以下などを参考に適切に読み込みましょう。

https://developer.android.com/topic/performance/graphics/load-bitmap?hl=ja

launchメソッドでは選択するメディアタイプを指定します。

selectImageLauncher.launch(PickVisualMediaRequest(PickVisualMedia.ImageOnly))

以下のようなUIが表示され、ユーザーに画像を選択してもらいます。

このUIはAndroid 11以上で、モジュラーシステムのシステムコンポーネントを受け取っている場合に使えます。Android 4.4からAndroid 10には「開発者向けサービス」を経由してバックポートバージョンが利用可能です。minSdkが30未満の場合は、バックポートバージョンを自動インストールできるように、AndroidManifestの<application> タグ以下に以下の記述を追加しておきます。

AndroidManifest.xml
<service
    android:name="com.google.android.gms.metadata.ModuleDependencies"
    android:enabled="false"
    android:exported="false"
    tools:ignore="MissingClass"
    >
    <intent-filter>
        <action android:name="com.google.android.gms.metadata.MODULE_DEPENDENCIES" />
    </intent-filter>
    <meta-data
        android:name="photopicker_activity:0:required"
        android:value=""
        />
</service>

AndroidStudioでは com.google.android.gms.metadata.ModuleDependencies が見つからないため赤字表示になりますが、 tools:ignore="MissingClass" がついているように、見つからなくても問題ない記述です。

写真の撮影

写真の撮影もIntentで実行できます。端末のカメラアプリが起動して撮影を行うため、アプリではカメラ制御の実装も、パーミッションも不要です。

パーミッションが不要なのは <uses-permission android:name="android.permission.CAMERA" /> の宣言をしていない場合のみです。uses-permissionを宣言していながら許可をもらえていない場合は、Intentであってもカメラ機能を使うことはできません。後から別の機能追加で動かなくなる可能性があるため、注意が必要です。

IntentのActionとしては、MediaStore.ACTION_IMAGE_CAPTURE、ActivityResultContractではTakePicture を利用します。
ファイルの選択ではURIが結果として渡されましたが、写真撮影では成功失敗を表すBooleanが結果として渡されます。画像ファイルへのURIはアプリ側が指定し、カメラアプリがそこに書き込みます。ActivityResultContractとしては少し扱いにくいので、URIが戻り値になるようにカスタマイズしても良いでしょう

FileProviderの実装

カメラの撮影では書き込み先のURIをアプリが提供します。
直接MediaStoreを使ってpublicなエリアに書き込みを行うようにリクエストしても良いようですが、アプリで受け取って内容を確認してから書き出しを行えるように、アプリのローカルストレージへのURIを提供するように実装します。
そのためにFileProviderの実装が必要です。

AndroidManifestの<application> タグ以下に以下の記述を追加しておきます。

AndroidManifest.xml
<provider
    android:name="androidx.core.content.FileProvider"
    android:authorities="${applicationId}.fileprovider"
    android:exported="false"
    android:grantUriPermissions="true"
    >
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths"
        />
</provider>

リソースで指定した、file_pathsは以下のように記述します。
タグとしてどのタイプのストレージを使うかを指定し、pathで利用するディレクトリへの相対パスと、nameでURIに変換する際の識別子を指定しています。
どこでも良いと思いますが、この場合はexternal-cache領域を使うようにしました。

res/xml/file_paths.xml
<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-cache-path
        name="external-cache"
        path="."
        />
</paths>

利用する際は、まずはディレクトリの作成とクリーンアップ(必要があれば)を行います。
アプリ管理下のディレクトリなので、アプリのあずかり知らぬところでファイルが作られることはありませんが、以下の例では別の機能との衝突を避けるため、ディレクトリ分けとして「PhotoPicker」というディレクトリを作っています。

private fun createEmptyDirectory(): File {
    val directory = File(externalCacheDir, "PhotoPicker")
    directory.deleteRecursively()
    directory.mkdirs()
    return directory
}

用意したディレクトリ以下に書き込み先の画像ファイル名を決め、そのファイルに対するuriをFileProviderを使って取得します。

val file = File(createEmptyDirectory(), "photo.jpg")
uri = FileProvider.getUriForFile(
    this@MainActivity,
    "$packageName.fileprovider",
    file
)

撮影リクエスト

contractにTakePicture()を指定し、onResultは成功失敗が返ってくるだけなので、uriは別途覚えておく必要があります。

var uri: Uri by remember { mutableStateOf(Uri.EMPTY) }
val takePhotoLauncher = rememberLauncherForActivityResult(
    contract = TakePicture(),
    onResult = { succeed ->
        if (!succeed) return@rememberLauncherForActivityResult
        contentResolver.openInputStream(uri).use {
            drawable = BitmapDrawable(resources, it)
        }
    }
)

前項の方法でファイルへのuriを取得し、それを引数にlauncherを呼び出します。

val file = File(createEmptyDirectory(), "photo.jpg")
uri = FileProvider.getUriForFile(
    this@MainActivity,
    "$packageName.fileprovider",
    file
)
takePhotoLauncher.launch(uri)

URIを結果として受け取れるActivityResultContract

前述したように、TakePictureは引数でUriが渡されないので、uriの引き渡しが別途必要です。
URIの作成はアプリ依存なので、これを引数で渡せるようにしたActivityResultContractを作るとちょっと便利かもしれません。

class TakePictureUri(
    private val uriProvider: (context: Context, fileName: String) -> Uri,
) : ActivityResultContract<String, Uri?>() {
    private var uri: Uri? = null

    override fun createIntent(context: Context, input: String): Intent {
        uri = uriProvider(context, input)
        return Intent(MediaStore.ACTION_IMAGE_CAPTURE).putExtra(MediaStore.EXTRA_OUTPUT, uri)
    }

    override fun getSynchronousResult(
        context: Context,
        input: String,
    ): SynchronousResult<Uri?>? = null

    override fun parseResult(resultCode: Int, intent: Intent?): Uri? {
        val resultUri = uri
        uri = null
        return if (resultCode == Activity.RESULT_OK) resultUri else null
    }
}

これを使うと、利用箇所でUriの引き渡しが必要なくなります。

val takePhotoLauncher = rememberLauncherForActivityResult(
    contract = TakePictureUri(
        uriProvider = { context, fileName ->
            val file = File(createEmptyDirectory(), fileName)
            FileProvider.getUriForFile(
                context,
                "$packageName.fileprovider",
                file
            )
        }
    ),
    onResult = {
        it ?: return@rememberLauncherForActivityResult
        contentResolver.openInputStream(it).use {
            drawable = BitmapDrawable(resources, it)
        }
    }
)

撮影した画像をメディアストアに追加する

前項までの方法で、写真撮影した画像は、アプリのみ参照できる状態になっています。しかし、ユーザーからすると自分が撮影した画像なのだから他のアプリからも使えてほしいと思うでしょう。
後から別のアプリでも使えるように、撮影データは内容の確認や加工の後、メディアストアに格納しておくのが良いでしょう。

Android 10以上では、メディアストアへのエントリー追加にパーミッションは不要になっています。(Android 9未満ではWRITE_EXTERNAL_STORAGEのパーミッションが必要です)

ローカルファイルへのURIからメディアストアへ格納するコードは以下のようになります。

private fun saveToMediaStore(localUri: Uri) {
    val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        // こちらはパーミッション不要
        MediaStore.Images.Media.getContentUri(
            MediaStore.VOLUME_EXTERNAL_PRIMARY
        )
    } else {
        // こちらはWRITE_EXTERNAL_STORAGEが必要
        MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
    }
    val imageEntry = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, "photo.jpg")
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        put(MediaStore.Images.Media.IS_PENDING, 1) // 書き込み中フラグ
        put(MediaStore.MediaColumns.RELATIVE_PATH, "Pictures/PhotoPicker")
    }
    val entryUri = contentResolver.insert(collection, imageEntry) ?: return
    contentResolver.openOutputStream(entryUri)?.use { output ->
        contentResolver.openInputStream(localUri)?.use { input ->
            input.copyTo(output)
        }
    }
    imageEntry.clear()
    imageEntry.put(MediaStore.Images.Media.IS_PENDING, 0) // 書き込み中フラグ解除
    contentResolver.update(entryUri, imageEntry, null, null)
}

画像ファイルの場合、通常Pictures(Environment. DIRECTORY_PICTURES)直下に配置されます。フォルダを作成してその下に格納したい場合は、MediaStore.MediaColumns.RELATIVE_PATH を使って外部ストレージからの相対パスを指定します。


以上です。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?