Skip to content

The Glue Code Standard for Kotlin Multiplatform. A type-safe, leak-free bridge to dispatch UI commands from shared code to native platforms (Android & iOS). Supports Sticky Queue & Lifecycle management.

License

Notifications You must be signed in to change notification settings

brewkits/KRelay

Repository files navigation

KRelay

The Glue Code Standard for Kotlin Multiplatform

Safe, leak-free bridge between shared code and platform-specific APIs

Kotlin Multiplatform Maven Central Zero Dependencies License


What is KRelay?

KRelay is a lightweight bridge that connects your shared Kotlin code to platform-specific implementations (Android/iOS) without memory leaks or lifecycle complexity.

Perfect for: Toast/Snackbar, Navigation, Permissions, Haptics, Notifications - any one-way UI command from shared ViewModels.

Key insight: Integrating KMP libraries (Voyager, Moko, Peekaboo) into shared ViewModels traditionally requires complex patterns. KRelay provides a simple, type-safe bridge.

// Shared ViewModel - Zero platform dependencies
class LoginViewModel {
    fun onLoginSuccess() {
        KRelay.dispatch<ToastFeature> { it.show("Welcome!") }
        KRelay.dispatch<NavigationFeature> { it.goToHome() }
    }
}

What's New in v1.1.0 - Hardening Core 🛡️

KRelay v1.1.0 focuses on production-grade hardening without adding new features or breaking changes:

Thread Safety Improvements

  • iOS Lock: Replaced pthread_mutex with NSRecursiveLock (ARC-managed, reentrant, zero memory leaks)
  • Stress Tested: Validated under 100k concurrent operations
  • Platform Parity: Both Android and iOS use reentrant locks

Diagnostic & Monitoring

  • dump(): Visual debugging output of system state
  • getDebugInfo(): Comprehensive diagnostic data
  • getRegisteredFeaturesCount(): Track registered features
  • getTotalPendingCount(): Monitor queue depth across all features

Developer Safety

  • @MemoryLeakWarning: New opt-in annotation warns about lambda capture risks
  • Enhanced Documentation: Clear DO/DON'T examples for memory management
  • Test Coverage: 99.2% success (262/264 tests across platforms)

Recommendation: All users should upgrade to v1.1.0 for improved stability and diagnostics.

See iOS Test Report for detailed validation results.


Memory Management Best Practices

Lambda Capture Warning

KRelay queues lambdas that may capture variables. Follow these rules to avoid leaks:

✅ DO: Capture primitives and data

val message = viewModel.successMessage
KRelay.dispatch<ToastFeature> { it.show(message) }

❌ DON'T: Capture ViewModels or Contexts

// BAD: Captures entire viewModel
KRelay.dispatch<ToastFeature> { it.show(viewModel.data) }

🔧 CLEANUP: Use clearQueue() in onCleared()

class MyViewModel : ViewModel() {
    override fun onCleared() {
        super.onCleared()
        KRelay.clearQueue<ToastFeature>()
    }
}

Built-in Protections

KRelay includes three passive safety mechanisms:

  1. actionExpiryMs (default: 5 min): Old actions auto-expire
  2. maxQueueSize (default: 100): Oldest actions dropped when full
  3. WeakReference: Platform implementations auto-released

For 99% of use cases (Toast, Navigation, Permissions), these are sufficient.


Why KRelay?

Problem 1: Memory Leaks from Strong References

Without KRelay:

// ❌ DIY approach - Memory leak!
object MyBridge {
    var activity: Activity? = null  // Forgot to clear → LEAK
}

With KRelay:

// ✅ Automatic WeakReference - Zero leaks
override fun onCreate(savedInstanceState: Bundle?) {
    KRelay.register<ToastFeature>(AndroidToast(this))
    // Auto-cleanup when Activity destroyed
}

Problem 2: Missed Commands During Lifecycle Changes

Without KRelay:

// ❌ Command missed if Activity not ready
viewModelScope.launch {
    val data = load()
    nativeBridge.showToast("Done") // Activity not created yet - event lost!
}

With KRelay:

// ✅ Sticky Queue - Commands preserved
viewModelScope.launch {
    val data = load()
    KRelay.dispatch<ToastFeature> { it.show("Done") }
    // Queued if Activity not ready → Auto-replays when ready
}

Problem 3: Complex Integration with KMP Libraries

Without KRelay:

// ❌ ViewModel coupled to Voyager Navigator
class LoginViewModel(private val navigator: Navigator) {
    fun onLoginSuccess() {
        navigator.push(HomeScreen())
    }
}
// - Hard to test (need Navigator mock)
// - Can't switch navigation libraries

With KRelay:

// ✅ ViewModel stays pure Kotlin
class LoginViewModel {
    fun onLoginSuccess() {
        KRelay.dispatch<NavigationFeature> { it.goToHome() }
    }
}
// - Easy testing with simple mock
// - Switch Voyager → Decompose without touching ViewModel

Quick Start

Installation

Option 1: Maven Central (Recommended for published library)

// In your shared module's build.gradle.kts
commonMain.dependencies {
    implementation("dev.brewkits:krelay:1.1.0")
}

Option 2: Local Project Reference

// In your shared module's build.gradle.kts
commonMain.dependencies {
    implementation(project(":krelay"))
}

Basic Usage

Step 1: Define Feature Contract (commonMain)

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

Step 2: Use from Shared Code

class LoginViewModel {
    @OptIn(ProcessDeathUnsafe::class, SuperAppWarning::class)
    fun onLoginSuccess() {
        KRelay.dispatch<ToastFeature> { it.show("Welcome back!") }
    }
}

⚠️ Important Warnings:

  • @ProcessDeathUnsafe: Queue is lost on process death (safe for UI feedback, NOT for payments)
  • @SuperAppWarning: Global singleton (use feature namespacing in large apps)

See Managing Warnings to suppress at module level.

Step 3: Implement on Android

class AndroidToast(private val context: Context) : ToastFeature {
    override fun show(message: String) {
        Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
    }
}

// In Activity
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    KRelay.register<ToastFeature>(AndroidToast(applicationContext))
}

Step 4: Implement on iOS

class IOSToast: ToastFeature {
    weak var viewController: UIViewController?

    func show(message: String) {
        let alert = UIAlertController(title: nil, message: message,
                                     preferredStyle: .alert)
        viewController?.present(alert, animated: true)
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            alert.dismiss(animated: true)
        }
    }
}

// Register
KRelay.shared.register(impl: IOSToast(viewController: controller))

Key Features

🛡️ Memory Safety

  • Automatic WeakReference prevents Activity/ViewController leaks
  • No manual cleanup needed
  • Proven zero-leak in production apps

📦 Sticky Queue

  • Commands never lost during rotation/lifecycle changes
  • Auto-replay when platform implementation registers
  • Configurable queue size and expiry

🧵 Thread Safety (Enhanced in v1.1.0)

  • All commands execute on Main/UI thread automatically
  • Reentrant lock on both platforms (NSRecursiveLock on iOS, ReentrantLock on Android)
  • Stress-tested with 100k concurrent operations
  • No CalledFromWrongThreadException

🔌 Library Integration

  • Works with Voyager, Decompose, Compose Navigation
  • Integrates Moko Permissions, Peekaboo, Play Core
  • Clean decoupling from platform libraries

🧪 Testability

  • Simple mock implementations for tests
  • No mocking library needed
  • Easy to verify dispatched commands

⚡ Performance

  • Zero overhead when on main thread
  • Efficient queue management
  • Minimal memory footprint

🔍 Diagnostic Tools (v1.1.0+)

  • dump() for visual debugging
  • getDebugInfo() for programmatic inspection
  • Real-time monitoring of registered features and queue depth
  • Production-ready diagnostics without overhead

Core API

Register Implementation

KRelay.register<ToastFeature>(AndroidToast(context))

Dispatch Command

KRelay.dispatch<ToastFeature> { it.show("Hello") }

Utility Functions

KRelay.isRegistered<ToastFeature>()        // Check if registered
KRelay.getPendingCount<ToastFeature>()     // Count queued actions
KRelay.unregister<ToastFeature>()          // Manual unregister (optional)
KRelay.clearQueue<ToastFeature>()          // Clear pending actions
KRelay.reset()                              // Clear all (for testing)

Diagnostic Functions (v1.1.0+)

KRelay.dump()                               // Print system state to console
KRelay.getDebugInfo()                       // Get DebugInfo data class
KRelay.getRegisteredFeaturesCount()         // Count registered features
KRelay.getTotalPendingCount()               // Total pending actions across all features

When to Use KRelay

✅ Perfect For (Recommended)

  • Navigation: KRelay.dispatch<NavFeature> { it.goToHome() }
  • Toast/Snackbar: Show user feedback
  • Permissions: Request camera/location
  • Haptics/Sound: Trigger vibration/audio
  • Analytics: Fire-and-forget events
  • Notifications: In-app banners

❌ Do NOT Use For

  • Return Values: Use expect/actual instead
  • State Management: Use StateFlow
  • Heavy Processing: Use Dispatchers.IO
  • Database Ops: Use Room/SQLite directly
  • Critical Transactions: Use WorkManager
  • Network Requests: Use Repository pattern

Golden Rule: KRelay is for one-way, fire-and-forget UI commands. If you need a return value or guaranteed execution after process death, use different tools.


Important Limitations

1. Queue NOT Persistent (Process Death)

Lambda functions cannot survive process death (OS kills app).

Impact:

  • Safe: Toast, Navigation, Haptics (UI feedback - acceptable to lose)
  • Dangerous: Payments, Uploads, Critical Analytics (use WorkManager)

Why? Lambdas can't be serialized. When OS kills your app, the queue is cleared.

See @ProcessDeathUnsafe and Anti-Patterns Guide for details.

2. Global Singleton

KRelay uses object KRelay singleton pattern.

Impact:

  • Perfect for: Single-module apps, small-medium projects
  • ⚠️ Caution: Super Apps (Grab/Gojek style) - use Feature Namespacing

Workaround for large apps:

// Namespace your features
interface ModuleAToastFeature : RelayFeature { ... }
interface ModuleBToastFeature : RelayFeature { ... }

See @SuperAppWarning for guidance.


Documentation

📚 Guides

🏗️ Technical

🎯 Understanding KRelay

  • Positioning - Why KRelay exists (The Glue Code Standard)
  • Roadmap - Future development plans (Desktop, Web, v2.0)

FAQ

Q: Isn't this just EventBus? I remember the nightmare on Android...

A: We understand the PTSD! 😅 But KRelay is fundamentally different:

Aspect Old EventBus KRelay
Scope Global pub/sub across all components Strictly Shared ViewModel → Platform (one direction)
Memory Safety Manual lifecycle management → leaks everywhere Automatic WeakReference - leak-free by design
Direction Any-to-Any (spaghetti) Unidirectional (ViewModel → View only)
Discovery Events hidden in random places Type-safe interfaces - clear contracts
Use Case General messaging (wrong tool) KMP "Last Mile" problem (right tool)

Key difference: EventBus was used for component-to-component communication (wrong pattern). KRelay is for ViewModel-to-Platform bridge only (the missing piece in KMP).


Q: Why not just use DI (Koin/Hilt) to inject platform helpers?

A: DI is perfect for ApplicationContext-scoped helpers (file paths, toasts). KRelay complements DI, doesn't replace it.

The problem arises with Activity-scoped actions (Navigation, Dialogs, Permissions):

DI Approach:

// ❌ Can't inject Activity into ViewModel - memory leak!
class LoginViewModel(private val activity: Activity) // LEAK!

// ⚠️ Need manual WeakReference + attach/detach boilerplate
interface ViewAttached { fun attach(activity: Activity) }

KRelay Approach:

// ✅ ViewModel stays pure - no Activity reference
class LoginViewModel {
    fun onSuccess() {
        KRelay.dispatch<NavFeature> { it.goToHome() }
    }
}

// Activity registers itself automatically
override fun onCreate(savedInstanceState: Bundle?) {
    KRelay.register<NavFeature>(AndroidNav(this))
}

When to use what:

  • DI: For stateless helpers, repositories, use cases (ApplicationContext-scoped)
  • KRelay: For Activity-scoped UI actions (Navigation, Permissions, Dialogs)

Q: Can't I just use LaunchedEffect + SharedFlow? Why add another library?

A: Absolutely! LaunchedEffect is lifecycle-aware and doesn't leak. KRelay solves two different problems:

1. Boilerplate Reduction

Without KRelay:

// ViewModel
class LoginViewModel {
    private val _navEvents = MutableSharedFlow<NavEvent>()
    val navEvents = _navEvents.asSharedFlow()

    fun onSuccess() {
        viewModelScope.launch {
            _navEvents.emit(NavEvent.GoHome)
        }
    }
}

// Every screen needs this collector
@Composable
fun LoginScreen(viewModel: LoginViewModel) {
    val navigator = LocalNavigator.current
    LaunchedEffect(Unit) {
        viewModel.navEvents.collect { event ->
            when (event) {
                is NavEvent.GoHome -> navigator.push(HomeScreen())
                // ... handle all events
            }
        }
    }
}

With KRelay:

// ViewModel
class LoginViewModel {
    fun onSuccess() {
        KRelay.dispatch<NavFeature> { it.goToHome() }
    }
}

// One-time registration in MainActivity
override fun onCreate(savedInstanceState: Bundle?) {
    KRelay.register<NavFeature>(VoyagerNav(navigator))
}

2. Missed Events During Rotation

If you dispatch an event during rotation (between old Activity destroy → new Activity create), LaunchedEffect isn't running yet → event lost.

KRelay's Sticky Queue catches these events and replays them when the new Activity is ready.

Trade-off: If you only have 1-2 features and prefer explicit Flow collectors, stick with LaunchedEffect. If you have many platform actions (Toast, Nav, Permissions, Haptics), KRelay reduces boilerplate significantly.


Testing

class LoginViewModelTest {
    @BeforeTest
    fun setup() {
        KRelay.reset() // Clean state
    }

    @Test
    fun `when login success should show toast and navigate`() {
        // Arrange: Register mock implementations
        val mockToast = MockToast()
        val mockNav = MockNav()
        KRelay.register<ToastFeature>(mockToast)
        KRelay.register<NavigationFeature>(mockNav)

        // Act: Trigger login
        viewModel.onLoginSuccess(testUser)

        // Assert: Verify commands dispatched
        assertEquals("Welcome back!", mockToast.lastMessage)
        assertTrue(mockNav.navigatedToHome)
    }
}

class MockToast : ToastFeature {
    var lastMessage: String? = null
    override fun show(message: String) { lastMessage = message }
}

Run tests:

./gradlew :krelay:testDebugUnitTest        # Android
./gradlew :krelay:iosSimulatorArm64Test    # iOS Simulator

Demo App

The project includes a demo app showcasing real integrations:

Android:

./gradlew :composeApp:installDebug

Features:

  • Basic Demo: Core KRelay features
  • Voyager Integration: Real navigation library integration

See composeApp/src/commonMain/kotlin/dev/brewkits/krelay/ for complete examples.


Philosophy: Do One Thing Well

KRelay follows Unix philosophy - it has one responsibility:

Guarantee safe, leak-free dispatch of UI commands from shared code to platform.

What KRelay Is:

  • ✅ A messenger for one-way UI commands
  • ✅ Fire-and-forget pattern
  • ✅ Lifecycle-aware bridge

What KRelay Is NOT:

  • ❌ RPC framework (no request-response)
  • ❌ State management (use StateFlow)
  • ❌ Background worker (use WorkManager)
  • ❌ DI framework (use Koin/Hilt)

By staying focused, KRelay remains simple, reliable, and maintainable.


Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

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

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

⭐ Star Us on GitHub!

If KRelay saves you time, please give us a star!

It helps other developers discover this project.


⬆️ Back to Top


Made with ❤️ by Nguyễn Tuấn Việt at Brewkits

Support: datacenter111@gmail.comCommunity: GitHub Issues