ActivityResultContracts.TakePictureでuriを取得したい
スペースマーケットでAndroidエンジニアをしていますseoです。
今日は、カメラで撮影した画像をアップロードする機能を実装したときに、はまった点をシェアしたいと思います。
ゴール
今回実装する機能は、カメラを起動し、撮影した画像をMultipartBody.Part
を使ってアップロードする(Retrofit)機能です。
先にネタバレしちゃいますと、実装する上で、下記の2つの大きなハマりポイントがあります。
-
ActivityResultContracts.TakePicture()
の返り値は、成功したかどうかのBooleanしか返ってこない - カメラから取得したUriがContentスキームの場合、File形式に変換できない
上記をどのように回避できたか、説明したいと思います。
実装
カメラを起動する機能
- カメラを起動する上で、パーミッションの取得が必要になるため、「カメラを起動する」ボタンをタップしたときに、パーミッションの確認をし、許可されている場合とされていない場合の分岐をします。
val cameraPermissionState = rememberPermissionState(
Manifest.permission.CAMERA
)
OutlinedButton(
modifier = Modifier.fillMaxWidth(),
onClick = {
when {
cameraPermissionState.status.isGranted -> {
// カメラ起動処理(後述)
}
else -> {
// パーミッション未許可の場合、ユーザーに許可を求める
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
},
) {
Text("カメラを起動する")
}
- パーミッション許可されていない場合は、ユーザーに許可を求める処理を追加します
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
when (isGranted) {
true -> {
// 許可されたため、カメラ起動処理(後述)
}
false -> {
// 拒否されたときの処理 (例:スナックバー出すetc)
}
}
}
- カメラを起動して、撮影した画像のuriを取得します。
ActivityResultContracts
のカメラを起動するクラスは2種類あります。
-
TakePicturePreview
-> Callbackとしてbitmapが返ってきますが、名前の通り、一時的なPreviewを表示するためのもののため、画質が荒いです。
https://developer.android.com/reference/androidx/activity/result/contract/ActivityResultContracts.TakePicturePreview -
TakePicture
-> 指定されたContent-uriに撮影した画像を保存し、Callbackとして、保存成功したかどうかのみBooleanで返します。
https://developer.android.com/reference/androidx/activity/result/contract/ActivityResultContracts.TakePicture
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
}
}
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ファイルの作成は省略します。
- 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形式へ必ずしも変換できるとは限りません。こちらに、詳しい説明があります。
そのため、今回はContentスキームでもfileスキームでも対応できるようにしてみます。
- 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()
}
}
- アップロードのメソッドを実装します
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分単位で借りられます。
実は弊社オフィス自体もこのように貸し出ししてます!!
会議室や個室ブースも貸し出しています。
いつも自宅でコーディング飽きたな、と思ったそこのあなた、
スペースマーケットのオフィスで気分転換してみてはいかがでしょうか?
また、アプリエンジニアも絶賛募集中なので、興味ある方はご応募お待ちしています!
スペースを簡単に貸し借りできるサービス「スペースマーケット」のエンジニアによる公式ブログです。 弊社採用技術スタックはこちら -> whatweuse.dev/company/spacemarket
Discussion