feat: Live Activities and the Dynamic Island (iOS) - #36
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
ActivityAttributestype. 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.The Dynamic Island drew
Sleading and12 min awaytrailing — the extension's own closures, rendering state the plugin's module built. The match is by type name and the shape ofContentState, not by module.Written up, with what it does not prove, in
findings-live-activity-module-boundary.md.So the plugin owns
GlanceActivityAttributes, andGlanceLiveActivityWidget.swiftis a template copied like the other seven.The API
UnsupportedErroron 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 answerfalsethere instead, so branching needs no try/catch.isLiveActivityRunningcame 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.activitiesis the system's list, and a registry would be wrong exactly when it mattered.Map.Tests for what has no compiler behind it
The drift that breaks a Live Activity — a renamed type, a changed field, a
CodingKeysmoving 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.RunnerTestsis 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:
CodingKeys { case status = "state" }to the template's copy — compiles clean on both sides — failscontentStateShapesMatch()andnilProgressMatches(), exit 65.MissingPluginException→UnsupportedErrormapping fails the three Android tests.End-to-end on a simulator
Through the example app's new Live Activity page, driven with Maestro:
12 min awayisLiveActivityRunninganswers true, so the page comes back with Update enabled9 min awayEvidence
Dart: 23 new tests. iOS: 119 → 133.