Files
OpenParcelBox/mobile-app/app/lib/ble_controller.dart
T

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();
}
}