chore: initial commit

Signed-off-by: The one with the braid <the-one@with-the-braid.cf>
This commit is contained in:
The one with the braid 2023-08-26 23:05:51 +02:00
commit cf9609d699
20 changed files with 1483 additions and 0 deletions

8
lib/pkpass.dart Normal file
View file

@ -0,0 +1,8 @@
/// Support for doing something awesome.
///
/// More dartdocs go here.
library pkpass;
export 'src/pass_file.dart';
export 'src/error.dart';
export 'src/models/pass.dart';

30
lib/src/error.dart Normal file
View file

@ -0,0 +1,30 @@
abstract class PKPassError extends Error {
final String message;
PKPassError({required this.message});
@override
String toString() => 'PKPassError: $message';
}
class InvalidEncodingError extends PKPassError {
InvalidEncodingError() : super(message: 'Input not in ZIP format.');
}
class ManifestNotFoundError extends PKPassError {
ManifestNotFoundError()
: super(message: 'No manifest.json found in PKPass archive.');
}
class ManifestChecksumError extends PKPassError {
final String expected;
final String actual;
ManifestChecksumError({
required this.expected,
required this.actual,
}) : super(
message:
'Manifest sha1 checksum missmatch, expected $expected, actual $actual',
);
}

123
lib/src/file_matcher.dart Normal file
View file

@ -0,0 +1,123 @@
import 'dart:typed_data';
import 'package:intl/locale.dart';
abstract class FileMatcher {
const FileMatcher._();
static String? matchFile({
required List<String> files,
required String name,
String extension = 'png',
int scale = 1,
Locale? locale,
}) {
final localized = matchLocale(files: files, name: 'logo', extension: 'png');
final scaled = matchScale(files: localized, name: 'logo', extension: 'png');
final file = files.singleWhere((element) => element == scaled);
return file;
}
static List<String> matchLocale({
required List<String> files,
Locale? locale,
required String name,
required String extension,
}) {
files.sort();
files = files.reversed.toList();
List<RegExp> expressions = <RegExp>[];
expressions.add(
RegExp(
'^$name(@\\d+x)?\\.$extension\$',
unicode: true,
caseSensitive: false,
),
);
if (locale != null) {
final language = locale.languageCode;
expressions.add(
RegExp(
'^$language(-[a-z]+)?\\.lproj/$name(@\\d+x)?\\.$extension\$',
unicode: true,
caseSensitive: false,
),
);
final region = locale.countryCode;
if (region != null) {
expressions.add(
RegExp(
'^$language-$region\\.lproj/$name(@\\d+x)?\\.$extension\$',
unicode: true,
caseSensitive: false,
),
);
}
}
for (final regex in expressions.reversed) {
final matches = <String>[];
for (final file in files) {
final match = regex.stringMatch(file);
if (match != null) matches.add(match);
}
if (matches.isNotEmpty) return matches;
}
return [];
}
static String matchScale({
required List<String> files,
int scale = 1,
required String name,
required String extension,
}) {
files.sort();
files = files.reversed.toList();
final regex = RegExp(
'$name(@(\\d+)x)?\\.$extension\$',
unicode: true,
caseSensitive: false,
);
int closestScale = 0;
final matches = <String>[];
for (final file in files) {
final match = regex.firstMatch(file);
if (match != null) {
String? group = match[2];
if (group == null) {
if (scale == 1) return match.input;
group = '1';
}
final matchedScale = int.parse(group);
if (matchedScale == scale) return match.input;
if (matchedScale < scale) {
if (closestScale < matchedScale) closestScale = matchedScale;
} else if (closestScale < matchedScale) {
closestScale = matchedScale;
}
matches.add(match.input);
}
}
final scaledMatches = matches
.where(
(element) => RegExp(
'$name(@${closestScale}x)?\\.$extension\$',
unicode: true,
caseSensitive: false,
).hasMatch(element),
)
.toList();
scaledMatches.sort();
return scaledMatches.last;
}
}

325
lib/src/models/pass.dart Normal file
View file

