fix(android): codes UI, deduplicate codes and add manual modification for permanent code
This commit is contained in:
@@ -10,6 +10,14 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevented generated permanent and temporary codes from colliding with any
|
||||
active code, including across code kinds, and made firmware reject exclusive
|
||||
creation when the value already exists.
|
||||
- Added manual six-digit permanent-code editing with a numeric OTP-style input,
|
||||
continuous multi-digit backspace behavior, and explicit confirmation before
|
||||
converting a matching temporary code to permanent.
|
||||
- Kept the final code card above the Android navigation area with the same
|
||||
visible bottom inset as the modal's side spacing.
|
||||
- Persisted each box's last authenticated BLE state in secure mobile storage so
|
||||
history, permanent and one-time codes, NFC tags, and guests remain visible
|
||||
after restarting the application while the box is out of range.
|
||||
|
||||
@@ -40,7 +40,8 @@ Completed or validated:
|
||||
- One-time access codes with automatic removal after first successful keypad use
|
||||
- Phone-synchronized runtime timestamps for lock-opening logs
|
||||
- Persistent seven-day opening history with power-loss time continuity
|
||||
- Eight permanent and twenty temporary access-code slots
|
||||
- Eight permanent and twenty temporary access-code slots, with unique random
|
||||
generation and administrator-confirmed temporary-to-permanent conversion
|
||||
- BLE LE Secure Connections, encrypted GATT access, and 128-bit phone identities
|
||||
- First-phone administrator provisioning and named guest identities
|
||||
- App-triggered NFC enrollment with stored tag names
|
||||
|
||||
@@ -194,7 +194,8 @@ Add a permanent code:
|
||||
{
|
||||
"command": "add_code",
|
||||
"code": "123456",
|
||||
"kind": "permanent"
|
||||
"kind": "permanent",
|
||||
"exclusive": 1
|
||||
}
|
||||
```
|
||||
|
||||
@@ -204,10 +205,30 @@ Add a one-time code:
|
||||
{
|
||||
"command": "add_code",
|
||||
"code": "654321",
|
||||
"kind": "one_time"
|
||||
"kind": "one_time",
|
||||
"exclusive": 1
|
||||
}
|
||||
```
|
||||
|
||||
`exclusive: 1` rejects the command if the six-digit value already belongs to
|
||||
either kind. Generated mobile codes always use this mode, so creating a code
|
||||
can never silently change an existing code's kind.
|
||||
|
||||
Replace a permanent code atomically:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "replace_code",
|
||||
"old_code": "123456",
|
||||
"new_code": "654321",
|
||||
"kind": "permanent"
|
||||
}
|
||||
```
|
||||
|
||||
Only administrators can replace permanent codes. If `new_code` currently
|
||||
belongs to a one-time code, the old permanent slot is removed and that existing
|
||||
code is converted to permanent in one persistent update.
|
||||
|
||||
Remove a code:
|
||||
|
||||
```json
|
||||
@@ -219,8 +240,8 @@ Remove a code:
|
||||
|
||||
Administrators can add or remove either code kind. Guests can add and remove
|
||||
one-time codes, but the firmware rejects every guest attempt to add or remove a
|
||||
permanent code. The limits of eight permanent and twenty temporary codes are
|
||||
global to the box, not per application identity.
|
||||
permanent code, or to use `replace_code`. The limits of eight permanent and
|
||||
twenty temporary codes are global to the box, not per application identity.
|
||||
|
||||
Add or remove an NFC tag UID:
|
||||
|
||||
|
||||
@@ -477,6 +477,47 @@ int access_codes_upsert(const char *code, enum access_code_kind kind) {
|
||||
return access_codes_set_with_kind(first_free_slot, code, kind);
|
||||
}
|
||||
|
||||
int access_codes_replace(const char *old_code, const char *new_code,
|
||||
enum access_code_kind kind) {
|
||||
size_t old_slot = ACCESS_CODE_MAX_COUNT;
|
||||
size_t new_slot = ACCESS_CODE_MAX_COUNT;
|
||||
|
||||
if (!access_code_is_digit_string(old_code, ACCESS_CODE_LENGTH) ||
|
||||
!access_code_is_digit_string(new_code, ACCESS_CODE_LENGTH) ||
|
||||
(kind != ACCESS_CODE_KIND_PERMANENT &&
|
||||
kind != ACCESS_CODE_KIND_ONE_TIME)) {
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < ACCESS_CODE_MAX_COUNT; i++) {
|
||||
if (!code_table.slots[i].enabled) {
|
||||
continue;
|
||||
}
|
||||
if (memcmp(code_table.slots[i].code, old_code, ACCESS_CODE_LENGTH) == 0) {
|
||||
old_slot = i;
|
||||
}
|
||||
if (memcmp(code_table.slots[i].code, new_code, ACCESS_CODE_LENGTH) == 0) {
|
||||
new_slot = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (old_slot == ACCESS_CODE_MAX_COUNT) {
|
||||
return -ENOENT;
|
||||
}
|
||||
if (new_slot == ACCESS_CODE_MAX_COUNT || new_slot == old_slot) {
|
||||
return access_codes_set_with_kind(old_slot, new_code, kind);
|
||||
}
|
||||
|
||||
code_table.slots[old_slot].enabled = false;
|
||||
code_table.slots[old_slot].kind = ACCESS_CODE_KIND_PERMANENT;
|
||||
memset(code_table.slots[old_slot].code, 0,
|
||||
sizeof(code_table.slots[old_slot].code));
|
||||
code_table.slots[new_slot].kind = kind;
|
||||
code_table.count--;
|
||||
|
||||
return access_codes_save();
|
||||
}
|
||||
|
||||
int access_codes_clear(size_t slot) {
|
||||
if (slot >= ACCESS_CODE_MAX_COUNT) {
|
||||
return -EINVAL;
|
||||
|
||||
@@ -110,6 +110,19 @@ int access_codes_set_with_kind(size_t slot, const char *code,
|
||||
*/
|
||||
int access_codes_upsert(const char *code, enum access_code_kind kind);
|
||||
|
||||
/**
|
||||
* @brief Replace one code atomically, merging with an existing destination.
|
||||
*
|
||||
* When @p new_code already exists, the old slot is removed and the existing
|
||||
* destination slot receives @p kind. This supports an explicit temporary to
|
||||
* permanent conversion without ever storing duplicate code values.
|
||||
*
|
||||
* @return 0 on success, -ENOENT when old_code is absent, or another negative
|
||||
* value on validation/storage error.
|
||||
*/
|
||||
int access_codes_replace(const char *old_code, const char *new_code,
|
||||
enum access_code_kind kind);
|
||||
|
||||
/**
|
||||
* @brief Disable one stored code slot.
|
||||
*
|
||||
|
||||
@@ -503,6 +503,8 @@ static int handle_add_code(const char *json, bool allow_permanent) {
|
||||
char code[ACCESS_CODE_LENGTH + 1];
|
||||
char kind_text[16];
|
||||
enum access_code_kind kind = ACCESS_CODE_KIND_PERMANENT;
|
||||
enum access_code_kind existing_kind;
|
||||
int64_t exclusive = 0;
|
||||
|
||||
if (!json_get_string(json, "code", code, sizeof(code))) {
|
||||
return -EINVAL;
|
||||
@@ -516,9 +518,37 @@ static int handle_add_code(const char *json, bool allow_permanent) {
|
||||
return -EACCES;
|
||||
}
|
||||
|
||||
if (json_get_int64(json, "exclusive", &exclusive) && exclusive != 0 &&
|
||||
access_codes_find(code, strlen(code), &existing_kind)) {
|
||||
return -EEXIST;
|
||||
}
|
||||
|
||||
return access_codes_upsert(code, kind);
|
||||
}
|
||||
|
||||
static int handle_replace_code(const char *json, bool allow_permanent) {
|
||||
char old_code[ACCESS_CODE_LENGTH + 1];
|
||||
char new_code[ACCESS_CODE_LENGTH + 1];
|
||||
enum access_code_kind old_kind;
|
||||
|
||||
if (!allow_permanent) {
|
||||
return -EACCES;
|
||||
}
|
||||
if (!json_get_string(json, "old_code", old_code, sizeof(old_code)) ||
|
||||
!json_get_string(json, "new_code", new_code, sizeof(new_code))) {
|
||||
return -EINVAL;
|
||||
}
|
||||
if (!access_codes_find(old_code, strlen(old_code), &old_kind)) {
|
||||
return -ENOENT;
|
||||
}
|
||||
if (old_kind != ACCESS_CODE_KIND_PERMANENT) {
|
||||
return -EACCES;
|
||||
}
|
||||
|
||||
return access_codes_replace(old_code, new_code,
|
||||
ACCESS_CODE_KIND_PERMANENT);
|
||||
}
|
||||
|
||||
static int handle_remove_code(const char *json, bool allow_permanent) {
|
||||
char code[ACCESS_CODE_LENGTH + 1];
|
||||
enum access_code_kind kind;
|
||||
@@ -663,6 +693,10 @@ static int handle_command(struct bt_conn *conn, const char *json) {
|
||||
return handle_remove_code(json, identity.role == APP_IDENTITY_ADMIN);
|
||||
}
|
||||
|
||||
if (strcmp(command, "replace_code") == 0) {
|
||||
return handle_replace_code(json, identity.role == APP_IDENTITY_ADMIN);
|
||||
}
|
||||
|
||||
if (strcmp(command, "add_nfc_tag") == 0) {
|
||||
return handle_add_nfc_tag(json);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,10 @@ unused record when needed.
|
||||
history refreshed automatically after firmware changes.
|
||||
- Eight permanent and twenty one-time access codes shared globally. Guests can
|
||||
manage temporary codes and view permanent codes; only administrators can
|
||||
modify permanent codes.
|
||||
modify permanent codes. Random generation excludes every currently active
|
||||
permanent and temporary value. Administrators can also edit permanent codes
|
||||
through the six-digit numeric editor; choosing an existing temporary value
|
||||
requires confirmation before converting it to permanent.
|
||||
- NFC tag listing, renaming, deletion, manual UID entry, phone NFC scanning,
|
||||
and enrollment through the box reader.
|
||||
- Named guest creation, invitation QR display, and revocation.
|
||||
|
||||
@@ -89,6 +89,30 @@ class AppStrings {
|
||||
'connecting': {'fr': 'Connexion…', 'en': 'Connecting…'},
|
||||
'no_code': {'fr': 'Aucun code.', 'en': 'No code.'},
|
||||
'new_code': {'fr': 'Générer un code', 'en': 'Generate a code'},
|
||||
'edit_code': {'fr': 'Modifier le code', 'en': 'Edit code'},
|
||||
'edit_permanent_code': {
|
||||
'fr': 'Modifier le code permanent',
|
||||
'en': 'Edit permanent code',
|
||||
},
|
||||
'code_input_help': {
|
||||
'fr': 'Saisissez les six chiffres du nouveau code.',
|
||||
'en': 'Enter the six digits of the new code.',
|
||||
},
|
||||
'code_already_permanent': {
|
||||
'fr': 'Ce code est déjà utilisé par un autre code permanent.',
|
||||
'en': 'This code is already used by another permanent code.',
|
||||
},
|
||||
'convert_temporary_code_title': {
|
||||
'fr': 'Convertir le code temporaire ?',
|
||||
'en': 'Convert temporary code?',
|
||||
},
|
||||
'convert_temporary_code_message': {
|
||||
'fr':
|
||||
'Ce code existe déjà comme code temporaire. Souhaitez-vous le faire passer en code permanent ? Le code permanent que vous modifiez sera remplacé.',
|
||||
'en':
|
||||
'This code already exists as a temporary code. Do you want to convert it to a permanent code? The permanent code you are editing will be replaced.',
|
||||
},
|
||||
'convert': {'fr': 'Convertir', 'en': 'Convert'},
|
||||
'code_limit': {
|
||||
'fr': 'La limite de codes est atteinte.',
|
||||
'en': 'The code limit has been reached.',
|
||||
|
||||
@@ -59,9 +59,12 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
OpenParcelBoxBleController({
|
||||
SecureBoxStore? store,
|
||||
this.reconnectOnInitialize = true,
|
||||
}) : store = store ?? SecureBoxStore();
|
||||
Random? random,
|
||||
}) : store = store ?? SecureBoxStore(),
|
||||
_random = random ?? Random.secure();
|
||||
|
||||
final SecureBoxStore store;
|
||||
final Random _random;
|
||||
@visibleForTesting
|
||||
final bool reconnectOnInitialize;
|
||||
final List<SavedBox> savedBoxes = <SavedBox>[];
|
||||
@@ -673,8 +676,28 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
}
|
||||
return send(
|
||||
OpenParcelBoxCommand('add_code', <String, Object?>{
|
||||
'code': _generateCode(),
|
||||
'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',
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1020,8 +1043,22 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
guests.clear();
|
||||
}
|
||||
|
||||
String _generateCode() =>
|
||||
List<int>.generate(6, (_) => Random.secure().nextInt(10)).join();
|
||||
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))
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
@@ -608,6 +609,21 @@ class _OpenParcelBoxHomeState extends State<OpenParcelBoxHome> {
|
||||
title: code.code,
|
||||
actions: hasModifyPermission
|
||||
? <Widget>[
|
||||
if (kind == CredentialKind.permanent)
|
||||
IconButton(
|
||||
onPressed: canModify
|
||||
? () async {
|
||||
final changed = await _editPermanentCode(
|
||||
code,
|
||||
);
|
||||
if (changed) {
|
||||
setModalState(() {});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: strings.text('edit_code'),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: canModify
|
||||
? () async {
|
||||
@@ -630,6 +646,61 @@ class _OpenParcelBoxHomeState extends State<OpenParcelBoxHome> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _editPermanentCode(AccessCode code) async {
|
||||
final newCode = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => CodeEditorDialog(
|
||||
title: strings.text('edit_permanent_code'),
|
||||
description: strings.text('code_input_help'),
|
||||
initialCode: code.code,
|
||||
cancelLabel: strings.text('cancel'),
|
||||
confirmLabel: strings.text('save'),
|
||||
),
|
||||
);
|
||||
if (newCode == null || newCode == code.code) {
|
||||
return false;
|
||||
}
|
||||
if (!mounted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final duplicatesPermanent = _controller.permanentCodes.any(
|
||||
(item) => item.id != code.id && item.code == newCode,
|
||||
);
|
||||
if (duplicatesPermanent) {
|
||||
await _showError(strings.text('code_already_permanent'));
|
||||
return false;
|
||||
}
|
||||
|
||||
final convertsTemporary = _controller.oneTimeCodes.any(
|
||||
(item) => item.code == newCode,
|
||||
);
|
||||
if (convertsTemporary) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: Text(strings.text('convert_temporary_code_title')),
|
||||
content: Text(strings.text('convert_temporary_code_message')),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: Text(strings.text('cancel')),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: Text(strings.text('convert')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return _guarded(() => _controller.replacePermanentCode(code, newCode));
|
||||
}
|
||||
|
||||
Future<void> _showNfc() async {
|
||||
_nfcScanStatus = null;
|
||||
await showOpbModal(
|
||||
@@ -1335,6 +1406,154 @@ class _TextPromptDialogState extends State<_TextPromptDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
class CodeEditorDialog extends StatefulWidget {
|
||||
const CodeEditorDialog({
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.initialCode,
|
||||
required this.cancelLabel,
|
||||
required this.confirmLabel,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String description;
|
||||
final String initialCode;
|
||||
final String cancelLabel;
|
||||
final String confirmLabel;
|
||||
|
||||
@override
|
||||
State<CodeEditorDialog> createState() => _CodeEditorDialogState();
|
||||
}
|
||||
|
||||
class _CodeEditorDialogState extends State<CodeEditorDialog> {
|
||||
late final TextEditingController _controller = TextEditingController(
|
||||
text: widget.initialCode,
|
||||
);
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
|
||||
bool get _isComplete => _controller.text.length == 6;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
_focusNode.requestFocus();
|
||||
_selectDigit(5);
|
||||
});
|
||||
}
|
||||
|
||||
void _selectDigit(int index) {
|
||||
_focusNode.requestFocus();
|
||||
final length = _controller.text.length;
|
||||
_controller.selection = index < length
|
||||
? TextSelection(baseOffset: index, extentOffset: index + 1)
|
||||
: TextSelection.collapsed(offset: length);
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(widget.title),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
Text(widget.description),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: 1,
|
||||
height: 1,
|
||||
child: Opacity(
|
||||
opacity: 0,
|
||||
child: TextField(
|
||||
key: const ValueKey<String>('code-input'),
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
keyboardType: TextInputType.number,
|
||||
textInputAction: TextInputAction.done,
|
||||
maxLength: 6,
|
||||
inputFormatters: <TextInputFormatter>[
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
onChanged: (_) => setState(() {}),
|
||||
onSubmitted: (_) {
|
||||
if (_isComplete) {
|
||||
Navigator.of(context).pop(_controller.text);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: List<Widget>.generate(11, (position) {
|
||||
if (position.isOdd) {
|
||||
return const SizedBox(width: 6);
|
||||
}
|
||||
final index = position ~/ 2;
|
||||
final selected =
|
||||
_focusNode.hasFocus &&
|
||||
(_controller.selection.start == index ||
|
||||
(_controller.selection.isCollapsed &&
|
||||
_controller.selection.extentOffset == index));
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
key: ValueKey<String>('code-digit-$index'),
|
||||
onTap: () => _selectDigit(index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 120),
|
||||
height: 54,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF252525),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: selected ? _accent : Colors.white24,
|
||||
width: selected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
index < _controller.text.length
|
||||
? _controller.text[index]
|
||||
: '',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(widget.cancelLabel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _isComplete
|
||||
? () => Navigator.of(context).pop(_controller.text)
|
||||
: null,
|
||||
child: Text(widget.confirmLabel),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyHome extends StatelessWidget {
|
||||
const _EmptyHome({
|
||||
required this.addLabel,
|
||||
@@ -1967,7 +2186,12 @@ Future<void> showOpbModal(
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 28),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
16,
|
||||
16,
|
||||
16 + MediaQuery.viewPaddingOf(context).bottom,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -75,6 +76,38 @@ class _FakeBackupService extends BackupService {
|
||||
<SavedBox>[];
|
||||
}
|
||||
|
||||
class _SequenceRandom implements Random {
|
||||
_SequenceRandom(this.values);
|
||||
|
||||
final List<int> values;
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
int nextInt(int max) => values[_index++] % max;
|
||||
|
||||
@override
|
||||
bool nextBool() => nextInt(2) == 1;
|
||||
|
||||
@override
|
||||
double nextDouble() => nextInt(1 << 20) / (1 << 20);
|
||||
}
|
||||
|
||||
class _RecordingController extends OpenParcelBoxBleController {
|
||||
_RecordingController({required Random random})
|
||||
: super(
|
||||
store: _MemoryStore(),
|
||||
reconnectOnInitialize: false,
|
||||
random: random,
|
||||
);
|
||||
|
||||
OpenParcelBoxCommand? lastCommand;
|
||||
|
||||
@override
|
||||
Future<void> send(OpenParcelBoxCommand command, {bool refresh = true}) async {
|
||||
lastCommand = command;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -320,6 +353,158 @@ void main() {
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test(
|
||||
'generated codes skip every active permanent and temporary code',
|
||||
() async {
|
||||
final controller =
|
||||
_RecordingController(
|
||||
random: _SequenceRandom(<int>[123456, 654321, 42]),
|
||||
)
|
||||
..currentBox = const SavedBox(
|
||||
remoteId: 'AA:BB',
|
||||
name: 'Front gate',
|
||||
identityKey: 'ADMIN',
|
||||
role: BoxRole.administrator,
|
||||
)
|
||||
..permanentCodes.add(
|
||||
const AccessCode(
|
||||
id: 'slot-0',
|
||||
code: '123456',
|
||||
kind: CredentialKind.permanent,
|
||||
),
|
||||
)
|
||||
..oneTimeCodes.add(
|
||||
const AccessCode(
|
||||
id: 'slot-1',
|
||||
code: '654321',
|
||||
kind: CredentialKind.oneTime,
|
||||
),
|
||||
);
|
||||
|
||||
await controller.addGeneratedCode(CredentialKind.permanent);
|
||||
|
||||
expect(controller.lastCommand?.name, 'add_code');
|
||||
expect(controller.lastCommand?.payload['code'], '000042');
|
||||
expect(controller.lastCommand?.payload['exclusive'], 1);
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'manual permanent-code replacement uses the atomic BLE command',
|
||||
() async {
|
||||
final controller = _RecordingController(random: _SequenceRandom(<int>[0]))
|
||||
..currentBox = const SavedBox(
|
||||
remoteId: 'AA:BB',
|
||||
name: 'Front gate',
|
||||
identityKey: 'ADMIN',
|
||||
role: BoxRole.administrator,
|
||||
);
|
||||
const previous = AccessCode(
|
||||
id: 'slot-0',
|
||||
code: '123456',
|
||||
kind: CredentialKind.permanent,
|
||||
);
|
||||
|
||||
await controller.replacePermanentCode(previous, '654321');
|
||||
|
||||
expect(controller.lastCommand?.name, 'replace_code');
|
||||
expect(controller.lastCommand?.payload['old_code'], '123456');
|
||||
expect(controller.lastCommand?.payload['new_code'], '654321');
|
||||
expect(controller.lastCommand?.payload['kind'], 'permanent');
|
||||
controller.dispose();
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('code editor uses a numeric keyboard and repeated backspace', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
const MaterialApp(
|
||||
home: Scaffold(
|
||||
body: CodeEditorDialog(
|
||||
title: 'Edit permanent code',
|
||||
description: 'Enter six digits.',
|
||||
initialCode: '123456',
|
||||
cancelLabel: 'Cancel',
|
||||
confirmLabel: 'Save',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
final input = tester.widget<TextField>(
|
||||
find.byKey(const ValueKey<String>('code-input')),
|
||||
);
|
||||
expect(input.keyboardType, TextInputType.number);
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.backspace);
|
||||
await tester.pump();
|
||||
|
||||
final editedController = tester
|
||||
.widget<TextField>(find.byKey(const ValueKey<String>('code-input')))
|
||||
.controller;
|
||||
expect(editedController?.text, '123');
|
||||
expect(
|
||||
editedController?.selection,
|
||||
const TextSelection.collapsed(offset: 3),
|
||||
);
|
||||
expect(find.text('4'), findsNothing);
|
||||
expect(find.text('5'), findsNothing);
|
||||
expect(find.text('6'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('styled modal keeps its final row above Android navigation', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(viewPadding: const EdgeInsets.only(bottom: 24)),
|
||||
child: child!,
|
||||
),
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => showOpbModal(
|
||||
context,
|
||||
title: 'Codes',
|
||||
child: Column(
|
||||
children: List<Widget>.generate(
|
||||
8,
|
||||
(index) => Container(
|
||||
key: ValueKey<String>('modal-code-$index'),
|
||||
height: 72,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
final finalRow = find.byKey(const ValueKey<String>('modal-code-7'));
|
||||
await tester.ensureVisible(finalRow);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getRect(finalRow).bottom, lessThanOrEqualTo(760));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
test('guest logic rejects permanent-code mutations', () {
|
||||
final controller = OpenParcelBoxBleController(store: _MemoryStore())
|
||||
..currentBox = const SavedBox(
|
||||
|
||||
Reference in New Issue
Block a user