
Start, update and end live activities via one API — driving Lock Screen widgets, Dynamic Island and promoted ongoing notifications with shared content, native ticking timers and payload guardrails.
One API. Lock Screen, Dynamic Island, and Android Live Updates.
Start, update and end a live activity from commonMain. iOS renders it with
ActivityKit on the Lock Screen and in the Dynamic Island; Android renders it as an
Android 16 Live Update (promoted ongoing notification with progress and a ticking
chronometer), falling back to a plain ongoing notification on older versions.
| iOS Lock Screen | iOS ended | Android 16 Live Update | Android ended |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
The default iOS widget and the Android notification are both driven by the same
LiveActivityContent: title, subtitle, icon, accent colour, progress and a timer that
ticks natively on each platform without per-second updates.
implementation("io.github.androidpoet:halo:0.2.0") // core API + platform managers
implementation("io.github.androidpoet:halo-compose:0.2.0") // rememberLiveActivityManager()val liveActivities = rememberLiveActivityManager()
val now = Clock.System.now().toEpochMilliseconds()
val request =
LiveActivityRequest(
content =
LiveActivityContent(
title = "Focus session",
subtitle = "Deep work",
progress = LiveActivityProgress.Determinate(current = 0, max = 25),
timer = LiveActivityTimer(startAtEpochMillis = now, endAtEpochMillis = now + 25 * 60_000L),
icon = "timer",
accentColorArgb = 0xFF4F46E5,
),
deepLink = "myapp://focus",
)
when (val result = liveActivities.start(request)) {
is LiveActivityResult.Success -> sessionId = result.value.id
is LiveActivityResult.Failure -> show(result.error.code, result.error.message)
}
liveActivities.update(sessionId, content.copy(subtitle = "Almost there"))
liveActivities.end(sessionId, finalContent = content.copy(title = "Session complete"))The timer ticks on its own on both platforms. Call update only when the content changes.
| Platform | Surface | Floor | One-time setup |
|---|---|---|---|
| Android | Live Update (promoted ongoing notification) on Android 16; ongoing notification below | API 26 | Request POST_NOTIFICATIONS on API 33+ |
| iOS | Lock Screen + Dynamic Island via ActivityKit | iOS 16.2 | Link the Swift package, add the widget extension |
| JVM, macOS, Wasm |
UnsupportedLiveActivityManager so shared code compiles |
— | — |
Halo.androidConfig =
AndroidLiveActivityConfig(
smallIconRes = R.drawable.ic_timer,
iconResolver = { key -> if (key == "timer") R.drawable.ic_timer else 0 },
)Icons are resolved through iconResolver at compile time, never by resource name, so
shrunk release builds keep working. AndroidLiveActivityManager.canPromote and
isPromotable(id) tell you whether the system will show the status-bar chip.
ActivityKit is Swift-only and renders in a widget extension, so two things live on the Swift side. Neither is code you write against the Kotlin framework:
swift/HaloKMP — add it to both the app target and the widget
extension. It holds HaloActivityAttributes (the one attributes type ActivityKit matches
on; never copy it), HaloActivityWidget, a ready-made Lock Screen + Dynamic Island UI, and
the bridge the Kotlin side finds by itself at run time.WidgetBundle whose body is HaloActivityWidget(), and
NSSupportsLiveActivities = YES in the app's Info.plist.No bridge file to copy, no register call, no export of the Kotlin framework.
Want your own look? Write an ActivityConfiguration(for: HaloActivityAttributes.self) and
read context.state (title, subtitle, progress, timer range, values).
LiveActivityState.Expired; the library
never restarts them for you.shortText replaces the ticking timer in the status-bar chip and the
Dynamic Island compact view. Leave it null to show the timer.PayloadTooLarge; ActivityKit's ceiling is
about 4 KB.activities with their
content, on both platforms. Nothing survives a reboot on Android.Default dismissal keeps the final card as a normal, swipeable notification for up to
four hours (best effort; some OEMs ignore timeouts). Use Immediate to remove it now.| Symptom | Cause |
|---|---|
iOS: start succeeds, nothing renders |
The attributes type is duplicated across targets. Link the Swift package from both targets; do not copy HaloActivityAttributes. |
iOS: Unsupported
|
The HaloKMP Swift package is not linked into the app target, or iOS < 16.2. |
| Android: no status-bar chip | Pre-Android 16, canPostPromotedNotifications() false, or shortText and timer both absent. |
sample/composeApp is one shared screen (Start, +5 min, Pause, Finish). Android:
./gradlew :sample:composeApp:installDebug. iOS: cd sample/composeApp/iosApp && xcodegen && open iosApp.xcodeproj.
MIT © Ranbir Singh
One API. Lock Screen, Dynamic Island, and Android Live Updates.
Start, update and end a live activity from commonMain. iOS renders it with
ActivityKit on the Lock Screen and in the Dynamic Island; Android renders it as an
Android 16 Live Update (promoted ongoing notification with progress and a ticking
chronometer), falling back to a plain ongoing notification on older versions.
| iOS Lock Screen | iOS ended | Android 16 Live Update | Android ended |
|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
The default iOS widget and the Android notification are both driven by the same
LiveActivityContent: title, subtitle, icon, accent colour, progress and a timer that
ticks natively on each platform without per-second updates.
implementation("io.github.androidpoet:halo:0.2.0") // core API + platform managers
implementation("io.github.androidpoet:halo-compose:0.2.0") // rememberLiveActivityManager()val liveActivities = rememberLiveActivityManager()
val now = Clock.System.now().toEpochMilliseconds()
val request =
LiveActivityRequest(
content =
LiveActivityContent(
title = "Focus session",
subtitle = "Deep work",
progress = LiveActivityProgress.Determinate(current = 0, max = 25),
timer = LiveActivityTimer(startAtEpochMillis = now, endAtEpochMillis = now + 25 * 60_000L),
icon = "timer",
accentColorArgb = 0xFF4F46E5,
),
deepLink = "myapp://focus",
)
when (val result = liveActivities.start(request)) {
is LiveActivityResult.Success -> sessionId = result.value.id
is LiveActivityResult.Failure -> show(result.error.code, result.error.message)
}
liveActivities.update(sessionId, content.copy(subtitle = "Almost there"))
liveActivities.end(sessionId, finalContent = content.copy(title = "Session complete"))The timer ticks on its own on both platforms. Call update only when the content changes.
| Platform | Surface | Floor | One-time setup |
|---|---|---|---|
| Android | Live Update (promoted ongoing notification) on Android 16; ongoing notification below | API 26 | Request POST_NOTIFICATIONS on API 33+ |
| iOS | Lock Screen + Dynamic Island via ActivityKit | iOS 16.2 | Link the Swift package, add the widget extension |
| JVM, macOS, Wasm |
UnsupportedLiveActivityManager so shared code compiles |
— | — |
Halo.androidConfig =
AndroidLiveActivityConfig(
smallIconRes = R.drawable.ic_timer,
iconResolver = { key -> if (key == "timer") R.drawable.ic_timer else 0 },
)Icons are resolved through iconResolver at compile time, never by resource name, so
shrunk release builds keep working. AndroidLiveActivityManager.canPromote and
isPromotable(id) tell you whether the system will show the status-bar chip.
ActivityKit is Swift-only and renders in a widget extension, so two things live on the Swift side. Neither is code you write against the Kotlin framework:
swift/HaloKMP — add it to both the app target and the widget
extension. It holds HaloActivityAttributes (the one attributes type ActivityKit matches
on; never copy it), HaloActivityWidget, a ready-made Lock Screen + Dynamic Island UI, and
the bridge the Kotlin side finds by itself at run time.WidgetBundle whose body is HaloActivityWidget(), and
NSSupportsLiveActivities = YES in the app's Info.plist.No bridge file to copy, no register call, no export of the Kotlin framework.
Want your own look? Write an ActivityConfiguration(for: HaloActivityAttributes.self) and
read context.state (title, subtitle, progress, timer range, values).
LiveActivityState.Expired; the library
never restarts them for you.shortText replaces the ticking timer in the status-bar chip and the
Dynamic Island compact view. Leave it null to show the timer.PayloadTooLarge; ActivityKit's ceiling is
about 4 KB.activities with their
content, on both platforms. Nothing survives a reboot on Android.Default dismissal keeps the final card as a normal, swipeable notification for up to
four hours (best effort; some OEMs ignore timeouts). Use Immediate to remove it now.| Symptom | Cause |
|---|---|
iOS: start succeeds, nothing renders |
The attributes type is duplicated across targets. Link the Swift package from both targets; do not copy HaloActivityAttributes. |
iOS: Unsupported
|
The HaloKMP Swift package is not linked into the app target, or iOS < 16.2. |
| Android: no status-bar chip | Pre-Android 16, canPostPromotedNotifications() false, or shortText and timer both absent. |
sample/composeApp is one shared screen (Start, +5 min, Pause, Finish). Android:
./gradlew :sample:composeApp:installDebug. iOS: cd sample/composeApp/iosApp && xcodegen && open iosApp.xcodeproj.
MIT © Ranbir Singh