Navigation Componentのdeep linkの仕組みを調べて見た

Navigation Componentでは起動した時点でFragmentのバックスタックが作られるDeep Linkという機能があります。
リファクタリング中の既存の動作を維持しつつnavigationを適用していたときに、応用の仕方が分からず、どうやって実装されているのか、調べて見ることにしました。

# Deep Linkの起動方法

Deep Linkには暗黙的deep link (implicit deep link) と明示的deep link (explicit deep link) の2種類があります。まずはそれぞれの起動方法がどうなっているのか調べて見ましょう。

## 暗黙的deep link

暗黙的deep linkではdeep linkにuriを設定して、そのuriで起動された時にdeep linkが発動するようになります。呼び出しの仕組みはuri指定のIntentなので、呼び出し側はなにも複雑なことはしていません。

設定の仕方は、navigation graphにdeeplinkタグを設定

```xml:nav_graph.xml
<fragment
    android:id="@+id/SecondFragment"
    android:name="com.example.myapplication.SecondFragment"
    android:label="@string/second_fragment_label"
    tools:layout="@layout/fragment_second"
    >
    <deepLink app:uri="example.com/" />
</fragment>
```

AndroidManifestに以下のようにnav-graphタグを設定します

```xml:AndroidManifest.xml
<activity
    android:name=".MainActivity"
    android:exported="true"
    >
    <nav-graph android:value="@navigation/nav_graph" />
```

これをビルドすると、nav-graphの部分が以下のようなintent-filterに変換されます

```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="http" />
    <data android:scheme="https" />
    <data android:host="example.com" />
    <data android:path="/" />
</intent-filter>
```

schemeを指定しなければhttp/httpsに変換されます。このURLを指定したIntentで起動することでdeep linkが発動します。

ちなみに、使ったことがないので知らなかったですが、試した範囲ではactionやmimeTypeがintent-filterに反映されないですね。。

## 明示的deep link

明示的deep linkはNavDeepLinkBuilderにnavigation graphとdestinationを指定して、`createPendingIntent()`でPendingIntentを作り、このPendingIntentで起動することでdeep linkを実現します。
`createTaskStackBuilder()`でTaskStackBuilderを作ることもできます。

```kotlin
NavDeepLinkBuilder(this)
    .setGraph(R.navigation.nav_graph)
    .setDestination(R.id.SecondFragment)
    .createPendingIntent()
```

## NavDeepLinkBuilder

NavDeepLinkBuilderで何が行われているのかを見てみましょう。

### コンストラクタ

コンストラクタでは以下のようにIntentのインスタンスを作成しています。
渡されたContextがActivityならそのActivityを、そうでないならLauncherActivityを起動するIntentが作られます。
SingleActivityなアプリや、LauncherActivityでのみNavigation componentを使っているアプリなら気にする必要はありませんが、そうでない場合、NavDeepLinkBuilderに渡すContextは、対象となるNavigationControllerを持つActivityである必要がある点は注意ですね。

```java:NavDeepLinkBuilder.java
public NavDeepLinkBuilder(@NonNull Context context) {
    mContext = context;
    if (mContext instanceof Activity) {
        mIntent = new Intent(mContext, mContext.getClass());
    } else {
        Intent launchIntent = mContext.getPackageManager().getLaunchIntentForPackage(
                mContext.getPackageName());
        mIntent = launchIntent != null ? launchIntent : new Intent();
    }
    mIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
}
```

### setGraph/setDestination

続いて、setGraph/setDestinationを見てみましょう。
setGraphはResourceIDを渡す方法と、インスタンスを渡す方法があります、どちらも同じですが、ResourceIDを渡した場合はinflateが行われていますね。
setGraph/setDestinationのコール順がどちらでも問題ないようになっていますが、両方の情報がそろったら`fillIntent()`がコールされていますね。

