Skip to content
Draft
Show file tree
Hide file tree
Changes from 57 commits
Commits
Show all changes
71 commits
Select commit Hold shift + click to select a range
7d4713a
Add iOS Live Activity webhook handlers to mobile_app integration
rwarner Mar 20, 2026
e377da7
Use constants for event names and add EventOrigin.remote
rwarner Mar 20, 2026
14a6987
Address Copilot review: Inclusive validation and token cleanup
rwarner Mar 20, 2026
3d7ea81
Wire up notify.py to route Live Activity pushes through APNs relay
rwarner Mar 20, 2026
3f6346d
Simplify Live Activity routing — use FCM native liveActivityToken
rwarner Mar 21, 2026
d1163a5
Address Copilot feedback: validate tag type and use constants in events
rwarner Mar 23, 2026
d299519
Tighten input validation for Live Activity fields
rwarner Mar 24, 2026
b76e405
Address Copilot review: use ATTR_WEBHOOK_ID in events, validate dismi…
rwarner Mar 25, 2026
d9df34f
Require non-empty tag in update_live_activity_token webhook schema
rwarner Mar 26, 2026
ecbb296
Use live_update: true instead of live_activity: true for iOS Live Act…
rwarner Mar 26, 2026
a16c8c9
Remove unused bus events and supports_live_activities helper; simplif…
rwarner Apr 1, 2026
336c64b
Rename live activity webhook tag field from 'tag' to 'live_activity_tag'
rwarner Apr 1, 2026
023065f
Align webhook type names with iOS companion app
rwarner Apr 1, 2026
23ff061
Remove unused ATTR_WEBHOOK_ID import from webhook.py
rwarner Apr 2, 2026
61a609b
Fix test_init.py to use renamed webhook type and tag field
rwarner Apr 2, 2026
d5e8477
Add comments clarifying live_update vs live_activity naming
rwarner Apr 7, 2026
1337547
Address edenhaus review comments on Live Activity code
rwarner Apr 28, 2026
df217bd
Use cv.string for live activity webhook schemas
rwarner Apr 29, 2026
9d9ef58
Fix docstring indentation in test_notify.py after merge conflict reso…
rwarner Apr 29, 2026
a1a6db3
Rename live activity webhooks to drop mobile_app_ prefix
rwarner Apr 29, 2026
d44727e
Fix prek formatting: remove unused import, sort imports, wrap long line
rwarner Apr 29, 2026
eb478d4
Fix live activity token storage format and stale webhook type in tests
rwarner Apr 29, 2026
978d802
mobile_app: simplify live activity comments and docstrings
rwarner Apr 30, 2026
1437794
mobile_app: restore websocket channel comment in SCHEMA_APP_DATA
rwarner May 7, 2026
25340ac
mobile_app: persist live activity tokens across restarts with TTL cle…
rwarner May 7, 2026
883f1f8
Merge upstream/dev into feat/ios-live-activity
rwarner May 7, 2026
b0cb713
mobile_app: fold live activity tokens into existing store
rwarner May 7, 2026
16fde1c
mobile_app: fix pylint hass-use-runtime-data in savable_state
rwarner May 7, 2026
83ee21a
mobile_app: fix import order in test_notify
rwarner May 7, 2026
e5ae0fa
mobile_app: clean up live activity tokens on remove, not unload
rwarner May 19, 2026
983ed24
mobile_app: add store migration for live_activity_tokens (v1 → v2)
rwarner May 19, 2026
0ec8f4e
mobile_app: remove duplicate condition in live activity token filter
rwarner May 19, 2026
0f092eb
mobile_app: save store and schedule cleanup on startup for live activ…
rwarner May 19, 2026
917a4fc
mobile_app: use direct indexing where keys are guaranteed to exist
rwarner May 19, 2026
8c85dbf
mobile_app: remove eager token cleanup from notify, rely on cleanup task
rwarner May 19, 2026
fccb524
Merge upstream/dev into branch
rwarner May 19, 2026
bbf2bfe
mobile_app: fix ruff formatting — line length and import order
rwarner May 19, 2026
8b41416
mobile_app: drop unnecessary delay floor in token cleanup scheduling
rwarner May 20, 2026
0eea88e
mobile_app: reuse cleanup function at startup instead of duplicating it
rwarner May 20, 2026
c4f85ec
mobile_app: use async_call_later to schedule live activity token cleanup
rwarner May 20, 2026
0d00b7f
mobile_app: restart live activity token cleanup when a token is added
rwarner May 20, 2026
4926cec
mobile_app: index live activity token stored_at directly
rwarner May 20, 2026
78b5f66
mobile_app: debounce live activity token store writes
rwarner May 20, 2026
1c98aeb
mobile_app: bump store minor version instead of major for the new field
rwarner May 20, 2026
3660474
Merge remote-tracking branch 'upstream/dev' into feat/ios-live-activity
rwarner May 20, 2026
3afe179
Merge branch 'dev' into feat/ios-live-activity
edenhaus May 26, 2026
4227077
Restructure live activty
edenhaus May 26, 2026
94b4a8d
Refactor tests
edenhaus May 26, 2026
334f24b
Apply suggestions from code review
edenhaus May 27, 2026
3097854
Rename variable
edenhaus May 27, 2026
135cd18
mobile_app: translate flat Live Activity payload and end via clear_no…
rwarner May 27, 2026
a7f2052
mobile_app: address review feedback for live activities
rwarner May 28, 2026
b7ffeac
mobile_app: alphabetize ATTR_TOKEN import in webhook.py
rwarner May 28, 2026
237a415
Add live activity token retention, notify usage and clean up cycle to…
bgoncal Jun 3, 2026
d51de14
Document live activity push routing
bgoncal Jun 3, 2026
7b6e08a
Merge branch 'dev' into live-activity-last-mile
bgoncal Jun 3, 2026
1776af6
Clarify ActivityKit routing comments
bgoncal Jun 3, 2026
e21b629
Improve naming and add comments
bgoncal Jun 4, 2026
de47f7b
Rename live activity tag constant
bgoncal Jun 4, 2026
ff5de45
Move live activity token storage comment
bgoncal Jun 4, 2026
9e96478
Separate live activity push-to-start token key
bgoncal Jun 4, 2026
d057e14
Move live activity helpers into module
bgoncal Jun 4, 2026
9f2f7b5
Clarify live activity cleanup naming
bgoncal Jun 4, 2026
830cc39
Remove redundant live activity comment
bgoncal Jun 4, 2026
b749ac8
Move Live Activity related code to separate files and folder
bgoncal Jun 4, 2026
ab773c5
Keep remote notification sending generic
bgoncal Jun 4, 2026
b9221f8
Address live activity review comments
bgoncal Jun 4, 2026
ecff7fa
Merge branch 'dev' into live-activity-last-mile
bgoncal Jun 4, 2026
8f34c9e
Update __init__.py
bgoncal Jun 4, 2026
3f8977d
Update const.py
bgoncal Jun 4, 2026
ec936a0
Fix ATTR
bgoncal Jun 4, 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
34 changes: 27 additions & 7 deletions homeassistant/components/mobile_app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,19 @@
DATA_CONFIG_ENTRIES,
DATA_DELETED_IDS,
DATA_DEVICES,
DATA_LIVE_ACTIVITY_TOKENS,
DATA_PENDING_UPDATES,
DATA_PUSH_CHANNEL,
DATA_STORE,
DOMAIN,
SENSOR_TYPES,
STORAGE_KEY,
STORAGE_VERSION,
STORAGE_VERSION_MINOR,
)
from .helpers import async_is_local_only_user, savable_state
from .http_api import RegistrationsView
from .live_activity import async_cleanup_expired_tokens
from .timers import async_handle_timer_event
from .util import async_create_cloud_hook, supports_push
from .webhook import handle_webhook
Expand All @@ -72,24 +75,26 @@

