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を設定しておきます。

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

アプリB（com.android.myapplication）：アプリAを起動するアプリです。
単に`startActivity`するだけですが、同じIntentを`queryIntentActivities`と`resolveActivity`に渡した結果を表示するようにしてみます。

```kotlin
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 }
```

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

# Intent.CATEGORY_DEFAULT

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

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

```xml: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
```

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

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

```kotlin
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`は通常追加しないと思います、`queryIntentActivities`と`resolveActivity`で暗黙的IntentをstartActivityに渡した場合と同じ結果が必要な場合は、`queryIntentActivities`と`resolveActivity`の第二引数であるflagsに`PackageManager.MATCH_DEFAULT_ONLY`を指定します。

```kotlin
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です。

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

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

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

# 複数のCategoryを追加してみる

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

```kotlin
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_DEFAULT`と`Intent.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にないため起動できません。

```xml: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を削除してみます


```kotlin
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に実装されています

https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/content/IntentFilter.java;l=1821

```java: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になります。

https://cs.android.com/android/platform/superproject/+/android-11.0.0_r40:frameworks/base/core/java/android/content/IntentFilter.java;l=1731

```java: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`を指定する

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

以上です。