Files
native/src/api/push.ts
T
Linus Rath 365ffefd33 chore: add Android release CI and sync local work
CI:
- .github/workflows/release-android.yml builds APK on release publish
  and via workflow_dispatch, attaches it to the release.
- android/app/build.gradle: optional release signingConfig driven by
  Gradle properties (ANDROID_KEYSTORE_BASE64 secret etc.); falls back
  to the debug keystore when no secrets are set.
- .gitignore: untrack /android so the runner can build directly;
  ignore build outputs (build/, .cxx/), Metro/Expo dev logs, scratch.

Track android/ sources including BulwarkFcmPackage.kt and
google-services.json (Firebase Android keys are public per Google's
docs and intended to ship in the APK).

JS sync: push notifications + background task, vacation responder
(api + store), contact activity component, search utilities, settings
polish, calendar/contacts/email store and screen tweaks.

Tooling: scripts/poll-reload.mjs — Windows polling-watcher that hits
Metro /reload on file change (workaround for unreliable fs.watch
without Watchman).
2026-04-26 04:12:08 +02:00

188 lines
5.0 KiB
TypeScript

import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { PushSubscription, StateChange } from './types';
export type StateChangeHandler = (change: StateChange) => void;
// ─── PushSubscription (RFC 8620 §7.2) ───────────────────
export async function listPushSubscriptions(): Promise<PushSubscription[]> {
const res = await jmapClient.request(
[['PushSubscription/get', { ids: null }, '0']],
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
return (body?.list as PushSubscription[]) ?? [];
}
/**
* Create a PushSubscription pointing the JMAP server at the given relay URL.
* Returns the server-assigned id (which the client also registers with the
* relay so the relay can route incoming pushes to an Expo token).
*/
export async function createPushSubscription(params: {
deviceClientId: string;
url: string;
types: string[];
}): Promise<string> {
const res = await jmapClient.request(
[
[
'PushSubscription/set',
{
create: {
new: {
deviceClientId: params.deviceClientId,
url: params.url,
types: params.types,
},
},
},
'0',
],
],
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
const created = body?.created?.new as { id?: string } | undefined;
if (!created?.id) {
const notCreated = body?.notCreated?.new;
throw new Error(
`PushSubscription/set create failed: ${JSON.stringify(notCreated ?? body)}`,
);
}
return created.id;
}
/**
* Send the verification code back to the server — the call that flips the
* subscription from pending to active (RFC 8620 §7.2.2).
*/
export async function verifyPushSubscription(
id: string,
verificationCode: string,
): Promise<void> {
const res = await jmapClient.request(
[
[
'PushSubscription/set',
{ update: { [id]: { verificationCode } } },
'0',
],
],
[CAPABILITIES.CORE],
);
const [, body] = res.methodResponses[0] ?? [];
if (body?.notUpdated?.[id]) {
throw new Error(
`PushSubscription verification failed: ${JSON.stringify(body.notUpdated[id])}`,
);
}
}
export async function destroyPushSubscription(id: string): Promise<void> {
await jmapClient.request(
[['PushSubscription/set', { destroy: [id] }, '0']],
[CAPABILITIES.CORE],
);
}
/**
* Connect to the JMAP EventSource (SSE) endpoint for real-time push updates.
* Returns a cleanup function to close the connection.
*/
export function connectEventSource(
onStateChange: StateChangeHandler,
): (() => void) | null {
const session = jmapClient.currentSession;
if (!session?.eventSourceUrl) return null;
const url = session.eventSourceUrl
.replace('{types}', '*')
.replace('{closeafter}', 'no')
.replace('{ping}', '30');
// Use native EventSource or react-native-sse
const EventSourceImpl = typeof EventSource !== 'undefined'
? EventSource
: require('react-native-sse').default;
const es = new EventSourceImpl(url, {
headers: { Authorization: (jmapClient as any).authHeader },
});
const handler = (event: any) => {
try {
const data: StateChange = JSON.parse(
typeof event === 'string' ? event : event.data,
);
onStateChange(data);
} catch {
// Ignore malformed events
}
};
es.addEventListener('state', handler);
return () => {
es.removeEventListener('state', handler);
es.close();
};
}
/**
* Polling fallback when SSE is not available.
* Returns a cleanup function to stop polling.
*/
export function startPolling(
onStateChange: StateChangeHandler,
interval = 5000,
): () => void {
const accountId = jmapClient.accountId;
const stateCache: Record<string, string> = {};
const timer = setInterval(async () => {
try {
const res = await jmapClient.request(
[
['Mailbox/get', { accountId, ids: [] }, 'm'],
['Email/get', { accountId, ids: [] }, 'e'],
],
[CAPABILITIES.CORE, CAPABILITIES.MAIL],
);
const changed: Record<string, string> = {};
for (const [method, result] of res.methodResponses) {
const type = method.replace('/get', '');
const newState = result.state;
if (newState && stateCache[type] && stateCache[type] !== newState) {
changed[type] = newState;
}
if (newState) stateCache[type] = newState;
}
if (Object.keys(changed).length > 0) {
onStateChange({
'@type': 'StateChange',
changed: { [accountId]: changed },
});
}
} catch {
// Silently retry on next interval
}
}, interval);
return () => clearInterval(timer);
}
/**
* Start real-time updates, preferring SSE with polling fallback.
*/
export function startPushUpdates(
onStateChange: StateChangeHandler,
): () => void {
const cleanup = connectEventSource(onStateChange);
if (cleanup) return cleanup;
return startPolling(onStateChange);
}