```java:NavDeepLinkBuilder.java
@NonNull
public NavDeepLinkBuilder setGraph(@NavigationRes int navGraphId) {
    return setGraph(new NavInflater(mContext, new PermissiveNavigatorProvider())
            .inflate(navGraphId));
}

@NonNull
public NavDeepLinkBuilder setGraph(@NonNull NavGraph navGraph) {
    mGraph = navGraph;
    if (mDestId != 0) {
        fillInIntent();
    }
    return this;
}

@NonNull
public NavDeepLinkBuilder setDestination(@IdRes int destId) {
    mDestId = destId;
    if (mGraph != null) {
        fillInIntent();
    }
    return this;
}
```

ついでに、`setArguments`をコールした場合は、`NavController.KEY_DEEP_LINK_EXTRAS`をkeyとするExtraに格納されます。

```java:NavDeepLinkBuilder.java
@NonNull
public NavDeepLinkBuilder setArguments(@Nullable Bundle args) {
    mArgs = args;
    mIntent.putExtra(NavController.KEY_DEEP_LINK_EXTRAS, args);
    return this;
}
```

### fillIntent

fillIntentの中を見てみます。

```java:NavDeepLinkBuilder.java
private void fillInIntent() {
    NavDestination node = null;
    ArrayDeque<NavDestination> possibleDestinations = new ArrayDeque<>();
    possibleDestinations.add(mGraph);
    while (!possibleDestinations.isEmpty() && node == null) {
        NavDestination destination = possibleDestinations.poll();
        if (destination.getId() == mDestId) {
            node = destination;
        } else if (destination instanceof NavGraph) {
            for (NavDestination child : (NavGraph) destination) {
                possibleDestinations.add(child);
            }
        }
    }
    if (node == null) {
        final String dest = NavDestination.getDisplayName(mContext, mDestId);
        throw new IllegalArgumentException("Navigation destination " + dest
                + " cannot be found in the navigation graph " + mGraph);
    }
    mIntent.putExtra(NavController.KEY_DEEP_LINK_IDS, node.buildDeepLinkIds());
}
```

whileループではnavigation graphの中から指定されたdestinationのインスタンスを検索しています。nested navigation graphの場合は、ネストの中も検索するようにwhileループしているわけですね。
destinationのインスタンスを見つけたら、buildDeepLinkIds()をコールして、IntentのExtraにセットしています。

### NavDestination#buildDeepLinkIds()

`buildDeepLinkIds()` の中を見てみます。

```java:NavDestination.java
@NonNull
int[] buildDeepLinkIds() {
    ArrayDeque<NavDestination> hierarchy = new ArrayDeque<>();
    NavDestination current = this;
    do {
        NavGraph parent = current.getParent();
        if (parent == null || parent.getStartDestination() != current.getId()) {
            hierarchy.addFirst(current);
        }
        current = parent;
    } while (current != null);
    int[] deepLinkIds = new int[hierarchy.size()];
    int index = 0;
    for (NavDestination destination : hierarchy) {
        deepLinkIds[index++] = destination.getId();
    }
    return deepLinkIds;
}
```

navigation graphを親方向にたどって、親から自分までのIDの配列を返しています。
`NavController.KEY_DEEP_LINK_IDS`をKeyとするExtraには、スタックに積むdestinationのIDの配列が入っているわけですね。

### createTaskStackBuilder

`createPendingIntent`の中で`createTaskStackBuilder`が使われているので、こちらから見ていきましょう。

```java:NavDeepLinkBuilder.java
@NonNull
public TaskStackBuilder createTaskStackBuilder() {
    if (mIntent.getIntArrayExtra(NavController.KEY_DEEP_LINK_IDS) == null) {
        if (mGraph == null) {
            throw new IllegalStateException("You must call setGraph() "
                    + "before constructing the deep link");
        } else {
            throw new IllegalStateException("You must call setDestination() "
                    + "before constructing the deep link");
        }
    }
    // We create a copy of the Intent to ensure the Intent does not have itself
    // as an extra. This also prevents developers from modifying the internal Intent
    // via taskStackBuilder.editIntentAt()
    TaskStackBuilder taskStackBuilder = TaskStackBuilder.create(mContext)
            .addNextIntentWithParentStack(new Intent(mIntent));
    for (int index = 0; index < taskStackBuilder.getIntentCount(); index++) {
        // Attach the original Intent to each Activity so that they can know
        // they were constructed in response to a deep link
        taskStackBuilder.editIntentAt(index)
                .putExtra(NavController.KEY_DEEP_LINK_INTENT, mIntent);
    }
    return taskStackBuilder;
}
```

