A small, navigation agnostic deeplink engine for Kotlin Multiplatform.
Kepler turns a raw link, from a push notification, a QR code, a web URL, or a chat message, into a validated, intercepted, typed outcome that your app acts on. It stops before navigation, so it works with any navigation library: Compose Multiplatform Navigation, Compose Destinations, Navigation 3, Voyager, Decompose, Circuit, or your own.
Describe your destinations however you like. A sealed type keeps the handling exhaustive.
sealed interface Screen {
data class NotificationDetail(val id: String) : Screen
data object WhatsNew : Screen
data class Outlet(val id: String) : Screen
}Build a Kepler, mapping each route pattern to a typed destination. Required parameters
are path segments, optional parameters are query parameters, and repeated query
parameters bind to a list.
val kepler = Kepler<Screen> {
route("notifications/{id}") { NotificationDetail(id = string("id")) }
route("whatsnew") { WhatsNew }
route("outlets/{id}") { Outlet(id = string("id")) }
}Handle a link and act on the outcome.
when (val outcome = kepler.handle("app://notifications/123")) {
is KeplerOutcome.Resolved -> navigateTo(outcome.destination)
is KeplerOutcome.Pending -> goToLogin()
is KeplerOutcome.Rejected -> goToHome()
is KeplerOutcome.Proceed -> Unit
}Interception is modeled on OkHttp's Interceptor and Chain. Auth gating, analytics,
and feature flags compose as interceptors rather than branches. AuthInterceptor defers
a link until the user is authenticated and captures it for replay.
val kepler = Kepler<Screen> {
route("notifications/{id}") { NotificationDetail(string("id")) }
intercept(AuthInterceptor { session.isLoggedIn })
pendingStore = keplerPendingRouteStore(context)
}A deferred link is stored, then replayed after sign in.
kepler.resumePending()?.let { outcome ->
if (outcome is KeplerOutcome.Resolved) navigateTo(outcome.destination)
}Feed an intent straight in. The activity overload also clears the consumed intent, so a later recreation does not replay the same link.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
kepler.handle(this)?.let { outcome -> /* ... */ }
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
kepler.handle(intent)?.let { outcome -> /* ... */ }
}Because parsing and resolution are pure Kotlin, every deeplink is testable on the JVM
without an emulator. The kepler-testing artifact adds assertions.
assertThat(kepler.handle("app://notifications/123"))
.isResolvedTo(Screen.NotificationDetail(id = "123"))A navigation library that already matches URIs, such as Compose Destinations, runs in
Mode A: register no routes and Kepler returns Proceed, handing the link to that
library's matcher after interception. A library without a matcher runs in Mode B:
register routes and Kepler returns Resolved with a typed destination.
Kepler does not verify Android App Links, host links, or attribute installs. Those are OS and server concerns. See DESIGN.md for the full architecture.
dependencies {
implementation("io.github.joelkanyi:kepler:0.1.0")
testImplementation("io.github.joelkanyi:kepler-testing:0.1.0")
}Copyright 2026 Joel Kanyi
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
https://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.