2012/06/30

Android:なぜShareActionProviderの共有対象Activityが入れ替わるのか?


前回の投稿で少し触れた下記について原因を探ります。
共有アイテムの中に同じパッケージ名をもつアイテムが複数ある場合は注意が必要です。
例えば、"android.intent.action.VIEW"には下記2つのActivityが反応します。
 - com.android.development/com.android.development.AppHwPref
 - com.android.development/com.android.development.ProcessInfo
"よく使う共有アイテム"にAppHwPrefが登録されていると仮定します。
あなたはこれを選択してAppHwPrefを起動します。これは正しく起動できるはずです。
しかし、もう一度これを選択した場合は"ProcessInfo"が起動されます。
ユーザの意図に反する動作のように見えますが、、、
ShareActionProviderは"よく使う共有アイテム"に属するコンポーネントのパッケージが
複数ある場合は、選択する都度入れ替わる(スイッチ)するような動作になっています。

この現象、不具合なのかは不明ですがちょっと気持ちわるい動作ですね。
# なにか、こうしないといけない深い理由があるのかな?

なお、本稿では"よく使う共有アイテム"を以降"デフォルトアクティビティ"と表記します。
# デフォルトアクティビティはコードの変数名から命名しました。

●原因箇所

結論から言うと、Intentに反応できるActivityリストのソート部分が原因です。

●共有アイテムのソート

共有アイテムリストに同じパッケージ名が存在する場合、後に出現するアイテムが上
(index0寄り)にくるようソートされます。
・ソート前
 [0] com.android.development/com.android.development.AppHwPref
 [1] com.android.development/com.android.development.ProcessInfo

・ソート後
 [0] com.android.development/com.android.development.ProcessInfo
 [1] com.android.development/com.android.development.AppHwPref

共有アイテムリストでは、リスト上位のアイテムほど優先度が高く扱われます。
つまり、パッケージ名が同じアイテムは優先度の低いアイテムを優先度が高くなるように
ソートされます。

●デフォルトアクティビティの選定

デフォルトアクティビティにはActivityリストの先頭が指定されます。
つまり、下記ActivityリストではAppHwPrefがデフォルトアクティビティとなります。
 [0] com.android.development/com.android.development.AppHwPref    // 重要度:高
 [1] com.android.development/com.android.development.ProcessInfo  // 重要度:低

しかし、これがソートされると、、、
 [0] com.android.development/com.android.development.ProcessInfo  // 重要度:低
 [1] com.android.development/com.android.development.AppHwPref    // 重要度:高

デフォルトアクティビティにはProcessInfoが選ばれます。
ソートをする度に先頭が入れ替わるので、デフォルトアクティビティもスイッチするよう
な動作になってしまいます。

●ソートされるタイミング

ソートは共有アイテム選択履歴が保存されるタイミングで行われます。
つまり、デフォルトアクティビティを選択した直後にソートが開始されます。

以下もう少し深く。。。
原因箇所のコードを追ってみます。

●デフォルトアクティビティを表示しているのは誰?

デフォルトアクティビティのUIはandroid.widget.ActivityChooserViewです。
何を表示するかを決定するロジックはandroid.widget.ActivityChooserModelの責務です。

デフォルトアクティビティを取得するスタックトレースは下記
ActivityChooserModel.getDefaultActivity()
ActivityChooserView$ActivityChooserViewAdapter.getDefaultActivity()
public ResolveInfo getDefaultActivity() {
    ...
    return mActivites.get(0).resolveInfo;
    ...
}
ということで、mActivitesインスタンスの並び順が重要になってきます。

●mActivitiesのソート

