読者です 読者をやめる 読者になる 読者になる

なるようになるかも

力は多くの場合、その人の思いを超えない。

いまさら Android 6.0 の Permissions at Run Time について

ここに書いてある内容が正しいという保証が一切持てないので、けっして鵜呑みにしないでください。

とりあえず公式ドキュメントのRequesting Permissions at Run Timeをざっくり読みました。

ランタイムパーミッションの概要

Andorid 6.0(API level23)以降では、ユーザーはインストール時ではなく、アプリの実行中にパーミッションを付与します。

このアプローチにより、ユーザーはアプリのインストールやアップデート時に権限を付与する必要がなくなったため、インストールプロセスがスリムになります。

それだけでなくユーザーはアプリの機能単位で、より詳細な制御を行うことができます。

例えば、カメラアプリが「カメラ」と「位置情報」の機能を要求する場合、「カメラ」へのアクセスを許可し、「位置情報」には権限を与えないことができます。

ユーザーはアプリの設定画面へ行くことで、いつでも権限の取り消しを行うことができます。

今までのパーミッションシステムには以下のような問題がありました。

  • 今まではインストール・アップデート時に必要な権限が長々と並んで、ユーザーが許諾しないとアプリを入れられない仕組みだったけれど、インストール時点で「何のためにその権限を要求しているのか」ということは分かりづらかった。
  • 説明がユーザーにとっては直感的ではない上に、ネタみたいなのもあった。android.permission.BRICK(あなたの端末を文鎮化する可能性があります)とか。
  • すべてのパーミッション要求を許諾しないとインストールできない仕組みだったため、アプリのコア機能に不要な付加的なパーミッションも許諾する必要があった。
  • 開発者でもないとその権限で何ができるのかは分からないので、危険なパーミッションがそれと知らずに紛れ込んでいる可能性があった。

ただし全ての問題が解決したわけではないですし、実装する側にとっては面倒きわまりない話です。これはそんな話です。

パーミッションのON/OFFができる粒度

パーミッションには膨大な数がありますが、それぞれの一つ一つについて、ON/OFFの制御をすることは できません

motida-japan.hatenablog.com

上記の記事が非常に参考になるのですが、パーミッショングループの単位でのみ設定が可能です。

パーミッショングループ

パーミッショングループについて補足しておくと、これはもともとパーミッションをカテゴライズしておくことで、インストール時に整理された状態で表示する機能でした。

f:id:quesera2:20160429124931p:plain

古のインストール確認画面はこんな感じで、「現在地」や「ネットワーク通信」のように分かりやすく分類し、優先度の高いものが上に並ぶように並べ替え、そして本当に危険なパーミッションを「その他」に追いやって分かりにくくするための機能でした。

パーミッショングループ単位での指定の問題

「より詳細な制御」はできない

例えば「カレンダー」というグループ単位でONにすると、「カレンダーデータの読み込み(android.permission.READ_CALENDAR)」と「カレンダーデータへの書き込み(android.permission.WRITE_CALENDAR)」の両方を許諾したことになります。

アプリから「電話」を掛けられるようにONをすると、android.permission.READ_PHONE_STATEが許諾されます。「電話の状態の読み取り」という牧歌的な名前に反して、電話番号から端末の一意識別子まで抜ける超危険なパーミッションのひとつです。

もちろん、マニフェストに宣言されていないパーミッションまでは取得できません。要求する権限は最小限にしましょう。

また、ユーザー視点としては、Permissions at Run Timeがあっても安心せずに、アプリが要求するパーミッションを見定める必要があります。

許諾状態は常に変わる可能性がある

このパーミッショングループの分類について、将来的に変更される可能性があります。

例えば「カレンダーの読み込み」と「書き込み」が分離されたとして、その場合は一度「カレンダー」としてパーミッションの許諾を得ていたとしても、再度パーミッションを取得する処理を入れる必要があります。

また、ユーザーは任意のタイミングで許諾の取り消しを行うことができます。このため、常に最新の許諾状態を取得する必要があります。

例えばカーナビアプリが定期的にバックグラウンドで位置情報を取得している場合、設定から位置情報の許諾を取り消された場合ってどうすればいいんでしょう?

フォアグラウンドのみのアプリならonResume()での確認でよいのではと思うのですが、Android Nのマルチウインドウで設定を変えられたら?

許諾状態は常に変わる可能性を踏まえて、パーミッションの確認、要求ダイアログの表示などを自前で実装する必要があります。

パーミッショングループと危険性には相関がない

もともとAndroidパーミッションにはProtectionLevelという概念があり、個々のパーミッションについてnormaldangerousなどの格付けがありました。

この部分は正直確証がないのですが、Android 6.0ではこの仕組みを投げ捨てていているように思います。

Normal PermissionDangerous Permissionに大分し、前者はユーザーの許諾を必要としないパーミッション、後者は必要とする(=パーミッショングループに定義されている)パーミッションというように再構築されているような…。

