1100 lines
32 KiB
Dart
1100 lines
32 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';
|
|
const _statePageSize = 480;
|
|
|
|
@visibleForTesting
|
|
bool isRecoverableAndroidBondFailure(Object error) {
|
|
if (error is! FlutterBluePlusException) {
|
|
return false;
|
|
}
|
|
|
|
return error.function == 'discoverServices' ||
|
|
error.function == 'setNotifyValue' ||
|
|
error.function == 'readCharacteristic' ||
|
|
error.function == 'writeCharacteristic';
|
|
}
|
|
|
|
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 OpenParcelBoxAlreadyProvisionedException implements Exception {
|
|
const OpenParcelBoxAlreadyProvisionedException();
|
|
|
|
@override
|
|
String toString() => 'This box already has an administrator.';
|
|
}
|
|
|
|
class OpenParcelBoxAuthenticationException implements Exception {
|
|
const OpenParcelBoxAuthenticationException();
|
|
|
|
@override
|
|
String toString() =>
|
|
'The restored application identity is no longer accepted by this box.';
|
|
}
|
|
|
|
class OpenParcelBoxBleController extends ChangeNotifier {
|
|
OpenParcelBoxBleController({
|
|
SecureBoxStore? store,
|
|
this.reconnectOnInitialize = true,
|
|
Random? random,
|
|
}) : store = store ?? SecureBoxStore(),
|
|
_random = random ?? Random.secure();
|
|
|
|
final SecureBoxStore store;
|
|
final Random _random;
|
|
@visibleForTesting
|
|
final bool reconnectOnInitialize;
|
|
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;
|
|
StreamSubscription<List<int>>? _stateSubscription;
|
|
Future<Map<String, dynamic>?>? _stateReadInProgress;
|
|
Future<void>? _savedBoxConnectionInProgress;
|
|
bool _stateRefreshInProgress = false;
|
|
bool _stateRefreshPending = false;
|
|
bool initialized = false;
|
|
bool isScanning = false;
|
|
bool isBusy = false;
|
|
bool isAuthenticated = false;
|
|
bool nfcEnrollmentActive = false;
|
|
String? nfcEnrollmentUid;
|
|
String? nfcEnrollmentError;
|
|
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 _loadCachedStateForCurrentBox();
|
|
}
|
|
} catch (error) {
|
|
errorMessage = '$error';
|
|
} finally {
|
|
initialized = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
if (currentBox != null && reconnectOnInitialize) {
|
|
unawaited(connectSavedBox());
|
|
}
|
|
}
|
|
|
|
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(timeout: const Duration(seconds: 8));
|
|
await FlutterBluePlus.isScanning.where((scanning) => !scanning).first;
|
|
} 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.');
|
|
}
|
|
await _stopScanBeforeConnection();
|
|
_setBusy(true);
|
|
try {
|
|
errorMessage = null;
|
|
final publicState = await _connectForRegistration(result.device);
|
|
if (publicState?['admin_exists'] == true) {
|
|
throw const OpenParcelBoxAlreadyProvisionedException();
|
|
}
|
|
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;
|
|
notifyListeners();
|
|
_scheduleClockSyncAfterRegistration();
|
|
} catch (error, stackTrace) {
|
|
errorMessage = '$error';
|
|
debugPrint(
|
|
'OpenParcelBox administrator registration failed: '
|
|
'$error\n$stackTrace',
|
|
);
|
|
notifyListeners();
|
|
rethrow;
|
|
} finally {
|
|
_setBusy(false);
|
|
}
|
|
}
|
|
|
|
void _scheduleClockSyncAfterRegistration() {
|
|
unawaited(
|
|
Future<void>(() async {
|
|
try {
|
|
await syncClock();
|
|
} catch (error, stackTrace) {
|
|
debugPrint(
|
|
'OpenParcelBox clock synchronization after registration failed: '
|
|
'$error\n$stackTrace',
|
|
);
|
|
errorMessage = null;
|
|
}
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> _connectForRegistration(
|
|
BluetoothDevice device,
|
|
) async {
|
|
await _connectDeviceWithAndroidBondRecovery(device);
|
|
return await _readState(updateCollections: false, paginated: false);
|
|
}
|
|
|
|
Future<void> _stopScanBeforeConnection() async {
|
|
if (FlutterBluePlus.isScanningNow) {
|
|
await FlutterBluePlus.stopScan();
|
|
}
|
|
while (isScanning) {
|
|
await Future<void>.delayed(const Duration(milliseconds: 20));
|
|
}
|
|
}
|
|
|
|
Future<void> _resetAndroidBond(BluetoothDevice device) async {
|
|
connectedDevice = null;
|
|
_commandCharacteristic = null;
|
|
_stateCharacteristic = null;
|
|
isAuthenticated = false;
|
|
|
|
if (device.isConnected) {
|
|
await device.disconnect();
|
|
}
|
|
try {
|
|
await device.removeBond();
|
|
} catch (error) {
|
|
debugPrint('OpenParcelBox Android bond removal reported: $error');
|
|
}
|
|
}
|
|
|
|
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,
|
|
identityName: decoded['guest_name'] as String? ?? '',
|
|
needsRediscovery: true,
|
|
);
|
|
await _upsertSavedBox(saved);
|
|
currentBox = saved;
|
|
await _loadCachedStateForCurrentBox();
|
|
notifyListeners();
|
|
unawaited(connectSavedBox());
|
|
}
|
|
|
|
Future<void> connectSavedBox() async {
|
|
final currentConnection = _savedBoxConnectionInProgress;
|
|
if (currentConnection != null) {
|
|
return currentConnection;
|
|
}
|
|
|
|
final connection = _connectSavedBoxOnce();
|
|
_savedBoxConnectionInProgress = connection;
|
|
try {
|
|
await connection;
|
|
} finally {
|
|
if (identical(_savedBoxConnectionInProgress, connection)) {
|
|
_savedBoxConnectionInProgress = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _connectSavedBoxOnce() async {
|
|
final box = currentBox;
|
|
if (box == null) {
|
|
return;
|
|
}
|
|
_setBusy(true);
|
|
try {
|
|
final savedDevice = BluetoothDevice.fromId(box.remoteId);
|
|
final shouldRediscover = await _shouldRediscoverBeforeConnecting(
|
|
box,
|
|
savedDevice,
|
|
);
|
|
if (shouldRediscover) {
|
|
if (!await _rediscoverAndAuthenticate(box)) {
|
|
throw StateError('The saved box is currently unavailable.');
|
|
}
|
|
} else {
|
|
try {
|
|
await _connectAndAuthenticateSavedBox(savedDevice);
|
|
} catch (error, stackTrace) {
|
|
await _disconnectForRetry(savedDevice);
|
|
if (!await _rediscoverAndAuthenticate(box)) {
|
|
Error.throwWithStackTrace(error, stackTrace);
|
|
}
|
|
}
|
|
}
|
|
await syncClock();
|
|
} catch (error) {
|
|
isAuthenticated = false;
|
|
errorMessage = '$error';
|
|
status = 'disconnected';
|
|
} finally {
|
|
_setBusy(false);
|
|
}
|
|
}
|
|
|
|
Future<void> _connectAndAuthenticateSavedBox(BluetoothDevice device) async {
|
|
await _connectDeviceWithAndroidBondRecovery(
|
|
device,
|
|
connectionTimeout: const Duration(seconds: 8),
|
|
);
|
|
await _write(const OpenParcelBoxCommand('authenticate'));
|
|
isAuthenticated = true;
|
|
}
|
|
|
|
Future<bool> _shouldRediscoverBeforeConnecting(
|
|
SavedBox box,
|
|
BluetoothDevice device,
|
|
) async {
|
|
if (box.needsRediscovery) {
|
|
return true;
|
|
}
|
|
|
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await device.bondState.first == BluetoothBondState.none;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> _rediscoverAndAuthenticate(SavedBox box) async {
|
|
final devices = await _scanForOpenParcelBoxes();
|
|
devices.sort((left, right) {
|
|
if (left.remoteId.str == box.remoteId) return -1;
|
|
if (right.remoteId.str == box.remoteId) return 1;
|
|
return 0;
|
|
});
|
|
|
|
for (final device in devices) {
|
|
try {
|
|
await _connectDeviceWithAndroidBondRecovery(
|
|
device,
|
|
connectionTimeout: const Duration(seconds: 8),
|
|
);
|
|
final publicState = await _readState(
|
|
updateCollections: false,
|
|
paginated: false,
|
|
);
|
|
if (publicState?['box_name'] != box.name) {
|
|
await _disconnectForRetry(device);
|
|
continue;
|
|
}
|
|
await _write(const OpenParcelBoxCommand('authenticate'));
|
|
isAuthenticated = true;
|
|
await _markSavedBoxRediscovered(box, device.remoteId.str);
|
|
return true;
|
|
} catch (error, stackTrace) {
|
|
debugPrint(
|
|
'OpenParcelBox rediscovery candidate rejected: '
|
|
'${device.remoteId.str}\n$error\n$stackTrace',
|
|
);
|
|
await _disconnectForRetry(device);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
Future<List<BluetoothDevice>> _scanForOpenParcelBoxes() async {
|
|
await _stopScanBeforeConnection();
|
|
await FlutterBluePlus.adapterState
|
|
.where((state) => state == BluetoothAdapterState.on)
|
|
.first
|
|
.timeout(const Duration(seconds: 5));
|
|
|
|
final devices = <String, BluetoothDevice>{};
|
|
final subscription = FlutterBluePlus.onScanResults.listen((results) {
|
|
for (final result in results.where(_looksLikeOpenParcelBox)) {
|
|
devices[result.device.remoteId.str] = result.device;
|
|
}
|
|
});
|
|
|
|
try {
|
|
await FlutterBluePlus.startScan(timeout: const Duration(seconds: 8));
|
|
await FlutterBluePlus.isScanning
|
|
.where((scanning) => !scanning)
|
|
.first
|
|
.timeout(const Duration(seconds: 10));
|
|
} finally {
|
|
if (FlutterBluePlus.isScanningNow) {
|
|
await FlutterBluePlus.stopScan();
|
|
}
|
|
await subscription.cancel();
|
|
}
|
|
|
|
return devices.values.toList();
|
|
}
|
|
|
|
Future<void> _disconnectForRetry(BluetoothDevice device) async {
|
|
await _stateSubscription?.cancel();
|
|
_stateSubscription = null;
|
|
await _connectionSubscription?.cancel();
|
|
_connectionSubscription = null;
|
|
if (device.isConnected) {
|
|
await device.disconnect();
|
|
}
|
|
connectedDevice = null;
|
|
_commandCharacteristic = null;
|
|
_stateCharacteristic = null;
|
|
isAuthenticated = false;
|
|
}
|
|
|
|
Future<void> _connectDeviceWithAndroidBondRecovery(
|
|
BluetoothDevice device, {
|
|
Duration connectionTimeout = const Duration(seconds: 20),
|
|
}) async {
|
|
BluetoothBondState? initialBondState;
|
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
|
try {
|
|
initialBondState = await device.bondState.first;
|
|
} catch (_) {
|
|
initialBondState = null;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await _connectDevice(device, connectionTimeout: connectionTimeout);
|
|
} catch (error, stackTrace) {
|
|
if (!_canRecoverAndroidBond(error, initialBondState)) {
|
|
rethrow;
|
|
}
|
|
|
|
debugPrint(
|
|
'OpenParcelBox connection failed; resetting the Android bond and '
|
|
'retrying once.\n$error\n$stackTrace',
|
|
);
|
|
await _resetAndroidBond(device);
|
|
await Future<void>.delayed(const Duration(milliseconds: 1000));
|
|
await _connectDevice(device, connectionTimeout: connectionTimeout);
|
|
}
|
|
}
|
|
|
|
bool _canRecoverAndroidBond(
|
|
Object error,
|
|
BluetoothBondState? initialBondState,
|
|
) {
|
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
|
return false;
|
|
}
|
|
|
|
return initialBondState == BluetoothBondState.bonded &&
|
|
isRecoverableAndroidBondFailure(error);
|
|
}
|
|
|
|
Future<void> _connectDevice(
|
|
BluetoothDevice device, {
|
|
required Duration connectionTimeout,
|
|
}) async {
|
|
status = 'connecting';
|
|
errorMessage = null;
|
|
notifyListeners();
|
|
await device.connect(
|
|
license: License.nonprofit,
|
|
timeout: connectionTimeout,
|
|
mtu: null,
|
|
);
|
|
_connectionSubscription?.cancel();
|
|
await _stateSubscription?.cancel();
|
|
_stateSubscription = null;
|
|
_connectionSubscription = device.connectionState.listen((state) {
|
|
if (state == BluetoothConnectionState.disconnected) {
|
|
connectedDevice = null;
|
|
_commandCharacteristic = null;
|
|
_stateCharacteristic = null;
|
|
isAuthenticated = false;
|
|
status = 'disconnected';
|
|
notifyListeners();
|
|
}
|
|
});
|
|
await _ensureAndroidBond(device);
|
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
|
await device.requestMtu(512);
|
|
}
|
|
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.');
|
|
}
|
|
_stateSubscription = _stateCharacteristic!.onValueReceived.listen((value) {
|
|
try {
|
|
final notification = jsonDecode(utf8.decode(value));
|
|
if (notification is Map<String, dynamic> &&
|
|
notification['changed'] == true) {
|
|
_refreshStateFromNotification();
|
|
}
|
|
} catch (_) {
|
|
// Long state reads are handled by _readState. Only the compact
|
|
// {"changed":true} notification should trigger another read.
|
|
}
|
|
});
|
|
await _stateCharacteristic!.setNotifyValue(true);
|
|
connectedDevice = device;
|
|
status = 'connected';
|
|
}
|
|
|
|
Future<void> _refreshStateFromNotification() async {
|
|
if (_stateRefreshInProgress) {
|
|
_stateRefreshPending = true;
|
|
return;
|
|
}
|
|
_stateRefreshInProgress = true;
|
|
try {
|
|
do {
|
|
_stateRefreshPending = false;
|
|
await _readState();
|
|
} while (_stateRefreshPending && isConnected);
|
|
} catch (error, stackTrace) {
|
|
debugPrint(
|
|
'OpenParcelBox automatic state synchronization failed: '
|
|
'$error\n$stackTrace',
|
|
);
|
|
} finally {
|
|
_stateRefreshInProgress = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _ensureAndroidBond(BluetoothDevice device) async {
|
|
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
|
|
return;
|
|
}
|
|
|
|
var bondState = await device.bondState.first;
|
|
if (bondState == BluetoothBondState.bonded) {
|
|
return;
|
|
}
|
|
|
|
if (bondState == BluetoothBondState.bonding) {
|
|
bondState = await _waitForAndroidBondState(
|
|
device,
|
|
timeout: const Duration(seconds: 30),
|
|
);
|
|
if (bondState == BluetoothBondState.bonded) {
|
|
return;
|
|
}
|
|
throw StateError('Secure Bluetooth pairing did not complete.');
|
|
}
|
|
|
|
await device.createBond(timeout: 30);
|
|
bondState = await _waitForAndroidBondState(
|
|
device,
|
|
timeout: const Duration(seconds: 30),
|
|
);
|
|
if (bondState != BluetoothBondState.bonded) {
|
|
throw StateError('Secure Bluetooth pairing did not complete.');
|
|
}
|
|
}
|
|
|
|
Future<BluetoothBondState> _waitForAndroidBondState(
|
|
BluetoothDevice device, {
|
|
required Duration timeout,
|
|
}) async {
|
|
var latestState = await device.bondState.first;
|
|
if (latestState == BluetoothBondState.bonded) {
|
|
return latestState;
|
|
}
|
|
|
|
final initialState = latestState;
|
|
var isFirstEvent = true;
|
|
final settledState = Completer<BluetoothBondState>();
|
|
late final StreamSubscription<BluetoothBondState> subscription;
|
|
subscription = device.bondState.listen((state) {
|
|
latestState = state;
|
|
if (isFirstEvent && state == initialState) {
|
|
isFirstEvent = false;
|
|
return;
|
|
}
|
|
isFirstEvent = false;
|
|
if (state != BluetoothBondState.bonding && !settledState.isCompleted) {
|
|
settledState.complete(state);
|
|
}
|
|
});
|
|
|
|
try {
|
|
return await settledState.future.timeout(timeout);
|
|
} on TimeoutException {
|
|
return latestState;
|
|
} finally {
|
|
await subscription.cancel();
|
|
}
|
|
}
|
|
|
|
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;
|
|
final normalizedName = name.trim().toLowerCase();
|
|
return normalizedName.startsWith('opb-') ||
|
|
normalizedName.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> synchronize() async {
|
|
if (!isConnected) {
|
|
await connectSavedBox();
|
|
return;
|
|
}
|
|
_setBusy(true);
|
|
try {
|
|
await _readState();
|
|
} catch (error) {
|
|
errorMessage = '$error';
|
|
rethrow;
|
|
} finally {
|
|
_setBusy(false);
|
|
}
|
|
}
|
|
|
|
Future<void> addGeneratedCode(CredentialKind kind) {
|
|
if (currentBox?.role == BoxRole.guest && kind == CredentialKind.permanent) {
|
|
throw StateError('Guests cannot modify permanent codes.');
|
|
}
|
|
return send(
|
|
OpenParcelBoxCommand('add_code', <String, Object?>{
|
|
'code': _generateUniqueCode(),
|
|
'kind': kind == CredentialKind.oneTime ? 'one_time' : 'permanent',
|
|
'exclusive': 1,
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<void> replacePermanentCode(AccessCode previous, String newCode) {
|
|
if (currentBox?.role != BoxRole.administrator) {
|
|
throw StateError('Only administrators can modify permanent codes.');
|
|
}
|
|
if (previous.kind != CredentialKind.permanent) {
|
|
throw ArgumentError.value(previous, 'previous', 'Must be permanent.');
|
|
}
|
|
if (!RegExp(r'^\d{6}$').hasMatch(newCode)) {
|
|
throw ArgumentError.value(newCode, 'newCode', 'Must contain six digits.');
|
|
}
|
|
return send(
|
|
OpenParcelBoxCommand('replace_code', <String, Object?>{
|
|
'old_code': previous.code,
|
|
'new_code': newCode,
|
|
'kind': 'permanent',
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<void> removeCode(AccessCode code) {
|
|
if (currentBox?.role == BoxRole.guest &&
|
|
code.kind == CredentialKind.permanent) {
|
|
throw StateError('Guests cannot modify permanent codes.');
|
|
}
|
|
return 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() =>
|
|
send(const OpenParcelBoxCommand('start_nfc_enrollment'));
|
|
|
|
Future<void> cancelNfcEnrollment() =>
|
|
send(const OpenParcelBoxCommand('cancel_nfc_enrollment'));
|
|
|
|
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 Future<void>.delayed(const Duration(milliseconds: 600));
|
|
await forgetCurrentBox();
|
|
}
|
|
|
|
Future<void> forgetCurrentBox() async {
|
|
final box = currentBox;
|
|
if (box == null) {
|
|
return;
|
|
}
|
|
|
|
final device = connectedDevice ?? BluetoothDevice.fromId(box.remoteId);
|
|
await _stateSubscription?.cancel();
|
|
_stateSubscription = null;
|
|
await _connectionSubscription?.cancel();
|
|
_connectionSubscription = null;
|
|
if (device.isConnected) {
|
|
await device.disconnect();
|
|
}
|
|
connectedDevice = null;
|
|
_commandCharacteristic = null;
|
|
_stateCharacteristic = null;
|
|
isAuthenticated = false;
|
|
|
|
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
|
|
try {
|
|
await device.removeBond();
|
|
} catch (error, stackTrace) {
|
|
debugPrint(
|
|
'OpenParcelBox Android bond removal while forgetting the box '
|
|
'failed.\n$error\n$stackTrace',
|
|
);
|
|
status = 'disconnected';
|
|
notifyListeners();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
savedBoxes.removeWhere((item) => item.remoteId == box.remoteId);
|
|
await store.deleteCachedState(box.identityKey);
|
|
await store.saveBoxes(savedBoxes);
|
|
currentBox = savedBoxes.firstOrNull;
|
|
_clearState();
|
|
await _loadCachedStateForCurrentBox();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> replaceSavedBoxes(List<SavedBox> boxes) async {
|
|
savedBoxes
|
|
..clear()
|
|
..addAll(boxes.map((box) => box.copyWith(needsRediscovery: true)));
|
|
await store.saveBoxes(savedBoxes);
|
|
currentBox = savedBoxes.firstOrNull;
|
|
await _loadCachedStateForCurrentBox();
|
|
notifyListeners();
|
|
if (currentBox != null) {
|
|
unawaited(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.');
|
|
}
|
|
try {
|
|
await characteristic.write(command.encode(key), withoutResponse: false);
|
|
} catch (error) {
|
|
if (authenticate && _isAuthorizationWriteFailure(error)) {
|
|
throw const OpenParcelBoxAuthenticationException();
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
bool _isAuthorizationWriteFailure(Object error) {
|
|
if (error is! FlutterBluePlusException ||
|
|
error.function != 'writeCharacteristic') {
|
|
return false;
|
|
}
|
|
|
|
return error.code == 8 || error.code == 19;
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> _readState({
|
|
bool updateCollections = true,
|
|
bool paginated = true,
|
|
}) async {
|
|
final currentRead = _stateReadInProgress;
|
|
if (currentRead != null) {
|
|
return currentRead;
|
|
}
|
|
|
|
final read = _readStateOnce(
|
|
updateCollections: updateCollections,
|
|
paginated: paginated,
|
|
);
|
|
_stateReadInProgress = read;
|
|
try {
|
|
return await read;
|
|
} finally {
|
|
if (identical(_stateReadInProgress, read)) {
|
|
_stateReadInProgress = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>?> _readStateOnce({
|
|
required bool updateCollections,
|
|
required bool paginated,
|
|
}) async {
|
|
final characteristic = _stateCharacteristic;
|
|
if (characteristic == null) {
|
|
return null;
|
|
}
|
|
|
|
final stateBytes = <int>[];
|
|
if (paginated && currentBox?.identityKey != null) {
|
|
var offset = 0;
|
|
while (true) {
|
|
await _write(
|
|
OpenParcelBoxCommand('read_state_page', <String, Object?>{
|
|
'offset': offset,
|
|
}),
|
|
);
|
|
final page = await characteristic.read();
|
|
if (page.length > _statePageSize) {
|
|
throw StateError('The box returned an invalid state page.');
|
|
}
|
|
stateBytes.addAll(page);
|
|
offset += page.length;
|
|
if (page.length < _statePageSize) {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
stateBytes.addAll(await characteristic.read());
|
|
}
|
|
|
|
final decoded = jsonDecode(utf8.decode(stateBytes));
|
|
if (decoded is! Map<String, dynamic>) {
|
|
return null;
|
|
}
|
|
if (updateCollections) {
|
|
final box = currentBox;
|
|
if (box != null) {
|
|
await store.saveCachedState(box.identityKey, decoded);
|
|
}
|
|
_applyState(decoded);
|
|
}
|
|
return decoded;
|
|
}
|
|
|
|
void _applyState(Map<String, dynamic> state, {bool notify = true}) {
|
|
_clearCollections();
|
|
final enrollment = state['nfc_enrollment'];
|
|
if (enrollment is Map<String, dynamic>) {
|
|
nfcEnrollmentActive = enrollment['active'] == true;
|
|
final uid = enrollment['uid'];
|
|
nfcEnrollmentUid = uid is String && uid.isNotEmpty ? uid : null;
|
|
final error = enrollment['error'];
|
|
nfcEnrollmentError = error is String && error.isNotEmpty ? error : null;
|
|
} else {
|
|
nfcEnrollmentActive = false;
|
|
nfcEnrollmentUid = null;
|
|
nfcEnrollmentError = null;
|
|
}
|
|
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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
if (notify) {
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> _loadCachedStateForCurrentBox() async {
|
|
_clearCollections();
|
|
final box = currentBox;
|
|
if (box == null) {
|
|
return;
|
|
}
|
|
final state = await store.loadCachedState(box.identityKey);
|
|
if (state != null) {
|
|
_applyState(state, notify: false);
|
|
}
|
|
}
|
|
|
|
Future<void> _upsertSavedBox(SavedBox box) async {
|
|
savedBoxes.removeWhere((item) => item.remoteId == box.remoteId);
|
|
savedBoxes.insert(0, box);
|
|
await store.saveBoxes(savedBoxes);
|
|
}
|
|
|
|
Future<void> _markSavedBoxRediscovered(
|
|
SavedBox previous,
|
|
String remoteId,
|
|
) async {
|
|
final updated = previous.copyWith(
|
|
remoteId: remoteId,
|
|
needsRediscovery: false,
|
|
);
|
|
savedBoxes.removeWhere(
|
|
(item) => item.remoteId == previous.remoteId || item.remoteId == remoteId,
|
|
);
|
|
savedBoxes.insert(0, updated);
|
|
currentBox = updated;
|
|
await store.saveBoxes(savedBoxes);
|
|
}
|
|
|
|
void _clearState() {
|
|
connectedDevice = null;
|
|
_commandCharacteristic = null;
|
|
_stateCharacteristic = null;
|
|
isAuthenticated = false;
|
|
_clearCollections();
|
|
status = 'disconnected';
|
|
}
|
|
|
|
void _clearCollections() {
|
|
permanentCodes.clear();
|
|
oneTimeCodes.clear();
|
|
nfcTags.clear();
|
|
history.clear();
|
|
guests.clear();
|
|
nfcEnrollmentActive = false;
|
|
nfcEnrollmentUid = null;
|
|
nfcEnrollmentError = null;
|
|
}
|
|
|
|
String _generateUniqueCode() {
|
|
final usedCodes = <String>{
|
|
...permanentCodes.map((code) => code.code),
|
|
...oneTimeCodes.map((code) => code.code),
|
|
};
|
|
if (usedCodes.length >= 1000000) {
|
|
throw StateError('No six-digit access code is available.');
|
|
}
|
|
|
|
while (true) {
|
|
final candidate = _random.nextInt(1000000).toString().padLeft(6, '0');
|
|
if (!usedCodes.contains(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
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();
|
|
_stateSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|