Android WebViewのリダイレクトでカスタムURLスキームをインターセプトする方法

opoinstall
2026-09-21
5 min read

Android WebViewでディープリンクやカスタムスキームを扱うには? Android WebViewでディープリンクを扱うには、WebViewClientを拡張し、shouldOverrideUrlLoadingをオーバーライドしてisForMainFrameを確認し、許可リストに基づいてカスタムスキームを検証した上で、認可された外部Intentを安全に起動する必要があります。これにより、非WebスキームがWebViewの読み込みパイプラインに残ることを防ぎます。

Android WebViewにおけるディープリンクのインターセプトは、カスタムWebViewClient内のナビゲーションコールバックをオーバーライドし、カスタムURIスキームやChrome Intent URIなどの非HTTPプロトコルを捕捉するプログラム的な手法です。リクエストのメタデータを評価し、送信先ペイロードを検証することで、ホストアプリは適切なIntent起動を実行し、プロトコル未対応によるクラッシュを回避します。

用語 定義 関連エンティティ 検索インテントの役割
WebView Androidアプリ内でWebコンテンツをレンダリングする、Chromiumベースの埋め込みビューコンポーネント。 Android SDK 情報提供 / 商用
カスタムURLスキーム Webリンクからネイティブアプリを呼び出すための独自URIプロトコル。 ディープリンクルーティング 技術 / 情報提供
Web to App ユーザーをWebページからネイティブアプリのインターフェースへ誘導するプロセス。 モバイルディープリンク 情報提供

Chromiumが失敗する前に、カスタムスキームをWebViewの読み込みパイプラインから外す必要があります。

なぜAndroid WebViewでnet ERR UNKNOWN URL SCHEMEエラーが発生するのか

Chromiumプロトコルの制限:WebViewが標準的なWebスキームしか認識しない理由

AndroidのWebViewコンポーネントは、Chromiumレンダリングエンジンによって駆動されており、基本的にはhttp://、https://、file://、data://、about:、javascript:といった標準的なWebリソースプロトコルのみを解析・表示するように設定されています。

デフォルトでは、カスタムWebViewClientが割り当てられていない場合、Androidフレームワークは未処理のURLをシステムのActivity Managerに委譲します。しかし、アプリがカスタムWebViewClientを提供すると、myapp://product/detail/1024やalipay://、whatsapp://といった非HTTP(S)スキームは、クライアントのナビゲーションコールバックに直接ルーティングされます。ホストアプリがカスタムスキームをインターセプトせずにWebViewの読み込みパイプラインに残してしまうと、Chromiumエンジンはそれを通常のWebリソースとして解決しようとします。非標準プロトコルはDNSやWebサーバーのトランスポート層で解決できないため、エンジンは低レベルのナビゲーション失敗を引き起こします。

net::ERR_UNKNOWN_URL_SCHEMEのクラッシュ構造:ユーザーのタップからWebリソース読み込み失敗まで

ユーザーの操作からエラーページに至るまで、以下の迅速かつ決定的なシーケンスで進行します:

  1. ユーザートリガー:ユーザーが埋め込みH5ランディングページ内のCTAボタンやハイパーリンクをタップします。
  2. ナビゲーションリクエスト:WebエンジンがターゲットURI文字列(例:myapp://promo/summer)を含むナビゲーションリクエストを構築します。
  3. パイプライン評価:shouldOverrideUrlLoading()がfalseを返すか、非HTTP(S) URIをインターセプトし損ねると、ChromiumエンジンはURLを標準的なWebリソース読み込みとして扱います。
  4. エラーのディスパッチ:プロトコルに対する内部ハンドラーが見つからないため、エンジンはonReceivedError()をトリガーし、WebViewのコンテンツをChromiumのデフォルトエラーテンプレートであるnet::ERR_UNKNOWN_URL_SCHEMEに置き換えます。

このエラーページはユーザー体験を遮断し、コンバージョン計測を阻害し、ホストアプリの安定性に対する信頼を低下させます。

ターゲットアプリが存在しない場合のActivityNotFoundExceptionクラッシュのリスク

開発者がnet::ERR_UNKNOWN_URL_SCHEMEを解決しようとする際、よくある間違いは、URI文字列を解析してcontext.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))を呼び出す際に、適切な例外処理を怠ることです。

