diff --git a/src/device-sync/__tests__/contacts/download.test.ts b/src/device-sync/__tests__/contacts/download.test.ts index 3bdf68f2..4fd6e639 100644 --- a/src/device-sync/__tests__/contacts/download.test.ts +++ b/src/device-sync/__tests__/contacts/download.test.ts @@ -99,7 +99,7 @@ describe('contacts download: golden rows', () => { expect(Object.keys(rows).some((k) => k.startsWith('onlineServices'))).toBe(false); }); - it('maps a Google card: custom display name, legacy cell phone, labelled link, department list, relation by name', async () => { + it('maps a Google card: custom display name, legacy cell phone, profile link, department list, relation by name', async () => { const h = new Harness(); const { rawId } = await h.seed(googleCard()); const rows = h.rowsByKey(rawId); @@ -110,7 +110,8 @@ describe('contacts download: golden rows', () => { expect(cells(rows['nicknames:k1'], ['data1'])).toEqual({ data1: 'Gü' }); expect(cells(rows['nicknames:k2'], ['data1'])).toEqual({ data1: 'GKG' }); expect(cells(rows['phones:k1'], TYPED)).toEqual({ data1: '0171 2345678', data2: '2', data3: null }); - expect(cells(rows['links:k1'], TYPED)).toEqual({ data1: 'https://example.org/~guenther', data2: '0', data3: 'profile' }); + // The label names a type Android has. + expect(cells(rows['links:k1'], TYPED)).toEqual({ data1: 'https://example.org/~guenther', data2: '3', data3: null }); expect(cells(rows['organizations:k1|titles:k1,'], ORG)).toEqual({ data1: 'Uni Beispiel', data4: 'Professor', data5: 'Informatik, AG Sync', data6: null }); expect(cells(rows['relatedTo:Maria Groß'], TYPED)).toEqual({ data1: 'Maria Groß', data2: '14', data3: null }); expect(cells(rows['anniversaries:k1'], TYPED)).toEqual({ data1: '--08-15', data2: '1', data3: null }); diff --git a/src/device-sync/__tests__/contacts/units.test.ts b/src/device-sync/__tests__/contacts/units.test.ts index e9bb609c..87ed4457 100644 --- a/src/device-sync/__tests__/contacts/units.test.ts +++ b/src/device-sync/__tests__/contacts/units.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { Data, MimeType, PhoneType, RelationType } from '../../android-columns'; +import { Data, EmailType, MimeType, PhoneType, RelationType, WebsiteType } from '../../android-columns'; import { contactsPlanner } from '../../contacts/planner'; import { entryKey, orgKey, parseEntryKey, parseOrgKey } from '../../contacts/keys'; import { editPostalComponents, postalCells, resplitPostal } from '../../contacts/postal'; import { base64ToBytes, dataUriBase64, shadowOf } from '../../contacts/photo'; import { fullIsDerived, nameCells } from '../../contacts/name'; -import { phoneFlags, phoneType, relationName, relationType } from '../../contacts/types-map'; +import { emailType, phoneFlags, phoneType, relationName, relationType, websiteType } from '../../contacts/types-map'; import type { ContactCardWire } from '../../planner'; import { JPEG } from './fixtures'; @@ -94,6 +94,17 @@ describe('contacts type tables', () => { expect(phoneFlags(PhoneType.CAR, null)).toEqual({ contexts: [], features: ['voice'], label: 'car' }); }); + it('reads back the types RFC 9553 lacks from the label they upload with', () => { + expect(phoneType({ features: { 'main-number': true }, contexts: { work: true } }).type).toBe(PhoneType.COMPANY_MAIN); + expect(phoneType({ features: { voice: true }, label: 'car' })).toEqual({ type: PhoneType.CAR, label: null }); + expect(phoneType({ features: { text: true }, label: 'mms' })).toEqual({ type: PhoneType.MMS, label: null }); + // A custom label uploads without the feature, so it stays custom; a lone `text` is no MMS. + expect(phoneType({ label: 'car' })).toEqual({ type: PhoneType.CUSTOM, label: 'car' }); + expect(phoneType({ features: { text: true } }).type).toBe(PhoneType.OTHER); + expect(emailType({ label: 'mobile' })).toEqual({ type: EmailType.MOBILE, label: null }); + expect(websiteType({ label: 'blog', contexts: { work: true } })).toEqual({ type: WebsiteType.BLOG, label: null }); + }); + it('maps relations both ways, device-only types to the nearest RFC type', () => { expect(relationType({ friend: true })).toEqual({ type: RelationType.FRIEND, label: null }); expect(relationType({ 'co-worker': true })).toEqual({ type: RelationType.CUSTOM, label: 'co-worker' }); diff --git a/src/device-sync/__tests__/contacts/upload-fields.test.ts b/src/device-sync/__tests__/contacts/upload-fields.test.ts index e1582aa1..cca03bb9 100644 --- a/src/device-sync/__tests__/contacts/upload-fields.test.ts +++ b/src/device-sync/__tests__/contacts/upload-fields.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Data, MimeType } from '../../android-columns'; +import { Data, EmailType, MimeType, PhoneType, WebsiteType } from '../../android-columns'; import { deepEqual } from '../../common/json'; import { applyPatch, assertPatchWellFormed, type PatchObject } from '../../common/patch'; import type { ContactCardWire } from '../../planner'; @@ -108,6 +108,42 @@ describe('contacts upload: one edited field, one pointer', () => { }); }); +describe('contacts upload: every device type comes back as the device wrote it', () => { + const cases = (types: Record, custom: number) => + Object.entries(types).map(([name, type]) => [name, type, type === custom ? 'Boat' : null] as const); + const kinds = [ + { mimetype: MimeType.PHONE, value: '+49 30 1', map: 'phones', entry: { number: '+49 30 1', features: { voice: true } }, types: cases(PhoneType, PhoneType.CUSTOM) }, + { mimetype: MimeType.EMAIL, value: 'typed@example.org', map: 'emails', entry: { address: 'typed@example.org' }, types: cases(EmailType, EmailType.CUSTOM) }, + { mimetype: MimeType.WEBSITE, value: 'https://typed.example', map: 'links', entry: { uri: 'https://typed.example' }, types: cases(WebsiteType, WebsiteType.CUSTOM) }, + ]; + const typeOf = (row: Row) => [Number(row.data2), row.data3 ?? null]; + + describe.each(kinds)('$map', ({ mimetype, value, map, entry, types }) => { + it.each(types)('%s, on a contact created on the device', async (_name, type, label) => { + const h = new Harness(); + const rawId = h.device.user.insertContact('usera@example.org', [ + { [Data.MIMETYPE]: MimeType.STRUCTURED_NAME, data1: 'Typed Person', data2: 'Typed', data3: 'Person' }, + { [Data.MIMETYPE]: mimetype, data1: value, data2: type, data3: label }, + ]); + await h.upload(rawId); + const [row] = h.device.rows('data', 'raw_contact_id = ? AND mimetype = ?', [rawId, mimetype]); + expect(typeOf(row)).toEqual([type, label]); + const local = await h.contact(rawId); + expect(local.dirty).toBe(false); + expect(h.planner.planDownload(h.card(h.cardIdOf(local)), local, h.ctx).effect).toBe('none'); + }); + + it.each(types)('%s, chosen on the device for a synced entry', async (_name, type, label) => { + const h = new Harness(); + const { id, rawId } = await h.seed({ name: { full: 'Typed Person' }, [map]: { e1: entry } }); + h.device.user.updateData(h.rowsByKey(rawId)[`${map}:e1`]._id as number, { data2: type, data3: label }); + await h.upload(rawId); + expect(typeOf(h.rowsByKey(rawId)[`${map}:e1`])).toEqual([type, label]); + expect(h.planner.planDownload(h.card(id), await h.contact(rawId), h.ctx).effect).toBe('none'); + }); + }); +}); + describe('contacts upload: an address edited as one line (AOSP Contacts)', () => { const munich = () => ({ name: { full: 'Maria Muster' }, diff --git a/src/device-sync/contacts/types-map.ts b/src/device-sync/contacts/types-map.ts index bd39d86c..d8d9c290 100644 --- a/src/device-sync/contacts/types-map.ts +++ b/src/device-sync/contacts/types-map.ts @@ -34,9 +34,22 @@ function labelled(label: unknown, type: number, custom: number): TypeCells { return typeof label === 'string' && label.trim() ? { type: custom, label } : { type, label: null }; } +/** + * Android types RFC 9553 has no word for upload as a label naming them; that + * label (exactly as written) names the type again on the way back. + */ +function namedType(labels: Record, label: unknown): number | null { + const hit = Object.entries(labels).find(([, name]) => name === label); + return hit ? Number(hit[0]) : null; +} + // ─── Emails ──────────────────────────────────────────── +const EMAIL_LABELS: Record = { [EmailType.MOBILE]: 'mobile' }; + export function emailType(entry: { contexts?: unknown; label?: unknown }): TypeCells { + const named = namedType(EMAIL_LABELS, entry.label); + if (named !== null) return { type: named, label: null }; const type = on(entry.contexts, 'private') ? EmailType.HOME : on(entry.contexts, 'work') ? EmailType.WORK : EmailType.OTHER; return labelled(entry.label, type, EmailType.CUSTOM); } @@ -45,7 +58,7 @@ export function emailFlags(type: number | null, label: string | null): EntryFlag switch (type) { case EmailType.HOME: return { contexts: ['private'], label: null }; case EmailType.WORK: return { contexts: ['work'], label: null }; - case EmailType.MOBILE: return { contexts: [], label: 'mobile' }; + case EmailType.MOBILE: return { contexts: [], label: EMAIL_LABELS[EmailType.MOBILE] }; case EmailType.CUSTOM: return { contexts: [], label }; default: return { contexts: [], label: null }; } @@ -53,10 +66,16 @@ export function emailFlags(type: number | null, label: string | null): EntryFlag // ─── Phones ──────────────────────────────────────────── -/** First match wins, as in the design's table; `cell` is read as `mobile`. */ +/** + * First match wins, as in the design's table; `cell` is read as `mobile`. A + * label naming a type RFC 9553 lacks, next to the feature that type uploads + * with, is that type (a custom "car" uploads without the feature). + */ export function phoneType(entry: { features?: unknown; contexts?: unknown; label?: unknown }): TypeCells { const f = (n: string) => on(entry.features, n); const c = (n: string) => on(entry.contexts, n); + const named = namedType(PHONE_LABELS, entry.label); + if (named !== null && (phoneFlags(named, null).features ?? []).every(f)) return { type: named, label: null }; const mobile = f('mobile') || f('cell'); let type: number = PhoneType.OTHER; if (mobile && c('work')) type = PhoneType.WORK_MOBILE; @@ -67,6 +86,7 @@ export function phoneType(entry: { features?: unknown; contexts?: unknown; label else if (f('pager') && c('work')) type = PhoneType.WORK_PAGER; else if (f('pager')) type = PhoneType.PAGER; else if (f('textphone')) type = PhoneType.TTY_TDD; + else if (f('main-number') && c('work')) type = PhoneType.COMPANY_MAIN; else if (f('main-number')) type = PhoneType.MAIN; else if (c('work')) type = PhoneType.WORK; else if (c('private')) type = PhoneType.HOME; @@ -83,6 +103,7 @@ const PHONE_LABELS: Record = { [PhoneType.RADIO]: 'radio', [PhoneType.TELEX]: 'telex', [PhoneType.ASSISTANT]: 'assistant', + [PhoneType.MMS]: 'mms', }; export function phoneFlags(type: number | null, label: string | null): EntryFlags { @@ -99,7 +120,7 @@ export function phoneFlags(type: number | null, label: string | null): EntryFlag case PhoneType.COMPANY_MAIN: return { contexts: ['work'], features: ['main-number'], label: null }; case PhoneType.WORK: return { contexts: ['work'], features: ['voice'], label: null }; case PhoneType.HOME: return { contexts: ['private'], features: ['voice'], label: null }; - case PhoneType.MMS: return { contexts: [], features: ['text'], label: null }; + case PhoneType.MMS: return { contexts: [], features: ['text'], label: PHONE_LABELS[PhoneType.MMS] }; case PhoneType.CUSTOM: return { contexts: [], features: [], label }; case null: case PhoneType.OTHER: return { contexts: [], features: [], label: null }; @@ -124,6 +145,8 @@ export function postalFlags(type: number | null, label: string | null): EntryFla } export function websiteType(entry: { contexts?: unknown; label?: unknown }): TypeCells { + const named = namedType(WEBSITE_LABELS, entry.label); + if (named !== null) return { type: named, label: null }; const type = on(entry.contexts, 'private') ? WebsiteType.HOME : on(entry.contexts, 'work') ? WebsiteType.WORK : WebsiteType.OTHER; return labelled(entry.label, type, WebsiteType.CUSTOM); }