あらためてRuntime Permissionと実装方法をおさらいする

  • 11
    いいね
  • 3
    コメント

はじめに

この記事は、DroidKaigi2017で発表した『いまからはじめるAndroid 6.0対応 〜Android 7.0から8.xを見つめて〜』の補完的な記事になります。

概要

Android 6.0の大きな変更点の一つとして、Runtime Permission(Request Permission、パーミッションのリクエストなど)があります。

この機能はtargetSdkVersionを23以上にしたタイミングで適応し、後述する内容から決して無視することのできないものとなっています。

本記事ではRuntime Permissionの特徴と、ネイティブでの実装方法、当日紹介した「Permission Dispatcher」を使った実装方法を紹介します。

Runtime Permissionとはなにか

Androidアプリが一部の端末機能(ネットワークやストレージへのアクセス、センサなど)を利用する時には、該当する機能に割り当てられたパーミッションの許可(リクエスト)をAndroidManifest.xmlに記述する必要がありました。

AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="io.yamacraft.app.permission"
    android:installLocation="auto"
    >

    <!-- パーミッションの設定 -->
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

これを記述することで、それぞれのパーミッションを必要とするAPIが利用可能となります。また、Google Play上にもどんなパーミッション設定がされているかをユーザーが確認することができるようになっています。

2017-03-14_07_51_04 のコピー.png

しかし、この仕組みだけではユーザーがきちんとパーミッションの設定状況を把握しきれないとGoogleが判断したのでしょう。targetSdkVersionを23以上にしたアプリからは、 一部のパーミッションはその許可をユーザーにリクエストする処理が必要となりました。

Android_Emulator_-_Nexus_5X_API_25_GoogleAPI_5554.png

ちなみにパーミッションにはNormalパーミッションとDangerousパーミッションという種類があり、今回のパーミッションのリクエストが必要になるのはDangerousパーミッションのみとなります。

他にもパーミッションの許可申請のダイアログは、パーミッショングループごとに統一した文言が表示されます。このあたりの情報は、公式デベロッパーサイトの『Normal パーミッションと Dangerous パーミッション』を参照してください。

Runtime Permissionに対する注意点

DangerousパーミッションはデフォルトでOFFになっている

考えれば当然の話ですが、Dangerousパーミッションはユーザーによる明示的な許可が必要となるため、インストール直後は許可が全てOFFとなっています。

そのため、targetSdkVersionを23以上とした時点で、パーミッションのリクエスト処理の実装が絶対に必要となります。

パーミッションリクエストの文言は変更できない

permission_account.png

こちらも考えれば当然の話ですが、このダイアログの文言はデベロッパー側で変更することはできません。

そしてパーミッショングループによっては文言の表示内容が誤解を生みそうな文言になっている場合もあります。

permission_phone_mini.png

これはPHONEグループのリクエスト文言です。PHONEグループの中には発信履歴の許可も含まれているため、例えばREAD_CALL_LOGパーミッションを追加しているだけなのに、ユーザーにはアプリ内で発信ができてしまうと誤解されてしまいます。

そのため、該当するパーミッションのパーミッショングループによっては、パーミッションのリクエストを表示する前に何らかの形でユーザーに事前通知が必要になるケースも考えられます。

パーミッションのリクエストを実装する

ネイティブで実装する

ネイティブの実装ですが、そこまで難しくはありません。主にやるべき処理は、以下の通りです。

  • 該当のパーミッションが許可されているか確認する
  • していれば処理をそのまま実行 / されていなければパーミッションのリクエストを呼び出し
  • パーミッションのリクエストに合わせて処理を実行

以下は「ギャラリーアプリから選択した画像の情報を取得する」処理のサンプルコードです。
コードは長いですが、見るべき部分はonActivityResult()内のcheckSelfPermission()shouldShowRequestPermissionRationale()onRequestPermissionsResult()内の処理全般です。

NativePermissionActivity.java
public class NativePermissionActivity extends AppCompatActivity {

    private static final int REQUEST_CODE_PICKER = 1;
    private static final int REQUEST_CODE_PERMISSION = 2;