その結果、パーミッショングループに含まれない、dangerousパーミッションは現状では許可を取る必要なかったり、整合性を取るためにandroid.permission.INTERNETdangerousからnormalに格下されたりと不穏な感じがします。

どう実装すればいいのか

ここからが本題です。

Android Nが出る時代に、targetSDKを22にしたときの話をしてもしょうがないので、targetSDKを23以上にして開発するものとします。

また、requestPermissions()を投げて、onRequestPermissionsResult()で結果を得る、という基本的な部分は理解しているものとします。この部分についてはいくらでも情報があるので、特に困ることはないでしょう。

パーミッションを得ているかどうか知る方法

checkSelfPermission()

これは、PackageManagercheckPermission(String permName, String pkgName)シンタックスシュガーです。

checkPermission()メソッドAPI Lv1からあり、指定したパッケージのアプリがパーミッションを宣言しているかどうかを知ることができました。

Android 6.0ではこの意味合いが変わり、「パーミッションを宣言しており、かつユーザーに許可されているかどうか」に変わりました。

checkSelfPermission()ではパッケージ名の指定が不要で、アプリ自身のパーミッションの取得状態を知ることができます。

ただし、checkSelfPermission()API Lv23以降にしかありません。そこで、互換ライブラリのContextCompat.checkSelfPermission()を使うことができます。

実装は単純で、単に自分のパッケージ名を明示的に指定して、checkPermission()を呼び出しているだけです。

このAPIを使うことで、

を知ることができます。

ただしまだrequestPermissions()を呼んでいない場合には、PERMISSION_DENIEDが返ってきます。(targetSDK23以上の場合。22以下ではインストール時点で自動的にパーミッションの承諾が行われるので、ユーザーが明示的に設定を変えない限りはPERMISSION_GRANTEDが返って来る)

PermissionChecker

サポートライブラリには、PermissionCheckerというクラスもあり、checkSelfPermission()など同名のメソッドが用意されています。

これが何のために存在するのかというと、Android 4.3のAppOpsという隠し機能 のためです。

ご存知の方はみんな知っての通り、Android 6.0で追加されたPermissions at Run Timeの原型とも言える仕組みは、Android 4.3の時点から隠し機能として搭載されていました。

恐らくですが、

  • targetSdkを23未満でビルドしたアプリを、Android 6.0で動作させて、パーミッションを剥奪した場合
    • このときはPermissions at Run Timeではなく、AppOpsで動作している?
  • Android 6.0未満の端末で、App Opsを用いて、パーミッションを剥奪した場合

こうした状況では、PermissionCheckerがより適切です。App Opsによって剥奪されたパーミッションは、通常のrequestPermissions()ではPERMISSION_GRANTEDが返却されますが、PermissionCheckerではAppOpsManagerに問い合わせて、PERMISSION_DENIED_APP_OPという結果を返してくれます。

ContextCompat.checkSelfPermission()PermissionChecker.checkSelfPermission()のどちらを使うべきなのでしょうか。PermissionCheckerを推されている記事もありますが、公式ドキュメントではContextCompatを使うことを推奨しています。

個人的には、今後はtargetSdk 23以降が基本となり、App Opsに対応しなければならない状況はほとんどなくなると思いますので、PermissionCheckerの存在は忘れてしまっても支障はないと思います。

パーミッションのリクエストを得る方法

requestPermissions()

例によって、以下の二つのメソッドがあります。

  • Activity#requestPermissions()
  • ActiivtyCompat#requestPermissions()

引数は取得したいパーミッションの配列(Manifest.permission以下のString定数)と、リクエストコードです。

オリジナルのrequestPermissions()メソッド、およびコールバックとなるonRequestPermissionsResult()は当然ながら、API level23以降のActivityにしかありません。

ActiivtyCompat#requestPermissions()は内部でバージョン判定を行ってくれるのので便利なのですが、両者の仕組みはちょっとだけ違います。

ほとんどの場合、AppCompatActivityやv4パッケージの偽Fragmentを使っていると思うのですが、ActiivtyCompat#requestPermissions()でリクエストした場合、FragmentActivityが実装しているActivityCompat.OnRequestPermissionsResultCallbackインターフェースで結果が処理されます。

このとき、コールバック先がFragmentまたはActivityかの振り分けを16bitのシフト演算で行っています。これはFragmentActivity#startActivityForResult()の原理をご存知でしたらお馴染みですね。ActiivtyCompat版を使う場合には、リクエストコードの値が16bitを超えないようにする必要があります。

ActiivtyCompat版では、 Android 6.0未満の端末でrequestPermissions()を呼んだ場合、即座にonRequestPermissionsResult()コールバックメソッドが呼び出される という違いがあります。

つまりAndroid 6.0未満がターゲットに入っていても、パーミッションリクエストが存在するものとして書いてしまえば、同じ処理で書けるのでは?

www.slideshare.net

PERMISSION_GRANTEDパーミッションに対して、requestPermissions()を行った場合に、何度でもリクエストのダイアログが表示されるという仕様らしく、この方法はダメみたいですね…。

