Jetpack Compose 版の Navigation で &
を含む文字列をパラメーターにうまく渡せなくてなぜ? というケースに遭遇したことはないでしょうか。
※ navigation-compose については以下の公式ガイドをごらんください:
具体的なサンプルをもとにこのケースを解説します。
これを書いたときは androidx.navigation:navigation-compose:2.5.3
でした)
サンプルコード
サンプル App では 以下の 3 つの画面があるとします:
- ホーム画面
- もてなし画面
- 名前を与えてよびかける画面 (なにそれですがそういうものがあると思ってください)
- 名前は必須のパラメーター
- オススメ音楽トップ 3 画面
- ジャンルを与えて曲を探す画面
- ジャンルはオプショナルなパラメーター
ガイドにある感じで navigation-compose を使い画面遷移を実装しましょう。
全体感
サンプルでこういう App ができたとします:
(サンプルなので地獄みたいな UI ですが気にしないでください)
ホーム画面
もてなし画面
名前を入力して 入室
ボタンを押すともてなし画面に遷移して
入力した名前が表示されます:
オススメ音楽トップ 3 画面
音楽ジャンルを入れて オススメを見る
ボタンを押すと
おすすめ曲表示画面に遷移して
キーワードに応じたいい感じの曲の結果が出るとします:
結果が出てるからこれでよさそうな感じがしますね。
確認ケース1 - 「R&B」
ところがこれだと R&B のオススメを紹介されたくても
"R&B" は "R" になります:
&B
が抜けていますね。ブルースはなくてリズムだけ。
確認ケース2 - 「GNU/Linux マスター mangano-ito」
また名前を 「GNU/Linux マスター mangano-ito」 にすると落ちます:
これは別に僕が全然マスターじゃないからとか
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=""
だと解釈されたということですね:
あらためてみてみると Web のリンクの URL にそのまま変数を入れてるみたいなもので、
Web ページで考えると容易に脆弱性になりうりそうで怖いしダメそうだねとなりますよね。
どうすればいいのか
Now in Android では Uri.encode(param)
しています:
fun NavController.navigateToTopic(topicId: String) {
val encodedId = Uri.encode(topicId)
this.navigate("topic_route/$encodedId")
}
まっとうな感じで URI なのだからエンコードしてセーフに渡すのでいいでしょうということに思えます。
一方 Android Sunflower はそのままわたしています:
@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 はそれぞれ期待したように動作するようになりました:
ここでは URI の組み立てを素朴に文字列に埋め込んでますが、
複雑なケースでは Uri.Builder
とかを使うほうが楽に安全にできそうですね。
Compose 版じゃない Navigation では?
ところで Compose 版じゃない Navigation ではどうだったのかというと…
そもそも Safe Args をみなさん使っていたので、そっちがカバーしてくれていて意識していなかったかもしれません。どうかな:
binding.buttonFindMusic.setOnClickListener {
val action = FirstFragmentDirections.actionFirstFragmentToFindMusicFragment(
keyword = "R&B",
)
findNavController().navigate(action)
}
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 overrideserializeAsValue
to serialize a value into a String, allowing both serialization and deserialization (viaparseValue
) to be entirely encapsulated in theNavType
class.StringType
now overrides this method to callUri.encode
on the givenString
.
StringType
だと URL-safe になるようにエンコードするなど、
型に応じたシリアライズをカプセル化できるようになったようすです:
By default, I think this method should just call
toString()
on the class. The one exception could beStringType
, which couldUri.encode()
for you.
面白いところでは null
が "null"
とシリアライズされるのも parseValue
では考慮されていたりもします
(ホントに null
を渡したい時はどうなのかな ):
しかし、まだ試せていないので今後の動向を見守ります!
最後に
Web の URL だと考えるとエスケープするのは納得ですが、
こうして App 内の画面だと思うとその下にある実装の詳細が意識されず忘れてしまいそうです。必要に応じてエンコードするのを忘れないようにしたいですね。
みんな既にできてて僕だけが意識できてなかったという裸の王様現象でもありそう。