    private Uri selectImageUri;
    private TextView mConsoleText;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_get_storage);

        Button button = (Button)findViewById(R.id.button_get_storage);
        mConsoleText = (TextView)findViewById(R.id.console_text);

        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                // ギャラリーアプリなどを立ち上げて選ばれた画像を取得
                Intent photoPickerIntent = new Intent(Intent.ACTION_PICK);
                photoPickerIntent.setType("image/*");
                startActivityForResult(photoPickerIntent, REQUEST_CODE_PICKER);
            }
        });
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if(requestCode == REQUEST_CODE_PICKER && resultCode == RESULT_OK) {
            selectImageUri = data.getData();
            // 選択された画像の情報を取得(ストレージされたファイルは要READ_EXTERNAL_STORAGE)
            if(ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
                // 許可されていない
                if (ActivityCompat.shouldShowRequestPermissionRationale(this,
                        Manifest.permission.READ_EXTERNAL_STORAGE)) {

                    // すでに1度パーミッションのリクエストが行われていて、
                    // ユーザーに「許可しない(二度と表示しないは非チェック)」をされていると
                    // この処理が呼ばれます。

                    Toast.makeText(this, "パーミッションがOFFになっています。", Toast.LENGTH_SHORT).show();
                } else {
                    // パーミッションのリクエストを表示
                    ActivityCompat.requestPermissions(this,
                            new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},
                            REQUEST_CODE_PERMISSION);
                }
                return;
            }
            // 許可されている、またはAndroid 6.0以前
            mConsoleText.setText("Image Width:" + readPickerImageWidth(selectImageUri));
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {

        if(requestCode == REQUEST_CODE_PERMISSION) {
            // requestPermissionsで設定した順番で結果が格納されています。
            if(grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                // 許可されたので処理を続行
                mConsoleText.setText("Image Width:" + readPickerImageWidth(selectImageUri));
            } else {
                // パーミッションのリクエストに対して「許可しない」
                // または以前のリクエストで「二度と表示しない」にチェックを入れられた状態で
                // 「許可しない」を押されていると、必ずここに呼び出されます。

                Toast.makeText(this, "パーミッションが許可されていません。", Toast.LENGTH_SHORT).show();

            }
            return;
        }
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    }

    /**
     * 選択画像の幅を返す
     * @param uri 選択画像のURI
     * @return 幅
     */
    private int readPickerImageWidth(Uri uri) {
        try {
            final InputStream imageStream = getContentResolver().openInputStream(uri);
            final BitmapFactory.Options imageOptions = new BitmapFactory.Options();
            imageOptions.inJustDecodeBounds = true;
            BitmapFactory.decodeStream(imageStream, null, imageOptions);
            return imageOptions.outWidth;
        } catch (FileNotFoundException e) {
            return -1;
        }
    }
}

checkSelfPermission()でパーミッションの許可状況をチェックし、これがPERMISSION_GRANTED(許可)であるかどうかを確認します。許可されていればそのまま処理を実行するだけでよいですが、そうでない場合は「現状どうなっているか」をさらに調べ、状況に応じて適宜処理を実装していきます。

requestPermissions()が必要なパーミッションのリクエストを実行するためのメソッドで、このあとリクエストダイアログの操作に応じてonRequestPermissionsResult()が呼び出されます。ライフサイクル上の動きは暗黙的なIntentの呼び出しに近いです。

shouldShowRequestPermissionRationale()はユーザーが既に許可の選択を行ったことがあるかどうかをチェックするため、これがTrueであればユーザーは一度、意図的にパーミッションのリクエストを拒否していることになります。そのため、単純に再度パーミッションのリクエストを送っても拒否される可能性が非常に高いと思います。このチェックを挟むことで、ユーザーにパーミッションを許可することの必要性を伝えるアクションを追加するなどすると、良いかもしれません。

Permission Dispatcherで実装する

Permission Dispatcherは上記の処理をannotationを使って簡単に実装できるように作られたライブラリです。

https://github.com/hotchemi/PermissionsDispatcher

ライブラリの実装手順ですが、基本的にはREADMEのDownloadを参照してください。Android Gradle Pluginが2.2以上の場合、appモジュール以下のbuild.gradleに2行追加するだけでOKです。

