はじめに

こんにちは、エンジニアの遠藤です。

今回は数年前に開発した Android アプリの実装を見直していたところ、全く検討はずれな実装になっていたことに衝撃を受けたので、今いちど、整理してみようと思います。

今回の検証ソースコードは以下に置いてあります。
https://github.com/milldea/blog-android-permission-sample

検証した端末は Xperia 1 Ⅱ (Android12) です。

公式の推奨する権限要求フロー

公式には、以下のように説明されています。
https://developer.android.com/training/permissions/requesting

  • パーミッションが付与済みかどうかチェックする (checkSelfPermission)
  • 付与済みの場合
    • 必要な情報にアクセスする
  • 未付与の場合
    • 説明の表示が必要かどうかをチェックする (shouldShowRequestPermissionRationale)
      • 不要な場合
        • 権限をそのまま要求する (requestPermissions)
      • 必要な場合
        • 説明文を表示する
        • 権限を要求する (requestPermissions)
    • 要求結果を受け取る (onRequestPermissionsResult)
      • 拒否された場合
        • アプリを適切に degrade させ、アプリは使えるようにしておく
      • 許可された場合
        • 必要な情報にアクセスする

公式にもありますし、基本の実装はこれを踏襲するべきかと思います。
公式の推奨するフローで実装すると、以下の流れになります。

mermaidのコード
graph TD
    START[START] -->A[[初回要求]]
    A -->|shouldShowRequestPermissionRationale = false| B(requestPermissions)
    B --> C(システムダイアログ表示)
    C --> D{onRequestPermissionsResult}
    D -->|許可| E[アクセスできる]
    D -->|今回のみ| E[アクセスできる]
    D -->|許可しない| F[[2回目要求]]
    F -->|shouldShowRequestPermissionRationale = true| G(権限要求の説明を表示)
    G --> H(requestPermissions)
    H --> I(システムダイアログ表示)
    I --> J{onRequestPermissionsResult}
    J -->|許可| K[アクセスできる]
    J -->|今回のみ| K[アクセスできる]
    J -->|許可しない| L[[3回目以降要求]]
    L -->|shouldShowRequestPermissionRationale = false| M(requestPermissions)
    M --> N{onRequestPermissionsResult}
    N -->|許可しない| L[[3回目要求]]
    

このフローを見ていただくと分かりますが、3回目以降の要求では、システムダイアログは出ません。
裏側で勝手にリクエストを行い、勝手に失敗が返される形となります。

それでも良いよ、自前のダイアログなんか出さないよ、という場合は こちらの Activity の実装がそのまま参考になると思います。

この先の内容は特に見ていただく必要はありません。

さて、アプリによっては、システムダイアログが出ない状態を検知して、自前のダイアログを表示し、システム設定画面に飛べるようにしたい、ということもあるかもしれません。
(そもそも拒否しているのだから、自前でもダイアログなんか出すなよ、という意見はあろうかと思いますが...)
そこで、4パターンほど、システムダイアログが出ない状態の判定を実装してみました。

shouldShowRequestPermissionRationale

実装説明の前に、 shouldShowRequestPermissionRationale の仕様を簡単に見ておきましょう。
ちなみに公式には、

Gets whether you should show UI with rationale before requesting a permission.

「permission 要求する前に、権限要求の根拠を表示する必要があるか?」という説明しかありません。
ちょっと不親切かなという感じがしますね。

なので、実際に端末を使って確認して、挙動を整理すると以下のようになります。

  • 権限が付与済み
    • false
  • 権限未付与時
    • 「設定」アプリから「許可しない」に戻した場合
      (これは requestPermissions() で1度「許可しない」を選んだ状態と同じです。)
      • true
    • 初回のリクエストをしていない場合
      • false
    • requestPermissions() で1度だけ「許可しない」を選んでいる場合
      • true
    • requestPermissions() で2度続けて「許可しない」を選んでいる場合
      • false

公式で推奨している割には、なんだか条件がシンプルではなく、使い勝手の悪い API に感じられます。

システムダイアログが出ない状態の判定 4 パターン

というわけで、標準で用意された API はなく、自分で判定を仕込む必要があります。
今回の検証コードは、ネットで見かけたパターンや、自分で考えたパターンなどを 4 パターン実装してみました。
どの判定方法も一長一短かなとは思います。

パターン1: システムダイアログが出たかどうかを確認する

パターン1の実装はこちら

requestPermissions を呼ぶと、 onPause が呼ばれます。
そのタイミングで、 ActivityManager を使い、最上位に表示されている Activity が自身の Activity と一致するか判定しています。

ちなみに、システムダイアログは com.android.permissioncontroller.permission.ui.GrantPermissionsActivity という Class 名で取得できます。

この方法には懸念があり、 onPause のスレッド内で判定すると、システムダイアログが出るタイミングでも、最上位の Activity は自身の Activity になります。
さらに、見た目的にはシステムダイアログが出ていなくても、 システムダイアログの Activity が取れてしまう瞬間が 30 ms ほどあるため、サンプルでは 50ms の sleep を入れています。

また、今後の OS アップデートで、ダイアログが出ない時は onPause が呼ばれないなどの変更が入る可能性があります。

パターン2: requestPermissions してから onRequestPermissionsResult が呼ばれるまでの時間を判定する

パターン2の実装はこちら

この間隔が 100 ms などまぁまぁ短ければ、ダイアログが出ていないと判定します。
シンプルで良さそうですが、操作がとても早い人がいたり、端末がもっさりしていたら、タイミングがズレる懸念はあります。

パターン3: SharedPreferences に前回のチェック状態を保持しておく

パターン3の実装はこちら

この方法もちょこちょこ見かけました。
onRequestPermissionsResult のタイミングで権限が貰えていなければ true を保存しておく。
要求前に shouldShowRequestPermissionRationale の値と保存した権限状態を比較する。
不一致だったら、システムダイアログが出ないと判断する、というもの。

状態 permission shouldShowRequestPermissionRationale 結果
初回 false false 一致しているのでシステムダイアログは出ない
2回目 true true 一致しているのでシステムダイアログは出ない
3回目 true false 不一致なのでシステムダイアログが出る

表にすると、こうなるのですが、、、なんだか直感的でなくて気持ち悪いですよね。。
そもそも shouldShowRequestPermissionRationale を本来の用途と異なる、システムダイアログの表示判定用に使っている点があまり好きになれません。
また、設定アプリから手動で変更した時(「毎回確認する」に変更した場合)に、うまく判定できません。

パターン4: onRequestPermissionsResult で shouldShowRequestPermissionRationale する

パターン4の実装はこちら

こちらは、 onRequestPermissionsResult のタイミングで、shouldShowRequestPermissionRationale を呼び出し、 false だったら次はシステムダイアログが出ない、と判定するタイプです。
考え方は パターン3 とほぼ同じです。
手動で権限設定を変更していた場合(「毎回確認する」に変更)に、条件が狂ってしまいます。

公式でこの挙動に関する API がないのが不思議ですね...
パターン1, 2 は機種のタイミング依存で判定条件が狂いそうな点が、パターン3, 4 は手動での権限変更に対応しきれていない点が、それぞれデメリットになるかなと思います。

余談

ヘンテコな実装になった原因

数年前に開発した Android アプリの実装が全く検討はずれな実装になった原因は、 shouldShowRequestPermissionRationale の仕様を、
【「次回から確認しない」のチェックを入れて拒否すると、 false が返ってくる関数】
と勘違いしていたことでした。
つまり、【権限を要求した際に、システムダイアログが出るかどうかを判定する関数】だと思い込んでいたわけです。
おそらく、何かのブログで読んだものをそのまま実装したのだと思います。
たしかに、チェックを入れると false が返ってくるため、あまり深く考えずに実装したのだと思います。

Android 11 以降では、このチェックボックスもなくなり、複数回拒否すると出なくなる仕様に変わったことや、「今回のみ」の選択も増えましたので、改めてまとめておこう、となった次第です。
https://developer.android.com/about/versions/11/privacy/permissions

ACCESS_BACKGROUND_LOCATION の挙動も難しい

ACCESS_BACKGROUND_LOCATION を要求する場合に関しては、 ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION の権限がある場合とない場合で挙動が変わります。

位置情報に権限がある場合は、上記までの説明通りですが、権限がない場合は以下の挙動になります。

shouldShowRequestPermissionRationale は、常に true を返します。
また、 requestPermissions はシステムダイアログが出ず、必ず拒否されて onRequestPermissionsResult に入ってきます。

「今回のみ」を選んだ場合の権限は?

「今回のみ」 を選ぶと、権限は PERMISSION_GRANTED が返却されます。
タスクキルしても、一定時間は許可された状態が続きます。

Logcat を見ているとバックグラウンドに遷移してから、大体1分くらいで以下のようなログが出てきて、 checkSelfPermissionPERMISSION_DENIEDを返すようになります。

OneTimePermissionUserManager: One time session expired for com.example.permissionsample (10725).

公式の翻訳がおかしい

「次回から表示しない」の仕様変更に関する記事ですが...

英語の場合

https://developer.android.com/about/versions/11/privacy/permissions

Starting in Android 11, if the user taps Deny for a specific permission more than once during your app's lifetime of installation on a device, the user doesn't see the system permissions dialog if your app requests that permission again.

「more than once」 と2回以上であることが明記されているのに対し

日本語の場合

https://developer.android.com/about/versions/11/privacy/permissions?hl=ja

Android 11 以降では、デバイスにインストールされたアプリの全期間に、同じ権限に対してユーザーが何度も [許可しない] をタップした場合、アプリがその権限を再度リクエストしても、ユーザーにシステム権限ダイアログが表示されることはありません。

「何度も」 ととても曖昧な表現に変わっています。
こういうところでも、原典にあたるのは大事だなと思わされますが、それはそうとして公式が用意した翻訳ページくらいは信じたいところ...

おわりに

久しぶりに、権限周りをしっかり見直して、全然理解せずに実装していたことを恥ずかしく思いました。
Android は機種依存、バージョン依存が多く、検証では心が折れそうになることも多いですが、めげずに開発を楽しもうと思います。