diff --git a/src/device-sync/__tests__/calendar/big-events.test.ts b/src/device-sync/__tests__/calendar/big-events.test.ts index 0b1942ba..6075134c 100644 --- a/src/device-sync/__tests__/calendar/big-events.test.ts +++ b/src/device-sync/__tests__/calendar/big-events.test.ts @@ -5,9 +5,8 @@ import { describe, expect, it } from 'vitest'; import { Attendees, Events } from '../../android-columns'; import { calendarPlanner } from '../../calendar/planner'; import { contactsPlanner } from '../../contacts/planner'; -import { estimateBatchBytes } from '../../engine/batch'; import type { OpGroup } from '../../planner'; -import { CALENDAR_AUTHORITY, type ProviderPort } from '../../types'; +import { CALENDAR_AUTHORITY } from '../../types'; import type { CalendarEventWire } from '../../wire'; import { createHarness, rowWrites, type Harness as EngineHarness } from '../engine/harness'; import { Harness, ME, serverApply } from './harness'; @@ -47,23 +46,10 @@ function meeting(attendees: number, overrides: number, me = ME, calendarId = 'b' } as unknown as CalendarEventWire; } -/** The engine with the real planners, its provider refusing a batch bigger than one Binder transaction (as Android does). */ -function engineWithBinderLimit(): EngineHarness { +/** The engine with the real planners (the fake provider refuses a batch over one Binder transaction, as Android does). */ +function engineWithRealPlanners(): EngineHarness { const h = createHarness(); h.deps.planners = { contacts: contactsPlanner, calendar: calendarPlanner }; - const base = h.deps.provider; - h.deps.provider = (name, authority): ProviderPort => { - const port = base(name, authority); - return { - accountName: port.accountName, - authority: port.authority, - query: (q) => port.query(q), - readSyncState: () => port.readSyncState(), - readPhoto: (id, px) => port.readPhoto(id, px), - applyBatch: async (ops) => - estimateBatchBytes(ops) > TRANSACTION ? { ok: false, reason: 'tooLarge', message: 'TransactionTooLargeException' } : port.applyBatch(ops), - }; - }; return h; } @@ -204,7 +190,7 @@ describe('device sync engine: big meetings', () => { [25, 52], [100, 12], ])('downloads a meeting of %i attendees on %i overrides in several transactions', async (attendees, overrides) => { - const h = engineWithBinderLimit(); + const h = engineWithRealPlanners(); const { id: _drop, ...event } = meeting(attendees, overrides, 'alice@example.com', h.calendar) as unknown as Record; const id = h.server.addEvent('a', event); @@ -219,7 +205,7 @@ describe('device sync engine: big meetings', () => { it('finishes a big meeting cut off between its groups by a crash, without duplicating rows', async () => { const setup = () => { - const h = engineWithBinderLimit(); + const h = engineWithRealPlanners(); const { id: _drop, ...event } = meeting(25, 52, 'alice@example.com', h.calendar) as unknown as Record; return { h, id: h.server.addEvent('a', event) }; }; diff --git a/src/device-sync/__tests__/contacts/download.test.ts b/src/device-sync/__tests__/contacts/download.test.ts index 4fd6e639..40c02b7f 100644 --- a/src/device-sync/__tests__/contacts/download.test.ts +++ b/src/device-sync/__tests__/contacts/download.test.ts @@ -52,7 +52,9 @@ describe('contacts download: golden rows', () => { expect(cells(rows['relatedTo:urn:uuid:other-person'], TYPED)).toEqual({ data1: 'Other Person', data2: '6', data3: null }); expect(cells(rows['notes:n1'], ['data1'])).toEqual({ data1: 'first note' }); expect(rows['media:ph'].data_sync2).toBe(photoHash(JPEG)); - expect(h.device.photo(Number(rows['media:ph'].data14))).toBe(JPEG); + // A 1×1 photo is kept as the thumbnail alone (no PHOTO_FILE_ID), and read from it. + expect(rows['media:ph'].data14).toBeNull(); + expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG); const raw = h.rawContact(rawId); expect(raw).toMatchObject({ diff --git a/src/device-sync/__tests__/contacts/harness.ts b/src/device-sync/__tests__/contacts/harness.ts index 8705ef99..544aa89a 100644 --- a/src/device-sync/__tests__/contacts/harness.ts +++ b/src/device-sync/__tests__/contacts/harness.ts @@ -4,7 +4,7 @@ * way the engine applies them (one op group per batch); uploads go through * the fake server's patch handling and come back through its normalisation. */ -import { Data, GroupMembership, Groups, RawContacts } from '../../android-columns'; +import { GroupMembership, Groups, RawContacts } from '../../android-columns'; import { makeKeyMinter, parseObjectRef, uuidFrom } from '../../common/ids'; import { contactsPlanner } from '../../contacts/planner'; import type { @@ -16,7 +16,7 @@ import type { UploadAction, UploadPlan, } from '../../planner'; -import { CONTACTS_AUTHORITY, type BatchResult, type OpResult, type ProviderOp, type ProviderPort, type Row } from '../../types'; +import { CONTACTS_AUTHORITY, type BatchResult, type ProviderPort, type Row } from '../../types'; import { FakeJmapServer } from '../fakes/fake-jmap-server'; import { FakeDeviceProviders } from '../fakes/fake-provider'; @@ -34,40 +34,6 @@ function rowsOf(res: { columns: string[]; rows: unknown[][] }): Row[] { return res.rows.map((cells) => Object.fromEntries(res.columns.map((c, i) => [c, cells[i]])) as Row); } -/** - * ContactsProvider keeps a photo no larger than its 96 px thumbnail as the - * thumbnail only: PHOTO_FILE_ID (DATA14) stays NULL, and the thumbnail - * (DATA15) does not read back. The fake stores every photo with a display - * photo; this makes the photo rows a batch wrote thumbnail-only, as a device - * does with the test fixtures' 1×1 JPEGs. - */ -export function keepThumbnailsOnly(device: FakeDeviceProviders, ops: ProviderOp[], results: OpResult[]): void { - ops.forEach((op, i) => { - if ((op.op !== 'insert' && op.op !== 'update') || op.table !== 'data') return; - const bytes = op.values[Data.DATA15]; - if (!bytes || typeof bytes !== 'object') return; - const id = op.op === 'insert' ? results[i]?.id : op.id; - const row = id === undefined ? undefined : device.table('data').get(id); - if (row) row[Data.DATA14] = null; - }); -} - -/** A provider port whose photo writes come out thumbnail-only (see `keepThumbnailsOnly`). */ -export function thumbnailOnlyPort(device: FakeDeviceProviders, port: ProviderPort): ProviderPort { - return { - accountName: port.accountName, - authority: port.authority, - query: (q) => port.query(q), - readSyncState: () => port.readSyncState(), - readPhoto: (id, px) => port.readPhoto(id, px), - applyBatch: async (ops) => { - const res = await port.applyBatch(ops); - if (res.ok) keepThumbnailsOnly(device, ops, res.results); - return res; - }, - }; -} - export class Harness { readonly device = new FakeDeviceProviders(); readonly port: ProviderPort = this.device.port(ACCOUNT, CONTACTS_AUTHORITY); @@ -82,8 +48,6 @@ export class Harness { readonly unselected = new Set(); /** When false the context has no `groupsOf` (memberships not synced). */ groupIndex = true; - /** When true the photos the planner writes come out thumbnail-only (see `keepThumbnailsOnly`). */ - thumbnailPhotos = false; readonly ctx: ContactsContext; constructor() { @@ -133,9 +97,7 @@ export class Harness { async apply(group: OpGroup): Promise { if (!group.ops.length) return { ok: true, results: [] }; - const res = await this.port.applyBatch(group.ops); - if (res.ok && this.thumbnailPhotos) keepThumbnailsOnly(this.device, group.ops, res.results); - return res; + return this.port.applyBatch(group.ops); } async applyOk(group: OpGroup): Promise { diff --git a/src/device-sync/__tests__/contacts/lifecycle.test.ts b/src/device-sync/__tests__/contacts/lifecycle.test.ts index 8d66eff4..e8583e32 100644 --- a/src/device-sync/__tests__/contacts/lifecycle.test.ts +++ b/src/device-sync/__tests__/contacts/lifecycle.test.ts @@ -80,8 +80,7 @@ describe('contacts lifecycle', () => { const h = new Harness(); h.blobs.set('B1', JPEG); const { id, rawId } = await h.seed({ name: { full: 'Blob Photo' }, media: { p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } } }); - const photo = h.rowsByKey(rawId)['media:p']; - expect(h.device.photo(Number(photo.data14))).toBe(JPEG); + expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG); expect((await h.contact(rawId)).shadow?.media).toEqual({ p: { kind: 'photo', blobId: 'B1', mediaType: 'image/jpeg' } }); await expectSettled(h, id, rawId); }); @@ -164,7 +163,7 @@ describe('contacts lifecycle', () => { h.blobs.set('B1', JPEG); h.server.serverUpdate('ContactCard', JMAP, id, { 'emails/e1/address': 'c@example.org' }); await h.download(id); - expect(h.device.photo(Number(h.rowsByKey(rawId)['media:p'].data14))).toBe(JPEG); + expect((await h.port.readPhoto(rawId, 512))?.jpegBase64).toBe(JPEG); await expectSettled(h, id, rawId); }); @@ -188,7 +187,6 @@ describe('contacts lifecycle', () => { it('keeps one row through downloads and merges, and never reads it as deleted', async () => { const h = new Harness(); - h.thumbnailPhotos = true; const { id, rawId } = await h.seed(small()); expect(photos(h, rawId)).toMatchObject([{ data14: null, data_sync1: 'media:ph' }]); await expectSettled(h, id, rawId); @@ -220,7 +218,6 @@ describe('contacts lifecycle', () => { it('heals the photo rows the same photo was inserted as again, keeping one and the server photo', async () => { const h = new Harness(); - h.thumbnailPhotos = true; const { id, rawId } = await h.seed(small()); const [row] = photos(h, rawId); const again = { [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: JPEG }, data_sync1: row.data_sync1, data_sync2: row.data_sync2, data_sync3: row.data_sync3 }; diff --git a/src/device-sync/__tests__/contacts/real-engine.test.ts b/src/device-sync/__tests__/contacts/real-engine.test.ts index d39e6bb4..3e1ee1c3 100644 --- a/src/device-sync/__tests__/contacts/real-engine.test.ts +++ b/src/device-sync/__tests__/contacts/real-engine.test.ts @@ -9,7 +9,6 @@ import { contactsPlanner } from '../../contacts/planner'; import { addServerCards, ANDROID_ACCOUNT, CONTACTS_AUTHORITY, createHarness, rowWrites, type Harness } from '../engine/harness'; import type { SetTarget } from '../fakes/fake-jmap-server'; import { JPEG } from './fixtures'; -import { thumbnailOnlyPort } from './harness'; function real(options: Parameters[0] = {}): Harness { const h = createHarness(options); @@ -119,8 +118,6 @@ describe('contacts through the engine', () => { it('keeps one row for a small photo the provider stores as its thumbnail, and never deletes it on the server', async () => { const h = real(); - const base = h.deps.provider; - h.deps.provider = (name, authority) => thumbnailOnlyPort(h.device, base(name, authority)); const media = { ph: { kind: 'photo', uri: `data:image/jpeg;base64,${JPEG}`, mediaType: 'image/jpeg' } }; const card = h.server.addCard('a', { uid: 'u-small', name: { full: 'Photo Smalltest' }, notes: { n1: { note: 'v1' } }, addressBookIds: { [h.book]: true }, media }); expect((await h.run()).outcome).toBe('ok'); diff --git a/src/device-sync/__tests__/fake-jmap-server.test.ts b/src/device-sync/__tests__/fake-jmap-server.test.ts index ceae4551..426830f4 100644 --- a/src/device-sync/__tests__/fake-jmap-server.test.ts +++ b/src/device-sync/__tests__/fake-jmap-server.test.ts @@ -429,6 +429,29 @@ describe('FakeJmapServer', () => { expect(server.get('CalendarEvent', 'a', id)?.updated).toBe('2026-09-27T10:02:00Z'); }); + it('drops empty maps inside overrides too, which an override VEVENT cannot hold', async () => { + const { server, port, cal } = setup(); + const id = server.addEvent('a', { + title: 'Standup', + start: '2026-09-28T09:00:00', + recurrenceRule: { frequency: 'daily' }, + alerts: { al1: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M' } } }, + recurrenceOverrides: { '2026-09-29T09:00:00': { title: 'Late', alerts: { a2: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H' } } } } }, + calendarIds: { [cal]: true }, + }); + + await call(port, 'CalendarEvent/set', { + accountId: 'a', + update: { [id]: { 'recurrenceOverrides/2026-09-29T09:00:00/alerts': {}, 'recurrenceOverrides/2026-09-29T09:00:00/locations': {} } }, + }); + + // Read back without them, so the occurrence shows the series' alerts again, as on Stalwart. + const override = (server.get('CalendarEvent', 'a', id)?.recurrenceOverrides as Record>)['2026-09-29T09:00:00']; + expect(override).toMatchObject({ title: 'Late' }); + expect(override).not.toHaveProperty('alerts'); + expect(override).not.toHaveProperty('locations'); + }); + it('leaves useDefaultAlerts out of properties: null and returns it when listed', async () => { const { server, port, cal } = setup(); const id = server.addEvent('a', { title: 'x', useDefaultAlerts: true, calendarIds: { [cal]: true } }); diff --git a/src/device-sync/__tests__/fake-provider.test.ts b/src/device-sync/__tests__/fake-provider.test.ts index 98803ef9..41afcc47 100644 --- a/src/device-sync/__tests__/fake-provider.test.ts +++ b/src/device-sync/__tests__/fake-provider.test.ts @@ -10,6 +10,7 @@ import { StructuredPostal, } from '../android-columns'; import { CALENDAR_AUTHORITY, CONTACTS_AUTHORITY, type ProviderOp } from '../types'; +import { JPEG } from './contacts/fixtures'; import { FakeDeviceProviders, aospDurationValid, aospRruleValid } from './fakes/fake-provider'; import { compileWhere } from './fakes/fake-sql'; @@ -129,13 +130,13 @@ describe('FakeDeviceProviders: contacts', () => { }); it('refuses 500 contacts ops without a yield point and oversized batches', async () => { - const fake = new FakeDeviceProviders({ maxBatchBytes: 50_000 }); + const fake = new FakeDeviceProviders({ maxBatchBytes: 500_000 }); const port = fake.port(ME, CONTACTS_AUTHORITY); const ops: ProviderOp[] = Array.from({ length: 500 }, () => ({ op: 'insert', table: 'raw_contacts', values: {} }) as ProviderOp); expect(await port.applyBatch(ops)).toMatchObject({ ok: false, reason: 'provider' }); ops[250] = { ...ops[250], yieldAllowed: true }; expect((await port.applyBatch(ops)).ok).toBe(true); - const big = await port.applyBatch([{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.SYNC2]: 'x'.repeat(60_000) } }]); + const big = await port.applyBatch([{ op: 'insert', table: 'raw_contacts', values: { [RawContacts.SYNC2]: 'x'.repeat(300_000) } }]); expect(big).toMatchObject({ ok: false, reason: 'tooLarge' }); }); @@ -166,6 +167,31 @@ describe('FakeDeviceProviders: contacts', () => { expect(rows.rows[0]).toEqual([String(photo!.fileId), null]); }); + it('keeps a photo within the 96 px thumbnail as the thumbnail alone, and serves that to readPhoto', async () => { + const fake = new FakeDeviceProviders(); + const { port, rawId } = await contactWithEmail(fake); + const res = await port.applyBatch([ + { op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: JPEG } } }, + ]); + expect(res.ok).toBe(true); + const rows = await port.query({ table: 'data', columns: [Data.DATA14, Data.DATA15], where: `${Data.MIMETYPE} = ?`, args: [MimeType.PHOTO] }); + // No PHOTO_FILE_ID for a 1×1 JPEG, and the thumbnail does not read back. + expect(rows.rows[0]).toEqual([null, null]); + expect(await port.readPhoto(rawId, 512)).toEqual({ jpegBase64: JPEG, fileId: null }); + }); + + it('weighs a batch as its Binder transaction does: two bytes per character of its JSON, a photo by its bytes', async () => { + const fake = new FakeDeviceProviders(); + const { port, rawId } = await contactWithEmail(fake); + // 600,000 characters of text are about 1.2 MB in the Parcel; the same number of base64 characters are 450 KB of photo. + const text = await port.applyBatch([{ op: 'update', table: 'raw_contacts', id: rawId, values: { [RawContacts.SYNC2]: 'x'.repeat(600_000) } }]); + expect(text).toMatchObject({ ok: false, reason: 'tooLarge' }); + const photo = await port.applyBatch([ + { op: 'insert', table: 'data', values: { [Data.RAW_CONTACT_ID]: rawId, [Data.MIMETYPE]: MimeType.PHOTO, [Data.DATA15]: { b64: 'A'.repeat(600_000) } } }, + ]); + expect(photo.ok).toBe(true); + }); + it('models Fossify saves: rows re-inserted without DATA_SYNC', async () => { const fake = new FakeDeviceProviders(); const { rawId, dataId } = await contactWithEmail(fake); @@ -246,6 +272,15 @@ describe('FakeDeviceProviders: calendar', () => { expect(fake.row('events', exceptionId)).toBeDefined(); }); + it('leaves STATUS NULL on an event an app inserts without one (the column has no default)', async () => { + const fake = new FakeDeviceProviders(); + const { calendarId } = await calendar(fake); + const id = fake.user.insertEvent(calendarId, { [Events.DTSTART]: 0, [Events.DTEND]: 1000, [Events.EVENT_TIMEZONE]: 'UTC', [Events.TITLE]: 'x' }); + expect(fake.row('events', id)![Events.STATUS]).toBeNull(); + // Unlike AVAILABILITY and ACCESS_LEVEL (NOT NULL DEFAULT 0). + expect(fake.row('events', id)).toMatchObject({ [Events.AVAILABILITY]: 0, [Events.ACCESS_LEVEL]: 0 }); + }); + it('hard-deletes app-deleted events without _SYNC_ID and soft-deletes synced ones', async () => { const fake = new FakeDeviceProviders(); const { calendarId } = await calendar(fake); diff --git a/src/device-sync/__tests__/fakes/fake-jmap-server.ts b/src/device-sync/__tests__/fakes/fake-jmap-server.ts index 015a161d..892f4c7f 100644 --- a/src/device-sync/__tests__/fakes/fake-jmap-server.ts +++ b/src/device-sync/__tests__/fakes/fake-jmap-server.ts @@ -965,6 +965,8 @@ function normalizeEvent(input: Obj, now: string, mode: 'jmap' | 'server'): Obj { } privacy = Math.max(privacy, PRIVACY_RANK.indexOf(String(value.privacy))); for (const forbidden of FORBIDDEN_OVERRIDE_PROPERTIES) delete value[forbidden]; + // Stored as a VEVENT of its own: an empty map writes nothing there either. + dropEmptyMaps(value, EVENT_MAPS); if (Object.keys(value).length > 0) { normalizeEventTimes(value); addNestedTypes(value); diff --git a/src/device-sync/__tests__/fakes/fake-provider.ts b/src/device-sync/__tests__/fakes/fake-provider.ts index 100d5229..17c238f6 100644 --- a/src/device-sync/__tests__/fakes/fake-provider.ts +++ b/src/device-sync/__tests__/fakes/fake-provider.ts @@ -25,6 +25,12 @@ * owner, all-day times are zeroed in UTC and `PS` all-day durations become * `PD`, while `PT0S`-style all-day durations, NULL STATUS updates, * SELF_ATTENDEE_STATUS updates and RRULEs with unknown parts fail the batch. + * STATUS has no default: an event an app inserts without one holds NULL. + * - Photos, as PhotoProcessor keeps them: one within the 96 px thumbnail is + * the thumbnail alone (PHOTO_FILE_ID NULL), a bigger one (or bytes that are + * no JPEG or PNG) a display photo with a file id; DATA15 never reads back. + * - A batch over one Binder transaction (`estimateBatchBytes` over 1 MB, by + * default) fails with `tooLarge`. * - Scoping: rows of other accounts are invisible and untouchable. * - The native bridge's rules (android/.../sync/ProviderOps.kt and * ProviderScope.kt): an op with `id` expects exactly one row unless it says @@ -74,6 +80,7 @@ import { type WriteCell, type WriteRow, } from '../../types'; +import { estimateBatchBytes } from '../../engine/batch'; import { compileOrderBy, compileWhere, whereColumns } from './fake-sql'; export const FAKE_ACCOUNT_TYPE = 'com.anonymous.bulwarkmobile.account'; @@ -240,7 +247,7 @@ export interface FakeProviderOptions { accountType?: string; /** Derive/split names and addresses like ContactsProvider (default true). */ normalize?: boolean; - /** Byte budget of one applyBatch before it fails with `tooLarge` (default 1 MB). */ + /** Bytes of one applyBatch, as `estimateBatchBytes` weighs them, before it fails with `tooLarge` (default 1 MB). */ maxBatchBytes?: number; } @@ -517,7 +524,7 @@ export class FakeDeviceProviders { record(false); return { ok: false, reason, message }; } - if (JSON.stringify(ops).length > this.maxBatchBytes) { + if (parcelBytes(ops) > this.maxBatchBytes) { record(false); return { ok: false, reason: 'tooLarge', message: 'TransactionTooLargeException' }; } @@ -569,6 +576,26 @@ export class FakeDeviceProviders { } } +/** + * What a batch weighs in its Binder transaction: `estimateBatchBytes`, with a + * photo blob counted by its bytes (the native side hands ContentValues a + * decoded `byte[]`, not the base64 text). + */ +function parcelBytes(ops: readonly ProviderOp[]): number { + let blobs = 0; + const texts = ops.map((op) => { + if (op.op !== 'insert' && op.op !== 'update') return op; + const values = { ...op.values }; + for (const [column, value] of Object.entries(values)) { + if (value === null || typeof value !== 'object') continue; + blobs += Math.floor((value.b64.length * 3) / 4); + values[column] = null; + } + return { ...op, values }; + }); + return estimateBatchBytes(texts) + blobs; +} + function cloneTables(tables: Tables): Tables { const out = {} as Tables; for (const [name, rows] of Object.entries(tables) as Array<[ProviderTable, RowMap]>) { @@ -642,8 +669,11 @@ class FakePort implements ProviderPort { } for (const row of this.fake.table('data').values()) { if (row[Data.RAW_CONTACT_ID] === rawContactId && row[Data.MIMETYPE] === MimeType.PHOTO) { - const fileId = row[Data.DATA14] === null ? null : Number(row[Data.DATA14]); - const b64 = fileId === null ? undefined : this.fake.photo(fileId); + const fileId = row[Data.DATA14] === null || row[Data.DATA14] === undefined ? null : Number(row[Data.DATA14]); + // A photo kept as its thumbnail alone is read from the thumbnail. + const thumbnail = row[Data.DATA15]; + if (fileId === null) return typeof thumbnail === 'string' && thumbnail.startsWith('blob:') ? { jpegBase64: thumbnail.slice(5), fileId: null } : null; + const b64 = this.fake.photo(fileId); return b64 === undefined ? null : { jpegBase64: b64, fileId }; } } @@ -755,10 +785,19 @@ class BatchApplier { return out; } - /** A photo row's full-size bytes become a stored display photo plus a file id, like PhotoProcessor. */ + /** + * A photo row's full-size bytes, as PhotoProcessor keeps them: within the + * 96 px thumbnail as the thumbnail alone (PHOTO_FILE_ID NULL, the bytes kept + * for readPhoto), else as a stored display photo plus a file id. + */ private processPhoto(row: StoredRow): void { const v = row[Data.DATA15]; if (typeof v === 'string' && v.startsWith('blob:')) { + const size = imageSize(v.slice(5)); + if (size && Math.max(size.width, size.height) <= THUMBNAIL_PX) { + row[Data.DATA14] = null; + return; + } row[Data.DATA14] = this.fake.storePhoto(v.slice(5)); row[Data.DATA15] = 'thumbnail'; } @@ -996,6 +1035,34 @@ class BatchApplier { const defaults = (d: StoredRow): StoredRow => ({ ...d }); +/** ContactsProvider's thumbnail size: a photo no bigger is kept as the thumbnail alone. */ +const THUMBNAIL_PX = 96; + +/** The pixel size of JPEG (its SOFn segment) or PNG (its IHDR chunk) bytes; null for anything else. */ +function imageSize(b64: string): { width: number; height: number } | null { + let bytes: Uint8Array; + try { + bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + } catch { + return null; + } + const u16 = (i: number) => (bytes[i] << 8) | bytes[i + 1]; + const u32 = (i: number) => ((bytes[i] << 24) >>> 0) + (bytes[i + 1] << 16) + (bytes[i + 2] << 8) + bytes[i + 3]; + if (bytes.length >= 24 && bytes[0] === 0x89 && String.fromCharCode(...bytes.slice(12, 16)) === 'IHDR') { + return { width: u32(16), height: u32(20) }; + } + if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; + for (let i = 2; i + 9 < bytes.length; ) { + if (bytes[i] !== 0xff) return null; + const marker = bytes[i + 1]; + if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) { + return { height: u16(i + 5), width: u16(i + 7) }; + } + i += 2 + u16(i + 2); + } + return null; +} + const RAW_CONTACT_DEFAULTS: StoredRow = { [RawContacts.SOURCE_ID]: null, [RawContacts.VERSION]: 1, @@ -1045,7 +1112,7 @@ const EVENT_DEFAULTS: StoredRow = { [Events.GUESTS_CAN_INVITE_OTHERS]: 1, [Events.GUESTS_CAN_SEE_GUESTS]: 1, [Events.ALL_DAY]: 0, - [Events.STATUS]: 0, + // No default for STATUS (`eventStatus INTEGER`): an app that writes none leaves NULL. }; /**