diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 2711f9457888c2..332ae73d6b2381 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -41,6 +41,7 @@ DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, + DATA_LIVE_ACTIVITY_TOKENS, DATA_PENDING_UPDATES, DATA_PUSH_CHANNEL, DATA_STORE, @@ -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}, @@ -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 diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index a4ed3ea598bd45..fc549f1aeb2768 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -35,6 +35,21 @@ 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 in the iOS companion app webhooks. +ATTR_LIVE_ACTIVITY_TAG = "live_activity_tag" + +# In-memory store for per-device Live Activity push tokens, keyed by webhook_id → live_activity_tag. +# Populated by mobile_app_live_activity_token and cleared by mobile_app_live_activity_dismissed webhooks. +DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens" + ATTR_PUSH_RATE_LIMITS = "rateLimits" ATTR_PUSH_RATE_LIMITS_ERRORS = "errors" ATTR_PUSH_RATE_LIMITS_MAXIMUM = "maximum" @@ -92,6 +107,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, ) diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index 085c80afbebff3..128d063e7feb64 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -29,6 +29,7 @@ ATTR_APP_ID, ATTR_APP_VERSION, ATTR_DEVICE_NAME, + ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, ATTR_OS_VERSION, ATTR_PUSH_RATE_LIMITS, ATTR_PUSH_RATE_LIMITS_ERRORS, @@ -39,6 +40,7 @@ ATTR_PUSH_URL, ATTR_WEBHOOK_ID, DATA_CONFIG_ENTRIES, + DATA_LIVE_ACTIVITY_TOKENS, DATA_NOTIFY, DATA_PUSH_CHANNEL, DOMAIN, @@ -145,6 +147,42 @@ 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, app_data: 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_update: 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_update") is not True: + return None + + tag = notification_data.get("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+). + 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] @@ -154,6 +192,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, app_data, 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], diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index cbbcd7710ee845..9165b6bce45a31 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -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, @@ -98,6 +100,7 @@ DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, + DATA_LIVE_ACTIVITY_TOKENS, DATA_PENDING_UPDATES, DOMAIN, ERR_ENCRYPTION_ALREADY_ENABLED, @@ -797,3 +800,60 @@ async def webhook_scan_tag( registration_context(config_entry.data), ) return empty_okay_response() + + +@WEBHOOK_COMMANDS.register("mobile_app_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], + } + + return empty_okay_response() + + +@WEBHOOK_COMMANDS.register("mobile_app_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] + + return empty_okay_response() diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py index a67ed39b760339..e5983e2ba2cfe0 100644 --- a/tests/components/mobile_app/test_init.py +++ b/tests/components/mobile_app/test_init.py @@ -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 @@ -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 @@ -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": "mobile_app_live_activity_token", + "data": { + "live_activity_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] diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py index c7fd8c48359588..975ee3a7f93de7 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -6,7 +6,7 @@ import pytest -from homeassistant.components.mobile_app.const import DOMAIN +from homeassistant.components.mobile_app.const import DATA_LIVE_ACTIVITY_TOKENS, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -594,3 +594,156 @@ async def test_notify_multiple_targets_if_any_disconnected( # Check that there are no more messages to receive (timeout expected) with pytest.raises(asyncio.TimeoutError): await asyncio.wait_for(client.receive_json(), timeout=0.1) + + +async def test_notify_live_activity_uses_stored_token( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_push_receiver +) -> None: + """Test that live_update notifications include live_activity_token.""" + # Simulate the iOS app having registered a per-activity token via webhook. + hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]["mock-webhook_id"] = { + "washer_cycle": { + "push_token": "LIVE_ACTIVITY_TOKEN_HEX", + } + } + + await hass.services.async_call( + "notify", + "mobile_app_test", + { + "message": "45 minutes remaining", + "target": ["mock-webhook_id"], + "data": {"live_update": True, "tag": "washer_cycle", "progress": 2700}, + }, + blocking=True, + ) + + # Should be sent to the SAME push URL with both tokens. + assert len(aioclient_mock.mock_calls) == 1 + call_json = aioclient_mock.mock_calls[0][2] + # FCM token stays as push_token; live activity token is a separate field. + assert call_json["push_token"] == "PUSH_TOKEN" + assert call_json["live_activity_token"] == "LIVE_ACTIVITY_TOKEN_HEX" + assert call_json["data"]["live_update"] is True + assert call_json["data"]["tag"] == "washer_cycle" + + +async def test_notify_live_activity_falls_back_to_push_to_start( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_admin_user: MockUser, +) -> None: + """Test that live_update without stored token uses push-to-start token.""" + push_url = "https://mobile-push.home-assistant.dev/push" + now = datetime.now() + timedelta(hours=24) + iso_time = now.strftime("%Y-%m-%dT%H:%M:%SZ") + + aioclient_mock.post( + push_url, + json={ + "rateLimits": { + "successful": 1, + "errors": 0, + "maximum": 150, + "resetsAt": iso_time, + } + }, + ) + + entry = MockConfigEntry( + data={ + "app_data": { + "push_token": "FCM_TOKEN", + "push_url": push_url, + "live_activity_push_to_start_token": "PUSH_TO_START_HEX_TOKEN", + "live_activity_push_to_start_apns_environment": "production", + }, + "app_id": "io.robbie.HomeAssistant", + "app_name": "Home Assistant", + "app_version": "2024.1", + "device_id": "ios-device-1", + "device_name": "iPhone", + "manufacturer": "Apple", + "model": "iPhone 15", + "os_name": "iOS", + "os_version": "17.2", + "supports_encryption": False, + "user_id": hass_admin_user.id, + "webhook_id": "ios-webhook-1", + }, + domain=DOMAIN, + source="registration", + title="iPhone entry", + version=1, + ) + entry.add_to_hass(hass) + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + await hass.services.async_call( + "notify", + "mobile_app_iphone", + { + "message": "Laundry started", + "target": ["ios-webhook-1"], + "data": {"live_update": True, "tag": "laundry"}, + }, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 + call_json = aioclient_mock.mock_calls[0][2] + # FCM token stays as push_token; push-to-start token is live_activity_token. + assert call_json["push_token"] == "FCM_TOKEN" + assert call_json["live_activity_token"] == "PUSH_TO_START_HEX_TOKEN" + + +async def test_notify_live_activity_without_tag_uses_fcm( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_push_receiver +) -> None: + """Test that live_update without a tag falls through to normal FCM push.""" + await hass.services.async_call( + "notify", + "mobile_app_test", + { + "message": "No tag here", + "target": ["mock-webhook_id"], + "data": {"live_update": True}, + }, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 + call_json = aioclient_mock.mock_calls[0][2] + # Should use normal FCM token since there's no tag. + assert call_json["push_token"] == "PUSH_TOKEN" + assert "live_activity_token" not in call_json + + +async def test_notify_normal_notification_ignores_live_activity_tokens( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_push_receiver +) -> None: + """Test that normal notifications don't route through live activity tokens.""" + # Store a live activity token — it should be ignored for non-live-activity pushes. + hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]["mock-webhook_id"] = { + "some_tag": { + "push_token": "SHOULD_NOT_USE_THIS", + } + } + + await hass.services.async_call( + "notify", + "mobile_app_test", + { + "message": "Normal notification", + "target": ["mock-webhook_id"], + "data": {"tag": "some_tag"}, + }, + blocking=True, + ) + + assert len(aioclient_mock.mock_calls) == 1 + call_json = aioclient_mock.mock_calls[0][2] + # Should use normal FCM token — live_update flag not set. + assert call_json["push_token"] == "PUSH_TOKEN" + assert "live_activity_token" not in call_json diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 7fd0cbda8a6d9d..b082ce1e5eb4c7 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -13,7 +13,12 @@ import pytest from homeassistant.components.camera import CameraEntityFeature -from homeassistant.components.mobile_app.const import CONF_SECRET, DATA_DEVICES, DOMAIN +from homeassistant.components.mobile_app.const import ( + CONF_SECRET, + DATA_DEVICES, + DATA_LIVE_ACTIVITY_TOKENS, + DOMAIN, +) from homeassistant.components.tag import EVENT_TAG_SCANNED from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN from homeassistant.const import ( @@ -1275,3 +1280,125 @@ async def test_sending_sensor_state( state = hass.states.get("sensor.test_1_battery_health") assert state is not None assert state.state == "okay-ish" + + +async def test_webhook_update_live_activity_token( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that we can store a Live Activity push token.""" + webhook_id = create_registrations[1]["webhook_id"] + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "mobile_app_live_activity_token", + "data": { + "live_activity_tag": "washer_cycle", + "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + }, + }, + ) + + assert resp.status == HTTPStatus.OK + result = await resp.json() + assert result == {} + + # Verify token was stored in hass.data + tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] + assert webhook_id in tokens + assert tokens[webhook_id]["washer_cycle"]["push_token"] == ( + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" + ) + + +async def test_webhook_update_live_activity_token_stores_only_push_token( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that stored token data contains only push_token (FCM handles routing).""" + webhook_id = create_registrations[1]["webhook_id"] + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "mobile_app_live_activity_token", + "data": { + "live_activity_tag": "ev_charge", + "push_token": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }, + }, + ) + + assert resp.status == HTTPStatus.OK + + tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] + stored = tokens[webhook_id]["ev_charge"] + assert stored == { + "push_token": "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + } + + +async def test_webhook_live_activity_dismissed( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that we can dismiss a Live Activity and clean up its token.""" + webhook_id = create_registrations[1]["webhook_id"] + + # First register a token + await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "mobile_app_live_activity_token", + "data": { + "live_activity_tag": "washer_cycle", + "push_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2", + }, + }, + ) + + # Verify token is stored + tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS] + assert webhook_id in tokens + assert "washer_cycle" in tokens[webhook_id] + + # Now dismiss it + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "mobile_app_live_activity_dismissed", + "data": { + "live_activity_tag": "washer_cycle", + }, + }, + ) + + assert resp.status == HTTPStatus.OK + result = await resp.json() + assert result == {} + + # Verify token was removed — webhook_id key also cleaned up since no activities remain + assert webhook_id not in tokens + + +async def test_webhook_live_activity_dismissed_nonexistent_tag( + hass: HomeAssistant, + create_registrations: tuple[dict[str, Any], dict[str, Any]], + webhook_client: TestClient, +) -> None: + """Test that dismissing a nonexistent tag does not error.""" + webhook_id = create_registrations[1]["webhook_id"] + + resp = await webhook_client.post( + f"/api/webhook/{webhook_id}", + json={ + "type": "mobile_app_live_activity_dismissed", + "data": { + "live_activity_tag": "nonexistent_activity", + }, + }, + ) + + assert resp.status == HTTPStatus.OK