Skip to content
4 changes: 3 additions & 1 deletion functions/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
'use strict';

const functions = require('firebase-functions');
// firebase-functions v6+ defaults to v2 APIs; use the v1 import to retain
// functions.config(), functions.region(), and functions.runWith().
const functions = require('firebase-functions/v1');
const { initializeApp } = require('firebase-admin/app');

// We need to initialize the app before importing modules that want Firestore.
Expand Down
117 changes: 117 additions & 0 deletions functions/ios.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
'use strict';

// Live Activity 'end' events dismiss an ongoing activity rather than delivering new content,
// so they are exempt from rate limits — equivalent to clear_notification on Android.
const NO_RATE_LIMIT_LIVE_ACTIVITY_EVENTS = new Set(['end']);

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 +157,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';
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',
},
};

return {
updateRateLimits: !NO_RATE_LIMIT_LIVE_ACTIVITY_EVENTS.has(event),
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;
}
4 changes: 2 additions & 2 deletions functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
"@google-cloud/logging": "^11.0.0",
"@valkey/valkey-glide": "^2.0.1",
"fastify": "^5.8.3",
"firebase-admin": "^12.1.0",
"firebase-functions": "^5.0.1"
"firebase-admin": "^13.5.0",
"firebase-functions": "^6.1.1"
},
"devDependencies": {
"@types/node": "^24.1.0",
Expand Down
1 change: 1 addition & 0 deletions functions/test/fcm-errors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const mockLogging = {
};

jest.mock('firebase-functions', () => mockFunctions);
jest.mock('firebase-functions/v1', () => mockFunctions);
jest.mock('@google-cloud/logging', () => ({
Logging: jest.fn(() => mockLogging),
}));
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": false,
"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"
}
}
1 change: 1 addition & 0 deletions functions/test/handleRequest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ const mockLogging = {
};

jest.mock('firebase-functions', () => mockFunctions);
jest.mock('firebase-functions/v1', () => mockFunctions);
jest.mock('@google-cloud/logging', () => ({
Logging: jest.fn(() => mockLogging),
}));
Expand Down
Loading
Loading