async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the mobile app component."""
store = Store[dict[str, Any]](hass, STORAGE_VERSION, STORAGE_KEY)
store = _MobileAppStore(
hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR
)
if (app_config := await store.async_load()) is None or not isinstance(
app_config, dict
):
app_config = {
DATA_CONFIG_ENTRIES: {},
DATA_DELETED_IDS: [],
}
app_config = {DATA_DELETED_IDS: [], DATA_LIVE_ACTIVITY_TOKENS: {}}

hass.data[DOMAIN] = {
DATA_CONFIG_ENTRIES: {},
DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []),
DATA_DELETED_IDS: app_config[DATA_DELETED_IDS],
DATA_DEVICES: {},
DATA_LIVE_ACTIVITY_TOKENS: app_config[DATA_LIVE_ACTIVITY_TOKENS],
DATA_PUSH_CHANNEL: {},
DATA_STORE: store,
DATA_PENDING_UPDATES: {sensor_type: {} for sensor_type in SENSOR_TYPES},
}

hass.async_create_task(async_cleanup_expired_tokens(hass))

hass.http.register_view(RegistrationsView())

for deleted_id in hass.data[DOMAIN][DATA_DELETED_IDS]:
Expand Down Expand Up @@ -243,10 +248,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Cleanup when entry is removed."""
hass.data[DOMAIN][DATA_DELETED_IDS].append(entry.data[CONF_WEBHOOK_ID])
webhook_id = entry.data[CONF_WEBHOOK_ID]
hass.data[DOMAIN][DATA_DELETED_IDS].append(webhook_id)
hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS].pop(webhook_id, None)
store = hass.data[DOMAIN][DATA_STORE]
await store.async_save(savable_state(hass))

