mirror of
https://gitlab.com/TheOneWithTheBraid/dart_pkpass.git
synced 2025-07-06 13:28:48 +00:00
chore: initial commit
Signed-off-by: The one with the braid <the-one@with-the-braid.cf>
This commit is contained in:
commit
cf9609d699
20 changed files with 1483 additions and 0 deletions
30
lib/src/error.dart
Normal file
30
lib/src/error.dart
Normal 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
123
lib/src/file_matcher.dart
Normal 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
325
lib/src/models/pass.dart
Normal 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
110
lib/src/pass_file.dart
Normal 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));
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue