mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
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:
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user