システム開発部のTです。
いつもは、Flutterの記事を書いています。
今回は、Androidでカメラやストレージを利用するときに必須となるRuntimePermissionの実装について書いていきたいと思います。RuntimePermission自体は、Android6.0から実装する必要があるため、正直今更感ありますが、ここ最近その実装手段が変わってきているので、今回あらためてこちらに書いていければと思った次第です。

開発環境

本件では、以下の開発環境を前提としております。

  • Android Studio Dolphin | 2021.3.1 Patch 1
  • TargetSDK: 32
  • CompileSDK: 32

Dependenciesは以下を想定しています。

dependencies {
    implementation 'androidx.core:core-ktx:1.9.0'
    implementation 'androidx.appcompat:appcompat:1.5.1'
    implementation 'com.google.android.material:material:1.7.0'
    implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
    testImplementation 'junit:junit:4.13.2'
    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

上記を前提として、これより実装してみましょう。

※今回のサンプルコードでは便宜的に前面カメラのcameraIdを0, 背面カメラのcameraIdを1としています。
本実装の際にはカメラの存在確認など、適切に実装してください。

RuntimePermissionの実装(非推奨)

カメラ機能を呼び出すことを想定した実装をしてみましょう。
まずは、いままでの実装方法になります。

現在、この実装方法は非推奨となっており、今後実装する場合は、以降で説明する実装方法でお願いします。

package com.hoge.requestpermissionsampleapp

import android.content.pm.PackageManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import android.Manifest
import androidx.databinding.DataBindingUtil
import com.hoge.requestpermissionsampleapp.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding =  DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
        binding.activity = this
    }

    private var mCameraId = 0

    fun onClickFrontCameraButton(view: View) {
        // 前面カメラ起動
        requestCamera(0)
    }

    fun onClickBackCameraButton(view: View) {
        // 背面カメラ起動
        requestCamera(1)
    }

    private fun requestCamera(cameraId: Int) {
        // カメラを起動
        val permissionCheck = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
        if (permissionCheck != PackageManager.PERMISSION_GRANTED) {
            this.mCameraId = cameraId
            // Android6.0以上のみ、該当パーミッションが許可されていない場合
            if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) {
                // パーミッションが必要であることを明示するアプリケーション独自のUIを表示
                Toast.makeText(this, "設定画面からパーミッションを許可してください。", Toast.LENGTH_SHORT).show()
            } else {
                // パーミッションリクエストダイアログを開く
                ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), REQUEST_CODE)
            }
        } else {
            // Android6.0未満、またはパーミッションが許可されているため、カメラを起動
            showCamera(cameraId)
        }
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
        when(requestCode) {
            REQUEST_CODE -> {
                if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                    // パーミッションが許可されているため、カメラを起動
                    showCamera(mCameraId)
                } else {
                    // パーミッションが得られなかった時
                    // 処理を中断する・エラーメッセージを出す・アプリケーションを終了する等
                }
            }
            else -> {}
        }
    }

    private fun showCamera(cameraId: Int) {
        // カメラ起動(ダミー処理)
        if (cameraId == 0) {
            Toast.makeText(this, "前面カメラ起動!", Toast.LENGTH_SHORT).show()
        } else {
            Toast.makeText(this, "背面カメラ起動!", Toast.LENGTH_SHORT).show()
        }
    }

    companion object {
        const val REQUEST_CODE = 1000
    }
}

背面カメラ、または前面カメラのいずれかを起動するボタン押下イベントが実行されたとき、requestCamera()メソッドがコールしています。

requestCamera()内でカメラPermission有無をチェックし、許可された場合はカメラ機能(ここではshowCamera())を呼び出すようにしています。

上記以外は、すでにユーザー側でPermission「未許可」が選択済みの場合、Toastでメッセージを表示、そうでなかった場合はユーザーにRequestPermissionするための処理を実行しています。

RequestPermission後、そのレスポンスをActivityクラスのonRequestPermissionsResult()で受け取り、「許可」であれば、showCamera()をコールしています。

以上が、いままでのRuntimePermissionの実装です。

しかし、実際以下のFragmentActivity::onRequestPermissionResult()コード上には、deprecationの文字があるように非推奨となります。

    @SuppressWarnings("deprecation")
    @CallSuper
    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
            @NonNull int[] grantResults) {
        mFragments.noteStateNotSaved();
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    }

現在は以降で説明する、Activity Result APIを利用した実装がメインとなります。

参考元リンク:https://developer.android.com/training/basics/intents/result?hl=ja

