fix(android): codes UI, deduplicate codes and add manual modification for permanent code

This commit is contained in:
2026-07-18 19:49:01 +02:00
parent ae00cba1de
commit 201187b752
11 changed files with 602 additions and 11 deletions
+8
View File
@@ -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.
+2 -1
View File
@@ -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
+25 -4
View File
@@ -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:
+41
View File
@@ -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;
+13
View File
@@ -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.
*
+34
View File
@@ -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);
}
+4 -1
View File
@@ -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.
+24
View File
@@ -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.',
+41 -4
View File
@@ -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))
+225 -1
View File
@@ -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,
),
),
+185
View File
@@ -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(