Skip to content
Draft
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
acaa98a
Add iOS Live Activity webhook handlers to mobile_app integration
rwarner Mar 20, 2026
01604ad
Use constants for event names and add EventOrigin.remote
rwarner Mar 20, 2026
bd08936
Address Copilot review: Inclusive validation and token cleanup
rwarner Mar 20, 2026
5d27da3
Wire up notify.py to route Live Activity pushes through APNs relay
rwarner Mar 20, 2026
a0c8bd7
Simplify Live Activity routing — use FCM native liveActivityToken
rwarner Mar 21, 2026
dea3db7
Use ATTR_LIVE_ACTIVITY_TAG constant in notify.py
rwarner Mar 21, 2026
4d450fd
Address Copilot feedback: validate tag type and use constants in events
rwarner Mar 23, 2026
6ec5302
Tighten input validation for Live Activity fields
rwarner Mar 24, 2026
57ec417
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 24, 2026
62b09f7
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 24, 2026
ca84c70
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 25, 2026
4ce2a2e
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 25, 2026
9be40d9
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 25, 2026
b381fe4
Address Copilot review: use ATTR_WEBHOOK_ID in events, validate dismi…
rwarner Mar 25, 2026
29a27c7
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 26, 2026
9548dbb
Require non-empty tag in update_live_activity_token webhook schema
rwarner Mar 26, 2026
e60ec73
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 26, 2026
0aa42a1
Use live_update: true instead of live_activity: true for iOS Live Act…
rwarner Mar 26, 2026
3f43e96
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 26, 2026
ae9d27b
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 26, 2026
2dd5151
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 26, 2026
aa4b2fa
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 26, 2026
fc20efd
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 27, 2026
ee0974e
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 30, 2026
311f7f2
Merge branch 'dev' into feat/ios-live-activity
rwarner Mar 31, 2026
0d466f3
Merge branch 'dev' into feat/ios-live-activity
rwarner Apr 1, 2026
9567852
Remove unused bus events and supports_live_activities helper; simplif…
rwarner Apr 1, 2026
c3d3eb0
Rename live activity webhook tag field from 'tag' to 'live_activity_tag'
rwarner Apr 1, 2026
e0ae7da
Align webhook type names with iOS companion app
rwarner Apr 1, 2026
db1b8f5
Merge branch 'dev' into feat/ios-live-activity
rwarner Apr 1, 2026
83ac025
Remove unused ATTR_WEBHOOK_ID import from webhook.py
rwarner Apr 2, 2026
33cb529
Merge branch 'dev' into feat/ios-live-activity
rwarner Apr 2, 2026
7379af0
Fix test_init.py to use renamed webhook type and tag field
rwarner Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions homeassistant/components/mobile_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
DATA_CONFIG_ENTRIES,
DATA_DELETED_IDS,
DATA_DEVICES,
DATA_LIVE_ACTIVITY_TOKENS,
DATA_PENDING_UPDATES,
DATA_PUSH_CHANNEL,
DATA_STORE,
Expand Down Expand Up @@ -75,6 +76,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
DATA_CONFIG_ENTRIES: {},
DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []),
DATA_DEVICES: {},
DATA_LIVE_ACTIVITY_TOKENS: {},
DATA_PUSH_CHANNEL: {},
DATA_STORE: store,
DATA_PENDING_UPDATES: {sensor_type: {} for sensor_type in SENSOR_TYPES},
Expand Down Expand Up @@ -225,6 +227,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
webhook_unregister(hass, webhook_id)
del hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id]
del hass.data[DOMAIN][DATA_DEVICES][webhook_id]
hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS].pop(webhook_id, None)
await hass_notify.async_reload(hass, DOMAIN)

return True
Expand Down
34 changes: 34 additions & 0 deletions homeassistant/components/mobile_app/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@
ATTR_PUSH_WEBSOCKET_CHANNEL = "push_websocket_channel"
ATTR_PUSH_TOKEN = "push_token"
ATTR_PUSH_URL = "push_url"

ATTR_SUPPORTS_LIVE_ACTIVITIES = "supports_live_activities"
ATTR_SUPPORTS_LIVE_ACTIVITIES_FREQUENT_UPDATES = (
"supports_live_activities_frequent_updates"
)
ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN = "live_activity_push_to_start_token"
ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT = (
"live_activity_push_to_start_apns_environment"
)

