Add Android node foundation with FCM push support

This commit is contained in:
William Valentin
2026-02-16 12:55:22 -08:00
parent 58c4b0b9bb
commit a954d7e136
11 changed files with 190 additions and 19 deletions
+40
View File
@@ -275,4 +275,44 @@ describe('node handlers', () => {
expect((result as { result: { push: { tokenPreview: string } } }).result.push.tokenPreview).toContain('abcd1234');
expect(states.get('conn-1')?.pushToken?.provider).toBe('apns');
});
it('accepts fcm push token registration for android companions', async () => {
const states = new Map<string, NodeConnectionState>([['conn-1', {
node: {
nodeId: 'android-node',
role: 'companion',
protocolVersion: 1,
capabilities: ['notifications'],
registeredAt: Date.now(),
},
}]]);
const handlers = createNodeHandlers({
enabled: true,
locationEnabled: true,
pushEnabled: true,
allowedRoles: ['companion'],
featureGates: {},
getConnectionState: (connectionId) => states.get(connectionId),
setNodeRegistration: () => {},
setNodeLocation: () => {},
setNodeStatus: () => {},
setNodePushToken: (connectionId, pushToken) => {
const prior = states.get(connectionId) ?? {};
states.set(connectionId, { ...prior, pushToken });
},
});
const result = await handlers['node.push_token.set']({
id: 9,
method: 'node.push_token.set',
params: {
connectionId: 'conn-1',
provider: 'fcm',
token: 'fcm_abcdefghijklmnopqrstuvwxyz123456',
},
});
expect((result as { result: { updated: boolean } }).result.updated).toBe(true);
expect(states.get('conn-1')?.pushToken?.provider).toBe('fcm');
expect(states.get('conn-1')?.pushToken?.environment).toBeUndefined();
});
});
+4 -4
View File
@@ -50,10 +50,10 @@ export interface NodeStatus {
}
export interface NodePushToken {
provider: 'apns';
provider: 'apns' | 'fcm';
token: string;
topic?: string;
environment: 'sandbox' | 'production';
environment?: 'sandbox' | 'production';
registeredAt: number;
}
@@ -279,10 +279,10 @@ export function createNodeHandlers(deps: NodeHandlerDeps) {
}
const pushToken: NodePushToken = {
provider: 'apns',
provider: parsed.provider,
token: parsed.token,
topic: parsed.topic || undefined,
environment: parsed.environment ?? 'production',
environment: parsed.provider === 'apns' ? (parsed.environment ?? 'production') : undefined,
registeredAt: Date.now(),
};
deps.setNodePushToken(parsed.connectionId, pushToken);
+1 -1
View File
@@ -46,7 +46,7 @@ export interface NodePushTokenSummary {
provider: NodePushToken['provider'];
tokenPreview: string;
topic?: string;
environment: NodePushToken['environment'];
environment?: NodePushToken['environment'];
registeredAt: number;
}
+16 -1
View File
@@ -233,7 +233,7 @@ describe('protocol', () => {
it('rejects invalid node push token params', () => {
expect(parseNodePushTokenSetParams({
connectionId: 'conn-1',
provider: 'fcm',
provider: 'webpush',
token: 'abcd1234abcd1234abcd1234abcd1234',
})).toBeNull();
expect(parseNodePushTokenSetParams({
@@ -247,6 +247,21 @@ describe('protocol', () => {
token: 'abcd1234abcd1234abcd1234abcd1234',
})).toBeNull();
});
it('parses valid fcm token params for android nodes', () => {
const parsed = parseNodePushTokenSetParams({
connectionId: 'conn-2',
provider: 'fcm',
token: 'fcm_abcdefghijklmnopqrstuvwxyz123456',
});
expect(parsed).toEqual({
connectionId: 'conn-2',
provider: 'fcm',
token: 'fcm_abcdefghijklmnopqrstuvwxyz123456',
topic: undefined,
environment: undefined,
});
});
});
describe('makeResponse', () => {
+3 -3
View File
@@ -46,7 +46,7 @@ export interface NodeStatusSetParams {
export interface NodePushTokenSetParams {
connectionId: string;
provider: 'apns';
provider: 'apns' | 'fcm';
token: string;
topic?: string;
environment?: 'sandbox' | 'production';
@@ -309,7 +309,7 @@ export function parseNodePushTokenSetParams(params: unknown): NodePushTokenSetPa
if (typeof p.connectionId !== 'string' || !p.connectionId.trim()) {
return null;
}
if (p.provider !== 'apns') {
if (p.provider !== 'apns' && p.provider !== 'fcm') {
return null;
}
if (typeof p.token !== 'string' || p.token.trim().length < 16) {
@@ -324,7 +324,7 @@ export function parseNodePushTokenSetParams(params: unknown): NodePushTokenSetPa
return {
connectionId: p.connectionId,
provider: 'apns',
provider: p.provider,
token: p.token.trim(),
topic: typeof p.topic === 'string' ? p.topic.trim() : undefined,
environment: p.environment as NodePushTokenSetParams['environment'] | undefined,
+52
View File
@@ -868,4 +868,56 @@ describe('GatewayServer node registration and capability negotiation', () => {
}
}
});
it('supports android fcm push token registration', async () => {
if (!LISTEN_ALLOWED) {
return;
}
const ws = await new Promise<WebSocket>((resolve, reject) => {
const c = new WebSocket(`ws://127.0.0.1:${NODE_PORT}`);
c.on('open', () => resolve(c));
c.on('error', reject);
});
try {
const registered = await sendAndReceive(ws, {
id: 40,
method: 'node.register',
params: {
nodeId: 'node-android',
role: 'companion',
protocolVersion: 1,
capabilities: ['notifications'],
},
});
expect(((registered as GatewayResponse).result as { registered: boolean }).registered).toBe(true);
const push = await sendAndReceive(ws, {
id: 41,
method: 'node.push_token.set',
params: {
provider: 'fcm',
token: 'fcm_abcdefghijklmnopqrstuvwxyz123456',
},
});
expect(((push as GatewayResponse).result as { updated: boolean }).updated).toBe(true);
const nodes = await sendAndReceive(ws, {
id: 42,
method: 'system.nodes',
params: { role: 'companion', limit: 20 },
});
const list = ((nodes as GatewayResponse).result as {
nodes: Array<{ nodeId: string; push?: { provider: string; environment?: string } }>;
}).nodes;
const androidNode = list.find((entry) => entry.nodeId === 'node-android');
expect(androidNode?.push?.provider).toBe('fcm');
expect(androidNode?.push?.environment).toBeUndefined();
} finally {
if (ws.readyState === WebSocket.OPEN) {
ws.close();
}
}
});
});
+1 -1
View File
@@ -236,7 +236,7 @@ export class GatewayServer {
provider: NonNullable<NodeConnectionState['pushToken']>['provider'];
tokenPreview: string;
topic?: string;
environment: NonNullable<NodeConnectionState['pushToken']>['environment'];
environment?: NonNullable<NodeConnectionState['pushToken']>['environment'];
registeredAt: number;
};
}> = [];