要約

  • 3回目に権限をリクエストしたときにダイアログが出ないのは仕様です。権限ダイアログを出すのは諦めてください。
  • 解決策:  アプリ詳細設定画面へ遷移させましょう
     
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {

   data = Uri.fromParts("package", packageName, null)

}

startActivity(intent)
  • 権限に関する状態遷移は以下の表を参照。
フェーズ OSの状態

(isGranted)

OS内部フラグ

(USER_SET)

OS内部フラグ

(USER_FIXED)

Rationale

(shouldShowRationale)

アプリ自前フラグ

(hasRequested)

アプリが取るべき次のアクション ユーザーへ提示するUI
①初期状態 false false false false false OS標準の権限ダイアログを表示する 任意
②1回拒否 false true false true true ダイアログ等で理由を説明した後(Rationale)、再度リクエスト 「〇〇のために必要です」と説明
③2回拒否 false true true false true アプリ詳細設定画面へ誘導するUIを表示 「設定から許可してください」ボタン
④許可済 true true false false true 位置情報の取得・機能の実行
⑤権限剥奪 false true false true true 理由説明後、再度リクエスト(②と同じ) 「〇〇」のために必要ですと説明

はじめに

はじめまして!九州大学3年の後藤尋と申します。2026年2月の1ヶ月間、CyberAgentのインターン「Tech Job」に参加させていただき、Androidアプリ開発を行いました。この期間中、「権限ダイアログを2回拒否したあとに、ダイアログが表示されない」というバグに遭遇し、本記事の方法で解決いたしました。今回はその方法をご紹介します。

概要

Androidアプリ開発において、避けては通れない権限リクエスト。一見シンプルに見えるこの機能ですが、実装を進めると、「権限を2回拒否すると、3回目以降はタップしても何も起きなくなる」不可解な挙動に直面することがあります。

 

この記事では、

  • Androidの権限許可ダイアログの仕組み
  • 権限を2回拒否したあとの正しい実装フロー
  • 設定画面への遷移が必要な理由と判定方法

について解説します。

想定する権限と機能

  • 検証環境:Android 16.0(API 36.0)
  • 対象権限: ACCESS_FINE_LOCATION(正確な位置情報)
  • 機能: 画面に現在の緯度・経度を表示する。権限がない場合は「権限リクエスト」ボタンを表示してユーザーに許可を求める。

ACCESS_COARSE_LOCATIONやバックグラウンド位置情報については、本記事の対象外とします。

背景・解決したい課題

位置情報を取得し、画面に緯度経度を表示する機能を実装しているときに、以下のバグに遭遇しました。

 

  1. ユーザーが権限リクエストを「許可しない」と選択する。
  2. もう一度ボタンを押し、再度「許可しない」を選択する。
  3. 3回目、ボタンを押してもOSのダイアログが表示されない。

 

これはAndroidの仕様で、ユーザーが2回拒否すると永久的な拒否(Permanent Denial)とみなされ、以降のリクエストが自動的に無視されるためです。この状態になった場合、アプリはOSダイアログではなくアプリの詳細設定画面へ誘導するUIを出す必要があります。コードを確認したところ、この処理は実装されていました。

なぜこの現象が発生したのか

原因は、「過去に権限リクエストを行ったか」というフラグを永続的に保存していなかったためです(一時的に保存されていたため)。当初、このフラグはViewModel内で保持していたため、画面遷移やタスクキルによってViewModelが破棄されるとフラグもリセットされてしまいます。その結果、アプリが初めての権限リクエストと誤認してrequestPermissionsを呼びますが、OS側は永久的な拒否(Permanent Denial)状態であるため、ダイアログを出せない状態でした。

 

開発者は、この状態に陥った場合、OSのダイアログではなく、アプリの詳細設定画面へ誘導するUIを出す必要があります。しかし、単にrequestPermissionsを呼ぶだけでは、この「永久的な拒否(Permanent Denial)」状態を判別できません。

解決へのアプローチ

以下にフローを提示します。単にリクエストを投げるのではなく、アプリ側で状態を判断し、適切なアクション(ダイアログ表示 or アプリ詳細設定画面への遷移)に振り分ける必要があります。

適切なアクションへの分岐を実現するために重要なのが、OSの状態とアプリ独自のフラグの組み合わせです。

権限に関する状態管理

Android標準のshouldShowRequestPermissionRationale()だけでは、①初期状態③2回拒否後(永久的な拒否(Permanent Denial))の区別がつきません。どちらもfalseを返すからです。区別するために、永続化領域(DataStoreなど)に権限リクエストを行ったかどうかを保存する必要があります。

 

フェーズ OSの状態

(isGranted)

OS内部フラグ

(USER_SET)

OS内部フラグ

(USER_FIXED)

Rationale

(shouldShowRationale)

アプリ自前フラグ

(hasRequested)

アプリが取るべき次のアクション ユーザーへ提示するUI
①初期状態 false false false false false OS標準の権限ダイアログを表示する 任意
②1回拒否 false true false true true ダイアログ等で理由を説明した後(Rationale)、再度リクエスト 「〇〇のために必要です」と説明
③2回拒否 false true true false true アプリ詳細設定画面へ誘導するUIを表示 「設定から許可してください」ボタン
④許可済 true true false false true 位置情報の取得・機能の実行
⑤権限剥奪 false true false true true 理由説明後、再度リクエスト(②と同じ) 「〇〇」のために必要ですと説明

 

※②でUSER_SETというフラグが立ちます。ユーザーが選択(許可または拒否)したことを示します。権限が永久に拒否された場合、権限は USER_FIXED としてマークされます。
※③でUSER_FIXEDというフラグが立ちます。このFlagはユーザーが権限を2回拒否した際に設定されます。一度このフラグが立つと、システムは自動的に以降の権限リクエストを拒否し、アプリ側からの権限リクエストが無効化されます。(永久的な拒否(Permanent Denial)状態)
※⑤権限リセットは、権限を一度許可したあとに、設定画面で「拒否」に設定した場合の挙動。shouldShowRationaleが常にtrueになるため、②1回拒否と同じ状態になります。(参考:AOSPの該当箇所
※アプリをアンインストールして、再度インストールしたあとは①初期状態になります。
※OS内部フラグについてはadb shell dumpsys package my.package.name等で確認可能です。

※shouldShowRationaleはOS内部フラグによって判定されます。

// shouldShowRequestPermissionRationaleの内部ロジック

val fixedFlags = FLAG_PERMISSION_SYSTEM_FIXED | 

                 FLAG_PERMISSION_POLICY_FIXED | 

                 FLAG_PERMISSION_USER_FIXED

if ((flags & fixedFlags) != 0) {

    return false  // USER_FIXEDが立っていると強制的にfalse

}

return (flags & FLAG_PERMISSION_USER_SET) != 0

参考:AOSPの該当箇所

 

上記の表のとおり、shouldShowRationalefalseかつhasRequestedtrueの場合こそが、設定画面への誘導すべきタイミング(永久的な拒否(Permanent Denial)状態)となります。

権限要求の流れ

このロジックをシーケンス図で可視化すると、以下のようになります。

実装コードのポイント

権限に関する状態保持

権限に関する状態をsealed interfaceで管理します。

sealed interface PermissionState {

    /** 許可されているか */

    val isGranted: Boolean

        get() = this is Granted


    /** 初期状態 */

    object Initial : PermissionState


    /** * 拒否された状態

     * @property shouldShowRationale trueならアプリ内で説明が必要、falseなら設定画面へ誘導

     */

    data class Denied(val shouldShowRationale: Boolean) : PermissionState


    /** 許可済み */

    object Granted : PermissionState

}

 

永久的な拒否(Permanent Denial)の判定
ここでは、DataStoreに保存したhasRequestedLocationPermissionが重要になります。

private suspend fun isPermanentDenial(shouldShowRationaleAfter: Boolean): Boolean {

        val hasRequestedBefore = locationRepository.getHasRequestedLocationPermission() // DataStoreに保存した値をRepository経由で取得

        return !shouldShowRationaleAfter && hasRequestedBefore

    }

 

権限リクエストボタンを押したときに、ダイアログの出しわけを行う

2回目の権限リクエストの際は、OS標準のダイアログを表示する前に、自作ダイアログ等でワンクッション挟んで理由を説明するのがGoogleによって推奨されています。

    fun onClickRequestLocationPermissionButton(shouldShowRationaleAfter: Boolean) {

        when(viewModelState.value.locationPermissionState) {

            PermissionState.Initial -> {

                // 初回リクエスト

                // TODO:権限許可ダイアログの表示(1回目)

                return

            }


            is PermissionState.Denied -> {

            // 永久的な拒否(Permanent Denial)の可能性があるため、

            // ダイアログ表示フラグを更新してから、永久的な拒否かどうかを判定する

                viewModelScope.launch {

                    val isPermanent = isPermanentDenial(shouldShowRationaleAfter)

                    viewModelState.update {

                        it.copy(

                            event = if (isPermanent) {

                                // 拒否後の設定誘導ダイアログを表示

                                // TODO: 設定画面への遷移

                            } else {

                                // 通常の権限リクエストダイアログを再表示

                                // TODO:権限許可ダイアログの表示(2回目)

                            }

                        )

                    }

                }

                return

            }


            PermissionState.Granted -> {

                // 既に許可されている場合は、特に何もしない

                return

            }

        }

    }

 

権限リクエスト結果の反映

権限リクエストの結果を受け取ったあとの処理です。ここでhasRequestedLocalePermissionを保存します。

    fun onPermissionResult(

        isGranted: Boolean, context: Context, shouldShowRationale: Boolean = false

    ) {

        viewModelScope.launch {

            // 権限リクエストを行ったことを保存

            locationRepository.saveHasRequestedLocationPermission()


            // permissionStateの更新

            val newPermissionState = when {

                isGranted -> PermissionState.Granted

                shouldShowRationale -> PermissionState.Denied(shouldShowRationale = true)

                else -> PermissionState.Denied(shouldShowRationale = false)

            }

            viewModelState.update {

                it.copy(

                    locationPermissionState = newPermissionState

                )

            }

            // 許可された場合は、位置情報を取得

            if (isGranted) {

                getLocation()

            }

        }

    }

設定画面への遷移

永久的な拒否(Permanent Denial)状態になった場合は、アプリ側で権限を許可することはできません。
設定画面へ遷移するために、以下のIntentを使用します。

突然、設定画面に飛ばすとユーザーが驚くので、なぜ権限設定が必要かを説明する自作のダイアログを挟んでから遷移させると親切です。

 val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {

    data = Uri.fromParts("package", packageName, null)

}

startActivity(intent)

注意事項:権限変更とプロセスキル

ユーザーが設定画面で「許可」や「拒否」を押下し、権限を変更すると、Android OSはセキュリティと整合性を保つため、アプリのプロセスをキルして再起動する場合があります。

  • 拒否→許可に切り替えたとき
    • 権限を変更してもプロセスキルは発生しませんでした。
  • 許可→拒否に切り替えたとき
    • 権限を変更すると、即座にプロセスキルが発生します。アプリに戻るとActivityが再生成されます。

拒否 → 許可に切り替えてアプリに戻ってきてもonCreateが走らないため、権限の状態更新ができていない可能性があります。
許可 → 拒否に切り替えたとき、onStartonResume、あるいはLifecycleObserverを使用して、画面が表示されるたびに最新の権限状態を確認する実装にしておくのが安全です。

まとめ

Androidの権限リクエストで「ダイアログが出ない」問題に遭遇したら、以下の3点を確認してください。

 

  1. Android OSのshouldShowRequestPermissionRationaleは、2回拒否したり、アプリの権限をリセットし1回拒否したらfalseに戻る。
  2. 初期状態永久拒否を見分けるために、アプリ側でhasRequestedPermission(永続)フラグを管理する。
  3. 設定画面から戻った際の挙動を考慮し、onResume等で権限チェックを行う。

最後に

1ヶ月のインターンシップでしたが、2月は短くて祝日が多いということもあり、あっという間に終了しました。

トレーナーの小野さんとメンターの手塚さん、そしてチームの方々には特にお世話になりました。

今後の開発に活かせるとても貴重な経験となりました。ありがとうございました!

参考リンク