Repository files navigation

KRelay logo

KRelay

Type-safe native interop bridge for Kotlin Multiplatform.

Dispatch UI commands (Toast, Navigation, Permissions) from shared ViewModels to Android and iOS — leak-free, rotation-safe, always on the Main Thread.

Maven CentralKotlinKMPZero DependenciesLicense


The "State" Trap

Most mobile apps treat everything (Toasts, Navigation, Alerts) as State. This is why you see "Ghost Toasts" popping up after rotation, or users stuck because a navigation event fired in the 300ms "blind spot" during an Activity restart.

ApproachThe Pain Point
Pass Activity / UIViewControllerMemory leaks and onDestroy boilerplate
SharedFlow(replay=0)Events are lost during screen rotation
StateFlow as EventDouble-execution / Side-effects that "stick"
ChannelsSingle-observer only (unsuitable for UI + Analytics)

KRelay is a Buffered Multicasting bridge. Your shared ViewModel signals an intent, and the platform fulfills it — exactly once, always on the Main Thread, even if the UI wasn't ready when you called it.


Architectural Philosophy

"State is for seeing, Event is for running."

KRelay is designed for mission-critical systems (VoIP, Fintech, SOS) where event delivery is non-negotiable.

  • Buffering: Holds events during the UI startup "blind spot."
  • Multicasting: One dispatch, multiple listeners (UI, Analytics, Logging).
  • No-Replay: Side-effects vanish immediately after they run.

Read the State vs. Event: Why your MVI/Redux app is probably leaking side-effects.


Install

// shared/build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.1.1")
implementation("dev.brewkits:krelay-compose:2.1.1") // Compose helpers (optional)
}

Quickstart

1. Define a contract in commonMain

interfaceToastFeature : RelayFeature {
funshow(message:String)
}

2. Dispatch from your ViewModel

classLoginViewModel : ViewModel() {
funonLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
// Zero platform imports. Zero leaks. Queued if the UI isn't ready yet.
}
}

3. Register the platform implementation

// Android — Activity or ComposableKRelay.register<ToastFeature>(object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
})
// iOS — Swift
lettoastClass=KRelayKClassHelpersKt.toastFeatureKClass()KRelayIosHelperKt.registerFeature(
instance:KRelay.shared.instance,
kClass: toastClass,
impl:IOSToast(viewController:self))

That's all the wiring needed. KRelay routes the call to the Main Thread, replays it if the UI wasn't ready, and releases the implementation when it's GC'd.


How it works

ViewModel KRelay Platform
─────────────────────────────────────────────────────────────
dispatch<Toast> { ... } ──► impl registered?
├── yes: runOnMain { block(impl) }
└── no: sticky queue ──► replay on register()

Three guarantees, always active:

  • WeakReference registry — implementations are never strongly held; no onDestroy cleanup needed for 99% of cases.
  • Sticky queue — actions dispatched before registration are held and replayed automatically. Screen rotation, async init, cold start — all covered.
  • Main Thread dispatch — regardless of which thread dispatch is called from, the block executes on Android's Looper.mainLooper() / iOS's GCD main queue.

Core API

The API is identical on the global singleton and on any isolated instance.

// RegistrationKRelay.register<ToastFeature>(impl)
KRelay.unregister<ToastFeature>() // unconditionalKRelay.unregister<ToastFeature>(impl) // identity-safe (won't clear a newer registration)KRelay.isRegistered<ToastFeature>()
// DispatchKRelay.dispatch<ToastFeature> { it.show("Hello") }
KRelay.dispatchWithPriority<ToastFeature>(ActionPriority.CRITICAL) { it.show("Error!") }
// Queue managementKRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
// Scope tokens — cancel queued actions by caller identityval token = scopedToken()
KRelay.dispatch<ToastFeature>(token) { it.show("...") }
KRelay.cancelScope(token) // in ViewModel.onCleared()// DebugKRelay.dump()
KRelay.debugMode =true

Priority dispatch

When multiple actions queue up before an implementation registers, higher-priority actions replay first. On overflow, the lowest-priority action is evicted (not just the oldest).

KRelay.dispatchWithPriority<NavFeature>(ActionPriority.HIGH) { it.goToHome() }
KRelay.dispatchWithPriority<NavFeature>(ActionPriority.CRITICAL) { it.showError("Timeout") }
// ActionPriority: LOW(0) NORMAL(50) HIGH(100) CRITICAL(1000)

Persistent dispatch

Survives process death. The action is saved to SharedPreferences (Android) or NSUserDefaults (iOS) and restored on next launch.

// Register a factory to reconstruct the action from its payload
instance.registerActionFactory<ToastFeature>("toast", "show") { payload ->
{ feature -> feature.show(payload) }
}
// Dispatch — persisted to disk if no impl is available
instance.dispatchPersisted<ToastFeature>("toast", "show", "Payment received")
// On app restart — restores actions into the in-memory queue
instance.restorePersistedActions()

Use an explicit string featureKey (not the class name) — class names can be obfuscated by ProGuard/R8.


Instance API — modular apps and DI

The singleton is fine for small apps. For multi-module projects or Koin/Hilt injection, create isolated instances:

// Each module owns its registry — no cross-module interferenceval rideKRelay =KRelay.create("Rides")
val foodKRelay =KRelay.create("Food")
// Or with custom settings via builderval krelay =KRelay.builder("Payment")
.maxQueueSize(50)
.actionExpiry(60_000L)
.debugMode(BuildConfig.DEBUG)
.build()

Inject into ViewModels via Koin:

val appModule = module {
single { KRelay.create("AppScope") }
viewModel { LoginViewModel(krelay = get()) }
}
classLoginViewModel(privatevalkrelay:KRelayInstance) : ViewModel() {
funonSuccess() { krelay.dispatch<NavFeature> { it.goToHome() } }
}

Compose Multiplatform

Add krelay-compose and use the built-in helpers:

// Registers when composition enters, unregisters when it leaves
@Composable
funHomeScreen() {
val context =LocalContext.current
KRelayEffect<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// ...
}
// When you need to use the implementation in the same composable
@Composable
funHomeScreen() {
val snackbarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
rememberKRelayImpl<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) {
scope.launch { snackbarState.showSnackbar(message) }
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) { ... }
}

Both helpers accept an optional instance parameter for the Instance API:

KRelayEffect<ToastFeature>(instance = myKRelayInstance) { ... }

Manual DisposableEffect? Always hoist the implementation into remember {}. Without it, Kotlin/Native's GC can collect the object before the first dispatch.

See Compose Integration Guide for full patterns including Navigation Compose and Voyager.


Testing

No mocking library required. Inject a real KRelayInstance and register plain Kotlin objects.

privatelateinitvar krelay:KRelayInstanceprivatelateinitvar viewModel:LoginViewModel
@BeforeTest
funsetup() {
krelay =KRelay.create("TestScope")
viewModel =LoginViewModel(krelay = krelay)
}
@AfterTest
funtearDown() {
krelay.reset()
}
@Test
fun`login success shows toast and navigates`() {
val toast =MockToast()
val nav =MockNav()
krelay.register<ToastFeature>(toast)
krelay.register<NavFeature>(nav)
viewModel.onLoginSuccess()
assertEquals("Welcome back!", toast.lastMessage)
assertEquals("home", nav.lastDestination)
}
classMockToast : ToastFeature {
var lastMessage:String?=nulloverridefunshow(message:String) { lastMessage = message }
}
classMockNav : NavFeature {
var lastDestination:String?=nulloverridefunnavigateTo(screen:String) { lastDestination = screen }
}

Run the test suite:

./gradlew :krelay:test # JVM (fast)
./gradlew :krelay:iosSimulatorArm64Test # iOS Simulator
./gradlew :krelay:connectedDebugAndroidTest # Real Android device

Memory safety

By default, three passive protections apply to every queued action:

ProtectionDefaultBehaviour
WeakReferenceAlways onPlatform impls released when GC'd — no onDestroy cleanup needed
actionExpiryMs5 minQueued actions expire and are dropped automatically
maxQueueSize100When full, lowest-priority (or oldest) action is evicted

For granular control, use scope tokens to cancel only the actions queued by a specific ViewModel:

classMyViewModel : ViewModel() {
privateval token = scopedToken()
fundoWork() =KRelay.dispatch<WorkFeature>(token) { it.run() }
overridefunonCleared() =KRelay.cancelScope(token)
}

When not to use KRelay

KRelay is for one-way, fire-and-forget UI commands. For anything else, use the right tool:

ScenarioBetter alternative
Need a return valuesuspend fun + expect/actual
Reactive UI stateStateFlow / MutableStateFlow
Critical side-effects (payment, upload)WorkManager / background service
DatabaseRoom / SQLDelight
NetworkKtor + Repository

Integrations

KRelay is framework-agnostic. It connects to whatever navigation, media, or permission library you already use — ViewModels stay clean of all framework imports.

CategoryLibrary
NavigationVoyager · Decompose · Navigation Compose
MediaPeekaboo (image/camera picker)
PermissionsMoko Permissions
BiometricsMoko Biometry
ReviewsPlay Core · StoreKit
DIKoin · Hilt

See Integration Guides for step-by-step examples.


Compatibility

KRelayKotlinAGPAndroid minSdkiOS
2.1.x2.1.x8.x2414.0+
2.0.x2.1.x8.x2414.0+
1.1.x2.0.x8.x2313.0+
1.0.x1.9.x7.x2113.0+

Platforms: Android arm64 · Android x86_64 · iOS arm64 (device) · iOS arm64 (simulator) · iOS x64 (simulator)


What's New

v2.1.1 — Hardened & Standardized
  • Atomic dispatch — the impl lookup, queue insertion, and persistence decision happen inside a single lock, closing the TOCTOU window that could strand an action indefinitely.
  • krelay-compose artifactKRelayEffect<T> and rememberKRelayImpl<T> published as dev.brewkits:krelay-compose:2.1.1, separate from the zero-dependency core.
  • ProGuard/R8-safe persistenceregisterActionFactory and dispatchPersisted now require an explicit stable featureKey string. Old overloads deprecated with replaceWith guidance.
  • Identity-aware unregisterunregister(impl) only removes the registration if the stored reference matches, preventing a recomposing Compose component from clearing a newer registration.
  • Thread-safe metrics — all KRelayMetrics operations are now lock-protected.
  • iOS registration validationregisterFeature validates interface conformance at runtime; crashes in debug, warns in release.
  • Priority eviction — queue overflow now evicts the lowest-priority action, not the oldest FIFO item.
v2.1.0 — Compose, Persistence & Scope Tokens
  • KRelayEffect<T> and rememberKRelayImpl<T> Compose helpers
  • Persistent dispatch with dispatchPersisted<T>() — survives process death
  • SharedPreferencesPersistenceAdapter (Android) and NSUserDefaultsPersistenceAdapter (iOS)
  • Scope Token API: scopedToken() + cancelScope(token) for fine-grained ViewModel cleanup
  • dispatchWithPriority available on instances (was singleton-only)
  • resetConfiguration() without clearing the registry or queue
v2.0.0 — Instance API for Super Apps
  • KRelay.create("ScopeName") — isolated instances per module
  • KRelay.builder(...) — configure queue, expiry, and debug mode per instance
  • DI-friendly: KRelayInstance is an interface, injectable via Koin or Hilt
  • 100% backward compatible with v1.x

Documentation

GuideDescription
Compose IntegrationKRelayEffect, rememberKRelayImpl, Navigation Compose, Voyager
SwiftUI IntegrationiOS-specific patterns, XCTest
Integration GuidesVoyager, Decompose, Moko, Peekaboo, DI
Lifecycle GuideActivity · Fragment · UIViewController · SwiftUI
Testing GuidePatterns, mocks, instrumented tests
Anti-PatternsWhat not to do and why
ArchitectureInternals deep dive
API ReferenceFull API cheat sheet
Managing WarningsSuppress @OptIn at module level
Migration to v2.0Upgrading from v1.x

License

Copyright 2026 Brewkits
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

Made with care by Nguyễn Tuấn Việt · Brewkits

Issues · Changelog · datacenter111@gmail.com

About

Dispatch Toasts, Navigation & Permissions from KMP shared ViewModels to Android/iOS — zero memory leaks, survives screen rotation. Works with Voyager, Decompose, Moko, Peekaboo & Compose Multiplatform.

Topics

Resources

Stars

14 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Repository files navigation

