From 8149b9f62c4f5f044278a8cc084771dbca29a788 Mon Sep 17 00:00:00 2001 From: Olivier Dolbeau Date: Sat, 3 Oct 2026 14:28:48 +0200 Subject: [PATCH] feat: Android dialect Reads and writes the X-ANDROID-CUSTOM rows of the Android contacts app: relations, wedding anniversaries and default nicknames. The Android type of a relation is kept in X-ANDROID-TYPE. Dates without a year are written to vCard 3.0 as Android does, as Apple's already were: the parsing of their JSPROP moves to the shared helpers. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +- src/VCard/Dialect/Android.php | 206 +++++++++++++++++++++++ src/VCard/Dialect/Apple.php | 26 +-- src/VCard/Dialect/Internal/VCardEdit.php | 35 ++++ tests/VCard/Dialect/AndroidTest.php | 114 +++++++++++++ 5 files changed, 363 insertions(+), 24 deletions(-) create mode 100644 src/VCard/Dialect/Android.php create mode 100644 tests/VCard/Dialect/AndroidTest.php diff --git a/README.md b/README.md index 0a6aaf4..76dbfc6 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,12 @@ vendor properties are kept verbatim in `vCardProps`. | Dialect | Reads and writes | |---------|------------------------------------------------------------------------------------------------------| | `Apple` | The properties Apple's Address Book introduced, which other address books, such as Google Contacts, also export: `X-ABRELATEDNAMES`, `X-ABDATE`, `X-ABADR`, `X-SOCIALPROFILE`, `X-ABShowAs`, `X-ADDRESSBOOKSERVER-KIND`/`-MEMBER`, built-in labels such as `_$!!$_`, and dates of year 1604, Apple's "no year" | +| `Android` | The Android contacts app: relations, wedding anniversaries and nicknames in `X-ANDROID-CUSTOM`, and dates without a year in vCard 3.0 | | `LegacyMessaging` | The instant messaging properties written before IMPP: `X-AIM`, `X-ICQ`, `X-JABBER`, `X-MSN`, `X-YAHOO`, `X-SKYPE`, `X-QQ`, `X-GOOGLE-TALK`… (Android, Google, Evolution, KDE, Apple) | -Pass the same list to both: the encoder applies dialects in reverse order, so that `[new Apple(), new LegacyMessaging()]` -works both ways. +To read, pass every dialect the vCards may use. To write, pass those of the address book the vCard is for: Apple and +Android write relations and dates differently. The encoder applies dialects in reverse order, so that +`[new Apple(), new LegacyMessaging()]` works both ways. ### Localizations diff --git a/src/VCard/Dialect/Android.php b/src/VCard/Dialect/Android.php new file mode 100644 index 0000000..2c16459 --- /dev/null +++ b/src/VCard/Dialect/Android.php @@ -0,0 +1,206 @@ + 'agent', 2 => 'sibling', 3 => 'child', 5 => 'parent', 6 => 'friend', + 8 => 'parent', 9 => 'parent', 12 => 'kin', 13 => 'sibling', 14 => 'spouse', + ]; + + /** RELATED types and the Android relation type written for them. */ + private const array RELATION_TYPES = ['agent' => 1, 'child' => 3, 'friend' => 6, 'parent' => 9, 'kin' => 12, 'spouse' => 14]; + + /** The type of a custom row, which its label names. */ + private const int CUSTOM = 0; + + /** The type of wedding anniversaries and default nicknames. */ + private const int DEFAULT = 1; + + public function read(VCard $vCard): array + { + foreach (VCardEdit::properties($vCard) as $property) { + if ('X-ANDROID-CUSTOM' !== $property->name) { + continue; + } + + $row = $this->row($property); + $type = $row[2] ?? ''; + $params = VCardEdit::parameters($property, ['CHARSET', 'ENCODING']); + match (true) { + self::RELATION === $row[0] && '' !== ($row[1] ?? '') => $this->readRelation($vCard, $property, $row[1], $type, $row[3] ?? '', $params), + self::EVENT === $row[0] && (string) self::DEFAULT === $type && '' !== ($row[1] ?? '') => VCardEdit::replace($vCard, $property, 'ANNIVERSARY', $row[1], $params), + self::NICKNAME === $row[0] && (string) self::DEFAULT === $type && '' !== ($row[1] ?? '') => VCardEdit::replace($vCard, $property, 'NICKNAME', $row[1], $params), + default => null, + }; + } + + return []; + } + + public function write(VCard $vCard): array + { + foreach (VCardEdit::properties($vCard) as $property) { + match ($property->name) { + 'RELATED' => $this->writeRelation($vCard, $property), + 'ANNIVERSARY' => $this->writeRow($vCard, $property, [self::EVENT, $this->date((string) $property), (string) self::DEFAULT], VCardEdit::parameters($property, ['VALUE'])), + 'JSPROP' => $this->writeDatesWithoutYear($vCard, $property), + default => null, + }; + } + + return []; + } + + /** + * @param array> $params + */ + private function readRelation(VCard $vCard, Property $property, string $name, string $type, string $label, array $params): void + { + $relation = ctype_digit($type) ? (self::RELATIONS[(int) $type] ?? null) : null; + if ((string) self::CUSTOM === $type && \in_array(strtolower($label), Registry::RELATION_TYPES, true)) { + $relation = strtolower($label); + } + + $params['VALUE'] = ['text']; + if (null !== $relation) { + $params['TYPE'] = [$relation]; + } + + if ('' !== $type) { + $params['X-ANDROID-TYPE'] = [$type]; + } + + if ('' !== $label) { + $params['X-ANDROID-LABEL'] = [$label]; + } + + VCardEdit::replace($vCard, $property, 'RELATED', $name, $params); + } + + /** + * A related entity, as a relation row: of the type it was read with, or of the type of + * its first RELATED type, or a custom one named after it. + */ + private function writeRelation(VCard $vCard, Property $property): void + { + $types = array_map(strtolower(...), VCardEdit::parts($property, 'TYPE')); + $type = VCardEdit::parts($property, 'X-ANDROID-TYPE')[0] ?? null; + $label = VCardEdit::parts($property, 'X-ANDROID-LABEL')[0] ?? null; + if (null === $type) { + $type = (string) (self::RELATION_TYPES[$types[0] ?? ''] ?? self::CUSTOM); + $label = (string) self::CUSTOM === $type ? ($types[0] ?? '') : ''; + } + + $params = VCardEdit::parameters($property, ['VALUE', 'TYPE', 'X-ANDROID-TYPE', 'X-ANDROID-LABEL']); + $this->writeRow($vCard, $property, [self::RELATION, (string) $property, $type, $label ?? ''], $params); + } + + /** + * vCard 3.0 has no dates without a year: the conversion writes them as JSPROP. Android + * writes them as --MM-DD. + */ + private function writeDatesWithoutYear(VCard $vCard, Property $jsProp): void + { + $dates = VCardEdit::datesWithoutYear($jsProp); + if (null === $dates) { + return; + } + + $vCard->remove($jsProp); + foreach ($dates as $key => [$kind, $month, $day]) { + $date = \sprintf('--%02d-%02d', $month, $day); + if ('birth' === $kind) { + $vCard->add('BDAY', $date, ['PROP-ID' => $key]); + } else { + $this->writeRow($vCard, null, [self::EVENT, $date, (string) self::DEFAULT], ['PROP-ID' => [$key]]); + } + } + } + + /** + * A date as Android writes it: YYYY-MM-DD, or --MM-DD without a year. + */ + private function date(string $value): string + { + $value = trim($value); + if (1 === preg_match('/^(\d{4}|-)-?(\d{2})-?(\d{2})$/', $value, $matches)) { + return $matches[1].'-'.$matches[2].'-'.$matches[3]; + } + + return $value; + } + + /** + * Replaces a property, if any, with an X-ANDROID-CUSTOM row, in the same group. Android + * writes rows of 16 columns. + * + * @param list $row + * @param array> $params + */ + private function writeRow(VCard $vCard, ?Property $replaced, array $row, array $params): void + { + if (null !== $replaced) { + $vCard->remove($replaced); + } + + $row = array_pad($row, 16, ''); + // sabre/vobject would escape the semicolons between the columns: write them as is. + $property = new RawProperty($vCard, 'X-ANDROID-CUSTOM', null, $params, $replaced?->group); + $property->setRawMimeDirValue(implode(';', array_map(static fn (string $column): string => strtr($column, ['\\' => '\\\\', ';' => '\;', "\n" => '\n']), $row))); + + $vCard->add($property); + } + + /** + * The columns of a row: its type, then the data columns. + * + * @return non-empty-list + */ + private function row(Property $property): array + { + // sabre/vobject splits quoted-printable values on semicolons, but not the others. + $parts = []; + foreach ($property->getParts() as $part) { + $parts[] = \is_scalar($part) ? (string) $part : ''; + } + + if ([] === $parts) { + return ['']; + } + + return 1 === \count($parts) ? explode(';', $parts[0]) : $parts; + } +} diff --git a/src/VCard/Dialect/Apple.php b/src/VCard/Dialect/Apple.php index 0827b3a..4e90933 100644 --- a/src/VCard/Dialect/Apple.php +++ b/src/VCard/Dialect/Apple.php @@ -198,32 +198,14 @@ private function writeDate(Property $property): array */ private function writeDatesWithoutYear(VCard $vCard, Property $jsProp): void { - $pointer = VCardEdit::parts($jsProp, 'JSPTR')[0] ?? ''; - $value = json_decode((string) $jsProp, true); - if ('anniversaries' === $pointer && \is_array($value)) { - $anniversaries = $value; - } elseif (1 === preg_match('~^anniversaries/([^/]+)$~', $pointer, $matches)) { - $anniversaries = [strtr($matches[1], ['~1' => '/', '~0' => '~']) => $value]; - } else { + $dates = VCardEdit::datesWithoutYear($jsProp); + if (null === $dates) { return; } - $dates = []; - foreach ($anniversaries as $key => $anniversary) { - $date = \is_array($anniversary) ? ($anniversary['date'] ?? null) : null; - $isDateWithoutYear = \is_array($date) && \is_int($date['month'] ?? null) && \is_int($date['day'] ?? null) - && [] === array_diff(array_keys($date), ['@type', 'month', 'day']); - $kind = \is_array($anniversary) ? ($anniversary['kind'] ?? null) : null; - // Anything more, such as a place, needs the JSPROP. - if (!$isDateWithoutYear || !\in_array($kind, ['birth', 'wedding'], true) || [] !== array_diff(array_keys($anniversary), ['@type', 'kind', 'date'])) { - return; - } - - $dates[(string) $key] = [$kind, \sprintf('%s-%02d-%02d', self::NO_YEAR, $date['month'], $date['day'])]; - } - $vCard->remove($jsProp); - foreach ($dates as $key => [$kind, $date]) { + foreach ($dates as $key => [$kind, $month, $day]) { + $date = \sprintf('%s-%02d-%02d', self::NO_YEAR, $month, $day); $params = ['PROP-ID' => $key, 'X-APPLE-OMIT-YEAR' => self::NO_YEAR]; if ('birth' === $kind) { $vCard->add('BDAY', $date, $params); diff --git a/src/VCard/Dialect/Internal/VCardEdit.php b/src/VCard/Dialect/Internal/VCardEdit.php index fa2d848..63651c8 100644 --- a/src/VCard/Dialect/Internal/VCardEdit.php +++ b/src/VCard/Dialect/Internal/VCardEdit.php @@ -78,4 +78,39 @@ public static function properties(VCard $vCard): array { return array_values(array_filter($vCard->children(), static fn (mixed $child): bool => $child instanceof Property)); } + + /** + * The birthdays and wedding anniversaries without a year a JSPROP holds: vCard 3.0 has + * no such dates, so the conversion writes them as JSPROP. Null if it holds anything else, + * such as a place, which needs the JSPROP. + * + * @return array|null Kind, month and day, by key + */ + public static function datesWithoutYear(Property $jsProp): ?array + { + $pointer = self::parts($jsProp, 'JSPTR')[0] ?? ''; + $value = json_decode((string) $jsProp, true); + if ('anniversaries' === $pointer && \is_array($value)) { + $anniversaries = $value; + } elseif (1 === preg_match('~^anniversaries/([^/]+)$~', $pointer, $matches)) { + $anniversaries = [strtr($matches[1], ['~1' => '/', '~0' => '~']) => $value]; + } else { + return null; + } + + $dates = []; + foreach ($anniversaries as $key => $anniversary) { + $date = \is_array($anniversary) ? ($anniversary['date'] ?? null) : null; + $isDateWithoutYear = \is_array($date) && \is_int($date['month'] ?? null) && \is_int($date['day'] ?? null) + && [] === array_diff(array_keys($date), ['@type', 'month', 'day']); + $kind = \is_array($anniversary) ? ($anniversary['kind'] ?? null) : null; + if (!$isDateWithoutYear || !\in_array($kind, ['birth', 'wedding'], true) || [] !== array_diff(array_keys($anniversary), ['@type', 'kind', 'date'])) { + return null; + } + + $dates[(string) $key] = [$kind, $date['month'], $date['day']]; + } + + return $dates; + } } diff --git a/tests/VCard/Dialect/AndroidTest.php b/tests/VCard/Dialect/AndroidTest.php new file mode 100644 index 0000000..9a4c9ea --- /dev/null +++ b/tests/VCard/Dialect/AndroidTest.php @@ -0,0 +1,114 @@ +decode((string) file_get_contents(__DIR__.'/../../Fixtures/CozyVcard/android-full.vcf'))[0]; + $card = $result->value; + + self::assertSame([], array_map(strval(...), $result->issues)); + self::assertEquals(new Relation(['parent'], vCardParams: ['x-android-type' => '8']), $card->relatedTo['Mother'] ?? null); + self::assertEquals(new Relation(['kin'], vCardParams: ['x-android-type' => '12']), $card->relatedTo['Relative'] ?? null); + self::assertEquals(new Relation([], vCardParams: ['x-android-type' => '7']), $card->relatedTo['Manager'] ?? null); + self::assertEquals(new Anniversary(Anniversary::KIND_WEDDING, new PartialDate(2015, 3, 4)), $card->anniversaries['ANNIVERSARY-2'] ?? null); + self::assertContains('Pseudo', array_map(static fn (Nickname $nickname): string => $nickname->name, $card->nicknames)); + + // Other events have no JSContact kind: they stay verbatim. + self::assertCount(2, array_filter($card->vCardProps, static fn (\Rondeto\JSContact\Model\VCardProperty $property): bool => 'x-android-custom' === $property->name)); + } + + public function testAQuotedPrintableRowIsRead(): void + { + $card = new VCardDecoder(dialects: [new Android()])->decode((string) file_get_contents(__DIR__.'/../../Fixtures/CozyVcard/android-quotedprintable.vcf'))[0]->value ?? null; + + self::assertSame(['Cozypouet'], array_keys($card->relatedTo ?? [])); + } + + public function testACustomRelationNamedAfterARelatedTypeHasThatType(): void + { + $card = new VCardDecoder(dialects: [new Android()])->decode("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Test\r\nX-ANDROID-CUSTOM:vnd.android.cursor.item/relation;Ann;0;Colleague;;;;;;;;;;;;\r\nEND:VCARD\r\n")[0]->value ?? null; + + self::assertEquals(new Relation(['colleague'], vCardParams: ['x-android-type' => '0', 'x-android-label' => 'Colleague']), $card?->relatedTo['Ann']); + } + + public function testACardIsWrittenAsAndroidReadsIt(): void + { + $card = new Card( + anniversaries: [ + 'b' => new Anniversary(Anniversary::KIND_BIRTH, new PartialDate(null, 4, 12)), + 'w' => new Anniversary(Anniversary::KIND_WEDDING, new PartialDate(null, 6, 12)), + 'w2' => new Anniversary(Anniversary::KIND_WEDDING, new PartialDate(2010, 6, 12)), + ], + relatedTo: ['Jane' => new Relation(['spouse']), 'Ann' => new Relation(['co-worker']), 'Bob' => new Relation([])], + ); + + $result = new VCardEncoder(dialects: [new Android()])->encode($card, VCardVersion::V30); + + self::assertSame([], array_map(strval(...), $result->issues)); + foreach ([ + 'BDAY;PROP-ID=b:--04-12', + 'X-ANDROID-CUSTOM;PROP-ID=w:vnd.android.cursor.item/contact_event;--06-12;1;;;;;;;;;;;;;', + 'X-ANDROID-CUSTOM;PROP-ID=w2:vnd.android.cursor.item/contact_event;2010-06-12;1;;;;;;;;;;;;;', + 'X-ANDROID-CUSTOM:vnd.android.cursor.item/relation;Jane;14;;;;;;;;;;;;;', + 'X-ANDROID-CUSTOM:vnd.android.cursor.item/relation;Ann;0;co-worker;;;;;;;;;;;;', + 'X-ANDROID-CUSTOM:vnd.android.cursor.item/relation;Bob;0;;;;;;;;;;;;;', + ] as $line) { + self::assertStringContainsString($line."\r\n", str_replace("\r\n ", '', $result->value)); + } + + $again = new VCardDecoder(dialects: [new Android()])->decode($result->value)[0]->value ?? null; + self::assertNotNull($again); + self::assertEquals($card->anniversaries, $again->anniversaries); + self::assertSame(['spouse'], $again->relatedTo['Jane']->relation); + self::assertSame(['co-worker'], $again->relatedTo['Ann']->relation); + } + + /** + * @return iterable}> + */ + public static function exports(): iterable + { + foreach (['android.vcf', 'android-full.vcf', 'android-quotedprintable.vcf'] as $file) { + yield $file => [$file, [new Android(), new LegacyMessaging()]]; + } + + foreach (['apple.vcf', 'google-full.vcf', 'ios-full.vcf'] as $file) { + yield $file => [$file, [new Apple(), new LegacyMessaging()]]; + } + } + + /** + * Reading with every dialect, writing with those of the address book the vCard is for. + * + * @param list<\Rondeto\JSContact\VCard\Dialect\Dialect> $dialects + */ + #[DataProvider('exports')] + public function testExportsSurviveARoundTripThroughTheirDialects(string $file, array $dialects): void + { + $decoder = new VCardDecoder(dialects: [new Apple(), new Android(), new LegacyMessaging()]); + foreach ($decoder->decode((string) file_get_contents(__DIR__.'/../../Fixtures/CozyVcard/'.$file)) as $result) { + $vCard = new VCardEncoder(validate: false, dialects: $dialects)->encode($result->value, VCardVersion::V30)->value; + + self::assertEquals($result->value, $decoder->decode($vCard)[0]->value ?? null); + } + } +}