バーミッションが拒絶された場合

  • Activity#shouldShowRequestPermissionRationale()
  • AppCompat#shouldShowRequestPermissionRationale()

もう2つある意味の説明は不要ですよね?

さて、一番良く分かんないメソッドがこれです。メソッドの意味としては「リクエストの根拠を示すべきか?」です。

とりあえず原文にあるサンプルコードを訳してみます。

// 以下、thisActivityが現在のActivityであるとする
if (ContextCompat.checkSelfPermission(thisActivity,
                Manifest.permission.READ_CONTACTS)
        != PackageManager.PERMISSION_GRANTED) {

    // パーミッションを必要とする説明を表示する必要があるか?
    if (ActivityCompat.shouldShowRequestPermissionRationale(thisActivity,
            Manifest.permission.READ_CONTACTS)) {

        // ユーザーへの説明を*非同期*で表示 ―決してブロックしてはいけない―
        // このスレッドはユーザーの応答を待っています!
        // ユーザーが説明を見た後、再度許可の要求を行ってください
        // 註)原文中のexpanationはexpalationのtypoとみなしました

    } else {

        // 何の説明も必要がありません、パーミッションの要求をすることができます
        ActivityCompat.requestPermissions(thisActivity,
                new String[]{Manifest.permission.READ_CONTACTS},
                MY_PERMISSIONS_REQUEST_READ_CONTACTS);

        // MY_PERMISSIONS_REQUEST_READ_CONTACTS はアプリが定義したint型の定数です
        // コールバックメソッドは要求の結果を得ることができます。
    }
}

まずパーミッションのチェックを行って、PERMISSION_DENIEDの場合に使うものみたいですね。

このメソッドについて理解するには、パーミッションダイアログの初回表示と2回目以降表示、そして「今後表示しない」が選択されたときの挙動を理解する必要があります。

f:id:quesera2:20160429155616p:plain

分かりやすく表にしました

最初からこの表を用意すれば8000文字も書かなくて良かった気がしますね。

メソッド Android 6.0未満 初回表示 2回目以降表示 今後表示しない
checkSelfPermission GRANTED DENIED 前回選択に順ずる DENIED
requestPermissions GRANTED パーミッション許可
ダイアログ表示
パーミッション許可
ダイアログ表示
(「今後表示しない」付き)
DENIED
shouldShowRequest
PermissionRationale
false false true false

※全部AppCompatでやっているものとします。

初回の問い合わせにおいて、checkSelfPermission()PERMISSION_DENIEDshouldShowRequestPermissionRationale()falseを返します。そのままrequestPermissions()を投げましょう。

一度リクエストが拒絶されたかどうかは、shouldShowRequestPermissionRationale()を呼ぶことで知ることができます。

そこで、2度目のrequestPermissions()の前に、なぜパーミッションを必要とするのか、その根拠を説明することができます。この処理は別途ダイアログを表示するなど、非同期で行う必要があります。もしユーザーが心変わりした場合だけ、requestPermissions()を投げましょう。

「今後表示しない」が選択された場合、もはや弁解の余地はないのでshouldShowRequestPermissionRationale()falseを返しますし、requestPermissions()を投げても即断でPERMISSION_DENIEDが返されます。

どうしても必要なパーミッションを拒絶された場合

もし「今後表示しない」が選ばれたパーミッションを、必ず要求するアプリの場合、どうすればいいのでしょうか?

アプリ設定を変更して貰うようなダイアログを表示するフローを用意するのが現実的です。しかしこれは「二度と表示するな」というユーザーの意思を尊重したものとはいえません。

shouldShowRequestPermissionRationale()trueを返したときに、「なぜこのアプリがパーミッションを要求するのか」という論理的な説明を行い、ユーザーに納得して貰うというのが最良の体験になるのではないでしょうか。

「今後表示しない」が選択されたことを知る方法

stackoverflow.com

onRequestPermissionsResult()コールバック内で、結果がPERMISSION_DENIEDかつ、shouldShowRequestPermissionRationale()falseを返した場合、「今後表示しない」が選択されたと判定することができます。

つまり、 必ずrequestPermissions()を呼ぶ必要があります 。もし既に「今後表示しない」が選択されている場合に、そのパーミッションが必要なボタンをdisabledにしておいたり、Snackbarでさりげなく通知するといった気を利かせる方法はありません。

個人的にはこの仕様は、Permissions at Run Time の欠陥だと思っています。公式ドキュメントのサンプルコードだとこのケースについて何も考えてないので注意です。

ちなみにiOSの世界では

iOSでは、マイクや位置情報を利用するAPIの初回に利用許可を求めるダイアログをOSが自動的に表示します。

アプリケーションを再インストールしない限り、再度表示されることはありません。設定から変更して貰う必要があります。

requestPermissions()のタイミングに注意しつつ、shouldShowRequestPermissionRationale()は2回目以降ではtrueを返す性質を利用すれば簡単にiOSと同じ挙動にできて便利ですね!

まとめ(rev2)

Permissions at Run Time は調べれば調べるほど闇が深い…。