# Tag identifying a specific Live Activity instance — matches the `tag` field used by
# the iOS companion app's ActivityKit integration.
ATTR_LIVE_ACTIVITY_TAG = "tag"

# In-memory store for per-device Live Activity push tokens, keyed by webhook_id → tag.
# Populated by update_live_activity_token and cleared by live_activity_dismissed webhooks.
DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens"

EVENT_LIVE_ACTIVITY_TOKEN_UPDATED = f"{DOMAIN}_live_activity_token_updated"
EVENT_LIVE_ACTIVITY_DISMISSED = f"{DOMAIN}_live_activity_dismissed"
ATTR_PUSH_RATE_LIMITS = "rateLimits"
ATTR_PUSH_RATE_LIMITS_ERRORS = "errors"
ATTR_PUSH_RATE_LIMITS_MAXIMUM = "maximum"
Expand Down Expand Up @@ -92,6 +112,20 @@
# Set to True to indicate that this registration will connect via websocket channel
# to receive push notifications.
vol.Optional(ATTR_PUSH_WEBSOCKET_CHANNEL): cv.boolean,
# iOS Live Activities capability flags and push-to-start token (iOS 17.2+).
# push-to-start allows HA to remotely start a new Live Activity on the device
# without requiring one to already be running.
vol.Optional(ATTR_SUPPORTS_LIVE_ACTIVITIES): cv.boolean,
vol.Optional(ATTR_SUPPORTS_LIVE_ACTIVITIES_FREQUENT_UPDATES): cv.boolean,
# push-to-start token and environment must be provided together — a token
# without an environment is ambiguous (sandbox tokens fail on production).
vol.Inclusive(
ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, "live_activity_push_to_start"
): vol.All(cv.string, vol.Length(min=1)),
vol.Inclusive(
ATTR_LIVE_ACTIVITY_PUSH_TO_START_APNS_ENVIRONMENT,
"live_activity_push_to_start",
): vol.In(["sandbox", "production"]),
},
extra=vol.ALLOW_EXTRA,
)
Expand Down
47 changes: 47 additions & 0 deletions homeassistant/components/mobile_app/notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@
ATTR_APP_ID,
ATTR_APP_VERSION,
ATTR_DEVICE_NAME,
ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN,
ATTR_LIVE_ACTIVITY_TAG,
ATTR_OS_VERSION,
ATTR_PUSH_RATE_LIMITS,
ATTR_PUSH_RATE_LIMITS_ERRORS,
Expand All @@ -39,6 +41,7 @@
ATTR_PUSH_URL,
ATTR_WEBHOOK_ID,
DATA_CONFIG_ENTRIES,
DATA_LIVE_ACTIVITY_TOKENS,
DATA_NOTIFY,
DATA_PUSH_CHANNEL,
DOMAIN,
Expand Down Expand Up @@ -145,6 +148,43 @@ async def async_send_message(self, message: str = "", **kwargs: Any) -> None:
f"Device(s) with webhook id(s) {', '.join(failed_targets)} not connected to local push notifications"
)

def _get_live_activity_token(
self, target: str, registration: dict[str, Any], data: dict[str, Any]
) -> str | None:
"""Return the Live Activity APNs token if this notification targets one.

Checks whether the notification payload contains live_activity: true
and a tag. If a per-activity APNs token is stored for that tag, it is
returned. Otherwise, if the device has a push-to-start token, that is
returned so the relay server can start a new activity remotely.

The token is sent alongside the FCM registration token to the same
relay URL. The relay server places it in the FCM message's
apns.liveActivityToken field, and FCM handles APNs delivery.

Returns None if this is a normal notification (not a Live Activity).
"""
notification_data = data.get(ATTR_DATA) or {}
if notification_data.get("live_activity") is not True:
return None

tag = notification_data.get(ATTR_LIVE_ACTIVITY_TAG)
if not tag or not isinstance(tag, str):
return None

# Per-activity token — the activity is already running on the device.
live_activity_tokens = self.hass.data[DOMAIN].get(DATA_LIVE_ACTIVITY_TOKENS, {})
device_tokens = live_activity_tokens.get(target, {})
if tag in device_tokens:
return device_tokens[tag][ATTR_PUSH_TOKEN]

# Push-to-start token — start a new activity remotely (iOS 17.2+).
app_data = registration[ATTR_APP_DATA]
if ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN in app_data:
return app_data[ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN]

return None

