Skip to content

feat: Live Activities and the Dynamic Island (iOS) - #36

Merged
abdullahtas0 merged 2 commits into
masterfrom
feat/live-activities
Aug 31, 2026
Merged

abdullahtas0 merged 2 commits into
masterfrom
feat/live-activities

Conversation

@abdullahtas0

Copy link
Copy Markdown
Owner

Closes #23.

The blocking question, settled by measurement

The issue said the central design question needed a physical device and sequenced the whole feature behind it. It could be answered in the simulator — and #10 is what made that possible, by giving the example app a real widget extension to put a presentation in.

The question: ActivityKit matches a running activity to its presentation by the ActivityAttributes type. The plugin is a Swift package, so an extension cannot import it and must declare its own copy. Two identically named, identically shaped types in two modules. If the match is module-qualified, the attributes have to move into the developer's app target behind a registration API — a different, more intrusive design.

The experiment: the plugin's module requested an activity; the extension's module declared its own copy and an ActivityConfiguration. Nothing shared — no file, no framework, no header.

Runner[93594] [dev.glance.widget:GlanceWidget] SPIKE: areActivitiesEnabled=true
Runner[93594] [dev.glance.widget:GlanceWidget] SPIKE: requested id=4F5D6B91-…

The Dynamic Island drew S leading and 12 min away trailing — the extension's own closures, rendering state the plugin's module built. The match is by type name and the shape of ContentState, not by module.

Written up, with what it does not prove, in findings-live-activity-module-boundary.md.

So the plugin owns GlanceActivityAttributes, and GlanceLiveActivityWidget.swift is a template copied like the other seven.

The API

if (await GlanceWidget.areLiveActivitiesEnabled()) {
  await GlanceWidget.startLiveActivity(
    activityId: 'delivery-42',
    content: const LiveActivityContent(
      title: '🛵 Order on its way',
      status: '12 min away',
      progress: 0.4,
      stats: {'Driver': 'Sam', 'Items': '3'},
    ),
  );
}
  • Android throws UnsupportedError on the three mutating calls. Android 16's Live Updates are a notification, not a widget; a quiet no-op would leave a caller believing something was on screen. The two queries answer false there instead, so branching needs no try/catch.
  • isLiveActivityRunning came out of the example app. An activity outlives the process that started it and the user can dismiss it without the app hearing, so resuming with work in flight means asking rather than remembering. Nothing is cached natively either — Activity.activities is the system's list, and a registry would be wrong exactly when it mattered.
  • One fixed shape, because an extension must name a concrete type at compile time. Stated in the API docs rather than hidden behind a Map.

Tests for what has no compiler behind it

The drift that breaks a Live Activity — a renamed type, a changed field, a CodingKeys moving a wire key — fails nothing at build time on either side. It just stops appearing, on a user's Lock Screen, with no error anywhere.

RunnerTests is the only place both copies exist at once, so it compares them: both type names, and both encoded states byte for byte. The template declares an alias for its own copy — an unqualified name there resolves to whichever is target-local and would have silently compared the plugin's copy against itself.

Gate proofs:

  • Adding CodingKeys { case status = "state" } to the template's copy — compiles clean on both sides — fails contentStateShapesMatch() and nilProgressMatches(), exit 65.
  • Removing the MissingPluginExceptionUnsupportedError mapping fails the three Android tests.

End-to-end on a simulator

Through the example app's new Live Activity page, driven with Maestro:

  1. Start → Dynamic Island shows 🛵 / 12 min away
  2. Kill the app, relaunch → isLiveActivityRunning answers true, so the page comes back with Update enabled
  3. Update → island reads 9 min away
  4. End immediately → island empties

Evidence

$ ./tool/verify.sh
format/analyze/test  ✓ × 5 packages
android-unit-tests   ✓
ios-unit-tests       ✓
publish dry-run      ✓ × 4 packages
exit=0

Dart: 23 new tests. iOS: 119 → 133.

Issue #23 said the central design question could not be settled without a
physical device, and sequenced the whole feature behind it. It could be
settled in the simulator, and #10 is what made that possible: the example app
now has a real widget extension to put a presentation in.

The question was whether ActivityKit matches a running activity to its
presentation across a module boundary. The plugin is a Swift package, so an
extension cannot import it and has to declare its own copy of the attributes
type. Two identically named, identically shaped types in two modules: if the
match is by module-qualified identity, the attributes have to live in the
developer's own app target behind a registration API.

Measured: the plugin's module requested an activity, the extension's module
drew it, and the Dynamic Island rendered the extension's own closures. The
match is by type name and the shape of ContentState, not by module. The
experiment and what it does not prove are written up in
findings-live-activity-module-boundary.md.

So the plugin owns `GlanceActivityAttributes`, and
`GlanceLiveActivityWidget.swift` is a template copied like the other seven.

The API is five calls. `startLiveActivity`, `updateLiveActivity` and
`endLiveActivity` throw `UnsupportedError` on Android: Android 16's Live
Updates are a notification rather than a widget, and doing nothing quietly
would leave a caller believing something was on screen.
`areLiveActivitiesEnabled` and `isLiveActivityRunning` answer false there
instead, so a caller can branch without a try/catch.

`isLiveActivityRunning` came out of the example app. An activity outlives the
process that started it -- it stays on the Lock Screen across a relaunch, and
the user can dismiss it without the app hearing -- so resuming with work in
flight means asking rather than remembering. Nothing is held in memory on the
native side either; `Activity.activities` is the system's list, and a registry
would be wrong exactly when it mattered.

Content is one fixed shape. An extension must name a concrete
`ActivityAttributes` type at compile time, so the plugin can offer exactly one;
a caller needing another edits their own copy of the template and stops being
described by `LiveActivityContent`. That is stated in the API docs rather than
hidden behind a `Map`.

Two contract tests guard what has no compiler behind it. The drift that breaks
a Live Activity -- a renamed type, a changed field, a `CodingKeys` that moves a
wire key -- fails nothing at build time on either side and simply stops the
activity appearing. `RunnerTests` is the only place both copies exist at once,
so it compares them: both type names, and both encoded states byte for byte.
The template declares an alias for its own copy, because an unqualified name
there resolves to whichever is target-local and would silently compare the
plugin's copy against itself.

Verified end to end on a simulator through the example app's new page: started,
killed the app, relaunched, `isLiveActivityRunning` answered true, updated --
the island read "9 min away" -- then ended, and the island emptied.

Dart tests 23 new, iOS tests 119 -> 133.

Closes #23
@abdullahtas0
abdullahtas0 merged commit 9910fcb into master Aug 31, 2026
14 checks passed
@abdullahtas0
abdullahtas0 deleted the feat/live-activities branch August 31, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Live Activities and Dynamic Island support (iOS)

1 participant