SkillAgentSearch skills...

KRelay

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.

Install / Use

/learn @brewkits/KRelay

README

⚡ KRelay

KRelay Cover

The missing piece in Kotlin Multiplatform. Call Toasts, navigate screens, request permissions — anything native — directly from your shared ViewModel. No leaks. No crashes. No boilerplate.

Maven Central Kotlin Kotlin Multiplatform Zero Dependencies License


🛑 Sound familiar?

You've written a clean, shared ViewModel. Then you need to show a permission dialog, navigate to the next screen, or open an image picker. And you hit the wall:

class ProfileViewModel : ViewModel() {
    fun updateAvatar() {
        // ❌ Can't pass Activity — memory leak waiting to happen
        // ❌ Can't pass UIViewController — platform dependency in shared code
        // ❌ SharedFlow loses events during screen rotation
        // ❌ expect/actual is overkill for a one-liner
        // 😤 So... what do you do?
    }
}

This is the "Last Mile" problem of KMP. Your business logic is clean and shared — but the moment you need to trigger something native, you're stuck choosing between leaks, boilerplate, or coupling.


✅ KRelay solves it in 3 steps

Step 1 — Define a shared contract (commonMain)

interface MediaFeature : RelayFeature {
    fun pickImage()
}

Step 2 — Dispatch from your ViewModel

class ProfileViewModel : ViewModel() {
    fun updateAvatar() {
        KRelay.dispatch<MediaFeature> { it.pickImage() }
        // ✅ Zero platform deps  ✅ Zero leaks  ✅ Queued if UI isn't ready yet
    }
}

Step 3 — Register the real implementation on each platform

// Android
KRelay.register<MediaFeature>(PeekabooMediaImpl(activity))

// iOS (Swift)
KRelay.shared.register(impl: IOSMediaImpl())

That's it. KRelay handles lifecycle safety, main-thread dispatch, queue management, and cleanup automatically.


Why developers choose KRelay

🛡️ Zero memory leaks — by design

Implementations are held as WeakReference. When your Activity or UIViewController is destroyed, KRelay releases it automatically. No null checks. No onDestroy cleanup for 99% of use cases.

🔄 Events survive screen rotation

Commands dispatched while the UI isn't ready are queued and automatically replayed when a new implementation registers. Your user rotated the screen mid-API-call? The navigation event still arrives.

🧵 Always runs on the Main Thread

Dispatch from any background coroutine. KRelay guarantees UI code always executes on the Main Thread — Android Looper and iOS GCD both handled.


Works with your stack

KRelay is the glue layer — it integrates with whatever libraries you already use, keeping your ViewModels free of framework dependencies:

| Category | Works with | |----------|-----------| | 🧭 Navigation | Voyager, Decompose, Navigation Compose | | 📷 Media | Peekaboo image/camera picker | | 🔐 Permissions | Moko Permissions | | 🔒 Biometrics | Moko Biometry | | ⭐ Reviews | Play Core (Android), StoreKit (iOS) | | 💉 DI | Koin, Hilt — inject KRelayInstance into ViewModels | | 🎨 Compose | Built-in KRelayEffect<T> and rememberKRelayImpl<T> helpers |

Your ViewModels stay pure — zero direct dependencies on Voyager, Decompose, Moko, or any platform library.

→ See Integration Guides for step-by-step examples.


Quick Start

Installation

// shared module build.gradle.kts
commonMain.dependencies {
    implementation("dev.brewkits:krelay:2.1.0")
}

Option A — Singleton (simple apps)

Perfect for single-module apps or getting started fast.

// 1. Define the contract (commonMain)
interface ToastFeature : RelayFeature {
    fun show(message: String)
}

// 2. Dispatch from shared ViewModel
class LoginViewModel {
    fun onLoginSuccess() {
        KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
    }
}

// 3A. Register on Android
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    KRelay.register<ToastFeature>(object : ToastFeature {
        override fun show(message: String) =
            Toast.makeText(this@MainActivity, message, Toast.LENGTH_SHORT).show()
    })
}

// 3B. Register on iOS (Swift)
override func viewDidLoad() {
    super.viewDidLoad()
    KRelay.shared.register(impl: IOSToast(viewController: self))
}

Option B — Instance API (DI & multi-module)

The recommended approach for new projects, Koin/Hilt, and modular "Super Apps." Each module gets its own isolated instance — no conflicts between modules.

// Koin module setup
val rideModule = module {
    single { KRelay.create("Rides") }        // isolated instance
    viewModel { RideViewModel(krelay = get()) }
}

// ViewModel — pure, no framework deps
class RideViewModel(private val krelay: KRelayInstance) : ViewModel() {
    fun onBookingConfirmed() {
        krelay.dispatch<ToastFeature> { it.show("Ride booked!") }
    }
}

// Android Activity
val rideKRelay: KRelayInstance by inject()
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    rideKRelay.register<ToastFeature>(AndroidToast(applicationContext))
}

Compose Multiplatform users: Use the built-in KRelayEffect<T> helper for zero-boilerplate, lifecycle-scoped registration:

KRelayEffect<ToastFeature> { AndroidToastImpl(context) }
// auto-unregisters when the composable leaves

See Compose Integration Guide.

⚠️ Warnings: @ProcessDeathUnsafe and @SuperAppWarning are compile-time reminders. See Managing Warnings to suppress them at module level.


❌ When NOT to use KRelay

KRelay is for one-way, fire-and-forget UI commands. Be honest with yourself:

| Use Case | Better Tool | |----------|-------------| | Need a return value | expect/actual or suspend fun | | State management | StateFlow / MutableStateFlow | | Critical data — payments, uploads | WorkManager / background services | | Database operations | Room / SQLDelight | | Network requests | Repository + Ktor | | Heavy background work | Dispatchers.IO |

Golden Rule: If you need a return value or guaranteed persistence across process death, use a different tool.


Core API

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

Singleton

KRelay.register<ToastFeature>(AndroidToast(context))
KRelay.dispatch<ToastFeature> { it.show("Hello!") }
KRelay.unregister<ToastFeature>()
KRelay.isRegistered<ToastFeature>()
KRelay.getPendingCount<ToastFeature>()
KRelay.clearQueue<ToastFeature>()
KRelay.reset()   // clear registry + queue
KRelay.dump()    // print debug state

Instance API

val krelay = KRelay.create("MyScope")           // isolated instance
// or
val krelay = KRelay.builder("MyScope")
    .maxQueueSize(50)
    .actionExpiryMs(30_000)
    .build()

krelay.register<ToastFeature>(impl)
krelay.dispatch<ToastFeature> { it.show("Hello!") }
krelay.reset()
krelay.dump()

Scope Token API — fine-grained cleanup

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

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

    override fun onCleared() {
        KRelay.cancelScope(token)  // removes only this ViewModel's queued actions
    }
}

Memory Management

Lambda capture rules

// ✅ DO: capture primitives and data
val message = viewModel.successMessage
KRelay.dispatch<ToastFeature> { it.show(message) }

// ❌ DON'T: capture ViewModels or Contexts
KRelay.dispatch<ToastFeature> { it.show(viewModel.data) }  // captures viewModel!

Built-in protections (passive — always active)

| Protection | Default | Effect | |-----------|---------|--------| | actionExpiryMs | 5 min | Old queued actions auto-expire | | maxQueueSize | 100 | Oldest actions dropped when queue fills | | WeakReference | Always | Platform impls released on GC automatically |

These are sufficient for 99% of use cases. Customize per-instance with KRelay.builder().


Testing

Singleton API

@BeforeTest
fun setup() {
    KRelay.reset()  // clean state for each test
}

@Test
fun `login success dispatches toast and navigation`() {
    val mockToast = MockToast()
    KRelay.register<ToastFeature>(mockToast)

    LoginViewModel().onLoginSuccess()

    assertEquals("Welcome back!", mockToast.lastMessage)
}

Instance API (recommended — explicit, no global state)

@BeforeTest
fun setup() {
    mockRelay = KRelay.create("TestScope")
    viewModel = RideViewModel(krelay = mockRelay)
}

@Test
fun `booking confirmed dispatches toast`() {
    val mockToast = MockToast()
    mockRelay.register<ToastFeature>(mockToast)

    viewModel.onBookingConfirmed()

    assertEquals("Ride booked!", mockToast.lastMessage)
}
// Simple mocks — no mocking libraries needed
class MockToast : ToastFeature {
    var lastMessage: String? = null
    override fun show(message: String) { lastMessage = message }
}

Run tests:

./gradlew :krelay:testDebugUnitTest        # JVM (fast)
./gradlew :krelay:iosSimulatorArm64Test    # iOS Simulator
./gradlew :krelay:connect

Related Skills

View on GitHub
GitHub Stars11
CategoryDevelopment
Updated19d ago
Forks0

Languages

Kotlin

Security Score

95/100

Audited on Mar 16, 2026

No findings