KRelay logo

KRelay

Type-safe native interop bridge for Kotlin Multiplatform.

Dispatch UI commands (Toast, Navigation, Permissions) from shared ViewModels to Android and iOS — leak-free, rotation-safe, always on the Main Thread.

Maven CentralKotlinKMPZero DependenciesLicense


The "State" Trap

Most mobile apps treat everything (Toasts, Navigation, Alerts) as State. This is why you see "Ghost Toasts" popping up after rotation, or users stuck because a navigation event fired in the 300ms "blind spot" during an Activity restart.

ApproachThe Pain Point
Pass Activity / UIViewControllerMemory leaks and onDestroy boilerplate
SharedFlow(replay=0)Events are lost during screen rotation
StateFlow as EventDouble-execution / Side-effects that "stick"
ChannelsSingle-observer only (unsuitable for UI + Analytics)

KRelay is a Buffered Multicasting bridge. Your shared ViewModel signals an intent, and the platform fulfills it — exactly once, always on the Main Thread, even if the UI wasn't ready when you called it.


Architectural Philosophy

"State is for seeing, Event is for running."

KRelay is designed for mission-critical systems (VoIP, Fintech, SOS) where event delivery is non-negotiable.

  • Buffering: Holds events during the UI startup "blind spot."
  • Multicasting: One dispatch, multiple listeners (UI, Analytics, Logging).
  • No-Replay: Side-effects vanish immediately after they run.

Read the State vs. Event: Why your MVI/Redux app is probably leaking side-effects.


Install

// shared/build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.1.1")
implementation("dev.brewkits:krelay-compose:2.1.1") // Compose helpers (optional)
}

Quickstart

1. Define a contract in commonMain

interfaceToastFeature : RelayFeature {
funshow(message:String)
}

2. Dispatch from your ViewModel

classLoginViewModel : ViewModel() {
funonLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
// Zero platform imports. Zero leaks. Queued if the UI isn't ready yet.
}
}

3. Register the platform implementation

// Android — Activity or ComposableKRelay.register<ToastFeature>(object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
})
// iOS — Swift
lettoastClass=KRelayKClassHelpersKt.toastFeatureKClass()KRelayIosHelperKt.registerFeature(
instance:KRelay.shared.instance,
kClass: toastClass,
impl:IOSToast(viewController:self))

That's all the wiring needed. KRelay routes the call to the Main Thread, replays it if the UI wasn't ready, and releases the implementation when it's GC'd.


How it works

ViewModel KRelay Platform
─────────────────────────────────────────────────────────────
dispatch<Toast> { ... } ──► impl registered?
├── yes: runOnMain { block(impl) }
└── no: sticky queue ──► replay on register()

Three guarantees, always active:

  • WeakReference registry — implementations are never strongly held; no onDestroy cleanup needed for 99% of cases.
  • Sticky queue — actions dispatched before registration are held and replayed automatically. Screen rotation, async init, cold start — all covered.
  • Main Thread dispatch — regardless of which thread dispatch is called from, the block executes on Android's Looper.mainLooper() / iOS's GCD main queue.

Core API

The API is identical on the global singleton and on any isolated instance.

// RegistrationKRelay.register<ToastFeature>(impl)
KRelay.unregister<ToastFeature>() // unconditionalKRelay.unregister<ToastFeature>(impl) // identity-safe (won't clear a newer registration)KRelay.isRegistered<ToastFeature>()
// DispatchKRelay.dispatch<ToastFeature> { it.show("Hello") }
KRelay.dispatchWithPriority<ToastFeature>(ActionPriority.CRITICAL) { it.show("Error!") }
// Queue managementKRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
// Scope tokens — cancel queued actions by caller identityval token = scopedToken()
KRelay.dispatch<ToastFeature>(token) { it.show("...") }
KRelay.cancelScope(token) // in ViewModel.onCleared()// DebugKRelay.dump()
KRelay.debugMode =true

Priority dispatch

When multiple actions queue up before an implementation registers, higher-priority actions replay first. On overflow, the lowest-priority action is evicted (not just the oldest).

KRelay.dispatchWithPriority<NavFeature>(ActionPriority.HIGH) { it.goToHome() }
KRelay.dispatchWithPriority<NavFeature>(ActionPriority.CRITICAL) { it.showError("Timeout") }
// ActionPriority: LOW(0) NORMAL(50) HIGH(100) CRITICAL(1000)

Persistent dispatch

Survives process death. The action is saved to SharedPreferences (Android) or NSUserDefaults (iOS) and restored on next launch.

// Register a factory to reconstruct the action from its payload
instance.registerActionFactory<ToastFeature>("toast", "show") { payload ->
{ feature -> feature.show(payload) }
}
// Dispatch — persisted to disk if no impl is available
instance.dispatchPersisted<ToastFeature>("toast", "show", "Payment received")
// On app restart — restores actions into the in-memory queue
instance.restorePersistedActions()

Use an explicit string featureKey (not the class name) — class names can be obfuscated by ProGuard/R8.


Instance API — modular apps and DI

The singleton is fine for small apps. For multi-module projects or Koin/Hilt injection, create isolated instances:

// Each module owns its registry — no cross-module interferenceval rideKRelay =KRelay.create("Rides")
val foodKRelay =KRelay.create("Food")
// Or with custom settings via builderval krelay =KRelay.builder("Payment")
.maxQueueSize(50)
.actionExpiry(60_000L)
.debugMode(BuildConfig.DEBUG)
.build()

Inject into ViewModels via Koin:

val appModule = module {
single { KRelay.create("AppScope") }
viewModel { LoginViewModel(krelay = get()) }
}
classLoginViewModel(privatevalkrelay:KRelayInstance) : ViewModel() {
funonSuccess() { krelay.dispatch<NavFeature> { it.goToHome() } }
}

Compose Multiplatform

Add krelay-compose and use the built-in helpers:

// Registers when composition enters, unregisters when it leaves
@Composable
funHomeScreen() {
val context =LocalContext.current
KRelayEffect<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// ...
}
// When you need to use the implementation in the same composable
@Composable
funHomeScreen() {
val snackbarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
rememberKRelayImpl<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) {
scope.launch { snackbarState.showSnackbar(message) }
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) { ... }
}

Both helpers accept an optional instance parameter for the Instance API:

KRelayEffect<ToastFeature>(instance = myKRelayInstance) { ... }

Manual DisposableEffect? Always hoist the implementation into remember {}. Without it, Kotlin/Native's GC can collect the object before the first dispatch.

See Compose Integration Guide for full patterns including Navigation Compose and Voyager.


Testing

No mocking library required. Inject a real KRelayInstance and register plain Kotlin objects.

privatelateinitvar krelay:KRelayInstanceprivatelateinitvar viewModel:LoginViewModel
@BeforeTest
funsetup() {
krelay =KRelay.create("TestScope")
viewModel =LoginViewModel(krelay = krelay)
}
@AfterTest
funtearDown() {
krelay.reset()
}
@Test
fun`login success shows toast and navigates`() {
val toast =MockToast()
val nav =MockNav()
krelay.register<ToastFeature>(toast)
krelay.register<NavFeature>(nav)
viewModel.onLoginSuccess()
assertEquals("Welcome back!", toast.lastMessage)
assertEquals("home", nav.lastDestination)
}
classMockToast : ToastFeature {
var lastMessage:String?=nulloverridefunshow(message:String) { lastMessage = message }
}
classMockNav : NavFeature {
var lastDestination:String?=nulloverridefunnavigateTo(screen:String) { lastDestination = screen }
}

Run the test suite:

./gradlew :krelay:test # JVM (fast)
./gradlew :krelay:iosSimulatorArm64Test # iOS Simulator
./gradlew :krelay:connectedDebugAndroidTest # Real Android device

Memory safety

By default, three passive protections apply to every queued action:

ProtectionDefaultBehaviour
WeakReferenceAlways onPlatform impls released when GC'd — no onDestroy cleanup needed
actionExpiryMs5 minQueued actions expire and are dropped automatically
maxQueueSize100When full, lowest-priority (or oldest) action is evicted

For granular control, use scope tokens to cancel only the actions queued by a specific ViewModel:

classMyViewModel : ViewModel() {
privateval token = scopedToken()
fundoWork() =KRelay.dispatch<WorkFeature>(token) { it.run() }
overridefunonCleared() =KRelay.cancelScope(token)
}

When not to use KRelay

KRelay is for one-way, fire-and-forget UI commands. For anything else, use the right tool:

ScenarioBetter alternative
Need a return valuesuspend fun + expect/actual
Reactive UI stateStateFlow / MutableStateFlow
Critical side-effects (payment, upload)WorkManager / background service
DatabaseRoom / SQLDelight
NetworkKtor + Repository

Integrations

KRelay is framework-agnostic. It connects to whatever navigation, media, or permission library you already use — ViewModels stay clean of all framework imports.

CategoryLibrary
NavigationVoyager · Decompose · Navigation Compose
MediaPeekaboo (image/camera picker)
PermissionsMoko Permissions
BiometricsMoko Biometry
ReviewsPlay Core · StoreKit
DIKoin · Hilt

See Integration Guides for step-by-step examples.


Compatibility

KRelayKotlinAGPAndroid minSdkiOS
2.1.x2.1.x8.x2414.0+
2.0.x2.1.x8.x2414.0+
1.1.x2.0.x8.x2313.0+
1.0.x1.9.x7.x2113.0+

Platforms: Android arm64 · Android x86_64 · iOS arm64 (device) · iOS arm64 (simulator) · iOS x64 (simulator)


What's New

v2.1.1 — Hardened & Standardized
  • Atomic dispatch — the impl lookup, queue insertion, and persistence decision happen inside a single lock, closing the TOCTOU window that could strand an action indefinitely.
  • krelay-compose artifactKRelayEffect<T> and rememberKRelayImpl<T> published as dev.brewkits:krelay-compose:2.1.1, separate from the zero-dependency core.
  • ProGuard/R8-safe persistenceregisterActionFactory and dispatchPersisted now require an explicit stable featureKey string. Old overloads deprecated with replaceWith guidance.
  • Identity-aware unregisterunregister(impl) only removes the registration if the stored reference matches, preventing a recomposing Compose component from clearing a newer registration.
  • Thread-safe metrics — all KRelayMetrics operations are now lock-protected.
  • iOS registration validationregisterFeature validates interface conformance at runtime; crashes in debug, warns in release.
  • Priority eviction — queue overflow now evicts the lowest-priority action, not the oldest FIFO item.
v2.1.0 — Compose, Persistence & Scope Tokens
  • KRelayEffect<T> and rememberKRelayImpl<T> Compose helpers
  • Persistent dispatch with dispatchPersisted<T>() — survives process death
  • SharedPreferencesPersistenceAdapter (Android) and NSUserDefaultsPersistenceAdapter (iOS)
  • Scope Token API: scopedToken() + cancelScope(token) for fine-grained ViewModel cleanup
  • dispatchWithPriority available on instances (was singleton-only)
  • resetConfiguration() without clearing the registry or queue
v2.0.0 — Instance API for Super Apps
  • KRelay.create("ScopeName") — isolated instances per module
  • KRelay.builder(...) — configure queue, expiry, and debug mode per instance
  • DI-friendly: KRelayInstance is an interface, injectable via Koin or Hilt
  • 100% backward compatible with v1.x

Documentation

GuideDescription
Compose IntegrationKRelayEffect, rememberKRelayImpl, Navigation Compose, Voyager
SwiftUI IntegrationiOS-specific patterns, XCTest
Integration GuidesVoyager, Decompose, Moko, Peekaboo, DI
Lifecycle GuideActivity · Fragment · UIViewController · SwiftUI
Testing GuidePatterns, mocks, instrumented tests
Anti-PatternsWhat not to do and why
ArchitectureInternals deep dive
API ReferenceFull API cheat sheet
Managing WarningsSuppress @OptIn at module level
Migration to v2.0Upgrading from v1.x

License

Copyright 2026 Brewkits
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

Made with care by Nguyễn Tuấn Việt · Brewkits

Issues · Changelog · datacenter111@gmail.com

About

Dispatch Toasts, Navigation & Permissions from KMP shared ViewModels to Android/iOS — zero memory leaks, survives screen rotation. Works with Voyager, Decompose, Moko, Peekaboo & Compose Multiplatform.

Topics

Resources

Stars

14 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

KRelay logo

KRelay

