Add iOS Live Activity webhook handlers to mobile_app#166072
Conversation
|
Hey there @home-assistant/core, mind taking a look at this pull request as it has been labeled with an integration ( Code owner commandsCode owners of
|
There was a problem hiding this comment.
Pull request overview
Adds Home Assistant Core support in the mobile_app integration for iOS Live Activities by introducing webhook handlers that store and clear per-activity APNs push tokens and emit lifecycle events for automations.
Changes:
- Extend
SCHEMA_APP_DATAand constants to support Live Activities capability flags and push-to-start registration fields. - Add
update_live_activity_tokenandlive_activity_dismissedwebhooks that manage an in-memory token store and fire remote-origin bus events. - Add a
supports_live_activities()helper and webhook tests covering token storage, defaults, and cleanup.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
homeassistant/components/mobile_app/const.py |
Adds Live Activity-related constants/events and extends registration SCHEMA_APP_DATA. |
homeassistant/components/mobile_app/__init__.py |
Initializes a new in-memory DATA_LIVE_ACTIVITY_TOKENS store under hass.data[DOMAIN]. |
homeassistant/components/mobile_app/webhook.py |
Implements the new Live Activity webhook handlers and fires lifecycle events. |
homeassistant/components/mobile_app/util.py |
Adds supports_live_activities() helper based on stored app_data. |
tests/components/mobile_app/test_webhook.py |
Adds tests for storing tokens, default env behavior, and dismiss cleanup/event firing. |
Comments suppressed due to low confidence (1)
tests/components/mobile_app/test_webhook.py:1398
- This test only asserts the HTTP status. To fully validate the contract of
live_activity_dismissed(which returnsempty_okay_response()), also assert the JSON body is{}(and optionally that no tokens were removed when none existed) to prevent regressions in response shape/side effects.
resp = await webhook_client.post(
f"/api/webhook/{webhook_id}",
json={
"type": "live_activity_dismissed",
"data": {
"tag": "nonexistent_activity",
},
},
)
assert resp.status == HTTPStatus.OK
|
|
||
| ATTR_LIVE_UPDATE = "live_update" | ||
| ATTR_LIVE_ACTIVITY_TOKEN = "live_activity_token" | ||
| ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN = "live_activity_push_to_start_token" |
There was a problem hiding this comment.
| ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN = "live_activity_push_to_start_token" | |
| ATTR_LIVE_ACTIVITY_START_TOKEN = "live_activity_start_token" |
Do we really need a so long name? Can we maybe reuse ATTR_LIVE_ACTIVITY_TOKEN?
There was a problem hiding this comment.
I understand live_activity_push_to_start_token is how we call it in iOS codebase but we can simplify and have it as live_activity_token leaving just 1 variable in core codebase.
I will make the change in the iOS codebase
Co-authored-by: Robert Resch <robert@resch.dev> Co-authored-by: Bruno Pantaleão Gonçalves <5808343+bgoncal@users.noreply.github.com>
## Summary - Removes `supports_live_activities` and `supports_live_activities_frequent_updates` from the app registration payload sent to HA These fields were removed from the HA core side in home-assistant/core#166072 (the `SCHEMA_APP_DATA` schema no longer accepts them). Sending data that the server doesn't read wastes bandwidth on every registration. ## Related - home-assistant/core#166072 🤖 Generated with [Claude Code](https://claude.com/claude-code) Part of epic: home-assistant/epics#61 Fixes: #4623 --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
## Summary - Renames `webhookTypeToken` from `"mobile_app_live_activity_token"` → `"live_activity_token"` - Renames `webhookTypeDismissed` from `"mobile_app_live_activity_dismissed"` → `"live_activity_dismissed"` Requested in home-assistant/core#166072 — all other webhooks in the mobile_app integration use short names without the `mobile_app_` prefix (e.g. `scan_tag`, `update_location`). This aligns our new webhook types with that convention. ## Related - home-assistant/core#166072 🤖 Generated with [Claude Code](https://claude.com/claude-code) Part of epic: home-assistant/epics#61 Fixes: #4623 --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
…tification Users send the documented flat fields (`progress`, `notification_icon`, `notification_icon_color`, `chronometer`, `critical_text`, `when` + `when_relative`) at `data.*`. The notify service now lifts them into the `content_state` block the iOS ActivityKit decoder expects, renaming `notification_icon` → `icon` and `notification_icon_color` → `color`, and computing `countdown_end` from `when` + `when_relative`. `data.event` is added so the relay sets `attributes-type` only on `start`. A `clear_notification` whose tag matches a stored Live Activity token attaches the token and `event=end` so iOS dismisses the activity remotely instead of falling through to a regular notification banner. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| # When a notification with this message arrives with a tag matching a stored | ||
| # Live Activity token, end the activity remotely instead of letting it fall | ||
| # through to a regular clear_notification banner. | ||
| LIVE_ACTIVITY_CLEAR_MESSAGE = "clear_notification" |
There was a problem hiding this comment.
I'm open to a better name if you feel it's needed. It's iOS-specific. Android dismisses live notifications by tag natively, but iOS ActivityKit needs an explicit event=end push to the activity's token, so this string is what triggers core to attach that token. Happy to rename if LIVE_ACTIVITY_ overspecifies.
There was a problem hiding this comment.
Command is not exclusive for live activity and therefore should not prefixed with it.
Also the comment has nothing to do with the constant per se and is at the wrong place
There was a problem hiding this comment.
Makes sense, renamed to CLEAR_NOTIFICATION and moved to correct ordering. Removed comment as well.
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| type LiveActivityEvent = Literal["start", "update", "end"] |
There was a problem hiding this comment.
Sounds good changed to a StrEnum let me know if that isn't right am. Also changed associating areas that reference this.
| } | ||
|
|
||
|
|
||
| def _translate_live_activity_payload( |
There was a problem hiding this comment.
Can we move this one to the ios app or does it really need to be in core?
There was a problem hiding this comment.
Agreed I'm not sure it makes sense to have it there, if you need the data to be in a specific shape can't we add the user to use this specific shape directly?
There was a problem hiding this comment.
Live activities are something in iOS very sensitive and need to receive the exact payload to start/update it, so this abstraction/translation needs to happen at core level (not necessarily in mobile_app but not in iOS app)
There was a problem hiding this comment.
It's very sensitive on Android too, but we ask the user at the moment to provide the right field at the right spot in the data field. Having validation for some fields here would be a first (I think) and I'm not sure we want that at the moment in the integration.
There was a problem hiding this comment.
The problem is that android requirements and iOS requirements are not the same, so thats why we need to translate to iOS from core, otherwise we will need to ask the user to use different data tags when initiating live activity for iOS and Android (which we are trying to avoid)
There was a problem hiding this comment.
Why should we do the conversion in core, IMO we should keep it within the iOS codebase. Is there anything that forces us to keep it in core?
| DATA_CONFIG_ENTRIES = "config_entries" | ||
| DATA_DELETED_IDS = "deleted_ids" | ||
| DATA_DEVICES = "devices" | ||
| DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens" |
There was a problem hiding this comment.
Can we make this specific to iOS in the name to avoid any confusion? Android don't uses this, same for the other new ATTRs.
There was a problem hiding this comment.
Yes good idea, I will hold off on this until there's an architectural decision with the whole platform specific code going into this. If this is going into a mobile_app/ios/ or something then the prefix might be redundant? But I can keep this open for now and can update them if that still sounds like a good direction
| # When a notification with this message arrives with a tag matching a stored | ||
| # Live Activity token, end the activity remotely instead of letting it fall | ||
| # through to a regular clear_notification banner. | ||
| LIVE_ACTIVITY_CLEAR_MESSAGE = "clear_notification" |
There was a problem hiding this comment.
@tr4nt0r just pinging you in case you are interested to look at this PR that does change a bit the notifications.
| @@ -0,0 +1,64 @@ | |||
| """Live Activity push token lifecycle: expiry-driven cleanup loop.""" | |||
There was a problem hiding this comment.
Add a mention that it is only for iOS.
There was a problem hiding this comment.
@edenhaus Is it a "valid" pattern to actually move specific code for iOS into a dedicated python module like mobile_app/ios we don't know yet how we want the integration to evolve but things that are specific to a platform could be isolated a bit to help with whatever change we might do in the future.
There was a problem hiding this comment.
Will hold on this until we make that architectural decision
| tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] | ||
| earliest_expires_at = min( | ||
| ( | ||
| token["expires_at"] |
There was a problem hiding this comment.
Wouldit make sense to extract "expires_at" into a const? It is used in multiple places.
There was a problem hiding this comment.
Good idea, introduced ATTR_EXPIRES_AT and implemented
| async_call_later(hass, delay, run_cleanup) | ||
|
|
||
|
|
||
| async def async_cleanup_expired_tokens(hass: HomeAssistant) -> None: |
There was a problem hiding this comment.
Shouldn't this be
| async def async_cleanup_expired_tokens(hass: HomeAssistant) -> None: | |
| async def _async_cleanup_expired_tokens(hass: HomeAssistant) -> None: |
There was a problem hiding this comment.
I'm not a big python guy so I'm not sure what the proper format should be. It looks like because both functions are called from __init__.py and webhook.py the module is public by design which constitutes not having the _
I would defer to @edenhaus I might have my understanding mixed up
| "token": data[ATTR_PUSH_TOKEN], | ||
| "expires_at": dt_util.utcnow().timestamp() + LIVE_ACTIVITY_TOKEN_TTL_SECONDS, | ||
| } | ||
| hass.data[DOMAIN][DATA_STORE].async_delay_save( |
There was a problem hiding this comment.
Could you add a small comment about why you need a delay here?
There was a problem hiding this comment.
Sounds good, added
# Debounce disk writes: ActivityKit can hand a fresh per-tag token to the
# iOS app multiple times in quick succession (e.g. when several activities
# start back-to-back), and we don't need to fsync after each one.
| STORAGE_KEY = DOMAIN | ||
| STORAGE_VERSION = 1 | ||
| STORAGE_VERSION_MINOR = 2 | ||
| STORAGE_SAVE_DELAY = 10 |
There was a problem hiding this comment.
| STORAGE_SAVE_DELAY = 10 | |
| STORAGE_SAVE_DELAY_SECONDS = 10 |
I suppose?
There was a problem hiding this comment.
Good call updated and fixed references
| vol.Required(ATTR_TAG): cv.string, | ||
| } | ||
| ) | ||
| async def webhook_live_activity_dismissed( |
There was a problem hiding this comment.
| async def webhook_live_activity_dismissed( | |
| async def webhook_ios_live_activity_dismissed( |
| # Clean up the device key if no activities remain. | ||
| if not live_activity_tokens[webhook_id]: | ||
| del live_activity_tokens[webhook_id] | ||
| hass.data[DOMAIN][DATA_STORE].async_delay_save( |
| activity_tag = data[ATTR_TAG] | ||
|
|
||
| live_activity_tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] | ||
| if webhook_id in live_activity_tokens: |
There was a problem hiding this comment.
Would it make sense to log when we are in the else branches of this function? To let know the user he wrongly configured his notification.
There was a problem hiding this comment.
Added a DEBUG log. The else path is usually the cleanup loop having already removed the token, but the log def helps with diagnostics. (Side note: this webhook is auto-fired by iOS on dismissal, not user config)
- Drop the LIVE_ACTIVITY_ prefix on CLEAR_NOTIFICATION; it's a generic notification command, not LA-exclusive. The use-site comment in notify.py already explains the LA bridging behavior. - Extract ATTR_TOKEN and ATTR_EXPIRES_AT for the stored token dict keys. - Rename STORAGE_SAVE_DELAY → STORAGE_SAVE_DELAY_SECONDS to make the unit explicit. - Convert LiveActivityEvent from a Literal type alias to StrEnum. - Expand async_cleanup_expired_tokens docstring to explain the self-rescheduling chain. - Add debounce-rationale comments above both async_delay_save call sites. - DEBUG-log the live_activity_dismissed else branch for diagnostics. - Cover the self-rescheduling cleanup chain with a new test where two tokens with staggered expiries exercise the partial-sweep + reschedule path. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
Yesterday afternoon 05/27/26
Updated the Epic for testing information and relevant PRs:
This morning 05/28/26
@bgoncal attached an MD file showcasing the yaml I verified working here from yesterday Re: Architectural Decision, sounds like @edenhaus @bgoncal and @TimoPtr might discuss next week about what direction we should take this |
## Summary Follow-up fixes to two issues surfaced while testing #4671 end-to-end. 1. **`HALiveActivityAttributes.ContentState.countdownEnd` decoded via Unix epoch.** ActivityKit decodes the `content-state` JSON arriving via APNs with the default `JSONDecoder`, whose `Date` strategy is `.deferredToDate` (seconds since the 2001 reference date). HA core sends `countdown_end` as Unix epoch seconds, matching the documented `data.when` / `data.when_relative` user contract and the in-app handler that already does `Date(timeIntervalSince1970:)`. Without a manual decode the APNs push path renders countdowns ~31 years in the future. Adds explicit `init(from:)` and `encode(to:)` that map `countdownEnd` via `timeIntervalSince1970`. All other fields use `container.decodeIfPresent` so behavior is unchanged for them. 2. **`NotificationManagerLocalPushInterfaceDirect` assigns `LocalPushManager.delegate`.** The Extension path assigns the delegate at line 197; the Direct path (used on simulator and Mac Catalyst) never did. On those platforms that meant silent commands such as `clear_notification` (no alert title/body) were dropped: iOS doesn't fire `willPresent` for content-less notifications, and the delegate is the only fallback that routes into `commandManager`. One-line fix that brings the Direct factory into line with the Extension's behavior. Real-device verification (iPhone 13 Mini, iOS 26.5, paid Developer account): chronometer countdown rendered correctly 60→0 with the Codable fix in place. Without it the timer rendered as if `Date` were seconds-since-2001. ## Screenshots n/a — both fixes affect decode / message routing behavior, not UI rendering. ## Link to pull request in Documentation repository Documentation: home-assistant/companion.home-assistant#1303 ## Any other notes Part of the Live Activities effort tracked in home-assistant/epics#61. Companion server PR: home-assistant/core#166072.
|
Taking over PR: #172928 |
Proposed change
Adds server-side support for iOS Live Activities in the `mobile_app` integration. This is the HA core companion to the iOS companion app PRs and the relay server PR.
Live Activities let Home Assistant automations push real-time state to the iOS Lock Screen and Dynamic Island. The iOS app handles the ActivityKit lifecycle; this PR adds the webhook handlers and notification routing that HA core needs.
How it works: When a notification contains `live_update: true` and a `tag`, the notify service looks up the stored APNs Live Activity token for that tag and includes it alongside the normal FCM registration token in the relay request. The relay places it in the FCM message's `apns.liveActivityToken` field — no separate APNs endpoint or credentials needed. If no per-activity token exists, it falls back to the device's push-to-start token (iOS 17.2+) to start a new activity remotely.
What this adds:
Type of change
Additional information
Part of epic: home-assistant/epics#61
Fixes: home-assistant/iOS#4623