navigation-compose のポイント : パラメーターに & と / を含む文字列を渡す

Jetpack Compose 版の Navigation で `&` を含む文字列をパラメーターにうまく渡せなくてなぜ？ というケースに遭遇したことはないでしょうか。

※ navigation-compose については以下の公式ガイドをごらんください:

https://developer.android.com/jetpack/compose/navigation

https://developer.android.com/guide/navigation

具体的なサンプルをもとにこのケースを解説します。


:::note warn
これを書いたときは `androidx.navigation:navigation-compose:2.5.3` でした)
:::


## サンプルコード

サンプル App では 以下の 3 つの画面があるとします:

1. ホーム画面
1. もてなし画面
   - 名前を与えてよびかける画面 (なにそれですがそういうものがあると思ってください)
   - 名前は必須のパラメーター
1. オススメ音楽トップ 3 画面
   - ジャンルを与えて曲を探す画面
   - ジャンルはオプショナルなパラメーター

ガイドにある感じで navigation-compose を使い画面遷移を実装しましょう。


## 全体感

サンプルでこういう App ができたとします:
(サンプルなので地獄みたいな UI ですが気にしないでください)

### ホーム画面

![home.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/917e5c50-950b-a713-90e2-b932332678f9.png)

### もてなし画面

名前を入力して `入室` ボタンを押すともてなし画面に遷移して
入力した名前が表示されます:

![name.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/b43728bf-43d6-dfc3-cca5-c4c7ab972043.png)

![greeting.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/877e28e7-18b1-ee4f-946e-bfc59ecc7b23.png)

### オススメ音楽トップ 3 画面

音楽ジャンルを入れて `オススメを見る` ボタンを押すと
おすすめ曲表示画面に遷移して
キーワードに応じたいい感じの曲の結果が出るとします:

![anison.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/33a56246-15e5-fa70-5a47-cc20a3d410b5.png)

![result.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/1093a588-f6cf-b3c5-47a4-d01293c41359.png)

結果が出てるからこれでよさそうな感じがしますね。


## 確認ケース1 - 「R&B」

ところがこれだと R&B のオススメを紹介されたくても
"R&B" は "R" になります:

![randb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/f63d7463-accd-dc70-868f-33518c17b366.png)

![result-randb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/36e93014-0287-4377-2f52-bcc5dc5af8ca.png)

`&B` が抜けていますね。ブルースはなくてリズムだけ。


## 確認ケース2 - 「GNU/Linux マスター mangano-ito」

また名前を 「GNU/Linux マスター mangano-ito」 にすると落ちます:

![master.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/106bbecc-1de7-a508-9d23-714cbf86e786.png)


![crash1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/1f0d1ca5-dbb6-c4a4-f807-b45776441649.png)

これは別に僕が全然マスターじゃないからとか
GNU と Linux の組み合わせが悪いわけではなくて
macOS でも Windows でもなんでも落ちます。


## コード

コードは Compose のスケルトンプロジェクトを作り、ざっくりとそのまま公式のガイドにある感じで navigation を追加したものです:

```kotlin
@Composable
fun MyAppNavHost(
    navController: NavHostController = rememberNavController(),
) {
    NavHost(
        navController = navController,
        startDestination = "home",
    ) {
        // 1. ホーム画面
        composable("home") {
            Home(
                onEnter = { name ->
                    navController.navigate("greet/$name")
                },
                onFindTunes = { keyword ->
                    if (keyword.isNullOrBlank()) {
                        navController.navigate("findMusic")
                    } else {
                        navController.navigate("findMusic?keyword=$keyword")
                    }
                },
            )
        }

        // 2. もてなし画面
        composable(
            "greet/{name}",
            arguments = listOf(
                navArgument("name") {
                    type = NavType.StringType
                },
            ),
        ) { backStackEntry ->
            Greeting(name = backStackEntry.arguments?.getString("name") ?: "Who knows?")
        }

        // 3. オススメ音楽トップ 3 画面
        composable(
            "findMusic?keyword={keyword}",
            arguments = listOf(
                navArgument("keyword") {
                    type = NavType.StringType
                    nullable = true
                },
            ),
        ) { backStackEntry ->
            FindMusic(keyword = backStackEntry.arguments?.getString("keyword"))
        }
    }
}
```

何の変哲もなさそうなコードです。


## 解説

確認ケース2 のエラーの内容を見ればわかるのですが、
navigation は DeepLink で遷移する仕組みという話なので
URI として解釈される文字列をそのまま渡すとそのとおり受け取り遷移するものの
対応する定義されたルートがないので落ちるということです:

> java.lang.IllegalArgumentException: Navigation destination that matches request NavDeepLinkRequest{ uri=android-app://androidx.navigation/greet/GNU/Linux マスター mangano-ito } cannot be found in the navigation graph NavGraph(0x0) startDestination={Destination(0x78d845ec) route=home}

R&B の `&B` が抜け落ちたのも `android-app://androidx.navigation/findMusic?keyword=R&B` として `keyword="R"` と `B=""` だと解釈されたということですね:

![queryParameterNames.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/a3f7b7e0-771e-95d2-3d33-b40813b3e94e.png)


あらためてみてみると Web のリンクの URL にそのまま変数を入れてるみたいなもので、
Web ページで考えると容易に脆弱性になりうりそうで怖いしダメそうだねとなりますよね。


## どうすればいいのか

Now in Android では `Uri.encode(param)` しています:

```kotlin:nowinandroid/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt
fun NavController.navigateToTopic(topicId: String) {
    val encodedId = Uri.encode(topicId)
    this.navigate("topic_route/$encodedId")
}
```

https://github.com/android/nowinandroid/blob/main/feature/topic/src/main/java/com/google/samples/apps/nowinandroid/feature/topic/navigation/TopicNavigation.kt#L38-L41

まっとうな感じで URI なのだからエンコードしてセーフに渡すのでいいでしょうということに思えます。

一方 Android Sunflower はそのままわたしています:

```kotlin:sunflower/app/src/main/java/com/google/samples/apps/sunflower/compose/SunflowerApp.kt
@Composable
fun SunFlowerNavHost(
    navController: NavHostController,
    onPageChange: (SunflowerPage) -> Unit = {},
    onAttached: (Toolbar) -> Unit = {},
) {
    val activity = (LocalContext.current as Activity)
    NavHost(navController = navController, startDestination = "home") {
        composable("home") {
            HomeScreen(
                onPlantClick = {
                    navController.navigate("plantDetail/${it.plantId}")
                },
                onPageChange = onPageChange,
                onAttached = onAttached
            )
        }
        composable(
            "plantDetail/{plantId}",
            arguments = listOf(navArgument("plantId") {
                type = NavType.StringType
            })
        ) {
            PlantDetailsScreen(
                onBackClick = { navController.navigateUp() },
                onShareClick = {
                    createShareIntent(activity, it)
                },
                onGalleryClick = {
                    navController.navigate("gallery/${it.name}")
                }
            )
        }
        composable(
            "gallery/{plantName}",
            arguments = listOf(navArgument("plantName") {
                type = NavType.StringType
            })
        ) {
            GalleryScreen(
                onPhotoClick = {
                    val uri = Uri.parse(it.user.attributionUrl)
                    val intent = Intent(Intent.ACTION_VIEW, uri)
                    activity.startActivity(intent)
                },
                onUpClick = {
                    navController.navigateUp()
                })
        }
    }
}
```

https://github.com/android/sunflower/blob/0596bab2dbd89b3b0ec91ef694d1f6ed6f222518/app/src/main/java/com/google/samples/apps/sunflower/compose/SunflowerApp.kt#L52-L102

サニタイズみたいなもので、少なくともユーザーからの信用できない値など何が来るか保証できないものはエンコードすることが必要そうな感じがしますね (ユーザーが信用できないという意味ではありません)。


## 解決版

したがってサンプルコードを素直にパラメーターを `Uri.encode` でエンコードしてから含めるようにしました:

```diff_kotlin
+import android.net.Uri

// 略…

fun MyAppNavHost(
    navController: NavHostController = rememberNavController(),
) {
    NavHost(
        navController = navController,
        startDestination = "home",
    ) {
         // 1. ホーム画面
         composable("home") {
             Home(
                 onEnter = { name ->
-                    navController.navigate("greet/$name")
+                    val safeName = Uri.encode(name)
+                    navController.navigate("greet/$safeName")
                 },
                 onFindTunes = { keyword ->
+                    val safeKeyword = Uri.encode(keyword)
                     if (keyword.isNullOrBlank()) {
                         navController.navigate("findMusic")
                     } else {
-                        navController.navigate("findMusic?keyword=$keyword")
+                        navController.navigate("findMusic?keyword=$safeKeyword")
                     }
                 },
             )
```

たぶん、これでいいはず。

これによりケース 1, ケース 2 はそれぞれ期待したように動作するようになりました:

![master-fixed.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/94682921-8a4a-3221-c7d3-e713d4fe2bc9.png)

![randb-fixed.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/2764dc4e-cb18-7e5f-2585-4fbd51669fee.png)

ここでは URI の組み立てを素朴に文字列に埋め込んでますが、
複雑なケースでは `Uri.Builder` とかを使うほうが楽に安全にできそうですね。


## Compose 版じゃない Navigation では？

ところで Compose 版じゃない Navigation ではどうだったのかというと…

https://developer.android.com/guide/navigation/navigation-pass-data

そもそも Safe Args をみなさん使っていたので、そっちがカバーしてくれていて意識していなかったかもしれません。どうかな:


```kotlin
binding.buttonFindMusic.setOnClickListener {
    val action = FirstFragmentDirections.actionFirstFragmentToFindMusicFragment(
        keyword = "R&B",
    )
    findNavController().navigate(action)
}
```

![R&B](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/359439/aaba6808-8f0f-d3c4-194e-ba09ea3ef7ad.png)

Safe Args ではこういう `Bundle` にパラメーターを入れるコードを生成しています:

```kotlin
public class FirstFragmentDirections private constructor() {
  // ...
  private data class ActionFirstFragmentToFindMusicFragment(
    public val keyword: String? = ""
  ) : NavDirections {
    public override val actionId: Int = R.id.action_FirstFragment_to_findMusicFragment

    public override val arguments: Bundle
      get() {
        val result = Bundle()
        result.putString("keyword", this.keyword)
        return result
      }
  }
  // ...
}
```

Jetpack Compose 版でも Safe Args 欲しいですよね。
とはいえ普通にシンプルにコードでルーティングを定義してるわけで、
いろいろ事情があるそうなのが想像できます。


## 追記: `navigation-compose:2.6.0`

`navigation-compose:2.6.0` で追加された `NavType#serializeAsValue` を使うと
中で `Uri#encode` してくれてるみたいな話題があり、
`NavType#parseValue` とうまく活用できると `String` に限らず
エンコード/デコードをこれに委譲できてよさそうなことを感じ取っています:

> Custom subclasses of `NavType` can now override `serializeAsValue` to serialize a value into a String, allowing both serialization and deserialization (via `parseValue`) to be entirely encapsulated in the `NavType` class. `StringType` now overrides this method to call `Uri.encode` on the given `String`.

https://developer.android.com/jetpack/androidx/releases/navigation#2.6.0

`StringType` だと URL-safe になるようにエンコードするなど、
型に応じたシリアライズをカプセル化できるようになったようすです:

> By default, I think this method should just call `toString()` on the class. The one exception could be `StringType`, which could `Uri.encode()` for you.

https://issuetracker.google.com/issues/247637434

面白いところでは `null` が `"null"` とシリアライズされるのも `parseValue` では考慮されていたりもします
(ホントに `null` を渡したい時はどうなのかな :thinking:):

https://android-review.googlesource.com/c/platform/frameworks/support/+/2244017/8/navigation/navigation-common/src/main/java/androidx/navigation/NavType.kt 

しかし、まだ試せていないので今後の動向を見守ります！

## 最後に

Web の URL だと考えるとエスケープするのは納得ですが、
こうして App 内の画面だと思うとその下にある実装の詳細が意識されず忘れてしまいそうです。必要に応じてエンコードするのを忘れないようにしたいですね。
みんな既にできてて僕だけが意識できてなかったという裸の王様現象でもありそう。