if CONF_CLOUDHOOK_URL in entry.data:
with suppress(cloud.CloudNotAvailable, ValueError):
await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID])


class _MobileAppStore(Store[dict[str, Any]]):
async def _async_migrate_func(
Comment thread
bgoncal marked this conversation as resolved.
self,
old_major_version: int,
old_minor_version: int,
old_data: dict[str, Any],
) -> dict[str, Any]:
"""Migrate mobile_app storage to the current version."""
if old_major_version == 1 and old_minor_version < 2:
old_data.setdefault(DATA_LIVE_ACTIVITY_TOKENS, {})
return old_data
Comment thread
bgoncal marked this conversation as resolved.
12 changes: 12 additions & 0 deletions homeassistant/components/mobile_app/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 2
STORAGE_SAVE_DELAY_SECONDS = 10

CONF_CLOUDHOOK_URL = "cloudhook_url"
CONF_REMOTE_UI_URL = "remote_ui_url"
Expand All @@ -17,6 +19,7 @@
DATA_CONFIG_ENTRIES = "config_entries"
DATA_DELETED_IDS = "deleted_ids"
DATA_DEVICES = "devices"
DATA_LIVE_ACTIVITY_TOKENS = "live_activity_tokens"
DATA_STORE = "store"
DATA_NOTIFY = "notify"
DATA_PUSH_CHANNEL = "push_channel"
Expand All @@ -40,6 +43,14 @@
ATTR_PUSH_RATE_LIMITS_SUCCESSFUL = "successful"
ATTR_SUPPORTS_ENCRYPTION = "supports_encryption"

ATTR_LIVE_UPDATE = "live_update"
ATTR_LIVE_ACTIVITY_TOKEN = "live_activity_token"
ATTR_EXPIRES_AT = "expires_at"
ATTR_TOKEN = "token"
ATTR_TAG = "tag"

CLEAR_NOTIFICATION = "clear_notification"