ソートは共有アイテム選択履歴が保存されるタイミングでしたね。
共有選択履歴が保存されるコードはActivityChooserModel.addHisoricalRecord(...)です。
private boolean addHisoricalRecord(HistoricalRecord historicalRecord) {
    synchronized (mInstanceLock) {
        final boolean added = mHistoricalRecords.add(historicalRecord);
        if (added) {
            mHistoricalRecordsChanged = true;
            pruneExcessiveHistoricalRecordsLocked();
            persistHistoricalData();
            sortActivities();
        }
        return added;
    }
}
選択履歴レコードを追加した後、sortActivities()でソートしてますね。
名は体を表す。mActivitiesのソートがここで実行されます。
private void sortActivities() {
    synchronized (mInstanceLock) {
        if (mActivitySorter != null && !mActivites.isEmpty()) {
            mActivitySorter.sort(mIntent, mActivites,
                    Collections.unmodifiableList(mHistoricalRecords));
            notifyChanged();
        }
    }
}
sortActivitiesの前後でmActivitiesの変化を見てみましょう。
・ソート前
[0]  com.android.development.AppHwPref p=0 o=0 m=0x108000}; weight:1]
[1]  com.android.calendar.EventInfoActivity p=0 o=0 m=0x108000}; weight:0]
[2]  com.android.development.ProcessInfo p=0 o=0 m=0x108000}; weight:0]
・ソート後
[0]★com.android.development.ProcessInfo p=0 o=0 m=0x108000}; weight:1.9500000476837158203125]
[1]  com.android.development.AppHwPref p=0 o=0 m=0x108000}; weight:0]
[2]  com.android.calendar.EventInfoActivity p=0 o=0 m=0x108000}; weight:0]
ソートの結果、ProcessInfoが先頭にきました。
前述の通り、ソート前では先頭だった"com.android.development"パッケージの
AppHwPrefより同パッケージのProcessInfoが優先された結果です。

つまり、ここでデフォルトアクティビティはAppHwPrefからProcessInfoに切り替わるとい
うことです。
# getDefaultActivityが要素0番目を取得することを思い出してください。


●ソートのアルゴリズム

もうちょっと踏み込んでソートアルゴリズムを見てみましょう。
ソートアルゴリズムはsortActivitiesメソッドにあるmActivitySorter.sortがキモです。

mActivitySorterの実体はDefaultSorterクラスです。
DefaultSorterはActivityChooserModelの内部クラスとして内包されています。
/**
 * The sorter for ordering activities based on intent and past choices.
 */
private ActivitySorter mActivitySorter = new DefaultSorter();
DefaultSorterは非常にシンプルです。DefaultSorter.sortで引数のactivitiesをソート
します。(activitiesにはmActivitesが渡されていましたね。)
/**
 * Default activity sorter implementation.
 */
private final class DefaultSorter implements ActivitySorter {
    private static final float WEIGHT_DECAY_COEFFICIENT = 0.95f;

    private final Map<String, ActivityResolveInfo> mPackageNameToActivityMap =
        new HashMap<String, ActivityResolveInfo>();

    public void sort(Intent intent, List<ActivityResolveInfo> activities,
            List<HistoricalRecord> historicalRecords) {
        Map<String, ActivityResolveInfo> packageNameToActivityMap =
            mPackageNameToActivityMap;
        packageNameToActivityMap.clear();

        final int activityCount = activities.size();
        for (int i = 0; i < activityCount; i++) {
            ActivityResolveInfo activity = activities.get(i);
            activity.weight = 0.0f;
            String packageName = activity.resolveInfo.activityInfo.packageName;
            packageNameToActivityMap.put(packageName, activity);
        }

        final int lastShareIndex = historicalRecords.size() - 1;
        float nextRecordWeight = 1;
        for (int i = lastShareIndex; i >= 0; i--) {
            HistoricalRecord historicalRecord = historicalRecords.get(i);
            String packageName = historicalRecord.activity.getPackageName();
            ActivityResolveInfo activity = packageNameToActivityMap.get(packageName);
            if (activity != null) {
                activity.weight += historicalRecord.weight * nextRecordWeight;
                nextRecordWeight = nextRecordWeight * WEIGHT_DECAY_COEFFICIENT;
            }
        }

        Collections.sort(activities);

        if (DEBUG) {
            for (int i = 0; i < activityCount; i++) {
                Log.i(LOG_TAG, "Sorted: " + activities.get(i));
            }
        }
    }
}
17行目~22行目。
今回のポイントとなるpackageNameToActivityMapです。
パッケージ名をキーにActivityインスタンスを保持するHashMapです。
最終的には、このMapに格納されたactivityが「優先するかどうか?」の対象となります。
つまり、このMapに格納されなかったActivityはデフォルトアクティビティになれません。

MapにはmActivitesの中身を走査した結果が格納されるので、同じパッケージ名が出現し
た場合は、後勝ち(上書き)となります。
ソート前のmActivitiesの状態は下記でしたね。
・ソート前mActivitiesの状態
[0]  com.android.development.AppHwPref p=0 o=0 m=0x108000}; weight:0]
[1]  com.android.calendar.EventInfoActivity p=0 o=0 m=0x108000}; weight:0]
[2]  com.android.development.ProcessInfo p=0 o=0 m=0x108000}; weight:0]
Mapのキーとなるパッケージ名"com.android.development"が2つ出現していますね。
先ほどのルールがあるので、同じパッケージ名が出現した場合は後勝ちです。
packageNameToActivityMapは最終的に下記の状態になります。
 Key=com.google.android.calendar Value=[resolveInfo:ResolveInfo{com.android.calendar.EventInfoActivity}; weight:0]
 Key=com.android.development Value=[resolveInfo:ResolveInfo{com.android.development.ProcessInfo}; weight:0]
