Android 12で変更されるウェブインテントの解決から、改めて考えるURLハンドリング

Android 12では[ウェブインテントの解決](https://developer.android.com/about/versions/12/web-intent-resolution?hl=ja)の方法が変わるらしいですね。
そもそも、従来からAndroidにおいて、URLをどうやってハンドリングするかは結構奥深い問題です。ちょっと整理してみようと思います。

# URLのハンドリング

URLをハンドリングするシーンとして、主には以下のようなものが考えられると思います。

- WebViewのshouldOverrideUrlLoading()で渡されたURL
- 広告など動的コンテンツのクリック時のアクションとして渡されたURL

安直に扱うなら、

```kotlin
runCatching {
    startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
```

と、暗黙的Intentを投げてしまえば良いです。

しかし、ChromeCustomTabsを使ってURLを表示させたい場合は、暗黙的Intentという訳にはいきません。必ずブラウザが起動してしまいます。
アプリ内にブラウザ機能を持たせている場合、自分で開くか、外で開くかという問題があります。

# URLに紐付けられた専用アプリがある場合のみ、外部アプリを起動する

httpsのURLだからと、すべてChromeCustomTabsやアプリ内ブラウザで開くというのは、ユーザビリティの観点でよろしくないですね。例えばPlayストアのURLをブラウザで開くより、ストアアプリを起動させた方が良いですよね。
そのURLに紐付けられた専用アプリがある場合、暗黙的Intentを投げ、そうでなければ、ChromeCustomTabsやアプリ内ブラウザで開くという動作を実装するのがユーザビリティ的に良いでしょう。

その判定は以下のようにやります。

## Android 11 (API30)未満

```kotlin
private fun launch(context: Context, uri: Uri) {
    val intent = Intent(Intent.ACTION_VIEW, uri).also {
        it.addCategory(Intent.CATEGORY_DEFAULT)
        it.addCategory(Intent.CATEGORY_BROWSABLE)
        it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    }
    if (shouldLaunchBrowser(context, uri)) {
        startBrowser(uri) // ブラウザ起動
    } else {
        startActivity(intent) // 暗黙的Intent
    }
}

private fun shouldLaunchBrowser(context: Context, uri: Uri): Boolean {
    val intent = Intent(Intent.ACTION_VIEW, uri).also {
        it.addCategory(Intent.CATEGORY_DEFAULT)
        it.addCategory(Intent.CATEGORY_BROWSABLE)
    }
    return uri.isWebScheme() && !intent.canLaunchNonBrowserWithoutChooser(context)
}

private fun Uri.isWebScheme(): Boolean = scheme.let {
    it.equals("http", ignoreCase = true) || it.equals("https", ignoreCase = true)
}

private fun Intent.canLaunchNonBrowserWithoutChooser(context: Context): Boolean {
    val pm = context.packageManager
    val activities = pm.queryIntentActivities(this, PackageManager.GET_RESOLVED_FILTER or PackageManager.MATCH_DEFAULT_ONLY)
        .filter { it.filter?.isNotBrowserFilter() ?: false }
    if (activities.isEmpty()) return false
    val activityInfo = pm.resolveActivity(this, PackageManager.MATCH_DEFAULT_ONLY)?.activityInfo ?: return false
    return activities.any { it.activityInfo?.packageName == activityInfo.packageName }
}

private fun IntentFilter.isNotBrowserFilter(): Boolean =
    !hasGenericAuthority() || !hasGenericPath()

private fun IntentFilter.hasGenericAuthority(): Boolean =
    countDataAuthorities() == 0 || authoritiesIterator().asSequence().any { it.host == "*" }

private fun IntentFilter.hasGenericPath(): Boolean =
    countDataPaths() == 0 || pathsIterator().asSequence().any { it.path == "*" }
```

`canLaunchNonBrowserWithoutChooser`でやっていることをざっくり説明します。

`PackageManager#resolveActivity()`では、そのIntentを投げたときに反応するアプリの情報を得ることができます、複数のアプリが反応する場合はアプリチューザーの情報が返ってきます。

`PackageManager#queryIntentActivities()`は、そのIntentを扱えるアプリの情報が返ってきます。PackageManager#MATCH_ALLを指定していないので、特定のアプリが紐付けされている場合はそのアプリの情報が返ってきます。特定のアプリが決められておらず、複数のアプリが反応する場合、複数の結果が返ります。アポリチューザーはこちらには含まれません。

まず、`PackageManager#queryIntentActivities()`の応答からブラウザアプリを除外します。何を持ってブラウザアプリとするかですが、IntentFilterを確認し、authorities/pathの両方が任意である、つまり、http/httpsのスキームすべてを受け付ける設定になっている場合にブラウザと判定しています。
通常、authority/pathが空であることのチェックだけで良いのですが、Chromeが一時期、空ではなく、ワイルドカードを指定していたことがあったので、その場合もブラウザと判定できるようにしています。

最後に、`PackageManager#resolveActivity()`の結果が、`PackageManager#queryIntentActivities()`の結果に含まれている、つまりアプリチューザーでも、ブラウザでもないアプリが`PackageManager#resolveActivity()`で返ってきていることを持って、ブラウザ以外のアプリが紐付けられている、と判断しています。

## Android 11 (API30)以上

Android 11からはPackageVisibilityの問題で、`PackageManager#queryIntentActivities()`などで情報を取得するのが難しくなりました。アプリの用途的に必要であれば、

```xml:AndroidManifest.xml
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
    tools:ignore="QueryAllPackagesPermission"
    />
```

と、パーミッションを宣言することで従来の判定が使えますが、一般アプリでこのパーミッションを取るのは難しいですね。
そこで、API 30で追加されたIntentフラグを利用します。

```kotlin
private fun launch(context: Context, uri: Uri) {
    val intent = Intent(Intent.ACTION_VIEW, uri).also {
        it.addCategory(Intent.CATEGORY_DEFAULT)
        it.addCategory(Intent.CATEGORY_BROWSABLE)
        it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    }
    if (VERSION.SDK_INT >= VERSION_CODES.R) {
        if (uri.isWebScheme()) {
            intent.addFlags(Intent.FLAG_ACTIVITY_REQUIRE_DEFAULT)
            intent.addFlags(Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER)
            try {
                // ブラウザ以外のURLに紐付けられたアプリを起動
                startActivity(intent)
            } catch (e : ActivityNotFoundException) {
                // 失敗したのでブラウザを起動
                startBrowser(uri)
            }
        } else {
            startActivity(intent)
        }
    }
}
```

`Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER`をつけると、ブラウザアプリが反応しなくなり、ブラウザ以外で反応できるアプリがいないと、ActivityNotFoundExceptionが発生します。
`Intent.FLAG_ACTIVITY_REQUIRE_DEFAULT`をつけると、アプリチューザーなしに起動できるアプリがある場合にのみ、そのアプリが起動し、そうでなければActivityNotFoundExceptionが発生します。

この二つを指定することで、ブラウザ以外のアプリがアプリチューザーなしで起動できる場合にのみ、アプリの起動に成功し、そうでなければActivityNotFoundExceptionが発生することになります。ActivityNotFoundExceptionが発生したのであれば、ブラウザを起動すべきであるということが分かります。

## Android 12 (API 31)以上

さて、Android 12でも、上記方法が使えます、一般的なアプリなら`Intent.FLAG_ACTIVITY_REQUIRE_DEFAULT`と`Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER`を使う方法で問題ないでしょう。
PackageVisibilityの設定を行い、従来と同じ判定をする場合、`PackageManager#queryIntentActivities()`に渡すIntentのカテゴリーに`Intent.CATEGORY_DEFAULT`を指定するか、flagsに`PackageManager.MATCH_DEFAULT_ONLY`を指定しないと、紐付けられたアプリ以外にデフォルトブラウザが返ってくるようになり、`PackageManager#resolveActivity()`がチューザーを返すようになってしまいます。（暗黙的Intentは`Intent.CATEGORY_DEFAULT`が必要なので、従来はこれに違反しててもそれなりに動作していた？）

また、Android 12では、

- ブラウザアプリをインストールしても、デフォルトブラウザ設定が外れない
- アプリとウェブインテントを紐付けするには、AppLinksか、ユーザーによる設定が必要
  - 紐付けができていないウェブインテントはデフォルトブラウザが起動する
  - ウェブインテントが紐付けできるアプリは一つだけ

という動作になっているため、http/httpsスキームの暗黙的Intentを投げた場合は、アプリチューザーが表示されなくなっているなどの違いがあるようです。

----

以上です。