async def _async_send_remote_message_target(self, target, registration, data):
"""Send a message to a target."""
app_data = registration[ATTR_APP_DATA]
Expand All @@ -154,6 +194,13 @@ async def _async_send_remote_message_target(self, target, registration, data):
target_data = dict(data)
target_data[ATTR_PUSH_TOKEN] = push_token

# If this is a Live Activity notification, include the APNs token so the
# relay server can set apns.liveActivityToken in the FCM payload. FCM then
# handles apns-push-type: liveactivity and APNs routing automatically.
live_activity_token = self._get_live_activity_token(target, registration, data)
if live_activity_token:
target_data["live_activity_token"] = live_activity_token

reg_info = {
ATTR_APP_ID: registration[ATTR_APP_ID],
ATTR_APP_VERSION: registration[ATTR_APP_VERSION],
Expand Down
9 changes: 9 additions & 0 deletions homeassistant/components/mobile_app/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
ATTR_PUSH_TOKEN,
ATTR_PUSH_URL,
ATTR_PUSH_WEBSOCKET_CHANNEL,
ATTR_SUPPORTS_LIVE_ACTIVITIES,
CONF_CLOUDHOOK_URL,
DATA_CONFIG_ENTRIES,
DATA_DEVICES,
Expand Down Expand Up @@ -48,6 +49,14 @@ def supports_push(hass: HomeAssistant, webhook_id: str) -> bool:
) or ATTR_PUSH_WEBSOCKET_CHANNEL in app_data


@callback
def supports_live_activities(hass: HomeAssistant, webhook_id: str) -> bool:
"""Return if the device supports iOS Live Activities."""
config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id]
app_data = config_entry.data.get(ATTR_APP_DATA, {})
return bool(app_data.get(ATTR_SUPPORTS_LIVE_ACTIVITIES))


@callback
def get_notify_service(hass: HomeAssistant, webhook_id: str) -> str | None:
"""Return the notify service for this webhook ID."""
Expand Down
87 changes: 87 additions & 0 deletions homeassistant/components/mobile_app/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@
ATTR_DEVICE_NAME,
ATTR_EVENT_DATA,
ATTR_EVENT_TYPE,
ATTR_LIVE_ACTIVITY_TAG,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTR_NO_LEGACY_ENCRYPTION,
ATTR_OS_VERSION,
ATTR_PUSH_TOKEN,
ATTR_SENSOR_ATTRIBUTES,
ATTR_SENSOR_DEVICE_CLASS,
ATTR_SENSOR_DISABLED,
Expand All @@ -90,6 +92,7 @@
ATTR_WEBHOOK_DATA,
ATTR_WEBHOOK_ENCRYPTED,
ATTR_WEBHOOK_ENCRYPTED_DATA,
ATTR_WEBHOOK_ID,
ATTR_WEBHOOK_TYPE,
CONF_CLOUDHOOK_URL,
CONF_REMOTE_UI_URL,
Expand All @@ -98,12 +101,15 @@
DATA_CONFIG_ENTRIES,
DATA_DELETED_IDS,
DATA_DEVICES,
DATA_LIVE_ACTIVITY_TOKENS,
DATA_PENDING_UPDATES,
DOMAIN,
ERR_ENCRYPTION_ALREADY_ENABLED,
ERR_ENCRYPTION_REQUIRED,
ERR_INVALID_FORMAT,
ERR_SENSOR_NOT_REGISTERED,
EVENT_LIVE_ACTIVITY_DISMISSED,
EVENT_LIVE_ACTIVITY_TOKEN_UPDATED,
SCHEMA_APP_DATA,
SENSOR_TYPES,
SIGNAL_LOCATION_UPDATE,
Expand Down Expand Up @@ -797,3 +803,84 @@ async def webhook_scan_tag(
registration_context(config_entry.data),
)
return empty_okay_response()


@WEBHOOK_COMMANDS.register("update_live_activity_token")
@validate_schema(
{
vol.Required(ATTR_LIVE_ACTIVITY_TAG): vol.All(cv.string, vol.Length(min=1)),
vol.Required(ATTR_PUSH_TOKEN): vol.All(cv.string, vol.Length(min=1)),
}
)
async def webhook_update_live_activity_token(
hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, Any]
) -> Response:
"""Handle a Live Activity token update from the iOS companion app.

When the iOS app creates a Live Activity locally, ActivityKit provides
a per-activity APNs push token. The app sends this token so HA can
later include it as live_activity_token in the push relay request.
The relay server places it in the FCM message's apns.liveActivityToken
field, and FCM handles APNs delivery automatically.
"""
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
activity_tag = data[ATTR_LIVE_ACTIVITY_TAG]