後勝ちのため、ProcessInfoが選ばれましたね。

packageNameToActivityMapの初期化処理はこれで終了。
前準備も整ったので、いよいよソートを開始します。

24行目~34行目。
ここでは、共有アイテム選択履歴を参照して各Activityの"重み付け"を行います。
ここでの重み付けはソート順序に影響します。

historicalRecordsは共有アイテム選択履歴のレコードリストです。
historicalRecordは共有アイテム選択履歴の各レコードを表します。
ここではレコード分ぐるぐる回してますね。

historicalRecord.activity.getPackageName();をみるとわかる通り、ここでも重要なの
はパッケージ名です。

先ほど初期化したpackageNameToActivityMapからhistoricalRecordと同じパッケージ名を
持つactivityを抽出します。
抽出されたActivityはactivity.weight変数に重みが加算されていきます。
つまり、共有アイテム選択履歴にレコードが多ければ多いほど加算されて、重みが増すよ
うになっています。

一通り重み付けが終わったmActivitiesの状態が下記です。
[0]  com.android.development.AppHwPref p=0 o=0 m=0x108000}; weight:0]
[1]  com.android.calendar.EventInfoActivity p=0 o=0 m=0x108000}; weight:0]
[2]  com.android.development.ProcessInfo p=0 o=0 m=0x108000}; weight:1.9500000476837158203125]
ProcessInfoのweight値に変化がありますね。

36行目。
重み付けもおわったので、mActivitiesのコレクションをソートします。
比較対象はmActivitiesのインスタンス要素。重み付けを行った
android.widget.ActivityChooserModel.ActivityResolveInfoです。
ソートで必要な比較メソッドcompareToを見てみましょう。
public int compareTo(ActivityResolveInfo another) {
     return  Float.floatToIntBits(another.weight) - Float.floatToIntBits(weight);
}
単純にweightの大きさを見ていますね。weightの大きいほうが上(index0寄り)となります。
weightはProcessInfoが最も大きかったので、ソートの結果は下記になります。
[0]  com.android.development.ProcessInfo p=0 o=0 m=0x108000}; weight:1.9500000476837158203125]
[1]  com.android.development.AppHwPref p=0 o=0 m=0x108000}; weight:0]
[2]  com.android.calendar.EventInfoActivity p=0 o=0 m=0x108000}; weight:0]
やっと、ProcessInfoが先頭にきましたね。

mActivitiesの0番目はデフォルトアクティビティとして扱われるのでしたね。
共有アイテム選択履歴の保存を契機にソートが走り、その都度上記のような処理が行われ
るので「デフォルトアクティビティを選択する度に遷移先が変化する」といった現象になります。


# DefaultSorter.sortを呼び出すたびにソート結果が変わっちゃうと思うけど、、、
# やっぱり不具合かな?

以上です。

2012/06/27

Android:ShareActionProvider

ShareActionProviderは、共有アクションを提供するクラスです。

共有アクションに応答できるActivityを管理し、ユーザの選択履歴を記録することで
"よく使う共有アイテム"のような機能を提供します。

●動作イメージ

初回は「共有アクション」のみ表示。


一度でも共有アイテムを選択すると、選択履歴として蓄積され"よく使う共有アイテム"
には専用のUIが用意されます。


メニューアイテムがoverflowの場合は、共有可能なアイテムをサブメニューとして表示
します。



共有可能なアイテムは"よく使う順"にソートされた状態でリスト表示されます。


●使い方

ShareActionProviderはメニューアイテムと関連付けされます。
メニューアイテムがActionItemとして表示されているか、OverflowItemとして表示されて
いるかによって若干振る舞いが変化します。
しかし、開発者はメニューアイテムがActionItem状態かOverflowItem状態かを意識する
必要はありません。
状態の違いからくる動作差異はShareActionProviderによってカプセル化されています。

ShareActionProviderを使う手順はシンプルです。
 1. メニューアイテムにShareActionProviderを関連付ける
 2. メニュー生成時にShareActionProviderインスタンスを取得する
 3. 共有IntentをShareActionProviderに設定する

