Skip to content
8 changes: 8 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<!-- Deep Link 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:scheme="app" android:host="wisp" />
</intent-filter>
</activity>
</application>

Expand Down
11 changes: 9 additions & 2 deletions app/src/main/java/com/angrypodo/wisp/navigation/Routes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ import kotlinx.serialization.Serializable
data object Home

@Serializable
@Wisp("product/{productId}")
data class ProductDetail(val productId: String)
@Wisp("product")
data class ProductDetail(
val productId: Int,
val showReviews: Boolean = false
)

@Serializable
@Wisp("settings")
data object Settings

@Serializable
@Wisp("user")
data class UserRoute(val userId: Int)

@Serializable
@Wisp("splash")
data object Splash
19 changes: 16 additions & 3 deletions app/src/main/java/com/angrypodo/wisp/ui/main/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ import com.angrypodo.wisp.navigation.Home
import com.angrypodo.wisp.navigation.ProductDetail
import com.angrypodo.wisp.navigation.Settings
import com.angrypodo.wisp.navigation.Splash
import com.angrypodo.wisp.navigation.UserRoute
import com.angrypodo.wisp.runtime.navigateTo
import com.angrypodo.wisp.ui.screens.HomeScreen
import com.angrypodo.wisp.ui.screens.ProductDetailScreen
import com.angrypodo.wisp.ui.screens.SettingsScreen
import com.angrypodo.wisp.ui.screens.SplashScreen
import com.angrypodo.wisp.ui.screens.UserScreen
import com.angrypodo.wisp.ui.theme.WispTheme

class MainActivity : ComponentActivity() {
Expand Down Expand Up @@ -57,18 +59,29 @@ private fun WispNavHost(deepLinkUri: Uri?) {
composable<Home> {
HomeScreen(
onNavigateToProduct = {
val uri = "app://wisp?stack=product/123|settings".toUri()
val uri = "app://wisp/product/settings?productId=123&showReviews=true".toUri()
navController.navigateTo(uri)
},
onNavigateToSettings = {
val uri = "app://wisp?stack=settings".toUri()
val uri = "app://wisp/settings".toUri()
navController.navigateTo(uri)
},
onNavigateToMultiStack = {
val uri = "app://wisp/product/user?productId=123&userId=99".toUri()
navController.navigateTo(uri)
}
)
}
composable<ProductDetail> { backStackEntry ->
val productDetail: ProductDetail = backStackEntry.toRoute()
ProductDetailScreen(productId = productDetail.productId)
ProductDetailScreen(
productId = productDetail.productId,
showReviews = productDetail.showReviews
)
}
composable<UserRoute> { backStackEntry ->
val userRoute: UserRoute = backStackEntry.toRoute()
UserScreen(userId = userRoute.userId)
}
composable<Settings> {
SettingsScreen()
Expand Down
36 changes: 28 additions & 8 deletions app/src/main/java/com/angrypodo/wisp/ui/screens/SampleScreens.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ import com.angrypodo.wisp.runtime.Wisp
import kotlinx.coroutines.delay

@Composable
fun HomeScreen(onNavigateToProduct: () -> Unit, onNavigateToSettings: () -> Unit) {
fun HomeScreen(
onNavigateToProduct: () -> Unit,
onNavigateToSettings: () -> Unit,
onNavigateToMultiStack: () -> Unit
) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
Expand All @@ -29,17 +33,21 @@ fun HomeScreen(onNavigateToProduct: () -> Unit, onNavigateToSettings: () -> Unit
Text(text = "Home Screen", fontSize = 24.sp)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onNavigateToProduct) {
Text(text = "Go to Product 123 -> Settings (Deep Link)")
Text(text = "Go to Product 123 -> Settings")
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onNavigateToSettings) {
Text(text = "Go to Settings (Single Deep Link)")
Text(text = "Go to Settings")
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onNavigateToMultiStack) {
Text(text = "Product(123) -> User(99)")
}
}
}

@Composable
fun ProductDetailScreen(productId: String) {
fun ProductDetailScreen(productId: Int, showReviews: Boolean) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
Expand All @@ -48,6 +56,7 @@ fun ProductDetailScreen(productId: String) {
Text(text = "Product Detail Screen", fontSize = 24.sp)
Spacer(modifier = Modifier.height(16.dp))
Text(text = "Product ID: $productId", fontSize = 20.sp)
Text(text = "Show Reviews: $showReviews", fontSize = 16.sp)
}
}

Expand All @@ -62,6 +71,19 @@ fun SettingsScreen() {
}
}

@Composable
fun UserScreen(userId: Int) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "User Screen", fontSize = 24.sp)
Spacer(modifier = Modifier.height(16.dp))
Text(text = "User ID: $userId", fontSize = 20.sp)
}
}

