瀏覽代碼

支持 DeepLink 功能

cc12458 9 月之前
父節點
當前提交
26f231cf83

+ 36 - 0
app/src/main/AndroidManifest.xml

@@ -21,6 +21,42 @@
 
         <category android:name="android.intent.category.LAUNCHER" />
       </intent-filter>
+
+      <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:host="launcher"
+            android:scheme="six-applet" />
+      </intent-filter>
+
+      <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:host="browser"
+            android:path="/kiosk"
+            android:scheme="six-applet" />
+      </intent-filter>
+
+      <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:host="browser"
+            android:path="/web"
+            android:scheme="six-applet" />
+      </intent-filter>
+
     </activity>
 
     <meta-data

+ 34 - 0
app/src/main/java/com/hzliuzhi/applet/container/MainActivity.kt

@@ -1,12 +1,14 @@
 package com.hzliuzhi.applet.container
 
 import android.annotation.SuppressLint
+import android.content.Intent
 import android.view.KeyEvent
 import android.widget.Toast
 import androidx.activity.compose.LocalActivity
 import androidx.activity.compose.setContent
 import androidx.compose.foundation.layout.padding
 import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
@@ -14,6 +16,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
 import androidx.navigation.NavHostController
 import androidx.navigation.compose.rememberNavController
 import com.hzliuzhi.applet.container.navigation.Host
+import com.hzliuzhi.applet.container.navigation.toRoute
+import com.hzliuzhi.applet.core.router.DeepLink
 import com.hzliuzhi.applet.core.shared.Message
 import com.hzliuzhi.applet.core.shared.Payload
 import com.hzliuzhi.applet.core.shared.SharedFlowHub
@@ -23,6 +27,12 @@ import com.hzliuzhi.applet.scanner.Scanner
 
 class MainActivity : AndroidActivity() {
   private var navController: NavHostController? = null
+  private var pendingDeepLink: String? = null
+
+  override fun onNewIntent(intent: Intent) {
+    super.onNewIntent(intent)
+    handleDeepLink(intent.data?.toString())
+  }
 
   @SuppressLint("RestrictedApi")
   override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -44,6 +54,13 @@ class MainActivity : AndroidActivity() {
         }
       }
 
+      LaunchedEffect(navController) {
+        pendingDeepLink?.takeIf { navController?.graph != null }?.also {
+          handleDeepLink(it)
+          pendingDeepLink = null
+        }
+      }
+
       val context = LocalContext.current
       val owner = LocalLifecycleOwner.current
       Scanner.getInstance(context).observe(owner) { result ->
@@ -52,6 +69,7 @@ class MainActivity : AndroidActivity() {
         } else {
           Toast.makeText(context, result.message, Toast.LENGTH_SHORT).show()
           when {
+            result.code.startsWith(DeepLink().scheme) -> runCatching { handleDeepLink(result.code).let { null } }.getOrNull()
             result.code.startsWith("six:") -> null
             else -> Payload.data(data = result).toJson()
           }
@@ -73,5 +91,21 @@ class MainActivity : AndroidActivity() {
         }
       }
     }
+    handleDeepLink(intent.data?.toString())
+  }
+
+  private fun handleDeepLink(link: String?) {
+    if (navController?.graph == null) {
+      pendingDeepLink = link
+    } else {
+      link?.toRoute()?.also {
+        navController?.apply {
+          navigate(it) {
+            popUpTo(graph.startDestinationId)
+            launchSingleTop = true
+          }
+        }
+      }
+    }
   }
 }

+ 3 - 2
app/src/main/java/com/hzliuzhi/applet/container/navigation/Host.kt

@@ -10,6 +10,7 @@ import androidx.navigation.compose.NavHost
 import androidx.navigation.compose.rememberNavController
 import com.hzliuzhi.applet.browser.navigation.browser
 import com.hzliuzhi.applet.container.R
+import com.hzliuzhi.applet.core.router.navRoute
 import com.hzliuzhi.applet.core.store.SettingStore
 
 @Composable
@@ -28,12 +29,12 @@ fun Host(
 }
 
 @Composable