@ -0,0 +1,325 @@
class PassMetadata {
final String? description;
final int formatVersion;
final String? organizationName;
final String? passTypeIdentifier;
final String? serialNumber;
final String? teamIdentifier;
final bool? suppressStripShine;
final EventTicket? eventTicket;
final Barcode? barcode;
final String? foregroundColor;
final List<Location>? locations;
const PassMetadata({
required this.description,
required this.formatVersion,
required this.organizationName,
required this.passTypeIdentifier,
required this.serialNumber,
required this.teamIdentifier,
required this.suppressStripShine,
required this.eventTicket,
required this.barcode,
required this.foregroundColor,
required this.locations,
});
factory PassMetadata.fromJson(Map<String, Object?> json) => PassMetadata(
description: json['description'] as String?,
formatVersion: json['formatVersion'] as int,
organizationName: json['organizationName'] as String?,
passTypeIdentifier: json['passTypeIdentifier'] as String?,
serialNumber: json['serialNumber'] as String?,
teamIdentifier: json['teamIdentifier'] as String?,
suppressStripShine: json['suppressStripShine'] as bool?,
eventTicket: json['ventTicket'] == null
? null
: EventTicket.fromJson(
json['eventTicket'] as Map<String, Object?>? ?? {},
),
barcode: json['barcode'] == null
? null
: Barcode.fromJson(json['barcode'] as Map<String, Object?>? ?? {}),
foregroundColor: json['foregroundColor'] as String?,
locations: (json['locations'] as List)
.map((i) => Location.fromJson(i))
.toList(),
);
Map<String, Object?> toJson() => {
'description': description,
'formatVersion': formatVersion,
'organizationName': organizationName,
'passTypeIdentifier': passTypeIdentifier,
'serialNumber': serialNumber,
'teamIdentifier': teamIdentifier,
'suppressStripShine': suppressStripShine,
'eventTicket': eventTicket?.toJson(),
'barcode': barcode?.toJson(),
'foregroundColor': foregroundColor,
'locations': locations?.map((i) => i.toJson()).toList(),
};
}
class EventTicket {
final List<HeaderField> headerFields;
final List<PrimaryField> primaryFields;
final List<SecondaryField> secondaryFields;
final List<BackField> backFields;
final List<AuxiliaryField> auxiliaryFields;
const EventTicket({
required this.headerFields,
required this.primaryFields,
required this.secondaryFields,
required this.backFields,
required this.auxiliaryFields,
});
factory EventTicket.fromJson(Map<String, Object?> json) => EventTicket(
headerFields: (json['headerFields'] as List)
.map((i) => HeaderField.fromJson(i))
.toList(),
primaryFields: (json['primaryFields'] as List)
.map((i) => PrimaryField.fromJson(i))
.toList(),
secondaryFields: (json['secondaryFields'] as List)
.map((i) => SecondaryField.fromJson(i))
.toList(),
backFields: (json['backFields'] as List)
.map((i) => BackField.fromJson(i))
.toList(),
auxiliaryFields: (json['auxiliaryFields'] as List)
.map((i) => AuxiliaryField.fromJson(i))
.toList(),
);
Map<String, Object?> toJson() => {
'headerFields': headerFields.map((i) => i.toJson()).toList(),
'primaryFields': primaryFields.map((i) => i.toJson()).toList(),
'secondaryFields': secondaryFields.map((i) => i.toJson()).toList(),
'backFields': backFields.map((i) => i.toJson()).toList(),
'auxiliaryFields': auxiliaryFields.map((i) => i.toJson()).toList(),
};
}
class HeaderField {
final String? key;
final String? value;
final String? label;
final String? changeMessage;
final String? textAlignment;
const HeaderField({
required this.key,
required this.value,
required this.label,
required this.changeMessage,
required this.textAlignment,
});
factory HeaderField.fromJson(Map<String, Object?> json) => HeaderField(
key: json['key'] as String?,
value: json['value'] as String?,
label: json['label'] as String?,
changeMessage: json['changeMessage'] as String?,
textAlignment: json['textAlignment'] as String?,
);
Map<String, Object?> toJson() => {
'key': key,
'value': value,
'label': label,
'changeMessage': changeMessage,
'textAlignment': textAlignment,
};
}
class PrimaryField {
final String? key;
final String? value;
final String? label;
final String? changeMessage;
final String? textAlignment;
const PrimaryField({
required this.key,
required this.value,
required this.label,
required this.changeMessage,
required this.textAlignment,
});
factory PrimaryField.fromJson(Map<String, Object?> json) => PrimaryField(
key: json['key'] as String?,
value: json['value'] as String?,
label: json['label'] as String?,
changeMessage: json['changeMessage'] as String?,
textAlignment: json['textAlignment'] as String?,
);
Map<String, Object?> toJson() => {
'key': key,
'value': value,
'label': label,
'changeMessage': changeMessage,
'textAlignment': textAlignment,
};
}
class SecondaryField {
final String? key;
final String? value;
final String? label;
final String? changeMessage;
final String? textAlignment;
const SecondaryField({
required this.key,
required this.value,
required this.label,
required this.changeMessage,
required this.textAlignment,
});
factory SecondaryField.fromJson(Map<String, Object?> json) => SecondaryField(
key: json['key'] as String?,
value: json['value'] as String?,
label: json['label'] as String?,
changeMessage: json['changeMessage'] as String?,
textAlignment: json['textAlignment'] as String?,
);
Map<String, Object?> toJson() => {
'key': key,
'value': value,
'label': label,
'changeMessage': changeMessage,
'textAlignment': textAlignment,
};
}
class BackField {
final String? key;
final String? value;
final String? label;
final String? changeMessage;
final String? textAlignment;
const BackField({
required this.key,
required this.value,
required this.label,
required this.changeMessage,
required this.textAlignment,
});
factory BackField.fromJson(Map<String, Object?> json) => BackField(
key: json['key'] as String?,
value: json['value'] as String?,
label: json['label'] as String?,
changeMessage: json['changeMessage'] as String?,
textAlignment: json['textAlignment'] as String?,
);
Map<String, Object?> toJson() => {
'key': key,
'value': value,
'label': label,
'changeMessage': changeMessage,
'textAlignment': textAlignment,
};
}
class AuxiliaryField {
final String? key;
final String? value;
final String? label;
final String? changeMessage;
final String? textAlignment;
const AuxiliaryField({
required this.key,
required this.value,
required this.label,
required this.changeMessage,
required this.textAlignment,
});
factory AuxiliaryField.fromJson(Map<String, Object?> json) => AuxiliaryField(
key: json['key'] as String?,
value: json['value'] as String?,
label: json['label'] as String?,
changeMessage: json['changeMessage'] as String?,
textAlignment: json['textAlignment'] as String?,
);
Map<String, Object?> toJson() => {
'key': key,
'value': value,
'label': label,
'changeMessage': changeMessage,
'textAlignment': textAlignment,
};
}
class Barcode {
final String? format;
final String? message;
final String? messageEncoding;
final String? altText;
const Barcode({
required this.format,
required this.message,
required this.messageEncoding,
required this.altText,
});
factory Barcode.fromJson(Map<String, Object?> json) => Barcode(
format: json['format'] as String?,
message: json['message'] as String?,
messageEncoding: json['messageEncoding'] as String?,
altText: json['altText'] as String?,
);
Map<String, Object?> toJson() => {
'format': format,
'message': message,
'messageEncoding': messageEncoding,
'altText': altText,
};
}
class Location {
final double? latitude;
final double? longitude;
final double? altitude;
final double? distance;
final String? relevantText;
const Location({
required this.latitude,
required this.longitude,
required this.altitude,
required this.distance,
required this.relevantText,
});
factory Location.fromJson(Map<String, Object?> json) => Location(
latitude: json['latitude'] as double?,
longitude: json['longitude'] as double?,
altitude: json['altitude'] as double?,
distance: json['distance'] as double?,
relevantText: json['relevantText'] as String?,
);
Map<String, Object?> toJson() => {
'latitude': latitude,
'longitude': longitude,
'altitude': altitude,
'distance': distance,
'relevantText': relevantText,
};
}