@Composable
fun SplashScreen(
navController: NavController,
Expand All @@ -83,10 +105,8 @@ fun SplashScreen(
}
} else {
// Deep link found.
// The first route is "splash", which we are already on.
// We navigate to the rest of the stack.
val routesToNavigate = routes.drop(1)
wisp.navigateTo(navController, routesToNavigate)
// Navigate to the parsed stack.
wisp.navigateTo(navController, routes)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ internal class WispRegistryGenerator {

val factoriesProperty = buildFactoriesProperty(routes)

val registryObject = TypeSpec.objectBuilder(registryClassName)
val registryObject = TypeSpec.classBuilder(registryClassName)
.addSuperinterface(WispClassName.WISP_MODULE_REGISTRY) // Changed interface
.addModifiers(KModifier.PUBLIC)
.addProperty(factoriesProperty)
Expand Down
30 changes: 23 additions & 7 deletions wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,30 @@ class Wisp(
*/
fun resolveRoutes(uri: Uri): List<Any> {
val paths = parser.parse(uri)
val queryParams = getQueryParams(uri)

return paths.map { path ->
matchAndCreate(path) ?: throw WispError.UnknownPath(path)
matchAndCreate(path, queryParams) ?: throw WispError.UnknownPath(path)
}
}

private fun getQueryParams(uri: Uri): Map<String, String> {
val params = mutableMapOf<String, String>()
uri.queryParameterNames.forEach { key ->
uri.getQueryParameter(key)?.let { value ->
params[key] = value
}
}
return params
}

private fun matchAndCreate(path: String): Any? {
private fun matchAndCreate(path: String, queryParams: Map<String, String>): Any? {
for ((pattern, factory) in mergedRoutes) {
val params = WispUriMatcher.match(path, pattern)
if (params != null) {
return factory.create(params)
val pathVariables = WispUriMatcher.match(path, pattern)
if (pathVariables != null) {
// Path Variable과 Query Parameter 병합 (Query Param 우선순위 낮음)
val combinedParams = queryParams + pathVariables
return factory.create(combinedParams)
}
}
return null
Expand Down Expand Up @@ -71,7 +85,9 @@ class Wisp(

@JvmStatic
@Synchronized
fun initialize() {
fun initialize(
parser: WispUriParser = DefaultWispUriParser()
) {
if (instance == null) {
val aggregatedRoutes = mutableMapOf<String, RouteFactory>()
val loader = ServiceLoader.load(WispModuleRegistry::class.java)
Expand All @@ -80,7 +96,7 @@ class Wisp(
aggregatedRoutes.putAll(registry.getRoutes())
}

instance = Wisp(aggregatedRoutes)
instance = Wisp(aggregatedRoutes, parser)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,28 @@
package com.angrypodo.wisp.runtime.parser

import android.net.Uri
import com.angrypodo.wisp.runtime.WispError
import java.net.URLDecoder
import java.nio.charset.StandardCharsets

private const val STACK = "stack"
/**
* 기본 URI 파서 구현체입니다.
* URI의 경로(Path)를 지정된 구분자([delimiter])로 분리하여 라우트 경로 리스트를 생성합니다.
*
* 예: "myapp://home/product" (delimiter="/") -> ["home", "product"]
*/
class DefaultWispUriParser(
private val delimiter: String = "/"
) : WispUriParser {

class DefaultWispUriParser : WispUriParser {
override fun parse(uri: Uri): List<String> {
val encodedStack = uri.getQueryParameter(STACK)
val path = uri.path ?: return emptyList()

if (encodedStack.isNullOrBlank()) {
throw WispError.ParsingFailed(uri.toString(), "Missing 'stack' query parameter")
// 경로가 구분자로 시작하면 제거 (예: "/home" -> "home")
val trimmedPath = if (path.startsWith(delimiter)) {
path.substring(delimiter.length)
} else {
path
}

return try {
val decodedStack = URLDecoder.decode(encodedStack, StandardCharsets.UTF_8.name())

decodedStack.split("|").filter { it.isNotBlank() }
} catch (e: Exception) {
throw WispError.ParsingFailed(uri.toString(), e.message ?: "Unknown decoding error")
}
return trimmedPath.split(delimiter)
.filter { it.isNotEmpty() }
}
}
Loading