記事一覧に戻る
CapacitorでWeb・Android・iOSのディープリンクを統一的に扱う

CapacitorでWeb・Android・iOSのディープリンクを統一的に扱う

Capacitorアプリでディープリンク処理に苦戦した経験から、Native側でURLを受け取りSPAルーターに委託する設計パターンを共有します

Capacitorでネイティブアプリを作っていると、ディープリンク処理が厄介だと感じていました。Web、Android、iOSでそれぞれ挙動が違うし、アプリが起動していない状態(コールドスタート)と起動済みの状態(ウォームスタート)で処理を分ける必要がある。さらにOAuth認証のコールバックも絡んでくると、どこで何を処理すべきか混乱しがちです。

この記事では、試行錯誤の末にたどり着いた設計パターンを共有します。

最初の失敗:OAuthコールバックをNative側で処理しようとした

OAuth認証でよく使われるPKCEフローでは、認証プロバイダーからのコールバックがカスタムURLスキーム(com.example.app://auth/callback)でアプリに戻ってきます。これを最初に受け取るのはNative側(iOS/Android)です。

最初はこのコールバック処理もNative側で完結させようとしていました。「どうせNative側で受け取るんだから、そのままNativeコードでトークン処理しよう」と考えたんです。トークンを受け取って、セッションを確立して、適切な画面に遷移する…という処理をSwift/Javaで書こうとしました。

結果、コードがどんどん複雑になっていきました。一番辛かったのはロジックの二重管理です。

  • 認証状態を判定するロジックがNative側とSPA側の両方に存在してしまう
  • トークンの保存先をNativeとSPAで共有する仕組みが必要
  • エラーハンドリングもNativeとSPAで二重になる
  • iOSとAndroidで同じロジックを別々に実装・メンテする必要がある

「Native側で受け取るからNative側で処理する」という発想自体が間違いだったと気づいてから、設計を見直しました。

たどり着いた設計思想

Native側でディープリンクを受け取り、SPA側のルーティングに委託する。

この設計のポイントは:

  1. Native側の責務を最小限に

    • ディープリンクURLを受け取る
    • URLをパースしてパス・クエリ・フラグメントを抽出
    • SPAルーターに処理を委託
  2. 判断はすべてSPA側で

    • 認証状態のチェック
    • リダイレクト先の決定
    • エラーハンドリング
  3. ページリロードを避ける

    • history.pushState + popstateイベントでSPAルーターに認識させる

こうすると、認証周りのロジックはTypeScript/Reactで一元管理できて、iOSとAndroidで同じ挙動を保証しやすくなりました。

全体像

+-----------------------------------------------------------+
|                           OS                              |
|         (Deep Link tap / OAuth callback)                  |
+-----------------------------+-----------------------------+
                              |
                              v
+-----------------------------------------------------------+
|                      Native Layer                         |
|  - Receive URL                                            |
|  - Parse (scheme, host, path, query, fragment)            |
|  - Cold start: save to pendingPath                        |
|  - Warm start: delegate to SPA immediately                |
+-----------------------------+-----------------------------+
                              |
                              v
+-----------------------------------------------------------+
|                        WebView                            |
|       history.pushState() + dispatch popstate event       |
+-----------------------------+-----------------------------+
                              |
                              v
+-----------------------------------------------------------+
|                       SPA Router                          |
|  - Route matching                                         |
|  - Auth check                                             |
|  - Render                                                 |
+-----------------------------------------------------------+

設定ファイル

Android(AndroidManifest.xml)

<activity
    android:name=".MainActivity"
    android:launchMode="singleTask">

    <!-- カスタムURLスキーム -->
    <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="${applicationId}" />
    </intent-filter>

    <!-- App Links(HTTPS) -->
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" android:host="my.example.com" />
    </intent-filter>
</activity>

設定のポイント:

  • android:launchMode="singleTask": アプリが既に起動している場合、新しいActivityを作らず既存のものを再利用する。これがないとディープリンクのたびに新しいインスタンスが作られてしまう
  • android.intent.action.VIEW: このActivityがURLを「見る」ためのものであることを宣言
  • android.intent.category.BROWSABLE: ブラウザからこのActivityを起動できるようにする。これがないとディープリンクが動作しない
  • android.intent.category.DEFAULT: 暗黙的Intentのデフォルトターゲットとして機能させる
  • android:scheme="${applicationId}": GradleのapplicationIdを参照する。ビルドバリアント(prod/stg/local)ごとに異なるスキームを自動で設定できる
  • android:autoVerify="true": App Links用。サーバー側の.well-known/assetlinks.jsonと照合して、このアプリが正当なハンドラーであることを検証する

iOS(Info.plist)

<!-- カスタムURLスキーム -->
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleURLSchemes</key>
        <array>
            <string>$(APP_URL_SCHEME)</string>
        </array>
    </dict>
</array>

設定のポイント:

  • CFBundleURLTypes: アプリが処理できるURLスキームの一覧を定義する配列
  • CFBundleURLSchemes: 実際のスキーム文字列。com.example.app://com.example.app部分
  • $(APP_URL_SCHEME): Xcodeのビルド設定(Build Settings)から注入される変数。Xcodeで「User-Defined」設定を追加するか、xcconfig ファイルで定義する。環境別にcom.example.appcom.example.app.stgなどを切り替えられる

Universal Links(HTTPSスキームのディープリンク)を使う場合は、追加で以下が必要です:

  1. Xcodeの設定: Signing & Capabilities → Associated Domains に applinks:my.example.com を追加
  2. サーバー側: https://my.example.com/.well-known/apple-app-site-association にJSON ファイルを配置

ウォームスタート対応

まずは基本となるウォームスタート(アプリ起動済み)の処理から。アプリが起動済みの場合は、SPAルーターに直接委託します。ページリロードを避けるために、JavaScriptのhistory.pushStateを使います。

Android

@Override
protected void onNewIntent(Intent intent) {
    super.onNewIntent(intent);

    Uri data = intent.getData();
    if (data != null) {
        handleDeepLink(data);
    }
}

private void handleDeepLink(Uri uri) {
    String path = extractPath(uri);
    String query = uri.getQuery();
    navigateWithSpaRouter(path, query);
}

private void navigateWithSpaRouter(String path, String query) {
    String fullPath = (query == null || query.isEmpty())
        ? path
        : path + "?" + query;

    String script = String.format(
        "(function() {" +
        "  window.history.pushState({}, '', '%s');" +
        "  window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));" +
        "})();",
        escapeForJs(fullPath)
    );

    getBridge().getWebView().evaluateJavascript(script, null);
}

onNewIntentは、singleTaskモードのActivityが既に存在する状態で新しいIntentを受け取ったときに呼ばれます。

iOS

private func navigateWithSpaRouter(path: String, query: String?) {
    let fullPath = query.map { "\(path)?\($0)" } ?? path

    let script = """
    (function() {
        window.history.pushState({}, '', '\(fullPath.escapedForJs)');
        window.dispatchEvent(new PopStateEvent('popstate', { state: {} }));
    })();
    """

    webView?.evaluateJavaScript(script)
}

popstateイベントを発火させることで、React RouterやTanStack Routerなどのクライアントサイドルーターがルート変更を検知してくれます。

コールドスタート対応

ここが一番ハマったところでした。アプリが起動していない状態でディープリンクを踏むと、WebViewの準備ができていない状態でURLを処理しようとしてしまいます。

Android

public class MainActivity extends BridgeActivity {
    private String pendingDeepLinkPath = null;

    @Override
    public void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        // getIntent()はActivityの標準メソッド。
        // このActivityを起動したIntentを取得する
        Intent intent = getIntent();
        if (intent != null && intent.getData() != null) {
            Uri uri = intent.getData();
            // パスとクエリを抽出して保存
            pendingDeepLinkPath = extractFullPath(uri);
        }
    }
}

WebViewの準備完了を検知するために、WebViewClientをオーバーライドします。

@Override
public void onPageStarted(WebView view, String url, Bitmap favicon) {
    super.onPageStarted(view, url, favicon);

    // 初回ロード時にpendingパスがあればリダイレクト
    if (pendingDeepLinkPath != null && isInitialLoad(url)) {
        String targetPath = pendingDeepLinkPath;
        pendingDeepLinkPath = null;

        // Capacitorのベースパスに置き換え
        view.stopLoading();
        view.loadUrl("https://localhost" + targetPath);
    }
}

private boolean isInitialLoad(String url) {
    // Capacitorの初期ロードURLかどうか判定
    return url.startsWith("https://localhost") &&
           (url.equals("https://localhost/") || url.endsWith("?init=1"));
}

iOS

最初はDispatchQueue.main.asyncAfterで遅延させていましたが、タイミングが安定しませんでした。WebViewの準備完了を確実に検知するために、WKNavigationDelegateを使います。

class CustomViewController: CAPBridgeViewController {
    private var pendingDeepLinkPath: String?
    private var initialLoadHandled = false

    override func viewDidLoad() {
        super.viewDidLoad()
        webView?.navigationDelegate = self
    }
}

extension CustomViewController: WKNavigationDelegate {
    func webView(_ webView: WKWebView,
                 didStartProvisionalNavigation navigation: WKNavigation!) {
        guard !initialLoadHandled,
              let targetPath = pendingDeepLinkPath,
              let url = webView.url else { return }

        // Capacitorの初期ロードかどうか判定
        if url.scheme == "capacitor" && url.host == "localhost" &&
           (url.path == "/" || url.path.isEmpty) {
            initialLoadHandled = true
            pendingDeepLinkPath = nil

            let targetUrl = URL(string: "capacitor://localhost\(targetPath)")!
            webView.stopLoading()
            webView.load(URLRequest(url: targetUrl))
        }
    }
}

didStartProvisionalNavigationは、WebViewがURLのロードを開始した直後に呼ばれます。このタイミングならWebViewは確実に準備できています。

OAuth認証との連携

OAuth認証のコールバックも、ディープリンクでアプリに戻ってくるという点では同じ仕組みです。認証プロバイダー(Google、Appleなど)での認証が完了すると、カスタムURLスキームでコールバックが返ってきます。

com.example.app://auth/callback#access_token=xxx&refresh_token=yyy

フラグメントの扱い

Supabase AuthなどのOAuthプロバイダーは、トークンをURLフラグメント(#以降)で返すことがあります。

ただし、ネイティブ側でフラグメントを扱うのは少し面倒です。理由としては:

  • フラグメントはサーバーに送信されないため、WebViewのURLロードでは#以降が無視されることがある
  • JavaScriptのwindow.location.hashで取得する方法もあるが、タイミングによっては空になる

そこで、Native側でフラグメントを受け取った時点でクエリパラメータに変換してしまうのが楽でした。?区切りならWebViewでも確実にパースできます。

// Android
private String buildFullPath(Uri uri) {
    String path = extractPath(uri);
    String query = uri.getQuery();
    String fragment = uri.getFragment();

    // フラグメントをクエリに結合
    if (fragment != null && !fragment.isEmpty()) {
        if (query == null || query.isEmpty()) {
            query = fragment;
        } else {
            query = query + "&" + fragment;
        }
    }

    return query == null ? path : path + "?" + query;
}

SPA側のコールバック処理

SPA側では、クエリパラメータからトークンを取得してセッションを確立します。このあたりは一般的なOAuthコールバックの実装と同じで、特別なことはしていません。Supabaseを使っている場合はsetSessionでトークンを渡すだけです。

認証ロジックがSPA側に集約されているので、エラーハンドリングやリダイレクト処理も一箇所で管理できます。

ハマったポイント

iOSではUniversal Links(https://)とカスタムURLスキーム(com.example.app://)で、コールバックメソッドが異なります。

  • URL Scheme: application(_:open:options:)
  • Universal Links: application(_:continue:restorationHandler:)

両方に対応する場合は、それぞれのメソッドを実装する必要があります。

// URL Scheme
func application(_ app: UIApplication,
                 open url: URL,
                 options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool {
    handleDeepLink(url: url)
    return true
}

// Universal Links
func application(_ application: UIApplication,
                 continue userActivity: NSUserActivity,
                 restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
    guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
          let url = userActivity.webpageURL else {
        return false
    }
    handleUniversalLink(url: url)
    return true
}

カスタムURLスキームのhost/path解釈

カスタムURLスキーム(com.example.app://calendars/123)の場合、://の直後がhostとして扱われます。

com.example.app://calendars/123

scheme = "com.example.app"
host = "calendars"
path = "/123"

つまり/calendars/123というパスを期待していると、hostcalendarspath/123に分かれてしまいます。hostpathを結合してSPAに渡すヘルパー関数を用意しておくと安心です。

// Android
private String extractPathFromCustomScheme(Uri uri) {
    String host = uri.getHost();
    String path = uri.getPath();

    StringBuilder result = new StringBuilder("/");
    if (host != null && !host.isEmpty()) {
        result.append(host);
    }
    if (path != null && !path.isEmpty()) {
        result.append(path);
    }
    return result.toString();
}

おわりに

この設計にしてから、ディープリンク周りのバグがだいぶ減りました。Native側のコードもシンプルになって、メンテナンスしやすくなった気がします。

ポイントをまとめると:

  • Native側はURLの受け渡しに徹する(判断はしない)
  • ウォームスタートはhistory.pushState + popstateイベント
  • コールドスタートはdidStartProvisionalNavigation(iOS)/ onPageStarted(Android)で検知
  • OAuth含め、認証ロジックはSPA側に集約

同じような悩みを持っている方の参考になれば。