fix: keep phone, email and website types as picked on the device #34

Types Android has but RFC 9553 lacks uploaded as a label naming them,
yet every label read back as a custom type, and MMS and company main
read back as Other and Main. A number typed Car showed up as
"Custom: car" right after the sync, an MMS number as Other, a mobile
email as "Custom: mobile" and a blog link as "Custom: blog".

Such a label now names its type again, for phones only next to the
feature the type uploads with, so a custom label stays custom. MMS
uploads its `mms` label next to `text`, which keeps a text-capable
number from reading as MMS, and `main-number` with `work` reads as
company main.

Checked with a round trip of every phone, email and website type,
both for a contact created on the device and for a type changed on
a synced entry.
This commit is contained in:
Linus Rath
2026-09-27 07:19:27 +02:00
parent 05e3605ec7
commit 00f1a66095
4 changed files with 79 additions and 8 deletions
@@ -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 });
@@ -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' });
@@ -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<string, number>, 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' },
+26 -3
View File
@@ -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<number, string>, label: unknown): number | null {
const hit = Object.entries(labels).find(([, name]) => name === label);
return hit ? Number(hit[0]) : null;
}
// ─── Emails ────────────────────────────────────────────
const EMAIL_LABELS: Record<number, string> = { [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<number, string> = {
[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);
}