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
…anup Tokens are now stored via a dedicated Store (mobile_app.live_activity_tokens) so they survive HA restarts. Each token is saved with a stored_at timestamp; tokens older than 8 hours are filtered on load and cleaned up lazily on lookup. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Re-instated the accidental removal of that comment above Integrated the storage capability for tokens: 25340ac Let me know how its looking overall and anything else to do |
|
Please fix the merge conflict. If you think the PR is ready for another review please mark it as ready for review. Will wait for the next review until it's marked ready for review |
Sounds good. I was hesitant to mess it up again with the whole rebase and update from last time |
Removes the separate live_activity_tokens store. Tokens are now saved
in the main mobile_app store (STORAGE_VERSION bumped to 2). Existing
v1 data is migrated inline by defaulting the new key to {}. Stores
timestamps as floats so no custom datetime parsing is needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Added to existing store: b0cb713 Removed custom datetime parsing |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
@edenhaus Is the current state good to be fully tested locally? Let me know so I can setup my environment using all open PRs (iOS, FCM, Core) |
edenhaus
left a comment
There was a problem hiding this comment.
Sorry forgotten to submit the review
| if hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS].pop(webhook_id, None) is not None: | ||
| await hass.data[DOMAIN][DATA_STORE].async_save(savable_state(hass)) |
There was a problem hiding this comment.
Why is this needed on unload?
Should we not remove the tokens on remove (function below)
There was a problem hiding this comment.
Sorry, good call. I moved the cleanup to async_remove_entry. Tokens should now survive unload and reload. Re-ran the tests and updated them
| elif DATA_LIVE_ACTIVITY_TOKENS not in app_config: | ||
| app_config[DATA_LIVE_ACTIVITY_TOKENS] = {} |
There was a problem hiding this comment.
| elif DATA_LIVE_ACTIVITY_TOKENS not in app_config: | |
| app_config[DATA_LIVE_ACTIVITY_TOKENS] = {} |
not needed. If loaded it's there. Migration will make sure that existing one will get the field on upgrade. Where is the migration?
There was a problem hiding this comment.
Sounds good. Added a _mobileappstore subclass with _async_migrate_func to handle version 1 -> 2 which adds live_activity_tokens on upgrade. Removed the elif like suggested and added a migration test
| live_activity_tokens: dict[str, Any] = { | ||
| wh_id: { | ||
| tag: entry | ||
| for tag, entry in tags.items() | ||
| if entry.get("stored_at", 0) > cutoff | ||
| } | ||
| for wh_id, tags in app_config[DATA_LIVE_ACTIVITY_TOKENS].items() |
There was a problem hiding this comment.
if statement is duplicated for example. Can we make the code nicer?
There was a problem hiding this comment.
Replaced the nest with a loop and re ran the tests once more for this. Let me know if I can make further improvements
| if entry.get("stored_at", 0) > cutoff | ||
| } | ||
| for wh_id, tags in app_config[DATA_LIVE_ACTIVITY_TOKENS].items() | ||
| if any(entry.get("stored_at", 0) > cutoff for entry in tags.values()) |
There was a problem hiding this comment.
If we found invalid entries during setup we should update the store.
As we are already iterating over all, we should identify the next token that will expire and setup a task that will be called at that time to remove it. Just one single task which will remove the token and schedule itself again with the next one
There was a problem hiding this comment.
Sounds good. Now going to track the earliest expiration during the startup loop. If expired tokens are found we save immediately and if there are valid ones, we schedule a single cleanup task that
- Removes what's expired
- Saves
- Reschedules itself for the next one
Also added tests for both behaviors.
|
|
||
| # Per-activity token — the activity is already running on the device. | ||
| webhook_id = entry.data[ATTR_WEBHOOK_ID] | ||
| live_activity_tokens = self.hass.data[DOMAIN].get(DATA_LIVE_ACTIVITY_TOKENS, {}) |
There was a problem hiding this comment.
| live_activity_tokens = self.hass.data[DOMAIN].get(DATA_LIVE_ACTIVITY_TOKENS, {}) | |
| live_activity_tokens = self.hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] |
use .get only when there could be a possibility that the data is not there.
Adopt also all the other places and other variables
There was a problem hiding this comment.
Ok thanks sounds good. Fixed in both places as well as app_config.get(DATA_DELETED_IDS) in __init__.py
| # Token expired — remove it lazily. | ||
| device_tokens.pop(tag, None) | ||
| if not device_tokens: | ||
| live_activity_tokens.pop(webhook_id, None) | ||
| await self.hass.data[DOMAIN][DATA_STORE].async_save( | ||
| savable_state(self.hass) | ||
| ) |
There was a problem hiding this comment.
Comment is not matching the code as you remove it immediately.
We probably can omit it here as we will have a clean up task. So remvoing is lazily
There was a problem hiding this comment.
Thanks for catching that. I removed the eager cleanup and now the scheduled task handles it. Notification path just skips the expired token and falls through to push-to-start
Tokens should survive unload (restart/reload) so they are available when the entry loads again. Remove them only in async_remove_entry, when the device is permanently deleted. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Subclass Store to provide a migration function that adds the live_activity_tokens key when upgrading from v1. Removes the ad-hoc elif fallback that was filling in the missing key after load. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace nested dict comprehension with a plain loop so the expiry check appears only once. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ity tokens If expired tokens are found during setup, persist the cleaned state immediately. Track the earliest expiry among valid tokens and schedule a single cleanup task that removes expired tokens, saves, and reschedules itself for the next expiry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace .get() with direct key access on hass.data[DOMAIN] and app_config where setup and migration guarantee the keys are present. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The scheduled cleanup task handles expired live activity token removal. The notification path now just skips the expired token and falls through to push-to-start without touching the store. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Resolve conflict in savable_state: keep DATA_LIVE_ACTIVITY_TOKENS key alongside DATA_DELETED_IDS and adopt updated pylint disable comment format. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Pushed up all suggested changes from @edenhaus in the most recent review. I left them marked as unresolved to not lose track of anything until @edenhaus is satisfied with it. I also fixed the merge conflict and was sure to not to rebase this time :) cc: @bgoncal Marking as ready for review once again 🚂 |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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