先ほど調べた`KEY_DEEP_LINK_IDS`がセットされているかをチェックした後、TaskStackBuilderに`addNextIntentWithParentStack`で、作成したIntentのコピーを渡しています。

`addNextIntentWithParentStack`は、名前の通り、指定したIntentのActivityを単に追加するのではなく、parentActivityで指定された親をたどって、そのルートから順に起動するスタックを作成して最終的に指定したIntentを追加します。
parentActivityをきちんと指定しておけばActivityのスタックも作ってくれるわけですね。

そして、作られたTaskStackのすべてのIntentに`NavController.KEY_DEEP_LINK_INTENT`をKeyとしてIntentをセットしています。
このIntentは特にdeep linkの起動の仕組みで使われるわけではなさそうで、利用側がdeep linkで起動したか、その引数は、などを調べるためのもののようですね。（ちょっと自信なし）

### createPendingIntent

つづいて、`createPendingIntent()` の中を見ていきましょう。

```java:NavDeepLinkBuilder.java
@NonNull
public PendingIntent createPendingIntent() {
    int requestCode = 0;
    if (mArgs != null) {
        for (String key: mArgs.keySet()) {
            Object value = mArgs.get(key);
            requestCode = 31 * requestCode + (value != null ? value.hashCode() : 0);
        }
    }
    requestCode = 31 * requestCode + mDestId;
    return createTaskStackBuilder()
            .getPendingIntent(requestCode, PendingIntent.FLAG_UPDATE_CURRENT);
}
```

requestCodeを計算し、`createTaskStackBuilder()`をコールし、TaskStackBuilder#getPendingIntent()をコールして、PendingIntentを作っているだけですね。getPendingIntent()では、`Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_TASK_ON_HOME`を最初のIntentのFlagにセットした後、`PendingIntent.getActivities`をコールしているだけですね。

・・・って今気づきましたが、FLAG_IMMUTABLEが指定されていませんね。。

# Deep Link起動時の処理

続いて、起動された後の処理を追っていきましょう。Activityが起動されるまでは通常のIntentと同じですね。
deep linkの処理は`NavController#handleDeepLink()`で行われます。
`handleDeepLink()`は、`NavHostFragment#onCreate()`で`NavController#setGraph()`がコールされ、そこからコールされる`NavController#onGraphCreated()`の中でコールされています。
launchModeを変更した場合は自動でハンドリングされないため、`onNewIntent`で`handleDeepLink`をコールする必要があります。

### handleDeepLink deep link情報の読み出し

`handleDeepLink()`の中を見ていきましょう。少し長いメソッドなので、まずは前半から

```java:NavHostFragment.java
public boolean handleDeepLink(@Nullable Intent intent) {
    if (intent == null) {
        return false;
    }
    Bundle extras = intent.getExtras();
    int[] deepLink = extras != null ? extras.getIntArray(KEY_DEEP_LINK_IDS) : null;
    Bundle bundle = new Bundle();
    Bundle deepLinkExtras = extras != null ? extras.getBundle(KEY_DEEP_LINK_EXTRAS) : null;
    if (deepLinkExtras != null) {
        bundle.putAll(deepLinkExtras);
    }
    if ((deepLink == null || deepLink.length == 0) && intent.getData() != null) {
        NavDestination.DeepLinkMatch matchingDeepLink =
                mGraph.matchDeepLink(new NavDeepLinkRequest(intent));
        if (matchingDeepLink != null) {
            NavDestination destination = matchingDeepLink.getDestination();
            deepLink = destination.buildDeepLinkIds();
            Bundle destinationArgs =
                    destination.addInDefaultArgs(matchingDeepLink.getMatchingArgs());
            bundle.putAll(destinationArgs);
        }
    }
```

`KEY_DEEP_LINK_IDS`の値を取り出していますね。この値がなく、dataがある場合はuriによる起動の可能性を探っています。

`matchDeepLink()`は以下のようになっていて、再帰的に一番適合度の高いdeep linkの情報を検索しています。

```java:NavGraph.java
@Override
@Nullable
DeepLinkMatch matchDeepLink(@NonNull NavDeepLinkRequest request) {
    // First search through any deep links directly added to this NavGraph
    DeepLinkMatch bestMatch = super.matchDeepLink(request);
    // Then search through all child destinations for a matching deep link
    for (NavDestination child : this) {
        DeepLinkMatch childBestMatch = child.matchDeepLink(request);
        if (childBestMatch != null && (bestMatch == null
                || childBestMatch.compareTo(bestMatch) > 0)) {
            bestMatch = childBestMatch;
        }
    }
    return bestMatch;
}
```

適合度の判定は、`NavDistination#compareTo`で行われていて、uriの合致が優先度が高いみたいですね。

```java:NavDestination.java
@Override
public int compareTo(@NonNull DeepLinkMatch other) {
    // Prefer exact deep links
    if (mIsExactDeepLink && !other.mIsExactDeepLink) {
        return 1;
    } else if (!mIsExactDeepLink && other.mIsExactDeepLink) {
        return -1;
    }

    if (mMatchingArgs != null && other.mMatchingArgs == null) {
        return 1;
    } else if (mMatchingArgs == null && other.mMatchingArgs != null) {
        return -1;
    }

    if (mMatchingArgs != null) {
        int sizeDifference = mMatchingArgs.size() - other.mMatchingArgs.size();
        if (sizeDifference > 0) {
            return 1;
        } else if (sizeDifference < 0) {
            return -1;
        }
    }

    if (mHasMatchingAction && !other.mHasMatchingAction) {
        return 1;
    } else if (!mHasMatchingAction && other.mHasMatchingAction) {
        return -1;
    }

    return mMimeTypeMatchLevel - other.mMimeTypeMatchLevel;
}
```

uriからdeep link先を見つけることができれば、`buildDeepLinkIds()`を使ってIDの配列を取得しています。

### handleDeepLink deep link情報のバリデーション

続いては以下の処理、deepLinkが見つからなければ終了。`findInvalidDestinationDisplayNameInDeepLink`はちょっと分かりにくい名前をしていますが、deepLinkの各IDがちゃんとnavigation graphをたどれるかを確認していて、たどれる場合にnullが返ります。deep linkが不正であればここで終了です。そして、Intentをbundleに格納。

```java:NavHostFragment.java
    if (deepLink == null || deepLink.length == 0) {
        return false;
    }
    String invalidDestinationDisplayName =
            findInvalidDestinationDisplayNameInDeepLink(deepLink);
    if (invalidDestinationDisplayName != null) {
        Log.i(TAG, "Could not find destination " + invalidDestinationDisplayName
                + " in the navigation graph, ignoring the deep link from " + intent);
        return false;
    }
    bundle.putParcelable(KEY_DEEP_LINK_INTENT, intent);
```

### handleDeepLink バックスタックが不確定なら再起動

続いて以下の処理、intentのFLAG_ACTIVITY_NEW_TASKが立っているのにFLAG_ACTIVITY_CLEAR_TASKが立っていない場合はFLAG_ACTIVITY_CLEAR_TASKをつけてActivityをリスタートさせています。

```java:NavHostFragment.java
    int flags = intent.getFlags();
    if ((flags & Intent.FLAG_ACTIVITY_NEW_TASK) != 0
            && (flags & Intent.FLAG_ACTIVITY_CLEAR_TASK) == 0) {
        // Someone called us with NEW_TASK, but we don't know what state our whole
        // task stack is in, so we need to manually restart the whole stack to
        // ensure we're in a predictably good state.
        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
        TaskStackBuilder taskStackBuilder = TaskStackBuilder
                .create(mContext)
                .addNextIntentWithParentStack(intent);
        taskStackBuilder.startActivities();
        if (mActivity != null) {
            mActivity.finish();
            // Disable second animation in case where the Activity is created twice.
            mActivity.overridePendingTransition(0, 0);
        }
        return true;
    }
```

### handleDeepLink deep link情報に従い、バックスタックの作成

続いて、`FLAG_ACTIVITY_NEW_TASK`が立っている場合。
これがdeep linkのバックスタックを作る本体の処理です。
すでにバックスタックがあるならnavigation graphの先頭のさらに前まで、バックスタックが空になるまで戻します。
そして、deepLinkのIDの順にアニメーションを無効化して順次navigateしています。思った以上にシンプル！

```java:NavHostFragment.java
    if ((flags & Intent.FLAG_ACTIVITY_NEW_TASK) != 0) {
        // Start with a cleared task starting at our root when we're on our own task
        if (!mBackStack.isEmpty()) {
            popBackStackInternal(mGraph.getId(), true);
        }
        int index = 0;
        while (index < deepLink.length) {
            int destinationId = deepLink[index++];
            NavDestination node = findDestination(destinationId);
            if (node == null) {
                final String dest = NavDestination.getDisplayName(mContext, destinationId);
                throw new IllegalStateException("Deep Linking failed:"
                        + " destination " + dest
                        + " cannot be found from the current destination "
                        + getCurrentDestination());
            }
            navigate(node, bundle,
                    new NavOptions.Builder().setEnterAnim(0).setExitAnim(0).build(), null);
        }
        return true;
    }
```

### handleDeepLink バックスタックを作らない起動

最後はIntentにFLAG_ACTIVITY_NEW_TASKがついていない場合ですね。この場合は単に最後のDestinationに遷移させるだけでバックスタックは作られていません。コメントにあるように、他のアプリのタスクにあるような状況ということですね。

```java:NavHostFragment.java
    // Assume we're on another apps' task and only start the final destination
    NavGraph graph = mGraph;
    for (int i = 0; i < deepLink.length; i++) {
        int destinationId = deepLink[i];
        NavDestination node = i == 0 ? mGraph : graph.findNode(destinationId);
        if (node == null) {
            final String dest = NavDestination.getDisplayName(mContext, destinationId);
            throw new IllegalStateException("Deep Linking failed:"
                    + " destination " + dest
                    + " cannot be found in graph " + graph);
        }
        if (i != deepLink.length - 1) {
            // We're not at the final NavDestination yet, so keep going through the chain
            graph = (NavGraph) node;
            // Automatically go down the navigation graph when
            // the start destination is also a NavGraph
            while (graph.findNode(graph.getStartDestination()) instanceof NavGraph) {
                graph = (NavGraph) graph.findNode(graph.getStartDestination());
            }
        } else {
            // Navigate to the last NavDestination, clearing any existing destinations
            navigate(node, node.addInDefaultArgs(bundle), new NavOptions.Builder()
                    .setPopUpTo(mGraph.getId(), true)
                    .setEnterAnim(0).setExitAnim(0).build(), null);
        }
    }
    mDeepLinkHandled = true;
    return true;
}
```

# まとめ

暗黙的deep linkは、カスタムURLスキームを使って、明示的deep linkはnagitaion graphとdestination idを指定して、deep linkを実現する方法です。

Intent的には暗黙的deep linkはuri指定のIntent、明示的deep linkはActiivtyのバックスタックを作るTaskStackBuilderと、`KEY_DEEP_LINK_IDS`をkeyとするExtraにバックスタックに積むdestination idの配列が格納されたものでした。

いずれの方法も、destinationとなるFragmentを検索し、そこまでのバックスタックをnavigation graphのネスト構造から読み取り、navigateメソッドで遷移させることで実現していました。

ただ、レガシーアプリのリファクタリング中のような状態では、使い方がむずかしい部分もありそうです。そういった場合は、必要なバックスタック分アニメーションを無効化してnavigateをコールすれば良いですね。検索メソッドなどがpublicでないので、かなり泥臭い実装が必要ではありますが。

以上です。