Type-safe native interop bridge for Kotlin Multiplatform.

Dispatch UI commands (Toast, Navigation, Permissions) from shared ViewModels to Android and iOS — leak-free, rotation-safe, always on the Main Thread.

Maven CentralKotlinKMPZero DependenciesLicense


The "State" Trap

Most mobile apps treat everything (Toasts, Navigation, Alerts) as State. This is why you see "Ghost Toasts" popping up after rotation, or users stuck because a navigation event fired in the 300ms "blind spot" during an Activity restart.

ApproachThe Pain Point
Pass Activity / UIViewControllerMemory leaks and onDestroy boilerplate
SharedFlow(replay=0)Events are lost during screen rotation
StateFlow as EventDouble-execution / Side-effects that "stick"
ChannelsSingle-observer only (unsuitable for UI + Analytics)

KRelay is a Buffered Multicasting bridge. Your shared ViewModel signals an intent, and the platform fulfills it — exactly once, always on the Main Thread, even if the UI wasn't ready when you called it.


Architectural Philosophy

"State is for seeing, Event is for running."

KRelay is designed for mission-critical systems (VoIP, Fintech, SOS) where event delivery is non-negotiable.

  • Buffering: Holds events during the UI startup "blind spot."
  • Multicasting: One dispatch, multiple listeners (UI, Analytics, Logging).
  • No-Replay: Side-effects vanish immediately after they run.

Read the State vs. Event: Why your MVI/Redux app is probably leaking side-effects.


Install

// shared/build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.1.1")
implementation("dev.brewkits:krelay-compose:2.1.1") // Compose helpers (optional)
}

Quickstart

1. Define a contract in commonMain

interfaceToastFeature : RelayFeature {
funshow(message:String)
}

2. Dispatch from your ViewModel

classLoginViewModel : ViewModel() {
funonLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
// Zero platform imports. Zero leaks. Queued if the UI isn't ready yet.
}
}

3. Register the platform implementation

// Android — Activity or ComposableKRelay.register<ToastFeature>(object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
})
// iOS — Swift
lettoastClass=KRelayKClassHelpersKt.toastFeatureKClass()KRelayIosHelperKt.registerFeature(
instance:KRelay.shared.instance,
kClass: toastClass,
impl:IOSToast(viewController:self))

That's all the wiring needed. KRelay routes the call to the Main Thread, replays it if the UI wasn't ready, and releases the implementation when it's GC'd.


How it works

ViewModel KRelay Platform
─────────────────────────────────────────────────────────────
dispatch<Toast> { ... } ──► impl registered?
├── yes: runOnMain { block(impl) }
└── no: sticky queue ──► replay on register()

Three guarantees, always active:

  • WeakReference registry — implementations are never strongly held; no onDestroy cleanup needed for 99% of cases.
  • Sticky queue — actions dispatched before registration are held and replayed automatically. Screen rotation, async init, cold start — all covered.
  • Main Thread dispatch — regardless of which thread dispatch is called from, the block executes on Android's Looper.mainLooper() / iOS's GCD main queue.

Core API

The API is identical on the global singleton and on any isolated instance.

// RegistrationKRelay.register<ToastFeature>(impl)
KRelay.unregister<ToastFeature>() // unconditionalKRelay.unregister<ToastFeature>(impl) // identity-safe (won't clear a newer registration)KRelay.isRegistered<ToastFeature>()
// DispatchKRelay.dispatch<ToastFeature> { it.show("Hello") }
KRelay.dispatchWithPriority<ToastFeature>(ActionPriority.CRITICAL) { it.show("Error!") }
// Queue managementKRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
// Scope tokens — cancel queued actions by caller identityval token = scopedToken()
KRelay.dispatch<ToastFeature>(token) { it.show("...") }
KRelay.cancelScope(token) // in ViewModel.onCleared()// DebugKRelay.dump()
KRelay.debugMode =true

Priority dispatch

When multiple actions queue up before an implementation registers, higher-priority actions replay first. On overflow, the lowest-priority action is evicted (not just the oldest).

KRelay.dispatchWithPriority<NavFeature>(ActionPriority.HIGH) { it.goToHome() }
KRelay.dispatchWithPriority<NavFeature>(ActionPriority.CRITICAL) { it.showError("Timeout") }
// ActionPriority: LOW(0) NORMAL(50) HIGH(100) CRITICAL(1000)

Persistent dispatch

Survives process death. The action is saved to SharedPreferences (Android) or NSUserDefaults (iOS) and restored on next launch.

// Register a factory to reconstruct the action from its payload
instance.registerActionFactory<ToastFeature>("toast", "show") { payload ->
{ feature -> feature.show(payload) }
}
// Dispatch — persisted to disk if no impl is available
instance.dispatchPersisted<ToastFeature>("toast", "show", "Payment received")
// On app restart — restores actions into the in-memory queue
instance.restorePersistedActions()

Use an explicit string featureKey (not the class name) — class names can be obfuscated by ProGuard/R8.


Instance API — modular apps and DI

The singleton is fine for small apps. For multi-module projects or Koin/Hilt injection, create isolated instances:

// Each module owns its registry — no cross-module interferenceval rideKRelay =KRelay.create("Rides")
val foodKRelay =KRelay.create("Food")
// Or with custom settings via builderval krelay =KRelay.builder("Payment")
.maxQueueSize(50)
.actionExpiry(60_000L)
.debugMode(BuildConfig.DEBUG)
.build()

Inject into ViewModels via Koin:

val appModule = module {
single { KRelay.create("AppScope") }
viewModel { LoginViewModel(krelay = get()) }
}
classLoginViewModel(privatevalkrelay:KRelayInstance) : ViewModel() {
funonSuccess() { krelay.dispatch<NavFeature> { it.goToHome() } }
}

Compose Multiplatform

Add krelay-compose and use the built-in helpers:

// Registers when composition enters, unregisters when it leaves
@Composable
funHomeScreen() {
val context =LocalContext.current
KRelayEffect<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// ...
}
// When you need to use the implementation in the same composable
@Composable
funHomeScreen() {
val snackbarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
rememberKRelayImpl<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) {
scope.launch { snackbarState.showSnackbar(message) }
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) { ... }
}

Both helpers accept an optional instance parameter for the Instance API:

KRelayEffect<ToastFeature>(instance = myKRelayInstance) { ... }

Manual DisposableEffect? Always hoist the implementation into remember {}. Without it, Kotlin/Native's GC can collect the object before the first dispatch.

See Compose Integration Guide for full patterns including Navigation Compose and Voyager.


Testing

No mocking library required. Inject a real KRelayInstance and register plain Kotlin objects.

privatelateinitvar krelay:KRelayInstanceprivatelateinitvar viewModel:LoginViewModel
@BeforeTest
funsetup() {
krelay =KRelay.create("TestScope")
viewModel =LoginViewModel(krelay = krelay)
}
@AfterTest
funtearDown() {
krelay.reset()
}
@Test
fun`login success shows toast and navigates`() {
val toast =MockToast()
val nav =MockNav()
krelay.register<ToastFeature>(toast)
krelay.register<NavFeature>(nav)
viewModel.onLoginSuccess()
assertEquals("Welcome back!", toast.lastMessage)
assertEquals("home", nav.lastDestination)
}
classMockToast : ToastFeature {
var lastMessage:String?=nulloverridefunshow(message:String) { lastMessage = message }
}
classMockNav : NavFeature {
var lastDestination:String?=nulloverridefunnavigateTo(screen:String) { lastDestination = screen }
}

Run the test suite:

./gradlew :krelay:test # JVM (fast)
./gradlew :krelay:iosSimulatorArm64Test # iOS Simulator
./gradlew :krelay:connectedDebugAndroidTest # Real Android device

Memory safety

By default, three passive protections apply to every queued action:

ProtectionDefaultBehaviour
WeakReferenceAlways onPlatform impls released when GC'd — no onDestroy cleanup needed
actionExpiryMs5 minQueued actions expire and are dropped automatically
maxQueueSize100When full, lowest-priority (or oldest) action is evicted

For granular control, use scope tokens to cancel only the actions queued by a specific ViewModel:

classMyViewModel : ViewModel() {
privateval token = scopedToken()
fundoWork() =KRelay.dispatch<WorkFeature>(token) { it.run() }
overridefunonCleared() =KRelay.cancelScope(token)
}

When not to use KRelay

KRelay is for one-way, fire-and-forget UI commands. For anything else, use the right tool:

ScenarioBetter alternative
Need a return valuesuspend fun + expect/actual
Reactive UI stateStateFlow / MutableStateFlow
Critical side-effects (payment, upload)WorkManager / background service
DatabaseRoom / SQLDelight
NetworkKtor + Repository

Integrations

KRelay is framework-agnostic. It connects to whatever navigation, media, or permission library you already use — ViewModels stay clean of all framework imports.

CategoryLibrary
NavigationVoyager · Decompose · Navigation Compose
MediaPeekaboo (image/camera picker)
PermissionsMoko Permissions
BiometricsMoko Biometry
ReviewsPlay Core · StoreKit
DIKoin · Hilt

See Integration Guides for step-by-step examples.


Compatibility

KRelayKotlinAGPAndroid minSdkiOS
2.1.x2.1.x8.x2414.0+
2.0.x2.1.x8.x2414.0+
1.1.x2.0.x8.x2313.0+
1.0.x1.9.x7.x2113.0+

Platforms: Android arm64 · Android x86_64 · iOS arm64 (device) · iOS arm64 (simulator) · iOS x64 (simulator)


What's New

v2.1.1 — Hardened & Standardized
  • Atomic dispatch — the impl lookup, queue insertion, and persistence decision happen inside a single lock, closing the TOCTOU window that could strand an action indefinitely.
  • krelay-compose artifactKRelayEffect<T> and rememberKRelayImpl<T> published as dev.brewkits:krelay-compose:2.1.1, separate from the zero-dependency core.
  • ProGuard/R8-safe persistenceregisterActionFactory and dispatchPersisted now require an explicit stable featureKey string. Old overloads deprecated with replaceWith guidance.
  • Identity-aware unregisterunregister(impl) only removes the registration if the stored reference matches, preventing a recomposing Compose component from clearing a newer registration.
  • Thread-safe metrics — all KRelayMetrics operations are now lock-protected.
  • iOS registration validationregisterFeature validates interface conformance at runtime; crashes in debug, warns in release.
  • Priority eviction — queue overflow now evicts the lowest-priority action, not the oldest FIFO item.
v2.1.0 — Compose, Persistence & Scope Tokens
  • KRelayEffect<T> and rememberKRelayImpl<T> Compose helpers
  • Persistent dispatch with dispatchPersisted<T>() — survives process death
  • SharedPreferencesPersistenceAdapter (Android) and NSUserDefaultsPersistenceAdapter (iOS)
  • Scope Token API: scopedToken() + cancelScope(token) for fine-grained ViewModel cleanup
  • dispatchWithPriority available on instances (was singleton-only)
  • resetConfiguration() without clearing the registry or queue
v2.0.0 — Instance API for Super Apps
  • KRelay.create("ScopeName") — isolated instances per module
  • KRelay.builder(...) — configure queue, expiry, and debug mode per instance
  • DI-friendly: KRelayInstance is an interface, injectable via Koin or Hilt
  • 100% backward compatible with v1.x

Documentation

GuideDescription
Compose IntegrationKRelayEffect, rememberKRelayImpl, Navigation Compose, Voyager
SwiftUI IntegrationiOS-specific patterns, XCTest
Integration GuidesVoyager, Decompose, Moko, Peekaboo, DI
Lifecycle GuideActivity · Fragment · UIViewController · SwiftUI
Testing GuidePatterns, mocks, instrumented tests
Anti-PatternsWhat not to do and why
ArchitectureInternals deep dive
API ReferenceFull API cheat sheet
Managing WarningsSuppress @OptIn at module level
Migration to v2.0Upgrading from v1.x

License

Copyright 2026 Brewkits
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

Made with care by Nguyễn Tuấn Việt · Brewkits

Issues · Changelog · datacenter111@gmail.com

About

Dispatch Toasts, Navigation & Permissions from KMP shared ViewModels to Android/iOS — zero memory leaks, survives screen rotation. Works with Voyager, Decompose, Moko, Peekaboo & Compose Multiplatform.

Topics

Resources

Stars

14 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

KRelay logo

KRelay

Type-safe native interop bridge for Kotlin Multiplatform.

