Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 19 additions & 0 deletions functions/legacy.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
'use strict';

const path = require('path');
const liveActivity = require('./live-activity.js');

module.exports = {
createPayload: (req) => {
if (req.body.live_activity_token) {
return liveActivity.createPayload(req);
}

if (process.env.DEBUG === 'true' && req.body.data?.live_update === true) {
console.info(
'[legacy-live-activity]',
JSON.stringify({
mode: 'fallback_notification',
reason: 'missing_live_activity_token',
tag: req.body.data?.tag ?? null,
activity_id: req.body.data?.activity_id ?? null,
}),
);
}

const payload = {
notification: {
body: req.body.message,
Expand Down
172 changes: 172 additions & 0 deletions functions/live-activity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
'use strict';

const CLEAR_NOTIFICATION = 'clear_notification';
const LiveActivityEvent = Object.freeze({
START: 'start',
UPDATE: 'update',
END: 'end',
});
const LiveActivityApsKey = Object.freeze({
ATTRIBUTES_TYPE: 'attributes-type',
CONTENT_STATE: 'content-state',
DISMISSAL_DATE: 'dismissal-date',
INTERRUPTION_LEVEL: 'interruption-level',
RELEVANCE_SCORE: 'relevance-score',
STALE_DATE: 'stale-date',
});

module.exports = { createPayload };

// Builds an FCM-compatible payload for Live Activity push notifications.
//
// The liveActivityToken field (camelCase) is required by Firebase Admin SDK v13.5.0+.
// When present in the apns config, FCM automatically sets apns-push-type: liveactivity
// and routes the notification to APNs correctly. No APNs credentials, HTTP/2 sessions,
// or environment routing are needed — FCM handles it all.
function createPayload(req) {
const { data = {} } = req.body;
const event = data.event ?? LiveActivityEvent.UPDATE;
const now = Math.floor(Date.now() / 1000);

const aps = {
timestamp: now,
event,
};

// content-state is required for start and update; send for end as well so the
// activity can display final state before dismissal.
const contentState = buildContentState(req.body, data);
aps[LiveActivityApsKey.CONTENT_STATE] = contentState;

if (event === LiveActivityEvent.START) {
// Push-to-start requires the static attributes that were registered with the activity.
// 'attributes-type' must exactly match the Swift struct name — HALiveActivityAttributes —
// because APNs uses it to look up the registered ActivityKit type on the device.
// This value is case-sensitive and cannot change after the app has shipped.
aps[LiveActivityApsKey.ATTRIBUTES_TYPE] = 'HALiveActivityAttributes';
aps.attributes = {
tag: data.activity_id ?? data.tag ?? '',
title: req.body.title ?? '',
};
}

if (event === LiveActivityEvent.END) {
aps[LiveActivityApsKey.DISMISSAL_DATE] = data.dismissal_date ?? now;
}

if (data.stale_date !== undefined) {
aps[LiveActivityApsKey.STALE_DATE] = data.stale_date;
}

if (data.relevance_score !== undefined) {
aps[LiveActivityApsKey.RELEVANCE_SCORE] = data.relevance_score;
}

if (data.alert) {
aps.alert = data.alert;
if (data.alert_sound) {
aps.sound = data.alert_sound;
}
} else if (event === LiveActivityEvent.START) {
// Start events always carry an alert so the user sees the activity launch.
aps.alert = buildAlert(req.body);
} else if (event === LiveActivityEvent.UPDATE) {
if (data.silent === true) {
// silent: true — title-only alert keeps fast APNs delivery without triggering the chime.
aps.alert = { title: '' };
} else {
aps.alert = buildAlert(req.body);
}
} else if (event === LiveActivityEvent.END) {
aps.alert = buildAlert(req.body);
}

if (process.env.DEBUG === 'true') {
console.info(
'[live-activity]',
JSON.stringify({
mode: 'live_activity',
event,
tag: data.tag ?? null,
activity_id: data.activity_id ?? null,
has_alert: Boolean(aps.alert),
interruption_level: aps[LiveActivityApsKey.INTERRUPTION_LEVEL] ?? null,
content_state_keys: Object.keys(contentState),
dismissal_date: aps[LiveActivityApsKey.DISMISSAL_DATE] ?? null,
}),
);
}

const payload = {
apns: {
// The liveActivityToken (camelCase) tells Firebase Admin SDK v13.5.0+ to route
// this message as a Live Activity notification. FCM automatically sets the
// apns-push-type: liveactivity header and the correct apns-topic suffix.
liveActivityToken: req.body.live_activity_token,
headers: {
'apns-priority': '10',
},
payload: {
aps,
},
},
fcm_options: {
analytics_label: 'iOSLiveActivityV1',
},
};

return {
updateRateLimits: true,
payload,
};
}

function buildAlert(body) {
return {
title: body.title ?? '',
body: body.message !== CLEAR_NOTIFICATION ? (body.message ?? '') : '',
};
}

// Builds the content-state object that APNs delivers to the app's Live Activity widget.
// Each field maps to a property in the Swift HALiveActivityContentState Codable struct.
// Only recognized fields are forwarded — extra keys would cause APNs to reject the payload.
function buildContentState(body, data) {
const state = {};

// Top-level message field is the primary text. Do not render command strings.
// The Swift ContentState requires message, so send an empty string if HA omitted it.
if (body.message !== undefined && body.message !== CLEAR_NOTIFICATION) {
state.message = body.message;
} else {
state.message = '';
}

if (body.title !== undefined) state.title = body.title;
if (data.critical_text !== undefined) state.critical_text = data.critical_text;
if (data.progress !== undefined) state.progress = data.progress;
if (data.progress_max !== undefined) state.progress_max = data.progress_max;
if (data.chronometer !== undefined) state.chronometer = data.chronometer;
if (data.notification_icon !== undefined) state.icon = data.notification_icon;
if (data.notification_icon_color !== undefined) state.color = data.notification_icon_color;
if (data.when !== undefined) {
state.countdown_end = data.when_relative
? Math.floor(Date.now() / 1000) + data.when
: data.when;
}

if (data.content_state) {
const cs = data.content_state;
if (cs.title !== undefined) state.title = cs.title;
if (cs.message !== undefined) state.message = cs.message;
if (cs.critical_text !== undefined) state.critical_text = cs.critical_text;
if (cs.progress !== undefined) state.progress = cs.progress;
if (cs.progress_max !== undefined) state.progress_max = cs.progress_max;
if (cs.chronometer !== undefined) state.chronometer = cs.chronometer;
if (cs.countdown_end !== undefined) state.countdown_end = cs.countdown_end;
if (cs.icon !== undefined) state.icon = cs.icon;
if (cs.color !== undefined) state.color = cs.color;
}

return state;
}
32 changes: 32 additions & 0 deletions functions/test/fixtures/live-activity/end.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"input": {
"push_token": "test:fcm-token-123",
"live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"message": "clear_notification",
"title": "Laundry",
"registration_info": {
"app_id": "io.robbie.HomeAssistant",
"app_version": "2024.1",
"os_version": "17.0"
},
"data": {
"event": "end",
"activity_id": "laundry-001",
"dismissal_date": 1234571490
}
},
"expected": {
"updateRateLimits": true,
"liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"apsEvent": "end",
"contentState": {
"title": "Laundry",
"message": ""
},
"dismissalDate": 1234571490,
"alert": {
"title": "Laundry",
"body": ""
}
}
}
43 changes: 43 additions & 0 deletions functions/test/fixtures/live-activity/start-push-to-start.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"input": {
"push_token": "test:fcm-token-123",
"live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"message": "Laundry started",
"title": "Laundry",
"registration_info": {
"app_id": "io.robbie.HomeAssistant",
"app_version": "2024.1",
"os_version": "17.2"
},
"data": {
"event": "start",
"activity_id": "laundry-001",
"progress": 0,
"progress_max": 3600,
"notification_icon": "mdi:washing-machine",
"notification_icon_color": "#2196F3"
}
},
"expected": {
"updateRateLimits": true,
"liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"apsEvent": "start",
"attributesType": "HALiveActivityAttributes",
"attributes": {
"tag": "laundry-001",
"title": "Laundry"
},
"contentState": {
"title": "Laundry",
"message": "Laundry started",
"progress": 0,
"progress_max": 3600,
"icon": "mdi:washing-machine",
"color": "#2196F3"
},
"alert": {
"title": "Laundry",
"body": "Laundry started"
}
}
}
38 changes: 38 additions & 0 deletions functions/test/fixtures/live-activity/update-basic.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
{
"input": {
"push_token": "test:fcm-token-123",
"live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"message": "Washer is done",
"title": "Laundry",
"registration_info": {
"app_id": "io.robbie.HomeAssistant",
"app_version": "2024.1",
"os_version": "17.0"
},
"data": {
"event": "update",
"activity_id": "laundry-001",
"progress": 3600,
"progress_max": 3600,
"notification_icon": "mdi:washing-machine",
"notification_icon_color": "#2196F3"
}
},
"expected": {
"updateRateLimits": true,
"liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"apsEvent": "update",
"contentState": {
"title": "Laundry",
"message": "Washer is done",
"progress": 3600,
"progress_max": 3600,
"icon": "mdi:washing-machine",
"color": "#2196F3"
},
"alert": {
"title": "Laundry",
"body": "Washer is done"
}
}
}
54 changes: 54 additions & 0 deletions functions/test/fixtures/live-activity/update-full.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
{
"input": {
"push_token": "test:fcm-token-123",
"live_activity_token": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"message": "Timer running",
"title": "Kitchen Timer",
"registration_info": {
"app_id": "io.robbie.HomeAssistant",
"app_version": "2024.1",
"os_version": "17.0"
},
"data": {
"event": "update",
"activity_id": "timer-001",
"critical_text": "45 min",
"progress": 2700,
"progress_max": 3600,
"chronometer": true,
"when": 1704110400,
"notification_icon": "mdi:timer",
"notification_icon_color": "#FF5722",
"stale_date": 1234571490,
"relevance_score": 0.8,
"alert": {
"title": "Timer Update",
"body": "45 minutes remaining"
},
"alert_sound": "default"
}
},
"expected": {
"updateRateLimits": true,
"liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"apsEvent": "update",
"contentState": {
"title": "Kitchen Timer",
"message": "Timer running",
"critical_text": "45 min",
"progress": 2700,
"progress_max": 3600,
"chronometer": true,
"countdown_end": 1704110400,
"icon": "mdi:timer",
"color": "#FF5722"
},
"staleDate": 1234571490,
"relevanceScore": 0.8,
"alert": {
"title": "Timer Update",
"body": "45 minutes remaining"
},
"alertSound": "default"
}
}
Loading