Skip to content

Latest commit

 

History

32 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

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, iOS, Desktop JVM, and Web (WasmJs) — leak-free, rotation-safe, always on the Main Thread.

Maven Central Kotlin KMP Targets Zero Dependencies License


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.

Approach The Pain Point
Pass Activity / UIViewController Memory leaks and onDestroy boilerplate
SharedFlow(replay=0) Events are lost during screen rotation
StateFlow as Event Double-execution / Side-effects that "stick"
Channels Single-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.


When to Use & When NOT to Use KRelay

🛑 When NOT to Use KRelay

  • Standard CRUD Applications: If your app consists of typical REST/GraphQL data fetching, list views, and straightforward state rendering, you do NOT need KRelay. Standard Kotlin Coroutines (StateFlow for state, Channel for single-shot UI events) are completely sufficient and introduce zero external dependencies.
  • Synchronous Returns or Business Logic: KRelay is strictly for fire-and-forget UI intents. Never use it for queries, database operations, or operations needing return values.
Scenario Better Alternative Why
Standard CRUD UI Events Channel<UiEvent> / SharedFlow Simpler, standard Kotlin idioms, zero dependencies
Need a return value suspend fun + expect/actual KRelay is fire-and-forget; cannot return results
Reactive UI state StateFlow / MutableStateFlow State is for seeing; events are for running
Critical side-effects (payment, upload) WorkManager / iOS Background Tasks KRelay queue is memory-based; lost on process death
Database / Network Room / SQLDelight / Ktor Core business architecture belongs in Repositories

✅ When KRelay Shines (The Two Real-World Scenarios)

1. Complex Native Platform Interop Without Coroutine Bindings

When shared business logic must trigger platform APIs tightly coupled to the active Activity or UIViewController lifecycle:

  • Real-world Examples: In-App Review (Google Play Core / Apple StoreKit), Native Biometric Prompts (AndroidX Biometric / iOS LocalAuthentication), Photo Picker intents, or Push notification system bridges.
  • Why not standard Flow? Passing callbacks or maintaining custom expect/actual interfaces through 4–5 architectural layers (ViewModel → UseCase → Repository → Activity) creates fragile boilerplate and lifecycle leaks. With KRelay, the platform Activity or Composable dynamically registers when resumed and unregisters when disposed. KRelay buffers commands in its sticky queue during rotations or screen transitions, dispatching them the moment the native UI is ready.

2. Multi-Team Modular Architectures & Super Apps (Micro-Apps)

In large, enterprise-scale codebases where independent feature modules run under a single host shell:

  • Real-world Examples: Team A (Ride) and Team B (Food) running inside the same Super App (e.g., Grab, Uber, Gojek). Modules are strictly forbidden from having compile-time dependencies on each other.
  • Why KRelay? KRelay provides isolated, namespaced instances (KRelay.create("Rides") vs. KRelay.create("Food")) with ScopeToken lifecycle guards. Feature teams dispatch cross-boundary intents dynamically without direct compile-time coupling or global event bus collision.

Install

KRelay now provides a Bill of Materials (BOM) to automatically align versions across all artifacts.

// shared/build.gradle.kts
sourceSets {
    commonMain.dependencies {
        // 1. (Recommended) Import the BOM
        api(platform("dev.brewkits:krelay-bom:2.2.0"))
        
        // 2. Add dependencies without specifying versions
        implementation("dev.brewkits:krelay")
        implementation("dev.brewkits:krelay-compose") // Optional: Compose helpers
        implementation("dev.brewkits:krelay-flow")    // Optional: Flow operators (v2.2.0+)
    }
    commonTest.dependencies {
        implementation("dev.brewkits:krelay-testing") // Optional: Test fakes and assertions
    }
}

Quickstart

1. Define a contract in commonMain

interface ToastFeature : RelayFeature {
    fun show(message: String)
}

2. Dispatch from your ViewModel

class LoginViewModel : ViewModel() {
    fun onLoginSuccess() {
        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 Composable
KRelay.register<ToastFeature>(object : ToastFeature {
    override fun show(message: String) =
        Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
})
// iOS — Swift
let toastClass = 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.

// Registration
KRelay.register<ToastFeature>(impl)
KRelay.unregister<ToastFeature>()          // unconditional
KRelay.unregister<ToastFeature>(impl)      // identity-safe (won't clear a newer registration)
KRelay.isRegistered<ToastFeature>()

// Dispatch
KRelay.dispatch<ToastFeature> { it.show("Hello") }
KRelay.dispatchWithPriority<ToastFeature>(ActionPriority.CRITICAL) { it.show("Error!") }

// Queue management
KRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()

// Scope tokens — cancel queued actions by caller identity
val token = scopedToken()
KRelay.dispatch<ToastFeature>(token) { it.show("...") }
KRelay.cancelScope(token)              // in ViewModel.onCleared()

// Debug
KRelay.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 interference
val rideKRelay  = KRelay.create("Rides")
val foodKRelay  = KRelay.create("Food")

// Or with custom settings via builder
val 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()) }
}

class LoginViewModel(private val krelay: KRelayInstance) : ViewModel() {
    fun onSuccess() { 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
fun HomeScreen() {
    val context = LocalContext.current

    KRelayEffect<ToastFeature> {
        object : ToastFeature {
            override fun show(message: String) =
                Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
        }
    }
    // ...
}
// When you need to use the implementation in the same composable
@Composable
fun HomeScreen() {
    val snackbarState = remember { SnackbarHostState() }
    val scope = rememberCoroutineScope()

    rememberKRelayImpl<ToastFeature> {
        object : ToastFeature {
            override fun show(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

KRelay is designed for maximum testability. The krelay-testing artifact provides test fakes, JUnit rules, and type-safe assertions, completely removing the need for mocking frameworks.

import dev.brewkits.krelay.testing.KRelayTestRule
import kotlin.test.Test
import kotlin.test.AfterTest

class LoginViewModelTest {
    
    // Automatically resets state after each test
    private val relayRule = KRelayTestRule()
    private val viewModel by lazy { LoginViewModel(krelay = relayRule.relay) }

    @AfterTest
    fun tearDown() = relayRule.after()

    @Test
    fun `login success shows toast and navigates`() {
        // Act
        viewModel.onLoginSuccess()

        // Assert - type-safe and precise
        relayRule.relay.assertDispatched<ToastFeature>()
        relayRule.relay.assertDispatched<NavFeature>()
        
        // Optional: execute the dispatch against a mock to verify parameters
        var toastMessage: String? = null
        relayRule.relay.executeLastDispatch(object : ToastFeature {
            override fun show(msg: String) { toastMessage = msg }
        })
        assertEquals("Welcome back!", toastMessage)
    }
}

Run the test suite:

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

Memory safety

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

Protection Default Behaviour
WeakReference Always on Platform impls released when GC'd — no onDestroy cleanup needed
actionExpiryMs 5 min Queued actions expire and are dropped automatically
maxQueueSize 100 When full, lowest-priority (or oldest) action is evicted

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

class MyViewModel : ViewModel() {
    private val token = scopedToken()

    fun doWork() = KRelay.dispatch<WorkFeature>(token) { it.run() }

    override fun onCleared() = KRelay.cancelScope(token)
}

Integrations

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

Category Library
Navigation Voyager · Decompose · Navigation Compose
Media Peekaboo (image/camera picker)
Permissions Moko Permissions
Biometrics Moko Biometry
Reviews Play Core · StoreKit
DI Koin · Hilt

See Integration Guides for step-by-step examples.


Compatibility

KRelay Kotlin AGP Android minSdk iOS Desktop (JVM) WasmJs
2.2.x 2.1.x 8.x 24 14.0+
2.1.x 2.1.x 8.x 24 14.0+
2.0.x 2.1.x 8.x 24 14.0+
1.1.x 2.0.x 8.x 23 13.0+
1.0.x 1.9.x 7.x 21 13.0+

Platforms: Android arm64 · Android x86_64 · iOS arm64 (device) · iOS arm64 (simulator) · iOS x64 (simulator) · JVM Desktop (macOS, Windows, Linux) · WasmJs Browser


What's New

v2.1.1 — QA Hardening & Ecosystem Infrastructure (Sep 2026)
  • krelay-testing artifactFakeKRelayInstance with a full assertion API (assertDispatched, assertNotDispatched, executeLastDispatch) for clean, mock-free unit testing.
  • krelay-bom (Bill of Materials) — automatically align versions across all KRelay artifacts.
  • Public reified dispatchWithPriority API — simplified prioritization on both the singleton and KRelayInstance.
  • Binary Compatibility Validator (BCV) — integrated JetBrains BCV (apiCheck) to mathematically guarantee zero breaking API changes in minor/patch releases.
  • Enterprise-grade CI/CD — automated multi-platform test matrices and release pipelines.
  • Atomic dispatch & bug fixes — zero TOCTOU race conditions.
v2.1.0 — Compose Integration & Scope Tokens (Mar 2026)
  • 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
  • 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

Guide Description
Compose Integration KRelayEffect, rememberKRelayImpl, Navigation Compose, Voyager
SwiftUI Integration iOS-specific patterns, XCTest
Integration Guides Voyager, Decompose, Moko, Peekaboo, DI
Lifecycle Guide Activity · Fragment · UIViewController · SwiftUI
Testing Guide Patterns, mocks, instrumented tests
Anti-Patterns What not to do and why
Architecture Internals deep dive
API Reference Full API cheat sheet
Managing Warnings Suppress @OptIn at module level
Migration to v2.0 Upgrading 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

Contributing

Security policy

Stars

14 stars

Watchers

2 watching

Forks

Releases

Used by

Contributors

Languages