Dispatch UI commands (Toast, Navigation, Permissions) from shared ViewModels to Android and iOS — leak-free, rotation-safe, always on the Main Thread.

Maven CentralKotlinKMPZero DependenciesLicense


The "State" Trap

Most mobile apps treat everything (Toasts, Navigation, Alerts) as State. This is why you see "Ghost Toasts" popping up after rotation, or users stuck because a navigation event fired in the 300ms "blind spot" during an Activity restart.

ApproachThe Pain Point
Pass Activity / UIViewControllerMemory leaks and onDestroy boilerplate
SharedFlow(replay=0)Events are lost during screen rotation
StateFlow as EventDouble-execution / Side-effects that "stick"
ChannelsSingle-observer only (unsuitable for UI + Analytics)

KRelay is a Buffered Multicasting bridge. Your shared ViewModel signals an intent, and the platform fulfills it — exactly once, always on the Main Thread, even if the UI wasn't ready when you called it.


Architectural Philosophy

"State is for seeing, Event is for running."

KRelay is designed for mission-critical systems (VoIP, Fintech, SOS) where event delivery is non-negotiable.

  • Buffering: Holds events during the UI startup "blind spot."
  • Multicasting: One dispatch, multiple listeners (UI, Analytics, Logging).
  • No-Replay: Side-effects vanish immediately after they run.

Read the State vs. Event: Why your MVI/Redux app is probably leaking side-effects.


Install

// shared/build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.1.1")
implementation("dev.brewkits:krelay-compose:2.1.1") // Compose helpers (optional)
}

Quickstart

1. Define a contract in commonMain

interfaceToastFeature : RelayFeature {
funshow(message:String)
}

2. Dispatch from your ViewModel

classLoginViewModel : ViewModel() {
funonLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
// Zero platform imports. Zero leaks. Queued if the UI isn't ready yet.
}
}

3. Register the platform implementation

// Android — Activity or ComposableKRelay.register<ToastFeature>(object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
})
// iOS — Swift
lettoastClass=KRelayKClassHelpersKt.toastFeatureKClass()KRelayIosHelperKt.registerFeature(
instance:KRelay.shared.instance,
kClass: toastClass,
impl:IOSToast(viewController:self))

That's all the wiring needed. KRelay routes the call to the Main Thread, replays it if the UI wasn't ready, and releases the implementation when it's GC'd.


How it works

ViewModel KRelay Platform
─────────────────────────────────────────────────────────────
dispatch<Toast> { ... } ──► impl registered?
├── yes: runOnMain { block(impl) }
└── no: sticky queue ──► replay on register()

Three guarantees, always active:

  • WeakReference registry — implementations are never strongly held; no onDestroy cleanup needed for 99% of cases.
  • Sticky queue — actions dispatched before registration are held and replayed automatically. Screen rotation, async init, cold start — all covered.
  • Main Thread dispatch — regardless of which thread dispatch is called from, the block executes on Android's Looper.mainLooper() / iOS's GCD main queue.

Core API

The API is identical on the global singleton and on any isolated instance.

// RegistrationKRelay.register<ToastFeature>(impl)
KRelay.unregister<ToastFeature>() // unconditionalKRelay.unregister<ToastFeature>(impl) // identity-safe (won't clear a newer registration)KRelay.isRegistered<ToastFeature>()
// DispatchKRelay.dispatch<ToastFeature> { it.show("Hello") }
KRelay.dispatchWithPriority<ToastFeature>(ActionPriority.CRITICAL) { it.show("Error!") }
// Queue managementKRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
// Scope tokens — cancel queued actions by caller identityval token = scopedToken()
KRelay.dispatch<ToastFeature>(token) { it.show("...") }
KRelay.cancelScope(token) // in ViewModel.onCleared()// DebugKRelay.dump()
KRelay.debugMode =true

Priority dispatch

When multiple actions queue up before an implementation registers, higher-priority actions replay first. On overflow, the lowest-priority action is evicted (not just the oldest).

KRelay.dispatchWithPriority<NavFeature>(ActionPriority.HIGH) { it.goToHome() }
KRelay.dispatchWithPriority<NavFeature>(ActionPriority.CRITICAL) { it.showError("Timeout") }
// ActionPriority: LOW(0) NORMAL(50) HIGH(100) CRITICAL(1000)

Persistent dispatch

Survives process death. The action is saved to SharedPreferences (Android) or NSUserDefaults (iOS) and restored on next launch.

// Register a factory to reconstruct the action from its payload
instance.registerActionFactory<ToastFeature>("toast", "show") { payload ->
{ feature -> feature.show(payload) }
}
// Dispatch — persisted to disk if no impl is available
instance.dispatchPersisted<ToastFeature>("toast", "show", "Payment received")
// On app restart — restores actions into the in-memory queue
instance.restorePersistedActions()

Use an explicit string featureKey (not the class name) — class names can be obfuscated by ProGuard/R8.


Instance API — modular apps and DI

The singleton is fine for small apps. For multi-module projects or Koin/Hilt injection, create isolated instances:

// Each module owns its registry — no cross-module interferenceval rideKRelay =KRelay.create("Rides")
val foodKRelay =KRelay.create("Food")
// Or with custom settings via builderval krelay =KRelay.builder("Payment")
.maxQueueSize(50)
.actionExpiry(60_000L)
.debugMode(BuildConfig.DEBUG)
.build()

Inject into ViewModels via Koin:

val appModule = module {
single { KRelay.create("AppScope") }
viewModel { LoginViewModel(krelay = get()) }
}
classLoginViewModel(privatevalkrelay:KRelayInstance) : ViewModel() {
funonSuccess() { krelay.dispatch<NavFeature> { it.goToHome() } }
}

Compose Multiplatform

Add krelay-compose and use the built-in helpers:

// Registers when composition enters, unregisters when it leaves
@Composable
funHomeScreen() {
val context =LocalContext.current
KRelayEffect<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// ...
}
// When you need to use the implementation in the same composable
@Composable
funHomeScreen() {
val snackbarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
rememberKRelayImpl<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) {
scope.launch { snackbarState.showSnackbar(message) }
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) { ... }
}

Both helpers accept an optional instance parameter for the Instance API:

KRelayEffect<ToastFeature>(instance = myKRelayInstance) { ... }

Manual DisposableEffect? Always hoist the implementation into remember {}. Without it, Kotlin/Native's GC can collect the object before the first dispatch.

See Compose Integration Guide for full patterns including Navigation Compose and Voyager.


Testing

No mocking library required. Inject a real KRelayInstance and register plain Kotlin objects.

privatelateinitvar krelay:KRelayInstanceprivatelateinitvar viewModel:LoginViewModel
@BeforeTest
funsetup() {
krelay =KRelay.create("TestScope")
viewModel =LoginViewModel(krelay = krelay)
}
@AfterTest
funtearDown() {
krelay.reset()
}
@Test
fun`login success shows toast and navigates`() {
val toast =MockToast()
val nav =MockNav()
krelay.register<ToastFeature>(toast)
krelay.register<NavFeature>(nav)
viewModel.onLoginSuccess()
assertEquals("Welcome back!", toast.lastMessage)
assertEquals("home", nav.lastDestination)
}
classMockToast : ToastFeature {
var lastMessage:String?=nulloverridefunshow(message:String) { lastMessage = message }
}
classMockNav : NavFeature {
var lastDestination:String?=nulloverridefunnavigateTo(screen:String) { lastDestination = screen }
}

Run the test suite:

./gradlew :krelay:test # JVM (fast)
./gradlew :krelay:iosSimulatorArm64Test # iOS Simulator
./gradlew :krelay:connectedDebugAndroidTest # Real Android device

Memory safety

By default, three passive protections apply to every queued action:

ProtectionDefaultBehaviour
WeakReferenceAlways onPlatform impls released when GC'd — no onDestroy cleanup needed
actionExpiryMs5 minQueued actions expire and are dropped automatically
maxQueueSize100When full, lowest-priority (or oldest) action is evicted

For granular control, use scope tokens to cancel only the actions queued by a specific ViewModel:

classMyViewModel : ViewModel() {
privateval token = scopedToken()
fundoWork() =KRelay.dispatch<WorkFeature>(token) { it.run() }
overridefunonCleared() =KRelay.cancelScope(token)
}

When not to use KRelay

KRelay is for one-way, fire-and-forget UI commands. For anything else, use the right tool:

ScenarioBetter alternative
Need a return valuesuspend fun + expect/actual
Reactive UI stateStateFlow / MutableStateFlow
Critical side-effects (payment, upload)WorkManager / background service
DatabaseRoom / SQLDelight
NetworkKtor + Repository

Integrations

KRelay is framework-agnostic. It connects to whatever navigation, media, or permission library you already use — ViewModels stay clean of all framework imports.

CategoryLibrary
NavigationVoyager · Decompose · Navigation Compose
MediaPeekaboo (image/camera picker)
PermissionsMoko Permissions
BiometricsMoko Biometry
ReviewsPlay Core · StoreKit
DIKoin · Hilt

See Integration Guides for step-by-step examples.


Compatibility

KRelayKotlinAGPAndroid minSdkiOS
2.1.x2.1.x8.x2414.0+
2.0.x2.1.x8.x2414.0+
1.1.x2.0.x8.x2313.0+
1.0.x1.9.x7.x2113.0+

Platforms: Android arm64 · Android x86_64 · iOS arm64 (device) · iOS arm64 (simulator) · iOS x64 (simulator)


What's New

v2.1.1 — Hardened & Standardized
  • Atomic dispatch — the impl lookup, queue insertion, and persistence decision happen inside a single lock, closing the TOCTOU window that could strand an action indefinitely.
  • krelay-compose artifactKRelayEffect<T> and rememberKRelayImpl<T> published as dev.brewkits:krelay-compose:2.1.1, separate from the zero-dependency core.
  • ProGuard/R8-safe persistenceregisterActionFactory and dispatchPersisted now require an explicit stable featureKey string. Old overloads deprecated with replaceWith guidance.
  • Identity-aware unregisterunregister(impl) only removes the registration if the stored reference matches, preventing a recomposing Compose component from clearing a newer registration.
  • Thread-safe metrics — all KRelayMetrics operations are now lock-protected.
  • iOS registration validationregisterFeature validates interface conformance at runtime; crashes in debug, warns in release.
  • Priority eviction — queue overflow now evicts the lowest-priority action, not the oldest FIFO item.
v2.1.0 — Compose, Persistence & Scope Tokens
  • KRelayEffect<T> and rememberKRelayImpl<T> Compose helpers
  • Persistent dispatch with dispatchPersisted<T>() — survives process death
  • SharedPreferencesPersistenceAdapter (Android) and NSUserDefaultsPersistenceAdapter (iOS)
  • Scope Token API: scopedToken() + cancelScope(token) for fine-grained ViewModel cleanup
  • dispatchWithPriority available on instances (was singleton-only)
  • resetConfiguration() without clearing the registry or queue
v2.0.0 — Instance API for Super Apps
  • KRelay.create("ScopeName") — isolated instances per module
  • KRelay.builder(...) — configure queue, expiry, and debug mode per instance
  • DI-friendly: KRelayInstance is an interface, injectable via Koin or Hilt
  • 100% backward compatible with v1.x

Documentation

GuideDescription
Compose IntegrationKRelayEffect, rememberKRelayImpl, Navigation Compose, Voyager
SwiftUI IntegrationiOS-specific patterns, XCTest
Integration GuidesVoyager, Decompose, Moko, Peekaboo, DI
Lifecycle GuideActivity · Fragment · UIViewController · SwiftUI
Testing GuidePatterns, mocks, instrumented tests
Anti-PatternsWhat not to do and why
ArchitectureInternals deep dive
API ReferenceFull API cheat sheet
Managing WarningsSuppress @OptIn at module level
Migration to v2.0Upgrading from v1.x

License

Copyright 2026 Brewkits
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

Made with care by Nguyễn Tuấn Việt · Brewkits

Issues · Changelog · datacenter111@gmail.com

About

Dispatch Toasts, Navigation & Permissions from KMP shared ViewModels to Android/iOS — zero memory leaks, survives screen rotation. Works with Voyager, Decompose, Moko, Peekaboo & Compose Multiplatform.

Topics

Resources

Stars

14 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Repository files navigation

KRelay logo

KRelay

Type-safe native interop bridge for Kotlin Multiplatform.