-fun rememberRoute(): Any {
+fun rememberRoute(): String {
   val context = LocalContext.current.applicationContext
   return remember {
     runCatching {
       val screen = SettingStore.getInstance(context).screen ?: context.resources.getString(R.string.app_screen)
       screen.toRoute()
-    }.getOrNull() ?: AppRoute.Launcher
+    }.getOrNull() ?: navRoute(AppRoute.Launcher())
   }
 }

+ 11 - 9
app/src/main/java/com/hzliuzhi/applet/container/navigation/Route.kt

@@ -6,9 +6,10 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
 import androidx.navigation.NavController
 import androidx.navigation.NavGraphBuilder
-import androidx.navigation.compose.composable
 import com.hzliuzhi.applet.container.ui.HomeScreen
 import com.hzliuzhi.applet.container.ui.LauncherScreen
+import com.hzliuzhi.applet.core.router.DeepLink
+import com.hzliuzhi.applet.core.router.route
 import kotlinx.serialization.Serializable
 
 sealed class AppRoute {
@@ -16,24 +17,25 @@ sealed class AppRoute {
   data object Home : AppRoute()
 
   @Serializable
-  data object Launcher : AppRoute()
+  data class Launcher(val text: String? = "") : AppRoute()
 }
 
 fun NavGraphBuilder.app(navController: NavController) {
-  composable<AppRoute.Home> { _ ->
+  route<AppRoute.Home> {
     HomeScreen(
       modifier = Modifier.fillMaxSize()
     )
   }
-  composable<AppRoute.Launcher> { _ ->
-    var context = LocalContext.current
+  route<AppRoute.Launcher> { (route) ->
+    val context = LocalContext.current
     LauncherScreen(
       modifier = Modifier.fillMaxSize(),
-      start = { it ->
+      input = route.text,
+      start = {
         runCatching {
-          "browser/kiosk?url=$it".also { url ->
-            url.toRoute()?.also { navController.navigate(it) } ?: throw IllegalArgumentException("无效的路由")
-          }
+          val value = if (it.startsWith(DeepLink().scheme)) it else "browser/kiosk?url=$it"
+          value.toRoute()?.also { navController.navigate(it) } ?: throw IllegalArgumentException("无效的路由")
+          value
         }.onFailure {
           Toast.makeText(context, "跳转失败: ${it.message}", Toast.LENGTH_SHORT).show()
         }.getOrNull()

+ 20 - 8
app/src/main/java/com/hzliuzhi/applet/container/navigation/RouteExtra.kt

@@ -1,18 +1,30 @@
 package com.hzliuzhi.applet.container.navigation
 
+import android.util.Log
 import com.hzliuzhi.applet.browser.navigation.BrowserRoute
+import com.hzliuzhi.applet.core.router.DeepLink
+import com.hzliuzhi.applet.core.router.navRoute
 
-data class DeepLink(
-  val scheme: String = "six-applet://",
-)
 
-internal fun String?.toRoute(): Any? {
+internal fun String?.toRoute(): String? {
   val value = this?.replace(DeepLink().scheme, "") ?: ""
   return when {
-    value.startsWith("home") -> AppRoute.Home
-    value.startsWith("browser/update") -> BrowserRoute.Update
-    value.startsWith("browser/web?url=") -> BrowserRoute.Web().copy(url = value.substringAfter("browser/web?url="))
-    value.startsWith("browser/kiosk?url=") -> BrowserRoute.Kiosk().copy(url = value.substringAfter("browser/kiosk?url="))
+    value.startsWith("home") -> navRoute<AppRoute.Home>()
+    value.startsWith("launcher") -> {
+      val text = value.substringAfter("launcher?text=", "")
+      navRoute(AppRoute.Launcher(text = text))
+    }
+
+    value.startsWith("browser/update") -> navRoute<BrowserRoute.Update>()
+    value.startsWith("browser/web?url=") -> {
+      val url = value.substringAfter("browser/web?url=", "")
+      navRoute(BrowserRoute.Web(url = url))
+    }
+
+    value.startsWith("browser/kiosk?url=") -> {
+      val url = value.substringAfter("browser/kiosk?url=", "")
+      navRoute(BrowserRoute.Kiosk(url = url))
+    }
     else -> null
   }
 }

+ 2 - 1
app/src/main/java/com/hzliuzhi/applet/container/ui/LauncherScreen.kt

@@ -53,11 +53,12 @@ import com.hzliuzhi.applet.scanner.Scanner
 
 @Composable
 fun LauncherScreen(
+  input: String?,
   modifier: Modifier = Modifier,
   start: (String) -> String?,
 ) {
   val context = LocalContext.current
-  var text by remember { mutableStateOf("") }
+  var text by remember { mutableStateOf(input ?: "") }
   var storeEnabled by remember { mutableStateOf(true) }
 
   Column(

+ 2 - 0
core/build.gradle.kts

@@ -37,6 +37,7 @@ dependencies {
 
   implementation(libs.androidx.core.ktx)
   implementation(libs.androidx.appcompat)
+  implementation(libs.androidx.navigation.compose)
   implementation(platform(libs.androidx.compose.bom))
   implementation(libs.androidx.ui)
   implementation(libs.androidx.ui.graphics)
@@ -46,4 +47,5 @@ dependencies {
   androidTestImplementation(libs.androidx.espresso.core)
 
   implementation(libs.gson)
+  implementation(libs.kotlin.reflect)
 }

+ 5 - 0
core/src/main/java/com/hzliuzhi/applet/core/router/DeepLink.kt

@@ -0,0 +1,5 @@
+package com.hzliuzhi.applet.core.router
+
+data class DeepLink(
+  val scheme: String = "six-applet://",
+)

+ 194 - 0
core/src/main/java/com/hzliuzhi/applet/core/router/route.kt

@@ -0,0 +1,194 @@
+package com.hzliuzhi.applet.core.router
+
+import android.os.Parcelable
+import androidx.compose.animation.AnimatedContentScope
+import androidx.compose.runtime.Composable
+import androidx.navigation.NamedNavArgument
+import androidx.navigation.NavBackStackEntry
+import androidx.navigation.NavDeepLink
+import androidx.navigation.NavGraphBuilder
+import androidx.navigation.NavHostController
+import androidx.navigation.NavType
+import androidx.navigation.compose.composable
+import androidx.navigation.navArgument
+import androidx.navigation.navDeepLink
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.reflect.KProperty1
+import kotlin.reflect.KType
+import kotlin.reflect.full.createInstance
+import kotlin.reflect.full.memberProperties
+import kotlin.reflect.full.primaryConstructor
+
+
+// region 反射缓存
+val constructorCache = ConcurrentHashMap<Class<*>, kotlin.reflect.KFunction<*>>()
+val memberPropertiesCache = ConcurrentHashMap<Class<*>, Collection<KProperty1<out Any, *>>>()
+
+inline fun <reified T : Any> getPrimaryConstructor(): kotlin.reflect.KFunction<*>? {
+  val key = T::class.java
+  constructorCache[key]?.let { return it }
+  val ctor = T::class.primaryConstructor
+  if (ctor != null) {
+    constructorCache[key] = ctor
+  }
+  return ctor
+}
+
+@Suppress("UNCHECKED_CAST")
+inline fun <reified T : Any> getMemberProperties(): Collection<KProperty1<T, *>> {
+  val key = T::class.java
+  memberPropertiesCache[key]?.let { return it as Collection<KProperty1<T, *>> }
+  val props = T::class.memberProperties as Collection<KProperty1<T, *>>
+  memberPropertiesCache[key] = props
+  return props
+}
+// endregion
+
+/**
+ * 根据Kotlin类型自动推断NavType,支持基础类型。
+ * @param type Kotlin反射类型
+ * @return 对应的NavType
+ */
+fun navTypeFor(type: KType): NavType<*> = when (val classifier = type.classifier) {
+  String::class -> NavType.StringType
+  Int::class -> NavType.IntType
+  Long::class -> NavType.LongType
+  Float::class -> NavType.FloatType
+  Boolean::class -> NavType.BoolType
+  else -> NavType.StringType
+}
+
+/**
+ * 自动生成navArgument,支持部分字段有默认参数。
+ * 只为有默认值的参数设置defaultValue,必填参数不设置defaultValue。
+ * @return 包含所有参数的NamedNavArgument列表
+ */
+inline fun <reified T : Any> navArgumentsWithDefault(): List<NamedNavArgument> = T::class.run {
+  val params = getPrimaryConstructor<T>()?.parameters.orEmpty()
+  // 只尝试获取有默认值的参数的默认实例
+  val defaultInstance = try {
+    if (params.all { it.isOptional || it.isVararg }) createInstance() else null
+  } catch (e: Exception) {
+    null
+  }
+  val memberProps = getMemberProperties<T>()
+  params.map { param ->
+    val paramName = param.name!!
+    val type = navTypeFor(param.type)
+    // 只为有默认值的参数设置 defaultValue
+    val hasDefault = param.isOptional || (defaultInstance != null && memberProps.find { it.name == paramName }?.getter?.call(defaultInstance) != null)
+    val defaultValue = if (hasDefault && defaultInstance != null) {
+      memberProps.find { it.name == paramName }?.getter?.call(defaultInstance)
+    } else null
+    navArgument(paramName) {
+      this.type = type
+      defaultValue?.let { value ->
+        @Suppress("IMPLICIT_CAST_TO_ANY")
+        when (type) {
+          NavType.StringType -> value as? String
+          NavType.IntType -> value as? Int
+          NavType.LongType -> value as? Long
+          NavType.FloatType -> value as? Float
+          NavType.BoolType -> value as? Boolean
+          is NavType.EnumType<*> -> value.toString()
+          is NavType.ParcelableType<*> -> value as? Parcelable
+          else -> null
+        }?.let { this.defaultValue = it }
+      }
+    }
+  }
+}
+
+/**
+ * 自动生成路由字符串,如 user?id={id}&name={name}
+ * @return 路由字符串
+ */
+inline fun <reified T : Any> navRoute() = T::class.run {
+  val name = simpleName?.replaceFirstChar { it.lowercase() } ?: "unknown"
+  val params = getPrimaryConstructor<T>()?.parameters.orEmpty()
+  params.takeIf { it.isNotEmpty() }
+    ?.joinToString("&", prefix = "$name?") { "${it.name}={${it.name}}" }
+    ?: name
+}
+
+inline fun <reified T : Any> navRoute(instance: T): String = T::class.run {
+  val name = simpleName?.replaceFirstChar { it.lowercase() } ?: "unknown"
+  val memberProps = getMemberProperties<T>()
+  val params = memberProps.mapNotNull { prop ->
+    prop.get(instance)?.let { "${prop.name}=$it" }
+  }
+  params.takeIf { it.isNotEmpty() }
+    ?.joinToString("&", prefix = "$name?")
+    ?: name
+}
+
+/**
+ * NavBackStackEntry自动转为目标data class,支持类型和错误提示
+ * @throws IllegalArgumentException 参数解析或构造失败时抛出
+ * @return 目标类型实例
+ */
+inline fun <reified T : Any> NavBackStackEntry.toRoute(): T = T::class.run {
+  val name = simpleName?.replaceFirstChar { it.lowercase() } ?: "unknown"
+  // 处理 object 单例类型
+  T::class.objectInstance?.let { return it as T }
+  val params = getPrimaryConstructor<T>()?.parameters.orEmpty()
+  val args = params.associateWith { param ->
+    val paramName = param.name!!
+    try {
+      when (param.type.classifier) {
+        String::class -> arguments?.getString(paramName)
+        Int::class -> arguments?.getInt(paramName)
+        Long::class -> arguments?.getLong(paramName)
+        Float::class -> arguments?.getFloat(paramName)
+        Boolean::class -> arguments?.getBoolean(paramName)
+        else -> arguments?.getString(paramName)
+      }
+    } catch (e: Exception) {
+      throw IllegalArgumentException("参数[$paramName] 解析失败: ${e.message}")
+    }
+  }
+  try {
+    getPrimaryConstructor<T>()!!.callBy(args) as T
+  } catch (e: Exception) {
+    throw IllegalArgumentException("$name 构造失败: ${e.message}")
+  }
+}
+
+/**
+ * NavGraphBuilder.route扩展,自动注册路由
+ * @param content 页面内容Composable
+ * @param deepLinks 可选的DeepLink列表
+ */
+inline fun <reified T : Any> NavGraphBuilder.route(
+  deepLinks: List<NavDeepLink> = emptyList(),
+  noinline content: @Composable AnimatedContentScope.(Pair<T, NavBackStackEntry>) -> Unit,
+) = T::class.run {
+  val name = simpleName?.replaceFirstChar { it.lowercase() } ?: "unknown"
+  composable(
+    route = navRoute<T>(),
+    arguments = navArgumentsWithDefault<T>(),
+    deepLinks = deepLinks + listOf(navDeepLink { uriPattern = "${DeepLink().scheme}${navRoute<T>()}" })
+  ) {
+    runCatching { content(Pair(it.toRoute(), it)) }.onFailure {
+      val message = "$name 路由参数解析或页面构建失败: ${it.message}"
+      android.util.Log.e("log:route", message, it)
+      throw IllegalStateException(message)
+    }.getOrNull()
+  }
+}
+
+/**
+ * NavController.router扩展,自动跳转并带参数
+ * @param route 路由参数对象
+ * @throws IllegalStateException 跳转失败时抛出
+ */
+inline fun <reified T : Any> NavHostController.router(route: T) = T::class.run {
+  runCatching {
+    navigate(navRoute(route))
+  }.onFailure { it ->
+    val name = simpleName?.replaceFirstChar { it.lowercase() } ?: "unknown"
+    val message = "$name 跳转失败: ${it.message}"
+    android.util.Log.e("log:router", message, it)
+    throw IllegalStateException(message)
+  }.getOrNull()
+}

+ 2 - 0
gradle/libs.versions.toml

@@ -1,6 +1,7 @@
 [versions]
 agp = "8.9.3"
 kotlin = "2.1.21"
+kotlinReflect = "2.1.21"
 serializationJson = "1.7.3"
 coreKtx = "1.16.0"
 junit = "4.13.2"
@@ -18,6 +19,7 @@ okhttp = "4.12.0"
 webkit = "1.14.0"
 
 [libraries]
+kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" }
 androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
 androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
 androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }

+ 5 - 8
library/browser/src/main/java/com/hzliuzhi/applet/browser/navigation/Route.kt

@@ -4,35 +4,32 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.ui.Modifier
 import androidx.navigation.NavController
 import androidx.navigation.NavGraphBuilder
-import androidx.navigation.compose.composable
-import androidx.navigation.toRoute
 import com.hzliuzhi.applet.browser.ui.KioskScreen
 import com.hzliuzhi.applet.browser.ui.UpdateScreen
 import com.hzliuzhi.applet.browser.ui.WebScreen
 import com.hzliuzhi.applet.browser.webview.WebContent
+import com.hzliuzhi.applet.core.router.route
 import kotlinx.serialization.Serializable
 
 sealed class BrowserRoute {
-
   @Serializable
   data class Web(val url: String? = "") : BrowserRoute()
 
   @Serializable
   data class Kiosk(val url: String? = "") : BrowserRoute()
+
   @Serializable
   data object Update : BrowserRoute()
 }
 
 fun NavGraphBuilder.browser(navController: NavController) {
-  composable<BrowserRoute.Web> { backStackEntry ->
-    val route = backStackEntry.toRoute<BrowserRoute.Web>()
+  route<BrowserRoute.Web> { (route) ->
     WebScreen(url = route.url ?: "localhost")
   }
-  composable<BrowserRoute.Kiosk> { backStackEntry ->
-    val route = backStackEntry.toRoute<BrowserRoute.Kiosk>()
+  route<BrowserRoute.Kiosk> { (route) ->
     KioskScreen(content = WebContent.Url(url = route.url ?: "localhost"))
   }
-  composable<BrowserRoute.Update> {
+  route<BrowserRoute.Update> {
     UpdateScreen(modifier = Modifier.fillMaxSize())
   }
 }