Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
@@ -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?
}
Loading