Dispatch UI commands (Toast, Navigation, Permissions) from shared ViewModels to Android and iOS — leak-free, rotation-safe, always on the Main Thread.

Maven CentralKotlinKMPZero DependenciesLicense


The "State" Trap

Most mobile apps treat everything (Toasts, Navigation, Alerts) as State. This is why you see "Ghost Toasts" popping up after rotation, or users stuck because a navigation event fired in the 300ms "blind spot" during an Activity restart.

ApproachThe Pain Point
Pass Activity / UIViewControllerMemory leaks and onDestroy boilerplate
SharedFlow(replay=0)Events are lost during screen rotation
StateFlow as EventDouble-execution / Side-effects that "stick"
ChannelsSingle-observer only (unsuitable for UI + Analytics)

KRelay is a Buffered Multicasting bridge. Your shared ViewModel signals an intent, and the platform fulfills it — exactly once, always on the Main Thread, even if the UI wasn't ready when you called it.


Architectural Philosophy

"State is for seeing, Event is for running."

KRelay is designed for mission-critical systems (VoIP, Fintech, SOS) where event delivery is non-negotiable.

  • Buffering: Holds events during the UI startup "blind spot."
  • Multicasting: One dispatch, multiple listeners (UI, Analytics, Logging).
  • No-Replay: Side-effects vanish immediately after they run.

Read the State vs. Event: Why your MVI/Redux app is probably leaking side-effects.


Install

// shared/build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.1.1")
implementation("dev.brewkits:krelay-compose:2.1.1") // Compose helpers (optional)
}

Quickstart

1. Define a contract in commonMain

interfaceToastFeature : RelayFeature {
funshow(message:String)
}

2. Dispatch from your ViewModel

classLoginViewModel : ViewModel() {
funonLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
// Zero platform imports. Zero leaks. Queued if the UI isn't ready yet.
}
}

3. Register the platform implementation

// Android — Activity or ComposableKRelay.register<ToastFeature>(object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
})
// iOS — Swift
lettoastClass=KRelayKClassHelpersKt.toastFeatureKClass()KRelayIosHelperKt.registerFeature(
instance:KRelay.shared.instance,
kClass: toastClass,
impl:IOSToast(viewController:self))

That's all the wiring needed. KRelay routes the call to the Main Thread, replays it if the UI wasn't ready, and releases the implementation when it's GC'd.


How it works

ViewModel KRelay Platform
─────────────────────────────────────────────────────────────
dispatch<Toast> { ... } ──► impl registered?
├── yes: runOnMain { block(impl) }
└── no: sticky queue ──► replay on register()

Three guarantees, always active:

  • WeakReference registry — implementations are never strongly held; no onDestroy cleanup needed for 99% of cases.
  • Sticky queue — actions dispatched before registration are held and replayed automatically. Screen rotation, async init, cold start — all covered.
  • Main Thread dispatch — regardless of which thread dispatch is called from, the block executes on Android's Looper.mainLooper() / iOS's GCD main queue.

Core API

The API is identical on the global singleton and on any isolated instance.

// RegistrationKRelay.register<ToastFeature>(impl)
KRelay.unregister<ToastFeature>() // unconditionalKRelay.unregister<ToastFeature>(impl) // identity-safe (won't clear a newer registration)KRelay.isRegistered<ToastFeature>()
// DispatchKRelay.dispatch<ToastFeature> { it.show("Hello") }
KRelay.dispatchWithPriority<ToastFeature>(ActionPriority.CRITICAL) { it.show("Error!") }
// Queue managementKRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
// Scope tokens — cancel queued actions by caller identityval token = scopedToken()
KRelay.dispatch<ToastFeature>(token) { it.show("...") }
KRelay.cancelScope(token) // in ViewModel.onCleared()// DebugKRelay.dump()
KRelay.debugMode =true

Priority dispatch

When multiple actions queue up before an implementation registers, higher-priority actions replay first. On overflow, the lowest-priority action is evicted (not just the oldest).

KRelay.dispatchWithPriority<NavFeature>(ActionPriority.HIGH) { it.goToHome() }
KRelay.dispatchWithPriority<NavFeature>(ActionPriority.CRITICAL) { it.showError("Timeout") }
// ActionPriority: LOW(0) NORMAL(50) HIGH(100) CRITICAL(1000)

Persistent dispatch

Survives process death. The action is saved to SharedPreferences (Android) or NSUserDefaults (iOS) and restored on next launch.

// Register a factory to reconstruct the action from its payload
instance.registerActionFactory<ToastFeature>("toast", "show") { payload ->
{ feature -> feature.show(payload) }
}
// Dispatch — persisted to disk if no impl is available
instance.dispatchPersisted<ToastFeature>("toast", "show", "Payment received")
// On app restart — restores actions into the in-memory queue
instance.restorePersistedActions()

Use an explicit string featureKey (not the class name) — class names can be obfuscated by ProGuard/R8.


Instance API — modular apps and DI

The singleton is fine for small apps. For multi-module projects or Koin/Hilt injection, create isolated instances:

// Each module owns its registry — no cross-module interferenceval rideKRelay =KRelay.create("Rides")
val foodKRelay =KRelay.create("Food")
// Or with custom settings via builderval krelay =KRelay.builder("Payment")
.maxQueueSize(50)
.actionExpiry(60_000L)
.debugMode(BuildConfig.DEBUG)
.build()

Inject into ViewModels via Koin:

val appModule = module {
single { KRelay.create("AppScope") }
viewModel { LoginViewModel(krelay = get()) }
}
classLoginViewModel(privatevalkrelay:KRelayInstance) : ViewModel() {
funonSuccess() { krelay.dispatch<NavFeature> { it.goToHome() } }
}

Compose Multiplatform

Add krelay-compose and use the built-in helpers:

// Registers when composition enters, unregisters when it leaves
@Composable
funHomeScreen() {
val context =LocalContext.current
KRelayEffect<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// ...
}
// When you need to use the implementation in the same composable
@Composable
funHomeScreen() {
val snackbarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
rememberKRelayImpl<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) {
scope.launch { snackbarState.showSnackbar(message) }
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) { ... }
}

Both helpers accept an optional instance parameter for the Instance API:

KRelayEffect<ToastFeature>(instance = myKRelayInstance) { ... }

Manual DisposableEffect? Always hoist the implementation into remember {}. Without it, Kotlin/Native's GC can collect the object before the first dispatch.

See Compose Integration Guide for full patterns including Navigation Compose and Voyager.


Testing

No mocking library required. Inject a real KRelayInstance and register plain Kotlin objects.

privatelateinitvar krelay:KRelayInstanceprivatelateinitvar viewModel:LoginViewModel
@BeforeTest
funsetup() {
krelay =KRelay.create("TestScope")
viewModel =LoginViewModel(krelay = krelay)
}
@AfterTest
funtearDown() {
krelay.reset()
}
@Test
fun`login success shows toast and navigates`() {
val toast =MockToast()
val nav =MockNav()
krelay.register<ToastFeature>(toast)
krelay.register<NavFeature>(nav)
viewModel.onLoginSuccess()
assertEquals("Welcome back!", toast.lastMessage)
assertEquals("home", nav.lastDestination)
}
classMockToast : ToastFeature {
var lastMessage:String?=nulloverridefunshow(message:String) { lastMessage = message }
}
classMockNav : NavFeature {
var lastDestination:String?=nulloverridefunnavigateTo(screen:String) { lastDestination = screen }
}

Run the test suite:

./gradlew :krelay:test # JVM (fast)
./gradlew :krelay:iosSimulatorArm64Test # iOS Simulator
./gradlew :krelay:connectedDebugAndroidTest # Real Android device

Memory safety

By default, three passive protections apply to every queued action:

ProtectionDefaultBehaviour
WeakReferenceAlways onPlatform impls released when GC'd — no onDestroy cleanup needed
actionExpiryMs5 minQueued actions expire and are dropped automatically
maxQueueSize100When full, lowest-priority (or oldest) action is evicted

For granular control, use scope tokens to cancel only the actions queued by a specific ViewModel:

classMyViewModel : ViewModel() {
privateval token = scopedToken()
fundoWork() =KRelay.dispatch<WorkFeature>(token) { it.run() }
overridefunonCleared() =KRelay.cancelScope(token)
}

When not to use KRelay

KRelay is for one-way, fire-and-forget UI commands. For anything else, use the right tool:

ScenarioBetter alternative
Need a return valuesuspend fun + expect/actual
Reactive UI stateStateFlow / MutableStateFlow
Critical side-effects (payment, upload)WorkManager / background service
DatabaseRoom / SQLDelight
NetworkKtor + Repository

Integrations

KRelay is framework-agnostic. It connects to whatever navigation, media, or permission library you already use — ViewModels stay clean of all framework imports.

CategoryLibrary
NavigationVoyager · Decompose · Navigation Compose
MediaPeekaboo (image/camera picker)
PermissionsMoko Permissions
BiometricsMoko Biometry
ReviewsPlay Core · StoreKit
DIKoin · Hilt

See Integration Guides for step-by-step examples.


Compatibility

KRelayKotlinAGPAndroid minSdkiOS
2.1.x2.1.x8.x2414.0+
2.0.x2.1.x8.x2414.0+
1.1.x2.0.x8.x2313.0+
1.0.x1.9.x7.x2113.0+

Platforms: Android arm64 · Android x86_64 · iOS arm64 (device) · iOS arm64 (simulator) · iOS x64 (simulator)


What's New

v2.1.1 — Hardened & Standardized
  • Atomic dispatch — the impl lookup, queue insertion, and persistence decision happen inside a single lock, closing the TOCTOU window that could strand an action indefinitely.
  • krelay-compose artifactKRelayEffect<T> and rememberKRelayImpl<T> published as dev.brewkits:krelay-compose:2.1.1, separate from the zero-dependency core.
  • ProGuard/R8-safe persistenceregisterActionFactory and dispatchPersisted now require an explicit stable featureKey string. Old overloads deprecated with replaceWith guidance.
  • Identity-aware unregisterunregister(impl) only removes the registration if the stored reference matches, preventing a recomposing Compose component from clearing a newer registration.
  • Thread-safe metrics — all KRelayMetrics operations are now lock-protected.
  • iOS registration validationregisterFeature validates interface conformance at runtime; crashes in debug, warns in release.
  • Priority eviction — queue overflow now evicts the lowest-priority action, not the oldest FIFO item.
v2.1.0 — Compose, Persistence & Scope Tokens
  • KRelayEffect<T> and rememberKRelayImpl<T> Compose helpers
  • Persistent dispatch with dispatchPersisted<T>() — survives process death
  • SharedPreferencesPersistenceAdapter (Android) and NSUserDefaultsPersistenceAdapter (iOS)
  • Scope Token API: scopedToken() + cancelScope(token) for fine-grained ViewModel cleanup
  • dispatchWithPriority available on instances (was singleton-only)
  • resetConfiguration() without clearing the registry or queue
v2.0.0 — Instance API for Super Apps
  • KRelay.create("ScopeName") — isolated instances per module
  • KRelay.builder(...) — configure queue, expiry, and debug mode per instance
  • DI-friendly: KRelayInstance is an interface, injectable via Koin or Hilt
  • 100% backward compatible with v1.x

Documentation

GuideDescription
Compose IntegrationKRelayEffect, rememberKRelayImpl, Navigation Compose, Voyager
SwiftUI IntegrationiOS-specific patterns, XCTest
Integration GuidesVoyager, Decompose, Moko, Peekaboo, DI
Lifecycle GuideActivity · Fragment · UIViewController · SwiftUI
Testing GuidePatterns, mocks, instrumented tests
Anti-PatternsWhat not to do and why
ArchitectureInternals deep dive
API ReferenceFull API cheat sheet
Managing WarningsSuppress @OptIn at module level
Migration to v2.0Upgrading from v1.x

License

Copyright 2026 Brewkits
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

Made with care by Nguyễn Tuấn Việt · Brewkits

Issues · Changelog · datacenter111@gmail.com

About

Dispatch Toasts, Navigation & Permissions from KMP shared ViewModels to Android/iOS — zero memory leaks, survives screen rotation. Works with Voyager, Decompose, Moko, Peekaboo & Compose Multiplatform.

Topics

Resources

Stars

14 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

KRelay logo

KRelay

Type-safe native interop bridge for Kotlin Multiplatform.

