fix(android): fix application connection

This commit is contained in:
2026-07-16 18:50:21 +02:00
parent 34e9d3f9ef
commit 161f09d88c
28 changed files with 559 additions and 241 deletions
+27
View File
@@ -82,6 +82,33 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
### Fixed
- Fixed Android box registration discovery by scanning nearby BLE
advertisements before applying the OpenParcelBox name/service filter in the
application, waiting for the actual scan timeout, and refreshing registration
results as advertisements arrive.
- Replaced the per-device registration icon with radio-button box selection and
an explicit add button, with inline errors and terminal diagnostics for
provisioning failures.
- Kept a completed administrator registration when the following clock
synchronization fails instead of reporting the whole addition as failed.
- Serialized Android BLE connection, secure bonding, MTU negotiation, and GATT
discovery before the first encrypted state read.
- Added one-time stale-bond recovery when Android disconnects during the first
encrypted provisioning read.
- Requested BLE security immediately on firmware connection and restarted
connectable advertising after every recycled connection, including failed
initial pairing attempts.
- Replaced Zephyr Secure Connections Only Mode, which implicitly required
unavailable level-4 passkey/OOB authentication, with Secure Connections
pairing-only mode and encrypted level-2 bonding for the headless XIAO.
- Added a Material surface behind registration radio rows so Android ink and
selection rendering no longer emits an invisible-background warning.
- Increased the firmware ATT MTU to 247 bytes with matching ACL buffers so the
120-byte administrator provisioning JSON fits in one acknowledged GATT write
instead of exceeding the previous 62-byte payload limit.
- Fixed the Android startup logo safe area so the complete logo remains
centered instead of being cropped by the system splash icon mask, while
restoring `background.jpg` and increasing the visible logo size.
- Removed oversized Settings callback allocations that exceeded the 1024-byte
main stack once opening-history or application-identity records existed.
- Moved Settings/NVS loading to a dedicated 4096-byte services thread and
+24 -3
View File
@@ -19,9 +19,13 @@ at the newest persisted opening timestamp after a reboot.
## Security
Pairing is restricted to BLE LE Secure Connections. BLE AES-CCM link encryption
protects every GATT command, state read, and notification, and Zephyr persists
bonding keys.
Pairing is restricted to BLE LE Secure Connections with legacy pairing
disabled. The headless XIAO uses encrypted Security Mode 1 Level 2 pairing;
Security Mode 1 Level 4 is not requested because it requires a passkey display,
input, or out-of-band confirmation that the box does not provide. BLE AES-CCM
link encryption protects every GATT command, state read, and notification, and
Zephyr persists bonding keys. The separate random application identity remains
mandatory for command authorization.
At startup, the controller is enabled first, the Zephyr `bt/*` settings subtree
is then restored, and advertising starts only after the Bluetooth identity has
@@ -32,6 +36,23 @@ 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 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
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.
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.
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
+4 -1
View File
@@ -25,7 +25,10 @@ CONFIG_BT_MAX_CONN=1
CONFIG_BT_SMP=y
CONFIG_BT_BONDABLE=y
CONFIG_BT_SETTINGS=y
CONFIG_BT_SMP_SC_ONLY=y
CONFIG_BT_SMP_SC_PAIR_ONLY=y
CONFIG_BT_PRIVACY=y
CONFIG_BT_RX_STACK_SIZE=2048
CONFIG_BT_BUF_ACL_RX_SIZE=251
CONFIG_BT_BUF_ACL_TX_SIZE=251
CONFIG_BT_L2CAP_TX_MTU=247
CONFIG_ENTROPY_GENERATOR=y
+49 -1
View File
@@ -69,6 +69,7 @@ static ssize_t command_write(struct bt_conn *conn,
const struct bt_gatt_attr *attr, const void *buf,
uint16_t len, uint16_t offset, uint8_t flags);
static void state_ccc_changed(const struct bt_gatt_attr *attr, uint16_t value);
static void restart_advertising(void);
static void factory_reset_work_handler(struct k_work *work) {
ARG_UNUSED(work);
@@ -76,8 +77,23 @@ static void factory_reset_work_handler(struct k_work *work) {
printf("BLE bonds cleared after factory reset\n");
}
static void connected(struct bt_conn *conn, uint8_t err) {
int ret;
if (err != 0U) {
printf("BLE connection failed: %u\n", err);
return;
}
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) {
ARG_UNUSED(reason);
printf("BLE disconnected: %u\n", reason);
if (conn == authenticated_conn) {
authenticated_conn = NULL;
memset(&authenticated_identity, 0, sizeof(authenticated_identity));
@@ -85,8 +101,27 @@ static void disconnected(struct bt_conn *conn, uint8_t reason) {
}
}
static void security_changed(struct bt_conn *conn, bt_security_t level,
enum bt_security_err err) {
ARG_UNUSED(conn);
if (err == BT_SECURITY_ERR_SUCCESS) {
printf("BLE security established: level %u\n", level);
return;
}
printf("BLE security failed: level %u error %d\n", level, err);
if (!app_identities_has_admin()) {
(void)bt_unpair(BT_ID_DEFAULT, NULL);
printf("BLE bonds cleared after failed initial pairing\n");
}
}
BT_CONN_CB_DEFINE(opb_conn_callbacks) = {
.connected = connected,
.disconnected = disconnected,
.recycled = restart_advertising,
.security_changed = security_changed,
};
BT_GATT_SERVICE_DEFINE(
@@ -111,6 +146,19 @@ static const struct bt_data scan_response_data[] = {
sizeof(CONFIG_BT_DEVICE_NAME) - 1),
};
static void restart_advertising(void) {
int ret = bt_le_adv_start(BT_LE_ADV_CONN_FAST_1, advertising_data,
ARRAY_SIZE(advertising_data), scan_response_data,
ARRAY_SIZE(scan_response_data));
if (ret < 0 && ret != -EALREADY) {
printf("BLE advertising restart failed: %d\n", ret);
return;
}
printf("BLE advertising: OpenParcelBox\n");
}
static const char *json_skip_ws(const char *cursor) {
while (*cursor != '\0' && isspace((unsigned char)*cursor)) {
cursor++;
+25 -4
View File
@@ -7,7 +7,11 @@ Flutter application for local OpenParcelBox setup and administration.
- Dark solid background `#333333`, button surfaces `#292929`, modal surfaces
`#303030`, accent `#c19d60`, and text `#c6c6c6`.
- Project logo used for the Android launcher icon and centered native splash.
- Native splash uses the project `background.jpg`.
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.
- 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,
@@ -20,9 +24,11 @@ Flutter application for local OpenParcelBox setup and administration.
The first phone connecting to an unprovisioned box:
1. chooses the box identifier;
2. generates a random 128-bit administrator identity;
3. sends both values over the encrypted BLE link;
4. stores the remote BLE identifier, box name, role, and identity in the phone
2. selects one detected box with a radio button and confirms with the explicit
add button;
3. generates a random 128-bit administrator identity;
4. sends both values over the encrypted BLE link;
5. stores the remote BLE identifier, box name, role, and identity in the phone
secure keystore.
An administrator creates named guest identities and displays an invitation QR
@@ -53,6 +59,16 @@ Bluetooth uses AES-CCM link encryption. Every application command is also
authorized with the random administrator or guest identity stored in the phone
keystore.
On Android, connection setup is serialized as connection, secure bonding, MTU
negotiation, and GATT service discovery. The application does not perform the
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.
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.
Service UUID:
```text
@@ -87,6 +103,11 @@ The app uses:
`flutter_blue_plus` 2.3.10 requires a license mode when connecting. The app uses
`License.nonprofit` for this open-source project.
The discovery scan is intentionally unfiltered at the Android API level, then
restricted in the application to advertisements carrying the OpenParcelBox
name or service UUID. The registration modal listens to scan results in real
time and the scan action remains active until the plugin's timeout completes.
## Development
```powershell
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

+126 -146
View File
@@ -7,151 +7,131 @@ class AppStrings {
bool get isFrench => locale.languageCode == 'fr';
String text(String key) => (_values[key] ?? const <String, String>{})[
isFrench ? 'fr' : 'en'] ??
key;
String text(String key) =>
(_values[key] ?? const <String, String>{})[isFrench ? 'fr' : 'en'] ?? key;
static const Map<String, Map<String, String>> _values =
<String, Map<String, String>>{
'add_box': {
'fr': 'Ajouter une nouvelle OpenParcelBox',
'en': 'Add a new OpenParcelBox',
},
'restore_backup': {
'fr': 'Restaurer une sauvegarde',
'en': 'Restore a backup',
},
'open': {'fr': 'Ouvrir', 'en': 'Open'},
'history': {'fr': 'Historique', 'en': 'History'},
'permanent_codes': {
'fr': 'Codes permanents',
'en': 'Permanent codes',
},
'temporary_codes': {
'fr': 'Codes temporaires',
'en': 'Temporary codes',
},
'nfc_tags': {'fr': 'Tags NFC', 'en': 'NFC tags'},
'guests': {'fr': 'Invités', 'en': 'Guests'},
'settings': {'fr': 'Paramètres', 'en': 'Settings'},
'close': {'fr': 'Fermer', 'en': 'Close'},
'add': {'fr': 'Ajouter', 'en': 'Add'},
'delete': {'fr': 'Supprimer', 'en': 'Delete'},
'edit': {'fr': 'Modifier', 'en': 'Edit'},
'cancel': {'fr': 'Annuler', 'en': 'Cancel'},
'save': {'fr': 'Enregistrer', 'en': 'Save'},
'scan': {'fr': 'Scanner', 'en': 'Scan'},
'scan_qr': {
'fr': 'Scanner un QR code invité',
'en': 'Scan a guest QR code',
},
'box_name': {
'fr': 'Identifiant de la box',
'en': 'Box identifier',
},
'nearby_boxes': {
'fr': 'OpenParcelBox détectées',
'en': 'Detected OpenParcelBox devices',
},
'search': {'fr': 'Rechercher', 'en': 'Search'},
'no_results': {
'fr': 'Aucune OpenParcelBox détectée.',
'en': 'No OpenParcelBox detected.',
},
'connected': {'fr': 'Connectée', 'en': 'Connected'},
'disconnected': {'fr': 'Déconnectée', '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'},
'code_limit': {
'fr': 'La limite de codes est atteinte.',
'en': 'The code limit has been reached.',
},
'no_history': {
'fr': 'Aucune ouverture enregistrée.',
'en': 'No opening has been recorded.',
},
'opened_by_permanent': {
'fr': 'Code permanent',
'en': 'Permanent code',
},
'opened_by_temporary': {
'fr': 'Code temporaire',
'en': 'Temporary code',
},
'opened_by_nfc': {'fr': 'Tag NFC', 'en': 'NFC tag'},
'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'},
'nfc_unavailable': {
'fr': 'Le NFC est indisponible sur ce téléphone.',
'en': 'NFC is unavailable on this phone.',
},
'hold_tag': {
'fr': 'Approchez un tag NFC du téléphone…',
'en': 'Hold an NFC tag near the phone…',
},
'guest_name': {'fr': "Nom de l'invité", 'en': 'Guest name'},
'show_qr': {'fr': 'Voir le QR code', 'en': 'Show QR code'},
'no_guest': {'fr': 'Aucun invité.', 'en': 'No guest.'},
'language': {'fr': 'Langue', 'en': 'Language'},
'automatic': {'fr': 'Automatique', 'en': 'Automatic'},
'french': {'fr': 'Français', 'en': 'French'},
'english': {'fr': 'Anglais', 'en': 'English'},
'backup': {
'fr': "Sauvegarder les paramètres de l'app",
'en': 'Back up app settings',
},
'backup_location': {
'fr': "Choisir l'emplacement de sauvegarde",
'en': 'Choose backup location',
},
'restore': {
'fr': 'Restaurer une sauvegarde',
'en': 'Restore a backup',
},
'password': {'fr': 'Mot de passe', 'en': 'Password'},
'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': {
'fr':
"Cette action supprime tous les paramètres de la box et autorise l'appairage d'un nouvel administrateur.",
'en':
'This deletes every setting on the box and allows a new administrator to pair.',
},
'forget_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.",
'en':
'Only app information will be deleted. Box settings remain unchanged and an administrator may permanently lose access.',
},
'confirm': {'fr': 'Confirmer', 'en': 'Confirm'},
'administrator': {'fr': 'Administrateur', 'en': 'Administrator'},
'guest': {'fr': 'Invité', 'en': 'Guest'},
'backup_done': {
'fr': 'Sauvegarde chiffrée créée.',
'en': 'Encrypted backup created.',
},
'restore_done': {
'fr': 'Sauvegarde restaurée.',
'en': 'Backup restored.',
},
};
static const Map<String, Map<String, String>>
_values = <String, Map<String, String>>{
'add_box': {
'fr': 'Ajouter une nouvelle OpenParcelBox',
'en': 'Add a new OpenParcelBox',
},
'restore_backup': {
'fr': 'Restaurer une sauvegarde',
'en': 'Restore a backup',
},
'open': {'fr': 'Ouvrir', 'en': 'Open'},
'history': {'fr': 'Historique', 'en': 'History'},
'permanent_codes': {'fr': 'Codes permanents', 'en': 'Permanent codes'},
'temporary_codes': {'fr': 'Codes temporaires', 'en': 'Temporary codes'},
'nfc_tags': {'fr': 'Tags NFC', 'en': 'NFC tags'},
'guests': {'fr': 'Invités', 'en': 'Guests'},
'settings': {'fr': 'Paramètres', 'en': 'Settings'},
'close': {'fr': 'Fermer', 'en': 'Close'},
'add': {'fr': 'Ajouter', 'en': 'Add'},
'delete': {'fr': 'Supprimer', 'en': 'Delete'},
'edit': {'fr': 'Modifier', 'en': 'Edit'},
'cancel': {'fr': 'Annuler', 'en': 'Cancel'},
'save': {'fr': 'Enregistrer', 'en': 'Save'},
'scan': {'fr': 'Scanner', 'en': 'Scan'},
'scan_qr': {
'fr': 'Scanner un QR code invité',
'en': 'Scan a guest QR code',
},
'box_name': {'fr': 'Identifiant de la box', 'en': 'Box identifier'},
'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'},
'select_box': {
'fr': 'Sélectionnez une OpenParcelBox dans la liste.',
'en': 'Select an OpenParcelBox from the list.',
},
'no_results': {
'fr': 'Aucune OpenParcelBox détectée.',
'en': 'No OpenParcelBox detected.',
},
'connected': {'fr': 'Connectée', 'en': 'Connected'},
'disconnected': {'fr': 'Déconnectée', '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'},
'code_limit': {
'fr': 'La limite de codes est atteinte.',
'en': 'The code limit has been reached.',
},
'no_history': {
'fr': 'Aucune ouverture enregistrée.',
'en': 'No opening has been recorded.',
},
'opened_by_permanent': {'fr': 'Code permanent', 'en': 'Permanent code'},
'opened_by_temporary': {'fr': 'Code temporaire', 'en': 'Temporary code'},
'opened_by_nfc': {'fr': 'Tag NFC', 'en': 'NFC tag'},
'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'},
'nfc_unavailable': {
'fr': 'Le NFC est indisponible sur ce téléphone.',
'en': 'NFC is unavailable on this phone.',
},
'hold_tag': {
'fr': 'Approchez un tag NFC du téléphone',
'en': 'Hold an NFC tag near the phone',
},
'guest_name': {'fr': "Nom de l'invité", 'en': 'Guest name'},
'show_qr': {'fr': 'Voir le QR code', 'en': 'Show QR code'},
'no_guest': {'fr': 'Aucun invité.', 'en': 'No guest.'},
'language': {'fr': 'Langue', 'en': 'Language'},
'automatic': {'fr': 'Automatique', 'en': 'Automatic'},
'french': {'fr': 'Français', 'en': 'French'},
'english': {'fr': 'Anglais', 'en': 'English'},
'backup': {
'fr': "Sauvegarder les paramètres de l'app",
'en': 'Back up app settings',
},
'backup_location': {
'fr': "Choisir l'emplacement de sauvegarde",
'en': 'Choose backup location',
},
'restore': {'fr': 'Restaurer une sauvegarde', 'en': 'Restore a backup'},
'password': {'fr': 'Mot de passe', 'en': 'Password'},
'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': {
'fr':
"Cette action supprime tous les paramètres de la box et autorise l'appairage d'un nouvel administrateur.",
'en':
'This deletes every setting on the box and allows a new administrator to pair.',
},
'forget_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.",
'en':
'Only app information will be deleted. Box settings remain unchanged and an administrator may permanently lose access.',
},
'confirm': {'fr': 'Confirmer', 'en': 'Confirm'},
'administrator': {'fr': 'Administrateur', 'en': 'Administrator'},
'guest': {'fr': 'Invité', 'en': 'Guest'},
'backup_done': {
'fr': 'Sauvegarde chiffrée créée.',
'en': 'Encrypted backup created.',
},
'restore_done': {'fr': 'Sauvegarde restaurée.', 'en': 'Backup restored.'},
};
}
+93 -9
View File
@@ -97,10 +97,8 @@ class OpenParcelBoxBleController extends ChangeNotifier {
.timeout(const Duration(seconds: 8));
isScanning = true;
notifyListeners();
await FlutterBluePlus.startScan(
withServices: <Guid>[Guid(_serviceUuid)],
timeout: const Duration(seconds: 8),
);
await FlutterBluePlus.startScan(timeout: const Duration(seconds: 8));
await FlutterBluePlus.isScanning.where((scanning) => !scanning).first;
} catch (error) {
errorMessage = '$error';
} finally {
@@ -117,8 +115,8 @@ class OpenParcelBoxBleController extends ChangeNotifier {
}
_setBusy(true);
try {
await _connectDevice(result.device);
final publicState = await _readState(updateCollections: false);
errorMessage = null;
final publicState = await _connectForRegistration(result.device);
if (publicState?['admin_exists'] == true) {
throw StateError('This box already has an administrator.');
}
@@ -140,12 +138,72 @@ class OpenParcelBoxBleController extends ChangeNotifier {
await _upsertSavedBox(saved);
currentBox = saved;
isAuthenticated = true;
await syncClock();
try {
await syncClock();
} catch (error, stackTrace) {
debugPrint(
'OpenParcelBox clock synchronization after registration failed: '
'$error\n$stackTrace',
);
errorMessage = null;
}
notifyListeners();
} catch (error, stackTrace) {
errorMessage = '$error';
debugPrint(
'OpenParcelBox administrator registration failed: '
'$error\n$stackTrace',
);
notifyListeners();
rethrow;
} finally {
_setBusy(false);
}
}
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;
}
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> _resetAndroidBond(BluetoothDevice device) async {
connectedDevice = null;
_commandCharacteristic = null;
_stateCharacteristic = null;
isAuthenticated = false;
if (device.isConnected) {
await device.disconnect();
}
try {
await device.removeBond();
} catch (error) {
debugPrint('OpenParcelBox Android bond removal reported: $error');
}
}
Future<void> importGuestInvitation(String rawPayload) async {
final decoded = jsonDecode(rawPayload);
if (decoded is! Map<String, dynamic> ||
@@ -193,6 +251,7 @@ class OpenParcelBoxBleController extends ChangeNotifier {
await device.connect(
license: License.nonprofit,
timeout: const Duration(seconds: 12),
mtu: null,
);
_connectionSubscription?.cancel();
_connectionSubscription = device.connectionState.listen((state) {
@@ -205,6 +264,10 @@ class OpenParcelBoxBleController extends ChangeNotifier {
notifyListeners();
}
});
await _ensureAndroidBond(device);
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
await device.requestMtu(512);
}
final services = await device.discoverServices();
_commandCharacteristic = _findCharacteristic(services, _commandUuid);
_stateCharacteristic = _findCharacteristic(services, _stateUuid);
@@ -215,6 +278,26 @@ class OpenParcelBoxBleController extends ChangeNotifier {
status = 'connected';
}
Future<void> _ensureAndroidBond(BluetoothDevice device) async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) {
return;
}
var bondState = await device.bondState.first;
if (bondState == BluetoothBondState.bonded) {
return;
}
await device.createBond(timeout: 30);
bondState = await device.bondState
.where((state) => state != BluetoothBondState.bonding)
.first
.timeout(const Duration(seconds: 30));
if (bondState != BluetoothBondState.bonded) {
throw StateError('Secure Bluetooth pairing did not complete.');
}
}
BluetoothCharacteristic? _findCharacteristic(
List<BluetoothService> services,
String uuid,
@@ -236,8 +319,9 @@ class OpenParcelBoxBleController extends ChangeNotifier {
final name = result.advertisementData.advName.isNotEmpty
? result.advertisementData.advName
: result.device.platformName;
return name.toUpperCase().startsWith('OPB-') ||
name.toLowerCase().contains('openparcelbox') ||
final normalizedName = name.trim().toLowerCase();
return normalizedName.startsWith('opb-') ||
normalizedName.contains('openparcelbox') ||
result.advertisementData.serviceUuids.any(
(uuid) => uuid.str.toLowerCase() == _serviceUuid,
);
+165 -69
View File
@@ -17,6 +17,7 @@ import 'models.dart';
import 'secure_box_store.dart';
const _logoAsset = 'images/openparcelbox-logo-256.png';
const _backgroundAsset = 'images/background.jpg';
const _background = Color(0xFF333333);
const _surface = Color(0xFF292929);
const _modalSurface = Color(0xFF303030);
@@ -29,10 +30,16 @@ void main() {
}
class MyApp extends StatefulWidget {
const MyApp({this.store, this.backupService, super.key});
const MyApp({
this.store,
this.backupService,
this.splashDuration = const Duration(milliseconds: 900),
super.key,
});
final SecureBoxStore? store;
final BackupService? backupService;
final Duration splashDuration;
@override
State<MyApp> createState() => _MyAppState();
@@ -50,12 +57,14 @@ class _MyAppState extends State<MyApp> {
}
Future<void> _loadLanguage() async {
final minimumSplashTime = Future<void>.delayed(widget.splashDuration);
try {
final language = await _store.loadLanguage();
_locale = language == null ? null : Locale(language);
} catch (_) {
_locale = null;
}
await minimumSplashTime;
if (mounted) {
setState(() => _languageLoaded = true);
}
@@ -122,11 +131,35 @@ class _MyAppState extends State<MyApp> {
selectedLanguage: _locale?.languageCode,
onLanguageChanged: _changeLanguage,
)
: const ColoredBox(color: _background),
: const _StartupSplash(),
);
}
}
class _StartupSplash extends StatelessWidget {
const _StartupSplash();
@override
Widget build(BuildContext context) => ColoredBox(
color: _background,
child: Stack(
fit: StackFit.expand,
children: <Widget>[
Image.asset(_backgroundAsset, fit: BoxFit.cover),
Center(
child: LayoutBuilder(
builder: (context, constraints) => Image.asset(
_logoAsset,
width: (constraints.maxWidth * 0.62).clamp(220.0, 300.0),
fit: BoxFit.contain,
),
),
),
],
),
);
}
class OpenParcelBoxHome extends StatefulWidget {
const OpenParcelBoxHome({
required this.store,
@@ -216,94 +249,157 @@ class _OpenParcelBoxHomeState extends State<OpenParcelBoxHome> {
Future<void> _showAddBox() async {
var boxName = '';
ScanResult? selectedResult;
await showOpbModal(
context,
title: strings.text('add_box'),
child: StatefulBuilder(
builder: (context, setModalState) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
TextFormField(
maxLength: 31,
onChanged: (value) => boxName = value,
decoration: InputDecoration(labelText: strings.text('box_name')),
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () async {
final payload = await _scanQrCode();
if (payload == null) {
return;
}
try {
await _controller.importGuestInvitation(payload);
if (context.mounted) {
Navigator.of(context).pop();
builder: (context, setModalState) => ListenableBuilder(
listenable: _controller,
builder: (context, child) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
TextFormField(
maxLength: 31,
textInputAction: TextInputAction.done,
onChanged: (value) {
boxName = value;
setModalState(() {});
},
onFieldSubmitted: (_) => FocusScope.of(context).unfocus(),
decoration: InputDecoration(
labelText: strings.text('box_name'),
),
),
const SizedBox(height: 8),
FilledButton.icon(
onPressed: () async {
final payload = await _scanQrCode();
if (payload == null) {
return;
}
} catch (error) {
if (context.mounted) {
_showMessage('$error');
try {
await _controller.importGuestInvitation(payload);
if (context.mounted) {
Navigator.of(context).pop();
}
} catch (error) {
if (context.mounted) {
_showMessage('$error');
}
}
}
},
icon: const Icon(Icons.qr_code_scanner),
label: Text(strings.text('scan_qr')),
),
const SizedBox(height: 16),
Row(
children: <Widget>[
Expanded(
child: Text(
strings.text('nearby_boxes'),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: _accent,
fontWeight: FontWeight.w700,
},
icon: const Icon(Icons.qr_code_scanner),
label: Text(strings.text('scan_qr')),
),
const SizedBox(height: 16),
Row(
children: <Widget>[
Expanded(
child: Text(
strings.text('nearby_boxes'),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: _accent,
fontWeight: FontWeight.w700,
),
),
),
IconButton.filledTonal(
onPressed: _controller.isScanning
? null
: _controller.startScan,
icon: const Icon(Icons.radar),
tooltip: strings.text('search'),
),
],
),
const SizedBox(height: 8),
if (_controller.errorMessage != null) ...<Widget>[
Text(
_controller.errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
IconButton.filledTonal(
onPressed: _controller.isScanning
? null
: () async {
await _controller.startScan();
setModalState(() {});
},
icon: const Icon(Icons.radar),
tooltip: strings.text('search'),
const SizedBox(height: 8),
],
if (_controller.scanResults.isEmpty)
_EmptyRow(text: strings.text('no_results'))
else
RadioGroup<ScanResult>(
groupValue: selectedResult,
onChanged: (result) =>
setModalState(() => selectedResult = result),
child: Column(
children: _controller.scanResults
.map(
(result) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Material(
color: _surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: selectedResult == result
? _accent
: Colors.white10,
),
),
clipBehavior: Clip.antiAlias,
child: RadioListTile<ScanResult>(
value: result,
activeColor: _accent,
selected: selectedResult == result,
secondary: const Icon(
Icons.inventory_2_outlined,
color: _accent,
),
title: Text(_deviceName(result)),
subtitle: Text(result.device.remoteId.str),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
),
),
),
),
)
.toList(),
),
),
if (selectedResult == null &&
_controller.scanResults.isNotEmpty) ...<Widget>[
const SizedBox(height: 4),
Text(
strings.text('select_box'),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
const SizedBox(height: 8),
if (_controller.scanResults.isEmpty)
_EmptyRow(text: strings.text('no_results'))
else
..._controller.scanResults.map(
(result) => _DataRowCard(
icon: Icons.inventory_2_outlined,
title: _deviceName(result),
subtitle: result.device.remoteId.str,
actions: <Widget>[
IconButton(
onPressed: () async {
const SizedBox(height: 16),
FilledButton.icon(
onPressed:
selectedResult == null ||
boxName.trim().isEmpty ||
_controller.isBusy
? null
: () async {
FocusScope.of(context).unfocus();
try {
await _controller.registerAdministrator(
result,
selectedResult!,
boxName,
);
if (context.mounted) {
Navigator.of(context).pop();
}
} catch (error) {
_showMessage('$error');
} catch (_) {
// The controller logs the detailed failure and exposes
// it inline in this modal.
}
},
icon: const Icon(Icons.link),
color: _accent,
),
],
),
icon: const Icon(Icons.add_link),
label: Text(strings.text('register_box')),
),
],
],
),
),
),
);
+4 -3
View File
@@ -69,6 +69,7 @@ flutter:
assets:
- images/background.jpg
- images/openparcelbox-logo-256.png
- images/openparcelbox-splash.png
flutter_launcher_icons:
android: true
@@ -78,14 +79,14 @@ flutter_launcher_icons:
adaptive_icon_foreground: images/openparcelbox-logo-256.png
flutter_native_splash:
color: "#333333"
background_image: images/background.jpg
image: images/openparcelbox-logo-256.png
image: images/openparcelbox-splash.png
android_gravity: center
android: true
ios: false
android_12:
color: "#333333"
image: images/openparcelbox-logo-256.png
image: images/openparcelbox-splash.png
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
+42 -5
View File
@@ -38,10 +38,29 @@ class _FakeBackupService extends BackupService {
}
void main() {
testWidgets('empty home only exposes registration and restore actions', (
testWidgets('startup splash shows the background and centered logo', (
WidgetTester tester,
) async {
await tester.pumpWidget(MyApp(store: _MemoryStore()));
expect(
find.image(const AssetImage('images/background.jpg')),
findsOneWidget,
);
expect(
find.image(const AssetImage('images/openparcelbox-logo-256.png')),
findsOneWidget,
);
await tester.pump(const Duration(milliseconds: 900));
});
testWidgets('empty home only exposes registration and restore actions', (
WidgetTester tester,
) async {
await tester.pumpWidget(
MyApp(store: _MemoryStore(), splashDuration: Duration.zero),
);
await tester.pumpAndSettle();
expect(find.text('Add a new OpenParcelBox'), findsOneWidget);
@@ -53,7 +72,9 @@ void main() {
testWidgets('registration opens the styled setup modal', (
WidgetTester tester,
) async {
await tester.pumpWidget(MyApp(store: _MemoryStore()));
await tester.pumpWidget(
MyApp(store: _MemoryStore(), splashDuration: Duration.zero),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Add a new OpenParcelBox'));
@@ -62,13 +83,23 @@ void main() {
expect(find.text('Box identifier'), findsOneWidget);
expect(find.text('Scan a guest QR code'), findsOneWidget);
expect(find.text('Detected OpenParcelBox devices'), findsOneWidget);
expect(find.text('Add this box'), findsOneWidget);
final addButton = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Add this box'),
);
expect(addButton.onPressed, isNull);
expect(find.byIcon(Icons.close), findsOneWidget);
});
testWidgets('registration text field works with the French locale', (
WidgetTester tester,
) async {
await tester.pumpWidget(MyApp(store: _MemoryStore(language: 'fr')));
await tester.pumpWidget(
MyApp(
store: _MemoryStore(language: 'fr'),
splashDuration: Duration.zero,
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Ajouter une nouvelle OpenParcelBox'));
@@ -81,7 +112,9 @@ void main() {
testWidgets(
'closing registration after editing does not dispose input early',
(WidgetTester tester) async {
await tester.pumpWidget(MyApp(store: _MemoryStore()));
await tester.pumpWidget(
MyApp(store: _MemoryStore(), splashDuration: Duration.zero),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Add a new OpenParcelBox'));
@@ -100,7 +133,11 @@ void main() {
) async {
final backupService = _FakeBackupService();
await tester.pumpWidget(
MyApp(store: _MemoryStore(), backupService: backupService),
MyApp(
store: _MemoryStore(),
backupService: backupService,
splashDuration: Duration.zero,
),
);
await tester.pumpAndSettle();