app/build.gradle
dependencies {

  //...

  compile 'com.github.hotchemi:permissionsdispatcher:2.3.2'
  annotationProcessor 'com.github.hotchemi:permissionsdispatcher-processor:2.3.2'

  //...
}

ネイティブ実装と同様の処理をPermission Dispatcherで実装したのが以下になります。

LibraryPermissionActivity.java

@RuntimePermissions
public class LibraryPermissionActivity extends AppCompatActivity {

    // ネイティブ実装と同一部分は除きます

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);

        if (requestCode == REQUEST_CODE_PICKER && resultCode == RESULT_OK) {
            selectImageUri = data.getData();

            LibraryPermissionActivityPermissionsDispatcher.setTextPickerImageWidthWithCheck(this);
            return;
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
        LibraryPermissionActivityPermissionsDispatcher.onRequestPermissionsResult(this, requestCode, grantResults);
    }

    @NeedsPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
    protected void setTextPickerImageWidth() {
        mConsoleText.setText("Image Width:" + readPickerImageWidth(selectImageUri));
    }

    @OnShowRationale(Manifest.permission.READ_EXTERNAL_STORAGE)
    protected void onShowRationaleReadExternalStorage(final PermissionRequest request) {
        // すでに1度パーミッションのリクエストが行われていて、
        // ユーザーに「許可しない(二度と表示しないは非チェック)」をされていると
        // この処理が呼ばれます。
        Toast.makeText(this, "パーミッション許可がOFFになっています。", Toast.LENGTH_SHORT).show();
    }

    @OnPermissionDenied(Manifest.permission.READ_EXTERNAL_STORAGE)
    protected void onPermissionDeniedReadExternalStorage() {
        Toast.makeText(this, "リクエストが拒否されました。", Toast.LENGTH_SHORT).show();
    }

    @OnNeverAskAgain(Manifest.permission.READ_EXTERNAL_STORAGE)
    protected void onNeverAskAgainReadExternalStorage() {
        Toast.makeText(this, "パーミッションが拒絶されています。", Toast.LENGTH_SHORT).show();
    }
}

annotationによって設定したメソッドを元にXXXXDispatcherクラスがbuild時に生成され、それによってリクエスト関連の処理を補完してもらう形での実装となります。見た目もかなりスッキリとしています。

v2.5.2では下記のコードが生成されていました。

LibraryPermissionActivityPermissionsDispatcher.java
final class LibraryPermissionActivityPermissionsDispatcher {
  private static final int REQUEST_SETTEXTPICKERIMAGEWIDTH = 0;

  private static final String[] PERMISSION_SETTEXTPICKERIMAGEWIDTH = new String[] {"android.permission.READ_EXTERNAL_STORAGE"};

  private LibraryPermissionActivityPermissionsDispatcher() {
  }

  static void setTextPickerImageWidthWithCheck(LibraryPermissionActivity target) {
    if (PermissionUtils.hasSelfPermissions(target, PERMISSION_SETTEXTPICKERIMAGEWIDTH)) {
      target.setTextPickerImageWidth();
    } else {
      if (PermissionUtils.shouldShowRequestPermissionRationale(target, PERMISSION_SETTEXTPICKERIMAGEWIDTH)) {
        target.onShowRationaleReadExternalStorage(new SetTextPickerImageWidthPermissionRequest(target));
      } else {
        ActivityCompat.requestPermissions(target, PERMISSION_SETTEXTPICKERIMAGEWIDTH, REQUEST_SETTEXTPICKERIMAGEWIDTH);
      }
    }
  }

