diff --git a/lib/ndef.dart b/lib/ndef.dart index a894629..7dcb3fb 100644 --- a/lib/ndef.dart +++ b/lib/ndef.dart @@ -20,6 +20,7 @@ export 'records/external/android_application.dart'; export 'records/external/external.dart'; export 'records/media/bluetooth.dart'; export 'records/media/mime.dart'; +export 'records/media/vcard.dart'; export 'records/media/wifi.dart'; export 'records/well_known/device_info.dart'; export 'records/well_known/handover.dart'; diff --git a/lib/record.dart b/lib/record.dart index 93affc3..7294f58 100644 --- a/lib/record.dart +++ b/lib/record.dart @@ -278,6 +278,8 @@ class NDEFRecord { record = BluetoothLowEnergyRecord(); } else if (classType == WifiRecord.classType) { record = WifiRecord(); + } else if (classType == VCardRecord.classType) { + record = VCardRecord(); } else { record = MimeRecord(); } diff --git a/lib/records/media/vcard.dart b/lib/records/media/vcard.dart new file mode 100644 index 0000000..aeaeef4 --- /dev/null +++ b/lib/records/media/vcard.dart @@ -0,0 +1,433 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:ndef/records/media/mime.dart'; + +/// Escapes special characters in a vCard value. +String _escapeValue(String value) { + return value + .replaceAll(r'\', r'\\') + .replaceAll(',', r'\,') + .replaceAll(';', r'\;') + .replaceAll('\n', r'\n'); +} + +/// Unescapes special characters in a vCard value. +String _unescapeValue(String value) { + return value + .replaceAll(r'\n', '\n') + .replaceAll(r'\,', ',') + .replaceAll(r'\;', ';') + .replaceAll(r'\\', r'\'); +} + +/// Extracts the TYPE parameter value from a parameter string. +/// +/// Handles both vCard 3.0/4.0 format ("TYPE=CELL") and vCard 2.1 +/// format ("CELL") where the type is specified without a TYPE= prefix. +String? _extractTypeParam(String parameters) { + if (parameters.isEmpty) return null; + // Handle "TYPE=CELL" and "type=cell" formats (vCard 3.0/4.0) + final match = + RegExp(r'TYPE=([^;,]+)', caseSensitive: false).firstMatch(parameters); + if (match != null) return match.group(1); + // Handle vCard 2.1 format where type is bare: "TEL;CELL:number" + // The parameter is the type itself (e.g., "CELL", "HOME", "WORK") + final bareParam = parameters.split(';').first.trim().toUpperCase(); + const knownTypes = { + 'CELL', 'HOME', 'WORK', 'FAX', 'PAGER', 'VOICE', 'MSG', + 'PREF', 'BBS', 'MODEM', 'CAR', 'ISDN', 'VIDEO', + // Address types + 'DOM', 'INTL', 'POSTAL', 'PARCEL', + }; + if (knownTypes.contains(bareParam)) return bareParam; + return null; +} + +/// A NDEF record for vCard contact sharing (RFC 6350). +/// +/// This record type uses the MIME type "text/vcard" and contains +/// contact information in vCard format for easy sharing through NFC. +/// +/// Supports vCard 2.1, 3.0, and 4.0 formats. When creating new records, +/// defaults to vCard 3.0. +/// +/// Example: +/// ```dart +/// var vcardRecord = VCardRecord( +/// formattedName: 'John Doe', +/// name: VCardName( +/// familyName: 'Doe', +/// givenName: 'John', +/// ), +/// emails: ['john@example.com'], +/// phones: [VCardPhone(number: '+1-555-0100', type: 'CELL')], +/// organization: 'ACME Corp', +/// ); +/// ``` +class VCardRecord extends MimeRecord { + /// The MIME type for vCard records. + static const String classType = "text/vcard"; + + /// vCard version (default: "3.0") + String version; + + /// Formatted name (FN property, required in vCard 3.0+) + String? formattedName; + + /// Structured name (N property) + VCardName? name; + + /// Email addresses + List emails; + + /// Phone numbers with optional type info + List phones; + + /// Organization name + String? organization; + + /// Job title + String? title; + + /// Postal addresses + List addresses; + + /// URL + String? url; + + /// Note / additional text + String? note; + + @override + String get decodedType => VCardRecord.classType; + + /// Constructs a [VCardRecord] with contact information. + VCardRecord({ + this.version = '3.0', + this.formattedName, + this.name, + List? emails, + List? phones, + this.organization, + this.title, + List? addresses, + this.url, + this.note, + super.id, + }) : emails = emails ?? [], + phones = phones ?? [], + addresses = addresses ?? [], + super(decodedType: VCardRecord.classType); + + @override + String toString() { + var str = "VCardRecord: "; + if (formattedName != null) str += "name=$formattedName "; + if (emails.isNotEmpty) str += "emails=$emails "; + if (phones.isNotEmpty) str += "phones=$phones "; + if (organization != null) str += "org=$organization "; + return str; + } + + /// Builds a vCard string from the structured properties. + String _buildVCard() { + if (formattedName == null && name == null) { + throw ArgumentError( + 'At least formattedName or name is required for a vCard record', + ); + } + + final lines = []; + lines.add('BEGIN:VCARD'); + lines.add('VERSION:$version'); + + if (formattedName != null) { + lines.add('FN:${_escapeValue(formattedName!)}'); + } + + if (name != null) { + lines.add('N:${name!.encode()}'); + } + + for (var email in emails) { + lines.add('EMAIL:${_escapeValue(email)}'); + } + + for (var phone in phones) { + if (phone.type != null) { + lines.add('TEL;TYPE=${phone.type}:${_escapeValue(phone.number)}'); + } else { + lines.add('TEL:${_escapeValue(phone.number)}'); + } + } + + if (organization != null) { + lines.add('ORG:${_escapeValue(organization!)}'); + } + + if (title != null) { + lines.add('TITLE:${_escapeValue(title!)}'); + } + + for (var addr in addresses) { + lines.add(addr.encode()); + } + + if (url != null) { + lines.add('URL:${_escapeValue(url!)}'); + } + + if (note != null) { + lines.add('NOTE:${_escapeValue(note!)}'); + } + + lines.add('END:VCARD'); + return lines.join('\r\n'); + } + + /// Parses a vCard string into structured properties. + void _parseVCard(String vcardText) { + // Unfold lines (RFC 6350 section 3.2): a line starting with space/tab + // is a continuation of the previous line. + final unfolded = vcardText.replaceAll(RegExp(r'\r?\n[ \t]'), ''); + + final lines = unfolded.split(RegExp(r'\r?\n')); + + // Reset fields + formattedName = null; + name = null; + emails = []; + phones = []; + addresses = []; + organization = null; + title = null; + url = null; + note = null; + + for (var line in lines) { + line = line.trim(); + if (line.isEmpty) continue; + + // Parse property name and value, handling parameters + final colonIndex = line.indexOf(':'); + if (colonIndex < 0) continue; + + final propertyPart = line.substring(0, colonIndex); + final value = line.substring(colonIndex + 1); + + // Extract base property name (before any parameters) + final semicolonIndex = propertyPart.indexOf(';'); + final propertyName = semicolonIndex >= 0 + ? propertyPart.substring(0, semicolonIndex).toUpperCase() + : propertyPart.toUpperCase(); + final parameters = + semicolonIndex >= 0 ? propertyPart.substring(semicolonIndex + 1) : ''; + + switch (propertyName) { + case 'VERSION': + version = value; + break; + case 'FN': + formattedName = _unescapeValue(value); + break; + case 'N': + name = VCardName.decode(value); + break; + case 'EMAIL': + emails.add(_unescapeValue(value)); + break; + case 'TEL': + final type = _extractTypeParam(parameters); + phones.add(VCardPhone(number: _unescapeValue(value), type: type)); + break; + case 'ORG': + organization = _unescapeValue(value); + break; + case 'TITLE': + title = _unescapeValue(value); + break; + case 'ADR': + addresses.add(VCardAddress.decode(value, parameters)); + break; + case 'URL': + url = _unescapeValue(value); + break; + case 'NOTE': + note = _unescapeValue(value); + break; + } + } + } + + @override + Uint8List? get payload { + final vcardString = _buildVCard(); + return Uint8List.fromList(utf8.encode(vcardString)); + } + + @override + set payload(Uint8List? payload) { + if (payload == null || payload.isEmpty) { + throw ArgumentError('Payload cannot be null or empty'); + } + + final vcardString = utf8.decode(payload); + _parseVCard(vcardString); + } +} + +/// Structured name for vCard N property. +/// +/// Components follow the vCard spec order: +/// family;given;additional;prefix;suffix +class VCardName { + String familyName; + String givenName; + String additionalNames; + String honorificPrefixes; + String honorificSuffixes; + + VCardName({ + this.familyName = '', + this.givenName = '', + this.additionalNames = '', + this.honorificPrefixes = '', + this.honorificSuffixes = '', + }); + + /// Escapes a single N-property component (`;` and `\` must be escaped). + static String _escapeComponent(String value) { + return value.replaceAll(r'\', r'\\').replaceAll(';', r'\;'); + } + + /// Unescapes a single N-property component. + static String _unescapeComponent(String value) { + return value.replaceAll(r'\;', ';').replaceAll(r'\\', r'\'); + } + + /// Encodes the name to vCard N property value format. + String encode() { + return '${_escapeComponent(familyName)};${_escapeComponent(givenName)}' + ';${_escapeComponent(additionalNames)}' + ';${_escapeComponent(honorificPrefixes)}' + ';${_escapeComponent(honorificSuffixes)}'; + } + + /// Decodes a vCard N property value string. + /// + /// Splits on unescaped `;` delimiters to correctly handle escaped + /// semicolons within component values. + static VCardName decode(String value) { + final parts = _splitUnescaped(value, ';'); + return VCardName( + familyName: parts.isNotEmpty ? _unescapeComponent(parts[0]) : '', + givenName: parts.length > 1 ? _unescapeComponent(parts[1]) : '', + additionalNames: parts.length > 2 ? _unescapeComponent(parts[2]) : '', + honorificPrefixes: parts.length > 3 ? _unescapeComponent(parts[3]) : '', + honorificSuffixes: parts.length > 4 ? _unescapeComponent(parts[4]) : '', + ); + } + + /// Splits a string on unescaped occurrences of [delimiter]. + static List _splitUnescaped(String value, String delimiter) { + final parts = []; + final buffer = StringBuffer(); + for (var i = 0; i < value.length; i++) { + if (value[i] == delimiter) { + // Check if preceded by an odd number of backslashes (escaped) + var backslashes = 0; + var j = i - 1; + while (j >= 0 && value[j] == '\\') { + backslashes++; + j--; + } + if (backslashes.isOdd) { + buffer.write(value[i]); + } else { + parts.add(buffer.toString()); + buffer.clear(); + } + } else { + buffer.write(value[i]); + } + } + parts.add(buffer.toString()); + return parts; + } + + @override + String toString() => '${givenName.isNotEmpty ? givenName : ''}' + '${givenName.isNotEmpty && familyName.isNotEmpty ? ' ' : ''}' + '${familyName.isNotEmpty ? familyName : ''}'; +} + +/// Phone number with optional type (CELL, WORK, HOME, etc.) +class VCardPhone { + String number; + String? type; + + VCardPhone({required this.number, this.type}); + + @override + String toString() => type != null ? '$type:$number' : number; +} + +/// Postal address for vCard ADR property. +/// +/// Components follow the vCard spec order: +/// PO Box;Extended;Street;City;Region;Postal Code;Country +class VCardAddress { + String? type; + String poBox; + String extended; + String street; + String city; + String region; + String postalCode; + String country; + + VCardAddress({ + this.type, + this.poBox = '', + this.extended = '', + this.street = '', + this.city = '', + this.region = '', + this.postalCode = '', + this.country = '', + }); + + /// Encodes the address to vCard ADR property line. + String encode() { + final value = '$poBox;$extended;$street;$city;$region;$postalCode;$country'; + if (type != null) { + return 'ADR;TYPE=$type:$value'; + } + return 'ADR:$value'; + } + + /// Decodes a vCard ADR property value string. + static VCardAddress decode(String value, String parameters) { + final parts = value.split(';'); + final type = _extractTypeParam(parameters); + return VCardAddress( + type: type, + poBox: parts.isNotEmpty ? parts[0] : '', + extended: parts.length > 1 ? parts[1] : '', + street: parts.length > 2 ? parts[2] : '', + city: parts.length > 3 ? parts[3] : '', + region: parts.length > 4 ? parts[4] : '', + postalCode: parts.length > 5 ? parts[5] : '', + country: parts.length > 6 ? parts[6] : '', + ); + } + + @override + String toString() { + final parts = [street, city, region, postalCode, country] + .where((s) => s.isNotEmpty) + .join(', '); + return type != null ? '$type: $parts' : parts; + } +} diff --git a/test/records/media/vcard_test.dart b/test/records/media/vcard_test.dart new file mode 100644 index 0000000..8555540 --- /dev/null +++ b/test/records/media/vcard_test.dart @@ -0,0 +1,302 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:ndef/ndef.dart'; + +void main() { + test('vcard record construction with basic fields', () { + var record = VCardRecord( + formattedName: 'John Doe', + name: VCardName(familyName: 'Doe', givenName: 'John'), + emails: ['john@example.com'], + phones: [VCardPhone(number: '+1-555-0100', type: 'CELL')], + organization: 'ACME Corp', + title: 'Engineer', + ); + + expect(record.formattedName, equals('John Doe')); + expect(record.name!.familyName, equals('Doe')); + expect(record.name!.givenName, equals('John')); + expect(record.emails.length, equals(1)); + expect(record.phones.length, equals(1)); + expect(record.organization, equals('ACME Corp')); + expect(record.decodedType, equals('text/vcard')); + }); + + test('vcard record requires name or formattedName', () { + var record = VCardRecord(); + expect(() => record.payload, throwsArgumentError); + }); + + test('vcard record payload cannot be null or empty', () { + var record = VCardRecord(); + expect(() => record.payload = null, throwsArgumentError); + expect(() => record.payload = Uint8List(0), throwsArgumentError); + }); + + test('vcard record round-trip encoding', () { + var original = VCardRecord( + formattedName: 'Jane Smith', + name: VCardName(familyName: 'Smith', givenName: 'Jane'), + emails: ['jane@example.com', 'jane.smith@work.com'], + phones: [ + VCardPhone(number: '+1-555-0200', type: 'CELL'), + VCardPhone(number: '+1-555-0201', type: 'WORK'), + ], + organization: 'Tech Inc', + title: 'Manager', + url: 'https://example.com', + note: 'A test contact', + ); + + var encoded = encodeNdefMessage([original]); + var decoded = decodeRawNdefMessage(encoded); + + expect(decoded.length, equals(1)); + expect(decoded[0], isA()); + + var vcard = decoded[0] as VCardRecord; + expect(vcard.formattedName, equals('Jane Smith')); + expect(vcard.name!.familyName, equals('Smith')); + expect(vcard.name!.givenName, equals('Jane')); + expect(vcard.emails.length, equals(2)); + expect(vcard.emails[0], equals('jane@example.com')); + expect(vcard.emails[1], equals('jane.smith@work.com')); + expect(vcard.phones.length, equals(2)); + expect(vcard.phones[0].number, equals('+1-555-0200')); + expect(vcard.phones[0].type, equals('CELL')); + expect(vcard.phones[1].number, equals('+1-555-0201')); + expect(vcard.phones[1].type, equals('WORK')); + expect(vcard.organization, equals('Tech Inc')); + expect(vcard.title, equals('Manager')); + expect(vcard.url, equals('https://example.com')); + expect(vcard.note, equals('A test contact')); + }); + + test('vcard record with address round-trip', () { + var original = VCardRecord( + formattedName: 'Bob Builder', + addresses: [ + VCardAddress( + type: 'WORK', + street: '123 Main St', + city: 'Springfield', + region: 'IL', + postalCode: '62701', + country: 'USA', + ), + ], + ); + + var encoded = encodeNdefMessage([original]); + var decoded = decodeRawNdefMessage(encoded); + var vcard = decoded[0] as VCardRecord; + + expect(vcard.addresses.length, equals(1)); + expect(vcard.addresses[0].type, equals('WORK')); + expect(vcard.addresses[0].street, equals('123 Main St')); + expect(vcard.addresses[0].city, equals('Springfield')); + expect(vcard.addresses[0].region, equals('IL')); + expect(vcard.addresses[0].postalCode, equals('62701')); + expect(vcard.addresses[0].country, equals('USA')); + }); + + test('vcard record decode real vcard payload', () { + // A typical vCard 3.0 as would come from an NFC tag + final vcardString = 'BEGIN:VCARD\r\n' + 'VERSION:3.0\r\n' + 'FN:Max Mustermann\r\n' + 'N:Mustermann;Max;;;\r\n' + 'EMAIL:max@example.de\r\n' + 'TEL;TYPE=CELL:+49-170-1234567\r\n' + 'TEL;TYPE=WORK:+49-69-1234567\r\n' + 'ORG:Beispiel GmbH\r\n' + 'TITLE:Gesch\u00e4ftsf\u00fchrer\r\n' + 'ADR;TYPE=WORK:;;Hauptstra\u00dfe 1;Frankfurt;Hessen;60311;Deutschland\r\n' + 'URL:https://example.de\r\n' + 'NOTE:Testnotiz\r\n' + 'END:VCARD'; + + var record = VCardRecord(); + record.payload = Uint8List.fromList(utf8.encode(vcardString)); + + expect(record.version, equals('3.0')); + expect(record.formattedName, equals('Max Mustermann')); + expect(record.name!.familyName, equals('Mustermann')); + expect(record.name!.givenName, equals('Max')); + expect(record.emails.length, equals(1)); + expect(record.emails[0], equals('max@example.de')); + expect(record.phones.length, equals(2)); + expect(record.phones[0].number, equals('+49-170-1234567')); + expect(record.phones[0].type, equals('CELL')); + expect(record.phones[1].number, equals('+49-69-1234567')); + expect(record.phones[1].type, equals('WORK')); + expect(record.organization, equals('Beispiel GmbH')); + expect(record.title, equals('Gesch\u00e4ftsf\u00fchrer')); + expect(record.addresses.length, equals(1)); + expect(record.addresses[0].street, equals('Hauptstra\u00dfe 1')); + expect(record.addresses[0].city, equals('Frankfurt')); + expect(record.addresses[0].country, equals('Deutschland')); + expect(record.url, equals('https://example.de')); + expect(record.note, equals('Testnotiz')); + }); + + test('vcard record decode vcard 2.1 format with bare type params', () { + final vcardString = 'BEGIN:VCARD\r\n' + 'VERSION:2.1\r\n' + 'FN:Old Format\r\n' + 'N:Format;Old;;;\r\n' + 'TEL;CELL:+1-555-1234\r\n' + 'TEL;WORK:+1-555-5678\r\n' + 'END:VCARD'; + + var record = VCardRecord(); + record.payload = Uint8List.fromList(utf8.encode(vcardString)); + + expect(record.version, equals('2.1')); + expect(record.formattedName, equals('Old Format')); + expect(record.phones.length, equals(2)); + expect(record.phones[0].number, equals('+1-555-1234')); + expect(record.phones[0].type, equals('CELL')); + expect(record.phones[1].number, equals('+1-555-5678')); + expect(record.phones[1].type, equals('WORK')); + }); + + test('vcard record minimal - formattedName only', () { + var record = VCardRecord(formattedName: 'Simple Name'); + + var encoded = encodeNdefMessage([record]); + var decoded = decodeRawNdefMessage(encoded); + var vcard = decoded[0] as VCardRecord; + + expect(vcard.formattedName, equals('Simple Name')); + expect(vcard.emails, isEmpty); + expect(vcard.phones, isEmpty); + }); + + test('vcard record with special characters', () { + var original = VCardRecord( + formattedName: 'M\u00fcller, Hans', + name: VCardName(familyName: 'M\u00fcller', givenName: 'Hans'), + organization: 'Sch\u00f6ne K\u00fcnste GmbH', + ); + + var encoded = encodeNdefMessage([original]); + var decoded = decodeRawNdefMessage(encoded); + var vcard = decoded[0] as VCardRecord; + + expect(vcard.formattedName, equals('M\u00fcller, Hans')); + expect(vcard.name!.familyName, equals('M\u00fcller')); + expect(vcard.organization, equals('Sch\u00f6ne K\u00fcnste GmbH')); + }); + + test('vcard name with semicolons in components round-trips correctly', () { + var original = VCardRecord( + formattedName: 'Smith; Jr., John', + name: VCardName( + familyName: 'Smith; Jr.', + givenName: 'John', + ), + ); + + var encoded = encodeNdefMessage([original]); + var decoded = decodeRawNdefMessage(encoded); + var vcard = decoded[0] as VCardRecord; + + expect(vcard.name!.familyName, equals('Smith; Jr.')); + expect(vcard.name!.givenName, equals('John')); + }); + + test('vcard name structured encoding/decoding', () { + var name = VCardName( + familyName: 'Doe', + givenName: 'John', + additionalNames: 'Philip', + honorificPrefixes: 'Dr.', + honorificSuffixes: 'Jr.', + ); + + expect(name.encode(), equals('Doe;John;Philip;Dr.;Jr.')); + + var decoded = VCardName.decode('Doe;John;Philip;Dr.;Jr.'); + expect(decoded.familyName, equals('Doe')); + expect(decoded.givenName, equals('John')); + expect(decoded.additionalNames, equals('Philip')); + expect(decoded.honorificPrefixes, equals('Dr.')); + expect(decoded.honorificSuffixes, equals('Jr.')); + }); + + test('vcard name toString', () { + expect(VCardName(givenName: 'John', familyName: 'Doe').toString(), + equals('John Doe')); + expect(VCardName(familyName: 'Doe').toString(), equals('Doe')); + expect(VCardName(givenName: 'John').toString(), equals('John')); + }); + + test('vcard phone toString', () { + expect(VCardPhone(number: '+1-555-0100', type: 'CELL').toString(), + equals('CELL:+1-555-0100')); + expect(VCardPhone(number: '+1-555-0100').toString(), equals('+1-555-0100')); + }); + + test('vcard record toString', () { + var record = VCardRecord( + formattedName: 'Test', + emails: ['test@example.com'], + phones: [VCardPhone(number: '123')], + organization: 'Org', + ); + var str = record.toString(); + expect(str, contains('VCardRecord')); + expect(str, contains('Test')); + expect(str, contains('Org')); + }); + + test('vcard line unfolding', () { + // RFC 6350: lines can be folded by inserting CRLF + space/tab + // The CRLF + single whitespace is removed, joining the content directly + final vcardString = 'BEGIN:VCARD\r\n' + 'VERSION:3.0\r\n' + 'FN:Very Long Name That Gets \r\n' + ' Folded Across Lines\r\n' + 'END:VCARD'; + + var record = VCardRecord(); + record.payload = Uint8List.fromList(utf8.encode(vcardString)); + + expect(record.formattedName, + equals('Very Long Name That Gets Folded Across Lines')); + }); + + test('vcard phone without type parameter', () { + final vcardString = 'BEGIN:VCARD\r\n' + 'VERSION:3.0\r\n' + 'FN:Test\r\n' + 'TEL:+1-555-0100\r\n' + 'END:VCARD'; + + var record = VCardRecord(); + record.payload = Uint8List.fromList(utf8.encode(vcardString)); + + expect(record.phones.length, equals(1)); + expect(record.phones[0].number, equals('+1-555-0100')); + expect(record.phones[0].type, isNull); + }); + + test('vcard record in NDEF message with other records', () { + var uriRecord = UriRecord.fromString('https://example.com'); + var vcardRecord = VCardRecord( + formattedName: 'Contact', + emails: ['contact@example.com'], + ); + + var encoded = encodeNdefMessage([uriRecord, vcardRecord]); + var decoded = decodeRawNdefMessage(encoded); + + expect(decoded.length, equals(2)); + expect(decoded[0], isA()); + expect(decoded[1], isA()); + expect((decoded[1] as VCardRecord).formattedName, equals('Contact')); + }); +}