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
113 changes: 113 additions & 0 deletions functions/ios.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
'use strict';

module.exports = {
createPayload: (req) => {
// When live_activity_token is present, this is a Live Activity push notification.
// Firebase Admin SDK v13.5.0+ supports the liveActivityToken (camelCase) field in the
// apns config object. When set, FCM automatically adds 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.
if (req.body.live_activity_token) {
return buildLiveActivityPayload(req);
}

const payload = {
notification: {
body: req.body.message,
Expand Down Expand Up @@ -142,3 +153,105 @@ module.exports = {
return { updateRateLimits, payload };
},
};

// 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 buildLiveActivityPayload(req) {
const { data = {} } = req.body;
const event = data.event ?? '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.
aps['content-state'] = buildLiveActivityContentState(req.body, data);

if (event === '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['attributes-type'] = 'HALiveActivityAttributes';
Comment thread
rwarner marked this conversation as resolved.
aps.attributes = {
tag: data.activity_id ?? data.tag ?? '',
title: req.body.title ?? '',
};
}

if (event === 'end' && data.dismissal_date) {
aps['dismissal-date'] = data.dismissal_date;
}

if (data.stale_date) {
aps['stale-date'] = data.stale_date;
}

if (data.relevance_score !== undefined) {
aps['relevance-score'] = data.relevance_score;
}

// Optional alert shown alongside the live activity update.
if (data.alert) {
aps.alert = data.alert;
if (data.alert_sound) {
aps.sound = data.alert_sound;
}
}

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',
Comment thread
bgoncal marked this conversation as resolved.
},
};

return {
updateRateLimits: true,
payload,
};
}

// 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 buildLiveActivityContentState(body, data) {
const state = {};

// Top-level message field is the primary text; content_state fields take precedence.
if (body.message) {
state.message = body.message;
}

if (data.content_state) {
const cs = data.content_state;
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;
}
2 changes: 1 addition & 1 deletion functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@valkey/valkey-glide": "^2.0.1",
"fastify": "^5.8.3",
"firebase-admin": "^13.5.0",
"firebase-functions": "^6.1.1"
"firebase-functions": "^6.1.1"
},
"devDependencies": {
"@types/node": "^24.1.0",
Expand Down
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": "Washer cycle complete",
"title": "Laundry",
"registration_info": {
"app_id": "io.robbie.HomeAssistant",
"app_version": "2024.1",
"os_version": "17.0"
},
"data": {
"event": "end",
"activity_id": "laundry-001",
"content_state": {
"message": "Washer cycle complete",
"icon": "mdi:washing-machine-off"
},
"dismissal_date": 1234571490
}
},
"expected": {
"updateRateLimits": true,
"liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"apsEvent": "end",
"contentState": {
"message": "Washer cycle complete",
"icon": "mdi:washing-machine-off"
},
"dismissalDate": 1234571490
}
}
41 changes: 41 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,41 @@
{
"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",
"content_state": {
"message": "Laundry started",
"progress": 0,
"progress_max": 3600,
"icon": "mdi:washing-machine",
"color": "#2196F3"
}
}
},
"expected": {
"updateRateLimits": true,
"liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"apsEvent": "start",
"attributesType": "HALiveActivityAttributes",
"attributes": {
"tag": "laundry-001",
"title": "Laundry"
},
"contentState": {
"message": "Laundry started",
"progress": 0,
"progress_max": 3600,
"icon": "mdi:washing-machine",
"color": "#2196F3"
}
}
}
36 changes: 36 additions & 0 deletions functions/test/fixtures/live-activity/update-basic.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"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",
"content_state": {
"message": "Washer is done",
"progress": 3600,
"progress_max": 3600,
"icon": "mdi:washing-machine",
"color": "#2196F3"
}
}
},
"expected": {
"updateRateLimits": true,
"liveActivityToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
"apsEvent": "update",
"contentState": {
"message": "Washer is done",
"progress": 3600,
"progress_max": 3600,
"icon": "mdi:washing-machine",
"color": "#2196F3"
}
}
}
56 changes: 56 additions & 0 deletions functions/test/fixtures/live-activity/update-full.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{
"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",
"content_state": {
"message": "45 min remaining",
"critical_text": "45 min",
"progress": 2700,
"progress_max": 3600,
"chronometer": true,
"countdown_end": "2024-01-01T12:00:00Z",
"icon": "mdi:timer",
"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": {
"message": "45 min remaining",
"critical_text": "45 min",
"progress": 2700,
"progress_max": 3600,
"chronometer": true,
"countdown_end": "2024-01-01T12:00:00Z",
"icon": "mdi:timer",
"color": "#FF5722"
},
"staleDate": 1234571490,
"relevanceScore": 0.8,
"alert": {
"title": "Timer Update",
"body": "45 minutes remaining"
},
"alertSound": "default"
}
}
Loading