Dispatch UI commands (Toast, Navigation, Permissions) from shared ViewModels to Android and iOS — leak-free, rotation-safe, always on the Main Thread.

Maven CentralKotlinKMPZero DependenciesLicense


The "State" Trap

Most mobile apps treat everything (Toasts, Navigation, Alerts) as State. This is why you see "Ghost Toasts" popping up after rotation, or users stuck because a navigation event fired in the 300ms "blind spot" during an Activity restart.

ApproachThe Pain Point
Pass Activity / UIViewControllerMemory leaks and onDestroy boilerplate
SharedFlow(replay=0)Events are lost during screen rotation
StateFlow as EventDouble-execution / Side-effects that "stick"
ChannelsSingle-observer only (unsuitable for UI + Analytics)

KRelay is a Buffered Multicasting bridge. Your shared ViewModel signals an intent, and the platform fulfills it — exactly once, always on the Main Thread, even if the UI wasn't ready when you called it.


Architectural Philosophy

"State is for seeing, Event is for running."

KRelay is designed for mission-critical systems (VoIP, Fintech, SOS) where event delivery is non-negotiable.

  • Buffering: Holds events during the UI startup "blind spot."
  • Multicasting: One dispatch, multiple listeners (UI, Analytics, Logging).
  • No-Replay: Side-effects vanish immediately after they run.

Read the State vs. Event: Why your MVI/Redux app is probably leaking side-effects.


Install

// shared/build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.1.1")
implementation("dev.brewkits:krelay-compose:2.1.1") // Compose helpers (optional)
}

Quickstart

1. Define a contract in commonMain

interfaceToastFeature : RelayFeature {
funshow(message:String)
}

2. Dispatch from your ViewModel

classLoginViewModel : ViewModel() {
funonLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
// Zero platform imports. Zero leaks. Queued if the UI isn't ready yet.
}
}

3. Register the platform implementation

// Android — Activity or ComposableKRelay.register<ToastFeature>(object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
})
// iOS — Swift
lettoastClass=KRelayKClassHelpersKt.toastFeatureKClass()KRelayIosHelperKt.registerFeature(
instance:KRelay.shared.instance,
kClass: toastClass,
impl:IOSToast(viewController:self))

That's all the wiring needed. KRelay routes the call to the Main Thread, replays it if the UI wasn't ready, and releases the implementation when it's GC'd.


How it works

ViewModel KRelay Platform
─────────────────────────────────────────────────────────────
dispatch<Toast> { ... } ──► impl registered?
├── yes: runOnMain { block(impl) }
└── no: sticky queue ──► replay on register()

Three guarantees, always active:

  • WeakReference registry — implementations are never strongly held; no onDestroy cleanup needed for 99% of cases.
  • Sticky queue — actions dispatched before registration are held and replayed automatically. Screen rotation, async init, cold start — all covered.
  • Main Thread dispatch — regardless of which thread dispatch is called from, the block executes on Android's Looper.mainLooper() / iOS's GCD main queue.

Core API

The API is identical on the global singleton and on any isolated instance.

// RegistrationKRelay.register<ToastFeature>(impl)
KRelay.unregister<ToastFeature>() // unconditionalKRelay.unregister<ToastFeature>(impl) // identity-safe (won't clear a newer registration)KRelay.isRegistered<ToastFeature>()
// DispatchKRelay.dispatch<ToastFeature> { it.show("Hello") }
KRelay.dispatchWithPriority<ToastFeature>(ActionPriority.CRITICAL) { it.show("Error!") }
// Queue managementKRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
// Scope tokens — cancel queued actions by caller identityval token = scopedToken()
KRelay.dispatch<ToastFeature>(token) { it.show("...") }
KRelay.cancelScope(token) // in ViewModel.onCleared()// DebugKRelay.dump()
KRelay.debugMode =true

Priority dispatch

When multiple actions queue up before an implementation registers, higher-priority actions replay first. On overflow, the lowest-priority action is evicted (not just the oldest).

KRelay.dispatchWithPriority<NavFeature>(ActionPriority.HIGH) { it.goToHome() }
KRelay.dispatchWithPriority<NavFeature>(ActionPriority.CRITICAL) { it.showError("Timeout") }
// ActionPriority: LOW(0) NORMAL(50) HIGH(100) CRITICAL(1000)

Persistent dispatch

Survives process death. The action is saved to SharedPreferences (Android) or NSUserDefaults (iOS) and restored on next launch.

// Register a factory to reconstruct the action from its payload
instance.registerActionFactory<ToastFeature>("toast", "show") { payload ->
{ feature -> feature.show(payload) }
}
// Dispatch — persisted to disk if no impl is available
instance.dispatchPersisted<ToastFeature>("toast", "show", "Payment received")
// On app restart — restores actions into the in-memory queue
instance.restorePersistedActions()

Use an explicit string featureKey (not the class name) — class names can be obfuscated by ProGuard/R8.


Instance API — modular apps and DI

The singleton is fine for small apps. For multi-module projects or Koin/Hilt injection, create isolated instances:

// Each module owns its registry — no cross-module interferenceval rideKRelay =KRelay.create("Rides")
val foodKRelay =KRelay.create("Food")
// Or with custom settings via builderval krelay =KRelay.builder("Payment")
.maxQueueSize(50)
.actionExpiry(60_000L)
.debugMode(BuildConfig.DEBUG)
.build()

Inject into ViewModels via Koin:

val appModule = module {
single { KRelay.create("AppScope") }
viewModel { LoginViewModel(krelay = get()) }
}
classLoginViewModel(privatevalkrelay:KRelayInstance) : ViewModel() {
funonSuccess() { krelay.dispatch<NavFeature> { it.goToHome() } }
}

Compose Multiplatform

Add krelay-compose and use the built-in helpers:

// Registers when composition enters, unregisters when it leaves
@Composable
funHomeScreen() {
val context =LocalContext.current
KRelayEffect<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// ...
}
// When you need to use the implementation in the same composable
@Composable
funHomeScreen() {
val snackbarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
rememberKRelayImpl<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) {
scope.launch { snackbarState.showSnackbar(message) }
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) { ... }
}

Both helpers accept an optional instance parameter for the Instance API:

KRelayEffect<ToastFeature>(instance = myKRelayInstance) { ... }

Manual DisposableEffect? Always hoist the implementation into remember {}. Without it, Kotlin/Native's GC can collect the object before the first dispatch.

See Compose Integration Guide for full patterns including Navigation Compose and Voyager.


Testing

No mocking library required. Inject a real KRelayInstance and register plain Kotlin objects.

privatelateinitvar krelay:KRelayInstanceprivatelateinitvar viewModel:LoginViewModel
@BeforeTest
funsetup() {
krelay =KRelay.create("TestScope")
viewModel =LoginViewModel(krelay = krelay)
}
@AfterTest
funtearDown() {
krelay.reset()
}
@Test
fun`login success shows toast and navigates`() {
val toast =MockToast()
val nav =MockNav()
krelay.register<ToastFeature>(toast)
krelay.register<NavFeature>(nav)
viewModel.onLoginSuccess()
assertEquals("Welcome back!", toast.lastMessage)
assertEquals("home", nav.lastDestination)
}
classMockToast : ToastFeature {
var lastMessage:String?=nulloverridefunshow(message:String) { lastMessage = message }
}
classMockNav : NavFeature {
var lastDestination:String?=nulloverridefunnavigateTo(screen:String) { lastDestination = screen }
}

Run the test suite:

./gradlew :krelay:test # JVM (fast)
./gradlew :krelay:iosSimulatorArm64Test # iOS Simulator
./gradlew :krelay:connectedDebugAndroidTest # Real Android device

Memory safety

By default, three passive protections apply to every queued action:

ProtectionDefaultBehaviour
WeakReferenceAlways onPlatform impls released when GC'd — no onDestroy cleanup needed
actionExpiryMs5 minQueued actions expire and are dropped automatically
maxQueueSize100When full, lowest-priority (or oldest) action is evicted

For granular control, use scope tokens to cancel only the actions queued by a specific ViewModel:

classMyViewModel : ViewModel() {
privateval token = scopedToken()
fundoWork() =KRelay.dispatch<WorkFeature>(token) { it.run() }
overridefunonCleared() =KRelay.cancelScope(token)
}

When not to use KRelay

KRelay is for one-way, fire-and-forget UI commands. For anything else, use the right tool:

ScenarioBetter alternative
Need a return valuesuspend fun + expect/actual
Reactive UI stateStateFlow / MutableStateFlow
Critical side-effects (payment, upload)WorkManager / background service
DatabaseRoom / SQLDelight
NetworkKtor + Repository

Integrations

KRelay is framework-agnostic. It connects to whatever navigation, media, or permission library you already use — ViewModels stay clean of all framework imports.

CategoryLibrary
NavigationVoyager · Decompose · Navigation Compose
MediaPeekaboo (image/camera picker)
PermissionsMoko Permissions
BiometricsMoko Biometry
ReviewsPlay Core · StoreKit
DIKoin · Hilt

See Integration Guides for step-by-step examples.


Compatibility

KRelayKotlinAGPAndroid minSdkiOS
2.1.x2.1.x8.x2414.0+
2.0.x2.1.x8.x2414.0+
1.1.x2.0.x8.x2313.0+
1.0.x1.9.x7.x2113.0+

Platforms: Android arm64 · Android x86_64 · iOS arm64 (device) · iOS arm64 (simulator) · iOS x64 (simulator)


What's New

v2.1.1 — Hardened & Standardized
  • Atomic dispatch — the impl lookup, queue insertion, and persistence decision happen inside a single lock, closing the TOCTOU window that could strand an action indefinitely.
  • krelay-compose artifactKRelayEffect<T> and rememberKRelayImpl<T> published as dev.brewkits:krelay-compose:2.1.1, separate from the zero-dependency core.
  • ProGuard/R8-safe persistenceregisterActionFactory and dispatchPersisted now require an explicit stable featureKey string. Old overloads deprecated with replaceWith guidance.
  • Identity-aware unregisterunregister(impl) only removes the registration if the stored reference matches, preventing a recomposing Compose component from clearing a newer registration.
  • Thread-safe metrics — all KRelayMetrics operations are now lock-protected.
  • iOS registration validationregisterFeature validates interface conformance at runtime; crashes in debug, warns in release.
  • Priority eviction — queue overflow now evicts the lowest-priority action, not the oldest FIFO item.
v2.1.0 — Compose, Persistence & Scope Tokens
  • KRelayEffect<T> and rememberKRelayImpl<T> Compose helpers
  • Persistent dispatch with dispatchPersisted<T>() — survives process death
  • SharedPreferencesPersistenceAdapter (Android) and NSUserDefaultsPersistenceAdapter (iOS)
  • Scope Token API: scopedToken() + cancelScope(token) for fine-grained ViewModel cleanup
  • dispatchWithPriority available on instances (was singleton-only)
  • resetConfiguration() without clearing the registry or queue
v2.0.0 — Instance API for Super Apps
  • KRelay.create("ScopeName") — isolated instances per module
  • KRelay.builder(...) — configure queue, expiry, and debug mode per instance
  • DI-friendly: KRelayInstance is an interface, injectable via Koin or Hilt
  • 100% backward compatible with v1.x

Documentation

GuideDescription
Compose IntegrationKRelayEffect, rememberKRelayImpl, Navigation Compose, Voyager
SwiftUI IntegrationiOS-specific patterns, XCTest
Integration GuidesVoyager, Decompose, Moko, Peekaboo, DI
Lifecycle GuideActivity · Fragment · UIViewController · SwiftUI
Testing GuidePatterns, mocks, instrumented tests
Anti-PatternsWhat not to do and why
ArchitectureInternals deep dive
API ReferenceFull API cheat sheet
Managing WarningsSuppress @OptIn at module level
Migration to v2.0Upgrading from v1.x

License

Copyright 2026 Brewkits
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

Made with care by Nguyễn Tuấn Việt · Brewkits

Issues · Changelog · datacenter111@gmail.com

About

Dispatch Toasts, Navigation & Permissions from KMP shared ViewModels to Android/iOS — zero memory leaks, survives screen rotation. Works with Voyager, Decompose, Moko, Peekaboo & Compose Multiplatform.

Topics

Resources

Stars

14 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Repository files navigation

KRelay logo

KRelay

Type-safe native interop bridge for Kotlin Multiplatform.

