そのスマホアプリ、権限を分離しませんか?

前置き

この前Twitterでこんなやりとりをしました。


という訳で、言い出しっぺの法則で実証コードを書いてみました。*1

仕様

仕様は次のようにしてみました。

  • プラグインアプリにだけ「連絡先読み取り」権限を付ける。
  • 本体アプリはプラグインアプリを呼びだして結果を受け取る。(今回はActivityで実装)
  • 本体アプリに渡す連絡先情報を限定出来るようにしておく。
  • プラグインアプリが未インストールだったら、使いたい人だけマーケットへ誘導してあげる。
  • プラグインアプリの画面から、アプリケーション管理画面へ飛べるようにしてアンインストールを容易にしてあげる。

あとセキュリティ対策として

  • 三者のアプリにプラグインアプリを勝手に使用されないよう権限設定する。
  • 関連するセキュリティ対策を行う。

もやっておきました。*2

作ってみたもの

以下適当にコード引用とスクリーンショット集。

↑アプリ本体。Importボタン押したらプラグイン呼び出す。



プラグインが未インストールだったら確認ダイアログ、欲しい人はマーケット(GooglePlay)へご案内。



↑呼び出されたプラグインは権限持ってるから電話帳の内容を表示可能。



↑インポート対象だけ選択。



↑連絡先のアクセス権限が無い本体アプリでも、プラグインと連携して連絡先インポートに成功!



プラグイン単体で起動したら、アンインストールする為のショートカット表示しておくと多分親切。(OSのアプリ管理画面に飛ばす)


ここからは実装する為のjavaコードとxmlの引用です(読み飛ばしOK)。

PackageManager pm = getPackageManager();
/* この辺りで権限チェック等のセキュリティ対策(はてダでは省略) */
Intent intent = new Intent();
intent.setClassName("com.example.separation.contactimport", "com.example.separation.contactimport.ContactImportPickActivity");
List<ResolveInfo> list = pm.queryIntentActivities(intent,
		PackageManager.MATCH_DEFAULT_ONLY);
if (list.size() > 0) {
	try {
		startActivityForResult(intent, REQUEST_CODE_IMPORT);
	} catch (SecurityException se) {
		/* (はてダでは省略) */
	}
} else {
	//please install
	showDialog(DIALOG_PLUGIN_INSTALL);
}

プラグインの存在チェックと、

case DIALOG_PLUGIN_INSTALL:
	AlertDialog.Builder builder = new AlertDialog.Builder(this);
	String message = "連絡先情報から電話番号をぶっこ抜くには取込プラグインが必要です。プラグインをインストールしますか?"
			+ "\n"
			+ "(プラグインは必要な時だけインストールされていれば良いので、要らなくなったら自由にアンインストール出来ます)";
	builder.setMessage(message);
	builder.setPositiveButton("Yes",
			new DialogInterface.OnClickListener() {
				@Override
				public void onClick(DialogInterface dialog, int which) {
					Uri uri = Uri.parse("market://details?id=com.example.separation.contactimport");
					Intent intent = new Intent();
					intent.setAction(Intent.ACTION_VIEW);
					intent.setData(uri);
					PackageManager pm = getPackageManager();
					List<ResolveInfo> list = pm.queryIntentActivities(intent,
							PackageManager.MATCH_DEFAULT_ONLY);
					if (list.size() > 0) {
						startActivity(intent);
					} else {
						// エミュ環境とかマーケットアプリが無いと対応してるIntentが無いって事。無理に起動すれば例外出る
						Toast.makeText(getApplicationContext(), "多分マーケットアプリ(Google Play)が無いんじゃね?", Toast.LENGTH_LONG).show();
					}
				}
			});
	builder.setNegativeButton("No",
			new DialogInterface.OnClickListener() {

				@Override
				public void onClick(DialogInterface dialog, int which) {
					dialog.cancel();
				}
			});
	dialog = builder.create();
	break;

↑マーケットへ案内するコード。

public void onClick(View v) {
	Intent intent = new Intent();
	StringBuilder sb = new StringBuilder();
	for (int i = 0; i < listItems.size(); i++) {
		Nyanyan nyanyan = listItems.get(i);
		if (nyanyan.isTarget){
			sb.append(nyanyan.getNumber());
			sb.append("\n");
		}
	}
	
	intent.putExtra("importdata", sb.toString());
	setResult(RESULT_OK, intent);
	finish();
}

プラグイン側で選択された連絡先情報をお返しするところ。

protected void onActivityResult(int requestCode, int resultCode, Intent data) {
	if (requestCode == REQUEST_CODE_IMPORT) {
		switch (resultCode){
		case RESULT_OK:
			String importdata = data.getExtras().getString("importdata");
			Toast.makeText(this, importdata, Toast.LENGTH_LONG).show();
			break;
		case RESULT_CANCELED:
			break;
		}
	}
}

プラグインから返された情報を受け取り。

private void showUninstallButton(String packageName) {
	Intent intent = new Intent();
	if (Build.VERSION.SDK_INT > 8) {
		intent.setAction("android.settings.APPLICATION_DETAILS_SETTINGS");
		Uri uri = Uri.fromParts("package", packageName, null);
		intent.setData(uri);
	} else {
		String appPkgName;
		if (Build.VERSION.SDK_INT == 8) {
			appPkgName = "pkg";
		} else {
			appPkgName = "com.android.settings.ApplicationPkgName";
		}
		intent.setAction(Intent.ACTION_VIEW);
		intent.setClassName("com.android.settings", "com.android.settings.InstalledAppDetails");
		intent.putExtra(appPkgName, packageName);
	}
	startActivity(intent);
}