1はメニューリソースで指定可能です。
関連付けにはitem要素のandroid:actionProviderClass属性で行います。
<menu xmlns:android="http://schemas.android.com/apk/res/android">
    <item android:id="@+id/menu_item_share_action"
        android:showAsAction="always"
        android:title="@string/menu_share1"
        android:actionProviderClass="android.widget.ShareActionProvider" />
</menu>
2はメニュー生成時のコールバックメソッドonCreateOptionsMenuで可能です。
@Override
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu_share_action, menu);
    MenuItem shareAction = menu.findItem(R.id.menu_item_share_action);
    mShareActionProvider
        = (ShareActionProvider)shareAction.getActionProvider();

    return true;
}
3はShareActionProvider.setShareIntent(intent)メソッドで可能です。
下記は0-9が押されたら、キーラベルを共有するコードです。
Cキーが押された場合は共有インテントをクリアします。
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
    if (KeyEvent.KEYCODE_9 >= keyCode && keyCode >= KeyEvent.KEYCODE_0) {
        Intent sharedIntent = new Intent(Intent.ACTION_SEND);
        sharedIntent.setType("text/plain");
        sharedIntent.putExtra(
                Intent.EXTRA_TEXT, String.valueOf(event.getDisplayLabel()));

        doShare(sharedIntent);
    } else if (KeyEvent.KEYCODE_C == keyCode) {
        doShare(null);
    }

    return super.onKeyDown(keyCode, event);
}

private void doShare(Intent intent) {
    mShareActionProvider.setShareIntent(intent);
}
ShareActionProviderはsetShareIntentを契機に共有アイテムのリストを更新します。
setShareIntentにnullが渡された場合は、共有アイテム無しとして扱われます。



この状態ではActionItemを押下しても無反応。



setShareIntentに共有Intentを設定すると共有アイテムリストが自動更新される。



setShareIntentにnullを設定すると共有アイテム無しとなる。


ちなみに、"よく使う共有アイテム"を長押下すると共有アイテムリストが表示される機能
も搭載されています。



●堅牢性

ShareActionProviderは堅牢性も備えています。
例えば、"よく使う共有アイテム"の対象となっているパッケージが無効化されていた場合、
別のよく使う共有アイテムを表示するようになっています。

しかし、共有アイテムリスト表示中にバックグラウンドへ一時退避して、リストに含まれ
るパッケージを無効化した後にアプリを復帰させると、共有アイテムリストに含まれるパ
ッケージは表示され続け、これを選択するとActivityNotFound例外で強制終了します。
ただし、これは特異な手順となるため対応の必要性はほぼ無いでしょう。


●注意

通常、ActionItemが選択されるとonOptionsItemSelectedが呼ばれます。
しかし、ActionItemのShareActionProviderのアイテムを選択しても、onOptionsItemSelected
は呼ばれません。※メニューがoverflow状態の場合は呼ばれます。

共有アイテムの中に同じパッケージ名をもつアイテムが複数ある場合は注意が必要です。
例えば、"android.intent.action.VIEW"には下記2つのActivityが反応します。
 - com.android.development/com.android.development.AppHwPref
 - com.android.development/com.android.development.ProcessInfo
"よく使う共有アイテム"にAppHwPrefが登録されていると仮定します。
あなたはこれを選択してAppHwPrefを起動します。これは正しく起動できるはずです。
しかし、もう一度これを選択した場合は"ProcessInfo"が起動されます。
ユーザの意図に反する動作のように見えますが、、、
ShareActionProviderは"よく使う共有アイテム"に属するコンポーネントのパッケージが
複数ある場合は、選択する都度スイッチするような動作になっています。


●その他

ShareActionProviderはどのようにして、共有アクション履歴を管理しているのでしょうか
これは、アプリローカルに選択履歴をxmlファイルとして保存することで実現しています。
デフォルトでは、/data/data/<ApplicationPackageName>/files配下にshare_history.xml
の名前で保存されます。

share_history.xmlは下記のような内容になっています。
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
<historical-records>
    <historical-record activity="com.android.calendar/com.android.calendar.EventInfoActivity" time="1340782021420" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.ProcessInfo" time="1340783164738" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.ProcessInfo" time="1340783171474" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.AppHwPref" time="1340783189589" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.ProcessInfo" time="1340783191079" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.AppHwPref" time="1340783195569" weight="1.0" />
    <historical-record activity="com.android.development/com.android.development.ProcessInfo" time="1340783197179" weight="1.0" />
</historical-records>
共有アイテムが選択されるとhistorical-record要素が末尾に追加されていきます。

選択履歴のxmlファイルはsetShareHistoryFileNameメソッドで指定可能です。


