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).
This commit is contained in:
Linus Rath
2026-04-26 04:12:08 +02:00
parent 2fe15e6d70
commit 365ffefd33
102 changed files with 7914 additions and 214 deletions
+20 -1
View File
@@ -1,6 +1,7 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { Email, EmailAddress, Mailbox, Thread } from './types';
import { toWildcardQuery } from '../lib/search-utils';
const EMAIL_LIST_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'size',
@@ -131,7 +132,7 @@ export async function searchEmails(
limit = 30,
): Promise<string[]> {
const accountId = jmapClient.accountId;
const filter: Record<string, unknown> = { text: query };
const filter: Record<string, unknown> = { text: toWildcardQuery(query) };
if (mailboxId) filter.inMailbox = mailboxId;
const res = await jmapClient.request([
@@ -145,6 +146,24 @@ export async function searchEmails(
return res.methodResponses[0][1].ids;
}
// Cross-mailbox query with an arbitrary JMAP filter (used by contact activity
// to search "from OR to <addresses>" without picking a specific mailbox).
export async function queryEmailsByFilter(
filter: Record<string, unknown>,
limit = 5,
): Promise<string[]> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request([
['Email/query', {
accountId,
filter,
sort: [{ property: 'receivedAt', isAscending: false }],
limit,
}, '0'],
]);
return res.methodResponses[0][1].ids;
}
export async function sendEmail(
email: {
from: EmailAddress[];
+84 -1
View File
@@ -1,9 +1,92 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
import type { StateChange } 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.
+12
View File
@@ -418,6 +418,18 @@ export interface StateChange {
changed: Record<string, Record<string, string>>;
}
// RFC 8620 §7.2. A PushSubscription is session-scoped (not per-account) and
// tells the server where to deliver StateChange events for the listed types.
export interface PushSubscription {
id: string;
deviceClientId: string;
url: string;
keys?: { p256dh: string; auth: string } | null;
verificationCode?: string | null;
expires?: string | null;
types?: string[] | null;
}
// ─── Capability URNs ────────────────────────────────────
export const CAPABILITIES = {
+67
View File
@@ -0,0 +1,67 @@
import { jmapClient } from './jmap-client';
import { CAPABILITIES } from './types';
export interface VacationResponse {
id: string;
isEnabled: boolean;
fromDate: string | null;
toDate: string | null;
subject: string;
textBody: string;
htmlBody: string | null;
}
const VACATION_USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, CAPABILITIES.VACATION];
const DEFAULT: VacationResponse = {
id: 'singleton',
isEnabled: false,
fromDate: null,
toDate: null,
subject: '',
textBody: '',
htmlBody: null,
};
export async function getVacationResponse(): Promise<VacationResponse> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['VacationResponse/get', { accountId, ids: ['singleton'] }, '0']],
VACATION_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'VacationResponse/get') {
const list = (resp[1] as { list?: VacationResponse[] }).list ?? [];
return list[0] ?? DEFAULT;
}
throw new Error('Unexpected response from VacationResponse/get');
}
export async function setVacationResponse(
updates: Partial<Omit<VacationResponse, 'id'>>,
): Promise<void> {
const accountId = jmapClient.accountId;
const res = await jmapClient.request(
[['VacationResponse/set', {
accountId,
update: { singleton: updates },
}, '0']],
VACATION_USING,
);
const resp = res.methodResponses?.[0];
if (resp && resp[0] === 'VacationResponse/set') {
const result = resp[1] as { notUpdated?: Record<string, { description?: string }> };
if (result.notUpdated?.singleton) {
throw new Error(result.notUpdated.singleton.description ?? 'Failed to update vacation responder');
}
return;
}
throw new Error('Unexpected response from VacationResponse/set');
}
export function isVacationSupported(): boolean {
const session = jmapClient.currentSession;
if (!session) return false;
const caps = session.capabilities ?? {};
return CAPABILITIES.VACATION in caps;
}