↑uninstallの為にアプリ管理画面呼び出し。( http://hascha.blogspot.jp/2012/04/blog-post.html を参考にさせて頂きました。 )

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.separation.hakudatsu"
    android:versionCode="1"
    android:versionName="1.0" >

    <uses-sdk android:minSdkVersion="4" android:targetSdkVersion="15"/>
    <permission android:protectionLevel="signature" android:name="com.example.separation.contactimport.access"></permission>
    <uses-permission android:name="com.example.separation.contactimport.access"/>

    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <activity
            android:name="com.example.separation.hakudatsu.HakudatsuSampleActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>

清廉潔白な本体アプリのAndroidManifest.xmlプラグイン連携用の権限を定義し、同時に使用を宣言。勿論連絡先読み取り権限は要求していない。

<?xml version="1.0" encoding="utf-8"?>
<manifest package="com.example.separation.contactimport"
    android:versionCode="1"
    android:versionName="1.0" xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-sdk android:minSdkVersion="4" android:targetSdkVersion="15"/>
    <uses-permission android:name="android.permission.READ_CONTACTS"/>
    
    <application
        android:icon="@drawable/ic_launcher"
        android:label="@string/app_name" >
        <activity
            android:name=".ContactImportActivity"
            android:label="@string/app_name" >
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
        <activity android:name=".ContactImportPickActivity" android:permission="com.example.separation.contactimport.access" android:exported="true">
        </activity>
    </application>

</manifest>

連絡先読み取り権限を要求するプラグインアプリのAndroidManifest.xmlプラグイン呼び出しされる時のActivityにpermissionを指定。


まとめて全部見たい、実際に動かしてみたいという奇特な人はソースをあげときますのでそちらをどうぞ。*3 「ファイル」→「インポート」→「既存プロジェクトをワークスペースへ」→「アーカイブ・ファイルの選択」でzipを指定してインポートしてください。
本体アプリ:HakudatsuSample.zip
プラグインアプリ:ContactImport.zip

結論

権限は簡単に分離出来るようです!*4
プラグイン形式でアプリを作っておけば、権限要求に過敏なユーザーも本体アプリを使ってくれるし、必要なユーザーはプラグインを自由に利用して全機能を使うことが出来るし、Win-Winだと思われます!*5


ていうかこういう「権限の分離」という考え方、マッシュルーム拡張使ってる人だったら、すでに当然の事かもしれませんね。権限皆無のテキストエディターで、simeji経由で自分の電話番号や連絡先の情報を入力出来ちゃうんですから。
本来それくらいシンプルなアプリ同士で連携するのが当たり前で、何でも屋さんなアプリを作るのは好ましくない事かもしれません。


それでは(長文閲覧)お疲れさまでした〜。

蛇足:権限の定義場所

Androidの権限って摩訶不思議Androidパーミッションで書いた様に、複雑怪奇です。
権限の定義場所(=アプリ)はそのままインストール順序に影響しますので、きちんと定義場所も考えないといけません。


安全な権限定義のパターンとしては
case 1

  • 本体アプリは必ず最初にインストール。
  • 本体アプリが権限定義する。
  • プラグインアプリは権限定義しない。
  • プラグインアプリはアクセス制御に権限を指定する。
  • 各アプリ内で権限が適切にチェックされてるかの処理を実装する。


case 2

  • 本体アプリでもプラグインアプリでインストール順は自由。
  • 本体アプリでも権限定義する
  • プラグインアプリでも権限定義する
  • プラグインアプリごとに個別の権限(=別個の名前)にする。2種類以上のプラグインで同じ権限定義をしない。
  • 各アプリ内で権限が適切にチェックされてるかの処理を実装する。


……の2つが考えられそうです。今回はcase1で実装しました。
まあ、インストール順を『本体アプリが必ず最初』にしておけばcase1でやっておけばまず問題になりませんけどね。
どうしてもインストール順を順不同にしたい、または単純な主従関係にする事が難しい時だけcase2のような工夫が必要になるでしょう。まだ出てないAndroidの新バージョンではもしかしたらこの辺の問題が解決されるかもしれませんが、現状の4.0.3まででは定義場所問題から逃げられないです…。

*1:色々汚いコードだけど許してくださいね。まあ逆に言えば「センス不足の素人ですら簡単に権限が分離出来る」難易度ですので、みんなも遠慮無くチャレンジしてください。

*2:JSSECさんの『Android アプリのセキュア設計・セキュアコーディングガイド』【6月1日版】をベースに、独自に好き勝手やったものです。

*3:EclipseAndroid SDKとADTが入った環境で、Android1.6以上だったら動くようになってるはずです。ただしエミュだと標準ではマーケットは呼べないし、実機でも実際のマーケットにプラグインアプリ(com.example.separation.contactimport)が実在しない為残念な結果になります。

*4:逆に言うと権限を分離したアプリ間の連携にもユーザーが注意する必要があるという事でもあります。この辺を自動検証してくれるセキュリティソフトがあったら良いのですが…。

*5:もし自分がFacebookやLINEとかのアプリ作る側だったら、現状みたいな権限てんこ盛りバージョンと、権限分離したシンプルバージョン+各種プラグインを両方公開すると思います。でもっててんこ盛りバージョンにはアプリ名に(dangerous)って付けておきますね。面倒嫌だし難しいの嫌だっていう『スマホは難しいよ層』の人達にもノーガード戦法選ぶ自由を残した上で、『安心安全を求めるケンゲンガー層』も満足するバージョンを公開するって方針で行きたいです。