translation: Read in Korean (νκ΅μ΄)
Wisp is a type-safe, server-driven deep link library for Jetpack Compose.
It allows you to dynamically build your navigation backstack from a single, standard URI,
overcoming the static backstack limitations of the navigation-compose library.
Standard deep links in Jetpack Compose often lead to predefined, static backstacks. It's challenging to implement scenarios where a server needs to dictate a dynamic user journey on the fly (e.g., Product Screen -> Coupon Screen -> Checkout Screen).
Wisp automates this process by building the entire backstack from the URI's path segments. It uses annotation processing (KSP) to generate the necessary boilerplate, allowing you to focus solely on defining your routes.
Standard Deep Links (navDeepLink) |
Wisp | |
|---|---|---|
| What a URI maps to | A single destination | The entire backstack |
| Backstack shape | Synthesized from your nested graph structure β fixed at compile time | Built from the URI's path segments β decided by the server at runtime |
| Changing a user journey | Restructure the nav graph β app release required | Change the URI the server sends β no release |
| Setup per screen | deepLinks = listOf(navDeepLink<...>(...)) on each destination + graph design |
One @Wisp("path") annotation |
| Unregistered link | Crash or silent fallback you must hand-roll | WispResult.Failure + onError callback |
With standard deep links, app://shop/checkout can only ever open Checkout on top of the hierarchy you hard-coded. With Wisp, the server can send app://wisp/product/123/coupon/42/checkout today and app://wisp/checkout tomorrow β the journey is data, not code.
- Single-Activity Architecture: Wisp is designed for a Single-Activity Architecture and does not support navigating between different Activities.
- Jetpack Navigation & Type-Safety: The library is exclusively designed for the type-safe navigation paradigm of Jetpack Navigation Compose. It requires a
NavControllerand does not support traditional string-based routes. - Multi-Module Support: Wisp fully supports multi-module projects using a
ServiceLoaderpattern. - Minimum Requirements:
- minSdk: 28 (Android 9.0)
- Kotlin: 2.0 or higher (with a KSP version matching your Kotlin version)
If you're using Version Catalog, you can configure the dependency by adding it to your libs.versions.toml file as follows:
[versions]
#...
wisp = "0.2.0"
[libraries]
#...
wisp-runtime = { module = "io.github.angrypodo:wisp-runtime", version.ref = "wisp" }
wisp-processor = { module = "io.github.angrypodo:wisp-processor", version.ref = "wisp" }Add the KSP plugin to your project-level build.gradle.kts. Make sure to use a KSP version that matches your Kotlin version. (Check KSP Releases)
plugins {
id("com.google.devtools.ksp") version "YOUR_KSP_VERSION" apply false
}Then, add the dependencies to your module's build.gradle.kts file:
plugins {
id("com.google.devtools.ksp")
}
dependencies {
implementation("io.github.angrypodo:wisp-runtime:0.2.0")
ksp("io.github.angrypodo:wisp-processor:0.2.0")
// if you're using Version Catalog
// implementation(libs.wisp.runtime)
// ksp(libs.wisp.processor)
}Designate a deep link destination by adding the @Wisp annotation to any @Serializable data class or object.
Route properties are populated from the URI's path parameters (declared as {placeholder} segments) and query parameters. If a property has a default value, it is considered optional.
// In your navigation or feature module
import com.angrypodo.wisp.annotations.Wisp
import kotlinx.serialization.Serializable
@Serializable
@Wisp("product/{productId}") // Matches "product/123" and captures productId
data class ProductDetail(
val productId: Int, // Populated from the {productId} path parameter
val showReviews: Boolean = false // Optional, populated from "?showReviews=..."
)Rule of thumb β required goes in the path, optional goes in the query. Declare required properties as
{placeholder}path segments, and pass optional properties (those with default values) as query parameters. Multiple required values are fine:@Wisp("board/{boardId}/post/{postId}"). This mirrors REST conventions, so the contract with your server team is one sentence long.
Register an <intent-filter> in your AndroidManifest.xml. Both scheme and host are required.
<!-- In AndroidManifest.xml -->
<activity ... >
<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>In your Application class, call Wisp.initialize().
// In your app's Application class
import android.app.Application
import com.angrypodo.wisp.runtime.Wisp
class SampleApplication : Application() {
override fun onCreate() {
super.onCreate()
// Initialize Wisp. It will automatically find all route registries.
// onError is invoked whenever a deep link fails to resolve or navigate.
Wisp.initialize(
onError = { error -> Log.e("Wisp", "Deep link failed", error) }
)
}
}Note: Don't forget to add
android:name=".SampleApplication"to the<application>tag in yourAndroidManifest.xml.
Construct a deep link URI and use the navigateTo extension function on your NavController.
- URI Format:
scheme://host/pattern1/pattern2?paramKey=paramValue - Backstack: The backstack is built from the URI's path segments. A single route pattern can consume multiple segments (e.g.
product/{productId}consumesproduct/123). - Parameters: Route properties are populated from path parameters and query parameters. When both provide the same key, the path parameter wins.
// This URI creates a backstack: ProductDetail -> UserRoute
// - ProductDetail gets productId=123 from the path. 'showReviews' uses its default value (false).
// - UserRoute gets userId=99 from the path.
val uri = "app://wisp/product/123/user/99".toUri()
val result: WispResult = navController.navigateTo(uri)navigateTo never throws. It returns a WispResult (Success or Failure), and every failure is also delivered to the onError callback passed to Wisp.initialize(). This matters for FCM-driven deep links: an older app version receiving a newer link degrades gracefully instead of crashing.
If your start destination performs login/token validation, do not execute the deep link immediately β the navigation would remove the splash screen before its validation completes. Instead, defer the deep link and execute it after validation:
// In your Activity
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Wisp.defer(intent?.data) // Hold the deep link instead of executing it now.
setContent { /* ... */ }
}
deferperforms no navigation β it only stores the URI. Your NavHost still starts at the splash destination as usual, and the stored link is executed only when you callnavigateToDeferred().
// In your splash screen, after validation succeeds:
val result = navController.navigateToDeferred()
if (result == null || result is WispResult.Failure) {
// No deep link was pending (or it failed) β go to your default destination.
navController.navigate(Home) { popUpTo(Splash) { inclusive = true } }
}Warm starts: Declare
android:launchMode="singleTop"on your single Activity and receive warm-start deep links inonNewIntent. Since that session already passed splash validation, you can usually execute them immediately withnavController.navigateTo(uri)β or defer them again if your app requires re-validation.
- Clone this repository and open it in Android Studio.
- Select the
apprun configuration and run it on an emulator or a physical device. - Use the buttons in the app to test navigation.
You can test your deep links directly from the command line using adb. This is a great way to simulate a link click from an external source.
Important: When testing multiple parameters on the command line, you must escape the & character (\&) or wrap the entire URI in single quotes to prevent the shell from interpreting it as a background command.
# Escape the '&' character with a backslash
adb shell am start -a android.intent.action.VIEW -d "app://wisp/product/123/user/99?showReviews=true"By default, Wisp parses the backstack from the URI path by splitting it with a / delimiter. If your deep link scheme requires a different logic (e.g., using | as a delimiter), you can provide your own implementation of the WispUriParser interface.
val myParser = DefaultWispUriParser(delimiter = "|")
Wisp.initialize(parser = myParser)- Parameter Source: Route parameters are populated from path parameters (
{placeholder}segments) and query parameters. Path parameters take precedence when both provide the same key. - Query Parameters Are Shared: Query parameters are visible to every route in the URI. If two routes in one backstack need different values for the same property name, pass them as path parameters instead.
- Pattern Rules: A
@Wisppath must start with a literal segment, and every{placeholder}must match a constructor property name. Violations are reported at compile time by the KSP processor. - Kotlinx Serialization: Wisp relies heavily on
kotlinx.serializationto deserialize parameters into your route data classes. - Parameter Naming: Path placeholder names and query parameter keys must exactly match the property names in your route
data class. - No Crashes on Unknown Links: Unregistered paths, missing parameters, and conversion failures return
WispResult.Failure(and invokeonError) instead of throwing.
Copyright 2025 angrypodo
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.