From 117823f746e50dd63b3a4c08bd132a848d2d37f9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:58:01 +0200 Subject: [PATCH] test: make the device sync fakes as strict as Android and Stalwart #34 The fake providers and the fake server were more lenient than the real ones, so three bugs showed only on a device: an event inserted without a status, small contact photos, and meetings too big for one provider transaction. The fake CalendarProvider leaves STATUS NULL when an app writes none. The fake ContactsProvider keeps a photo within the 96 px thumbnail as the thumbnail alone, without a PHOTO_FILE_ID, and serves it to readPhoto from there. A batch fails with tooLarge when it weighs more than one Binder transaction, a photo counted by its bytes. The fake server drops empty maps inside recurrence overrides, as Stalwart does. Tests now read photos the way the engine does, and the helpers that made up for the old fakes are gone. --- .../__tests__/calendar/big-events.test.ts | 24 ++---- .../__tests__/contacts/download.test.ts | 4 +- src/device-sync/__tests__/contacts/harness.ts | 44 +---------- .../__tests__/contacts/lifecycle.test.ts | 7 +- .../__tests__/contacts/real-engine.test.ts | 3 - .../__tests__/fake-jmap-server.test.ts | 23 ++++++ .../__tests__/fake-provider.test.ts | 39 ++++++++- .../__tests__/fakes/fake-jmap-server.ts | 2 + .../__tests__/fakes/fake-provider.ts | 79 +++++++++++++++++-- 9 files changed, 148 insertions(+), 77 deletions(-) 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. }; /**