ntsk

Android Chrome Custom Tabsでリクエストヘッダを付与する

June 11, 2022

Androidアプリでは、Browser.EXTRA_HEADERSIntent.EXTRA_REFERERを利用してIntentのextrasを経由し、リクエストヘッダを付与する手法がある。

Chrome Custom Tabsでリクエストヘッダを付与する場合、以下のようにしてヘッダを付与した状態で特定のURLを開くことができる。

val customTabsIntent = CustomTabsIntent.Builder().build()
val headers = Bundle()
headers.putString("Some-Header-Key", "value")
customTabsIntent.intent.putExtra(Browser.EXTRA_HEADERS, headers)
customTabsIntent.launchUrl(context, Uri.parse(url))

しかしChrome83以降では、セキュリティの観点からホワイトリスト(=CORS-safelisted request header) に登録されているヘッダしか追加することができないようになっている。
このため、自社サービスで利用するための独自のヘッダは付与することができないのだが、Chrome86以降では、サーバーとクライアントがデジタルアセットリンクを利用して関連付けが行われている場合、ホワイトリストに登録されていないヘッダであっても追加することができるようになっている。今回はこの手順について紹介する。

デジタルアセットリンクを利用する

デジタルアセットリンクについて

https://developers.google.com/digital-asset-links/v1/getting-started

デジタルアセットリンクは、特定のWebサイトと特定のアプリについて、パブリックかつ検証可能なステートメントを作成可能にするプロトコルで、scheme://domain/.well-known/assetlinks.json のようなjsonファイルを配置し、この内容を検証することで、サーバーとクライアントの関連付けを行うことができる。

例えば、google.com では以下のような assetlinks.json が配置されており、Google CalendarやGoogle Mapなどのアプリと関連付けが行われていることが分かる。

https://google.com/.well-known/assetlinks.json

Androidアプリにおいては、今回のようなケースの他にディープリンク遷移を安全に行うための仕組みであるApp Linksに対応する場合にも利用される。

assetlinks.jsonの配置

関連付けを行いたいWebページへ以下のようなjsonを作成し、scheme://domain/.well-known/assetlinks.json で参照できるようホストする。

[
  {
    "relation": [
      "delegate_permission/common.use_as_origin"
    ],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": [
        "hash_of_app_certificate"
      ]
    }
  }
]

relation には、アプリとWebページが同じoriginに属することを示す delegate_permission/common.use_as_origin を指定する。

target には、対象となるアプリの情報を記載する。namespaceandroid_apppackage_name には対象アプリのパッケージ名、 sha256_cert_fingerprints にはアプリの署名に使われるフィンガープリントを記載する。

自身のアプリのフィンガープリントを確認したい場合、keytoolコマンドで確認できる。

keytool -v -list -keystore <YOUR_KEYSTORE>

assetlinks.jsonの検証

Connectionの作成

初めに下記のようにServiceConnectionを作成する。session?.validateRelationship を実行すると、onRelationshipValidationResult で結果を受け取ることができる。 この後に、CustomTabsのIntentを起動することで、対象のページへヘッダを付与してリクエストすることができる流れとなっている。


private var session: CustomTabsSession? = null
private var connection: ServiceConnection? = null
private var url = ""

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_chrome_custom_tab)

    url = intent.getStringExtra(ARG_URL) ?: ""
    setupCustomTabsConnection(url)
}

private fun setupCustomTabsConnection(url: String) {
    val callback: CustomTabsCallback = object : CustomTabsCallback() {
        override fun onRelationshipValidationResult(relation: Int, requestedOrigin: Uri, result: Boolean, extras: Bundle?) {
            showCustomTabs(url)
        }
    }
    connection = object : CustomTabsServiceConnection() {
        override fun onCustomTabsServiceConnected(
                name: ComponentName, client: CustomTabsClient,
        ) {
            session = client.newSession(callback)
            client.warmup(0)
            session?.validateRelationship(CustomTabsService.RELATION_USE_AS_ORIGIN, Uri.parse(url), null)
        }

        override fun onServiceDisconnected(componentName: ComponentName) {
        }
    }
}

CustomTabsServiceをbindする

onStart/onStopでServiceのbind/unbindを行う。

CHROME_PACKAGESに記載の通り、betaなどのChromeのパッケージも記載しておくことでstable以外のChromeが利用されている場合でも動作させることができる。

override fun onStart() {
    super.onStart()
    bindCustomTabsService(connection)
}

override fun onStop() {
    unbindCustomTabsService(connection)
    super.onStop()
}

private fun bindCustomTabsService(connection: ServiceConnection?) {
    if (connection == null) {
        return
    }
    try {
        CustomTabsClient.bindCustomTabsService(
                this,
                CustomTabsClient.getPackageName(this, CHROME_PACKAGES),
                connection as CustomTabsServiceConnection
        )
    } catch (e: IllegalArgumentException) {
        Timber.e(e)
    }
}

private fun unbindCustomTabsService(connection: ServiceConnection?) {
    if (connection == null) {
        return
    }
    unbindService(connection)
}

companion object {
    private val CHROME_PACKAGES = listOf(
            "com.google.android.apps.chrome",
            "com.chrome.canary",
            "com.chrome.dev",
            "com.chrome.beta",
            "com.android.chrome",
    )
}

CustomTabsIntentの発行

冒頭の通り、Browser.EXTRA_HEADERS を利用してヘッダを付与する。 この時、CustomTabsIntent.Builderの引数にCustomTabsSessionを渡す必要がある。

private fun showCustomTabs(url: String) {
    val customTabsIntent = constructExtraHeadersIntent(session)
    customTabsIntent.launchUrl(this@ChromeCustomTabsActivity, Uri.parse(url))
    finish()
}

private fun constructExtraHeadersIntent(session: CustomTabsSession?): CustomTabsIntent {
    val params = CustomTabColorSchemeParams.Builder()
            .setToolbarColor(getColor(R.color.your_app_color))
            .build()
    val customTabsIntent = CustomTabsIntent.Builder(session)
            .setDefaultColorSchemeParams(params)
            .setShowTitle(true)
            .setCloseButtonIcon(BitmapUtil.getBitmap(this, R.drawable.ic_arrow_back))
            .build()

    val headers = Bundle()
    headers.putString("Some-Header-Key", "value")
    customTabsIntent.intent.putExtra(Browser.EXTRA_HEADERS, headers)

    return customTabsIntent
}

まとめ

上記によって、ヘッダを付与することができるようになる。

この流れは、以下のデモアプリにて確認ができる。

https://github.com/GoogleChrome/android-browser-helper/tree/main/demos/custom-tabs-headers

注意点として、このデモアプリはそのままビルドしただけでは動作せず、参照しているページのassetlinks.jsonを自身で変更し、フィンガープリントを更新する必要がある。 https://glitch.com/ のページをロードするようになっているため、Remix to Editで自身の手元でビルドしたアプリのフィンガープリントに更新したページのURLに向き先を変えてアプリをビルドする必要がある。

また、この手法では初回のリクエストにはヘッダが付与できるものの、ページ内遷移では付与することができなかった。このため、そのような用途の場合にはWebViewを利用するほうが良いと思われる。

参考


© 2020-2022 ntsk.