diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 441022aaf6e741..ee9d8ea465fbe8 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -42,6 +42,7 @@ DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, + DATA_LIVE_ACTIVITY_TOKENS, DATA_PENDING_UPDATES, DATA_PUSH_CHANNEL, DATA_STORE, @@ -81,6 +82,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}, @@ -231,6 +233,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..51fcc88d83a110 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -32,6 +32,7 @@ ATTR_NO_LEGACY_ENCRYPTION = "no_legacy_encryption" ATTR_OS_NAME = "os_name" ATTR_OS_VERSION = "os_version" +ATTR_PUSH_TAG = "tag" ATTR_PUSH_WEBSOCKET_CHANNEL = "push_websocket_channel" ATTR_PUSH_TOKEN = "push_token" ATTR_PUSH_URL = "push_url" @@ -42,6 +43,12 @@ ATTR_PUSH_RATE_LIMITS_SUCCESSFUL = "successful" ATTR_SUPPORTS_ENCRYPTION = "supports_encryption" +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" +ATTR_LIVE_ACTIVITY_TAG = "live_activity_tag" +DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens" + ATTR_EVENT_DATA = "event_data" ATTR_EVENT_TYPE = "event_type" @@ -89,9 +96,8 @@ { vol.Inclusive(ATTR_PUSH_TOKEN, "push_cloud"): cv.string, vol.Inclusive(ATTR_PUSH_URL, "push_cloud"): cv.url, - # 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, + vol.Optional(ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN): cv.string, }, extra=vol.ALLOW_EXTRA, ) diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index e508212c80b28e..05a3ca59938b39 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -35,16 +35,21 @@ ATTR_APP_ID, ATTR_APP_VERSION, ATTR_DEVICE_NAME, + ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN, + ATTR_LIVE_ACTIVITY_TOKEN, + ATTR_LIVE_UPDATE, ATTR_OS_VERSION, ATTR_PUSH_RATE_LIMITS, ATTR_PUSH_RATE_LIMITS_ERRORS, ATTR_PUSH_RATE_LIMITS_MAXIMUM, ATTR_PUSH_RATE_LIMITS_RESETS_AT, ATTR_PUSH_RATE_LIMITS_SUCCESSFUL, + ATTR_PUSH_TAG, ATTR_PUSH_TOKEN, ATTR_PUSH_URL, ATTR_WEBHOOK_ID, DATA_CONFIG_ENTRIES, + DATA_LIVE_ACTIVITY_TOKENS, DATA_NOTIFY, DATA_PUSH_CHANNEL, DOMAIN, @@ -211,12 +216,40 @@ 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, entry: ConfigEntry, data: dict[str, Any] + ) -> str | None: + """Return the Live Activity APNs token for this notification, or None.""" + notification_data = data.get(ATTR_DATA) or {} + if not notification_data.get(ATTR_LIVE_UPDATE): + return None + + tag = notification_data.get(ATTR_PUSH_TAG) + if not tag: + return None + + # 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, {}) + device_tokens = live_activity_tokens.get(webhook_id, {}) + 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 = entry.data[ATTR_APP_DATA] + return app_data.get(ATTR_LIVE_ACTIVITY_PUSH_TO_START_TOKEN) + async def _async_send_remote_message_target( self, entry: ConfigEntry, data: dict[str, Any] - ): + ) -> None: """Send a message to a target.""" try: - await _send_message(async_get_clientsession(self.hass), entry, data) + await _send_message( + async_get_clientsession(self.hass), + entry, + data, + live_activity_token=self._get_live_activity_token(entry, data), + ) except HomeAssistantError as e: if e.translation_key == "rate_limit_exceeded_sending_notification": _LOGGER.warning(str(e)) @@ -225,7 +258,11 @@ async def _async_send_remote_message_target( async def _send_message( - session: ClientSession, entry: ConfigEntry, data: dict[str, Any] + session: ClientSession, + entry: ConfigEntry, + data: dict[str, Any], + *, + live_activity_token: str | None = None, ) -> None: """Shared internal helper to send messages via cloud push notification services.""" reg_info = { @@ -236,15 +273,19 @@ async def _send_message( if ATTR_OS_VERSION in entry.data: reg_info[ATTR_OS_VERSION] = entry.data[ATTR_OS_VERSION] + payload: dict[str, Any] = { + **data, + ATTR_PUSH_TOKEN: entry.data[ATTR_APP_DATA][ATTR_PUSH_TOKEN], + "registration_info": reg_info, + } + if live_activity_token: + payload[ATTR_LIVE_ACTIVITY_TOKEN] = live_activity_token + try: async with asyncio.timeout(10): response = await session.post( entry.data[ATTR_APP_DATA][ATTR_PUSH_URL], - json={ - **data, - ATTR_PUSH_TOKEN: entry.data[ATTR_APP_DATA][ATTR_PUSH_TOKEN], - "registration_info": reg_info, - }, + json=payload, ) result: dict[str, Any] = await response.json() diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 232c4c50c6c336..468e8a6cd291b6 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -67,10 +67,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, @@ -99,6 +101,7 @@ DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, + DATA_LIVE_ACTIVITY_TOKENS, DATA_PENDING_UPDATES, DOMAIN, ERR_ENCRYPTION_ALREADY_ENABLED, @@ -798,3 +801,48 @@ async def webhook_scan_tag( registration_context(config_entry.data), ) return empty_okay_response() + + +@WEBHOOK_COMMANDS.register("live_activity_token") +@validate_schema( + { + vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, + vol.Required(ATTR_PUSH_TOKEN): cv.string, + } +) +async def webhook_update_live_activity_token( + hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, Any] +) -> Response: + """Store a Live Activity APNs token sent by the iOS app.""" + 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("live_activity_dismissed") +@validate_schema( + { + vol.Required(ATTR_LIVE_ACTIVITY_TAG): cv.string, + } +) +async def webhook_live_activity_dismissed( + hass: HomeAssistant, config_entry: ConfigEntry, data: dict[str, str] +) -> Response: + """Remove a stored Live Activity token when the activity ends on device.""" + 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..0fb1cfddb7f1a4 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": "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 405be4ef0e9593..b6454d055a3db2 100644 --- a/tests/components/mobile_app/test_notify.py +++ b/tests/components/mobile_app/test_notify.py @@ -10,7 +10,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.mobile_app.const import DOMAIN +from homeassistant.components.mobile_app.const import DATA_LIVE_ACTIVITY_TOKENS, DOMAIN from homeassistant.components.notify import ( ATTR_MESSAGE, ATTR_TITLE, @@ -835,3 +835,151 @@ async def test_send_message_local_push_exception(hass: HomeAssistant) -> None: assert err.value.translation_placeholders == { "device_name": "websocket push test entry" } + + +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 in the relay payload.""" + # 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, + ) + + assert len(aioclient_mock.mock_calls) == 1 + call_json = aioclient_mock.mock_calls[0][2] + # FCM token stays as push_token; live activity APNs 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 a stored token falls back to the 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 is 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 b7a247bc9736d5..85216b680f4916 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 ( @@ -1303,3 +1308,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": "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": "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": "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": "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": "live_activity_dismissed", + "data": { + "live_activity_tag": "nonexistent_activity", + }, + }, + ) + + assert resp.status == HTTPStatus.OK