live_activity_tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]
live_activity_tokens.setdefault(webhook_id, {})[activity_tag] = {
ATTR_PUSH_TOKEN: data[ATTR_PUSH_TOKEN],
}

device: dr.DeviceEntry = hass.data[DOMAIN][DATA_DEVICES][webhook_id]
hass.bus.async_fire(
EVENT_LIVE_ACTIVITY_TOKEN_UPDATED,
{
ATTR_LIVE_ACTIVITY_TAG: activity_tag,
ATTR_DEVICE_ID: device.id,
ATTR_WEBHOOK_ID: webhook_id,
},
EventOrigin.remote,
context=registration_context(config_entry.data),
)

return empty_okay_response()


@WEBHOOK_COMMANDS.register("live_activity_dismissed")
@validate_schema(
{
vol.Required(ATTR_LIVE_ACTIVITY_TAG): vol.All(cv.string, vol.Length(min=1)),
}
)
async def webhook_live_activity_dismissed(
hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, str]
) -> Response:
"""Handle a Live Activity dismissal from the iOS companion app.

When a Live Activity ends on the device (user dismissal, expiration,
or an explicit end event), the app notifies HA so the stored push
token for that activity can be cleaned up.
"""
webhook_id = config_entry.data[CONF_WEBHOOK_ID]
activity_tag = data[ATTR_LIVE_ACTIVITY_TAG]

live_activity_tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]
if webhook_id in live_activity_tokens:
live_activity_tokens[webhook_id].pop(activity_tag, None)
# Clean up the device key if no activities remain.
if not live_activity_tokens[webhook_id]:
del live_activity_tokens[webhook_id]

device: dr.DeviceEntry = hass.data[DOMAIN][DATA_DEVICES][webhook_id]
hass.bus.async_fire(
EVENT_LIVE_ACTIVITY_DISMISSED,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's confusing that it's called sometimes live_update and then live_activity

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From home-assistant/iOS#4444 (comment) it looks like we should rename everything to live_update

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user-facing notification flag is live_update: true throughout. The live_activity naming is intentionally kept for the ActivityKit-specific internals (token store, push-to-start registration, webhook names) where it refers to the Apple feature rather than the notification field.

It does get a bit difficult, but don't want to overstep with Android references and/or YAML references to live_update

{
ATTR_LIVE_ACTIVITY_TAG: activity_tag,
ATTR_DEVICE_ID: device.id,
ATTR_WEBHOOK_ID: webhook_id,
},
EventOrigin.remote,
context=registration_context(config_entry.data),
)

return empty_okay_response()
33 changes: 33 additions & 0 deletions tests/components/mobile_app/test_init.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Tests for the mobile app integration."""

from collections.abc import Awaitable, Callable
from http import HTTPStatus
from typing import Any
from unittest.mock import Mock, patch

from aiohttp.test_utils import TestClient
import pytest

from homeassistant.components.cloud import CloudNotAvailable
Expand All @@ -12,6 +14,7 @@
CONF_CLOUDHOOK_URL,
CONF_USER_ID,
DATA_DELETED_IDS,
DATA_LIVE_ACTIVITY_TOKENS,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
Expand Down Expand Up @@ -615,3 +618,33 @@ def mock_listen_cloudhook_change(hass_instance, wh_id: str, callback):

# URL should remain the same
assert config_entry.data[CONF_CLOUDHOOK_URL] == new_url


@pytest.mark.usefixtures("create_registrations")
async def test_unload_removes_live_activity_tokens(
hass: HomeAssistant, webhook_client: TestClient
) -> None:
"""Test that live activity tokens are removed from hass.data when entry is unloaded."""
# Use the cleartext (non-encrypted) entry
config_entry = hass.config_entries.async_entries("mobile_app")[1]
webhook_id = config_entry.data["webhook_id"]

# Store a live activity token via the webhook
resp = await webhook_client.post(
f"/api/webhook/{webhook_id}",
json={
"type": "update_live_activity_token",
"data": {
"tag": "washer_cycle",
"push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
},
},
)
assert resp.status == HTTPStatus.OK
assert webhook_id in hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]

# Unload the config entry
await hass.config_entries.async_unload(config_entry.entry_id)

# Verify the token is removed so stale tokens cannot be used after reloads/unloads
assert webhook_id not in hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]
Loading
Loading