fix(android): persistent data, persistent apairing and data transfert, add reset identity button
This commit is contained in:
+82
-3
@@ -8,8 +8,32 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- 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.
|
||||
- Marked restored backups and guest QR invitations for BLE rediscovery so a new
|
||||
phone creates its own Android bond before authenticating with the transferred
|
||||
OpenParcelBox identity key.
|
||||
- Removed the matching Android Bluetooth bond when a user forgets a box from
|
||||
the application, while leaving firmware data unchanged unless factory reset
|
||||
was explicitly selected.
|
||||
- Kept the cached dashboard immediately available while startup reconnection
|
||||
runs in the background or the box remains out of range.
|
||||
- Prevented ordinary Android BLE connection timeouts from deleting a valid
|
||||
bond, and added scan-based recovery when a saved private BLE address changes.
|
||||
- Reassembled authenticated BLE state from 480-byte pages so code, NFC-tag,
|
||||
opening-history, and guest data no longer produce JSON truncated at the
|
||||
512-byte GATT attribute limit.
|
||||
- Removed redundant state-change notifications after mobile BLE commands to
|
||||
prevent explicit and automatic state reads from racing each other.
|
||||
|
||||
### Added
|
||||
|
||||
- Internal identity-reset button on XIAO `P1.11`: holding it for three seconds
|
||||
clears administrator and guest app identities plus BLE bonds without erasing
|
||||
access codes, NFC tags, history, or the box name.
|
||||
- One-second white RGB indication when firmware initialization completes and
|
||||
the keypad loop is ready.
|
||||
- Branded Android launcher icon and native splash screen using the project
|
||||
@@ -32,6 +56,13 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
and 128-bit administrator/guest application identities.
|
||||
- First-phone administrator provisioning without exposing the stored key.
|
||||
- App-triggered NFC enrollment with persistent tag names.
|
||||
- Dashboard synchronization shortcut and automatic mobile refresh from compact
|
||||
firmware state-change notifications.
|
||||
- Persistent administrator-registration progress and error screen with an
|
||||
explicit return action.
|
||||
- Persistent timestamp for the most recent successful mobile settings backup.
|
||||
- Mobile offline dashboard banner with a reconnect shortcut and read-only access
|
||||
to cached history, codes, NFC tags, and guests.
|
||||
- Initial project structure.
|
||||
- Zephyr RTOS firmware baseline.
|
||||
- Standardized project architecture.
|
||||
@@ -64,6 +95,20 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
|
||||
### Changed
|
||||
|
||||
- Android BLE reconnection now removes a stale phone-side bond and retries when
|
||||
secure pairing reports that it did not complete after the box cleared bonds.
|
||||
- Android administrator registration now initiates bonding from the app before
|
||||
GATT discovery instead of racing a firmware-triggered security request.
|
||||
- Mobile BLE write authorization failures now explain that the restored
|
||||
application identity no longer matches the identity stored in the box.
|
||||
- Android administrator registration now waits for firmware-triggered BLE
|
||||
bonding before falling back to an explicit bond request.
|
||||
- Mobile administrator registration now explains when the box already has an
|
||||
administrator instead of surfacing a raw `Bad state` error.
|
||||
- Firmware now stores up to four BLE bonds and can replace the oldest unused
|
||||
bond, avoiding stale Android pairing keys blocking reconnection.
|
||||
- Firmware now clears stale BLE bonds before first administrator provisioning
|
||||
when no administrator identity exists yet.
|
||||
- Refactored firmware into independent components.
|
||||
- Standardized source file headers.
|
||||
- Standardized project coding style.
|
||||
@@ -71,6 +116,8 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
- Updated project documentation to English.
|
||||
- Renamed project constraint documents from `contraintes.md` to `constraints.md`.
|
||||
- Updated firmware, hardware, Home Assistant, and mobile application documentation.
|
||||
- Disabled mobile box-mutating actions while offline, including open,
|
||||
synchronization-only, code, NFC, guest, and factory-reset actions.
|
||||
- Aligned hardware documentation with the active buzzer transistor driver.
|
||||
- Aligned firmware documentation with C17 and the current Zephyr build tools.
|
||||
- Updated project tracking documents to reflect completed RGB LED, GPIO expander, keypad, and active buzzer work.
|
||||
@@ -78,6 +125,23 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
- Updated runtime LED feedback with blue NFC scan-mode indication.
|
||||
- Updated open-lock reminder beep interval to 2 seconds.
|
||||
- Updated the mobile application to reload access codes and NFC tags from the firmware BLE state characteristic.
|
||||
- Reworked NFC actions into a three-item shortcut bar, kept scan guidance
|
||||
visible inside the modal, and clarified manual UID entry and pairing labels.
|
||||
- Improved mobile settings with immediate language changes, language flags,
|
||||
visible backup-location state, and explanatory input-dialog text.
|
||||
- Replaced the solid mobile page background with the project background image
|
||||
and grouped the logo, box name, active identity, and Bluetooth indicator in a
|
||||
solid dashboard header.
|
||||
- Anchored dashboard actions to equal 20-pixel side and bottom margins and
|
||||
arranged NFC tags, guests, and settings as a compact three-card row.
|
||||
- Doubled the separation between the dashboard shortcut bar and the action
|
||||
cards, and added show/hide controls to password fields.
|
||||
- Restored guest access to global opening history and code lists, allowed guest
|
||||
management of temporary codes, hid permanent-code mutation and firmware
|
||||
update actions, and displayed modal errors above the active modal.
|
||||
- Merged the separate mobile factory-reset action into box removal: guests see
|
||||
a local-data warning, while administrators receive a detailed warning and an
|
||||
optional checkbox to reset the physical box as well.
|
||||
- Corrected NFC hardware documentation: the nRF52840 integrated NFCT peripheral is tag-side NFC-A hardware, while passive badge UID reading requires a dedicated NFC reader circuit.
|
||||
|
||||
### Fixed
|
||||
@@ -95,6 +159,20 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
discovery before the first encrypted state read.
|
||||
- Added one-time stale-bond recovery when Android disconnects during the first
|
||||
encrypted provisioning read.
|
||||
- Removed the Android BLE bond after a successful administrator factory reset,
|
||||
preventing the stale bond from rejecting the next administrator registration.
|
||||
- Moved modal close controls fully inside their hit-test area and increased the
|
||||
touch target so closing a mobile modal is reliable.
|
||||
- Removed the duplicate visible Android 12 splash icon before the branded
|
||||
Flutter splash frame.
|
||||
- Enabled administrator registration as soon as a box and name are selected,
|
||||
stopped active BLE scanning before connecting, and increased the registration
|
||||
connection window from 12 to 20 seconds.
|
||||
- Closed the administrator-registration progress modal immediately after
|
||||
provisioning instead of waiting for the secondary clock synchronization.
|
||||
- Removed the exact registration modal route as soon as the controller publishes
|
||||
the newly saved box, preventing the loading overlay from remaining above an
|
||||
already rendered dashboard.
|
||||
- Requested BLE security immediately on firmware connection and restarted
|
||||
connectable advertising after every recycled connection, including failed
|
||||
initial pairing attempts.
|
||||
@@ -141,9 +219,10 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
- Removed the interactive Zephyr/I2C shell and its competing serial backend;
|
||||
USB CDC is now owned exclusively by the firmware console, matching the
|
||||
validated standalone diagnostic configuration.
|
||||
- Restricted guest BLE sessions to authentication, clock synchronization, and
|
||||
lock opening, and replaced oversized full-state notifications with a compact
|
||||
change marker.
|
||||
- Restricted guest BLE sessions to authentication, clock synchronization, lock
|
||||
opening, and temporary-code management; permanent-code, NFC, identity, and
|
||||
destructive administration remain blocked. Replaced oversized full-state
|
||||
notifications with a compact change marker.
|
||||
- Added strict validation of persisted identity and opening-history records
|
||||
before they can be used or serialized.
|
||||
- Removed automatic NVS writes and migrations from the boot path. Default
|
||||
|
||||
@@ -46,6 +46,7 @@ Completed or validated:
|
||||
- App-triggered NFC enrollment with stored tag names
|
||||
- Lock command pulse on XIAO `D9`
|
||||
- KR-S79 `COM/NC` lock state feedback on XIAO `D7` / `D8`
|
||||
- Internal identity-reset button on XIAO `P1.11`
|
||||
- Green-open, off-closed, red-invalid LED feedback
|
||||
- Short key beeps, long valid-code beep, invalid-code beep sequence, and 2s open reminder beep
|
||||
- Door opened / closed serial debug messages
|
||||
@@ -140,6 +141,7 @@ Current development platform:
|
||||
- KR-S79 lock command on XIAO `D9`
|
||||
- KR-S79 `NC` feedback on XIAO `D7`
|
||||
- KR-S79 `COM` feedback reference on XIAO `D8`
|
||||
- Internal identity-reset button between `GND` and XIAO `P1.11`
|
||||
|
||||
The XIAO nRF52840 exposes the Nordic NFCT pins (`NFC1/P0.09` and `NFC2/P0.10`). This peripheral is tag-side NFC-A hardware, not a standalone active reader for passive badges. Badge UID reading requires a dedicated NFC reader circuit on the production hardware.
|
||||
|
||||
|
||||
@@ -36,11 +36,12 @@ This complete sequence runs in a dedicated thread after local keypad
|
||||
initialization. Bluetooth failure or slow bond restoration therefore cannot
|
||||
prevent offline keypad access or lock operation.
|
||||
|
||||
On each connection, the peripheral immediately requests encrypted Bluetooth
|
||||
security before the application reads or writes an administration
|
||||
characteristic. Android completes bonding before MTU negotiation and GATT
|
||||
service discovery. This ordering avoids a disconnect caused by overlapping the
|
||||
first encrypted read, pairing, and MTU negotiation.
|
||||
The command, state, and CCC attributes require encrypted GATT access. On
|
||||
Android, the application explicitly creates the BLE bond immediately after the
|
||||
connection and before MTU negotiation, service discovery, state reads, or
|
||||
command writes. The firmware does not force a security upgrade from the
|
||||
connection callback; this avoids racing Android's bonding state machine before
|
||||
the application can remove stale bonds and control the pairing flow.
|
||||
|
||||
The firmware supports an ATT MTU of 247 bytes with matching 251-byte ACL
|
||||
buffers. This provides a 244-byte GATT payload, allowing provisioning and other
|
||||
@@ -48,11 +49,43 @@ JSON commands to remain atomic writes with responses. The default Zephyr MTU of
|
||||
65 bytes only provides a 62-byte payload and cannot carry the administrator
|
||||
provisioning document.
|
||||
|
||||
Authenticated state can exceed the 512-byte maximum GATT attribute value. The
|
||||
mobile application therefore sends `read_state_page` commands and reassembles a
|
||||
frozen JSON snapshot from successive 480-byte characteristic values before
|
||||
decoding it. This keeps complete code, NFC-tag, history, and guest lists
|
||||
available without truncating the JSON document.
|
||||
|
||||
Connectable advertising stops when Zephyr allocates the only connection object.
|
||||
The firmware restarts advertising from the connection `recycled` callback after
|
||||
every disconnect, including failed initial pairing, so the box remains
|
||||
discoverable for another attempt.
|
||||
|
||||
The firmware keeps up to four phone bonds and allows Zephyr to replace the
|
||||
oldest unused bond when the bond store is full. This prevents a stale Android
|
||||
bond, for example after reinstalling the app or clearing phone-side Bluetooth
|
||||
pairing data, from permanently blocking a legitimate phone while command access
|
||||
still requires the separate application identity key.
|
||||
|
||||
When no administrator identity exists yet, the firmware clears stored BLE bonds
|
||||
after loading Bluetooth settings and before advertising. This keeps stale phone
|
||||
pairing keys from blocking the first administrator registration with an Android
|
||||
system-level invalid key or passcode error.
|
||||
|
||||
Dragging a UF2 firmware image onto the XIAO bootloader drive updates the
|
||||
application image only. It does not erase the Zephyr Settings/NVS records that
|
||||
hold codes, tags, history, box settings, application identities, or Bluetooth
|
||||
bonds. This prevents a simple USB firmware update from becoming an easy
|
||||
third-party factory reset path.
|
||||
|
||||
If the administrator identity is lost, the box can still clear its stored app
|
||||
identities from an internal reset button wired between `GND` and XIAO
|
||||
`GPIO1 pin 11` (`P1.11`). The firmware configures this input with an internal
|
||||
pull-up, so the button is active low. Holding this internal button for three seconds clears
|
||||
administrator and guest identities from NVS, asks the BLE stack to clear stored
|
||||
bonds, and blinks the blue LED for a few seconds. Access codes, NFC tags,
|
||||
opening history, and the box name are preserved. The external keypad must not
|
||||
provide this reset path because it is accessible to third parties.
|
||||
|
||||
Each phone also owns a random 128-bit identity key stored in its secure
|
||||
keystore. The first phone can send `provision_admin` only while no administrator
|
||||
exists. Afterwards every command requires a valid administrator or guest
|
||||
@@ -61,7 +94,8 @@ box name and `admin_exists`; codes, tags, identities, and history remain hidden.
|
||||
The box never returns the administrator key. After administrator
|
||||
authentication, it returns guest names and guest keys so the administrator can
|
||||
redisplay or back up invitation QR codes. A guest never receives identity
|
||||
records.
|
||||
or NFC-tag records, but does receive the global access-code lists and opening
|
||||
history.
|
||||
|
||||
An administrator can register a named guest using a random key generated by the
|
||||
administrator application and transferred through a versioned invitation QR
|
||||
@@ -98,6 +132,20 @@ Characteristics:
|
||||
## Command Format
|
||||
|
||||
Commands are UTF-8 JSON objects written to the command characteristic.
|
||||
|
||||
Authenticated clients retrieve state pages with:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "read_state_page",
|
||||
"identity_key": "<32 hexadecimal characters>",
|
||||
"offset": 0
|
||||
}
|
||||
```
|
||||
|
||||
Offset zero creates a consistent snapshot. The client increments `offset` by
|
||||
the number of bytes read and stops after a page shorter than 480 bytes. Direct
|
||||
unauthenticated reads remain available for the compact registration state.
|
||||
Except for initial administrator provisioning, each command includes
|
||||
`identity_key`.
|
||||
|
||||
@@ -169,6 +217,11 @@ 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.
|
||||
|
||||
Add or remove an NFC tag UID:
|
||||
|
||||
```json
|
||||
@@ -250,8 +303,12 @@ Example:
|
||||
}
|
||||
```
|
||||
|
||||
The firmware also sends a notification on the state characteristic after a
|
||||
successful write command when notifications are enabled by the client.
|
||||
The firmware sends a compact notification when state changes independently of
|
||||
the mobile command flow, for example after local keypad use or an internal
|
||||
identity reset. The application reads state explicitly after its own successful
|
||||
commands, avoiding duplicate concurrent synchronization.
|
||||
Authenticated guests receive `codes` and `history`, with empty `nfc_tags` and
|
||||
`guests` arrays. Administrators receive every collection.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -134,6 +134,29 @@ BLE clock synchronized: 1784135040000
|
||||
Lock open requested source=ble timestamp_ms=1784135045000
|
||||
```
|
||||
|
||||
A repeated `BLE command failed: -13` means the encrypted BLE link is working,
|
||||
but the application identity key in the command is not authorized by the box.
|
||||
This commonly happens after restoring a phone backup that does not match the
|
||||
administrator identity still stored in the XIAO NVS area.
|
||||
|
||||
Internal identity reset button:
|
||||
|
||||
```text
|
||||
Internal identity reset button pressed
|
||||
Identity reset requested from internal button
|
||||
Identity reset complete
|
||||
BLE bonds cleared after reset
|
||||
Internal identity reset button released
|
||||
```
|
||||
|
||||
The internal reset button is wired between `GND` and `GPIO1 pin 11`
|
||||
(`P1.11`) and uses the firmware pull-up, so the pressed state is active low.
|
||||
Holding it for three seconds clears
|
||||
only administrator and guest app identities plus stored BLE bonds. It does not
|
||||
erase access codes, NFC tags, history, or the box name. The blue LED blinks for
|
||||
a few seconds after the reset starts. The UF2 bootloader drive does not erase
|
||||
NVS by itself.
|
||||
|
||||
---
|
||||
|
||||
# Serial Console
|
||||
|
||||
+3
-1
@@ -105,7 +105,9 @@ The XIAO nRF52840 integrated NFCT peripheral is tag-side NFC-A hardware. Passive
|
||||
### Bluetooth
|
||||
|
||||
BLE administration service for the Flutter application, with a JSON command
|
||||
characteristic and a readable/notifiable state characteristic.
|
||||
characteristic and a readable/notifiable state characteristic. Authenticated
|
||||
state is transferred as 480-byte pages so complete history and credential lists
|
||||
can exceed the 512-byte GATT attribute-value limit safely.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,12 +16,14 @@ target_sources(app PRIVATE
|
||||
src/access_codes.c
|
||||
src/nfc_tags.c
|
||||
src/opb_ble.c
|
||||
src/opb_factory_reset.c
|
||||
src/opb_clock.c
|
||||
src/open_history.c
|
||||
src/app_identities.c
|
||||
src/box_settings.c
|
||||
src/lock_state.c
|
||||
src/nfc.c
|
||||
src/reset_button.c
|
||||
src/gpio_expander.c
|
||||
src/keypad.c
|
||||
)
|
||||
|
||||
@@ -44,4 +44,10 @@
|
||||
#define LOCK_STATE_COM_GPIO_PIN 13
|
||||
#define LOCK_STATE_COM_GPIO_FLAGS GPIO_ACTIVE_HIGH
|
||||
|
||||
/* Internal identity reset button */
|
||||
|
||||
#define RESET_BUTTON_GPIO_NODE DT_NODELABEL(gpio1)
|
||||
#define RESET_BUTTON_GPIO_PIN 11
|
||||
#define RESET_BUTTON_GPIO_FLAGS GPIO_ACTIVE_LOW
|
||||
|
||||
#endif /* BOARD_CONFIG_H */
|
||||
|
||||
Binary file not shown.
@@ -22,8 +22,10 @@ CONFIG_BT_PERIPHERAL=y
|
||||
CONFIG_BT_DEVICE_NAME="OpenParcelBox"
|
||||
CONFIG_BT_DEVICE_APPEARANCE=0
|
||||
CONFIG_BT_MAX_CONN=1
|
||||
CONFIG_BT_MAX_PAIRED=4
|
||||
CONFIG_BT_SMP=y
|
||||
CONFIG_BT_BONDABLE=y
|
||||
CONFIG_BT_KEYS_OVERWRITE_OLDEST=y
|
||||
CONFIG_BT_SETTINGS=y
|
||||
CONFIG_BT_SMP_SC_PAIR_ONLY=y
|
||||
CONFIG_BT_PRIVACY=y
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
#include "nfc_tags.h"
|
||||
#include "opb_ble.h"
|
||||
#include "opb_clock.h"
|
||||
#include "opb_factory_reset.h"
|
||||
#include "open_history.h"
|
||||
#include "reset_button.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdbool.h>
|
||||
@@ -37,6 +39,9 @@
|
||||
#define INVALID_CODE_SEQUENCE_MS \
|
||||
((INVALID_CODE_BEEP_COUNT * INVALID_CODE_BEEP_MS) + \
|
||||
((INVALID_CODE_BEEP_COUNT - 1) * INVALID_CODE_BEEP_PAUSE_MS))
|
||||
#define IDENTITY_RESET_HOLD_MS 3000
|
||||
#define IDENTITY_RESET_BLINK_MS 5000
|
||||
#define IDENTITY_RESET_BLINK_INTERVAL_MS 250
|
||||
|
||||
static bool led_ready;
|
||||
static bool buzzer_ready;
|
||||
@@ -104,6 +109,27 @@ static int request_lock_open(const char *source, const char *actor) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int request_identity_reset(void) {
|
||||
int ret;
|
||||
|
||||
if (atomic_get(&persistent_services_ready) == 0) {
|
||||
printf("Identity reset rejected: persistent services not ready\n");
|
||||
return -EAGAIN;
|
||||
}
|
||||
|
||||
printf("Identity reset requested from internal button\n");
|
||||
ret = opb_factory_reset_app_identities();
|
||||
if (ret < 0) {
|
||||
printf("Identity reset failed: %d\n", ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
opb_ble_clear_bonds_after_reset();
|
||||
opb_ble_notify_state_changed();
|
||||
printf("Identity reset complete\n");
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int initialize_persistent_modules(void) {
|
||||
int first_error = 0;
|
||||
int ret;
|
||||
@@ -173,10 +199,17 @@ int main(void) {
|
||||
char entered_code[ACCESS_CODE_LENGTH];
|
||||
size_t entered_length = 0;
|
||||
bool keypad_ready;
|
||||
bool reset_button_ready;
|
||||
bool reset_button_was_pressed = false;
|
||||
bool reset_button_triggered = false;
|
||||
bool identity_reset_blink_on = false;
|
||||
bool door_open;
|
||||
bool previous_door_open;
|
||||
int64_t next_keypad_retry_ms;
|
||||
int64_t next_open_beep_ms;
|
||||
int64_t reset_button_pressed_since_ms = 0;
|
||||
int64_t identity_reset_blink_until_ms = 0;
|
||||
int64_t next_identity_reset_blink_ms = 0;
|
||||
|
||||
/*
|
||||
* These two initializers are RAM-only. They guarantee that keypad access and
|
||||
@@ -191,6 +224,7 @@ int main(void) {
|
||||
lock_control_ready = lock_control_init() == 0;
|
||||
buzzer_ready = buzzer_init() == 0;
|
||||
lock_state_ready = lock_state_init() == 0;
|
||||
reset_button_ready = reset_button_init() == 0;
|
||||
|
||||
keypad_ready = keypad_init() == 0;
|
||||
next_keypad_retry_ms = k_uptime_get() + KEYPAD_RETRY_MS;
|
||||
@@ -225,6 +259,39 @@ int main(void) {
|
||||
if (keypad_ready) printf("Keypad initialization recovered\n");
|
||||
}
|
||||
|
||||
if (reset_button_ready) {
|
||||
bool reset_button_pressed = reset_button_is_pressed();
|
||||
int64_t now_ms = k_uptime_get();
|
||||
|
||||
if (reset_button_pressed) {
|
||||
if (!reset_button_was_pressed) {
|
||||
reset_button_pressed_since_ms = now_ms;
|
||||
reset_button_triggered = false;
|
||||
printf("Internal identity reset button pressed\n");
|
||||
} else if (!reset_button_triggered &&
|
||||
now_ms - reset_button_pressed_since_ms >=
|
||||
IDENTITY_RESET_HOLD_MS) {
|
||||
reset_button_triggered = true;
|
||||
if (request_identity_reset() == 0) {
|
||||
identity_reset_blink_until_ms =
|
||||
now_ms + IDENTITY_RESET_BLINK_MS;
|
||||
next_identity_reset_blink_ms = now_ms;
|
||||
identity_reset_blink_on = false;
|
||||
beep_success();
|
||||
} else {
|
||||
signal_invalid_code();
|
||||
set_idle_led(lock_state_ready ? lock_state_is_open() : false);
|
||||
}
|
||||
}
|
||||
} else if (reset_button_was_pressed) {
|
||||
reset_button_pressed_since_ms = 0;
|
||||
reset_button_triggered = false;
|
||||
printf("Internal identity reset button released\n");
|
||||
}
|
||||
|
||||
reset_button_was_pressed = reset_button_pressed;
|
||||
}
|
||||
|
||||
door_open = lock_state_ready ? lock_state_is_open() : false;
|
||||
if (door_open != previous_door_open) {
|
||||
previous_door_open = door_open;
|
||||
@@ -244,6 +311,26 @@ int main(void) {
|
||||
next_open_beep_ms = k_uptime_get() + LOCK_OPEN_BEEP_INTERVAL_MS;
|
||||
}
|
||||
|
||||
if (identity_reset_blink_until_ms > 0) {
|
||||
int64_t now_ms = k_uptime_get();
|
||||
if (now_ms >= identity_reset_blink_until_ms) {
|
||||
identity_reset_blink_until_ms = 0;
|
||||
identity_reset_blink_on = false;
|
||||
set_idle_led(door_open);
|
||||
} else if (now_ms >= next_identity_reset_blink_ms) {
|
||||
identity_reset_blink_on = !identity_reset_blink_on;
|
||||
if (led_ready) {
|
||||
if (identity_reset_blink_on) {
|
||||
led_set_blue();
|
||||
} else {
|
||||
led_off();
|
||||
}
|
||||
}
|
||||
next_identity_reset_blink_ms =
|
||||
now_ms + IDENTITY_RESET_BLINK_INTERVAL_MS;
|
||||
}
|
||||
}
|
||||
|
||||
key = keypad_ready ? keypad_get_key() : 0;
|
||||
if (key == 0) {
|
||||
k_msleep(MAIN_LOOP_DELAY_MS);
|
||||
|
||||
+116
-57
@@ -14,6 +14,7 @@
|
||||
#include "box_settings.h"
|
||||
#include "nfc_tags.h"
|
||||
#include "opb_clock.h"
|
||||
#include "opb_factory_reset.h"
|
||||
#include "open_history.h"
|
||||
|
||||
#include <ctype.h>
|
||||
@@ -35,6 +36,7 @@
|
||||
|
||||
#define OPB_BLE_RX_MAX 256
|
||||
#define OPB_BLE_STATE_MAX 12288
|
||||
#define OPB_BLE_STATE_PAGE_SIZE 480
|
||||
#define OPB_BLE_INIT_STACK_SIZE 4096
|
||||
#define OPB_BLE_INIT_PRIORITY 7
|
||||
#define OPB_BLE_ADV_RETRY_COUNT 20
|
||||
@@ -53,6 +55,9 @@
|
||||
static opb_ble_open_lock_cb_t open_lock_cb;
|
||||
static bool state_notifications_enabled;
|
||||
static char state_json[OPB_BLE_STATE_MAX];
|
||||
static size_t state_snapshot_length;
|
||||
static size_t state_page_start;
|
||||
static bool state_page_active;
|
||||
static char enrollment_name[NFC_TAG_NAME_MAX_LENGTH + 1];
|
||||
static bool enrollment_requested;
|
||||
static struct bt_conn *authenticated_conn;
|
||||
@@ -74,26 +79,30 @@ static void restart_advertising(void);
|
||||
static void factory_reset_work_handler(struct k_work *work) {
|
||||
ARG_UNUSED(work);
|
||||
(void)bt_unpair(BT_ID_DEFAULT, NULL);
|
||||
printf("BLE bonds cleared after factory reset\n");
|
||||
printf("BLE bonds cleared after reset\n");
|
||||
}
|
||||
|
||||
void opb_ble_clear_bonds_after_reset(void) {
|
||||
if (!opb_ble_init_thread_started) {
|
||||
return;
|
||||
}
|
||||
|
||||
(void)k_work_schedule(&factory_reset_work, K_MSEC(250));
|
||||
}
|
||||
|
||||
static void connected(struct bt_conn *conn, uint8_t err) {
|
||||
int ret;
|
||||
|
||||
if (err != 0U) {
|
||||
printf("BLE connection failed: %u\n", err);
|
||||
return;
|
||||
}
|
||||
|
||||
state_page_active = false;
|
||||
printf("BLE connected\n");
|
||||
ret = bt_conn_set_security(conn, BT_SECURITY_L2);
|
||||
if (ret < 0) {
|
||||
printf("BLE security request failed: %d\n", ret);
|
||||
}
|
||||
}
|
||||
|
||||
static void disconnected(struct bt_conn *conn, uint8_t reason) {
|
||||
printf("BLE disconnected: %u\n", reason);
|
||||
state_page_active = false;
|
||||
if (conn == authenticated_conn) {
|
||||
authenticated_conn = NULL;
|
||||
memset(&authenticated_identity, 0, sizeof(authenticated_identity));
|
||||
@@ -339,16 +348,10 @@ static size_t build_state_json(void) {
|
||||
}
|
||||
|
||||
/*
|
||||
* Guests only need enough state to confirm their authenticated session and
|
||||
* open the lock. Credential tables, tag names, history, and other guests are
|
||||
* administrator data.
|
||||
* Access-code limits and opening history are global to the box, so both
|
||||
* administrators and guests receive them. NFC names and application
|
||||
* identities remain administrator-only data.
|
||||
*/
|
||||
if (authenticated_identity.role != APP_IDENTITY_ADMIN) {
|
||||
append_text(state_json, sizeof(state_json), &offset,
|
||||
",\"admin_exists\":true}");
|
||||
return strlen(state_json);
|
||||
}
|
||||
|
||||
append_text(state_json, sizeof(state_json), &offset, ",\"codes\":[");
|
||||
for (size_t slot = 0; slot < ACCESS_CODE_MAX_COUNT; slot++) {
|
||||
char code[ACCESS_CODE_LENGTH + 1];
|
||||
@@ -368,27 +371,29 @@ static size_t build_state_json(void) {
|
||||
|
||||
append_text(state_json, sizeof(state_json), &offset, "],\"nfc_tags\":[");
|
||||
first = true;
|
||||
for (size_t slot = 0; slot < NFC_TAG_MAX_COUNT; slot++) {
|
||||
uint8_t uid[NFC_TAG_UID_MAX_LENGTH];
|
||||
size_t uid_length;
|
||||
char name[NFC_TAG_NAME_MAX_LENGTH + 1];
|
||||
if (authenticated_identity.role == APP_IDENTITY_ADMIN) {
|
||||
for (size_t slot = 0; slot < NFC_TAG_MAX_COUNT; slot++) {
|
||||
uint8_t uid[NFC_TAG_UID_MAX_LENGTH];
|
||||
size_t uid_length;
|
||||
char name[NFC_TAG_NAME_MAX_LENGTH + 1];
|
||||
|
||||
if (!nfc_tags_get_named(slot, uid, &uid_length, name)) {
|
||||
continue;
|
||||
if (!nfc_tags_get_named(slot, uid, &uid_length, name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
append_formatted(state_json, sizeof(state_json), &offset,
|
||||
"%s{\"slot\":%u,\"uid\":\"", first ? "" : ",",
|
||||
(unsigned int)slot);
|
||||
|
||||
for (size_t i = 0; i < uid_length; i++) {
|
||||
append_formatted(state_json, sizeof(state_json), &offset, "%s%02X",
|
||||
i == 0 ? "" : ":", uid[i]);
|
||||
}
|
||||
|
||||
append_formatted(state_json, sizeof(state_json), &offset,
|
||||
"\",\"name\":\"%s\"}", name);
|
||||
first = false;
|
||||
}
|
||||
|
||||
append_formatted(state_json, sizeof(state_json), &offset,
|
||||
"%s{\"slot\":%u,\"uid\":\"", first ? "" : ",",
|
||||
(unsigned int)slot);
|
||||
|
||||
for (size_t i = 0; i < uid_length; i++) {
|
||||
append_formatted(state_json, sizeof(state_json), &offset, "%s%02X",
|
||||
i == 0 ? "" : ":", uid[i]);
|
||||
}
|
||||
|
||||
append_formatted(state_json, sizeof(state_json), &offset,
|
||||
"\",\"name\":\"%s\"}", name);
|
||||
first = false;
|
||||
}
|
||||
|
||||
append_text(state_json, sizeof(state_json), &offset, "],\"history\":[");
|
||||
@@ -433,11 +438,22 @@ static size_t build_public_state_json(void) {
|
||||
|
||||
static ssize_t state_read(struct bt_conn *conn, const struct bt_gatt_attr *attr,
|
||||
void *buf, uint16_t len, uint16_t offset) {
|
||||
size_t state_len = conn == authenticated_conn && authenticated_identity_valid
|
||||
? build_state_json()
|
||||
: build_public_state_json();
|
||||
size_t state_len;
|
||||
const char *state_data = state_json;
|
||||
|
||||
return bt_gatt_attr_read(conn, attr, buf, len, offset, state_json, state_len);
|
||||
if (conn == authenticated_conn && authenticated_identity_valid) {
|
||||
if (state_page_active) {
|
||||
state_data += state_page_start;
|
||||
state_len = MIN(OPB_BLE_STATE_PAGE_SIZE,
|
||||
state_snapshot_length - state_page_start);
|
||||
} else {
|
||||
state_len = build_state_json();
|
||||
}
|
||||
} else {
|
||||
state_len = build_public_state_json();
|
||||
}
|
||||
|
||||
return bt_gatt_attr_read(conn, attr, buf, len, offset, state_data, state_len);
|
||||
}
|
||||
|
||||
static void state_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value) {
|
||||
@@ -453,8 +469,8 @@ void opb_ble_notify_state_changed(void) {
|
||||
|
||||
/*
|
||||
* A full state document can be several kilobytes and does not fit in one ATT
|
||||
* notification. Notify only that state changed; clients then perform the
|
||||
* existing long read on the state characteristic.
|
||||
* notification. Notify only that state changed; clients then retrieve a
|
||||
* paginated snapshot from the state characteristic.
|
||||
*/
|
||||
(void)bt_gatt_notify(authenticated_conn, &opb_service.attrs[4],
|
||||
changed_notification,
|
||||
@@ -483,7 +499,7 @@ static int handle_sync_clock(const char *json) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int handle_add_code(const char *json) {
|
||||
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;
|
||||
@@ -496,16 +512,28 @@ static int handle_add_code(const char *json) {
|
||||
strcmp(kind_text, "one_time") == 0) {
|
||||
kind = ACCESS_CODE_KIND_ONE_TIME;
|
||||
}
|
||||
if (!allow_permanent && kind == ACCESS_CODE_KIND_PERMANENT) {
|
||||
return -EACCES;
|
||||
}
|
||||
|
||||
return access_codes_upsert(code, kind);
|
||||
}
|
||||
|
||||
static int handle_remove_code(const char *json) {
|
||||
static int handle_remove_code(const char *json, bool allow_permanent) {
|
||||
char code[ACCESS_CODE_LENGTH + 1];
|
||||
enum access_code_kind kind;
|
||||
|
||||
if (!json_get_string(json, "code", code, sizeof(code))) {
|
||||
return -EINVAL;
|
||||
}
|
||||
if (!allow_permanent) {
|
||||
if (!access_codes_find(code, strlen(code), &kind)) {
|
||||
return -ENOENT;
|
||||
}
|
||||
if (kind == ACCESS_CODE_KIND_PERMANENT) {
|
||||
return -EACCES;
|
||||
}
|
||||
}
|
||||
|
||||
return access_codes_clear_code(code);
|
||||
}
|
||||
@@ -547,6 +575,10 @@ static int handle_command(struct bt_conn *conn, const char *json) {
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
if (strcmp(command, "read_state_page") != 0) {
|
||||
state_page_active = false;
|
||||
}
|
||||
|
||||
if (strcmp(command, "provision_admin") == 0) {
|
||||
char name[APP_IDENTITY_NAME_MAX_LENGTH + 1] = "Administrator";
|
||||
char box_name[BOX_NAME_MAX_LENGTH + 1] = "OpenParcelBox";
|
||||
@@ -575,10 +607,35 @@ static int handle_command(struct bt_conn *conn, const char *json) {
|
||||
authenticated_identity = identity;
|
||||
authenticated_identity_valid = true;
|
||||
|
||||
if (strcmp(command, "read_state_page") == 0) {
|
||||
int64_t requested_offset;
|
||||
|
||||
if (!json_get_int64(json, "offset", &requested_offset) ||
|
||||
requested_offset < 0) {
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
if (requested_offset == 0) {
|
||||
state_snapshot_length = build_state_json();
|
||||
} else if (!state_page_active) {
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
if ((uint64_t)requested_offset > state_snapshot_length) {
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
state_page_start = (size_t)requested_offset;
|
||||
state_page_active = true;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (identity.role == APP_IDENTITY_GUEST &&
|
||||
strcmp(command, "open_lock") != 0 &&
|
||||
strcmp(command, "sync_clock") != 0 &&
|
||||
strcmp(command, "authenticate") != 0) {
|
||||
strcmp(command, "authenticate") != 0 &&
|
||||
strcmp(command, "add_code") != 0 &&
|
||||
strcmp(command, "remove_code") != 0) {
|
||||
return -EACCES;
|
||||
}
|
||||
|
||||
@@ -599,11 +656,11 @@ static int handle_command(struct bt_conn *conn, const char *json) {
|
||||
}
|
||||
|
||||
if (strcmp(command, "add_code") == 0) {
|
||||
return handle_add_code(json);
|
||||
return handle_add_code(json, identity.role == APP_IDENTITY_ADMIN);
|
||||
}
|
||||
|
||||
if (strcmp(command, "remove_code") == 0) {
|
||||
return handle_remove_code(json);
|
||||
return handle_remove_code(json, identity.role == APP_IDENTITY_ADMIN);
|
||||
}
|
||||
|
||||
if (strcmp(command, "add_nfc_tag") == 0) {
|
||||
@@ -649,20 +706,12 @@ static int handle_command(struct bt_conn *conn, const char *json) {
|
||||
if (identity.role != APP_IDENTITY_ADMIN) {
|
||||
return -EACCES;
|
||||
}
|
||||
ret = access_codes_factory_reset();
|
||||
if (ret < 0) return ret;
|
||||
ret = nfc_tags_factory_reset();
|
||||
if (ret < 0) return ret;
|
||||
ret = open_history_clear();
|
||||
if (ret < 0) return ret;
|
||||
ret = box_settings_factory_reset();
|
||||
if (ret < 0) return ret;
|
||||
ret = app_identities_factory_reset();
|
||||
ret = opb_factory_reset_persistent_state();
|
||||
if (ret == 0) {
|
||||
authenticated_conn = NULL;
|
||||
authenticated_identity_valid = false;
|
||||
memset(&authenticated_identity, 0, sizeof(authenticated_identity));
|
||||
(void)k_work_schedule(&factory_reset_work, K_MSEC(250));
|
||||
opb_ble_clear_bonds_after_reset();
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
@@ -700,10 +749,15 @@ static ssize_t command_write(struct bt_conn *conn,
|
||||
ret = handle_command(conn, json);
|
||||
if (ret < 0) {
|
||||
printf("BLE command failed: %d\n", ret);
|
||||
if (ret == -EACCES || ret == -EPERM) {
|
||||
return BT_GATT_ERR(BT_ATT_ERR_AUTHORIZATION);
|
||||
}
|
||||
if (ret == -ENOENT || ret == -ENOTSUP) {
|
||||
return BT_GATT_ERR(BT_ATT_ERR_NOT_SUPPORTED);
|
||||
}
|
||||
return BT_GATT_ERR(BT_ATT_ERR_VALUE_NOT_ALLOWED);
|
||||
}
|
||||
|
||||
opb_ble_notify_state_changed();
|
||||
return len;
|
||||
}
|
||||
|
||||
@@ -731,6 +785,11 @@ static void opb_ble_init_thread_entry(void *unused1, void *unused2,
|
||||
printf("BLE settings load failed: %d\n", ret);
|
||||
}
|
||||
|
||||
if (!app_identities_has_admin()) {
|
||||
(void)bt_unpair(BT_ID_DEFAULT, NULL);
|
||||
printf("BLE bonds cleared before initial administrator provisioning\n");
|
||||
}
|
||||
|
||||
for (int attempt = 0; attempt < OPB_BLE_ADV_RETRY_COUNT; attempt++) {
|
||||
ret = bt_le_adv_start(BT_LE_ADV_CONN_FAST_1, advertising_data,
|
||||
ARRAY_SIZE(advertising_data), scan_response_data,
|
||||
|
||||
@@ -40,5 +40,6 @@ bool opb_ble_get_unix_time_ms(int64_t *unix_ms);
|
||||
*/
|
||||
void opb_ble_notify_state_changed(void);
|
||||
bool opb_ble_take_nfc_enrollment(char *name, size_t name_size);
|
||||
void opb_ble_clear_bonds_after_reset(void);
|
||||
|
||||
#endif /* OPB_BLE_H */
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#include "opb_factory_reset.h"
|
||||
|
||||
#include "access_codes.h"
|
||||
#include "app_identities.h"
|
||||
#include "box_settings.h"
|
||||
#include "nfc_tags.h"
|
||||
#include "open_history.h"
|
||||
|
||||
int opb_factory_reset_persistent_state(void) {
|
||||
int ret;
|
||||
|
||||
ret = access_codes_factory_reset();
|
||||
if (ret < 0) return ret;
|
||||
ret = nfc_tags_factory_reset();
|
||||
if (ret < 0) return ret;
|
||||
ret = open_history_clear();
|
||||
if (ret < 0) return ret;
|
||||
ret = box_settings_factory_reset();
|
||||
if (ret < 0) return ret;
|
||||
return app_identities_factory_reset();
|
||||
}
|
||||
|
||||
int opb_factory_reset_app_identities(void) {
|
||||
return app_identities_factory_reset();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#ifndef OPB_FACTORY_RESET_H
|
||||
#define OPB_FACTORY_RESET_H
|
||||
|
||||
int opb_factory_reset_persistent_state(void);
|
||||
int opb_factory_reset_app_identities(void);
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,23 @@
|
||||
#include "reset_button.h"
|
||||
|
||||
#include "board_config.h"
|
||||
|
||||
#include <zephyr/device.h>
|
||||
#include <zephyr/drivers/gpio.h>
|
||||
|
||||
static const struct device *reset_button_gpio =
|
||||
DEVICE_DT_GET(RESET_BUTTON_GPIO_NODE);
|
||||
|
||||
int reset_button_init(void) {
|
||||
if (!device_is_ready(reset_button_gpio)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return gpio_pin_configure(reset_button_gpio, RESET_BUTTON_GPIO_PIN,
|
||||
GPIO_INPUT | GPIO_PULL_UP);
|
||||
}
|
||||
|
||||
bool reset_button_is_pressed(void) {
|
||||
int value = gpio_pin_get(reset_button_gpio, RESET_BUTTON_GPIO_PIN);
|
||||
return value == 0;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
#ifndef RESET_BUTTON_H
|
||||
#define RESET_BUTTON_H
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
int reset_button_init(void);
|
||||
bool reset_button_is_pressed(void);
|
||||
|
||||
#endif
|
||||
+81
-14
@@ -4,20 +4,31 @@ Flutter application for local OpenParcelBox setup and administration.
|
||||
|
||||
## User Interface
|
||||
|
||||
- Dark solid background `#333333`, button surfaces `#292929`, modal surfaces
|
||||
`#303030`, accent `#c19d60`, and text `#c6c6c6`.
|
||||
- Project `background.jpg` behind the application, button surfaces `#292929`,
|
||||
modal surfaces `#303030`, accent `#c19d60`, and text `#c6c6c6`.
|
||||
- Solid `#292929` dashboard header containing the logo, box name, current
|
||||
application identity, and Bluetooth connection indicator.
|
||||
- Project logo used for the Android launcher icon and centered native splash.
|
||||
The splash variant includes transparent safe-area padding so Android displays
|
||||
the complete logo instead of cropping it through the system icon mask.
|
||||
- Native splash uses the project `background.jpg`. Android 12 only permits a
|
||||
solid color behind its system splash icon, so the first Flutter frame keeps
|
||||
the same centered logo over `background.jpg` for a consistent startup screen.
|
||||
- Native splash uses the project `background.jpg` on pre-Android 12 devices.
|
||||
Android 12 mandates a solid-color system launch screen, so its redundant
|
||||
system icon is transparent and the first Flutter frame presents the single
|
||||
visible logo over `background.jpg`.
|
||||
- Automatic French or English selection from the phone, with a manual override.
|
||||
- Empty state restricted to box registration and encrypted-backup restoration.
|
||||
- Shortcut bar for opening and history, plus modal cards for codes, NFC tags,
|
||||
guests, and settings.
|
||||
- Guest mode hides NFC, guest administration, factory reset, and future firmware
|
||||
update controls.
|
||||
- Shortcut bar for opening, explicit synchronization, and history, plus modal
|
||||
cards for codes, NFC tags, guests, and settings.
|
||||
- When the saved box is offline, a solid `#353535` disconnected banner is shown
|
||||
below the header. Opening and mutating actions are disabled, while history,
|
||||
permanent codes, temporary codes, NFC tags, and guest lists remain readable.
|
||||
The synchronization shortcut becomes a reconnect action until the BLE link is
|
||||
restored.
|
||||
- Dashboard actions anchored as one group to the bottom with equal side and
|
||||
bottom margins; NFC, guests, and settings share one compact three-card row.
|
||||
- Guest mode hides NFC, guest administration, factory reset, firmware update,
|
||||
and permanent-code mutation controls. Guests can read the global code lists
|
||||
and opening history, and can manage temporary codes.
|
||||
|
||||
## Registration and Secure Storage
|
||||
|
||||
@@ -31,25 +42,61 @@ The first phone connecting to an unprovisioned box:
|
||||
5. stores the remote BLE identifier, box name, role, and identity in the phone
|
||||
secure keystore.
|
||||
|
||||
Selecting a discovered box enables the add action immediately, even while the
|
||||
scan timeout is still running. The application stops the Android scan before
|
||||
connecting and displays a persistent registration progress page. If
|
||||
registration fails, the same page shows the complete error and a close action.
|
||||
Once administrator provisioning and secure local storage complete, the progress
|
||||
page closes immediately; clock synchronization continues in the background.
|
||||
|
||||
An administrator creates named guest identities and displays an invitation QR
|
||||
code. A guest scans that QR code to register the same box and its unique guest
|
||||
identity. Saved boxes reconnect automatically when the application starts.
|
||||
identity, even when the box is currently offline. The guest receives the cached
|
||||
offline-readable state only after the first successful connection to the box.
|
||||
The inviter's BLE address is treated only as a non-portable hint: the guest
|
||||
phone performs its own scan, creates its own encrypted bond, and authenticates
|
||||
with the guest identity key when it first reaches the box.
|
||||
Saved boxes reconnect automatically in the background when the application
|
||||
starts. Cached dashboard content is displayed immediately while the box is
|
||||
offline or out of range.
|
||||
|
||||
After every authenticated state synchronization, the application stores the
|
||||
complete state snapshot in Android secure storage, indexed by the application
|
||||
identity rather than the rotating BLE address. Opening history, permanent and
|
||||
one-time codes, NFC tags, and guest identities therefore remain available after
|
||||
an application restart while the box is offline. Forgetting a box also deletes
|
||||
its cached snapshot from the phone.
|
||||
|
||||
Application backups contain the saved connection records. They are protected
|
||||
with AES-256-GCM and a PBKDF2-SHA256 key derived from the user password before a
|
||||
file is written. During restoration, the application first asks for the backup
|
||||
file and only then requests the password needed to decrypt it.
|
||||
file and only then requests the password needed to decrypt it. Settings display
|
||||
the persisted date and time of the most recent successful backup, or `never`
|
||||
when no backup has been created.
|
||||
|
||||
Android Bluetooth bond keys cannot be exported by applications and are not part
|
||||
of the backup. A restored administrator or guest record keeps its OpenParcelBox
|
||||
identity but is marked for BLE rediscovery. On the new phone, the application
|
||||
scans for the stored box name, creates a new encrypted Android bond,
|
||||
authenticates with the restored identity key, and then stores that phone's BLE
|
||||
address. The firmware retains up to four phone bonds and can replace the oldest
|
||||
unused record when needed.
|
||||
|
||||
## Features
|
||||
|
||||
- Direct lock opening and seven-day opening history.
|
||||
- Eight permanent and twenty one-time access codes.
|
||||
- Direct lock opening, explicit state synchronization, and seven-day opening
|
||||
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.
|
||||
- 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.
|
||||
- Firmware clock synchronization after connection.
|
||||
- Language override, encrypted backup/restore, factory reset, and local removal
|
||||
of a saved box.
|
||||
of a saved box. Box reset is offered as an optional checkbox only when an
|
||||
administrator removes the saved box and is disabled while offline; guest
|
||||
removal clears phone data and the Android Bluetooth bond.
|
||||
- Reserved disabled entry for a later firmware-update flow.
|
||||
|
||||
## BLE Security and Protocol
|
||||
@@ -65,6 +112,22 @@ first encrypted state read until bonding has completed. If the initial
|
||||
provisioning connection is dropped because Android retained an obsolete bond,
|
||||
the application removes that bond and retries the setup once.
|
||||
|
||||
Connection timeouts and an out-of-range box never remove the Android bond. Bond
|
||||
recovery is restricted to encrypted GATT setup failures while Android still
|
||||
reports an existing bond. If a saved private BLE address is no longer usable,
|
||||
the application scans for OpenParcelBox devices, verifies the stored box name
|
||||
and application identity, and persists the rediscovered address.
|
||||
|
||||
The application subscribes to the compact state-change notification and then
|
||||
reassembles the complete state from 480-byte pages. Keypad openings and
|
||||
consumed one-time codes therefore update the open application without requiring
|
||||
a reconnect. A manual synchronization action is also available from the
|
||||
dashboard.
|
||||
|
||||
After an administrator factory reset, the application waits for the firmware
|
||||
to clear its persistent state and BLE bonds, removes Android's matching local
|
||||
bond, and only then deletes the saved box record.
|
||||
|
||||
The firmware negotiates an ATT MTU of 247 bytes, leaving 244 bytes for a single
|
||||
GATT write. This keeps the administrator provisioning JSON in one acknowledged
|
||||
write instead of relying on prepared long-write support.
|
||||
@@ -123,3 +186,7 @@ Native assets can be regenerated with:
|
||||
dart run flutter_launcher_icons
|
||||
dart run flutter_native_splash:create
|
||||
```
|
||||
|
||||
After regenerating native splash assets, preserve the Android 12
|
||||
`transparent_splash` override in both `values-v31/styles.xml` files; otherwise
|
||||
the generator restores the redundant masked system icon.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<size
|
||||
android:width="1dp"
|
||||
android:height="1dp" />
|
||||
<solid android:color="@android:color/transparent" />
|
||||
</shape>
|
||||
@@ -7,7 +7,8 @@
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#333333</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/transparent_splash</item>
|
||||
<item name="android:windowSplashScreenAnimationDuration">0</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||
<item name="android:windowSplashScreenBackground">#333333</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/transparent_splash</item>
|
||||
<item name="android:windowSplashScreenAnimationDuration">0</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
||||
@@ -21,6 +21,8 @@ class AppStrings {
|
||||
'en': 'Restore a backup',
|
||||
},
|
||||
'open': {'fr': 'Ouvrir', 'en': 'Open'},
|
||||
'synchronize': {'fr': 'Synchroniser', 'en': 'Synchronize'},
|
||||
'reconnect': {'fr': 'Se reconnecter', 'en': 'Reconnect'},
|
||||
'history': {'fr': 'Historique', 'en': 'History'},
|
||||
'permanent_codes': {'fr': 'Codes permanents', 'en': 'Permanent codes'},
|
||||
'temporary_codes': {'fr': 'Codes temporaires', 'en': 'Temporary codes'},
|
||||
@@ -28,6 +30,7 @@ class AppStrings {
|
||||
'guests': {'fr': 'Invités', 'en': 'Guests'},
|
||||
'settings': {'fr': 'Paramètres', 'en': 'Settings'},
|
||||
'close': {'fr': 'Fermer', 'en': 'Close'},
|
||||
'error': {'fr': 'Erreur', 'en': 'Error'},
|
||||
'add': {'fr': 'Ajouter', 'en': 'Add'},
|
||||
'delete': {'fr': 'Supprimer', 'en': 'Delete'},
|
||||
'edit': {'fr': 'Modifier', 'en': 'Edit'},
|
||||
@@ -39,12 +42,40 @@ class AppStrings {
|
||||
'en': 'Scan a guest QR code',
|
||||
},
|
||||
'box_name': {'fr': 'Identifiant de la box', 'en': 'Box identifier'},
|
||||
'box_name_help': {
|
||||
'fr':
|
||||
'Donnez un nom à cette box, puis sélectionnez l’appareil détecté à proximité.',
|
||||
'en':
|
||||
'Name this box, then select the nearby device that has been detected.',
|
||||
},
|
||||
'nearby_boxes': {
|
||||
'fr': 'OpenParcelBox détectées',
|
||||
'en': 'Detected OpenParcelBox devices',
|
||||
},
|
||||
'search': {'fr': 'Rechercher', 'en': 'Search'},
|
||||
'register_box': {'fr': 'Ajouter cette box', 'en': 'Add this box'},
|
||||
'registration_wait': {
|
||||
'fr':
|
||||
'Connexion sécurisée à la box en cours. Veuillez patienter quelques instants…',
|
||||
'en':
|
||||
'Secure connection to the box is in progress. Please wait a moment…',
|
||||
},
|
||||
'registration_failed': {
|
||||
'fr': 'Impossible d’ajouter la box',
|
||||
'en': 'The box could not be added',
|
||||
},
|
||||
'box_already_has_admin': {
|
||||
'fr':
|
||||
"Cette OpenParcelBox a déjà un administrateur enregistré. Restaurez une sauvegarde de l'application contenant l'identité administrateur, utilisez un QR code invité, ou réinitialisez complètement la box avant de l'ajouter comme nouvel administrateur.",
|
||||
'en':
|
||||
'This OpenParcelBox already has a registered administrator. Restore an app backup that contains the administrator identity, use a guest QR code, or fully reset the box before adding it as a new administrator.',
|
||||
},
|
||||
'box_identity_mismatch': {
|
||||
'fr':
|
||||
"La box refuse l'identité restaurée par l'application. Cela arrive si la box a gardé une autre identité administrateur en NVS. Restaurez la sauvegarde correspondant exactement à cette box, utilisez un QR code invité valide, ou utilisez le bouton de réinitialisation interne.",
|
||||
'en':
|
||||
'The box rejected the restored application identity. This happens when the box still stores a different administrator identity in NVS. Restore the backup that exactly matches this box, use a valid guest QR code, or use the internal reset button.',
|
||||
},
|
||||
'select_box': {
|
||||
'fr': 'Sélectionnez une OpenParcelBox dans la liste.',
|
||||
'en': 'Select an OpenParcelBox from the list.',
|
||||
@@ -54,7 +85,7 @@ class AppStrings {
|
||||
'en': 'No OpenParcelBox detected.',
|
||||
},
|
||||
'connected': {'fr': 'Connectée', 'en': 'Connected'},
|
||||
'disconnected': {'fr': 'Déconnectée', 'en': 'Disconnected'},
|
||||
'disconnected': {'fr': 'Déconnecté', 'en': 'Disconnected'},
|
||||
'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'},
|
||||
@@ -62,6 +93,7 @@ class AppStrings {
|
||||
'fr': 'La limite de codes est atteinte.',
|
||||
'en': 'The code limit has been reached.',
|
||||
},
|
||||
'code_count': {'fr': 'Codes enregistrés', 'en': 'Stored codes'},
|
||||
'no_history': {
|
||||
'fr': 'Aucune ouverture enregistrée.',
|
||||
'en': 'No opening has been recorded.',
|
||||
@@ -72,12 +104,8 @@ class AppStrings {
|
||||
'opened_by_app': {'fr': 'Application', 'en': 'Application'},
|
||||
'tag_name': {'fr': 'Nom du tag', 'en': 'Tag name'},
|
||||
'tag_uid': {'fr': 'UID du tag', 'en': 'Tag UID'},
|
||||
'scan_phone_nfc': {
|
||||
'fr': 'Scanner avec le téléphone',
|
||||
'en': 'Scan with phone',
|
||||
},
|
||||
'pair_on_box': {'fr': 'Appairer sur la box', 'en': 'Pair on the box'},
|
||||
'manual_uid': {'fr': 'Ajouter un UID', 'en': 'Add a UID'},
|
||||
'pairing_mode': {'fr': 'Mode appairage', 'en': 'Pairing mode'},
|
||||
'add_manually': {'fr': 'Ajouter manuellement', 'en': 'Add manually'},
|
||||
'nfc_unavailable': {
|
||||
'fr': 'Le NFC est indisponible sur ce téléphone.',
|
||||
'en': 'NFC is unavailable on this phone.',
|
||||
@@ -86,7 +114,28 @@ class AppStrings {
|
||||
'fr': 'Approchez un tag NFC du téléphone…',
|
||||
'en': 'Hold an NFC tag near the phone…',
|
||||
},
|
||||
'tag_name_help': {
|
||||
'fr': 'Choisissez un nom qui permettra de reconnaître facilement ce tag.',
|
||||
'en': 'Choose a name that will make this tag easy to recognize.',
|
||||
},
|
||||
'rename_tag_help': {
|
||||
'fr': 'Modifiez le nom affiché pour ce tag NFC.',
|
||||
'en': 'Change the display name for this NFC tag.',
|
||||
},
|
||||
'tag_uid_help': {
|
||||
'fr': 'Saisissez l’UID inscrit sur le tag NFC, avec ou sans séparateurs.',
|
||||
'en': 'Enter the UID printed on the NFC tag, with or without separators.',
|
||||
},
|
||||
'pairing_name_help': {
|
||||
'fr':
|
||||
'Nommez le tag, puis présentez-le au lecteur de la box après validation.',
|
||||
'en': 'Name the tag, then hold it near the box reader after confirming.',
|
||||
},
|
||||
'guest_name': {'fr': "Nom de l'invité", 'en': 'Guest name'},
|
||||
'guest_name_help': {
|
||||
'fr': 'Saisissez le nom qui identifiera cet invité dans l’application.',
|
||||
'en': 'Enter the name that will identify this guest in the application.',
|
||||
},
|
||||
'show_qr': {'fr': 'Voir le QR code', 'en': 'Show QR code'},
|
||||
'no_guest': {'fr': 'Aucun invité.', 'en': 'No guest.'},
|
||||
'language': {'fr': 'Langue', 'en': 'Language'},
|
||||
@@ -97,33 +146,63 @@ class AppStrings {
|
||||
'fr': "Sauvegarder les paramètres de l'app",
|
||||
'en': 'Back up app settings',
|
||||
},
|
||||
'last_backup': {'fr': 'Dernière sauvegarde', 'en': 'Last backup'},
|
||||
'last_backup_never': {
|
||||
'fr': 'Dernière sauvegarde : jamais',
|
||||
'en': 'Last backup: never',
|
||||
},
|
||||
'backup_location': {
|
||||
'fr': "Choisir l'emplacement de sauvegarde",
|
||||
'en': 'Choose backup location',
|
||||
},
|
||||
'no_backup_location': {
|
||||
'fr': 'Aucun chemin de sauvegarde n’est actuellement choisi.',
|
||||
'en': 'No backup location is currently selected.',
|
||||
},
|
||||
'restore': {'fr': 'Restaurer une sauvegarde', 'en': 'Restore a backup'},
|
||||
'password': {'fr': 'Mot de passe', 'en': 'Password'},
|
||||
'show_password': {'fr': 'Afficher le mot de passe', 'en': 'Show password'},
|
||||
'hide_password': {'fr': 'Masquer le mot de passe', 'en': 'Hide password'},
|
||||
'backup_password_help': {
|
||||
'fr':
|
||||
'Un mot de passe est obligatoire pour chiffrer la sauvegarde. Il n’est pas enregistré : conservez-le précieusement.',
|
||||
'en':
|
||||
'A password is required to encrypt the backup. It is not stored, so keep it safe.',
|
||||
},
|
||||
'restore_password_help': {
|
||||
'fr':
|
||||
'Saisissez le mot de passe utilisé lors de la création de cette sauvegarde.',
|
||||
'en': 'Enter the password used when this backup was created.',
|
||||
},
|
||||
'input_help': {
|
||||
'fr': 'Renseignez la valeur demandée avant de continuer.',
|
||||
'en': 'Enter the requested value before continuing.',
|
||||
},
|
||||
'firmware_update': {
|
||||
'fr': 'Mise à jour du firmware (à venir)',
|
||||
'en': 'Firmware update (coming later)',
|
||||
},
|
||||
'factory_reset': {
|
||||
'fr': "Réinitialiser la box",
|
||||
'en': 'Factory-reset the box',
|
||||
},
|
||||
'forget_box': {'fr': 'Supprimer la box', 'en': 'Forget the box'},
|
||||
'danger': {'fr': 'Danger', 'en': 'Danger'},
|
||||
'factory_warning': {
|
||||
'forget_guest_warning': {
|
||||
'fr':
|
||||
"Cette action supprime tous les paramètres de la box et autorise l'appairage d'un nouvel administrateur.",
|
||||
"Cette action supprime de ce téléphone les informations de connexion à la box ainsi que son association Bluetooth. Les données et les réglages de la box ne seront pas modifiés.",
|
||||
'en':
|
||||
'This deletes every setting on the box and allows a new administrator to pair.',
|
||||
'This deletes the box connection information and Bluetooth bond from this phone. The box data and settings will not be changed.',
|
||||
},
|
||||
'forget_warning': {
|
||||
'forget_admin_warning': {
|
||||
'fr':
|
||||
"Seules les informations de l'application seront supprimées. Les paramètres de la box restent inchangés et un administrateur peut perdre définitivement l'accès.",
|
||||
"Cette action supprime de ce téléphone les informations de connexion, l'identité administrateur et l'association Bluetooth. Sans réinitialisation, la configuration de la box reste inchangée et l'accès administrateur peut être définitivement perdu. L'option ci-dessous efface également tous les réglages, codes, tags, historiques, identités et liaisons Bluetooth de la box.",
|
||||
'en':
|
||||
'Only app information will be deleted. Box settings remain unchanged and an administrator may permanently lose access.',
|
||||
'This deletes the connection information, administrator identity, and Bluetooth bond from this phone. Without a reset, the box configuration remains unchanged and administrator access may be permanently lost. The option below also erases every box setting, code, tag, history entry, identity, and Bluetooth bond.',
|
||||
},
|
||||
'also_reset_box': {
|
||||
'fr': 'Réinitialiser également la box',
|
||||
'en': 'Also factory-reset the box',
|
||||
},
|
||||
'box_reset_offline_unavailable': {
|
||||
'fr':
|
||||
'Action impossible hors connexion. Le bouton de réinitialisation interne reste disponible pour effacer les identités et les liaisons Bluetooth.',
|
||||
'en': 'This action is unavailable while offline.',
|
||||
},
|
||||
'confirm': {'fr': 'Confirmer', 'en': 'Confirm'},
|
||||
'administrator': {'fr': 'Administrateur', 'en': 'Administrator'},
|
||||
|
||||
@@ -62,10 +62,7 @@ class BackupService {
|
||||
return result?.files.single.bytes;
|
||||
}
|
||||
|
||||
Future<List<SavedBox>> importBoxes(
|
||||
Uint8List bytes,
|
||||
String password,
|
||||
) async {
|
||||
Future<List<SavedBox>> importBoxes(Uint8List bytes, String password) async {
|
||||
if (password.isEmpty) {
|
||||
throw ArgumentError('A backup password is required.');
|
||||
}
|
||||
@@ -103,6 +100,7 @@ class BackupService {
|
||||
).deriveKey(secretKey: SecretKey(utf8.encode(password)), nonce: salt);
|
||||
}
|
||||
|
||||
Uint8List _randomBytes(int length) =>
|
||||
Uint8List.fromList(List<int>.generate(length, (_) => _random.nextInt(256)));
|
||||
Uint8List _randomBytes(int length) => Uint8List.fromList(
|
||||
List<int>.generate(length, (_) => _random.nextInt(256)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,19 @@ 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 {}]);
|
||||
@@ -27,11 +40,30 @@ class OpenParcelBoxCommand {
|
||||
);
|
||||
}
|
||||
|
||||
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})
|
||||
: store = store ?? SecureBoxStore();
|
||||
OpenParcelBoxBleController({
|
||||
SecureBoxStore? store,
|
||||
this.reconnectOnInitialize = true,
|
||||
}) : store = store ?? SecureBoxStore();
|
||||
|
||||
final SecureBoxStore store;
|
||||
@visibleForTesting
|
||||
final bool reconnectOnInitialize;
|
||||
final List<SavedBox> savedBoxes = <SavedBox>[];
|
||||
final List<ScanResult> scanResults = <ScanResult>[];
|
||||
final List<AccessCode> permanentCodes = <AccessCode>[];
|
||||
@@ -46,6 +78,11 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
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;
|
||||
@@ -66,7 +103,7 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
..addAll(await store.loadBoxes());
|
||||
if (savedBoxes.isNotEmpty) {
|
||||
currentBox = savedBoxes.first;
|
||||
await connectSavedBox();
|
||||
await _loadCachedStateForCurrentBox();
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = '$error';
|
||||
@@ -74,6 +111,10 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
initialized = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
if (currentBox != null && reconnectOnInitialize) {
|
||||
unawaited(connectSavedBox());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> startScan() async {
|
||||
@@ -113,12 +154,13 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
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 StateError('This box already has an administrator.');
|
||||
throw const OpenParcelBoxAlreadyProvisionedException();
|
||||
}
|
||||
final key = _generateIdentityKey();
|
||||
await _write(
|
||||
@@ -138,16 +180,8 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
await _upsertSavedBox(saved);
|
||||
currentBox = saved;
|
||||
isAuthenticated = true;
|
||||
try {
|
||||
await syncClock();
|
||||
} catch (error, stackTrace) {
|
||||
debugPrint(
|
||||
'OpenParcelBox clock synchronization after registration failed: '
|
||||
'$error\n$stackTrace',
|
||||
);
|
||||
errorMessage = null;
|
||||
}
|
||||
notifyListeners();
|
||||
_scheduleClockSyncAfterRegistration();
|
||||
} catch (error, stackTrace) {
|
||||
errorMessage = '$error';
|
||||
debugPrint(
|
||||
@@ -161,30 +195,35 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
try {
|
||||
await _connectDevice(device);
|
||||
return await _readState(updateCollections: false);
|
||||
} catch (error, stackTrace) {
|
||||
final canRecover =
|
||||
!kIsWeb &&
|
||||
defaultTargetPlatform == TargetPlatform.android &&
|
||||
error is FlutterBluePlusException &&
|
||||
error.code == FbpErrorCode.deviceIsDisconnected.index;
|
||||
if (!canRecover) {
|
||||
rethrow;
|
||||
}
|
||||
await _connectDeviceWithAndroidBondRecovery(device);
|
||||
return await _readState(updateCollections: false, paginated: false);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'OpenParcelBox initial pairing disconnected; resetting the Android '
|
||||
'bond and retrying once.\n$error\n$stackTrace',
|
||||
);
|
||||
await _resetAndroidBond(device);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 750));
|
||||
await _connectDevice(device);
|
||||
return _readState(updateCollections: false);
|
||||
Future<void> _stopScanBeforeConnection() async {
|
||||
if (FlutterBluePlus.isScanningNow) {
|
||||
await FlutterBluePlus.stopScan();
|
||||
}
|
||||
while (isScanning) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,22 +257,59 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
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 connectSavedBox();
|
||||
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 {
|
||||
await _connectDevice(BluetoothDevice.fromId(box.remoteId));
|
||||
await _write(const OpenParcelBoxCommand('authenticate'));
|
||||
isAuthenticated = true;
|
||||
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;
|
||||
@@ -244,16 +320,173 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectDevice(BluetoothDevice device) async {
|
||||
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: const Duration(seconds: 12),
|
||||
timeout: connectionTimeout,
|
||||
mtu: null,
|
||||
);
|
||||
_connectionSubscription?.cancel();
|
||||
await _stateSubscription?.cancel();
|
||||
_stateSubscription = null;
|
||||
_connectionSubscription = device.connectionState.listen((state) {
|
||||
if (state == BluetoothConnectionState.disconnected) {
|
||||
connectedDevice = null;
|
||||
@@ -274,10 +507,44 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
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;
|
||||
@@ -288,16 +555,61 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
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 device.bondState
|
||||
.where((state) => state != BluetoothBondState.bonding)
|
||||
.first
|
||||
.timeout(const Duration(seconds: 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,
|
||||
@@ -339,16 +651,43 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
|
||||
Future<void> openLock() => send(const OpenParcelBoxCommand('open_lock'));
|
||||
|
||||
Future<void> addGeneratedCode(CredentialKind kind) => send(
|
||||
OpenParcelBoxCommand('add_code', <String, Object?>{
|
||||
'code': _generateCode(),
|
||||
'kind': kind == CredentialKind.oneTime ? 'one_time' : 'permanent',
|
||||
}),
|
||||
);
|
||||
Future<void> synchronize() async {
|
||||
if (!isConnected) {
|
||||
await connectSavedBox();
|
||||
return;
|
||||
}
|
||||
_setBusy(true);
|
||||
try {
|
||||
await _readState();
|
||||
} catch (error) {
|
||||
errorMessage = '$error';
|
||||
rethrow;
|
||||
} finally {
|
||||
_setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeCode(AccessCode code) => send(
|
||||
OpenParcelBoxCommand('remove_code', <String, Object?>{'code': code.code}),
|
||||
);
|
||||
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': _generateCode(),
|
||||
'kind': kind == CredentialKind.oneTime ? 'one_time' : '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?>{
|
||||
@@ -394,6 +733,7 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
|
||||
Future<void> factoryReset() async {
|
||||
await send(const OpenParcelBoxCommand('factory_reset'), refresh: false);
|
||||
await Future<void>.delayed(const Duration(milliseconds: 600));
|
||||
await forgetCurrentBox();
|
||||
}
|
||||
|
||||
@@ -402,23 +742,53 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
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);
|
||||
await connectedDevice?.disconnect();
|
||||
currentBox = savedBoxes.firstOrNull;
|
||||
_clearState();
|
||||
await _loadCachedStateForCurrentBox();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> replaceSavedBoxes(List<SavedBox> boxes) async {
|
||||
savedBoxes
|
||||
..clear()
|
||||
..addAll(boxes);
|
||||
..addAll(boxes.map((box) => box.copyWith(needsRediscovery: true)));
|
||||
await store.saveBoxes(savedBoxes);
|
||||
currentBox = savedBoxes.firstOrNull;
|
||||
await _loadCachedStateForCurrentBox();
|
||||
notifyListeners();
|
||||
if (currentBox != null) {
|
||||
await connectSavedBox();
|
||||
unawaited(connectSavedBox());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,32 +819,96 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
if (authenticate && key == null) {
|
||||
throw StateError('No secure identity is available.');
|
||||
}
|
||||
await characteristic.write(command.encode(key), withoutResponse: false);
|
||||
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 raw = await _stateCharacteristic?.read();
|
||||
if (raw == null) {
|
||||
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 decoded = jsonDecode(utf8.decode(raw));
|
||||
|
||||
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) {
|
||||
permanentCodes.clear();
|
||||
oneTimeCodes.clear();
|
||||
nfcTags.clear();
|
||||
history.clear();
|
||||
guests.clear();
|
||||
void _applyState(Map<String, dynamic> state, {bool notify = true}) {
|
||||
_clearCollections();
|
||||
for (final item in (state['codes'] as List<dynamic>? ?? const [])) {
|
||||
if (item is! Map<String, dynamic> ||
|
||||
item['code'] is! String ||
|
||||
@@ -530,7 +964,21 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
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 {
|
||||
@@ -539,17 +987,37 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
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();
|
||||
status = 'disconnected';
|
||||
}
|
||||
|
||||
String _generateCode() =>
|
||||
@@ -570,6 +1038,7 @@ class OpenParcelBoxBleController extends ChangeNotifier {
|
||||
void dispose() {
|
||||
_scanSubscription?.cancel();
|
||||
_connectionSubscription?.cancel();
|
||||
_stateSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
+1057
-464
File diff suppressed because it is too large
Load Diff
@@ -8,18 +8,24 @@ class SavedBox {
|
||||
required this.name,
|
||||
required this.identityKey,
|
||||
required this.role,
|
||||
this.identityName = '',
|
||||
this.needsRediscovery = false,
|
||||
});
|
||||
|
||||
final String remoteId;
|
||||
final String name;
|
||||
final String identityKey;
|
||||
final BoxRole role;
|
||||
final String identityName;
|
||||
final bool needsRediscovery;
|
||||
|
||||
Map<String, Object?> toJson() => <String, Object?>{
|
||||
'remote_id': remoteId,
|
||||
'name': name,
|
||||
'identity_key': identityKey,
|
||||
'role': role.name,
|
||||
'identity_name': identityName,
|
||||
'needs_rediscovery': needsRediscovery,
|
||||
};
|
||||
|
||||
factory SavedBox.fromJson(Map<String, Object?> json) => SavedBox(
|
||||
@@ -29,15 +35,22 @@ class SavedBox {
|
||||
role: json['role'] == BoxRole.guest.name
|
||||
? BoxRole.guest
|
||||
: BoxRole.administrator,
|
||||
identityName: json['identity_name'] as String? ?? '',
|
||||
needsRediscovery: json['needs_rediscovery'] as bool? ?? false,
|
||||
);
|
||||
|
||||
SavedBox copyWith({String? remoteId, bool? needsRediscovery}) => SavedBox(
|
||||
remoteId: remoteId ?? this.remoteId,
|
||||
name: name,
|
||||
identityKey: identityKey,
|
||||
role: role,
|
||||
identityName: identityName,
|
||||
needsRediscovery: needsRediscovery ?? this.needsRediscovery,
|
||||
);
|
||||
}
|
||||
|
||||
class AccessCode {
|
||||
const AccessCode({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.kind,
|
||||
});
|
||||
const AccessCode({required this.id, required this.code, required this.kind});
|
||||
|
||||
final String id;
|
||||
final String code;
|
||||
|
||||
@@ -11,6 +11,8 @@ class SecureBoxStore {
|
||||
static const _boxesKey = 'opb.saved-boxes.v2';
|
||||
static const _languageKey = 'opb.language';
|
||||
static const _backupDirectoryKey = 'opb.backup-directory';
|
||||
static const _lastBackupAtKey = 'opb.last-backup-at';
|
||||
static const _cachedStatesKey = 'opb.cached-box-states.v1';
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
Future<List<SavedBox>> loadBoxes() async {
|
||||
@@ -33,6 +35,51 @@ class SecureBoxStore {
|
||||
value: jsonEncode(boxes.map((box) => box.toJson()).toList()),
|
||||
);
|
||||
|
||||
Future<Map<String, dynamic>?> loadCachedState(String identityKey) async {
|
||||
final states = await _loadCachedStates();
|
||||
final state = states[identityKey];
|
||||
if (state is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
return Map<String, dynamic>.from(state);
|
||||
}
|
||||
|
||||
Future<void> saveCachedState(
|
||||
String identityKey,
|
||||
Map<String, dynamic> state,
|
||||
) async {
|
||||
final states = await _loadCachedStates();
|
||||
states[identityKey] = state;
|
||||
await _storage.write(key: _cachedStatesKey, value: jsonEncode(states));
|
||||
}
|
||||
|
||||
Future<void> deleteCachedState(String identityKey) async {
|
||||
final states = await _loadCachedStates();
|
||||
if (states.remove(identityKey) == null) {
|
||||
return;
|
||||
}
|
||||
if (states.isEmpty) {
|
||||
await _storage.delete(key: _cachedStatesKey);
|
||||
} else {
|
||||
await _storage.write(key: _cachedStatesKey, value: jsonEncode(states));
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> _loadCachedStates() async {
|
||||
final raw = await _storage.read(key: _cachedStatesKey);
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
return decoded is Map<String, dynamic>
|
||||
? Map<String, dynamic>.from(decoded)
|
||||
: <String, dynamic>{};
|
||||
} on FormatException {
|
||||
return <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> loadLanguage() => _storage.read(key: _languageKey);
|
||||
|
||||
Future<void> saveLanguage(String? language) async {
|
||||
@@ -48,4 +95,14 @@ class SecureBoxStore {
|
||||
|
||||
Future<void> saveBackupDirectory(String path) =>
|
||||
_storage.write(key: _backupDirectoryKey, value: path);
|
||||
|
||||
Future<DateTime?> loadLastBackupAt() async {
|
||||
final value = await _storage.read(key: _lastBackupAtKey);
|
||||
return value == null ? null : DateTime.tryParse(value)?.toLocal();
|
||||
}
|
||||
|
||||
Future<void> saveLastBackupAt(DateTime date) => _storage.write(
|
||||
key: _lastBackupAtKey,
|
||||
value: date.toUtc().toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,6 +86,9 @@ flutter_native_splash:
|
||||
ios: false
|
||||
android_12:
|
||||
color: "#333333"
|
||||
# Android 12 always displays a system launch screen. The generated icon is
|
||||
# replaced by transparent_splash in values-v31/styles.xml so only the
|
||||
# branded Flutter splash remains visibly displayed.
|
||||
image: images/openparcelbox-splash.png
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
|
||||
@@ -1,26 +1,64 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
|
||||
import 'package:app/backup_service.dart';
|
||||
import 'package:app/ble_controller.dart';
|
||||
import 'package:app/main.dart';
|
||||
import 'package:app/models.dart';
|
||||
import 'package:app/secure_box_store.dart';
|
||||
|
||||
class _MemoryStore extends SecureBoxStore {
|
||||
_MemoryStore({this.language = 'en'});
|
||||
_MemoryStore({
|
||||
this.language = 'en',
|
||||
this.boxes = const <SavedBox>[],
|
||||
this.lastBackupAt,
|
||||
Map<String, Map<String, dynamic>>? cachedStates,
|
||||
}) : cachedStates = cachedStates ?? <String, Map<String, dynamic>>{};
|
||||
|
||||
final String? language;
|
||||
final List<SavedBox> boxes;
|
||||
DateTime? lastBackupAt;
|
||||
final Map<String, Map<String, dynamic>> cachedStates;
|
||||
|
||||
@override
|
||||
Future<List<SavedBox>> loadBoxes() async => <SavedBox>[];
|
||||
Future<List<SavedBox>> loadBoxes() async => boxes;
|
||||
|
||||
@override
|
||||
Future<String?> loadLanguage() async => language;
|
||||
|
||||
@override
|
||||
Future<void> saveBoxes(List<SavedBox> boxes) async {}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>?> loadCachedState(String identityKey) async {
|
||||
final state = cachedStates[identityKey];
|
||||
return state == null ? null : Map<String, dynamic>.from(state);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveCachedState(
|
||||
String identityKey,
|
||||
Map<String, dynamic> state,
|
||||
) async {
|
||||
cachedStates[identityKey] = Map<String, dynamic>.from(state);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteCachedState(String identityKey) async {
|
||||
cachedStates.remove(identityKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DateTime?> loadLastBackupAt() async => lastBackupAt;
|
||||
|
||||
@override
|
||||
Future<void> saveLastBackupAt(DateTime date) async {
|
||||
lastBackupAt = date;
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeBackupService extends BackupService {
|
||||
@@ -38,6 +76,119 @@ class _FakeBackupService extends BackupService {
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('connection timeouts never trigger Android bond recovery', () {
|
||||
final timeout = FlutterBluePlusException(
|
||||
ErrorPlatform.fbp,
|
||||
'connect',
|
||||
FbpErrorCode.timeout.index,
|
||||
'Timed out',
|
||||
);
|
||||
final encryptedGattFailure = FlutterBluePlusException(
|
||||
ErrorPlatform.android,
|
||||
'setNotifyValue',
|
||||
5,
|
||||
'Insufficient authentication',
|
||||
);
|
||||
|
||||
expect(isRecoverableAndroidBondFailure(timeout), isFalse);
|
||||
expect(isRecoverableAndroidBondFailure(encryptedGattFailure), isTrue);
|
||||
});
|
||||
|
||||
const transparentPixel = <int>[
|
||||
0x89,
|
||||
0x50,
|
||||
0x4E,
|
||||
0x47,
|
||||
0x0D,
|
||||
0x0A,
|
||||
0x1A,
|
||||
0x0A,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0D,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x08,
|
||||
0x06,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1F,
|
||||
0x15,
|
||||
0xC4,
|
||||
0x89,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0A,
|
||||
0x49,
|
||||
0x44,
|
||||
0x41,
|
||||
0x54,
|
||||
0x78,
|
||||
0x9C,
|
||||
0x63,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x05,
|
||||
0x00,
|
||||
0x01,
|
||||
0x0D,
|
||||
0x0A,
|
||||
0x2D,
|
||||
0xB4,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x49,
|
||||
0x45,
|
||||
0x4E,
|
||||
0x44,
|
||||
0xAE,
|
||||
0x42,
|
||||
0x60,
|
||||
0x82,
|
||||
];
|
||||
|
||||
setUpAll(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMessageHandler('flutter/assets', (ByteData? message) async {
|
||||
final key = String.fromCharCodes(message!.buffer.asUint8List());
|
||||
if (key == 'AssetManifest.bin') {
|
||||
return const StandardMessageCodec().encodeMessage(<String, Object>{
|
||||
'images/background.jpg': <Object>[
|
||||
<String, Object>{'asset': 'images/background.jpg'},
|
||||
],
|
||||
'images/openparcelbox-logo-256.png': <Object>[
|
||||
<String, Object>{'asset': 'images/openparcelbox-logo-256.png'},
|
||||
],
|
||||
});
|
||||
}
|
||||
if (key == 'images/background.jpg' ||
|
||||
key == 'images/openparcelbox-logo-256.png') {
|
||||
final bytes = Uint8List.fromList(transparentPixel);
|
||||
return ByteData.sublistView(bytes);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets('startup splash shows the background and centered logo', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@@ -67,6 +218,290 @@ void main() {
|
||||
expect(find.text('Restore a backup'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.lock_open), findsNothing);
|
||||
expect(find.text('Permanent codes'), findsNothing);
|
||||
final background = tester.widget<DecoratedBox>(
|
||||
find.byKey(const ValueKey<String>('app-background')),
|
||||
);
|
||||
final decoration = background.decoration as BoxDecoration;
|
||||
expect(decoration.image?.image, const AssetImage('images/background.jpg'));
|
||||
});
|
||||
|
||||
test('saved guest identity names survive backup serialization', () {
|
||||
const box = SavedBox(
|
||||
remoteId: 'AA:BB',
|
||||
name: 'Front gate',
|
||||
identityKey: '1234',
|
||||
role: BoxRole.guest,
|
||||
identityName: 'Alice',
|
||||
);
|
||||
|
||||
final restored = SavedBox.fromJson(box.toJson());
|
||||
|
||||
expect(restored.identityName, 'Alice');
|
||||
});
|
||||
|
||||
test('restored connection records can require BLE rediscovery', () {
|
||||
const box = SavedBox(
|
||||
remoteId: 'AA:BB',
|
||||
name: 'Front gate',
|
||||
identityKey: '1234',
|
||||
role: BoxRole.administrator,
|
||||
);
|
||||
|
||||
final portable = box.copyWith(needsRediscovery: true);
|
||||
final restored = SavedBox.fromJson(portable.toJson());
|
||||
|
||||
expect(restored.identityKey, box.identityKey);
|
||||
expect(restored.needsRediscovery, isTrue);
|
||||
});
|
||||
|
||||
test('guest invitations transfer the guest identity key', () {
|
||||
final controller = OpenParcelBoxBleController(store: _MemoryStore())
|
||||
..currentBox = const SavedBox(
|
||||
remoteId: 'AA:BB',
|
||||
name: 'Front gate',
|
||||
identityKey: 'ADMIN',
|
||||
role: BoxRole.administrator,
|
||||
);
|
||||
const guest = GuestIdentity(name: 'Alice', key: 'GUEST-KEY');
|
||||
|
||||
final invitation =
|
||||
jsonDecode(controller.invitationPayload(guest)) as Map<String, dynamic>;
|
||||
|
||||
expect(invitation['format'], 'openparcelbox-invite-v1');
|
||||
expect(invitation['box_name'], 'Front gate');
|
||||
expect(invitation['guest_name'], 'Alice');
|
||||
expect(invitation['guest_key'], 'GUEST-KEY');
|
||||
});
|
||||
|
||||
test('cached box state is available before BLE reconnection', () async {
|
||||
final store = _MemoryStore(
|
||||
boxes: const <SavedBox>[
|
||||
SavedBox(
|
||||
remoteId: 'AA:BB',
|
||||
name: 'Front gate',
|
||||
identityKey: 'ADMIN',
|
||||
role: BoxRole.administrator,
|
||||
),
|
||||
],
|
||||
cachedStates: <String, Map<String, dynamic>>{
|
||||
'ADMIN': <String, dynamic>{
|
||||
'codes': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'slot': 0, 'code': '123456', 'kind': 'permanent'},
|
||||
<String, dynamic>{'slot': 1, 'code': '654321', 'kind': 'one_time'},
|
||||
],
|
||||
'nfc_tags': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'slot': 0, 'uid': '60:4F:E2:B5', 'name': 'Alice'},
|
||||
],
|
||||
'history': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'unix_ms': 1784318264772,
|
||||
'kind': 0,
|
||||
'actor': 'Administrator',
|
||||
},
|
||||
],
|
||||
'guests': <Map<String, dynamic>>[
|
||||
<String, dynamic>{'name': 'Bob', 'key': 'GUEST'},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
final controller = OpenParcelBoxBleController(
|
||||
store: store,
|
||||
reconnectOnInitialize: false,
|
||||
);
|
||||
|
||||
await controller.initialize();
|
||||
|
||||
expect(controller.permanentCodes.single.code, '123456');
|
||||
expect(controller.oneTimeCodes.single.code, '654321');
|
||||
expect(controller.nfcTags.single.name, 'Alice');
|
||||
expect(controller.history.single.actor, 'Administrator');
|
||||
expect(controller.guests.single.name, 'Bob');
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
test('guest logic rejects permanent-code mutations', () {
|
||||
final controller = OpenParcelBoxBleController(store: _MemoryStore())
|
||||
..currentBox = const SavedBox(
|
||||
remoteId: 'AA:BB',
|
||||
name: 'Front gate',
|
||||
identityKey: '1234',
|
||||
role: BoxRole.guest,
|
||||
);
|
||||
|
||||
expect(
|
||||
() => controller.addGeneratedCode(CredentialKind.permanent),
|
||||
throwsStateError,
|
||||
);
|
||||
expect(
|
||||
() => controller.removeCode(
|
||||
const AccessCode(
|
||||
id: 'slot-0',
|
||||
code: '123456',
|
||||
kind: CredentialKind.permanent,
|
||||
),
|
||||
),
|
||||
throwsStateError,
|
||||
);
|
||||
controller.dispose();
|
||||
});
|
||||
|
||||
testWidgets('administrator compact actions share one bottom row', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MyApp(
|
||||
store: _MemoryStore(
|
||||
lastBackupAt: null,
|
||||
boxes: const <SavedBox>[
|
||||
SavedBox(
|
||||
remoteId: 'AA:BB',
|
||||
name: 'Front gate',
|
||||
identityKey: '1234',
|
||||
role: BoxRole.administrator,
|
||||
),
|
||||
],
|
||||
),
|
||||
splashDuration: Duration.zero,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final tags = tester.getRect(
|
||||
find.byKey(const ValueKey<String>('compact-NFC tags')),
|
||||
);
|
||||
final guests = tester.getRect(
|
||||
find.byKey(const ValueKey<String>('compact-Guests')),
|
||||
);
|
||||
final settings = tester.getRect(
|
||||
find.byKey(const ValueKey<String>('compact-Settings')),
|
||||
);
|
||||
final quickActions = tester.getRect(
|
||||
find.byKey(const ValueKey<String>('quick-actions')),
|
||||
);
|
||||
final codeActions = tester.getRect(
|
||||
find.byKey(const ValueKey<String>('code-actions')),
|
||||
);
|
||||
|
||||
expect(tags.top, guests.top);
|
||||
expect(guests.top, settings.top);
|
||||
expect(tags.width, guests.width);
|
||||
expect(guests.width, settings.width);
|
||||
expect(800 - settings.bottom, 20);
|
||||
expect(codeActions.top - quickActions.bottom, 28);
|
||||
await tester.tap(find.byKey(const ValueKey<String>('compact-Settings')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Last backup: never'), findsOneWidget);
|
||||
expect(find.text('Factory-reset the box'), findsNothing);
|
||||
expect(find.text('Disconnected'), findsOneWidget);
|
||||
expect(find.text('Reconnect'), findsOneWidget);
|
||||
await tester.ensureVisible(
|
||||
find.widgetWithText(OutlinedButton, 'Forget the box'),
|
||||
);
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, 'Forget the box'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.textContaining(
|
||||
'This deletes the connection information, administrator identity, '
|
||||
'and Bluetooth bond',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(CheckboxListTile), findsOneWidget);
|
||||
expect(tester.widget<Checkbox>(find.byType(Checkbox)).value, isFalse);
|
||||
expect(tester.widget<Checkbox>(find.byType(Checkbox)).onChanged, isNull);
|
||||
expect(
|
||||
find.text('This action is unavailable while offline.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.byType(Checkbox));
|
||||
await tester.pump();
|
||||
expect(tester.widget<Checkbox>(find.byType(Checkbox)).value, isFalse);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('guest can read history and manage only temporary codes', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
await tester.binding.setSurfaceSize(const Size(400, 800));
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
await tester.pumpWidget(
|
||||
MyApp(
|
||||
store: _MemoryStore(
|
||||
boxes: const <SavedBox>[
|
||||
SavedBox(
|
||||
remoteId: 'AA:BB',
|
||||
name: 'Front gate',
|
||||
identityKey: '1234',
|
||||
role: BoxRole.guest,
|
||||
identityName: 'Alice',
|
||||
),
|
||||
],
|
||||
),
|
||||
splashDuration: Duration.zero,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final temporary = tester.getRect(
|
||||
find.byKey(const ValueKey<String>('temporary-code-action')),
|
||||
);
|
||||
final permanent = tester.getRect(
|
||||
find.byKey(const ValueKey<String>('permanent-code-action')),
|
||||
);
|
||||
expect(temporary.left, lessThan(permanent.left));
|
||||
|
||||
await tester.tap(find.text('History'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('No opening has been recorded.'), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.close));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('permanent-code-action')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('Generate a code'), findsNothing);
|
||||
expect(find.text('Stored codes: 0/8'), findsOneWidget);
|
||||
await tester.tap(find.byIcon(Icons.close));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey<String>('temporary-code-action')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('Generate a code'), findsOneWidget);
|
||||
final generateButton = tester.widget<FilledButton>(
|
||||
find.widgetWithText(FilledButton, 'Generate a code (0/20)'),
|
||||
);
|
||||
expect(generateButton.onPressed, isNull);
|
||||
await tester.tap(find.byIcon(Icons.close));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey<String>('compact-Settings')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Firmware update (coming later)'), findsNothing);
|
||||
expect(find.text('Factory-reset the box'), findsNothing);
|
||||
await tester.ensureVisible(
|
||||
find.widgetWithText(OutlinedButton, 'Forget the box'),
|
||||
);
|
||||
await tester.tap(find.widgetWithText(OutlinedButton, 'Forget the box'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.textContaining(
|
||||
'This deletes the box connection information and Bluetooth bond '
|
||||
'from this phone.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(CheckboxListTile), findsNothing);
|
||||
await tester.tap(find.text('Cancel'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('registration opens the styled setup modal', (
|
||||
@@ -146,8 +581,30 @@ void main() {
|
||||
|
||||
expect(backupService.fileWasPicked, isTrue);
|
||||
expect(find.text('Password'), findsNWidgets(2));
|
||||
expect(
|
||||
find.text('Enter the password used when this backup was created.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Confirm'))
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).obscureText,
|
||||
isTrue,
|
||||
);
|
||||
await tester.tap(find.byIcon(Icons.visibility));
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).obscureText,
|
||||
isFalse,
|
||||
);
|
||||
expect(find.byIcon(Icons.visibility_off), findsOneWidget);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'secret');
|
||||
await tester.pump();
|
||||
await tester.tap(find.text('Confirm'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
|
||||
Reference in New Issue
Block a user