Intent / intent-filter のCategoryとはなんなのか?


Androidを扱う上で基本中の基本であるIntentですが、様々なパラメータを利用します、Action / Category / Extra / Flag / Data(Uri) とありますが、Categoryってあまり意味を意識して使わないのではないでしょうか?
ここでは、Categoryってなんなのかを説明してみようと思います。

intent-filterとintent

Categoryがどのように扱われるのかを調べるため、サンプルアプリを作ります。

アプリA(com.android.myapplication2):単に起動されるアプリです。検証用にhogeというschemeを受け取れるintent-filterを設定しておきます。

AndroidManifest.xml
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <data android:scheme="hoge" />
</intent-filter>

アプリB(com.android.myapplication):アプリAを起動するアプリです。
単にstartActivityするだけですが、同じIntentをqueryIntentActivitiesresolveActivityに渡した結果を表示するようにしてみます。

val intent = Intent(Intent.ACTION_VIEW).also {
    it.data = Uri.parse("hoge://test")
}
packageManager.queryIntentActivities(intent, 0).forEach {
    Log.e("XXXX", "queryIntentActivities: ${it.activityInfo?.packageName}/${ it.activityInfo?.name}")
}
packageManager.resolveActivity(intent, 0).let {
    Log.e("XXXX", "resolveActivity: ${it?.activityInfo?.packageName}/${ it?.activityInfo?.name}")
}
try {
    startActivity(intent)
} catch (e: Exception) {
    Log.e("XXXX", "error", e)
}

早速一発実行してみましょう。

E/XXXX: queryIntentActivities: com.android.myapplication2/com.android.myapplication2.MainActivity
E/XXXX: resolveActivity: com.android.myapplication2/com.android.myapplication2.MainActivity
E/XXXX: error
    android.content.ActivityNotFoundException: No Activity found to handle Intent { act=android.intent.action.VIEW dat=hoge://test }

はい、queryIntentActivitiesresolveActivityではアプリAが見つかっていますが、startActivityはActivityNotFoundExceptionになってしまいました。

Intent.CATEGORY_DEFAULT

いきなり変な挙動を見せましたが、このような挙動になる理由は、暗黙的IntentはstartActivity時に自動的にIntent.CATEGORY_DEFAULTが付与されているため、intent-filterにandroid.intent.category.DEFAULTがついていないため反応しなかったのです。

なので、暗黙的Intentを受け取る場合は、intent-filterにandroid.intent.category.DEFAULTを付与します。

AndroidManifest.xml
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <data android:scheme="hoge" />
</intent-filter>

これで実行すると、

E/XXXX: queryIntentActivities: com.android.myapplication2/com.android.myapplication2.MainActivity
E/XXXX: resolveActivity: com.android.myapplication2/com.android.myapplication2.MainActivity

と、queryIntentActivitiesresolveActivityの結果は変わらず、起動に成功します。

逆に、intent-filterを逆の状態で、intentにIntent.CATEGORY_DEFAULTを追加してみます。

val intent = Intent(Intent.ACTION_VIEW).also {
    it.data = Uri.parse("hoge://test")
    it.addCategory(Intent.CATEGORY_DEFAULT)
}

すると、結果は以下のようになり、queryIntentActivitiesは空、resolveActivityはnullが返ってくるようになり、startActivityの結果と一致します。

E/XXXX: resolveActivity: null/null
E/XXXX: error
    android.content.ActivityNotFoundException: No Activity found to handle Intent { act=android.intent.action.VIEW cat=[android.intent.category.DEFAULT] dat=hoge://test }

ただ、Intent.CATEGORY_DEFAULTは通常追加しないと思います、queryIntentActivitiesresolveActivityで暗黙的IntentをstartActivityに渡した場合と同じ結果が必要な場合は、queryIntentActivitiesresolveActivityの第二引数であるflagsにPackageManager.MATCH_DEFAULT_ONLYを指定します。

val intent = Intent(Intent.ACTION_VIEW).also {
    it.data = Uri.parse("hoge://test")
}
packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY).forEach {
    Log.e("XXXX", "queryIntentActivities: ${it.activityInfo?.packageName}/${ it.activityInfo?.name}")
}
packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY).let {
    Log.e("XXXX", "resolveActivity: ${it?.activityInfo?.packageName}/${ it?.activityInfo?.name}")
}

これで先ほどと同じ結果を得ることができます。

このことから
IntentにCategoryがついている場合、intent-filterにもついていなければ合致しない
と言えるでしょう。

Intent.CATEGORY_DEFAULTのつかないintent-filter

ちょっと話が横道にそれますが、暗黙的Intentを受け取るにはIntent.CATEGORY_DEFAULTが必要です。明示的intentであればComponentNameが指定されるのでintent-filterが無くてもIntentを受け取ることができます。では、Intent.CATEGORY_DEFAULTがついていないintent-filterって意味があるのかな?と思ってしまったりするかもしれませんが、身近な例がありますね。
テンプレートから新規プロジェクトを作成すると作成されるintent-filterです。

AndroidManifest.xml
<intent-filter>
    <action android:name="android.intent.action.MAIN" />
    <category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

起動Acitivtyについているintent-filterです。
これがどういう意味合いかは以下の記事で説明していますが、ホームアプリから起動起点となるActivityを探すために利用されます。
超シンプルなホームアプリを作る ~ホームアプリ(ランチャーアプリ)の作り方~

簡単にいうと、Intent.CATEGORY_LAUNCHERがついたintent-filterを検索し、そのComponentNameを利用して明示的Intentを投げます。つまり、Intentを直接受け取るために使用されるわけではなく、intent-filterを検索するために利用されています。当然、この目的で検索をするときはPackageManager.MATCH_DEFAULT_ONLYは使いません。

複数のCategoryを追加してみる

何でも良いので、Intent.CATEGORY_BROWSABLEを追加してみましょう。

val intent = Intent(Intent.ACTION_VIEW).also {
    it.data = Uri.parse("hoge://test")
    it.addCategory(Intent.CATEGORY_BROWSABLE)
}

なお、暗黙的IntentはstartActivity時にIntent.CATEGORY_DEFAULTが自動で付与されるというのは、他のCategoryがすでに設定されている場合でも同様です。つまり、上記intentはstartActivity時にはIntent.CATEGORY_DEFAULTIntent.CATEGORY_BROWSABLEが付与された状態になります。

それでは実行!

E/XXXX: resolveActivity: null/null
E/XXXX: error
    android.content.ActivityNotFoundException: No Activity found to handle Intent { act=android.intent.action.VIEW cat=[android.intent.category.BROWSABLE] dat=hoge://test }

はい、やはり、IntentにIntent.CATEGORY_BROWSABLEがついているのにintent-filterにないため起動できません。

AndroidManifest.xml
<intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="hoge" />
</intent-filter>

こうすれば、成功します。

queryIntentActivities: com.android.myapplication2/com.android.myapplication2.MainActivity
resolveActivity: com.android.myapplication2/com.android.myapplication2.MainActivity

逆に、Intentの方のCategoryを削除してみます

val intent = Intent(Intent.ACTION_VIEW).also {
    it.data = Uri.parse("hoge://test")
}

これは、成功します。

queryIntentActivities: com.android.myapplication2/com.android.myapplication2.MainActivity
resolveActivity: com.android.myapplication2/com.android.myapplication2.MainActivity

このことから、
intent-filterに設定されているCategoryはIntentに付与されていなくても合致する
と言えるでしょう。

intentとintent-filterの合致アルゴリズム

intentとintent-filterが合致するかのロジックは、IntentFilter#matchに実装されています

IntentFilter.java
public final int match(String action, String type, String scheme,
        Uri data, Set<String> categories, String logTag, boolean supportWildcards,
        @Nullable Collection<String> ignoreActions) {
    if (action != null && !matchAction(action, supportWildcards, ignoreActions)) {
        if (false) Log.v(
            logTag, "No matching action " + action + " for " + this);
        return NO_MATCH_ACTION;
    }

    int dataMatch = matchData(type, scheme, data, supportWildcards);
    if (dataMatch < 0) {
        if (false) {
            if (dataMatch == NO_MATCH_TYPE) {
                Log.v(logTag, "No matching type " + type
                      + " for " + this);
            }
            if (dataMatch == NO_MATCH_DATA) {
                Log.v(logTag, "No matching scheme/path " + data
                      + " for " + this);
            }
        }
        return dataMatch;
    }

    String categoryMismatch = matchCategories(categories);
    if (categoryMismatch != null) {
        if (false) {
            Log.v(logTag, "No matching category " + categoryMismatch + " for " + this);
        }
        return NO_MATCH_CATEGORY;
    }

    // It would be nice to treat container activities as more
    // important than ones that can be embedded, but this is not the way...
    if (false) {
        if (categories != null) {
            dataMatch -= mCategories.size() - categories.size();
        }
    }

    return dataMatch;
}

簡単にみると、Actionを確認し、Dataを確認し、次にcategoryを確認していますね。
matchCategoriesの中身を見てみましょう。戻り値がStringで分かりにくいですが、合致しなかったCategoryが戻り、合致した場合はnullになります。

IntentFilter.java
public final String matchCategories(Set<String> categories) {
    if (categories == null) {
        return null;
    }

    Iterator<String> it = categories.iterator();

    if (mCategories == null) {
        return it.hasNext() ? it.next() : null;
    }

    while (it.hasNext()) {
        final String category = it.next();
        if (!mCategories.contains(category)) {
            return category;
        }
    }

    return null;
}

ここからも、intentに付与されているCategoryがすべてintent-filterに含まれている場合、合致、intent-filter側にあるが、intent側にないものは無視されていることが分かります。

余談ですが、上記IntentFilterのソースコードは一読をお勧めします。

まとめ

ということでまとめると

  • intentに付与したCategoryがすべてintent-filterに記載されていないと、合致しない
  • intent-filterに付与したCategoryがintentに付与されていなくても、合致する

というルールになっていて、intentでintent-filterのカテゴリーを指定するために使うものであると言えるでしょう。intent-filterからintentのカテゴリーを指定するためには使用できません。

また、おまけ的にですが、

  • 暗黙的intentを受け取るにはintent-filterにIntent.CATEGORY_DEFAULTを付与する必要がある
  • 暗黙的intentを投げた場合と同じ結果を得るためには、queryIntentActivitiesなどのflagsにPackageManager.MATCH_DEFAULT_ONLYを指定する

ということも分かりましたね。

以上です。