ATTR_EVENT_DATA = "event_data"
ATTR_EVENT_TYPE = "event_type"

Expand Down Expand Up @@ -92,6 +103,7 @@
# will connect via websocket channel to receive
# push notifications.
vol.Optional(ATTR_PUSH_WEBSOCKET_CHANNEL): cv.boolean,
vol.Optional(ATTR_LIVE_ACTIVITY_TOKEN): cv.string,
},
extra=vol.ALLOW_EXTRA,
)
Expand Down
8 changes: 5 additions & 3 deletions homeassistant/components/mobile_app/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
CONF_SECRET,
CONF_USER_ID,
DATA_DELETED_IDS,
DATA_LIVE_ACTIVITY_TOKENS,
DOMAIN,
)

Expand Down Expand Up @@ -170,10 +171,11 @@ def safe_registration(registration: dict) -> dict:

def savable_state(hass: HomeAssistant) -> dict:
"""Return a clean object containing things that should be saved."""
# pylint: disable-next=home-assistant-use-runtime-data
domain_data = hass.data[DOMAIN]
return {
# Uses legacy hass.data[DOMAIN] pattern
# pylint: disable-next=home-assistant-use-runtime-data
DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS],
DATA_DELETED_IDS: domain_data[DATA_DELETED_IDS],
DATA_LIVE_ACTIVITY_TOKENS: domain_data[DATA_LIVE_ACTIVITY_TOKENS],
}


Expand Down
71 changes: 71 additions & 0 deletions homeassistant/components/mobile_app/live_activity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Live Activity push token lifecycle: expiry-driven cleanup loop."""
# pylint: disable=home-assistant-use-runtime-data # Uses legacy hass.data[DOMAIN] pattern

from datetime import datetime

from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util

from .const import ATTR_EXPIRES_AT, DATA_LIVE_ACTIVITY_TOKENS, DATA_STORE, DOMAIN
from .helpers import savable_state


@callback
def async_schedule_next_cleanup(hass: HomeAssistant) -> None:
"""Schedule a sweep for the earliest token expiry.

Only call when no sweep is already in flight. The invariant is: tokens
non-empty ⟹ sweep scheduled. So the two safe call sites are (1) the
webhook that added the first token (tokens was empty beforehand) and
(2) the tail of a sweep that found surviving tokens (its own timer
just fired).
"""
tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]
earliest_expires_at = min(
(
token[ATTR_EXPIRES_AT]
for device_tokens in tokens.values()
for token in device_tokens.values()
),
default=None,
)
if earliest_expires_at is None:
return

delay = earliest_expires_at - dt_util.utcnow().timestamp()

async def run_cleanup(_now: datetime) -> None:
await async_cleanup_expired_tokens(hass)

async_call_later(hass, delay, run_cleanup)


async def async_cleanup_expired_tokens(hass: HomeAssistant) -> None:
"""Remove expired tokens and reschedule the next sweep at the earliest expiry.

Runs as a one-shot callback scheduled by ``async_schedule_next_cleanup``. After
sweeping, if any tokens remain it calls ``async_schedule_next_cleanup`` again to
queue the next sweep — this self-rescheduling chain is what "the loop" refers to.
When tokens are empty no further sweep is scheduled; the chain restarts the next
time the webhook stores a token into an empty store.
"""
now = dt_util.utcnow().timestamp()
tokens = hass.data[DOMAIN][DATA_LIVE_ACTIVITY_TOKENS]
changed = False

for webhook_id in list(tokens):
device_tokens = tokens[webhook_id]
for tag, data in list(device_tokens.items()):
if data[ATTR_EXPIRES_AT] <= now:
del device_tokens[tag]
changed = True
if not device_tokens:
del tokens[webhook_id]
changed = True

if tokens:
async_schedule_next_cleanup(hass)

if changed:
await hass.data[DOMAIN][DATA_STORE].async_save(savable_state(hass))
Loading
Loading