  static void onRequestPermissionsResult(LibraryPermissionActivity target, int requestCode, int[] grantResults) {
    switch (requestCode) {
      case REQUEST_SETTEXTPICKERIMAGEWIDTH:
      if (PermissionUtils.getTargetSdkVersion(target) < 23 && !PermissionUtils.hasSelfPermissions(target, PERMISSION_SETTEXTPICKERIMAGEWIDTH)) {
        target.onPermissionDeniedReadExternalStorage();
        return;
      }
      if (PermissionUtils.verifyPermissions(grantResults)) {
        target.setTextPickerImageWidth();
      } else {
        if (!PermissionUtils.shouldShowRequestPermissionRationale(target, PERMISSION_SETTEXTPICKERIMAGEWIDTH)) {
          target.onNeverAskAgainReadExternalStorage();
        } else {
          target.onPermissionDeniedReadExternalStorage();
        }
      }
      break;
      default:
      break;
    }
  }

  private static final class SetTextPickerImageWidthPermissionRequest implements PermissionRequest {
    private final WeakReference<LibraryPermissionActivity> weakTarget;

    private SetTextPickerImageWidthPermissionRequest(LibraryPermissionActivity target) {
      this.weakTarget = new WeakReference<LibraryPermissionActivity>(target);
    }

    @Override
    public void proceed() {
      LibraryPermissionActivity target = weakTarget.get();
      if (target == null) return;
      ActivityCompat.requestPermissions(target, PERMISSION_SETTEXTPICKERIMAGEWIDTH, REQUEST_SETTEXTPICKERIMAGEWIDTH);
    }

    @Override
    public void cancel() {
      LibraryPermissionActivity target = weakTarget.get();
      if (target == null) return;
      target.onPermissionDeniedReadExternalStorage();
    }
  }
}

個人的にはPermission Dispatcherを使うのが鉄板だと思っていますが、現在のパーミッショングループが今後も6.0のまま固定される保証はありません。現状Permission Dispatcherは精力的に更新されていますが、いざ次のOS対応時にPermission Dispatcherの更新待ちで止めてしまわないよう、ネイティブでの実装方法もしっかり把握しておきましょう。

よくありそうな質問

アプリの権限設定にジャンプするIntent設定はありますか?

残念ながら、現時点では利用できないようです。

代わりにTwitterアプリなどが実装していますが、パーミッションの許可がOFFの場合の対応の一つとして、アプリの設定画面に遷移させたい場合があるかと思います。

現状では権限部分の選択画面にまでは直接遷移できませんが、その一つ上のアプリの設定画面にまで移動が可能です。必要に応じて実装してみましょう。

// 必要に応じてアプリの設定画面を開く
Intent intent = new Intent();
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.fromParts("package", getApplicationContext().getPackageName(), null));
getApplicationContext().startActivity(intent);

暗黙的Intentでカメラアプリを利用する際に、呼び出し元でカメラのパーミッションのリクエストは必要ですか?

不要です。現在のアプリでCameraグループのパーミッションを設定していないのであれば、改めて追加する必要はありません。
基本的なパーミッションの設定はいままでどおりで、一部のパーミッションは新たにリクエストの処理が必要になるというだけです。

ただし、カメラを外部アプリで起動した際に、撮影された写真を取得するためにはストレージへのアクセス許可が必要になるかと思います。その点は注意しましょう。

リクエスト拒否されたときって、どんな処理を入れた方がいいですか?

まずはアナリティクスのイベントをいれておきましょう。現時点のリクエストが想定通りユーザーに受け入れてくれるかの確認は大事です。

他にもその後の処理で本当に必要な処理であれば、ユーザーにリクエストの許可必要であることを伝えるUIの実装が必要になるでしょう。

Android 7.0での特定ディレクトリのアクセスについて

今回サンプルで触れたREAD_EXTERNAL_STORAGEですが、ストレージのアクセスに関しては7.0にて新たなアクセス方法が提示されました。

特定のディレクトリへのアクセス | Android Developers

こちらは今回のRequest Permissionとはまた別の形で実装するもので、例えばギャラリー系ではない、アプリ内で生成したファイルの読み書きレベルであればこちらで実装したほうが、パーミッションの処理がいっさい不要になるのでおすすめです。

おわりに

Runtime Permissionに関するリクエスト周りはユーザーリアクションにも、UIにも大きく影響がでる変更点です。エンジニアひとりだけで進めることは難しいでしょう。そのため、しっかりとチームに提案し、チーム全体でアプリの改善に取り組んでいってもらえればと思います。

参考資料