492 lines
14 KiB
Dart
492 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
|
|
|
import 'models.dart';
|
|
import 'secure_box_store.dart';
|
|
|
|
const _serviceUuid = 'f2a00000-8e7a-4f8d-9b1d-7d8e4b7a0001';
|
|
const _commandUuid = 'f2a00001-8e7a-4f8d-9b1d-7d8e4b7a0001';
|
|
const _stateUuid = 'f2a00002-8e7a-4f8d-9b1d-7d8e4b7a0001';
|
|
|
|
class OpenParcelBoxCommand {
|
|
const OpenParcelBoxCommand(this.name, [this.payload = const {}]);
|
|
|
|
final String name;
|
|
final Map<String, Object?> payload;
|
|
|
|
List<int> encode([String? identityKey]) => utf8.encode(
|
|
jsonEncode(<String, Object?>{
|
|
'command': name,
|
|
...payload,
|
|
'identity_key': ?identityKey,
|
|
}),
|
|
);
|
|
}
|
|
|
|
class OpenParcelBoxBleController extends ChangeNotifier {
|
|
OpenParcelBoxBleController({SecureBoxStore? store})
|
|
: store = store ?? SecureBoxStore();
|
|
|
|
final SecureBoxStore store;
|
|
final List<SavedBox> savedBoxes = <SavedBox>[];
|
|
final List<ScanResult> scanResults = <ScanResult>[];
|
|
final List<AccessCode> permanentCodes = <AccessCode>[];
|
|
final List<AccessCode> oneTimeCodes = <AccessCode>[];
|
|
final List<NfcTag> nfcTags = <NfcTag>[];
|
|
final List<OpeningEvent> history = <OpeningEvent>[];
|
|
final List<GuestIdentity> guests = <GuestIdentity>[];
|
|
|
|
SavedBox? currentBox;
|
|
BluetoothDevice? connectedDevice;
|
|
BluetoothCharacteristic? _commandCharacteristic;
|
|
BluetoothCharacteristic? _stateCharacteristic;
|
|
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
|
StreamSubscription<BluetoothConnectionState>? _connectionSubscription;
|
|
bool initialized = false;
|
|
bool isScanning = false;
|
|
bool isBusy = false;
|
|
bool isAuthenticated = false;
|
|
String status = 'disconnected';
|
|
String? errorMessage;
|
|
|
|
bool get hasBox => savedBoxes.isNotEmpty;
|
|
bool get isConnected =>
|
|
connectedDevice != null && _commandCharacteristic != null;
|
|
bool get isAdministrator =>
|
|
currentBox?.role == BoxRole.administrator && isAuthenticated;
|
|
|
|
Future<void> initialize() async {
|
|
try {
|
|
savedBoxes
|
|
..clear()
|
|
..addAll(await store.loadBoxes());
|
|
if (savedBoxes.isNotEmpty) {
|
|
currentBox = savedBoxes.first;
|
|
await connectSavedBox();
|
|
}
|
|
} catch (error) {
|
|
errorMessage = '$error';
|
|
} finally {
|
|
initialized = true;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> startScan() async {
|
|
_setBusy(true);
|
|
try {
|
|
scanResults.clear();
|
|
errorMessage = null;
|
|
status = 'scanning';
|
|
if (!await FlutterBluePlus.isSupported) {
|
|
throw StateError('Bluetooth is not supported on this phone.');
|
|
}
|
|
_scanSubscription ??= FlutterBluePlus.scanResults.listen((results) {
|
|
scanResults
|
|
..clear()
|
|
..addAll(results.where(_looksLikeOpenParcelBox));
|
|
notifyListeners();
|
|
});
|
|
await FlutterBluePlus.adapterState
|
|
.where((state) => state == BluetoothAdapterState.on)
|
|
.first
|
|
.timeout(const Duration(seconds: 8));
|
|
isScanning = true;
|
|
notifyListeners();
|
|
await FlutterBluePlus.startScan(
|
|
withServices: <Guid>[Guid(_serviceUuid)],
|
|
timeout: const Duration(seconds: 8),
|
|
);
|
|
} catch (error) {
|
|
errorMessage = '$error';
|
|
} finally {
|
|
isScanning = false;
|
|
status = isConnected ? 'connected' : 'disconnected';
|
|
_setBusy(false);
|
|
}
|
|
}
|
|
|
|
Future<void> registerAdministrator(ScanResult result, String boxName) async {
|
|
final cleanedName = boxName.trim();
|
|
if (cleanedName.isEmpty) {
|
|
throw ArgumentError('A box identifier is required.');
|
|
}
|
|
_setBusy(true);
|
|
try {
|
|
await _connectDevice(result.device);
|
|
final publicState = await _readState(updateCollections: false);
|
|
if (publicState?['admin_exists'] == true) {
|
|
throw StateError('This box already has an administrator.');
|
|
}
|
|
final key = _generateIdentityKey();
|
|
await _write(
|
|
OpenParcelBoxCommand('provision_admin', <String, Object?>{
|
|
'identity_key': key,
|
|
'name': 'Administrator',
|
|
'box_name': cleanedName,
|
|
}),
|
|
authenticate: false,
|
|
);
|
|
final saved = SavedBox(
|
|
remoteId: result.device.remoteId.str,
|
|
name: cleanedName,
|
|
identityKey: key,
|
|
role: BoxRole.administrator,
|
|
);
|
|
await _upsertSavedBox(saved);
|
|
currentBox = saved;
|
|
isAuthenticated = true;
|
|
await syncClock();
|
|
} finally {
|
|
_setBusy(false);
|
|
}
|
|
}
|
|
|
|
Future<void> importGuestInvitation(String rawPayload) async {
|
|
final decoded = jsonDecode(rawPayload);
|
|
if (decoded is! Map<String, dynamic> ||
|
|
decoded['format'] != 'openparcelbox-invite-v1' ||
|
|
decoded['remote_id'] is! String ||
|
|
decoded['box_name'] is! String ||
|
|
decoded['guest_key'] is! String) {
|
|
throw const FormatException('Invalid OpenParcelBox invitation.');
|
|
}
|
|
final saved = SavedBox(
|
|
remoteId: decoded['remote_id'] as String,
|
|
name: decoded['box_name'] as String,
|
|
identityKey: decoded['guest_key'] as String,
|
|
role: BoxRole.guest,
|
|
);
|
|
await _upsertSavedBox(saved);
|
|
currentBox = saved;
|
|
await connectSavedBox();
|
|
}
|
|
|
|
Future<void> connectSavedBox() async {
|
|
final box = currentBox;
|
|
if (box == null) {
|
|
return;
|
|
}
|
|
_setBusy(true);
|
|
try {
|
|
await _connectDevice(BluetoothDevice.fromId(box.remoteId));
|
|
await _write(const OpenParcelBoxCommand('authenticate'));
|
|
isAuthenticated = true;
|
|
await syncClock();
|
|
} catch (error) {
|
|
isAuthenticated = false;
|
|
errorMessage = '$error';
|
|
status = 'disconnected';
|
|
} finally {
|
|
_setBusy(false);
|
|
}
|
|
}
|
|
|
|
Future<void> _connectDevice(BluetoothDevice device) async {
|
|
status = 'connecting';
|
|
errorMessage = null;
|
|
notifyListeners();
|
|
await device.connect(
|
|
license: License.nonprofit,
|
|
timeout: const Duration(seconds: 12),
|
|
);
|
|
_connectionSubscription?.cancel();
|
|
_connectionSubscription = device.connectionState.listen((state) {
|
|
if (state == BluetoothConnectionState.disconnected) {
|
|
connectedDevice = null;
|
|
_commandCharacteristic = null;
|
|
_stateCharacteristic = null;
|
|
isAuthenticated = false;
|
|
status = 'disconnected';
|
|
notifyListeners();
|
|
}
|
|
});
|
|
final services = await device.discoverServices();
|
|
_commandCharacteristic = _findCharacteristic(services, _commandUuid);
|
|
_stateCharacteristic = _findCharacteristic(services, _stateUuid);
|
|
if (_commandCharacteristic == null || _stateCharacteristic == null) {
|
|
throw StateError('OpenParcelBox BLE characteristics were not found.');
|
|
}
|
|
connectedDevice = device;
|
|
status = 'connected';
|
|
}
|
|
|
|
BluetoothCharacteristic? _findCharacteristic(
|
|
List<BluetoothService> services,
|
|
String uuid,
|
|
) {
|
|
for (final service in services) {
|
|
if (service.uuid.str.toLowerCase() != _serviceUuid) {
|
|
continue;
|
|
}
|
|
for (final characteristic in service.characteristics) {
|
|
if (characteristic.uuid.str.toLowerCase() == uuid) {
|
|
return characteristic;
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool _looksLikeOpenParcelBox(ScanResult result) {
|
|
final name = result.advertisementData.advName.isNotEmpty
|
|
? result.advertisementData.advName
|
|
: result.device.platformName;
|
|
return name.toUpperCase().startsWith('OPB-') ||
|
|
name.toLowerCase().contains('openparcelbox') ||
|
|
result.advertisementData.serviceUuids.any(
|
|
(uuid) => uuid.str.toLowerCase() == _serviceUuid,
|
|
);
|
|
}
|
|
|
|
Future<void> syncClock() async {
|
|
final now = DateTime.now();
|
|
await send(
|
|
OpenParcelBoxCommand('sync_clock', <String, Object?>{
|
|
'unix_ms': now.toUtc().millisecondsSinceEpoch,
|
|
'timezone_offset_minutes': now.timeZoneOffset.inMinutes,
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<void> openLock() => send(const OpenParcelBoxCommand('open_lock'));
|
|
|
|
Future<void> addGeneratedCode(CredentialKind kind) => send(
|
|
OpenParcelBoxCommand('add_code', <String, Object?>{
|
|
'code': _generateCode(),
|
|
'kind': kind == CredentialKind.oneTime ? 'one_time' : 'permanent',
|
|
}),
|
|
);
|
|
|
|
Future<void> removeCode(AccessCode code) => send(
|
|
OpenParcelBoxCommand('remove_code', <String, Object?>{'code': code.code}),
|
|
);
|
|
|
|
Future<void> addNfcTag(String uid, String name) => send(
|
|
OpenParcelBoxCommand('add_nfc_tag', <String, Object?>{
|
|
'uid': uid.trim(),
|
|
'name': name.trim(),
|
|
}),
|
|
);
|
|
|
|
Future<void> startNfcEnrollment(String name) => send(
|
|
OpenParcelBoxCommand('start_nfc_enrollment', <String, Object?>{
|
|
'name': name.trim(),
|
|
}),
|
|
);
|
|
|
|
Future<void> removeNfcTag(NfcTag tag) => send(
|
|
OpenParcelBoxCommand('remove_nfc_tag', <String, Object?>{'uid': tag.uid}),
|
|
);
|
|
|
|
Future<String> addGuest(String name) async {
|
|
final key = _generateIdentityKey();
|
|
await send(
|
|
OpenParcelBoxCommand('add_guest', <String, Object?>{
|
|
'name': name.trim(),
|
|
'guest_key': key,
|
|
}),
|
|
);
|
|
return key;
|
|
}
|
|
|
|
Future<void> removeGuest(GuestIdentity guest) => send(
|
|
OpenParcelBoxCommand('remove_guest', <String, Object?>{
|
|
'guest_key': guest.key,
|
|
}),
|
|
);
|
|
|
|
String invitationPayload(GuestIdentity guest) => jsonEncode(<String, Object?>{
|
|
'format': 'openparcelbox-invite-v1',
|
|
'remote_id': currentBox!.remoteId,
|
|
'box_name': currentBox!.name,
|
|
'guest_name': guest.name,
|
|
'guest_key': guest.key,
|
|
});
|
|
|
|
Future<void> factoryReset() async {
|
|
await send(const OpenParcelBoxCommand('factory_reset'), refresh: false);
|
|
await forgetCurrentBox();
|
|
}
|
|
|
|
Future<void> forgetCurrentBox() async {
|
|
final box = currentBox;
|
|
if (box == null) {
|
|
return;
|
|
}
|
|
savedBoxes.removeWhere((item) => item.remoteId == box.remoteId);
|
|
await store.saveBoxes(savedBoxes);
|
|
await connectedDevice?.disconnect();
|
|
currentBox = savedBoxes.firstOrNull;
|
|
_clearState();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> replaceSavedBoxes(List<SavedBox> boxes) async {
|
|
savedBoxes
|
|
..clear()
|
|
..addAll(boxes);
|
|
await store.saveBoxes(savedBoxes);
|
|
currentBox = savedBoxes.firstOrNull;
|
|
notifyListeners();
|
|
if (currentBox != null) {
|
|
await connectSavedBox();
|
|
}
|
|
}
|
|
|
|
Future<void> send(OpenParcelBoxCommand command, {bool refresh = true}) async {
|
|
_setBusy(true);
|
|
try {
|
|
await _write(command);
|
|
if (refresh) {
|
|
await _readState();
|
|
}
|
|
} catch (error) {
|
|
errorMessage = '$error';
|
|
rethrow;
|
|
} finally {
|
|
_setBusy(false);
|
|
}
|
|
}
|
|
|
|
Future<void> _write(
|
|
OpenParcelBoxCommand command, {
|
|
bool authenticate = true,
|
|
}) async {
|
|
final characteristic = _commandCharacteristic;
|
|
if (characteristic == null) {
|
|
throw StateError('The box is not connected.');
|
|
}
|
|
final key = authenticate ? currentBox?.identityKey : null;
|
|
if (authenticate && key == null) {
|
|
throw StateError('No secure identity is available.');
|
|
}
|
|
await characteristic.write(command.encode(key), withoutResponse: false);
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> _readState({
|
|
bool updateCollections = true,
|
|
}) async {
|
|
final raw = await _stateCharacteristic?.read();
|
|
if (raw == null) {
|
|
return null;
|
|
}
|
|
final decoded = jsonDecode(utf8.decode(raw));
|
|
if (decoded is! Map<String, dynamic>) {
|
|
return null;
|
|
}
|
|
if (updateCollections) {
|
|
_applyState(decoded);
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
void _applyState(Map<String, dynamic> state) {
|
|
permanentCodes.clear();
|
|
oneTimeCodes.clear();
|
|
nfcTags.clear();
|
|
history.clear();
|
|
guests.clear();
|
|
for (final item in (state['codes'] as List<dynamic>? ?? const [])) {
|
|
if (item is! Map<String, dynamic> ||
|
|
item['code'] is! String ||
|
|
item['kind'] is! String) {
|
|
continue;
|
|
}
|
|
final kind = item['kind'] == 'one_time'
|
|
? CredentialKind.oneTime
|
|
: CredentialKind.permanent;
|
|
final code = AccessCode(
|
|
id: 'slot-${item['slot']}',
|
|
code: item['code'] as String,
|
|
kind: kind,
|
|
);
|
|
(kind == CredentialKind.oneTime ? oneTimeCodes : permanentCodes).add(
|
|
code,
|
|
);
|
|
}
|
|
for (final item in (state['nfc_tags'] as List<dynamic>? ?? const [])) {
|
|
if (item is Map<String, dynamic> && item['uid'] is String) {
|
|
nfcTags.add(
|
|
NfcTag(
|
|
id: 'slot-${item['slot']}',
|
|
uid: item['uid'] as String,
|
|
name: item['name'] as String? ?? '',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
for (final item in (state['history'] as List<dynamic>? ?? const [])) {
|
|
if (item is Map<String, dynamic> && item['unix_ms'] is int) {
|
|
history.add(
|
|
OpeningEvent(
|
|
date: DateTime.fromMillisecondsSinceEpoch(
|
|
item['unix_ms'] as int,
|
|
isUtc: true,
|
|
).toLocal(),
|
|
kind: item['kind'] as int? ?? 0,
|
|
actor: item['actor'] as String? ?? '',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
for (final item in (state['guests'] as List<dynamic>? ?? const [])) {
|
|
if (item is Map<String, dynamic> &&
|
|
item['name'] is String &&
|
|
item['key'] is String) {
|
|
guests.add(
|
|
GuestIdentity(
|
|
name: item['name'] as String,
|
|
key: item['key'] as String,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _upsertSavedBox(SavedBox box) async {
|
|
savedBoxes.removeWhere((item) => item.remoteId == box.remoteId);
|
|
savedBoxes.insert(0, box);
|
|
await store.saveBoxes(savedBoxes);
|
|
}
|
|
|
|
void _clearState() {
|
|
connectedDevice = null;
|
|
_commandCharacteristic = null;
|
|
_stateCharacteristic = null;
|
|
isAuthenticated = false;
|
|
permanentCodes.clear();
|
|
oneTimeCodes.clear();
|
|
nfcTags.clear();
|
|
history.clear();
|
|
guests.clear();
|
|
status = 'disconnected';
|
|
}
|
|
|
|
String _generateCode() =>
|
|
List<int>.generate(6, (_) => Random.secure().nextInt(10)).join();
|
|
|
|
String _generateIdentityKey() =>
|
|
List<int>.generate(16, (_) => Random.secure().nextInt(256))
|
|
.map((value) => value.toRadixString(16).padLeft(2, '0'))
|
|
.join()
|
|
.toUpperCase();
|
|
|
|
void _setBusy(bool value) {
|
|
isBusy = value;
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scanSubscription?.cancel();
|
|
_connectionSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|