LoginSignup
3
2

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

Posted at

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

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

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

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

サンプルコード

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

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

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

全体感

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

ホーム画面

home.png

もてなし画面

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

name.png

greeting.png

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

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

anison.png

result.png

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

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

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

randb.png

result-randb.png

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

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

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

master.png

crash1

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

コード

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

@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

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

どうすればいいのか

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

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")
}

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

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

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()
                })
        }
    }
}

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

解決版

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

+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

randb-fixed.png

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

Compose 版じゃない Navigation では?

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

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

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

R&B

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

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.

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.

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

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

最後に

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

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2