共有アクションを実装したい場合、ShareActionProviderは使いやすさも確保できる便利
なクラスといえそうです。

以上です。
2012/06/15

Android:ContentObserverにonChangeが通知される基準


ContentObserverにonChange通知される基準について調査しました。

結果、
 登録したURIへのnotifyChangeに加えて、親パスへのnotifyChangeもトリガとなる
です。

また、ContentObserver登録時の引数notifyForDescendentsにtrueを指定すると、
 登録したURIの子や孫にあたるパスへのnotifyChangeもトリガになる
ことがわかりました。

⇒notifyForDescendentsについて:android developers

下記は調査で使用したデータ。

次のURIを監視するContentObserverを登録。
  監視URI = content://yuki.contentobserver/lv0
  監視URI = content://yuki.contentobserver/lv0/lv1
  監視URI = content://yuki.contentobserver/lv0/lv1/lv2
  監視URI = content://yuki.contentobserver/lv1/lv0
  監視URI = content://yuki.contentobserver/lv2/lv1/lv0

いくつかのURIにnotifyChangeして、上記ContentObserverにonChange通知されるかを検証
(結果の見方は ○:onChange通知あり/×:onChange通知なし です)

ContentObserver登録時、notifyForDescendentsに"false"を指定した場合
content://yuki.contentobserver/lv0 にnotifyChangeすると...
 ○:監視URI = content://yuki.contentobserver/lv0
 ○:監視URI = content://yuki.contentobserver/lv0/lv1
 ○:監視URI = content://yuki.contentobserver/lv0/lv1/lv2
 ×:監視URI = content://yuki.contentobserver/lv1/lv0
 ×:監視URI = content://yuki.contentobserver/lv2/lv1/lv0

content://yuki.contentobserver/lv0/lv1 にnotifyChangeすると...
 ×:監視URI = content://yuki.contentobserver/lv0
 ○:監視URI = content://yuki.contentobserver/lv0/lv1
 ○:監視URI = content://yuki.contentobserver/lv0/lv1/lv2
 ×:監視URI = content://yuki.contentobserver/lv1/lv0
 ×:監視URI = content://yuki.contentobserver/lv2/lv1/lv0

content://yuki.contentobserver/lv0/lv1/lv2 にnotifyChangeすると...
 ×:監視URI = content://yuki.contentobserver/lv0
 ×:監視URI = content://yuki.contentobserver/lv0/lv1
 ○:監視URI = content://yuki.contentobserver/lv0/lv1/lv2
 ×:監視URI = content://yuki.contentobserver/lv1/lv0
 ×:監視URI = content://yuki.contentobserver/lv2/lv1/lv0


ContentObserver登録時、notifyForDescendentsに"true"を指定した場合
content://yuki.contentobserver/lv0 にnotifyChangeすると...
 ○:監視URI = content://yuki.contentobserver/lv0
 ○:監視URI = content://yuki.contentobserver/lv0/lv1
 ○:監視URI = content://yuki.contentobserver/lv0/lv1/lv2
 ×:監視URI = content://yuki.contentobserver/lv1/lv0
 ×:監視URI = content://yuki.contentobserver/lv2/lv1/lv0

content://yuki.contentobserver/lv0/lv1 にnotifyChangeすると...
 ○:監視URI = content://yuki.contentobserver/lv0
 ○:監視URI = content://yuki.contentobserver/lv0/lv1
 ○:監視URI = content://yuki.contentobserver/lv0/lv1/lv2
 ×:監視URI = content://yuki.contentobserver/lv1/lv0
 ×:監視URI = content://yuki.contentobserver/lv2/lv1/lv0

content://yuki.contentobserver/lv0/lv1/lv2 にnotifyChangeすると...
 ○:監視URI = content://yuki.contentobserver/lv0
 ○:監視URI = content://yuki.contentobserver/lv0/lv1
 ○:監視URI = content://yuki.contentobserver/lv0/lv1/lv2
 ×:監視URI = content://yuki.contentobserver/lv1/lv0
 ×:監視URI = content://yuki.contentobserver/lv2/lv1/lv0

以上です。

2012/06/09

Android:IntentFilterにDEFAULT_CATEGORYが必要な理由

IntentFilterにカテゴリandroid.intent.category.DEFAULTを登録しないと、
暗黙的IntentでActivityを呼び出すことができません。

「そういうものだ」で終わってもいいのですが...
なぜわざわざDEFAULTカテゴリを指定しなければならないのか?調査しました。

