Android
permission
Marshmallow

Android 6(Marshmallow)のランタイムパーミッション処理が、ごちゃごちゃになりがちなので、ヘルパークラスにまとめてみた話

More than 1 year has passed since last update.

Marshmallowのパーミッションについては、以前ふれましたが、ネット経由で画像を読み込んで、外部ストレージに保存する必要が生じた際に、コールバックの入れ子が激しくなるわ、Activityのメソッドがニョキニョキはえるわで、コードの可読性が悪くなってしまったため、パーミッションがらみの処理をヘルパークラスに追い出してみました。

問題点

なにが、困ってしまうかというと、

  • 従来、順次処理として書けていたものが、非同期なコールバック経由になってしまう。
  • 既に許諾されていたときと、新たに許諾してもらったときのそれぞれに同じ処理を書かなければならない(DRYじゃない)。
  • コールバックが宣言されているActivityCompat.OnRequestPermissionsResultCallbackをアクティビティで実装する必要がある。
  • そのくせ、その実装は決まり切ったボイラープレートな処理。

と言った点。

なので、ボイラープレートをアクティビティから引っぱがして、DRYじゃない部分をヘルパー内に閉じ込めようという趣旨です。

こんなクラスにしてみました

はい、どーん。

public abstract class PermissionHelper {

    private final int REQUEST_CODE;
    private final String PERMISSION;

    /**
     * コンストラクタ
     *
     * @param permission    扱うパーミッション(例:Manifest.permission.WRITE_EXTERNAL_STORAGE)
     * @param requestCode   内部的に使用するリクエストコード(重複しない値を選ぶこと)
     */
    public PermissionHelper(@NonNull String permission, @IntRange(from = 0, to = 255) int requestCode) {
        PERMISSION = permission;
        REQUEST_CODE = requestCode;
    }

    /**
     * 対象のパーミッションが許諾されていれば、本来やりたいことを実行し、そうでない場合は許諾を得るメソッド
     *
     * @param activity  Build.VERSION.SDK_INT < 23 の場合は、OnRequestPermissionsResultCallbackをimplementsしたActivity
     * @param message   このパーミッションが必要な理由を説明する文字列(nullの場合、説明するダイアログは省略)
     * @param caption   説明するダイアログを閉じるボタンのキャプション(nullの場合は、"OK"を表示)
     */
    public void tryExecute(@NonNull final Activity activity, @Nullable String message, @Nullable String caption) {

        if (ContextCompat.checkSelfPermission(activity, PERMISSION) != PackageManager.PERMISSION_GRANTED) {
            // 以前に許諾して、今後表示しないとしていた場合は、ここにはこない
            if (ActivityCompat.shouldShowRequestPermissionRationale(activity, PERMISSION) && message != null) {
                // ユーザに許諾してもらうために、なんで必要なのかを説明する
                // SnackBarを使うのもいいかもしんないけど、許諾要求自体がダイアログで出てくるので、それに合わせた。
                final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
                builder.setMessage(message);
                builder.setPositiveButton(caption == null ? "OK" : caption, new DialogInterface.OnClickListener() {
                    @Override
                    public void onClick(DialogInterface dialog, int which) {
                        //  許諾要求
                        requestPermission(activity);
                    }
                });
                builder.show();
            } else {
                //  許諾要求
                requestPermission(activity);
            }
        } else {
            // 許諾されているので、やりたいことをやる
            onAllowed();
        }
    }

    /**
     * tryExecute(Activity, String, String)の説明無しバージョン
     *
     * @param activity  Build.VERSION.SDK_INT < 23 の場合は、OnRequestPermissionsResultCallbackをimplementsしたActivity
     */
    public void tryExecute(@NonNull Activity activity) {
        tryExecute(activity, null, null);
    }

    /**
     * 許諾要求をだすメソッド
     *
     * @param activity  Build.VERSION.SDK_INT < 23 の場合は、OnRequestPermissionsResultCallbackをimplementsしたActivity
     */
    private void requestPermission(@NonNull Activity activity) {
        String[] permissions = new String[] {
                PERMISSION
        };
        ActivityCompat.requestPermissions(activity, permissions, REQUEST_CODE);
    }

