diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4ee64bd..3229a5d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,6 +5,7 @@ plugins { alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) + alias(libs.plugins.kotlin.serialization) } android { @@ -68,6 +69,7 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.navigation.testing) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3bf5224..5c69935 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -53,6 +53,7 @@ androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-toolin androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "composeNavigation" } +androidx-navigation-testing = { group = "androidx.navigation", name = "navigation-testing", version.ref = "composeNavigation" } # Test junit-bom = { group = "org.junit", name = "junit-bom", version.ref = "junit" } diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/WispClassName.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/WispClassName.kt index 2d0ceae..18b0d50 100644 --- a/wisp-processor/src/main/java/com/angrypodo/wisp/WispClassName.kt +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/WispClassName.kt @@ -7,7 +7,10 @@ internal object WispClassName { const val GENERATED_PACKAGE = "com.angrypodo.wisp.generated" val ROUTE_FACTORY = ClassName(RUNTIME_PACKAGE, "RouteFactory") + val WISP_REGISTRY_SPEC = ClassName(RUNTIME_PACKAGE, "WispRegistrySpec") + val WISP_URI_MATCHER = ClassName(RUNTIME_PACKAGE, "WispUriMatcher") + val UNKNOWN_PATH_ERROR = ClassName(RUNTIME_PACKAGE, "WispError", "UnknownPath") val MISSING_PARAMETER_ERROR = ClassName(RUNTIME_PACKAGE, "WispError", "MissingParameter") val INVALID_PARAMETER_ERROR = ClassName(RUNTIME_PACKAGE, "WispError", "InvalidParameter") } diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt index 0bc4cc5..f5922c4 100644 --- a/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/WispProcessor.kt @@ -26,6 +26,7 @@ internal class WispProcessor( ) : SymbolProcessor { private val factoryGenerator = RouteFactoryGenerator(logger) + private val registryGenerator = WispRegistryGenerator() override fun process(resolver: Resolver): List { val symbols = resolver.getSymbolsWithAnnotation(WISP_ANNOTATION) @@ -101,7 +102,7 @@ internal class WispProcessor( routeInfos: List, sourceFiles: List ) { - val fileSpec = WispRegistryGenerator.generate(routeInfos) + val fileSpec = registryGenerator.generate(routeInfos) val dependencies = Dependencies(true, *sourceFiles.toTypedArray()) fileSpec.writeTo(codeGenerator, dependencies) } diff --git a/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt index 5d57587..b947863 100644 --- a/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt +++ b/wisp-processor/src/main/java/com/angrypodo/wisp/generator/WispRegistryGenerator.kt @@ -1,8 +1,8 @@ package com.angrypodo.wisp.generator -import com.angrypodo.wisp.WispClassName.GENERATED_PACKAGE -import com.angrypodo.wisp.WispClassName.ROUTE_FACTORY +import com.angrypodo.wisp.WispClassName import com.angrypodo.wisp.model.RouteInfo +import com.squareup.kotlinpoet.ANY import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec @@ -10,55 +10,65 @@ import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.MAP import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.PropertySpec -import com.squareup.kotlinpoet.SET import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeSpec -internal object WispRegistryGenerator { - private const val REGISTRY_NAME = "WispRegistry" - private const val FACTORIES_PROPERTY_NAME = "factories" - private const val GET_FACTORY_FUN_NAME = "getRouteFactory" - private const val GET_PATTERNS = "getPatterns" +internal class WispRegistryGenerator { + + private val registryName = "WispRegistry" + private val factoriesPropertyName = "factories" fun generate(routes: List): FileSpec { - val mapType = MAP.parameterizedBy(STRING, ROUTE_FACTORY) + val factoriesProperty = buildFactoriesProperty(routes) + + val registryObject = TypeSpec.objectBuilder(registryName) + .addSuperinterface(WispClassName.WISP_REGISTRY_SPEC) + .addModifiers(KModifier.PUBLIC) + .addProperty(factoriesProperty) + .addFunction(buildCreateRouteFun(factoriesProperty)) + .build() + return FileSpec.builder(WispClassName.GENERATED_PACKAGE, registryName) + .addType(registryObject) + .build() + } + + private fun buildFactoriesProperty(routes: List): PropertySpec { + val mapType = MAP.parameterizedBy(STRING, WispClassName.ROUTE_FACTORY) val initializerBlock = CodeBlock.builder() .add("mapOf(\n") .indent() - routes.forEach { route -> initializerBlock.add("%S to %T,\n", route.wispPath, route.factoryClassName) } - initializerBlock.unindent().add(")") - val factoriesProperty = PropertySpec.builder(FACTORIES_PROPERTY_NAME, mapType) + return PropertySpec.builder(factoriesPropertyName, mapType) .addModifiers(KModifier.PRIVATE) .initializer(initializerBlock.build()) .build() + } - val getFactoryFun = FunSpec.builder(GET_FACTORY_FUN_NAME) - .addModifiers(KModifier.INTERNAL) - .addParameter("path", STRING) - .returns(ROUTE_FACTORY.copy(nullable = true)) - .addStatement("return %N[path]", factoriesProperty) - .build() - - val getPatternsFun = FunSpec.builder(GET_PATTERNS) + private fun buildCreateRouteFun(factoriesProperty: PropertySpec): FunSpec { + return FunSpec.builder("createRoute") .addModifiers(KModifier.OVERRIDE) - .returns(SET.parameterizedBy(STRING)) - .addStatement("return %N.keys", factoriesProperty) - .build() - - val registryObject = TypeSpec.objectBuilder(REGISTRY_NAME) - .addModifiers(KModifier.INTERNAL) - .addProperty(factoriesProperty) - .addFunction(getFactoryFun) - .build() - - return FileSpec.builder(GENERATED_PACKAGE, REGISTRY_NAME) - .addType(registryObject) + .addParameter("path", STRING) + .returns(ANY.copy(nullable = true)) + .addCode( + CodeBlock.builder() + .beginControlFlow("for (pattern in %N.keys)", factoriesProperty) + .addStatement( + "val params = %T.match(path, pattern)", + WispClassName.WISP_URI_MATCHER + ) + .beginControlFlow("if (params != null)") + .addStatement("val factory = %N[pattern]", factoriesProperty) + .addStatement("return factory?.create(params)") + .endControlFlow() + .endControlFlow() + .addStatement("return null") + .build() + ) .build() } } diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/NavControllerWisp.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/NavControllerWisp.kt new file mode 100644 index 0000000..1160d1f --- /dev/null +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/NavControllerWisp.kt @@ -0,0 +1,20 @@ +package com.angrypodo.wisp.runtime + +import android.net.Uri +import androidx.navigation.NavController + +/** + * URI를 분석하고 즉시 백스택을 새로 구성하여 탐색하는 최종 사용자용 API입니다. + * 내부적으로 `Wisp.getDefaultInstance()`를 호출하여 모든 작업을 위임합니다. + * + * @param uri 딥링크 URI + * @throws WispError.ParsingFailed URI 파싱에 실패한 경우 + * @throws WispError.UnknownPath `WispRegistry`에 등록되지 않은 경로가 포함된 경우 + * @throws WispError.NavigationFailed 내비게이션 실행에 실패한 경우 + * @throws IllegalStateException `Wisp.initialize()`가 먼저 호출되지 않은 경우 + */ +fun NavController.navigateTo(uri: Uri) { + val wisp = Wisp.getDefaultInstance() + val routes = wisp.resolveRoutes(uri) + wisp.navigateTo(this, routes) +} diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt index 68f00b7..20eb975 100644 --- a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt @@ -1,34 +1,68 @@ package com.angrypodo.wisp.runtime import android.net.Uri +import androidx.navigation.NavController +import androidx.navigation.NavGraph.Companion.findStartDestination +/** + * Wisp 라이브러리의 핵심 로직을 수행하고, 내비게이션 기능을 실행하는 클래스입니다. + */ class Wisp( private val registry: WispRegistrySpec, private val parser: WispUriParser = DefaultWispUriParser() ) { + /** + * URI를 분석하여 @Serializable 라우트 객체의 리스트로 변환합니다. + * @throws WispError.UnknownPath 등록되지 않은 경로가 포함된 경우 + */ fun resolveRoutes(uri: Uri): List { - val inputUris = parser.parse(uri) - - return inputUris.map { inputUri -> - createRouteObject(inputUri) + val paths = parser.parse(uri) + return paths.map { path -> + registry.createRoute(path) ?: throw WispError.UnknownPath(path) } } - private fun createRouteObject(inputUri: String): Any { - val allPatterns = registry.getPatterns() + /** + * 주어진 라우트 객체 리스트를 사용하여 백스택을 새로 구성하고 탐색합니다. + * NavController.navigate를 순차적으로 호출하여 백스택을 구성합니다. + */ + fun navigateTo(navController: NavController, routes: List) { + if (routes.isEmpty()) return - for (pattern in allPatterns) { - val params = WispUriMatcher.match(inputUri, pattern) + try { + val firstRoute = routes.first() + navController.navigate(firstRoute) { + popUpTo(navController.graph.findStartDestination().id) { + inclusive = true + } + launchSingleTop = true + } - if (params != null) { - val factory = registry.getRouteFactory(pattern) - ?: throw WispError.UnknownPath(pattern) + routes.drop(1).forEach { route -> + navController.navigate(route) + } + } catch (e: Exception) { + throw WispError.NavigationFailed( + reason = e::class.simpleName ?: "Unknown", + detail = e.message + ) + } + } + + companion object { + private var instance: Wisp? = null - return factory.create(params) + fun initialize(registry: WispRegistrySpec) { + if (instance == null) { + instance = Wisp(registry) } } - throw WispError.UnknownPath(inputUri) + internal fun getDefaultInstance(): Wisp { + return instance ?: throw IllegalStateException( + "Wisp.initialize() must be called first in your Application class." + ) + } } } diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt index ba0d563..a553e9d 100644 --- a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispError.kt @@ -2,7 +2,6 @@ package com.angrypodo.wisp.runtime /** * Wisp 라이브러리에서 발생하는 런타임 에러를 정의하는 Sealed Class 입니다. - * Issue #3에서 필요한 최소한의 에러 타입만 우선 정의합니다. */ sealed class WispError(override val message: String) : Exception(message) { class MissingParameter(path: String, paramName: String) : @@ -16,4 +15,7 @@ sealed class WispError(override val message: String) : Exception(message) { class UnknownPath(path: String) : WispError("The path \"$path\" is not registered with any @Wisp annotation.") + + class NavigationFailed(reason: String, detail: String?) : + WispError("Navigation failed: $reason. Detail: $detail") } diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispRegistrySpec.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispRegistrySpec.kt index b881364..d97814a 100644 --- a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispRegistrySpec.kt +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispRegistrySpec.kt @@ -1,6 +1,5 @@ package com.angrypodo.wisp.runtime interface WispRegistrySpec { - fun getRouteFactory(routePattern: String): RouteFactory? - fun getPatterns(): Set + fun createRoute(path: String): Any? } diff --git a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriMatcher.kt b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriMatcher.kt index 3a4e63b..7922af6 100644 --- a/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriMatcher.kt +++ b/wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/WispUriMatcher.kt @@ -1,6 +1,6 @@ package com.angrypodo.wisp.runtime -internal object WispUriMatcher { +object WispUriMatcher { /** * 입력된 URI와 라우트 패턴을 비교하여 매칭 여부를 확인하고 파라미터를 추출합니다.