RuntimePermissionの実装(推奨)

今度は、Activity Result APIを用いて、コードを置き換えてみます。

package com.hoge.requestpermissionsampleapp

import android.content.pm.PackageManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import android.Manifest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.databinding.DataBindingUtil
import com.hoge.requestpermissionsampleapp.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val binding =  DataBindingUtil.setContentView<ActivityMainBinding>(this, R.layout.activity_main)
        binding.activity = this
    }

    private var mCameraId = 0

    fun onClickFrontCameraButton(view: View) {
        // 前面カメラ起動
        requestCamera(0)
    }

    fun onClickBackCameraButton(view: View) {
        // 背面カメラ起動
        requestCamera(1)
    }

    private fun requestCamera(cameraId: Int) {
        // カメラを起動
        val permissionCheck = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
        if (permissionCheck != PackageManager.PERMISSION_GRANTED) {
            this.mCameraId = cameraId
            // Android6.0以上のみ、該当パーミッションが許可されていない場合
            if (ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.CAMERA)) {
                // パーミッションが必要であることを明示するアプリケーション独自のUIを表示
                Toast.makeText(this, "設定画面からパーミッションを許可してください。", Toast.LENGTH_SHORT).show()
            } else {
                // パーミッションリクエストダイアログを開く
                requestPermissionResult.launch(arrayOf(Manifest.permission.CAMERA))
            }
        } else {
            // Android6.0未満、またはパーミッションが許可されているため、カメラを起動
            showCamera(cameraId)
        }
    }

    private val requestPermissionResult = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
        if (granted[Manifest.permission.CAMERA] == true) {
            // パーミッションが許可されているため、カメラを起動
            showCamera(mCameraId)
        } else {
            // パーミッションが得られなかった時
            // 処理を中断する・エラーメッセージを出す・アプリケーションを終了する等
        }
    }

    private fun showCamera(cameraId: Int) {
        // カメラ起動(ダミー処理)
        if (cameraId == 0) {
            Toast.makeText(this, "前面カメラ起動!", Toast.LENGTH_SHORT).show()
        } else {
            Toast.makeText(this, "背面カメラ起動!", Toast.LENGTH_SHORT).show()
        }
    }
}

非推奨のonRequestPermissionsResult()に代わり、registerForActivityResultを実装することになります。
実行後、そのインスタンスをrequestPermissionResultに保持します。

    private val requestPermissionResult = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { granted ->
        if (granted[Manifest.permission.CAMERA] == true) {
            // パーミッションが許可されているため、カメラを起動
            showCamera(mCameraId)
        } else {
            // パーミッションが得られなかった時
            // 処理を中断する・エラーメッセージを出す・アプリケーションを終了する等
        }
    }

registerForActivityResult()の第1引数にはActivityResultContracts.RequestPermission()または、ActivityResultContracts.RequestMultiplePermissions()を設定します。複数のPermissionを取得する場合は、RequestMultiplePermissionsを設定します。本件ではCameraPermissionのみですが、あえてRequestMultiplePermissionsを設定します。

第2引数には、レスポンス時のコールバックを設定します。
このコールバック上に、onRequestPermissionsResult()と同様の処理を実装します。

最後に、Permissionリクエスト時のダイアログを表示するActivityCompat.requestPermissions()requestPermissionResult.launch()に置き換えてください。

// パーミッションリクエストダイアログを開く
//  ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CAMERA), REQUEST_CODE)
requestPermissionResult.launch(arrayOf(Manifest.permission.CAMERA))

上記に置き換えて実行すると、置き換え前と同様の動きであることを確認できます。
今後はActivityResultAPIを利用することが主流になりますので、覚えておきましょう。

ActivityResultContractを自作する

実際、onRequestPermissionsResult()同様にonActivityResult()も非推奨となり、その場合もregisterForActivityResultを利用することになりました。ここでは割愛しますが、以下に実装方法が詳しく記載されているので、読んでおいていただければと思います。

https://developer.android.com/training/basics/intents/result

このようにregisterForActivityResultの引数次第で、コールバックで受け取れるデータの内容を変えることができますが、このActivityResultContractをカスタマイズし、今以上にコードをすっきり書けるようにしてみようと思います。

たとえば、サンプルコードを見てもらうと分かりますが、MainActivityのプロパティ変数にmCameraIdってありますが、これの使い方としてはPermissionを求めたときにcameraIdlaunch()メソッドに渡せず、コールバック側にidを伝える手段が無いために、仕方なくクラスプロパティとして定義していました。これをActivityResultContract側で保持することができれば、不要なプロパティを定義する必要がなくなります。

今回、この不満点を解消したカスタムクラスを作成します。

ActivityResultContracts::RequestMultiplePermissionsのコードを見てみる

まずは、カスタム元となるクラスの中身を見てみます。

    class RequestMultiplePermissions :
        ActivityResultContract<Array<String>, Map<String, @JvmSuppressWildcards Boolean>>() {

        companion object {
            /**
             * An [Intent] action for making a permission request via a regular
             * [Activity.startActivityForResult] API.
             *
             * Caller must provide a `String[]` extra [EXTRA_PERMISSIONS]
             *
             * Result will be delivered via [Activity.onActivityResult] with
             * `String[]` [EXTRA_PERMISSIONS] and `int[]`
             * [EXTRA_PERMISSION_GRANT_RESULTS], similar to
             * [Activity.onRequestPermissionsResult]
             *
             * @see Activity.requestPermissions
             * @see Activity.onRequestPermissionsResult
             */
            const val ACTION_REQUEST_PERMISSIONS =
                "androidx.activity.result.contract.action.REQUEST_PERMISSIONS"

            /**
             * Key for the extra containing all the requested permissions.
             *
             * @see ACTION_REQUEST_PERMISSIONS
             */
            const val EXTRA_PERMISSIONS = "androidx.activity.result.contract.extra.PERMISSIONS"

            /**
             * Key for the extra containing whether permissions were granted.
             *
             * @see ACTION_REQUEST_PERMISSIONS
             */
            const val EXTRA_PERMISSION_GRANT_RESULTS =
                "androidx.activity.result.contract.extra.PERMISSION_GRANT_RESULTS"

            internal fun createIntent(input: Array<String>): Intent {
                return Intent(ACTION_REQUEST_PERMISSIONS).putExtra(EXTRA_PERMISSIONS, input)
            }
        }

        override fun createIntent(context: Context, input: Array<String>): Intent {
            return createIntent(input)
        }

        override fun getSynchronousResult(
            context: Context,
            input: Array<String>
        ): SynchronousResult<Map<String, Boolean>>? {
            if (input.isEmpty()) {
                return SynchronousResult(emptyMap())
            }
            val allGranted = input.all { permission ->
                ContextCompat.checkSelfPermission(
                    context,
                    permission
                ) == PackageManager.PERMISSION_GRANTED
            }
            return if (allGranted) {
                SynchronousResult(input.associate { it to true })
            } else null
        }

        override fun parseResult(
            resultCode: Int,
            intent: Intent?
        ): Map<String, Boolean> {
            if (resultCode != Activity.RESULT_OK) return emptyMap()
            if (intent == null) return emptyMap()
            val permissions = intent.getStringArrayExtra(EXTRA_PERMISSIONS)
            val grantResults = intent.getIntArrayExtra(EXTRA_PERMISSION_GRANT_RESULTS)
            if (grantResults == null || permissions == null) return emptyMap()
            val grantState = grantResults.map { result ->
                result == PackageManager.PERMISSION_GRANTED
            }
            return permissions.filterNotNull().zip(grantState).toMap()
        }
    }

上記でActivityResultContractのジェネリクスを眺めると、以下のようになっています。

ActivityResultContract<Array<String>, Map<String, @JvmSuppressWildcards Boolean>>

どうやら、Array<String>が入力(launch()メソッドに渡す引数)を表し、Map<String, @JvmSuppressWildcards Boolean>がコールバックに通知する型だと思われます。あとは、overrideメソッドの実装は基本的にそのまま利用できそうなので、難しい実装にはならないかと思います。

では、次にカスタムクラスの実装をしてみます。

CustomRequestMultiplePermissionsクラスの実装

RequestMultiplePermissionsの実装を参考に以下のように実装しました。

package com.hoge.requestpermissionsampleapp

import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.activity.result.contract.ActivityResultContract
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat

data class GrantInput<T>( // launchメソッドに渡すデータクラス追加
    val permissions: Array<String>,
    val param: T
)
data class GrantResults<T>( // コールバック側に通知するデータクラス追加
    val grants: Map<String, @JvmSuppressWildcards Boolean>,
    val param: T?
)
class CustomRequestMultiplePermissions<T>: ActivityResultContract<GrantInput<T>, GrantResults<T>>() {
    private var param: T? = null
    companion object {
        internal fun createIntent(input: Array<String>): Intent {
            return Intent(ActivityResultContracts.RequestMultiplePermissions.ACTION_REQUEST_PERMISSIONS).putExtra(
                ActivityResultContracts.RequestMultiplePermissions.EXTRA_PERMISSIONS, input)
        }
    }
    override fun createIntent(context: Context, input: GrantInput<T>): Intent {
        param = input.param
        return createIntent(input.permissions)
    }
    @Suppress("AutoBoxing")
    override fun parseResult(resultCode: Int, intent: Intent?): GrantResults<T> {
        if (resultCode != Activity.RESULT_OK) return GrantResults(emptyMap(), param)
        if (intent == null) return GrantResults(emptyMap(), param)
        val permissions = intent.getStringArrayExtra(ActivityResultContracts.RequestMultiplePermissions.EXTRA_PERMISSIONS)
        val grantResults = intent.getIntArrayExtra(ActivityResultContracts.RequestMultiplePermissions.EXTRA_PERMISSION_GRANT_RESULTS)
        if (grantResults == null || permissions == null) return GrantResults(emptyMap(), param)
        val grantState = grantResults.map { result ->
            result == PackageManager.PERMISSION_GRANTED
        }
        return GrantResults(permissions.filterNotNull().zip(grantState).toMap(), param)
    }
    override fun getSynchronousResult(
        context: Context,
        input: GrantInput<T>
    ): SynchronousResult<GrantResults<T>>? {
        if (input.permissions.isEmpty()) {
            return SynchronousResult(GrantResults(emptyMap(), param))
        }
        val allGranted = input.permissions.all { permission ->
            ContextCompat.checkSelfPermission(
                context,
                permission
            ) == PackageManager.PERMISSION_GRANTED
        }
        return if (allGranted) {
            SynchronousResult(GrantResults(input.permissions.associateWith { true }, param))
        } else null
    }
}

ざっくりと解説しますが、やっていることはカスタム前と変わりありません。
コードのトップに2種のデータクラスを定義し、ActivityResultContractのジェネリックに対して、以下のように変更しました。

ActivityResultContract<GrantInput<T>, GrantResults<T>>

型パラメータTには、本件で渡すことになるcameraIdなどのパラメータの型となります。
本件のcameraIdはInt型ですが、汎用的にさせたいところなので、ジェネリック定義としました。

それによって、overrideメソッドの入力と戻り値の型が変わるので、その部分についてはカスタマイズしています。

以上で、カスタムクラスの実装ができましたので、これを使ってみたいと思います。

CustomRequestMultiplePermissionsクラスを利用する

早速利用してみたいと思います。
以下の箇所を置き換えてください。

//    private val requestPermissionResult = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { 
    private val requestPermissionResult = registerForActivityResult(CustomRequestMultiplePermissions<Int>()) { result ->
        if (result.grants[Manifest.permission.CAMERA] == true) {
            // パーミッションが許可されているため、カメラを起動
//            showCamera(mCameraId)
            showCamera(result.param?:0) // わざわざmCamera参照することなく、result.paramからカメラIDが参照可能となる
        } else {
            // パーミッションが得られなかった時
            // 処理を中断する・エラーメッセージを出す・アプリケーションを終了する等
        }
    }

registerForActivityResultの第1引数をCustomRequestMultiplePermissionsに置き換えることで、result.paramからCameraIdを取得することができるようになります。CustomRequestMultiplePermissionsのジェネリック型はcameraIdに合わせてIntを指定します。これにより、クラスプロパティを設置することなく、CameraIdを取得できました。

最後に呼び出し元(launchメソッド)の置き換えです。

// requestPermissionResult.launch(arrayOf(Manifest.permission.CAMERA))
requestPermissionResult.launch(GrantInput(arrayOf(Manifest.permission.CAMERA), cameraId))

置き換えることで、launchの引数にGrantInput()を求められるようになっているかと思います。
GrantInput()の第1引数に今まで同様要求したいPermission定義を、第2引数にCameraIdをセットすることで、コールバックまでCameraIdが通知されるようになります。

以上で、カスタムクラスの説明となりますが、カスタムクラスが作れるようになると、本件のように任意のデータを引き回すことが可能なったりと、コーディングの幅を広げることができるかと思います。

まとめ

いかがだったでしょうか。
本件、RuntimePermissionの復習の意味で書いてみましたが、これからRuntimePermissionを実装する方への参考になれば幸いです。

また、本件でActivityResultAPIを利用しましたが、これについても機会があれば深堀りしていきたいと思います。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる