
Typed, atomic handoff of payloads and images to home-screen widgets: generation folders with atomic pointer swaps, corruption fallback, asset byte‑budgeting, write dedupe and redraw triggers.
Typed, atomic handoff of data and images from a Kotlin Multiplatform app to its home-screen widgets: Jetpack Glance on Android, WidgetKit on iOS. The widget extension never links Kotlin.
Widgets cannot run your shared Kotlin. On iOS the widget is a separate extension with a memory
ceiling around 30 MB: link the Kotlin framework into it and it gets killed, and App Store
validation rejects frameworks nested in extensions. On Android, Glance widgets wake when the app
is not running. So every KMP app hand-rolls the same handoff, usually a JSON string in
UserDefaults, with no atomicity across files, no images, and no recovery from a corrupt write.
WidgetBridge is that handoff done once: the app publishes a typed payload plus image assets as a complete generation folder and flips an atomic pointer; the widget reads it with a Kotlin reader (Glance) or a ten-line Swift package (WidgetKit).
@Serializable class in, the same shape out in Kotlin and as Decodable in Swift.// libs.versions.toml widgetbridge = { module = "com.vocabloot:widgetbridge", version = "0.2.0" }
// build.gradle.kts (shared) commonMain.dependencies { implementation(libs.widgetbridge) }// Package.swift or Xcode, on the widget extension AND the app target
.package(url: "https://github.com/vaazh-studios/widgetbridge", from: "0.2.0")An App Group on both iOS targets, a Glance receiver on Android (setup), then:
// shared
@Serializable
data class QuoteFeed(val quotes: List<Quote>, val emptyText: String) // pre-localised strings
// Android (androidMain) // iOS (iosMain)
val bridge = androidWidgetBridge( val bridge = iosWidgetBridge(
context, WidgetBridgeConfig(schemaVersion = 1), WidgetBridgeConfig(schemaVersion = 1, iosAppGroup = "group.com.example.quotes"),
QuoteFeed.serializer(), QuoteWidgetReceiver::class.java, QuoteFeed.serializer(),
) )
// publish whenever the data changes (debounced)
val assets = AssetBudget().pack(quotes.map { AssetCandidate("${'$'}{it.id}.jpg", it.photoPath, WidgetImageFormat.Jpeg) })
when (bridge.publish(QuoteFeed(quotes, emptyText), assets)) {
is PublishResult.Published -> Unit // widgets were asked to redraw
PublishResult.Unchanged -> Unit // nothing written
}// Android widget, inside provideGlance: read INSIDE the composition, keyed on the receiver's counter
provideContent {
val refresh by QuoteWidget.refreshes.collectAsState()
val feed = remember(refresh) { bridge.read() }
val index = WidgetRotation.index(WidgetRotation.hourSlot(System.currentTimeMillis(), zoneOffsetSeconds), feed?.payload?.quotes?.size ?: 1)
val bitmap = feed?.assetPath("${'$'}{feed.payload.quotes[index].id}.jpg")?.let(BitmapFactory::decodeFile)
QuoteCard(feed?.payload?.quotes?.getOrNull(index), bitmap)
}// iOS widget, inside the TimelineProvider
let reader = WidgetFeedReader(appGroup: "group.com.example.quotes", schemaVersion: 1)
guard let feed = reader?.read(QuoteFeed.self) else { return empty }
let index = WidgetRotation.index(slot: WidgetRotation.hourSlot(date), count: feed.payload.quotes.count)
let image = feed.assetURL("\(feed.payload.quotes[index].id).jpg").flatMap { UIImage(contentsOfFile: $0.path) }Skip the encode when nothing changed, and let iOS flip the word on the hour without the app running:
@Serializable data class QuoteFeed(val sourceFingerprint: String, val quotes: List<Quote>, val emptyText: String)
suspend fun publishIfChanged(quotes: List<Quote>) {
val fingerprint = sha256Hex(quotes.joinToString("\u001f") { "${'$'}{it.id}|${'$'}{it.text}|${'$'}{it.photoPath}" }.encodeToByteArray())
if (bridge.read()?.payload?.sourceFingerprint == fingerprint) return // one file read, zero encodes
bridge.publish(QuoteFeed(fingerprint, quotes, emptyText), AssetBudget().pack(candidates(quotes)))
}A 24-entry WidgetKit timeline, Lock Screen families, several widgets from one feed and localized payloads: recipes.
| Android (Glance) | iOS (WidgetKit) | |
|---|---|---|
| Publish from Kotlin | ✅ androidWidgetBridge
|
✅ iosWidgetBridge (App Group) |
| Read | ✅ Kotlin bridge.read()
|
✅ Swift WidgetFeedReader
|
| Images | ✅ WidgetImages + AssetBudget
|
✅ same |
| Redraw |
ACTION_APPWIDGET_UPDATE broadcast |
WidgetBridgeReloader |
| Kotlin in the widget process | Glance runs in the app process | never |
| Verified | Vocabloot development build, Pixel 10 Pro emulator, 2026-09-08 | Vocabloot development build, iPhone 17 Pro simulator, 2026-09-08 |
Targets: android (minSdk 24), iosArm64, iosSimulatorArm64, iosX64; Swift package iOS 16+.
| Minimum | Built with | |
|---|---|---|
| Kotlin / Gradle / AGP | 2.3 / 9.0 / 9.0 | 2.3.20 / 9.4.1 / 9.2.1 |
| Android | minSdk 24, Glance 1.1 in your app | compileSdk 36 |
| iOS / Xcode / Swift tools | 16 / 16 / 5.9 | iOS 26 / Xcode 26 |
Versioning, the on-disk format promise and the requirements in full: stability.
| Sample | Shows | Android | iOS |
|---|---|---|---|
| Quote of the day | debounced publish, images under budget, Glance re-read, WidgetKit timeline, featured override | ![]() |
![]() |
com.vocabloot:widgetbridge-test ships the fakes the library's own tests run on:
val storage = FakeWidgetFeedStorage(); val notifier = CountingNotifier()
val bridge = WidgetBridge(WidgetBridgeConfig(schemaVersion = 1), QuoteFeed.serializer(), storage, notifier, clock = { 1L })
bridge.publish(feed, assets); bridge.publish(feed, assets)
check(storage.writeCount == 1 && notifier.count == 1) // dedupe heldWorks with Glance, WidgetKit, kotlinx-serialization and whatever DI you use; the sample uses none. Using WidgetBridge? Open a PR and add yourself.
publish only asks.WidgetFeedReader(appGroup:) returns nil and iosWidgetBridge throws on first use.publish can only report Unchanged after your images are encoded. If encoding is expensive, keep a cheap fingerprint of your source data inside the payload and compare it with bridge.read()?.payload before packing assets (docs/refresh.md).provideContent, keyed on something the receiver bumps per update (the sample uses a MutableStateFlow counter), or a second publish within that window shows the first one's data.Open items with workarounds: known issues.
| Hand-rolled UserDefaults / MatchPin | WARP | Fidget | WidgetBridge | |
|---|---|---|---|---|
| Kotlin in the iOS extension | yes | yes | yes (Compose in WidgetKit) | no |
| Atomic across payload + images | no | no | n/a | yes |
| Fallback after a corrupt write | no | no | n/a | yes |
| Images | manual | manual | Compose-rendered | budgeted assets |
| Schema version check | no | no | n/a | yes |
| Widget UI | yours | Kotlin DSL | Compose | yours |
Docs site: setup, how it works, the feed format, when to publish, recipes, FAQ, known issues, stability. API reference (Dokka). Design notes and publishing steps in docs/ for maintainers.
Kotlin: kotlinx-coroutines-core, kotlinx-serialization-json (both exposed). Android: platform APIs only. iOS: Foundation only. Swift package: Foundation, and WidgetKit only inside WidgetBridgeReloader.
Apache 2.0. Made by Vaazh Studios.
Typed, atomic handoff of data and images from a Kotlin Multiplatform app to its home-screen widgets: Jetpack Glance on Android, WidgetKit on iOS. The widget extension never links Kotlin.
Widgets cannot run your shared Kotlin. On iOS the widget is a separate extension with a memory
ceiling around 30 MB: link the Kotlin framework into it and it gets killed, and App Store
validation rejects frameworks nested in extensions. On Android, Glance widgets wake when the app
is not running. So every KMP app hand-rolls the same handoff, usually a JSON string in
UserDefaults, with no atomicity across files, no images, and no recovery from a corrupt write.
WidgetBridge is that handoff done once: the app publishes a typed payload plus image assets as a complete generation folder and flips an atomic pointer; the widget reads it with a Kotlin reader (Glance) or a ten-line Swift package (WidgetKit).
@Serializable class in, the same shape out in Kotlin and as Decodable in Swift.// libs.versions.toml widgetbridge = { module = "com.vocabloot:widgetbridge", version = "0.2.0" }
// build.gradle.kts (shared) commonMain.dependencies { implementation(libs.widgetbridge) }// Package.swift or Xcode, on the widget extension AND the app target
.package(url: "https://github.com/vaazh-studios/widgetbridge", from: "0.2.0")An App Group on both iOS targets, a Glance receiver on Android (setup), then:
// shared
@Serializable
data class QuoteFeed(val quotes: List<Quote>, val emptyText: String) // pre-localised strings
// Android (androidMain) // iOS (iosMain)
val bridge = androidWidgetBridge( val bridge = iosWidgetBridge(
context, WidgetBridgeConfig(schemaVersion = 1), WidgetBridgeConfig(schemaVersion = 1, iosAppGroup = "group.com.example.quotes"),
QuoteFeed.serializer(), QuoteWidgetReceiver::class.java, QuoteFeed.serializer(),
) )
// publish whenever the data changes (debounced)
val assets = AssetBudget().pack(quotes.map { AssetCandidate("${'$'}{it.id}.jpg", it.photoPath, WidgetImageFormat.Jpeg) })
when (bridge.publish(QuoteFeed(quotes, emptyText), assets)) {
is PublishResult.Published -> Unit // widgets were asked to redraw
PublishResult.Unchanged -> Unit // nothing written
}// Android widget, inside provideGlance: read INSIDE the composition, keyed on the receiver's counter
provideContent {
val refresh by QuoteWidget.refreshes.collectAsState()
val feed = remember(refresh) { bridge.read() }
val index = WidgetRotation.index(WidgetRotation.hourSlot(System.currentTimeMillis(), zoneOffsetSeconds), feed?.payload?.quotes?.size ?: 1)
val bitmap = feed?.assetPath("${'$'}{feed.payload.quotes[index].id}.jpg")?.let(BitmapFactory::decodeFile)
QuoteCard(feed?.payload?.quotes?.getOrNull(index), bitmap)
}// iOS widget, inside the TimelineProvider
let reader = WidgetFeedReader(appGroup: "group.com.example.quotes", schemaVersion: 1)
guard let feed = reader?.read(QuoteFeed.self) else { return empty }
let index = WidgetRotation.index(slot: WidgetRotation.hourSlot(date), count: feed.payload.quotes.count)
let image = feed.assetURL("\(feed.payload.quotes[index].id).jpg").flatMap { UIImage(contentsOfFile: $0.path) }Skip the encode when nothing changed, and let iOS flip the word on the hour without the app running:
@Serializable data class QuoteFeed(val sourceFingerprint: String, val quotes: List<Quote>, val emptyText: String)
suspend fun publishIfChanged(quotes: List<Quote>) {
val fingerprint = sha256Hex(quotes.joinToString("\u001f") { "${'$'}{it.id}|${'$'}{it.text}|${'$'}{it.photoPath}" }.encodeToByteArray())
if (bridge.read()?.payload?.sourceFingerprint == fingerprint) return // one file read, zero encodes
bridge.publish(QuoteFeed(fingerprint, quotes, emptyText), AssetBudget().pack(candidates(quotes)))
}A 24-entry WidgetKit timeline, Lock Screen families, several widgets from one feed and localized payloads: recipes.
| Android (Glance) | iOS (WidgetKit) | |
|---|---|---|
| Publish from Kotlin | ✅ androidWidgetBridge
|
✅ iosWidgetBridge (App Group) |
| Read | ✅ Kotlin bridge.read()
|
✅ Swift WidgetFeedReader
|
| Images | ✅ WidgetImages + AssetBudget
|
✅ same |
| Redraw |
ACTION_APPWIDGET_UPDATE broadcast |
WidgetBridgeReloader |
| Kotlin in the widget process | Glance runs in the app process | never |
| Verified | Vocabloot development build, Pixel 10 Pro emulator, 2026-09-08 | Vocabloot development build, iPhone 17 Pro simulator, 2026-09-08 |
Targets: android (minSdk 24), iosArm64, iosSimulatorArm64, iosX64; Swift package iOS 16+.
| Minimum | Built with | |
|---|---|---|
| Kotlin / Gradle / AGP | 2.3 / 9.0 / 9.0 | 2.3.20 / 9.4.1 / 9.2.1 |
| Android | minSdk 24, Glance 1.1 in your app | compileSdk 36 |
| iOS / Xcode / Swift tools | 16 / 16 / 5.9 | iOS 26 / Xcode 26 |
Versioning, the on-disk format promise and the requirements in full: stability.
| Sample | Shows | Android | iOS |
|---|---|---|---|
| Quote of the day | debounced publish, images under budget, Glance re-read, WidgetKit timeline, featured override | ![]() |
![]() |
com.vocabloot:widgetbridge-test ships the fakes the library's own tests run on:
val storage = FakeWidgetFeedStorage(); val notifier = CountingNotifier()
val bridge = WidgetBridge(WidgetBridgeConfig(schemaVersion = 1), QuoteFeed.serializer(), storage, notifier, clock = { 1L })
bridge.publish(feed, assets); bridge.publish(feed, assets)
check(storage.writeCount == 1 && notifier.count == 1) // dedupe heldWorks with Glance, WidgetKit, kotlinx-serialization and whatever DI you use; the sample uses none. Using WidgetBridge? Open a PR and add yourself.
publish only asks.WidgetFeedReader(appGroup:) returns nil and iosWidgetBridge throws on first use.publish can only report Unchanged after your images are encoded. If encoding is expensive, keep a cheap fingerprint of your source data inside the payload and compare it with bridge.read()?.payload before packing assets (docs/refresh.md).provideContent, keyed on something the receiver bumps per update (the sample uses a MutableStateFlow counter), or a second publish within that window shows the first one's data.Open items with workarounds: known issues.
| Hand-rolled UserDefaults / MatchPin | WARP | Fidget | WidgetBridge | |
|---|---|---|---|---|
| Kotlin in the iOS extension | yes | yes | yes (Compose in WidgetKit) | no |
| Atomic across payload + images | no | no | n/a | yes |
| Fallback after a corrupt write | no | no | n/a | yes |
| Images | manual | manual | Compose-rendered | budgeted assets |
| Schema version check | no | no | n/a | yes |
| Widget UI | yours | Kotlin DSL | Compose | yours |
Docs site: setup, how it works, the feed format, when to publish, recipes, FAQ, known issues, stability. API reference (Dokka). Design notes and publishing steps in docs/ for maintainers.
Kotlin: kotlinx-coroutines-core, kotlinx-serialization-json (both exposed). Android: platform APIs only. iOS: Foundation only. Swift package: Foundation, and WidgetKit only inside WidgetBridgeReloader.
Apache 2.0. Made by Vaazh Studios.