ディベロッパサイトの-Intents and Intent Filters #Category test-では、
startActivityに渡される暗黙的Intentは、カテゴリ"android.intent.category.DEFAULT"
を含んでいるかのように扱われる
旨の記載があります。
http://developer.android.com/guide/topics/intents/intents-filters.html#ifs

ここの実装部分を見てみます。

Intentを解決するために、PackageManagerとPackageManagerServiceは
"どのActivityを起動するか"を決定する必要があります。

Intent解決の部分を追ってみます。

// startActivity~Intent解決処理開始まで
com.android.server.am.ActivityManagerService.startActivity(IApplicationThread...)
└com.android.server.am.ActivityStack.startActivityMayWait(IApplicationThread...)
 └com.android.server.am.ActivityStack.resolveActivity(Intent...)

startActivityが呼ばれると、ほぃほぃ色々あってActivityStack.resolveActivity()が呼
ばれます。
ここから「誰がこのIntentに応答できるのか?」を解決する為の"Intent解決"処理が開始
されます。

・DEFAULT_CATEGORY必須フラグを立てるresolveActivity
resolveActivityの中身は非常にシンプル。
しかしここで非常に重要なフラグ"PackageManager.MATCH_DEFAULT_ONLY"が設定されます。
resolveIntent(
    intent, resolvedType,
    PackageManager.MATCH_DEFAULT_ONLY | ActivityManagerService.STOCK_PM_FLAGS);


・PackageManager.MATCH_DEFAULT_ONLYは何者か?
ディベロッパサイトを見ると、
int PackageManager.MATCH_DEFAULT_ONLY
http://developer.android.com/reference/android/content/pm/PackageManager.html#MATCH_DEFAULT_ONLY

なにやら、Intent解決用のオプションプラグのよう。
これをONにすると、CATEGORY_DEFAULTをサポートするActivityが抽出される。
つまり、CATEGORY_DEFAULTをサポートしないActivityは抽出されないということ。

本当にそうなのか?もう少し追ってみます。

com.android.server.am.ActivityStack.resolveActivity(Intent...)
└com.android.server.pm.PackageManagerService.resolveIntent(Intent...)
 └com.android.server.pm.PackageManagerService.queryIntentActivities(Intent...)
  └com.android.server.pm.PackageManagerService.ActivityIntentResolver.queryIntent(Intent...)
   └com.android.server.IntentResolver.buildResolveList(Intent...)

Intent処理できるActivity一覧を取得するqueryIntentActivitiesがここででてきましたね!
# 引数にMATCH_DEFAULT_ONLYを指定する理由もこれでわかりますね。

いかにもそれらしい名前の"IntentResolver.buildResolveList()"が今回のメインです。
中身を見てみます。
match = filter.match(action, resolvedType, scheme, data, categories, TAG);
if (match >= 0) {
    if (debug) Slog.v(TAG, "  Filter matched!  match=0x" +
            Integer.toHexString(match));
    if (!defaultOnly || filter.hasCategory(Intent.CATEGORY_DEFAULT)) {
        final R oneResult = newResult(filter, match);
        if (oneResult != null) {
            dest.add(oneResult);  // ★Activity候補に追加する
        }
    } else {
        hasNonDefaults = true;
    }
...
ActivityがIntentに応答できる場合match >= 0がtrueとなります。
この時点ではIntentFilterにパスしています。

しかし、下記の条件式...
if (!defaultOnly || filter.hasCategory(Intent.CATEGORY_DEFAULT))
defaultOnlyは、MATCH_DEFAULT_ONLYが指定されるとtrueになります。
filter.hasCategory(Intent.CATEGORY_DEFAULT)は、CATEGORY_DEFAULTを持つフィルタで
あればtrueを返します。
つまり、今回のケースだとこの条件式はfalseとなりActivityは起動対象になれません。


これでCATEGORY_DEFAULTを定義しないと暗黙的IntentにActivityが反応できない理由が
わかりました。


以上です。

Android:Grant Permissionで権限付与できるパスを制御する

GrantPermissionでパーミッション付与できるコンテンツパスは制御することが可能です。
制御したい場合は、マニフェストファイルで<provider>のandroid:grantUriPermission
属性をfalseとして、子要素である<grant-uri-permission>で許可するパスを列挙します。
<provider
    android:authorities="yuki.test.provider"
    android:name="yuki.test.MyContentProvider"
    android:readPermission="yuki.test.signature"
    android:grantUriPermissions="false" >
    <grant-uri-permission
        android:path="/test/hoge/foo/lv5" />
</provider>
この例では下記の状態となります
  • データ参照にはyuki.test.signature権限が必要(android:readPermission)
  • 原則パーミッションの付与は許可しない(android:grantUriPermissions)
  • ただし下記パスに対するパーミッションの付与は可能(grant-uri-permission)
      - /test/hoge/foo/lv5
      - .allowで終わるパス(/yuki/hoge/foo.allow, /test/hoge.allowなど)

許可されていないURIに権限を付与した場合、下記例外が発生します。
java.lang.SecurityException: Provider yuki.test/yuki.test.MyContentProvider
    does not allow granting of permission to path
    of Uri content://yuki.test.provider/test/hoge/foo/lv5
アクセス許可するコンテンツパスを動的に変えたいような場合があります。
下記のように外部入力値をそのまま利用するコードは大変危険です。
grantUriPermission("yuki.client", getIntent().getData(),
        Intent.FLAG_GRANT_READ_URI_PERMISSION);
あらかじめ、パーミッション付与するコンテンツパスが決まっている場合は、
この仕組みを利用した方が安全です。

以上です。
2012/06/07

Android:Grant Permissionでアクセスを制御する

GrantPermissionの仕組みを使えば、一時的にパーミッションを付与することが可能です。

たとえば下記のようなケースで有効です。

1. 選択モードで起動されたが、選択結果をURIで返却し1回限りのアクセスを許可したい。
2. 起動するアプリAに対して、一時的なアクセス権限を付与したい

・ケース1の例
Android標準の電話帳アプリには"選択モード"があります。
下記のIntentで起動可能です。
Intent intent = new Intent(Intent.ACTION_PICK);
intent.setData(ContactsContract.Contacts.CONTENT_URI);
startActivityForResult(intent, requestCode);
選択モードで起動すると電話帳リストが表示されます。
リストを選択すると、選択したデータのURIを詰めたIntentを起動元アプリに返します。
起動元アプリは、このURIを参照すれば選択されたデータの詳細を取得可能という仕組み
です。
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    Uri resultUri = data.getData();
    getContentResolver().query(resultUri, null, null, null, null);
}
ここで、返却されるURIは
content://com.android.contacts/contacts/lookup/....
の形式です。
つまり、Contactsへのアクセスとなるため、通常であればREAD_CONTACTS権限が必要です。
しかし、このケースではREAD_CONTACTS権限無しでアクセスすることが可能です。
Getting a Result from an Activity /Bonus: Read the contact dataのNote参照

これは、電話帳アプリ側で呼出元アプリに一時的なアクセス権限を設定する
Intent.FLAG_GRANT_READ_URI_PERMISSION
を付与したIntentを返却しているためです。


・ケース1の注意点
もし、あなたのアプリがIntent.FLAG_GRANT_READ_URI_PERMISSIONで権限付与されること
を期待して、他アプリを呼び出す場合は下記の点に留意する必要があります。
  • Intent.FLAG_GRANT_READ_URI_PERMISSIONの付与は呼出先アプリの実装依存
  • "選択モード"で起動してもパーミッションが付与される保証はない
選択モードのケースでも、結果に対してFLAG_GRANT_READ_URI_PERMISSIONを付与しないア
プリが存在します。
つまり、FLAG_GRANT_READ_URI_PERMISSIONが付与されることを期待する場合は、
連携先アプリの仕様について知っている必要があるのです。

Android標準の電話帳選択モードはFLAG_GRANT_READ_URI_PERMISSIONを付与しますが、
キャリアカスタマイズされた電話帳の中には付与しないものが存在します。



・ケース2の例
たとえば、他アプリから自アプリデータに対するアクセスを一時的に許可したい場合。

まず、パーミッションの付与を許可するために、<provider>要素のandroid:grantUriPermissions
属性をtrueに設定します(デフォルトfalse)。
<provider
    android:authorities="yuki.provider"
    android:name=".TestProvider"
    android:grantUriPermissions="true"
    android:permission="yuki.provider.signature" >
ContextのgrantUriPermissionメソッドで一時的にパーミッションを付与することが可能です。
grantUriPermission("opponent.package.name",
    Uri.parse("content://test.provider/allow"),
    Intent.FLAG_GRANT_READ_URI_PERMISSION);
第一引数にはアクセスを許可するパッケージを指定します。
第二引数にはアクセスを許可するコンテンツパスを指定します。
第三引数にはアクセスを許可する種別(READ/WRITE)を指定します。

ただし、許可する側が付与対象のパーミッションを持っている必要があります。
これを怠るとセキュリティ例外が発生します。
java.lang.SecurityException:
    Uid 10042 does not have permission to uri content://test.provider/allow
この"特権状態"はContextのrevokeUriPermissionで権限が破棄されるまで続きます。
revokeUriPermission(Uri.parse("content://test.provider/allow"),
    Intent.FLAG_GRANT_READ_URI_PERMISSION);
第一引数には特権状態を破棄するコンテンツパスを指定します。
第二引数には破棄する種別(READ/WRITE)を指定します。


・ケース2の注意点
revokeUriPermissionを忘れた場合、相手先の"特権状態"は継続されます。
これは両プロセスが終了した後も続き、端末再起動されるまで持続します。

また、revokeUriPermissionにはgrantUriPermissionにあった「対象パッケージ名の指定」
がありません。
revokeUriPermissionされると、関連する全てのパッケージの権限が破棄されます。
// パッケージa, b, cにパーミッション付与
grantUriPermission("opponent.package.a",
    Uri.parse("content://test.provider/allow"),
    Intent.FLAG_GRANT_READ_URI_PERMISSION);
grantUriPermission("opponent.package.b",
    Uri.parse("content://test.provider/allow"),
    Intent.FLAG_GRANT_READ_URI_PERMISSION);
grantUriPermission("opponent.package.c",
    Uri.parse("content://test.provider/allow"),
    Intent.FLAG_GRANT_READ_URI_PERMISSION);

// これでパッケージa, b, cに付与したパーミッションが破棄される
revokeUriPermission(Uri.parse("content://test.provider/allow"),
    Intent.FLAG_GRANT_READ_URI_PERMISSION);
このため「パッケージbのパーミッションのみ破棄したい」というケースでは、
一度全パッケージのパーミッションを破棄した後、再度パッケージa, cにパーミッション
付与する必要があります。

参考:http://developer.android.com/guide/topics/providers/content-provider-basics.html

以上です。
2012/06/06

Android:コンテンツプロバイダのアクセス制御

コンテンツプロバイダのアクセス制御について調査しました。

■exported="false"指定のコンテンツプロバイダにアクセス

・定義
<provider
    android:authorities="yuki.provider"
    android:name=".TestProvider"
    android:exported="false" />
・例外発生
java.lang.SecurityException: Permission Denial: opening provider
    yuki.provider.TestProvider from ProcessRecord{415ae568 1100:yuki.client/10042}
    (pid=1100, uid=10042) that is not exported from uid 10040



■permissionを持たないパッケージからアクセス

・定義
<provider
    android:authorities="yuki.provider"
    android:name=".TestProvider"
    android:exported="true"
    android:permission="yuki.provider.signature" >
・例外発生
java.lang.SecurityException: Permission Denial: opening provider
    yuki.provider.TestProvider from ProcessRecord{41465b68 1198:yuki.client/10042}
    (pid=1198, uid=10042) requires yuki.provider.signature or yuki.provider.signature



■特定の文字列から始まるパスへのアクセスを許可

・定義
<provider
    android:authorities="yuki.provider"
    android:name=".TestProvider"
    android:exported="true"
    android:permission="yuki.provider.signature" >
    <path-permission
        android:pathPrefix="/allow/"
        android:permission="yuki.provider.normal" />
</provider>
※クライアント側では下記パーミッションを宣言
<uses-permission android:name="yuki.provider.normal" />
・結果
アクセス成功:content://yuki.provider/allow/
アクセス失敗:content://yuki.provider/allow



■特定のパターンを持つパスへのアクセスを許可

・定義
<provider
    android:authorities="yuki.provider"
    android:name=".TestProvider"
    android:exported="true"
    android:permission="yuki.provider.signature" >
    <path-permission
        android:pathPattern=".*\\.xml"
        android:permission="yuki.provider.normal" />
    <path-permission
        android:pathPattern="/allow/test/.*"
        android:permission="yuki.provider.normal" />
    <path-permission
        android:path="/test/yuki"
        android:permission="yuki.provider.normal" />
</provider>
※クライアント側では下記パーミッションを宣言
<uses-permission android:name="yuki.provider.normal" />
・結果
アクセス成功:content://yuki.provider/test.xml
アクセス成功:content://yuki.provider/test/test/test.xml
アクセス成功:content://yuki.provider/.xml
アクセス失敗:content://yuki.provider/test/xml
-----
アクセス成功:content://yuki.provider/allow/test/
アクセス成功:content://yuki.provider/allow/test/yuki/test
アクセス失敗:content://yuki.provider/allow/test
-----
アクセス成功:content://yuki.provider/test/yuki
アクセス失敗:content://yuki.provider/test/yuki/


以上です。