Android 4.3 の隠し機能 AppOpsについていろいろ

AppOpsとは?

Android 4.3 には、まだ公開されていない隠し機能として AppOps というものが追加されています。これまでとは別のアプローチで、 ユーザが自分自身の情報(電話帳や位置情報など)を守ることが出来るようにするというものです。

追記(2013年11月5日):Android 4.4 KITKATでは、AppOps機能の入口となるActivityが設定アプリのAndroidManifest.xmlから削除(コメントアウト)されています。よって、素のAndroid 4.4 KITKAT では AppOps機能を使用することはできません。 ((当該Activityをコメントアウトをすることで機能を使えなくしているため、将来バージョンにて正式版として復活させる可能性が高いのではないかと思われます。))

追記(2013年11月5日その2):Android 4.3.1 の時点ですでに塞がれていたという情報をいただきました。
Android 4.3端末で設定を行った後に、4.3.1または4.4にアップデートするとその設定をもとに戻せなくなる可能性がありますので、アップデートを予定されている方(アップデートが提供される端末をお持ちの方)は、その点にご注意の上でお試しください。

追記(2013年11月21日):Android 4.3.1と4.4でもAppOpsを呼び出す方法が発見されました

AppOpsの紹介記事はすでにいろいろあるようですので、そちらを参照されると大まかな機能についてはわかるかと思います。たとえば、engadget日本語版の記事など。

従来のAndroid 4.2まででは、アプリ作成時にそのアプリがどういう権限を使いますよ(どういう機能を使いますよ)ということを宣言しておき、ユーザがインストール時(アップデート時)に、それを確認して問題が無いと判断すればインストールしていました。一旦インストールされてしまえば、アプリは宣言した権限の範囲内の機能を自由に使用することが出来ます。

4.3では、アプリのインストール後に、インストール時に許可した機能であってもアプリに使わせなくすることが出来るようにユーザが設定できる機能が、隠し機能として搭載されています。 ((Android 4.1で隠し機能としてマルチユーザ機能が搭載され、4.2にて正式機能として格上げされた事例がありますので、この機能も近く正式なものになるものと思われます。))

AppOps時代に、アプリ開発者が気をつけないといけないことはあるのかどうかは知りたいところです。ということで、Android 4.3の公開されているソースコードから読み取れる情報などからいろいろと調べてみます。 ((正式機能となった時には変わっているかもしれませんので、あくまでもAndroid 4.3向けの情報です。))

アプリからAppOps画面を呼び出す方法

Google Playで”Permission Manager”という名称で、隠されているAppOpsの設定画面を呼び出す機能を持ったアプリがいくつか出ていますが、単にその画面を呼び出すだけなら自分で作るのも簡単です。

    Intent intent = new Intent();
    intent.setAction(Intent.ACTION_MAIN);
    intent.setClassName("com.android.settings",
        "com.android.settings.Settings$AppOpsSummaryActivity");
    startActivity(intent);

単にこれだけでOKです。setAction()で指定するアクションはアプリでの呼び出し目的に合わせて調整する方がいいかもしれませんが。

AppOpsで制限された機能を使った場合、アプリはどうなる?

アプリが 、マニフェストファイルで uses-permission 宣言していない、権限が必要な機能を使用した場合、その機能を利用した瞬間に例外(SecurityException)が発生します。では、AppOpsでユーザが制限した機能を使用した場合はどうなるでしょうか。同様に例外が発生するでしょうか?

Android 4.3のソースコードを確認したところ、これまでのAPI仕様に記載されていないような例外を発生させないようにしつつ、その機能を使わせないようにしようという意図が見て取れます。たとえば、電話帳データへのアクセスなどの様々な機能のベースとして用いられているContentProviderを見てみます。そのインナークラスのTransportの query() メソッドは次のようになっています。

    @Override
    public Cursor query(String callingPkg, Uri uri, String[] projection,
        String selection, String[] selectionArgs, String sortOrder,
        ICancellationSignal cancellationSignal) {
        if (enforceReadPermission(callingPkg, uri) != AppOpsManager.MODE_ALLOWED) {
            return rejectQuery(uri, projection, selection, selectionArgs, sortOrder,
                CancellationSignal.fromTransport(cancellationSignal));
        }
        return ContentProvider.this.query(uri, projection, selection, selectionArgs, sortOrder,
            CancellationSignal.fromTransport(cancellationSignal));
    }

ここから呼び出されている enforceReadPermission() メソッドでは、呼び出し元アプリのパッケージとアクセスするContentProviderのURIから、アクセス可否を判定する処理を行って結果を返します。アクセスが許可されている(ユーザがAppOpsでアクセス許可をOFFにしていない)場合、AppOpsManager.MODE_ALLOWED が返ってくるので、それと比較して処理を分けています。

MODE_ALLOWEDではない値が返ってきた場合は rejectQuery() を呼び出してその結果を返し、そうでない場合は通常のクエリを行って結果を返します。rejectQuery() では何をしているかというと、以下のようなコードで、絶対に検索結果が空になるクエリを行った結果を返します。 ((‘A’ = ‘B’ という必ずfalseになる条件を追加したクエリにすることで実現しています。))

    public Cursor rejectQuery(Uri uri, String[] projection,
        String selection, String[] selectionArgs, String sortOrder,
        CancellationSignal cancellationSignal) {
        // The read is not allowed...  to fake it out, we replace the given
        // selection statement with a dummy one that will always be false.
        // This way we will get a cursor back that has the correct structure
        // but contains no rows.
        if (selection == null || selection.isEmpty()) {
            selection = "'A' = 'B'";
        } else {
            selection = "'A' = 'B' AND (" + selection + ")";
        }
        return query(uri, projection, selection, selectionArgs, sortOrder, cancellationSignal);
    }

アプリからAppOpsによる制限状態を知る方法

自分の作ったアプリが使っている機能がAppOpsで制限されていたとしても、アプリはクラッシュしないから問題ないですね、ってそんなわけはありません。空っぽのデータが返ってきた時、データが無くて取れなかったのか、ユーザが制限していて取れなかったのかを区別しないといけない場合は多いのではないでしょうか。たとえば、アプリが利用している機能へのアクセスが制限されて必要な情報が得られない場合、アプリを活用するためには制限をかけないでくださいということをユーザに通知する必要があります。 ((ユーザにこっそり電話帳データを抜き取ってサーバに送るようなアプリは制限されてしかるべきだと思いますが、位置に基いた情報を提供する有益なアプリで、位置情報取得を制限されると何の役にも立たなくなってしまいます。))

ということで、アプリ開発者は、自分が使おうとしている機能がAppOpsで制限されているかどうか確認出来ないといけなくなるでしょう。しかし残念ながら、まだこの機能は非公開の隠し機能であるため制限状態を知る方法も公開されていません。

ただし、内部的にその確認のために利用しているメソッドが存在しているので、リフレクションでメソッドを呼び出すことで確認することが可能です。以下にそのコード例を示します(電話帳を読む機能が制限されているかどうかを確認する例です)。

    /* acquiring AppOpsManager class and its checkOpNoThrow method */
    Class appOpsClass = null;
    Method checkOpNoThrowMethod = null;
    try {
        appOpsClass = Class.forName("android.app.AppOpsManager");
        checkOpNoThrowMethod = appOpsClass.getMethod("checkOpNoThrow",
            Integer.TYPE, Integer.TYPE, String.class);
    } catch (ClassNotFoundException e) {
        e.printStackTrace();
    } catch (NoSuchMethodException e) {
        e.printStackTrace();
    }

    /* invoking checkOpNoThrow method to get AppOps access mode */
    Object appOps = getSystemService("appops");
    Object ret = null;
    int appOpsMode = 1; /* AppOpsManager.MODE_IGNORED */
    if (checkOpNoThrowMethod != null && applicationInfo != null) {
        try {
            ret = checkOpNoThrowMethod.invoke(appOps,
                    4, /* AppOpsManager.OP_READ_CONTACTS */
                    applicationInfo.uid, /* Linux UID */
                    this.getPackageName() /* package name of this application */
            );
            appOpsMode = ( (Integer)ret ).intValue();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }

    if (appOpsMode == 0) { /* AppOpsManager.MODE_ALLOWED */
        /* execute reading contacts because allowed */
        Log.d(TAG, "allowed");
    } else if (appOpsMode == 1) { /* AppOpsManager.MODE_IGNORED */
        /* don't read contacts because not allowed */
        Log.d(TAG, "not allowed");
    } else { /* AppOpsManager.MODE_ERRORED */
        /* dont' read contacts because of error */
        Log.d(TAG, "error");
    }

少しサボって、いくつかの値を即値で書いているのはご勘弁を。このコードで何をやっているかというと以下の通りです。

  • AppOpsManagerクラスの中で制限状態取得処理を司る checkOpNoThrow() メソッドを取得する
  • getSystemService() で AppOps サービスのインタフェースオブジェクトを取得する
  • そのオブジェクトの checkOpThrow() に、チェックしたい機能、このアプリのLinux UID、このアプリのパッケージ名を渡して、利用可能かどうかの判定結果を取得する ((パッケージ名だけでなくLinux UIDも判定に用いているのは、Android 4.2から導入されたマルチユーザ機能でユーザごとに異なる制限をかけるためと思われます。))
  • 判定結果に応じてログを表示する

チェックしたい機能を示す数値は、AppOpsManagerの中で定義されていますので、自分がチェックしたいものに合わせて変更してみてください。

おまけ

通知機能へのアクセスや、クリップボードの読み書きなど、従来ではパーミッションによる制限が無かった機能に対しても、AppOpsは機能制限を行えるようにしています。パーミッションの機能を拡張するのではなく、AppOpsをパーミッション機構とは別のものとして導入しているからこそ出来ることです。

もしかすると、正式版の時点では、パーミッションとしては従来通りの粒度で大きく与えておき(例:READ_PHONE_STATE)、AppOpsによって細かい粒度で機能を制限するということをやってくるのかもしれません(例:AppOpsでIMEIなどの端末固有ID取得を制限する)。

おまけ2

AppOpsManager では、機能が利用可能かどうかを判定するメソッドとして、checkOpNoThrow() だけでなく checkOp() も提供しています。名称からわかるように、前者は判定に失敗した場合に例外を投げず、後者は例外(SecurityException)を投げるものとなっています。

クリップボードとカメラ機能の制限チェックでは checkOp() が使われていますので、それらの機能にアクセスしているアプリでは例外処理の追加についても考慮しなければならないかもしれません。