Dispatch UI commands (Toast, Navigation, Permissions) from shared ViewModels to Android and iOS — leak-free, rotation-safe, always on the Main Thread.

Maven CentralKotlinKMPZero DependenciesLicense


The "State" Trap

Most mobile apps treat everything (Toasts, Navigation, Alerts) as State. This is why you see "Ghost Toasts" popping up after rotation, or users stuck because a navigation event fired in the 300ms "blind spot" during an Activity restart.

ApproachThe Pain Point
Pass Activity / UIViewControllerMemory leaks and onDestroy boilerplate
SharedFlow(replay=0)Events are lost during screen rotation
StateFlow as EventDouble-execution / Side-effects that "stick"
ChannelsSingle-observer only (unsuitable for UI + Analytics)

KRelay is a Buffered Multicasting bridge. Your shared ViewModel signals an intent, and the platform fulfills it — exactly once, always on the Main Thread, even if the UI wasn't ready when you called it.


Architectural Philosophy

"State is for seeing, Event is for running."

KRelay is designed for mission-critical systems (VoIP, Fintech, SOS) where event delivery is non-negotiable.

  • Buffering: Holds events during the UI startup "blind spot."
  • Multicasting: One dispatch, multiple listeners (UI, Analytics, Logging).
  • No-Replay: Side-effects vanish immediately after they run.

Read the State vs. Event: Why your MVI/Redux app is probably leaking side-effects.


Install

// shared/build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.1.1")
implementation("dev.brewkits:krelay-compose:2.1.1") // Compose helpers (optional)
}

Quickstart

1. Define a contract in commonMain

interfaceToastFeature : RelayFeature {
funshow(message:String)
}

2. Dispatch from your ViewModel

classLoginViewModel : ViewModel() {
funonLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
// Zero platform imports. Zero leaks. Queued if the UI isn't ready yet.
}
}

3. Register the platform implementation

// Android — Activity or ComposableKRelay.register<ToastFeature>(object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
})
// iOS — Swift
lettoastClass=KRelayKClassHelpersKt.toastFeatureKClass()KRelayIosHelperKt.registerFeature(
instance:KRelay.shared.instance,
kClass: toastClass,
impl:IOSToast(viewController:self))

That's all the wiring needed. KRelay routes the call to the Main Thread, replays it if the UI wasn't ready, and releases the implementation when it's GC'd.


How it works

ViewModel KRelay Platform
─────────────────────────────────────────────────────────────
dispatch<Toast> { ... } ──► impl registered?
├── yes: runOnMain { block(impl) }
└── no: sticky queue ──► replay on register()

Three guarantees, always active:

  • WeakReference registry — implementations are never strongly held; no onDestroy cleanup needed for 99% of cases.
  • Sticky queue — actions dispatched before registration are held and replayed automatically. Screen rotation, async init, cold start — all covered.
  • Main Thread dispatch — regardless of which thread dispatch is called from, the block executes on Android's Looper.mainLooper() / iOS's GCD main queue.

Core API

The API is identical on the global singleton and on any isolated instance.

// RegistrationKRelay.register<ToastFeature>(impl)
KRelay.unregister<ToastFeature>() // unconditionalKRelay.unregister<ToastFeature>(impl) // identity-safe (won't clear a newer registration)KRelay.isRegistered<ToastFeature>()
// DispatchKRelay.dispatch<ToastFeature> { it.show("Hello") }
KRelay.dispatchWithPriority<ToastFeature>(ActionPriority.CRITICAL) { it.show("Error!") }
// Queue managementKRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
// Scope tokens — cancel queued actions by caller identityval token = scopedToken()
KRelay.dispatch<ToastFeature>(token) { it.show("...") }
KRelay.cancelScope(token) // in ViewModel.onCleared()// DebugKRelay.dump()
KRelay.debugMode =true

Priority dispatch

When multiple actions queue up before an implementation registers, higher-priority actions replay first. On overflow, the lowest-priority action is evicted (not just the oldest).

KRelay.dispatchWithPriority<NavFeature>(ActionPriority.HIGH) { it.goToHome() }
KRelay.dispatchWithPriority<NavFeature>(ActionPriority.CRITICAL) { it.showError("Timeout") }
// ActionPriority: LOW(0) NORMAL(50) HIGH(100) CRITICAL(1000)

Persistent dispatch

Survives process death. The action is saved to SharedPreferences (Android) or NSUserDefaults (iOS) and restored on next launch.

// Register a factory to reconstruct the action from its payload
instance.registerActionFactory<ToastFeature>("toast", "show") { payload ->
{ feature -> feature.show(payload) }
}
// Dispatch — persisted to disk if no impl is available
instance.dispatchPersisted<ToastFeature>("toast", "show", "Payment received")
// On app restart — restores actions into the in-memory queue
instance.restorePersistedActions()

Use an explicit string featureKey (not the class name) — class names can be obfuscated by ProGuard/R8.


Instance API — modular apps and DI

The singleton is fine for small apps. For multi-module projects or Koin/Hilt injection, create isolated instances:

// Each module owns its registry — no cross-module interferenceval rideKRelay =KRelay.create("Rides")
val foodKRelay =KRelay.create("Food")
// Or with custom settings via builderval krelay =KRelay.builder("Payment")
.maxQueueSize(50)
.actionExpiry(60_000L)
.debugMode(BuildConfig.DEBUG)
.build()

Inject into ViewModels via Koin:

val appModule = module {
single { KRelay.create("AppScope") }
viewModel { LoginViewModel(krelay = get()) }
}
classLoginViewModel(privatevalkrelay:KRelayInstance) : ViewModel() {
funonSuccess() { krelay.dispatch<NavFeature> { it.goToHome() } }
}

Compose Multiplatform

Add krelay-compose and use the built-in helpers:

// Registers when composition enters, unregisters when it leaves
@Composable
funHomeScreen() {
val context =LocalContext.current
KRelayEffect<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// ...
}
// When you need to use the implementation in the same composable
@Composable
funHomeScreen() {
val snackbarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
rememberKRelayImpl<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) {
scope.launch { snackbarState.showSnackbar(message) }
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) { ... }
}

Both helpers accept an optional instance parameter for the Instance API:

KRelayEffect<ToastFeature>(instance = myKRelayInstance) { ... }

Manual DisposableEffect? Always hoist the implementation into remember {}. Without it, Kotlin/Native's GC can collect the object before the first dispatch.

See Compose Integration Guide for full patterns including Navigation Compose and Voyager.


Testing

No mocking library required. Inject a real KRelayInstance and register plain Kotlin objects.

privatelateinitvar krelay:KRelayInstanceprivatelateinitvar viewModel:LoginViewModel
@BeforeTest
funsetup() {
krelay =KRelay.create("TestScope")
viewModel =LoginViewModel(krelay = krelay)
}
@AfterTest
funtearDown() {
krelay.reset()
}
@Test
fun`login success shows toast and navigates`() {
val toast =MockToast()
val nav =MockNav()
krelay.register<ToastFeature>(toast)
krelay.register<NavFeature>(nav)
viewModel.onLoginSuccess()
assertEquals("Welcome back!", toast.lastMessage)
assertEquals("home", nav.lastDestination)
}
classMockToast : ToastFeature {
var lastMessage:String?=nulloverridefunshow(message:String) { lastMessage = message }
}
classMockNav : NavFeature {
var lastDestination:String?=nulloverridefunnavigateTo(screen:String) { lastDestination = screen }
}

Run the test suite:

./gradlew :krelay:test # JVM (fast)
./gradlew :krelay:iosSimulatorArm64Test # iOS Simulator
./gradlew :krelay:connectedDebugAndroidTest # Real Android device

Memory safety

By default, three passive protections apply to every queued action:

ProtectionDefaultBehaviour
WeakReferenceAlways onPlatform impls released when GC'd — no onDestroy cleanup needed
actionExpiryMs5 minQueued actions expire and are dropped automatically
maxQueueSize100When full, lowest-priority (or oldest) action is evicted

For granular control, use scope tokens to cancel only the actions queued by a specific ViewModel:

classMyViewModel : ViewModel() {
privateval token = scopedToken()
fundoWork() =KRelay.dispatch<WorkFeature>(token) { it.run() }
overridefunonCleared() =KRelay.cancelScope(token)
}

When not to use KRelay

KRelay is for one-way, fire-and-forget UI commands. For anything else, use the right tool:

ScenarioBetter alternative
Need a return valuesuspend fun + expect/actual
Reactive UI stateStateFlow / MutableStateFlow
Critical side-effects (payment, upload)WorkManager / background service
DatabaseRoom / SQLDelight
NetworkKtor + Repository

Integrations

KRelay is framework-agnostic. It connects to whatever navigation, media, or permission library you already use — ViewModels stay clean of all framework imports.

CategoryLibrary
NavigationVoyager · Decompose · Navigation Compose
MediaPeekaboo (image/camera picker)
PermissionsMoko Permissions
BiometricsMoko Biometry
ReviewsPlay Core · StoreKit
DIKoin · Hilt

See Integration Guides for step-by-step examples.


Compatibility

KRelayKotlinAGPAndroid minSdkiOS
2.1.x2.1.x8.x2414.0+
2.0.x2.1.x8.x2414.0+
1.1.x2.0.x8.x2313.0+
1.0.x1.9.x7.x2113.0+

Platforms: Android arm64 · Android x86_64 · iOS arm64 (device) · iOS arm64 (simulator) · iOS x64 (simulator)


What's New

v2.1.1 — Hardened & Standardized
  • Atomic dispatch — the impl lookup, queue insertion, and persistence decision happen inside a single lock, closing the TOCTOU window that could strand an action indefinitely.
  • krelay-compose artifactKRelayEffect<T> and rememberKRelayImpl<T> published as dev.brewkits:krelay-compose:2.1.1, separate from the zero-dependency core.
  • ProGuard/R8-safe persistenceregisterActionFactory and dispatchPersisted now require an explicit stable featureKey string. Old overloads deprecated with replaceWith guidance.
  • Identity-aware unregisterunregister(impl) only removes the registration if the stored reference matches, preventing a recomposing Compose component from clearing a newer registration.
  • Thread-safe metrics — all KRelayMetrics operations are now lock-protected.
  • iOS registration validationregisterFeature validates interface conformance at runtime; crashes in debug, warns in release.
  • Priority eviction — queue overflow now evicts the lowest-priority action, not the oldest FIFO item.
v2.1.0 — Compose, Persistence & Scope Tokens
  • KRelayEffect<T> and rememberKRelayImpl<T> Compose helpers
  • Persistent dispatch with dispatchPersisted<T>() — survives process death
  • SharedPreferencesPersistenceAdapter (Android) and NSUserDefaultsPersistenceAdapter (iOS)
  • Scope Token API: scopedToken() + cancelScope(token) for fine-grained ViewModel cleanup
  • dispatchWithPriority available on instances (was singleton-only)
  • resetConfiguration() without clearing the registry or queue
v2.0.0 — Instance API for Super Apps
  • KRelay.create("ScopeName") — isolated instances per module
  • KRelay.builder(...) — configure queue, expiry, and debug mode per instance
  • DI-friendly: KRelayInstance is an interface, injectable via Koin or Hilt
  • 100% backward compatible with v1.x

Documentation

GuideDescription
Compose IntegrationKRelayEffect, rememberKRelayImpl, Navigation Compose, Voyager
SwiftUI IntegrationiOS-specific patterns, XCTest
Integration GuidesVoyager, Decompose, Moko, Peekaboo, DI
Lifecycle GuideActivity · Fragment · UIViewController · SwiftUI
Testing GuidePatterns, mocks, instrumented tests
Anti-PatternsWhat not to do and why
ArchitectureInternals deep dive
API ReferenceFull API cheat sheet
Managing WarningsSuppress @OptIn at module level
Migration to v2.0Upgrading from v1.x

License

Copyright 2026 Brewkits
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

Made with care by Nguyễn Tuấn Việt · Brewkits

Issues · Changelog · datacenter111@gmail.com

About

Dispatch Toasts, Navigation & Permissions from KMP shared ViewModels to Android/iOS — zero memory leaks, survives screen rotation. Works with Voyager, Decompose, Moko, Peekaboo & Compose Multiplatform.

Topics

Resources

Stars

14 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages

, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Repository files navigation

KRelay logo

KRelay

Type-safe native interop bridge for Kotlin Multiplatform.

