4
3

ActivityResultContract時代のランタイムパーミッションリクエスト

Last updated at Posted at 2024-01-08

ラインタイムパーミッションのリクエストは伝統的に requestPermissions()onRequestPermissionsResult() で行われていましたが、この方法は非推奨となり、ActivityResultContractを使うことが推奨されていますね。

Activity/FragmentのコンストラクタもしくはonCreateにて

private val launcher = registerForActivityResult(RequestPermission()) {
    if (it) {
        // 許諾得られた
    } else {
        // 許諾得られなかった
    }
}

として、コールバックを定義したActivityResultLauncherを登録し、以下のようにリクエストするパーミッションを引数に指定し、リクエストします。

launcher.launch(Manifest.permission.XXXXX)

単純なリクエストするだけであれば良いのですが、すこし使いにくい感があります。
ランタイムパーミッションのリクエストは2回ユーザーに拒否されたあとは、何も表示されず失敗しますが、そのケアは別途自前で行う必要があります。

ActivityResultLauncherとパーミッションを紐付ける

registerForActivityResult()では、コールバックを実装する段階で、対象パーミッションが決まっておらず、リクエスト時にパーミッションを指定するスタイルのため、チョット使いにくい感がありますね。
スコープがActivityやFragmentに閉じるためそれほど広くはないですが、コールバックが想定しているパーミッションと異なるパーミッションをリクエストしてしまうリスクが出てくるので、リクエストするパーミッションとそれに対するコールバックという形で紐付けて定義したくなります。

ということで、紐付けできるようにしちゃいましょう。
以下のようにregisterForPermissionRequestをラップする仕組みを作ると

interface PermissionRequestLauncher {
    fun launch()
}

fun ComponentActivity.registerForPermissionRequest(
    permission: String,
    callback: (granted: Boolean) -> Unit,
): PermissionRequestLauncher =
    PermissionRequestLauncherImpl(permission, registerForActivityResult(RequestPermission()) { granted ->
        callback(granted)
    })

fun Fragment.registerForPermissionRequest(
    permission: String,
    callback: (granted: Boolean) -> Unit,
): PermissionRequestLauncher =
    PermissionRequestLauncherImpl(permission, registerForActivityResult(RequestPermission()) { granted ->
        callback(granted)
    })

class PermissionRequestLauncherImpl(
    private val permission: String,
    private val launcher: ActivityResultLauncher<String>,
) : PermissionRequestLauncher {
    override fun launch() {
        launcher.launch(permission)
    }
}

以下のように、定義時にリクエストするパーミッション、それに対するコールバックという形で定義できるようになります。

private val launcher = registerForPermissionRequest(Manifest.permission.XXXXX) {
    if (it) {
        // 許諾得られた
    } else {
        // 許諾得られなかった
    }
}

リクエスト時は引数なしで呼び出すだけです。

launcher.launch()

システムダイアログが表示されたかどうかを判定する

ランタイムパーミッションをリクエストして、ユーザーに2回「許可しない」を選択されると、以降ダイアログが表示されず、即座にコールバックが呼び出されます。
表示されなかった場合は、ユーザーにシステム設定で許可してもらう案内などを出す必要があります。一方、システムダイアログが表示された場合に再度許諾依頼を出すのはユーザーの心証を損ねるので避けたいところです。

しかし、この状態を直接検出する方法は提供されていません。

shouldShowRequestPermissionRationaleによる判定

「許可しない」を選択した場合に変化する値として、

ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.XXXXX)

の戻り値がありますね。パーミッションリクエストの根拠を示すべきかどうか、というメソッドですね。
1回目リクエストして、「許可しない」を選択されたあと、もう一度リクエストするときは、根拠を丁寧に説明してリクエストするべきという意味で用意されているメソッドですね。
このメソッドの戻り値は、以下のように変化します

「許可しない」の回数 shouldShowRequestPermissionRationaleの値
0回 false
1回 true
2回 false

システムダイアログの選択で許可されればそこで完了ですが、許可されないパターンとして、「許可しない」を選択した場合と、ダイアログ外部をタップするなどで「キャンセル」する場合の2パターンを考える必要があります。
ダイアログの選択内容と、パーミッションリクエスト前とコールバック時の変化を整理すると以下のようになります。

許可しない回数 リクエスト前 ダイアログの選択 コールバック時
0回 false キャンセル false
0回 false 許可しない true
1回 true キャンセル true
1回 true 許可しない false
2回 false ダイアログが表示されない false

リクエスト前後でshouldShowRequestPermissionRationale()をコールし、どちらかの戻り値がtrueならシステムダイアログが表示されたと判定できます。
問題は、どちらもfalseの場合で、これは、ダイアログが表示されたがキャンセルされた。もしくは、システムダイアログが表示されなかった。ということで、ここが絞り込めません。

時間による判定

shouldShowRequestPermissionRationale()の戻り値では、ダイアログが表示されたがユーザー操作でキャンセルされた場合か、システムダイアログが表示されず即座にコールバックが返ってきたのかの判定ができません。
ただし、ユーザー操作が入っているため、完璧でないにしても時間を計測すれば判定できそうです。
即座にコールバックが返る場合も100msぐらいはかかるようなので、連打などでキャンセルされた場合、誤判定をしてしまう可能性が出てきてしまいますが、「表示されなかった」→「十分な時間表示されなかった」と割り切ればそこまで不自然ではないと思いたい。

ダイアログ表示判定を追加

shouldShowRequestPermissionRationale()の戻り値と、経過時間を組み合わせてシステムダイアログが表示されたかどうかを判定する仕組みを組み込んでみます。

interface PermissionRequestLauncher {
    fun launch()
}

fun ComponentActivity.registerForPermissionRequest(
    permission: String,
    callback: (granted: Boolean, succeedToShowDialog: Boolean) -> Unit,
): PermissionRequestLauncher =
    PermissionRequestLauncherImpl({ this }, permission).also { launcher ->
        launcher.launcher = registerForActivityResult(RequestPermission()) { granted ->
            callback(granted, launcher.succeedToShowDialog())
        }
    }

fun Fragment.registerForPermissionRequest(
    permission: String,
    callback: (granted: Boolean, succeedToShowDialog: Boolean) -> Unit,
): PermissionRequestLauncher =
    PermissionRequestLauncherImpl({ requireActivity() }, permission).also { launcher ->
        launcher.launcher = registerForActivityResult(RequestPermission()) { granted ->
            callback(granted, launcher.succeedToShowDialog())
        }
    }

class PermissionRequestLauncherImpl(
    private val activitySupplier: () -> Activity,
    private val permission: String,
) : PermissionRequestLauncher {
    lateinit var launcher: ActivityResultLauncher<String>
    private var shouldShowRationalBefore: Boolean = false
    private var start: Long = 0L

    fun succeedToShowDialog(): Boolean {
        if (shouldShowRationalBefore) return true
        if (System.currentTimeMillis() - start > ENOUGH_DURATION) return true
        return ActivityCompat.shouldShowRequestPermissionRationale(activitySupplier(), permission)
    }

    override fun launch() {
        start = System.currentTimeMillis()
        shouldShowRationalBefore =
            ActivityCompat.shouldShowRequestPermissionRationale(activitySupplier(), permission)
        launcher.launch(permission)
    }

    companion object {
        private const val ENOUGH_DURATION = 1000L
    }
}

1秒以上経過していたら表示されたという判定をしています。
システムダイアログが表示されたのに、再度説明のダイアログが表示された、よりも、なんの説明もなく失敗した、の方を避けるべきと考え大きめの値を設定していますが、アプリのユースケースに併せて調整すれば良いと思います。

shouldShowRequestPermissionRationale()の判定にはActivityのインスタンスが必要なのですが、Fragmentの場合はonAttachまでの間Activityにアクセスできないため、registerForPermissionRequest()ではなく、launch()のタイミングでrequireActivity()をコールするようにラムダで渡しています。
微妙にモヤモヤするやり方なのでよりよい方法はないだろうかと悩んでいるところです。

とにもかくにも、上記のような仕組みを作っておくと、以下のように許諾が得られなかった場合にシステムダイアログが表示されたかどうかの判定も併せて行うことができるようになります。

private val launcher = registerForPermissionRequest(Manifest.permission.XXXXX) {
        granted, succeedToShowDialog ->
    if (granted) {
        // 許諾得られた
    } else if (succeedToShowDialog) {
        // システムダイアログが表示されたが許諾得られなかった
    } else {
        // システムダイアログが表示されなかった
    }
}

以上です。

4
3
1

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
4
3