ユーザーの端末にターゲットアプリがインストールされていない場合、Android OSはIntentを解決するための適切なActivityを見つけることができません。標準的なAndroidの動作において、対応するハンドラーなしでstartActivity()を呼び出すと、未処理のandroid.content.ActivityNotFoundExceptionがスローされます。この例外が構造化されたtry-catchブロック内で捕捉されない場合、ホストアプリのプロセス全体が突然終了し、致命的なクラッシュが発生します。

shouldOverrideUrlLoadingはどのようにディープリンクプロトコルをインターセプトするのか

APIの進化:API 24以降のWebResourceRequestオーバーロードと従来の文字列コールバックの比較

AndroidのWebViewClientには、Webエンジンがリクエストを実行する前に、ホストアプリがナビゲーションリクエストを調査・インターセプトできるようにするshouldOverrideUrlLoadingコールバックが用意されています。このメソッドのシグネチャと機能は、Android APIレベルによって進化してきました:

  • API 24以降(Android 7.0以降):shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Booleanをオーバーライドします。WebResourceRequestインターフェースは、ターゲットURL(request.url)、HTTPリクエストメソッド(request.method)、リクエストヘッダー、および重要なフレーム階層フラグ(request.isForMainFrame、request.hasGesture())を含む豊富なコンテキストメタデータを提供します。
  • 従来のオーバーロード(API 24で非推奨):shouldOverrideUrlLoading(view: WebView?, url: String?): Booleanをオーバーライドします。この従来のコールバックは、要求元フレームやユーザー操作状態に関するメタデータを含まない生のURL文字列のみを受け取ります。

Android Developers ReferenceのWebViewClient shouldOverrideUrlLoadingに従い、最新のアプリはWebResourceRequestのオーバーロードを実装する必要があります。従来のAndroidバージョンをサポートする場合は、セキュリティバイパスを防ぐために、非推奨の文字列コールバックを保守的に設定してください。

request.isForMainFrameの重要性:サブフレームとIframeハイジャックのブロック

メインフレームチェックは、iframeナビゲーションが外部アプリルートを起動することを防ぎます。

Android Developers GuideのWebResourceRequestに従い、shouldOverrideUrlLoadingコールバックはトップレベルのドキュメントナビゲーションだけでなく、サブフレーム、iframe、バックグラウンドリソースのリダイレクトに対してもトリガーされます。

request.isForMainFrameを強制することはアプリのセキュリティにとって不可欠です:

  • フレーム境界の分離:request.isForMainFrameを確認することで、サブフレームやiframeが外部アプリの起動やカスタムスキームのIntentをトリガーできないようにします。サブフレームがナビゲーションを試みた場合、標準的なHTTP(S)読み込みはfalseを返してフレーム内でのレンダリングを継続させ、非HTTP(S)スキームはtrueを返してエラーを出すことなく実行を抑制します。
  • ユーザージェスチャーの区別:isForMainFrameはリクエストがプライマリドキュメントに適用されることを確認しますが、ユーザーによるアクティブ化を証明するものではありません。アーキテクチャ上でユーザージェスチャーの検証が必要な場合は、request.hasGesture()を評価するか、アプリレベルの確認モーダルを導入してください。なお、Androidのドキュメントでは、一部の有効なユーザーフローにおいてhasGesture()がfalseを返す可能性があることに留意してください。

通常のWebナビゲーションとディープリンクハンドオフの区別:falseとtrueの使い分け

shouldOverrideUrlLoadingのBooleanの戻り値は、WebViewのレンダリングライフサイクルを制御します:

  • falseを返す:ホストアプリがURLを処理しなかったことをAndroidフレームワークに伝えます。WebViewは標準のWebナビゲーションを継続し、埋め込みブラウザ内でURLを読み込みます。この戻り値は、埋め込みブラウザ内に表示すべき標準的なhttp://およびhttps://Webページに対して必須です。
  • trueを返す:ホストアプリがURLをインターセプトし、処理したことをフレームワークに伝えます。WebViewは即座に内部の読み込みプロセスをキャンセルします。この戻り値は、外部Intentの起動、ネイティブアクティビティへのルーティング、または不正なスキームの抑制を行う際に必須です。

埋め込みコンテナ内でのホストアプリが検証したApp Linksの扱い

WebView内のリンクがホストアプリ自身の検証済みHTTPSドメイン(例:https://app.example.com/product/1024)を指している場合、デフォルトのナビゲーションを実行すると、WebViewは製品ページのWeb版を読み込んでしまいます。

ネイティブ体験を提供するため、WebViewClientは着信するHTTPS URLを検査する必要があります。ホストがアプリの関連ドメインと一致する場合、クライアントはリクエストをインターセプトし、内部ルートバリデーター(AppLinkRouteValidator)に対してパスとクエリパラメーターを検証し、ネイティブルーター(internalRouter(validatedRoute))にデータを直接ディスパッチします。その後、不要なWebレンダリングをキャンセルするためにtrueを返します。

カスタムスキームとIntent URIのためのセキュアなIntentハンドラーの構築

Chrome Intent URI構文の分解:スキーム、パッケージ、およびS.browser_fallback_url

Androidでは、WebページがChrome Intent URIを使用して、ブラウザのフォールバック機能を備えたネイティブディープリンクを構築することが一般的です。Intent URIはintent://スキームを使用し、ルーティングパラメーター、ターゲットパッケージ、およびフォールバックURLをシリアル化された文字列にエンコードします:

intent://product/detail/1024#Intent;scheme=myapp;package=com.example.app;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dcom.example.app;end

Chrome for Developers GuideのAndroid Intent URIsに記載されている通り、URIは以下のコンポーネントに分解されます:

  • ホストとパス (product/detail/1024):内部アプリの宛先を定義します。
  • scheme=myapp:基盤となるカスタムプロトコルを指定します。
  • package=com.example.app:Intentの解決を特定のAndroidパッケージに限定します。
  • S.browser_fallback_url=...:ターゲットアプリがインストールされていない場合に読み込むWebまたはストアのURLを定義するオプションのString Extraです。

生の解析済みIntentを実行する危険性:Intentインジェクションの脆弱性の緩和

よくある実装のアンチパターンとして、Intent.parseUri(url, Intent.URI_INTENT_SCHEME)で着信URIを解析し、結果のIntentをそのままcontext.startActivity(intent)に渡す実装があります。

OWASP Mobile Application Security Testing Guideのセキュアでないディープリンクに関するガイダンスに従い、解析された生のIntentを実行することは、アプリをIntentインジェクション攻撃にさらすことになります。Webページを制御する攻撃者は、悪意のある明示的なコンポーネントターゲット(component=com.example.app/.InternalAdminActivity)、機密アクション、または特権的なIntent Extra(S.is_admin=true)を含むIntent URIを作成できます。WebViewがこれらのパラメーターをデシリアライズしてエクスポートされていない内部アクティビティを起動し、認証障壁を回避してしまいます。

解析されたintent URIは、Androidでディスパッチされる前に検証および再構築される必要があります。

セキュアなIntentの再構築:コンポーネントの削除とACTION_VIEWへの限定

Intentインジェクションの脆弱性を排除するため、ホストアプリはデシリアライズされた生のIntentを直接起動してはなりません。代わりに、WebViewClientはURIを解析し、検証済みのデータペイロードのみを抽出し、クリーンで専用のIntentを再構築する必要があります:

  1. データの抽出:URIを解析し、検証済みのターゲットパッケージとデータURIを抽出します。
  2. パッケージの検証:宣言されたパッケージが、認可されたパートナーまたは内部パッケージの明示的な許可リストと一致することを確認します。
  3. 再構築:新しいIntent(Intent.ACTION_VIEW, validatedDataUri)をインスタンス化します。
  4. 分離:intent.addCategory(Intent.CATEGORY_BROWSABLE)を適用して、ブラウザ可能なアクティビティのみがリクエストを解決できるようにし、明示的なフラグ(FLAG_ACTIVITY_NEW_TASK)を設定し、intent.componentとintent.selectorがnullであることを保証します。

スキーム、ホスト、パス、およびクエリパラメーターの厳格な許可リストの強制

カスタムスキーム(myapp://)と再構築されたIntent URIの両方は、実行前に厳格なバリデーターを通過する必要があります。

このリファレンス実装は、ホストアプリ自身のmyapp://スキームを具体的に処理し、Intent実行をcom.example.appに固定します。マルチアプリのパートナーエコシステムの場合、開発者は厳格なスキームとパッケージのマッピングを維持するか、検証済みのHTTPS App Linksを優先すべきです。

バリデーターは以下の点を確認します:

  • スキーム:認可されたカスタムスキーム(myapp)と一致するか。
  • ホスト:事前定義されたルーティングカテゴリ(open、product、event)と一致するか。
  • パスのプレフィックス:予想されるルート構造(/detail/、/view/、/main/)に従っているか。
  • クエリパラメーター:許可リストにあるクエリキー(target、id、promo、utm_source)を強制し、パラメーター値が長さの制限(≤128\le 128文字)および英数字セットに準拠していることを検証します。
[User Taps Link Inside Android WebView]
                   │
                   ▼
[WebViewClient.shouldOverrideUrlLoading]
                   │
     ┌─────────────┴─────────────┐
     ▼                           ▼
[!request.isForMainFrame]   [request.isForMainFrame]
     │                           │
     ▼                           ▼
[Check Subframe Scheme]     [Inspect URL Scheme]
├─ HTTP(S) -> return false       │
└─ Non-Web -> return true   ┌────┴───────────────────────────┐
   (Suppress Error)         ▼                                ▼
                     [HTTP / HTTPS]                 [Non-HTTP(S) Scheme]
                            │                                │
                            ▼                                ▼
                   [Check Host Domain]              [CustomSchemeValidator]
                   ├─ Internal App Link             ├─ Valid -> Launch Pinned Intent
                   │  -> Internal Router            └─ Intent -> Reconstruct & Fallback
                   └─ External Web
                      -> Return false

Android 11のパッケージ可視性とApp Storeフォールバックの扱い

パッケージの可視性はプリフライトクエリに影響し、起動に失敗した場合は検証済みのフォールバックを使用します。

Android 11のパッケージ可視性の変更:なぜプリフライトクエリで宣言が必要なのか

Android 11(APIレベル30)から、Googleはユーザーのプライバシーを強化するためにパッケージの可視性フィルタリングを導入しました。このフレームワーク下では、インストールされている他のアプリに対するアプリケーションの可視性はデフォルトで制限されます。

Intentの起動とIntentの照会を区別することは極めて重要です:

  • 直接的なIntent起動:context.startActivity(intent)への標準的な呼び出しは、パッケージ可視性の宣言を必要としません。ターゲットアプリがインストールされていれば、OSはパッケージクエリから見えるかどうかに関係なく、ルーティングおよび起動に成功します。
  • プリフライトIntentクエリ:WebViewClientアーキテクチャが、起動前にアプリの存在を確認するためにpackageManager.resolveActivity(intent, 0)やpackageManager.queryIntentActivities(intent, 0)などを呼び出すプリフライト検査を行う場合、ターゲットパッケージやスキームがAndroidManifest.xmlで宣言されていない限り、クエリはnullや空のリストを返します。

カスタムスキームとパッケージターゲットのためのAndroidManifest.xmlにおける<queries>要素の宣言

Android Developers GuideのAndroid 11におけるパッケージの可視性に従い、Intent実行前にプリフライトチェックを行うアプリは、マニフェスト内でターゲットパッケージまたはIntentシグネチャを宣言する必要があります:

<manifest package="com.example.app">
    <!-- 明示的なパッケージ検出のためのオプションのプリフライトクエリ宣言 -->
    <queries>
        <package android:name="com.example.partner" />
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>
</manifest>

アーキテクチャとして標準的な起動パターンを優先する場合、ActivityNotFoundExceptionを捕捉するtry-catchブロック内でstartActivity()を実行することで、クエリに依存しないレジリエントな実装を提供できます。

フォールバックチェーンの実装:ターゲットアプリが存在しない場合の適切なストアへの誘導

インターセプトされたカスタムスキームやIntent URIが、ユーザーの端末にターゲットアプリが存在しないために起動できない場合、WebViewClientは明示的なフォールバックチェーンを実行する必要があります:

  1. フォールバックURLの抽出とバインド:intent:// URIの場合は、S.browser_fallback_url文字列パラメーターを抽出し、それが認可された宛先を指しているか検証します。Google PlayのURLの場合は、idパラメーターがターゲットパッケージ名(id=targetPackage)と完全に一致することを確認し、オープンリダイレクトエクスプロイトを防ぎます。
  2. マーケットURIの生成:フォールバックURLが存在しない場合は、マーケットIntent:market://details?id=com.example.appを構築します。
  3. Web Play Storeフォールバック:Google Playが無効化されているか存在しない場合は、マーケットIntentでActivityNotFoundExceptionを捕捉し、https://play.google.com/store/apps/details?id=com.example.appを読み込みます。

OpoInstallを使用したフォールバックリダイレクト間でのコンテキストパラメーターの保持

未インストールユーザーがGoogle Playへリダイレクトされる際、静的なストアリンクでは動的なパラメーターがドロップされます。モバイルアトリビューションおよびディープリンクプラットフォームであるOpoInstallは、ストアリダイレクト前にアトリビューションバックエンドで紹介トークンとキャンペーンコンテキストをキャプチャすることで、このギャップを埋めます。本番環境でデプロイされているOpoInstall Android SDKのバージョンに対して、遅延パラメーターの取得動作と初期化APIを検証してください。

インストール後の初回アプリ起動時に、OpoInstall Android SDKがキャッシュされたパラメーターを取得し、ホストアプリがユーザーのターゲットシーンを復元できるようにします。インストール後の遅延パラメーターの受け取り方法については、SDK統合ドキュメントを確認してください。

安全なスキームインターセプトのための本番環境WebViewClientの実装

KotlinにおけるカスタムWebViewClientアーキテクチャの構築

本番環境のWebViewClientは、フレーム検証、スキーム許可リスト、Intentの再構築、フォールバック抽出、および内部ルーティングを調整します。実装は、ナビゲーションコールバックをクリーンで保守しやすく保つために、専用のバリデーターオブジェクト(AppLinkRouteValidatorおよびCustomSchemeValidator)内に検証ルールをカプセル化します。

AppLinkRouteValidatorおよびCustomSchemeValidatorモデルの実装

バリデーターオブジェクトは、厳格な「失敗時に閉じる(fail-closed)」セキュリティを強制します:

  • AppLinkRouteValidatorは、内部HTTPSドメイン、許可されたパスのプレフィックス、および認可されたクエリキー(target、id、promo、utm_source)を検証し、構造化されたValidatedAppRouteデータクラスを返します。
  • CustomSchemeValidatorは、認可されたカスタムスキーム(myapp)を確認し、許可されたホスト(open、product、event)を検証し、パスのプレフィックスを強制し、クエリパラメーター値を英数字セットおよび長さの制限(≤128\le 128文字)に対してサニタイズします。

以下のコード実装は、オプションのAndroidManifest.xmlのパッケージ可視性宣言を設定し、自動化されたフォールバック処理を備えたKotlinによるスレッドセーフでセキュアなCustomWebViewClientを実装する方法を示しています。認定されたSDKバイナリおよび統合ラッパーは、OpoInstall SDKダウンロードセンターから取得できます。

<!-- AndroidManifest.xml: プリフライトIntentクエリのためのオプションのパッケージ可視性宣言 (Android 11+) -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.example.app">

    <!-- プリフライトのresolveActivityクエリを使用する場合のみ、外部パッケージとカスタムスキームを宣言します -->
    <queries>
        <!-- 特定のパートナーまたはターゲットパッケージのプリフライト検出を許可 -->
        <package android:name="com.example.partner" />
        
        <!-- カスタムディープリンクスキームのプリフライト解決を許可 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="myapp" />
        </intent>
        
        <!-- 検証済みのHTTPS App Linkドメインのプリフライト解決を許可 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="https" android:host="app.example.com" />
        </intent>
    </queries>

    <application
        android:allowBackup="true"
        android:label="@string/app_name"
        android:theme="@style/Theme.AppCompat.Light.NoActionBar">
        <!-- アプリケーションコンポーネント -->
    </application>
</manifest>
// Android: フレーム境界セキュリティ、許可リスト検証、およびフォールバックルーティングを備えたカスタムWebViewClient
// 統合リファレンス例です。展開されたAndroidアーキテクチャに対してメソッドシグネチャを検証してください。
package com.example.app.webview

import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

data class ValidatedAppRoute(val path: String, val queryParams: Map<String, String>)

object AppLinkRouteValidator {
    private val allowedPrefixes = setOf("/open/", "/product/", "/promo/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): ValidatedAppRoute? {
        val path = uri.path ?: ""
        if (allowedPrefixes.none { path.startsWith(it) }) {
            return null
        }

        val sanitizedParams = mutableMapOf<String, String>()
        for (name in uri.queryParameterNames) {
            // 厳格なセキュリティ姿勢:未知のクエリキーが存在する場合はリクエストを拒否
            if (name !in allowedQueryKeys) {
                return null
            }
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length <= 64 && value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                sanitizedParams[name] = value
            } else {
                return null
            }
        }

        return ValidatedAppRoute(path, sanitizedParams)
    }
}

object CustomSchemeValidator {
    private val allowedSchemes = setOf("myapp")
    private val allowedHosts = setOf("open", "product", "event")
    private val allowedPathPrefixes = setOf("/detail/", "/view/", "/main/")
    private val allowedQueryKeys = setOf("target", "id", "promo", "utm_source")

    fun validate(uri: Uri): Uri? {
        val scheme = uri.scheme?.lowercase() ?: return null
        val host = uri.host?.lowercase() ?: return null
        val path = uri.path ?: ""

        if (!allowedSchemes.contains(scheme) || !allowedHosts.contains(host)) {
            return null
        }

        if (allowedPathPrefixes.none { path.startsWith(it) }) {
            return null
        }

        for (name in uri.queryParameterNames) {
            if (name !in allowedQueryKeys) return null
            val value = uri.getQueryParameter(name) ?: ""
            if (value.length > 128 || !value.matches(Regex("^[A-Za-z0-9_-]+$"))) {
                return null
            }
        }

        return uri
    }
}

class CustomWebViewClient(
    private val context: Context,
    private val internalRouter: (ValidatedAppRoute) -> Unit
) : WebViewClient() {

    override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
        request ?: return false

        // セキュリティ要件:外部アプリのルーティングをメインフレームのナビゲーションのみに制限
        if (!request.isForMainFrame) {
            val scheme = request.url.scheme?.lowercase()
            // 標準的なHTTP(S)サブフレーム読み込みを許可するが、iframeハイジャックとエラーを防ぐため非Webスキームを抑制
            return if (scheme == "http" || scheme == "https") {
                false
            } else {
                Log.w("WebViewClient", "サブフレーム/iframeでの非Webスキームを抑制しました: $scheme")
                true // 外部Intentを起動したりChromiumエラーを表示したりせずに消費/キャンセル
            }
        }

        val url = request.url.toString()
        return handleUrlNavigation(view, url)
    }

    @Deprecated("Javaで非推奨")
    override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean {
        if (url == null) return false

        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()

        // レガシーコールバックには信頼できるisForMainFrameメタデータがないため、外部スキームをルーティングしないでください
        return if (scheme == "http" || scheme == "https") {
            false // 標準Webコンテンツの読み込みを許可
        } else {
            Log.w("WebViewClient", "レガシーコールバックで外部スキームをブロックしました: $scheme")
            true // 外部Intentを起動せずに、未処理のスキームエラーを抑制
        }
    }

    private fun handleUrlNavigation(view: WebView?, url: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase() ?: return false

        // 1. HTTP / HTTPSと内部App Linksの処理
        if (scheme == "http" || scheme == "https") {
            // URLがホストアプリ自身のApp Linkドメインと一致するか確認
            if (scheme == "https" && uri.host?.lowercase() == "app.example.com") {
                val validatedRoute = AppLinkRouteValidator.validate(uri)
                if (validatedRoute != null) {
                    Log.i("WebViewClient", "検証済みの内部HTTPSルート。内部ルーターへディスパッチします。")
                    internalRouter(validatedRoute)
                } else {
                    Log.w("WebViewClient", "不正な、または権限のない内部App Linkを拒否しました: $uri")
                }
                return true // 内部的に処理済み; webviewの読み込みをキャンセル
            }
            return false // WebViewが標準のWebコンテンツをネイティブに読み込むことを許可
        }

        // 2. ハイジャックを防ぐための厳格な許可リストとパッケージ固定によるカスタムスキームの検証
        val validatedUri = CustomSchemeValidator.validate(uri)
        if (validatedUri != null) {
            val pinnedIntent = Intent(Intent.ACTION_VIEW, validatedUri).apply {
                `package` = "com.example.app" // 明示的にホストアプリパッケージを固定
                addCategory(Intent.CATEGORY_BROWSABLE)
                flags = Intent.FLAG_ACTIVITY_NEW_TASK
            }
            return launchSafeIntent(pinnedIntent, fallbackAction = {
                handleStoreFallback(view, "com.example.app")
            })
        }

        // 3. 安全な再構築とフォールバック抽出を用いたChrome Intent URIの処理
        if (scheme == "intent") {
            return try {
                val parsed = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
                val targetPackage = parsed.`package`
                val allowedPackages = setOf("com.example.app", "com.example.partner")
                val fallbackUrl = parsed.getStringExtra("browser_fallback_url")

                if (targetPackage != null && allowedPackages.contains(targetPackage)) {
                    val rawDataUri = parsed.data
                    if (rawDataUri != null) {
                        val validatedDataUri = CustomSchemeValidator.validate(rawDataUri)
                        if (validatedDataUri != null) {
                            // 解析された生のオブジェクトを実行するのではなく、クリーンで専用のACTION_VIEW Intentを再構築
                            val safeIntent = Intent(Intent.ACTION_VIEW, validatedDataUri).apply {
                                `package` = targetPackage
                                addCategory(Intent.CATEGORY_BROWSABLE)
                                flags = Intent.FLAG_ACTIVITY_NEW_TASK
                            }
                            launchSafeIntent(safeIntent, fallbackAction = {
                                handleIntentFallback(view, fallbackUrl, targetPackage)
                            })
                        } else {
                            Log.w("WebViewClient", "無効なデータURIを含むintentを拒否しました: $rawDataUri")
                            true
                        }
                    } else {
                        Log.w("WebViewClient", "データURIペイロードを欠くintentを拒否しました")
                        true
                    }
                } else {
                    Log.w("WebViewClient", "検証されていないパッケージをターゲットとするintentを拒否しました: $targetPackage")
                    true // 未検証のパッケージintentをキャンセル
                }
            } catch (e: Exception) {
                Log.e("WebViewClient", "intent URIの解析に失敗しました: $url", e)
                true // 未処理のスキームエラーを抑制
            }
        }

        // net::ERR_UNKNOWN_URL_SCHEMEエラーを避けるために不明なスキームを拒否
        Log.w("WebViewClient", "検証されていないスキームナビゲーションをブロックしました: $scheme")
        return true
    }

    private fun launchSafeIntent(intent: Intent, fallbackAction: () -> Unit): Boolean {
        return try {
            context.startActivity(intent)
            true
        } catch (e: ActivityNotFoundException) {
            Log.w("WebViewClient", "ターゲットアプリがインストールされていません: ${intent.data}。フォールバックをトリガーします。")
            fallbackAction()
            true
        } catch (e: SecurityException) {
            Log.e("WebViewClient", "intent起動中にセキュリティ例外が発生しました: ${intent.data}", e)
            true
        }
    }

    private fun handleIntentFallback(view: WebView?, fallbackUrl: String?, targetPackage: String) {
        if (!fallbackUrl.isNullOrBlank() && isAuthorizedFallbackUrl(fallbackUrl, targetPackage)) {
            Log.i("WebViewClient", "認可されたブラウザフォールバックURLへルーティングします: $fallbackUrl")
            view?.loadUrl(fallbackUrl)
        } else {
            handleStoreFallback(view, targetPackage)
        }
    }

    private fun handleStoreFallback(view: WebView?, packageName: String) {
        val marketUri = Uri.parse("market://details?id=$packageName")
        val marketIntent = Intent(Intent.ACTION_VIEW, marketUri).apply {
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
        }
        try {
            context.startActivity(marketIntent)
        } catch (e: ActivityNotFoundException) {
            val webStoreUrl = "https://play.google.com/store/apps/details?id=$packageName"
            Log.i("WebViewClient", "Playストアアプリが存在しません。WebストアURLを読み込みます: $webStoreUrl")
            view?.loadUrl(webStoreUrl)
        }
    }

    private fun isAuthorizedFallbackUrl(url: String, targetPackage: String): Boolean {
        val uri = Uri.parse(url)
        val scheme = uri.scheme?.lowercase()
        val host = uri.host?.lowercase()

        if (scheme != "https" || host == null) return false

        return when (host) {
            "play.google.com" -> {
                // Google PlayフォールバックURLが特にターゲットパッケージを指していることを強制
                uri.path == "/store/apps/details" && uri.getQueryParameter("id") == targetPackage
            }
            "app.example.com", "www.example.com" -> {
                // 所有ドメインのフォールバックルートを検証
                AppLinkRouteValidator.validate(uri) != null
            }
            else -> false
        }
    }
}

スレッドセーフな実行:内部ルートのディスパッチとライフサイクル状態の管理

AppLinkRouteValidatorが有効な内部ルートを確認した場合、UI遷移はAndroidのメインUIスレッドで実行される必要があります。internalRouterがフラグメント、アクティビティ遷移、またはComposeナビゲーションをトリガーする場合は、スレッドクラッシュを防ぐために、runOnUiThreadまたはメインスレッドライフサイクルコルーチン内でディスパッチ呼び出しをラップしてください。

Android WebViewリダイレクトエラーと診断ソリューションマトリックス

Android WebViewディープリンクの包括的なトラブルシューティングガイド

以下のマトリックスは、Android WebViewでカスタムスキームを扱う際に遭遇する一般的なエラーシグネチャと、その主要な根本原因、およびエンジニアリング上の修正方法を詳細に示しています:

エラーシグネチャ / 症状 主要な根本原因 影響を受けるAPIレベル 診断チェックポイント 推奨される修正方法
net::ERR_UNKNOWN_URL_SCHEME WebViewローダーで非Webスキームが未処理 すべてのAPIレベル shouldOverrideUrlLoadingの欠如またはfalseを返す デリゲートをオーバーライドし、スキームをインターセプトしてtrueを返す
ActivityNotFoundException デバイスにターゲットアプリが存在しない すべてのAPIレベル 未処理のcontext.startActivity() 例外をキャッチし、S.browser_fallback_urlまたはPlayストアへのルートを実行する
Iframe Intentハイジャック サブフレームが外部Intentをトリガーしている API 24以降 request.isForMainFrameのチェック漏れ if (!request.isForMainFrame)で保護し、非Webスキームを抑制する
プリフライトクエリがNullを返す Android 11以降のパッケージ可視性フィルター API 30以降 (Android 11以降) マニフェスト宣言なしでresolveActivityを呼び出している マニフェスト内で<queries>を宣言するか、直接startActivity try-catchを使用する
内部App Linkのリロードループ ホストApp LinkがWebViewへ再ルーティングされている すべてのAPIレベル デリゲートでホストドメインが未処理 自ドメインをインターセプトし、ルートを検証してネイティブルーターへディスパッチする

よくある質問 (FAQ)

カスタムURLスキームを開くときにAndroid WebViewがクラッシュするのはなぜですか?
カスタムスキームは、ホストアプリによってインターセプトされず、URLがWebViewの読み込みパイプラインに残った場合に`net::ERR_UNKNOWN_URL_SCHEME`を生成します。カスタム`WebViewClient`を使用し、非Webスキームを安全に処理または抑制した後に`true`を返してください。WebViewが通常のWebナビゲーションを継続すべき場合にのみ`false`を返します。ターゲットアプリが存在しない場合、`try-catch`ブロックなしで`startActivity()`を呼び出すと、未処理の`ActivityNotFoundException`がスローされます。
セキュリティの脆弱性を発生させずにWebViewでintent:// URLを安全に処理するにはどうすればよいですか?
intent:// URIを安全に処理するには、`Intent.parseUri(url, Intent.URI_INTENT_SCHEME)`を使用してURIを解析し、ターゲットパッケージとデータURIが厳格な許可リストと一致することを検証した上で、新しくクリーンな`Intent(Intent.ACTION_VIEW, safeDataUri)`を構築してください。ブラウザ可能なアクティビティのみがIntentを処理できるように明示的に`Intent.CATEGORY_BROWSABLE`を追加し、危険なコンポーネントを取り除き、ターゲットパッケージに結び付けられた検証済みのフォールバックURL処理を実装してください。
Android 11のパッケージ可視性はWebViewのディープリンクインターセプトにどのような影響を与えますか?
Android 11のパッケージ可視性は、`startActivity()`が一致する外部アクティビティを起動することを妨げません。ただし、`WebViewClient`が起動を試みる前に`resolveActivity()`や`queryIntentActivities()`を使用してプリフライトチェックを行う場合は、`AndroidManifest.xml`ファイルの`<queries>`要素内でターゲットパッケージ名またはカスタムIntentスキームを宣言する必要があります。

要約と意思決定フレームワーク

Android WebView内でのディープリンクとカスタムスキームの処理には、WebレンダリングのサンドボックスとネイティブOSのIntentルーティングのギャップを埋める必要があります。デフォルトのWebView動作に依存すると、未処理のプロトコルエラー、低品質なユーザー体験、および潜在的なセキュリティの脆弱性を招きます。

フレーム境界を検証し、カスタムスキームを明示的な許可リストに対してバリデーションし、安全なACTION_VIEW Intentを再構築し、適切なストアフォールバックを処理する堅牢なWebViewClientを実装することで、エンジニアリングチームはWebViewのスキーム処理失敗を減らし、よりレジリエントなナビゲーションをサポートできます。信頼性の高いクライアント側のルーティングを包括的なモバイルアトリビューションフレームワークと統合することで、アトリビューションフロー全体のルーティング摩擦を低減し、一貫性のあるユーザージャーニーをサポートします。

ネイティブAndroidのディープリンクとパラメータールーティングアーキテクチャについては、SDK統合ドキュメントを確認するか、OpoInstall SDKダウンロードセンターからクライアントライブラリをダウンロードしてください。モバイルアトリビューションの実装リファレンスを確認するか、OpoInstall開発者コンソールでアプリを登録することも可能です。

関連資料

Share this article