Dispatch UI commands (Toast, Navigation, Permissions) from shared ViewModels to Android and iOS — leak-free, rotation-safe, always on the Main Thread.

Maven CentralKotlinKMPZero DependenciesLicense


The "State" Trap

Most mobile apps treat everything (Toasts, Navigation, Alerts) as State. This is why you see "Ghost Toasts" popping up after rotation, or users stuck because a navigation event fired in the 300ms "blind spot" during an Activity restart.

ApproachThe Pain Point
Pass Activity / UIViewControllerMemory leaks and onDestroy boilerplate
SharedFlow(replay=0)Events are lost during screen rotation
StateFlow as EventDouble-execution / Side-effects that "stick"
ChannelsSingle-observer only (unsuitable for UI + Analytics)

KRelay is a Buffered Multicasting bridge. Your shared ViewModel signals an intent, and the platform fulfills it — exactly once, always on the Main Thread, even if the UI wasn't ready when you called it.


Architectural Philosophy

"State is for seeing, Event is for running."

KRelay is designed for mission-critical systems (VoIP, Fintech, SOS) where event delivery is non-negotiable.

  • Buffering: Holds events during the UI startup "blind spot."
  • Multicasting: One dispatch, multiple listeners (UI, Analytics, Logging).
  • No-Replay: Side-effects vanish immediately after they run.

Read the State vs. Event: Why your MVI/Redux app is probably leaking side-effects.


Install

// shared/build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:krelay:2.1.1")
implementation("dev.brewkits:krelay-compose:2.1.1") // Compose helpers (optional)
}

Quickstart

1. Define a contract in commonMain

interfaceToastFeature : RelayFeature {
funshow(message:String)
}

2. Dispatch from your ViewModel

classLoginViewModel : ViewModel() {
funonLoginSuccess() {
KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
// Zero platform imports. Zero leaks. Queued if the UI isn't ready yet.
}
}

3. Register the platform implementation

// Android — Activity or ComposableKRelay.register<ToastFeature>(object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
})
// iOS — Swift
lettoastClass=KRelayKClassHelpersKt.toastFeatureKClass()KRelayIosHelperKt.registerFeature(
instance:KRelay.shared.instance,
kClass: toastClass,
impl:IOSToast(viewController:self))

That's all the wiring needed. KRelay routes the call to the Main Thread, replays it if the UI wasn't ready, and releases the implementation when it's GC'd.


How it works

ViewModel KRelay Platform
─────────────────────────────────────────────────────────────
dispatch<Toast> { ... } ──► impl registered?
├── yes: runOnMain { block(impl) }
└── no: sticky queue ──► replay on register()

Three guarantees, always active:

  • WeakReference registry — implementations are never strongly held; no onDestroy cleanup needed for 99% of cases.
  • Sticky queue — actions dispatched before registration are held and replayed automatically. Screen rotation, async init, cold start — all covered.
  • Main Thread dispatch — regardless of which thread dispatch is called from, the block executes on Android's Looper.mainLooper() / iOS's GCD main queue.

Core API

The API is identical on the global singleton and on any isolated instance.

// RegistrationKRelay.register<ToastFeature>(impl)
KRelay.unregister<ToastFeature>() // unconditionalKRelay.unregister<ToastFeature>(impl) // identity-safe (won't clear a newer registration)KRelay.isRegistered<ToastFeature>()
// DispatchKRelay.dispatch<ToastFeature> { it.show("Hello") }
KRelay.dispatchWithPriority<ToastFeature>(ActionPriority.CRITICAL) { it.show("Error!") }
// Queue managementKRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
// Scope tokens — cancel queued actions by caller identityval token = scopedToken()
KRelay.dispatch<ToastFeature>(token) { it.show("...") }
KRelay.cancelScope(token) // in ViewModel.onCleared()// DebugKRelay.dump()
KRelay.debugMode =true

Priority dispatch

When multiple actions queue up before an implementation registers, higher-priority actions replay first. On overflow, the lowest-priority action is evicted (not just the oldest).

KRelay.dispatchWithPriority<NavFeature>(ActionPriority.HIGH) { it.goToHome() }
KRelay.dispatchWithPriority<NavFeature>(ActionPriority.CRITICAL) { it.showError("Timeout") }
// ActionPriority: LOW(0) NORMAL(50) HIGH(100) CRITICAL(1000)

Persistent dispatch

Survives process death. The action is saved to SharedPreferences (Android) or NSUserDefaults (iOS) and restored on next launch.

// Register a factory to reconstruct the action from its payload
instance.registerActionFactory<ToastFeature>("toast", "show") { payload ->
{ feature -> feature.show(payload) }
}
// Dispatch — persisted to disk if no impl is available
instance.dispatchPersisted<ToastFeature>("toast", "show", "Payment received")
// On app restart — restores actions into the in-memory queue
instance.restorePersistedActions()

Use an explicit string featureKey (not the class name) — class names can be obfuscated by ProGuard/R8.


Instance API — modular apps and DI

The singleton is fine for small apps. For multi-module projects or Koin/Hilt injection, create isolated instances:

// Each module owns its registry — no cross-module interferenceval rideKRelay =KRelay.create("Rides")
val foodKRelay =KRelay.create("Food")
// Or with custom settings via builderval krelay =KRelay.builder("Payment")
.maxQueueSize(50)
.actionExpiry(60_000L)
.debugMode(BuildConfig.DEBUG)
.build()

Inject into ViewModels via Koin:

val appModule = module {
single { KRelay.create("AppScope") }
viewModel { LoginViewModel(krelay = get()) }
}
classLoginViewModel(privatevalkrelay:KRelayInstance) : ViewModel() {
funonSuccess() { krelay.dispatch<NavFeature> { it.goToHome() } }
}

Compose Multiplatform

Add krelay-compose and use the built-in helpers:

// Registers when composition enters, unregisters when it leaves
@Composable
funHomeScreen() {
val context =LocalContext.current
KRelayEffect<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) =Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// ...
}
// When you need to use the implementation in the same composable
@Composable
funHomeScreen() {
val snackbarState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
rememberKRelayImpl<ToastFeature> {
object:ToastFeature {
overridefunshow(message:String) {
scope.launch { snackbarState.showSnackbar(message) }
}
}
}
Scaffold(snackbarHost = { SnackbarHost(snackbarState) }) { ... }
}

Both helpers accept an optional instance parameter for the Instance API:

KRelayEffect<ToastFeature>(instance = myKRelayInstance) { ... }

Manual DisposableEffect? Always hoist the implementation into remember {}. Without it, Kotlin/Native's GC can collect the object before the first dispatch.

See Compose Integration Guide for full patterns including Navigation Compose and Voyager.


Testing

No mocking library required. Inject a real KRelayInstance and register plain Kotlin objects.

privatelateinitvar krelay:KRelayInstanceprivatelateinitvar viewModel:LoginViewModel
@BeforeTest
funsetup() {
krelay =KRelay.create("TestScope")
viewModel =LoginViewModel(krelay = krelay)
}
@AfterTest
funtearDown() {
krelay.reset()
}
@Test
fun`login success shows toast and navigates`() {
val toast =MockToast()
val nav =MockNav()
krelay.register<ToastFeature>(toast)
krelay.register<NavFeature>(nav)
viewModel.onLoginSuccess()
assertEquals("Welcome back!", toast.lastMessage)
assertEquals("home", nav.lastDestination)
}
classMockToast : ToastFeature {
var lastMessage:String?=nulloverridefunshow(message:String) { lastMessage = message }
}
classMockNav : NavFeature {
var lastDestination:String?=nulloverridefunnavigateTo(screen:String) { lastDestination = screen }
}

Run the test suite:

./gradlew :krelay:test # JVM (fast)
./gradlew :krelay:iosSimulatorArm64Test # iOS Simulator
./gradlew :krelay:connectedDebugAndroidTest # Real Android device

Memory safety

By default, three passive protections apply to every queued action:

ProtectionDefaultBehaviour
WeakReferenceAlways onPlatform impls released when GC'd — no onDestroy cleanup needed
actionExpiryMs5 minQueued actions expire and are dropped automatically
maxQueueSize100When full, lowest-priority (or oldest) action is evicted

For granular control, use scope tokens to cancel only the actions queued by a specific ViewModel:

classMyViewModel : ViewModel() {
privateval token = scopedToken()
fundoWork() =KRelay.dispatch<WorkFeature>(token) { it.run() }
overridefunonCleared() =KRelay.cancelScope(token)
}

When not to use KRelay

KRelay is for one-way, fire-and-forget UI commands. For anything else, use the right tool:

ScenarioBetter alternative
Need a return valuesuspend fun + expect/actual
Reactive UI stateStateFlow / MutableStateFlow
Critical side-effects (payment, upload)WorkManager / background service
DatabaseRoom / SQLDelight
NetworkKtor + Repository

Integrations

KRelay is framework-agnostic. It connects to whatever navigation, media, or permission library you already use — ViewModels stay clean of all framework imports.

CategoryLibrary
NavigationVoyager · Decompose · Navigation Compose
MediaPeekaboo (image/camera picker)
PermissionsMoko Permissions
BiometricsMoko Biometry
ReviewsPlay Core · StoreKit
DIKoin · Hilt

See Integration Guides for step-by-step examples.


Compatibility

KRelayKotlinAGPAndroid minSdkiOS
2.1.x2.1.x8.x2414.0+
2.0.x2.1.x8.x2414.0+
1.1.x2.0.x8.x2313.0+
1.0.x1.9.x7.x2113.0+

Platforms: Android arm64 · Android x86_64 · iOS arm64 (device) · iOS arm64 (simulator) · iOS x64 (simulator)


What's New

v2.1.1 — Hardened & Standardized
  • Atomic dispatch — the impl lookup, queue insertion, and persistence decision happen inside a single lock, closing the TOCTOU window that could strand an action indefinitely.
  • krelay-compose artifactKRelayEffect<T> and rememberKRelayImpl<T> published as dev.brewkits:krelay-compose:2.1.1, separate from the zero-dependency core.
  • ProGuard/R8-safe persistenceregisterActionFactory and dispatchPersisted now require an explicit stable featureKey string. Old overloads deprecated with replaceWith guidance.
  • Identity-aware unregisterunregister(impl) only removes the registration if the stored reference matches, preventing a recomposing Compose component from clearing a newer registration.
  • Thread-safe metrics — all KRelayMetrics operations are now lock-protected.
  • iOS registration validationregisterFeature validates interface conformance at runtime; crashes in debug, warns in release.
  • Priority eviction — queue overflow now evicts the lowest-priority action, not the oldest FIFO item.
v2.1.0 — Compose, Persistence & Scope Tokens
  • KRelayEffect<T> and rememberKRelayImpl<T> Compose helpers
  • Persistent dispatch with dispatchPersisted<T>() — survives process death
  • SharedPreferencesPersistenceAdapter (Android) and NSUserDefaultsPersistenceAdapter (iOS)
  • Scope Token API: scopedToken() + cancelScope(token) for fine-grained ViewModel cleanup
  • dispatchWithPriority available on instances (was singleton-only)
  • resetConfiguration() without clearing the registry or queue
v2.0.0 — Instance API for Super Apps
  • KRelay.create("ScopeName") — isolated instances per module
  • KRelay.builder(...) — configure queue, expiry, and debug mode per instance
  • DI-friendly: KRelayInstance is an interface, injectable via Koin or Hilt
  • 100% backward compatible with v1.x

Documentation

GuideDescription
Compose IntegrationKRelayEffect, rememberKRelayImpl, Navigation Compose, Voyager
SwiftUI IntegrationiOS-specific patterns, XCTest
Integration GuidesVoyager, Decompose, Moko, Peekaboo, DI
Lifecycle GuideActivity · Fragment · UIViewController · SwiftUI
Testing GuidePatterns, mocks, instrumented tests
Anti-PatternsWhat not to do and why
ArchitectureInternals deep dive
API ReferenceFull API cheat sheet
Managing WarningsSuppress @OptIn at module level
Migration to v2.0Upgrading from v1.x

License

Copyright 2026 Brewkits
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

Made with care by Nguyễn Tuấn Việt · Brewkits

Issues · Changelog · datacenter111@gmail.com

About

Dispatch Toasts, Navigation & Permissions from KMP shared ViewModels to Android/iOS — zero memory leaks, survives screen rotation. Works with Voyager, Decompose, Moko, Peekaboo & Compose Multiplatform.

Topics

Resources

Stars

14 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages