🤳

ActivityResultContracts.TakePictureでuriを取得したい

Baek2022/11/18に公開

スペースマーケットでAndroidエンジニアをしていますseoです。
今日は、カメラで撮影した画像をアップロードする機能を実装したときに、はまった点をシェアしたいと思います。

ゴール

今回実装する機能は、カメラを起動し、撮影した画像をMultipartBody.Partを使ってアップロードする(Retrofit)機能です。

先にネタバレしちゃいますと、実装する上で、下記の2つの大きなハマりポイントがあります。

  • ActivityResultContracts.TakePicture()の返り値は、成功したかどうかのBooleanしか返ってこない
  • カメラから取得したUriがContentスキームの場合、File形式に変換できない

上記をどのように回避できたか、説明したいと思います。

実装

カメラを起動する機能

  1. カメラを起動する上で、パーミッションの取得が必要になるため、「カメラを起動する」ボタンをタップしたときに、パーミッションの確認をし、許可されている場合とされていない場合の分岐をします。
val cameraPermissionState = rememberPermissionState(
    Manifest.permission.CAMERA
)

OutlinedButton(
    modifier = Modifier.fillMaxWidth(),
    onClick = {
	when {
	    cameraPermissionState.status.isGranted -> {
	        // カメラ起動処理(後述)
	    }
	    else -> {
	        // パーミッション未許可の場合、ユーザーに許可を求める
		requestPermissionLauncher.launch(Manifest.permission.CAMERA)
	    }
	}
    },
) {
    Text("カメラを起動する")
}
  1. パーミッション許可されていない場合は、ユーザーに許可を求める処理を追加します
val requestPermissionLauncher = rememberLauncherForActivityResult(
    ActivityResultContracts.RequestPermission()
) { isGranted ->
    when (isGranted) {
        true -> {
            // 許可されたため、カメラ起動処理(後述)
        }
        false -> {
	    // 拒否されたときの処理 (例:スナックバー出すetc)
        }
    }
}
  1. カメラを起動して、撮影した画像のuriを取得します。
    ActivityResultContractsのカメラを起動するクラスは2種類あります。

TakePictureでも保存先のuriを返してほしいところですが、仕方ないです。自作しましょう!

import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import androidx.activity.result.contract.ActivityResultContract
import androidx.annotation.CallSuper

class TakePictureWithUriContract : ActivityResultContract<Uri, Pair<Boolean, Uri>>() {

    private lateinit var imageUri: Uri

    @CallSuper
    override fun createIntent(context: Context, input: Uri): Intent {
        imageUri = input
        return Intent(MediaStore.ACTION_IMAGE_CAPTURE).putExtra(MediaStore.EXTRA_OUTPUT, input)
    }

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

    @Suppress("AutoBoxing")
    override fun parseResult(resultCode: Int, intent: Intent?): Pair<Boolean, Uri> {
        return (resultCode == Activity.RESULT_OK) to imageUri
    }
}
  1. FileProviderクラスを使って、画像保存先Uriを取得する
    content-uriを外部アプリと共有するために、FileProviderを利用します。詳細は、公式こちらをご参照ください。
object ImageFileHelper {
    fun getImageUri(context: Context): Uri {
        val directory = File(context.cacheDir, "images")
        directory.mkdirs()
        val file = File.createTempFile(
            "selected_image_",
            ".jpg",
            directory,
        )
        val authority = context.packageName + ".fileprovider"
        return getUriForFile(
            context,
            authority,
            file,
        )
    }
}

*Manifestにproviderの記載、xmlファイルの作成は省略します。

  1. 3, 4で作成したクラスを使って、カメラを起動し、uriを取得する処理を作成します。
val cameraLauncher = rememberLauncherForActivityResult(TakePictureWithUriContract()) { (isSuccess, uri) ->
    if (isSuccess) {
        // アップロード処理(後述)
        viewModel.sendFile(uri)
    }
}
val coroutineScope = rememberCoroutineScope()

...

OutlinedButton(
    ...
    onClick = {
	when {
	    cameraPermissionState.hasPermission -> {
	        coroutineScope.launch {
                    ImageFileHelper.getImageUri(context).let { uri ->
                        cameraLauncher.launch(uri)
                    }
                }
	    }
	    ...
        }
    }
)

以上で、カメラ撮影後uriを取得する処理ができました。

Uriからアップロード処理する

Fileのアップロード処理の実装を検索してみると、少し古い記事などではUri->String->File->RequestBodyへと変換してアップロードする方法がヒットすると思いますが、Content-uriスキームをFile形式へ必ずしも変換できるとは限りません。こちらに、詳しい説明があります。
https://qiita.com/wakamesoba98/items/98b79bdfde19612d12b0

そのため、今回はContentスキームでもfileスキームでも対応できるようにしてみます。

  1. Uri -> RequestBodyへの変換クラスを作成します
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import java.io.File
import java.io.IOException
import java.util.Locale
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source

class InputStreamRequestBody(
    private val context: Context,
    private val uri: Uri
) : RequestBody() {

    override fun contentType(): MediaType? {
        return getMimeType(context, uri)?.toMediaTypeOrNull()
    }

    @Throws(IOException::class)
    override fun contentLength(): Long {
        return -1
    }

    @Throws(IOException::class)
    override fun writeTo(sink: BufferedSink) {
        sink.writeAll(context.contentResolver.openInputStream(uri)!!.source())
    }

    companion object {
        fun getMimeType(context: Context, uri: Uri): String? {
            return when (uri.scheme) {
                ContentResolver.SCHEME_CONTENT -> context.contentResolver.getType(uri)
                ContentResolver.SCHEME_FILE -> MimeTypeMap.getSingleton().getMimeTypeFromExtension(
                    MimeTypeMap.getFileExtensionFromUrl(uri.toString()).toLowerCase(Locale.US)
                )
                else -> null
            }
        }

        fun getFileName(context: Context, uri: Uri): String? {
            when (uri.scheme) {
                ContentResolver.SCHEME_FILE -> {
                    val filePath = uri.path
                    if (!filePath.isNullOrEmpty()) {
                        return File(filePath).name
                    }
                }

                ContentResolver.SCHEME_CONTENT -> {
                    return getCursorContent(uri, context.contentResolver)
                }
            }
            return null
        }

        private fun getCursorContent(uri: Uri, contentResolver: ContentResolver): String? =
            kotlin.runCatching {
                contentResolver.query(uri, null, null, null, null)?.use { cursor ->
                    val nameColumnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
                    if (cursor.moveToFirst()) {
                        cursor.getString(nameColumnIndex)
                    } else null
                }
            }.getOrNull()
    }
}
  1. アップロードのメソッドを実装します
class MainViewMode @Inject constructor(
    mainRepository: MainRepository
) : ViewModel() {
    fun sendFile(context: Context, uri: Uri) {
        val requestFile: RequestBody = InputStreamRequestBody(context, file)
        val fileName = InputStreamRequestBody.getFileName(context, file)
	val multipartImage = MultipartBody.Part.createFormData("file", fileName, requestFile)
	
	mainRepository.sendFile(multipartImage)
    }
}

以上です!
Nativeアプリ開発で、端末固有の機能を使いこなして実装するのは、なかなか難しかったりしますが、うまく実装できると、ユーザーの利便性を飛躍的に向上させられるため、避けて通れない領域かと思います。
めげずにがんばりましょう!

最後に

スペースマーケットでは、パーティー用途から1人用の作業ワークスペースなど、さまざまな用途のスペースが15分単位で借りられます。

実は弊社オフィス自体もこのように貸し出ししてます笑

https://www.spacemarket.com/spaces/spm_sharespace_l/rooms/eI-d2aqc3lExsk0R/

会議室や個室ブースも貸し出しています。

https://www.spacemarket.com/spaces/spm_sharespace/rooms/oFaQ2lpJwp6fY1F5/

https://www.spacemarket.com/spaces/spm_sharespace/rooms/-07atbgLb838J_PX/

いつも自宅でコーディング飽きたな、と思ったそこのあなた、
スペースマーケットのオフィスで気分転換してみてはいかがでしょうか?

また、アプリエンジニアも絶賛募集中なので、興味ある方はご応募お待ちしています!

スペースマーケット Engineer Blog

スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> https://www.whatweuse.dev/company/spacemarket

Discussion

ログインするとコメントできます