110
lib/src/pass_file.dart Normal file
View file

@ -0,0 +1,110 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:crypto/crypto.dart';
import 'package:intl/locale.dart';
import 'package:pkpass/pkpass.dart';
import 'package:pkpass/src/file_matcher.dart';
final _utf8codec = Utf8Codec();
final _jsonCodec = JsonCodec();
class PassFile {
PassFile(this.metadata, this._folder);
static Future<PassFile> parse(Uint8List pass) async {
final codec = ZipDecoder();
Archive archive;
try {
archive = codec.decodeBytes(pass);
} catch (e) {
throw InvalidEncodingError();
}
Map<String, String> manifest;
try {
final file = archive.files
.singleWhere((element) => element.name == 'manifest.json');
manifest = (_jsonCodec.decode(
_utf8codec.decode(
file.rawContent?.toUint8List() ?? (file.content as Uint8List),
),
) as Map)
.cast<String, String>();
} catch (e) {
throw ManifestNotFoundError();
}
final folder = <ArchiveFile>[];
await Future.wait(
manifest.entries.map(
(manifestEntry) async {
final file = archive.files
.singleWhere((element) => element.name == manifestEntry.key);
final content =
file.rawContent?.toUint8List() ?? file.content as Uint8List;
String hash = sha1.convert(content).toString();
final checksum = manifestEntry.value;
if (hash != checksum) {
throw ManifestChecksumError(expected: checksum, actual: hash);
}
folder.add(file);
},
),
);
final passFile =
archive.singleWhere((element) => element.name == 'pass.json');
final PassMetadata metadata = PassMetadata.fromJson(
_jsonCodec.decode(passFile.stringContent) as Map<String, Object?>,
);
return PassFile(metadata, folder);
}
final PassMetadata metadata;
final List<ArchiveFile> _folder;
Uint8List? _matchUtf8List({
required String name,
required Locale? locale,
required int scale,
}) {
final files = _folder.map((e) => e.name).toList();
final path = FileMatcher.matchFile(
files: files, name: name, scale: scale, locale: locale);
if (path == null) return null;
final file = _folder.singleWhere((element) => element.name == path);
final content = file.rawContent?.toUint8List() ?? file.content as Uint8List;
return content;
}
Uint8List? getBackground({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'background', locale: locale, scale: scale);
Uint8List? getFooter({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'footer', locale: locale, scale: scale);
Uint8List? getIcon({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'icon', locale: locale, scale: scale);
Uint8List? getLogo({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'logo', locale: locale, scale: scale);
Uint8List? getStrip({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'strip', locale: locale, scale: scale);
Uint8List? getThumbnail({Locale? locale, int scale = 1}) =>
_matchUtf8List(name: 'thumbnail', locale: locale, scale: scale);
}
extension on ArchiveFile {
String get stringContent =>
_utf8codec.decode(rawContent?.toUint8List() ?? (content as Uint8List));
}