Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ internal class WispProcessor(
) : SymbolProcessor {

private val factoryGenerator = RouteFactoryGenerator(logger)
private val registryGenerator = WispRegistryGenerator()

override fun process(resolver: Resolver): List<KSAnnotated> {
val symbols = resolver.getSymbolsWithAnnotation(WISP_ANNOTATION)
Expand Down Expand Up @@ -101,7 +102,7 @@ internal class WispProcessor(
routeInfos: List<RouteInfo>,
sourceFiles: List<KSFile>
) {
val fileSpec = WispRegistryGenerator.generate(routeInfos)
val fileSpec = registryGenerator.generate(routeInfos)
val dependencies = Dependencies(true, *sourceFiles.toTypedArray())
fileSpec.writeTo(codeGenerator, dependencies)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,64 +1,124 @@
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.ClassRouteInfo
import com.angrypodo.wisp.model.ObjectRouteInfo
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
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<RouteInfo>): 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))
.addFunction(buildGetRoutePatternFun(routes))
.build()

return FileSpec.builder(WispClassName.GENERATED_PACKAGE, registryName)
.addType(registryObject)
.build()
}

private fun buildFactoriesProperty(routes: List<RouteInfo>): 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)
private fun buildCreateRouteFun(factoriesProperty: PropertySpec): FunSpec {
return FunSpec.builder("createRoute")
.addModifiers(KModifier.OVERRIDE)
.addParameter("path", STRING)
.returns(ROUTE_FACTORY.copy(nullable = true))
.addStatement("return %N[path]", factoriesProperty)
.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()
}

val getPatternsFun = FunSpec.builder(GET_PATTERNS)
private fun buildGetRoutePatternFun(routes: List<RouteInfo>): FunSpec {
return FunSpec.builder("getRoutePattern")
.addModifiers(KModifier.OVERRIDE)
.returns(SET.parameterizedBy(STRING))
.addStatement("return %N.keys", factoriesProperty)
.addParameter("route", ANY)
.returns(STRING.copy(nullable = true))
.apply {
val whenBlock = CodeBlock.builder()
.beginControlFlow("return when (route)")
routes.forEach { routeInfo ->
val routePattern = buildRoutePatternString(routeInfo)
whenBlock.addStatement("is %T -> %S", routeInfo.routeClassName, routePattern)
}
whenBlock.addStatement("else -> null")
.endControlFlow()
addCode(whenBlock.build())
}
.build()
}

val registryObject = TypeSpec.objectBuilder(REGISTRY_NAME)
.addModifiers(KModifier.INTERNAL)
.addProperty(factoriesProperty)
.addFunction(getFactoryFun)
.build()
private fun buildRoutePatternString(routeInfo: RouteInfo): String {
return when (routeInfo) {
is ClassRouteInfo -> {
val pathParams = routeInfo.parameters
.filter { param ->
!param.isNullable && routeInfo.wispPath.contains(
"{${param.name}}"
)
}
.joinToString("") { "/{${it.name}}" }

return FileSpec.builder(GENERATED_PACKAGE, REGISTRY_NAME)
.addType(registryObject)
.build()
val queryParams = routeInfo.parameters
.filterNot { param ->
!param.isNullable && routeInfo.wispPath.contains(
"{${param.name}}"
)
}
.joinToString("&") { "${it.name}={${it.name}}" }

val canonical = routeInfo.routeClassName.canonicalName
val query = if (queryParams.isNotEmpty()) "?$queryParams" else ""

return "$canonical$pathParams$query"
}
is ObjectRouteInfo -> routeInfo.routeClassName.canonicalName
}
}
}
Original file line number Diff line number Diff line change
@@ -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, this.context, routes)
}
106 changes: 93 additions & 13 deletions wisp-runtime/src/main/java/com/angrypodo/wisp/runtime/Wisp.kt
Original file line number Diff line number Diff line change
@@ -1,34 +1,114 @@
package com.angrypodo.wisp.runtime

import android.content.Context
import android.net.Uri
import android.os.Bundle
import androidx.navigation.NavController
import androidx.navigation.NavDeepLinkBuilder
import androidx.navigation.NavDestination
import androidx.navigation.NavType

/**
* Wisp 라이브러리의 핵심 로직을 수행하고, 내비게이션 기능을 실행하는 클래스입니다.
* [WispRegistrySpec]과 [WispUriParser]를 주입받아 URI를 라우트 객체 리스트로 변환하고,
* [NavController]를 통해 실제 탐색을 수행합니다.
*
* 고급 사용자는 이 클래스를 직접 생성하여 DI 컨테이너로 관리할 수 있습니다.
* 대부분의 사용자는 [Wisp.initialize]와 [NavController.navigateTo] 확장 함수를 통해 Wisp를 간접적으로 사용합니다.
*/
class Wisp(
private val registry: WispRegistrySpec,
private val parser: WispUriParser = DefaultWispUriParser()
) {

/**
* URI를 분석하여 @Serializable 라우트 객체의 리스트로 변환합니다.
* @throws WispError.UnknownPath 등록되지 않은 경로가 포함된 경우
*/
fun resolveRoutes(uri: Uri): List<Any> {
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()
/**
* 주어진 라우트 객체 리스트를 사용하여 백스택을 새로 구성하고 탐색합니다.
* 백스택 생성을 위해 NavDeepLinkBuilder를 사용합니다.
*/
fun navigateTo(navController: NavController, context: Context, routes: List<Any>) {
if (routes.isEmpty()) return

try {
val builder = NavDeepLinkBuilder(context).setGraph(navController.graph)
routes.forEach { route ->
val routePattern = registry.getRoutePattern(route)
?: throw IllegalArgumentException(
"Route pattern not found for ${route::class.simpleName}"
)

for (pattern in allPatterns) {
val params = WispUriMatcher.match(inputUri, pattern)
val destination = navController.graph.findNode(routePattern)
?: throw IllegalArgumentException(
"Destination not found for route pattern: $routePattern"
)

builder.addDestination(destination.id, destination.buildArguments(route))
}
builder.createPendingIntent().send()
} catch (e: Exception) {
throw WispError.NavigationFailed(
reason = e::class.simpleName ?: "Unknown",
detail = e.message
)
}
}

if (params != null) {
val factory = registry.getRouteFactory(pattern)
?: throw WispError.UnknownPath(pattern)
/**
* Wisp의 기본 인스턴스를 제공하고 초기화하는 역할을 담당합니다.
*/
companion object {
private var instance: Wisp? = null
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서 경쟁 상태가 일어날.. 가능성은 없겠죠? 멀티 스레드 관련해서 잘 아는 편이 아니라서 여쭤봅니다!

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

일단 추후에 고민 해보겠습니다...지금대로면 크게 가능성은 없다고 생각해요!


return factory.create(params)
/**
* 대부분의 사용자를 위한 초기화 함수입니다.
* Application.onCreate()에서 KSP가 생성한 WispRegistry를 전달하여 호출합니다.
*/
fun initialize(registry: WispRegistrySpec) {
if (instance == null) {
instance = Wisp(registry)
}
}

throw WispError.UnknownPath(inputUri)
/**
* 라이브러리 내부에서 기본 인스턴스를 사용하기 위한 함수입니다.
* @throws IllegalStateException Wisp.initialize()가 먼저 호출되지 않은 경우
*/
internal fun getDefaultInstance(): Wisp {
return instance ?: throw IllegalStateException(
"Wisp.initialize() must be called first in your Application class."
)
}
}
}

/**
* NavDestination의 정보를 기반으로 route 객체로부터 Bundle을 생성합니다.
* 타입 이름을 비교하여, 여러 인자 중 @Serializable 객체 자신을 담는 인자를 정확히 찾아냅니다.
*/
@Suppress("UNCHECKED_CAST")
private fun NavDestination.buildArguments(route: Any): Bundle? {
val argumentEntry = arguments.entries.find { (_, arg) ->
arg.type.name == route::class.qualifiedName
}

if (argumentEntry == null) {
return if (arguments.isEmpty()) null else Bundle()
}

val argumentName = argumentEntry.key
val navType = argumentEntry.value.type as NavType<Any>

return Bundle().apply {
navType.put(this, argumentName, route)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) :
Expand All @@ -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")
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.angrypodo.wisp.runtime

interface WispRegistrySpec {
fun getRouteFactory(routePattern: String): RouteFactory?
fun getPatterns(): Set<String>
fun createRoute(path: String): Any?
fun getRoutePattern(route: Any): String?
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.angrypodo.wisp.runtime

internal object WispUriMatcher {
object WispUriMatcher {

/**
* 입력된 URI와 라우트 패턴을 비교하여 매칭 여부를 확인하고 파라미터를 추출합니다.
Expand Down