    /**
     * OnRequestPermissionsResultCallback#onRequestPermissionsResult()で呼び出すメソッド
     * 渡されたパラメータをそのまま、このメソッドに渡す
     *
     * @param requestCode   リクエストコード
     * @param permissions   パーミッションの配列
     * @param grantResults  許諾状態の配列
     */
    public void onRequestPermissionsResult(int requestCode, 
              @NonNull String[] permissions, @NonNull int[] grantResults) {
        if (requestCode == REQUEST_CODE) {
            if (permissions.length > 0 && permissions[0].equals(PERMISSION) && 
                grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
                // 許諾されたので、やりたいことをやる
                onAllowed();
            } else {
                onDenied();
            }
        }
    }

    /**
     * 本当にやりたいことは、これをOverrideして実装する
     */
    protected abstract void onAllowed();

    /**
     * 権限を許諾されなかったときの処理が必要であれば実装する
     */
    protected void onDenied() {}
}

権限が既に許諾されていたり、許諾されたときに実行する、本当にやりたい処理を、onAllowed()をオーバーライドして実装すればいいようになっています。

上記のコードは、シンプルにするために、権限は一つだけに制限しています。複数の権限に対応させるのであれば、許諾の判別のところと、許諾を要求するところをゴニョる必要があります。

こんな風に使います

public class HogeActivity extends AppCompatActivity 
                          implements ActivityCompat.OnRequestPermissionsResultCallback {

  // PermissionHelper自体は、Activityの状態に依存しないのでいつでも生成できる
  // ここでは、外部ストレージへの書込権限を扱うように指定している
  // 123は内部的に使うユニーク値なので、適当に与える(ここでしか使わないので、即値でもOK)
  private HogeHelper helper = new HogeHelper(Manifest.permission.WRITE_EXTERNAL_STORAGE, 123);

  @Override
  protected void onCreate(Bundle savedInstanceState) {

    :

    button.setOnClickListener(new View.OnClickListener() {
      @Override
      public void onClick(View v) {
        //  やりたいことの代わりにhelperのメソッドを呼ぶ
        // 許諾済みであれば、この時点で実行される
        helper.tryExecute(HogeActivity.this);
      }
    });
  }  

    public void onRequestPermissionsResult(int requestCode, 
              @NonNull String[] permissions, @NonNull int[] grantResults) {
        // 渡されたパラメータをそのまま丸投げ
        // Kotlinならtraitとして実装することで、もっとすっきりするかもしれない
        helper.onRequestPermissionsResult(requestCode, permissions, grantResults);
    }


  // 無名クラスで実装してもいいけど、ここでは名前付きの内部クラスとして実装している
  // 単に、見やすさを優先しただけ
  private class HogeHelper extends PermissionHelper {

    @Override
    protected void onAllowed() {
      //実際にやりたい処理
       :
       :
    }
  }
}

できれば、ActivityCompat.OnRequestPermissionsResultCallbackの実装まで面倒見れればいいのだけど、Javaで実装するとなると、ActivityのサブクラスにPermissionHelperの機能を持たせるしかなくなって、使い勝手が悪いので妥協しました。
コメントにもあるように、KotlinであればActivityCompat.OnRequestPermissionsResultCallbackを継承したtraitとして実装することができると思うのですが、ウチではまだプロダクションでKotlinを使うところまでは行っていないので、しょうがないです。

追記(2016/3/3)

Kotlinの trait はいつの間にか interface にかわってました。
でもって、interfaceを継承して継承元のメソッドを実装することはできないみたいです。実装しても、mix-in先で、メソッドが実装されてないというエラーが出てしまいます。
onRequestPermissionsResult()の実装を隠すためには、hotchemiさんのようなアノテーションを使うアプローチが必要なようです。

追記(2016/3/17)

調べてみたところ、interfaceを継承して継承元のメソッドを実装することはできます。
もともと、メソッドが実装されていないというエラーではなく、実装が複数あるというエラーでした(エラーメッセージをちゃんと読まないなんて素人か>オレ)。

まとめ

端的に言えば、テンプレート・メソッド・パターンでお決まりの処理をまとめただけなのですが、こういうのを作っておけば、アクティビティを汚さずにすむので、Marshmallow対応も怖くないぞと言うことで。

ちなみに、上記のコードはパブリックドメインと同様の扱いとし、コピペして使用してもかまいません。