diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 718133f2..6f799b87 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -56,7 +56,9 @@ import '../storage/client_id_store.dart'; import '../storage/contact_discovery_store.dart'; import '../storage/contact_settings_store.dart'; import '../storage/contact_store.dart'; +import '../storage/factory_reset_store.dart'; import '../storage/message_store.dart'; +import '../storage/radio_data_wipe.dart'; import '../storage/unread_store.dart'; import '../utils/app_logger.dart'; import '../utils/battery_utils.dart'; @@ -297,6 +299,28 @@ class ContactSyncShortfall { /// What the radio said when asked for one contact by key. (#762) enum ContactKeyCheckResult { onRadio, notFound, unresolved } +/// Why a factory reset was not sent. (#1054) +enum FactoryResetRefusal { notConnected } + +/// Where a factory reset stands, for the app-wide banner. (#1054) +enum FactoryResetStatus { + /// Sent. Over BLE the radio's pairing is gone too, so the OS one must go. + sentBle, + sent, + + /// The radio came back with a new identity and its app data was deleted. + complete, + + /// New identity; the user chose to keep the app data. + completeKept, + + /// Same identity on reconnect: the radio was not wiped. Nothing deleted. + notReset, + + /// New identity, but deleting the old app data failed. Retried next connect. + deleteFailed, +} + class _PendingKeyCheck { final String keyHex; final DateTime startedAt; @@ -6856,6 +6880,127 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(buildRebootFrame()); } + final FactoryResetStore _factoryResetStore = FactoryResetStore(); + FactoryResetStatus? _factoryResetStatus; + FactoryResetStatus? get factoryResetStatus => _factoryResetStatus; + + void dismissFactoryResetStatus() { + _factoryResetStatus = null; + notifyListeners(); + } + + @visibleForTesting + String? transportIdentityForTest; + + /// Which physical device the link goes to, independent of its identity, + /// which a factory reset replaces. + String? get _transportIdentity => + transportIdentityForTest ?? + switch (_activeTransport) { + MeshCoreTransportType.bluetooth => _deviceId, + MeshCoreTransportType.usb => activeUsbPort, + MeshCoreTransportType.tcp => activeTcpEndpoint, + }; + + String? get _radioPrefix { + final hex = selfPublicKeyHex; + return hex.length >= 10 ? hex.substring(0, 10) : null; + } + + /// Wipes the connected radio (#1054). Null when sent, else why not. + /// + /// The app data is NOT deleted here. The radio drops the link before it + /// formats, so nothing proves the wipe until it reconnects with a new + /// identity; see [_resolvePendingFactoryReset]. The session is ended on + /// purpose so it does not auto-reconnect into a wiped radio. + Future factoryResetDevice({ + required bool deleteAppData, + }) async { + final deviceId = _transportIdentity; + final prefix = _radioPrefix; + if (!isConnected || deviceId == null || prefix == null) { + return FactoryResetRefusal.notConnected; + } + + await _factoryResetStore.save( + PendingFactoryReset( + deviceId: deviceId, + radioPrefix: prefix, + deleteAppData: deleteAppData, + ), + ); + final overBle = _activeTransport == MeshCoreTransportType.bluetooth; + _appDebugLogService?.info( + 'Factory reset sent device=$deviceId radio=$prefix ' + 'deleteAppData=$deleteAppData', + tag: 'Connection', + ); + await sendFrame(buildFactoryResetFrame()); + _factoryResetStatus = overBle + ? FactoryResetStatus.sentBle + : FactoryResetStatus.sent; + notifyListeners(); + // Give the frame time to leave before the transport closes. + await Future.delayed(const Duration(milliseconds: 500)); + await disconnect(); + return null; + } + + /// Settles a pending factory reset on the first SELF_INFO of a connect. + /// + /// Only a connect to the same device decides anything. Same identity means + /// the wipe did not happen and every byte of app data stays. A failed delete + /// keeps the record, so the next connect tries again. + Future _resolvePendingFactoryReset() async { + final pending = _factoryResetStore.load(); + final deviceId = _transportIdentity; + final prefix = _radioPrefix; + if (pending == null || deviceId == null || prefix == null) return; + final outcome = resolveFactoryReset( + pending, + deviceId: deviceId, + radioPrefix: prefix, + ); + if (outcome == null) return; + + if (outcome == FactoryResetOutcome.wipeAppData) { + try { + final removed = await wipeRadioData(pending.radioPrefix); + await _appSettingsService?.forgetRadio(pending.radioPrefix); + _appDebugLogService?.info( + 'Factory reset confirmed, removed $removed stored items for ' + '${pending.radioPrefix}', + tag: 'Connection', + ); + } catch (e) { + _appDebugLogService?.error( + 'Factory reset confirmed but deleting ${pending.radioPrefix} ' + 'failed, will retry next connect: $e', + tag: 'Connection', + ); + _factoryResetStatus = FactoryResetStatus.deleteFailed; + notifyListeners(); + return; + } + } + + await _factoryResetStore.clear(); + _factoryResetStatus = switch (outcome) { + FactoryResetOutcome.wipeAppData => FactoryResetStatus.complete, + FactoryResetOutcome.keepAppData => FactoryResetStatus.completeKept, + FactoryResetOutcome.notReset => FactoryResetStatus.notReset, + }; + _appDebugLogService?.info( + 'Factory reset resolved: $outcome', + tag: 'Connection', + ); + notifyListeners(); + } + + @visibleForTesting + Future resolvePendingFactoryResetForTest() => + _resolvePendingFactoryReset(); + Future setPrivacyMode(bool enabled) async { await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}'); } @@ -8904,6 +9049,10 @@ class MeshCoreConnector extends ChangeNotifier { // another radio's data. storageService?.setPublicKeyHex = selfPublicKeyHex; + // A factory reset is proven or disproven here, by the identity this + // device now reports (#1054). + unawaited(_resolvePendingFactoryReset()); + // Now that we have self info, we can load all the persisted data for this // node. Each step is timed: this sequence has stalled the UI isolate for // ~40s on a large store, and the timings say which step is responsible. diff --git a/lib/connector/meshcore_protocol.dart b/lib/connector/meshcore_protocol.dart index 937dd9a8..a9aa5320 100644 --- a/lib/connector/meshcore_protocol.dart +++ b/lib/connector/meshcore_protocol.dart @@ -239,6 +239,7 @@ const int cmdSendTelemetryReq = 39; const int cmdGetCustomVar = 40; const int cmdSetCustomVar = 41; const int cmdSendBinaryReq = 50; +const int cmdFactoryReset = 51; const int cmdGetStats = 56; const int cmdSendAnonReq = 57; const int cmdSetAutoAddConfig = 58; @@ -1494,6 +1495,13 @@ Uint8List buildRebootFrame() { return Uint8List.fromList([cmdReboot, ...utf8.encode('reboot')]); } +// Build CMD_FACTORY_RESET frame (stock since firmware ver code 7) +// Format: [cmd]["reset"]. The radio disables its link before formatting, so +// success is seen as a drop, never an OK. +Uint8List buildFactoryResetFrame() { + return Uint8List.fromList([cmdFactoryReset, ...utf8.encode('reset')]); +} + // Build CMD_SYNC_NEXT_MESSAGE frame Uint8List buildSyncNextMessageFrame() { return Uint8List.fromList([cmdSyncNextMessage]); diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8dfe0f50..cb528347 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -433,6 +433,21 @@ "settings_rebootDevice": "Reboot Device", "settings_rebootDeviceSubtitle": "Restart the MeshCore device", "settings_rebootDeviceConfirm": "Are you sure you want to reboot the device? You will be disconnected.", + "settings_factoryReset":"Factory Reset", + "settings_factoryResetSubtitle": "Erase the radio and give it a new identity", + "settings_factoryResetConfirm": "This erases everything on the radio: its identity, contacts, channels and settings. It can't be undone.", + "settings_factoryResetDeleteAppData": "Also delete this radio's data stored in the app", + "settings_factoryResetNotConnected": "Factory reset not sent: no radio is connected.", + "factoryResetSentTitle": "Factory reset sent", + "factoryResetSentBody": "The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.", + "factoryResetSentBleBody": "The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer's Bluetooth settings, then reconnect to finish.", + "factoryResetCompleteTitle": "Factory reset complete", + "factoryResetCompleteBody": "The radio has a new identity. Its old data was deleted from the app.", + "factoryResetCompleteKeptBody": "The radio has a new identity. Its old data was kept in the app.", + "factoryResetNotResetTitle": "Factory reset did not happen", + "factoryResetNotResetBody": "The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.", + "factoryResetDeleteFailedTitle": "Factory reset done, app cleanup failed", + "factoryResetDeleteFailedBody": "The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.", "settings_blePin": "BLE pairing PIN", "settings_blePinDefault": "Firmware default", "settings_blePinHelp": "6 digits (100000-999999). New phones and PCs must enter it when pairing. Reset to default to return to the firmware's built-in behavior.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index bd093c78..381ddb78 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1486,6 +1486,96 @@ abstract class AppLocalizations { /// **'Are you sure you want to reboot the device? You will be disconnected.'** String get settings_rebootDeviceConfirm; + /// No description provided for @settings_factoryReset. + /// + /// In en, this message translates to: + /// **'Factory Reset'** + String get settings_factoryReset; + + /// No description provided for @settings_factoryResetSubtitle. + /// + /// In en, this message translates to: + /// **'Erase the radio and give it a new identity'** + String get settings_factoryResetSubtitle; + + /// No description provided for @settings_factoryResetConfirm. + /// + /// In en, this message translates to: + /// **'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'** + String get settings_factoryResetConfirm; + + /// No description provided for @settings_factoryResetDeleteAppData. + /// + /// In en, this message translates to: + /// **'Also delete this radio\'s data stored in the app'** + String get settings_factoryResetDeleteAppData; + + /// No description provided for @settings_factoryResetNotConnected. + /// + /// In en, this message translates to: + /// **'Factory reset not sent: no radio is connected.'** + String get settings_factoryResetNotConnected; + + /// No description provided for @factoryResetSentTitle. + /// + /// In en, this message translates to: + /// **'Factory reset sent'** + String get factoryResetSentTitle; + + /// No description provided for @factoryResetSentBody. + /// + /// In en, this message translates to: + /// **'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'** + String get factoryResetSentBody; + + /// No description provided for @factoryResetSentBleBody. + /// + /// In en, this message translates to: + /// **'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'** + String get factoryResetSentBleBody; + + /// No description provided for @factoryResetCompleteTitle. + /// + /// In en, this message translates to: + /// **'Factory reset complete'** + String get factoryResetCompleteTitle; + + /// No description provided for @factoryResetCompleteBody. + /// + /// In en, this message translates to: + /// **'The radio has a new identity. Its old data was deleted from the app.'** + String get factoryResetCompleteBody; + + /// No description provided for @factoryResetCompleteKeptBody. + /// + /// In en, this message translates to: + /// **'The radio has a new identity. Its old data was kept in the app.'** + String get factoryResetCompleteKeptBody; + + /// No description provided for @factoryResetNotResetTitle. + /// + /// In en, this message translates to: + /// **'Factory reset did not happen'** + String get factoryResetNotResetTitle; + + /// No description provided for @factoryResetNotResetBody. + /// + /// In en, this message translates to: + /// **'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'** + String get factoryResetNotResetBody; + + /// No description provided for @factoryResetDeleteFailedTitle. + /// + /// In en, this message translates to: + /// **'Factory reset done, app cleanup failed'** + String get factoryResetDeleteFailedTitle; + + /// No description provided for @factoryResetDeleteFailedBody. + /// + /// In en, this message translates to: + /// **'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'** + String get factoryResetDeleteFailedBody; + /// No description provided for @settings_blePin. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 0d3787a0..de9ee51a 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -809,6 +809,62 @@ class AppLocalizationsBg extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Сигурни ли сте, че искате да рестартирате устройството? Ще бъдете откъснати.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 7b6e23b6..7116eebb 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -806,6 +806,62 @@ class AppLocalizationsDe extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Sind Sie sicher, dass Sie das Gerät neu starten möchten? Sie werden getrennt.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 9d6360b6..1784ff17 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -797,6 +797,62 @@ class AppLocalizationsEn extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Are you sure you want to reboot the device? You will be disconnected.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 1520aaf5..4fa57309 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -807,6 +807,62 @@ class AppLocalizationsEs extends AppLocalizations { String get settings_rebootDeviceConfirm => '¿Está seguro de que desea reiniciar el dispositivo? Se desconectará.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index fafd98cc..07bee59b 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -811,6 +811,62 @@ class AppLocalizationsFr extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Êtes-vous sûr de vouloir redémarrer l\'appareil ? Vous serez déconnecté.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 2176b65e..6f7db77c 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -809,6 +809,62 @@ class AppLocalizationsHu extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Biztosan szeretné újraindítani a készüléket? Ebben az esetben a kapcsolat megszűnik.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 82716c68..cd01fdd5 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -809,6 +809,62 @@ class AppLocalizationsIt extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Sei sicuro di voler riavviare il dispositivo? Sarai disconnesso.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 6cfce039..cf5f5651 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -783,6 +783,62 @@ class AppLocalizationsJa extends AppLocalizations { String get settings_rebootDeviceConfirm => '本当にデバイスを再起動したいですか? その場合、接続が切断されます。'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 8a01425c..6497537a 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -783,6 +783,62 @@ class AppLocalizationsKo extends AppLocalizations { String get settings_rebootDeviceConfirm => '정말로 장치를 재부팅하시겠습니까? 이 경우 연결이 끊어집니다.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 3038dc06..dd1d3798 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -803,6 +803,62 @@ class AppLocalizationsNl extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Ben je er zeker van dat je het apparaat opnieuw wilt opstarten? Je wordt losgekoppeld.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index 6b48b575..9a781304 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -811,6 +811,62 @@ class AppLocalizationsPl extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Czy na pewno chcesz zrestartować urządzenie? Będziesz odłączony.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index ba124be2..c923805f 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -809,6 +809,62 @@ class AppLocalizationsPt extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Tem certeza de que deseja reiniciar o dispositivo? Você será desconectado.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e957c3b5..d7cc917f 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -808,6 +808,62 @@ class AppLocalizationsRu extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Вы уверены, что хотите перезагрузить устройство? Вы будете отключены.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 5eb4482f..5ecf0703 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -803,6 +803,62 @@ class AppLocalizationsSk extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Ste si istý, že chcete zariadenie reštartovať? Budete odpojení.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 3c9f08ef..48b620f9 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -801,6 +801,62 @@ class AppLocalizationsSl extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Ste prepričani, da želite ponovno zagnati napravo? Povezava bo prekinjena.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 3e610198..def070e3 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -798,6 +798,62 @@ class AppLocalizationsSv extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Är du säker på att du vill starta om enheten? Du kommer att bli avkopplad.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 21a6c674..2626bffb 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -805,6 +805,62 @@ class AppLocalizationsUk extends AppLocalizations { String get settings_rebootDeviceConfirm => 'Ви впевнені, що хочете перезавантажити пристрій? Вас буде відключено.'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 9287a67a..803195ad 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -776,6 +776,62 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settings_rebootDeviceConfirm => '确定要重启设备吗?这将断开与设备的连接。'; + @override + String get settings_factoryReset => 'Factory Reset'; + + @override + String get settings_factoryResetSubtitle => + 'Erase the radio and give it a new identity'; + + @override + String get settings_factoryResetConfirm => + 'This erases everything on the radio: its identity, contacts, channels and settings. It can\'t be undone.'; + + @override + String get settings_factoryResetDeleteAppData => + 'Also delete this radio\'s data stored in the app'; + + @override + String get settings_factoryResetNotConnected => + 'Factory reset not sent: no radio is connected.'; + + @override + String get factoryResetSentTitle => 'Factory reset sent'; + + @override + String get factoryResetSentBody => + 'The radio is erasing itself and will restart with a new identity. Reconnect to it to finish.'; + + @override + String get factoryResetSentBleBody => + 'The radio is erasing itself and will restart with a new identity. Its Bluetooth pairing is erased too: remove the old pairing for this radio in your phone or computer\'s Bluetooth settings, then reconnect to finish.'; + + @override + String get factoryResetCompleteTitle => 'Factory reset complete'; + + @override + String get factoryResetCompleteBody => + 'The radio has a new identity. Its old data was deleted from the app.'; + + @override + String get factoryResetCompleteKeptBody => + 'The radio has a new identity. Its old data was kept in the app.'; + + @override + String get factoryResetNotResetTitle => 'Factory reset did not happen'; + + @override + String get factoryResetNotResetBody => + 'The radio reconnected with the same identity, so it was not erased. Nothing was deleted from the app.'; + + @override + String get factoryResetDeleteFailedTitle => + 'Factory reset done, app cleanup failed'; + + @override + String get factoryResetDeleteFailedBody => + 'The radio was erased, but deleting its old data from the app failed. The app will try again the next time this radio connects.'; + @override String get settings_blePin => 'BLE pairing PIN'; diff --git a/lib/main.dart b/lib/main.dart index d184ef4d..51ad37be 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -37,6 +37,7 @@ import 'services/storage_health_service.dart'; import 'services/radio_preset_service.dart'; import 'storage/drift/blob_store.dart'; import 'widgets/contact_sync_shortfall_banner.dart'; +import 'widgets/factory_reset_banner.dart'; import 'widgets/storage_unavailable_banner.dart'; import 'storage/prefs_manager.dart'; import 'utils/app_logger.dart'; @@ -369,7 +370,11 @@ class MeshCoreApp extends StatelessWidget { onDismiss: connector.dismissContactSyncShortfall, onKeep: connector.resolveContactSyncKeepLocal, onUseRadio: connector.resolveContactSyncUseRadio, - child: child ?? const SizedBox.shrink(), + child: FactoryResetBanner( + status: connector.factoryResetStatus, + onDismiss: connector.dismissFactoryResetStatus, + child: child ?? const SizedBox.shrink(), + ), ), ), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 7528c7c8..5ad90988 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -875,6 +875,13 @@ class _SettingsScreenState extends State { subtitle: Text(l10n.settings_rebootDeviceSubtitle), onTap: () => _confirmReboot(context, connector), ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.delete_forever, color: Colors.red), + title: Text(l10n.settings_factoryReset), + subtitle: Text(l10n.settings_factoryResetSubtitle), + onTap: () => _confirmFactoryReset(context, connector), + ), ], ), ); @@ -1287,6 +1294,63 @@ class _SettingsScreenState extends State { ); } + void _confirmFactoryReset(BuildContext context, MeshCoreConnector connector) { + final l10n = context.l10n; + var deleteAppData = true; + showDialog( + context: context, + builder: (dialogContext) => StatefulBuilder( + builder: (dialogContext, setState) => AlertDialog( + title: Text(l10n.settings_factoryReset), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(l10n.settings_factoryResetConfirm), + const SizedBox(height: 8), + CheckboxListTile( + contentPadding: EdgeInsets.zero, + controlAffinity: ListTileControlAffinity.leading, + value: deleteAppData, + onChanged: (value) => + setState(() => deleteAppData = value ?? deleteAppData), + title: Text(l10n.settings_factoryResetDeleteAppData), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: Text(l10n.common_cancel), + ), + TextButton( + onPressed: () async { + Navigator.pop(dialogContext); + final refusal = await connector.factoryResetDevice( + deleteAppData: deleteAppData, + ); + if (refusal == null || !context.mounted) return; + showDismissibleSnackBar( + context, + content: Text(switch (refusal) { + FactoryResetRefusal.notConnected => + l10n.settings_factoryResetNotConnected, + }), + backgroundColor: Colors.red, + persist: true, + ); + }, + child: Text( + l10n.settings_factoryReset, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ), + ); + } + Future _gpxExport( GpxExport exporter, String name, diff --git a/lib/services/app_settings_service.dart b/lib/services/app_settings_service.dart index 21a267be..1df64d43 100644 --- a/lib/services/app_settings_service.dart +++ b/lib/services/app_settings_service.dart @@ -297,6 +297,18 @@ class AppSettingsService extends ChangeNotifier { ); } + /// Drops [devicePrefix]'s notify modes, the per-radio part of this global + /// blob, after that radio was factory reset (#1054). + Future forgetRadio(String devicePrefix) async { + final prefix = _scopePrefix(devicePrefix); + if (prefix.isEmpty) return; + final byRadio = _copyByRadio(); + if (byRadio.remove(prefix) == null) return; + await updateSettings( + _settings.copyWith(channelNotifyModesByRadio: byRadio), + ); + } + /// One-time migration of the pre-#839 global state into the FIRST radio seen /// after upgrade. Called by the connector once that radio's channels are /// known. Later radios start clean, so the ambiguous legacy state never diff --git a/lib/storage/factory_reset_store.dart b/lib/storage/factory_reset_store.dart new file mode 100644 index 00000000..5e71a36f --- /dev/null +++ b/lib/storage/factory_reset_store.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +import 'prefs_manager.dart'; + +/// A factory reset that was sent and not yet proven (#1054). +/// +/// The firmware disables the link before it formats, so the client never +/// hears whether the wipe worked. The proof is the next connect to the same +/// device: a new identity means it worked. Until then this record keeps the +/// old radio's data alive, so a reset that failed cannot cost the user their +/// history. +class PendingFactoryReset { + const PendingFactoryReset({ + required this.deviceId, + required this.radioPrefix, + required this.deleteAppData, + }); + + /// The transport identity the reset went to: BLE remote id, USB port key or + /// TCP endpoint. A connect to anything else says nothing about this reset. + final String deviceId; + + /// First 10 hex of the radio's key before the reset. + final String radioPrefix; + + /// The user's choice in the confirm dialog. + final bool deleteAppData; + + Map toJson() => { + 'device_id': deviceId, + 'radio_prefix': radioPrefix, + 'delete_app_data': deleteAppData, + }; + + static PendingFactoryReset? fromJson(Map json) { + final deviceId = json['device_id']; + final radioPrefix = json['radio_prefix']; + final deleteAppData = json['delete_app_data']; + if (deviceId is! String || + radioPrefix is! String || + deleteAppData is! bool) { + return null; + } + return PendingFactoryReset( + deviceId: deviceId, + radioPrefix: radioPrefix, + deleteAppData: deleteAppData, + ); + } +} + +enum FactoryResetOutcome { + /// New identity, and the user asked for the app data to go. + wipeAppData, + + /// New identity, and the user asked to keep the app data. + keepAppData, + + /// Same identity: the radio was not wiped. Nothing is deleted. + notReset, +} + +/// What connecting to [deviceId] as [radioPrefix] says about [pending]. +/// +/// Null when it is a different device, so the record stays and nothing is +/// decided on the strength of some other radio. +FactoryResetOutcome? resolveFactoryReset( + PendingFactoryReset pending, { + required String deviceId, + required String radioPrefix, +}) { + if (deviceId != pending.deviceId) return null; + if (radioPrefix == pending.radioPrefix) return FactoryResetOutcome.notReset; + return pending.deleteAppData + ? FactoryResetOutcome.wipeAppData + : FactoryResetOutcome.keepAppData; +} + +/// Survives an app restart between the reset and the reconnect, so closing +/// the app in between does not quietly skip the delete the user asked for. +class FactoryResetStore { + static const String _key = 'factory_reset_pending'; + + PendingFactoryReset? load() { + final raw = PrefsManager.instance.getString(_key); + if (raw == null) return null; + try { + return PendingFactoryReset.fromJson( + jsonDecode(raw) as Map, + ); + } catch (_) { + return null; + } + } + + Future save(PendingFactoryReset pending) => + PrefsManager.instance.setString(_key, jsonEncode(pending.toJson())); + + Future clear() => PrefsManager.instance.remove(_key); +} diff --git a/lib/storage/radio_data_wipe.dart b/lib/storage/radio_data_wipe.dart new file mode 100644 index 00000000..9e01d078 --- /dev/null +++ b/lib/storage/radio_data_wipe.dart @@ -0,0 +1,49 @@ +import 'drift/blob_store.dart'; +import 'scoping_inventory.dart'; + +/// Deletes everything this app stores for one radio, after that radio was +/// factory reset (#1054). +/// +/// Driven by [storageSurfaces] rather than a list of stores, so a per-radio +/// store added later is wiped without anyone remembering to add it here: the +/// inventory test already refuses an undeclared key. Every per-radio key in +/// this repo is the surface key followed by the 10-hex radio prefix, with or +/// without a suffix, so a prefix match per surface finds all of them in both +/// backends and in the message rows. +/// +/// [radioPrefix] must be exactly 10 lowercase hex. Anything else throws before +/// a single delete: an empty prefix would match every radio's data. +/// +/// Returns how many keys and row groups were removed. +Future wipeRadioData(String radioPrefix) async { + if (!RegExp(r'^[0-9a-f]{10}$').hasMatch(radioPrefix)) { + throw ArgumentError.value( + radioPrefix, + 'radioPrefix', + 'must be the 10-hex radio key prefix', + ); + } + + final blobs = BlobStore.instance; + final prefixes = [ + for (final s in storageSurfaces) + if (s.verdict == ScopingVerdict.perRadio) '${s.key}$radioPrefix', + ]; + + var removed = 0; + for (final prefix in prefixes) { + for (final key in await blobs.keysWithPrefix(prefix)) { + await blobs.deleteEverywhere(key); + removed++; + } + } + + for (final storeKey in await blobs.messageStoreKeys()) { + if (prefixes.any(storeKey.startsWith)) { + await blobs.deleteMessageRows(storeKey); + removed++; + } + } + + return removed; +} diff --git a/lib/storage/scoping_inventory.dart b/lib/storage/scoping_inventory.dart index 8c7d2858..51532961 100644 --- a/lib/storage/scoping_inventory.dart +++ b/lib/storage/scoping_inventory.dart @@ -248,6 +248,15 @@ const List storageSurfaces = [ reason: 'keyed by repeater node, same reasoning as repeater_passwords', owner: 'lib/services/storage_service.dart', ), + StorageSurface( + 'factory_reset_pending', + verdict: ScopingVerdict.globalOnPurpose, + reason: + 'one factory reset awaiting proof (#1054). It spans two identities of ' + 'the same device, the key before the wipe and the key after, so it ' + 'cannot live under either. It names the old radio inside the record', + owner: 'lib/storage/factory_reset_store.dart', + ), StorageSurface( 'map_removed_marker_ids', verdict: ScopingVerdict.globalOnPurpose, diff --git a/lib/widgets/factory_reset_banner.dart b/lib/widgets/factory_reset_banner.dart new file mode 100644 index 00000000..c9ea51fc --- /dev/null +++ b/lib/widgets/factory_reset_banner.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; + +import '../connector/meshcore_connector.dart'; +import '../l10n/l10n.dart'; + +/// Shown above the whole app while a factory reset is outstanding and once it +/// settles (#1054). The settings screen is gone by then, because the reset +/// ends the session, so the outcome is reported here and stays until +/// dismissed. Sits in `MaterialApp.builder`, like [ContactSyncShortfallBanner]. +class FactoryResetBanner extends StatelessWidget { + final FactoryResetStatus? status; + final VoidCallback onDismiss; + final Widget child; + + const FactoryResetBanner({ + super.key, + required this.status, + required this.onDismiss, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final current = status; + if (current == null) return child; + return Column( + children: [ + _banner(context, current), + Expanded(child: child), + ], + ); + } + + Widget _banner(BuildContext context, FactoryResetStatus current) { + final l10n = context.l10n; + final scheme = Theme.of(context).colorScheme; + final failed = + current == FactoryResetStatus.notReset || + current == FactoryResetStatus.deleteFailed; + final background = failed + ? scheme.errorContainer + : scheme.secondaryContainer; + final foreground = failed + ? scheme.onErrorContainer + : scheme.onSecondaryContainer; + final (title, body) = switch (current) { + FactoryResetStatus.sentBle => ( + l10n.factoryResetSentTitle, + l10n.factoryResetSentBleBody, + ), + FactoryResetStatus.sent => ( + l10n.factoryResetSentTitle, + l10n.factoryResetSentBody, + ), + FactoryResetStatus.complete => ( + l10n.factoryResetCompleteTitle, + l10n.factoryResetCompleteBody, + ), + FactoryResetStatus.completeKept => ( + l10n.factoryResetCompleteTitle, + l10n.factoryResetCompleteKeptBody, + ), + FactoryResetStatus.notReset => ( + l10n.factoryResetNotResetTitle, + l10n.factoryResetNotResetBody, + ), + FactoryResetStatus.deleteFailed => ( + l10n.factoryResetDeleteFailedTitle, + l10n.factoryResetDeleteFailedBody, + ), + }; + return Material( + color: background, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 4, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + failed ? Icons.error_outline : Icons.restart_alt, + color: foreground, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + color: foreground, + ), + ), + const SizedBox(height: 2), + Text(body, style: TextStyle(color: foreground)), + ], + ), + ), + // No tooltip: this sits above the Navigator, where there is no + // Overlay, and a Tooltip without one blanks the window (#713). + IconButton( + icon: Icon( + Icons.close, + color: foreground, + semanticLabel: l10n.common_close, + ), + onPressed: onDismiss, + ), + ], + ), + ), + ), + ); + } +} diff --git a/test/connector/factory_reset_test.dart b/test/connector/factory_reset_test.dart new file mode 100644 index 00000000..6f5b7705 --- /dev/null +++ b/test/connector/factory_reset_test.dart @@ -0,0 +1,180 @@ +// #1057 (epic #1054): factory reset of the connected radio. +// +// The radio drops the link before it formats, so success is only ever seen as +// the same device coming back with a new identity. These pin that the app data +// waits for that proof, and that every branch where it cannot tell keeps it. + +import 'dart:typed_data'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/connector/meshcore_protocol.dart'; +import 'package:meshcore_open/storage/drift/blob_store.dart'; +import 'package:meshcore_open/storage/drift/offband_database.dart'; +import 'package:meshcore_open/storage/factory_reset_store.dart'; +import 'package:meshcore_open/storage/prefs_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late OffbandDatabase db; + late MeshCoreConnector connector; + late List sent; + + const oldPrefix = 'aaaaaaaaaa'; + const newPrefix = 'bbbbbbbbbb'; + Uint8List keyOf(int fill) => Uint8List.fromList(List.filled(32, fill)); + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + PrefsManager.reset(); + await PrefsManager.initialize(); + db = OffbandDatabase(NativeDatabase.memory()); + BlobStore.overrideForTest(BlobStore(db)); + sent = []; + connector = MeshCoreConnector() + ..sendFrameOverrideForTest = sent.add + ..transportIdentityForTest = 'dev-1' + ..setSelfPublicKeyForTest(keyOf(0xaa)) + ..setFirmwareVerCodeForTest(14); + }); + + tearDown(() async { + await db.close(); + BlobStore.clearTestOverride(); + }); + + Future seedOldRadio() async { + await BlobStore.instance.write('contacts$oldPrefix', '[]'); + await PrefsManager.instance.setString('channels$oldPrefix', '[]'); + } + + Future oldRadioDataExists() async => + (await BlobStore.instance.keysWithPrefix( + 'contacts$oldPrefix', + )).isNotEmpty || + PrefsManager.instance.getString('channels$oldPrefix') != null; + + test('the frame is the command then "reset"', () { + expect(buildFactoryResetFrame(), [cmdFactoryReset, ...'reset'.codeUnits]); + expect(cmdFactoryReset, 51); + }); + + group('refusals send nothing', () { + test('not connected', () async { + final refusal = await connector.factoryResetDevice(deleteAppData: true); + + expect(refusal, FactoryResetRefusal.notConnected); + expect(sent, isEmpty); + expect(FactoryResetStore().load(), isNull); + }); + }); + + // Owner ruling: no FIRMWARE_VER_CODE gating. A radio that cannot reset keeps + // its identity, so the next connect resolves the record as "not reset". + for (final verCode in [null, 0, 6]) { + test('ver code $verCode is never consulted: the reset is sent', () async { + await seedOldRadio(); + connector + ..setConnectedForTest() + ..setFirmwareVerCodeForTest(verCode); + + final refusal = await connector.factoryResetDevice(deleteAppData: true); + + expect(refusal, isNull); + expect(sent, [buildFactoryResetFrame()]); + }); + } + + test('a sent reset records the pending wipe, keeps the data, and ends the ' + 'session', () async { + await seedOldRadio(); + connector.setConnectedForTest(); + + final refusal = await connector.factoryResetDevice(deleteAppData: true); + + expect(refusal, isNull); + expect(sent.single, buildFactoryResetFrame()); + final pending = FactoryResetStore().load(); + expect(pending?.deviceId, 'dev-1'); + expect(pending?.radioPrefix, oldPrefix); + expect(pending?.deleteAppData, isTrue); + expect(await oldRadioDataExists(), isTrue); + expect(connector.state, MeshCoreConnectionState.disconnected); + expect(connector.factoryResetStatus, isNotNull); + }); + + group('the next connect settles it', () { + Future pend({bool deleteAppData = true}) => FactoryResetStore().save( + PendingFactoryReset( + deviceId: 'dev-1', + radioPrefix: oldPrefix, + deleteAppData: deleteAppData, + ), + ); + + test('new identity, box ticked: old data deleted', () async { + await seedOldRadio(); + await pend(); + connector.setSelfPublicKeyForTest(keyOf(0xbb)); + + await connector.resolvePendingFactoryResetForTest(); + + expect(await oldRadioDataExists(), isFalse); + expect(FactoryResetStore().load(), isNull); + expect(connector.factoryResetStatus, FactoryResetStatus.complete); + }); + + test('new identity, box unticked: old data kept', () async { + await seedOldRadio(); + await pend(deleteAppData: false); + connector.setSelfPublicKeyForTest(keyOf(0xbb)); + + await connector.resolvePendingFactoryResetForTest(); + + expect(await oldRadioDataExists(), isTrue); + expect(FactoryResetStore().load(), isNull); + expect(connector.factoryResetStatus, FactoryResetStatus.completeKept); + }); + + test('same identity: not reset, data kept', () async { + await seedOldRadio(); + await pend(); + + await connector.resolvePendingFactoryResetForTest(); + + expect(await oldRadioDataExists(), isTrue); + expect(FactoryResetStore().load(), isNull); + expect(connector.factoryResetStatus, FactoryResetStatus.notReset); + }); + + test('a different device: nothing decided, data and record kept', () async { + await seedOldRadio(); + await pend(); + connector + ..transportIdentityForTest = 'dev-2' + ..setSelfPublicKeyForTest(keyOf(0xbb)); + + await connector.resolvePendingFactoryResetForTest(); + + expect(await oldRadioDataExists(), isTrue); + expect(FactoryResetStore().load(), isNotNull); + expect(connector.factoryResetStatus, isNull); + }); + + test('the new radio\'s own data is never touched', () async { + await pend(); + await BlobStore.instance.write('contacts$newPrefix', '[]'); + connector.setSelfPublicKeyForTest(keyOf(0xbb)); + + await connector.resolvePendingFactoryResetForTest(); + + expect( + await BlobStore.instance.keysWithPrefix('contacts$newPrefix'), + isNotEmpty, + ); + }); + }); +} diff --git a/test/storage/radio_data_wipe_test.dart b/test/storage/radio_data_wipe_test.dart new file mode 100644 index 00000000..c60db357 --- /dev/null +++ b/test/storage/radio_data_wipe_test.dart @@ -0,0 +1,174 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/storage/drift/blob_store.dart'; +import 'package:meshcore_open/storage/drift/offband_database.dart'; +import 'package:meshcore_open/storage/factory_reset_store.dart'; +import 'package:meshcore_open/storage/prefs_manager.dart'; +import 'package:meshcore_open/storage/radio_data_wipe.dart'; +import 'package:meshcore_open/storage/scoping_inventory.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// #1057: the factory-reset app-data wipe and the reconnect decision. +void main() { + late OffbandDatabase db; + + const wiped = 'aaaaaaaaaa'; + const other = 'bbbbbbbbbb'; + + final perRadio = [ + for (final s in storageSurfaces) + if (s.verdict == ScopingVerdict.perRadio) s.key, + ]; + final global = [ + for (final s in storageSurfaces) + if (s.verdict == ScopingVerdict.globalOnPurpose) s.key, + ]; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + PrefsManager.reset(); + await PrefsManager.initialize(); + db = OffbandDatabase(NativeDatabase.memory()); + BlobStore.overrideForTest(BlobStore(db)); + }); + + tearDown(() async { + await db.close(); + BlobStore.clearTestOverride(); + }); + + /// Every perRadio surface gets a drift key, a prefs key and message rows for + /// both radios, so the wipe has to tell them apart in every backend. + Future seed() async { + final blobs = BlobStore.instance; + final prefs = PrefsManager.instance; + for (final radio in [wiped, other]) { + for (final surface in perRadio) { + await blobs.write('$surface${radio}_drift', 'v'); + await prefs.setString('$surface${radio}_prefs', 'v'); + await blobs.upsertMessageRows('$surface${radio}_rows', const [ + MessageRowInput(mergeKey: 'm', timestampMs: 1, payload: '{}'), + ]); + } + } + for (final surface in global) { + await prefs.setString(surface, 'v'); + } + } + + Future> allKeys() async => { + ...await BlobStore.instance.keysWithPrefix(''), + ...await BlobStore.instance.messageStoreKeys(), + }; + + test('removes every perRadio surface of the reset radio, in every ' + 'backend', () async { + await seed(); + await wipeRadioData(wiped); + + final left = await allKeys(); + for (final surface in perRadio) { + expect( + left.where((k) => k.startsWith('$surface$wiped')), + isEmpty, + reason: '$surface survived the wipe', + ); + } + }); + + test('leaves another radio and every global surface alone', () async { + await seed(); + await wipeRadioData(wiped); + + final left = await allKeys(); + for (final surface in perRadio) { + for (final suffix in ['_drift', '_prefs', '_rows']) { + expect(left, contains('$surface$other$suffix')); + } + } + for (final surface in global) { + expect(PrefsManager.instance.getString(surface), 'v'); + } + }); + + test('refuses anything but a 10-hex prefix, before deleting', () async { + await seed(); + final before = await allKeys(); + + for (final bad in [ + '', + 'aaaaa', + 'AAAAAAAAAA', + 'aaaaaaaaaaa', + 'zzzzzzzzzz', + ]) { + await expectLater(wipeRadioData(bad), throwsArgumentError); + } + expect(await allKeys(), before); + }); + + group('resolveFactoryReset', () { + const pending = PendingFactoryReset( + deviceId: 'dev-1', + radioPrefix: wiped, + deleteAppData: true, + ); + + test('new key on the same device with the box ticked wipes', () { + expect( + resolveFactoryReset(pending, deviceId: 'dev-1', radioPrefix: other), + FactoryResetOutcome.wipeAppData, + ); + }); + + test('new key with the box unticked keeps', () { + const keep = PendingFactoryReset( + deviceId: 'dev-1', + radioPrefix: wiped, + deleteAppData: false, + ); + expect( + resolveFactoryReset(keep, deviceId: 'dev-1', radioPrefix: other), + FactoryResetOutcome.keepAppData, + ); + }); + + test('same key means the radio was not reset', () { + expect( + resolveFactoryReset(pending, deviceId: 'dev-1', radioPrefix: wiped), + FactoryResetOutcome.notReset, + ); + }); + + test('a different device decides nothing', () { + expect( + resolveFactoryReset(pending, deviceId: 'dev-2', radioPrefix: other), + isNull, + ); + }); + }); + + test('the pending record survives a restart and clears', () async { + final store = FactoryResetStore(); + await store.save( + const PendingFactoryReset( + deviceId: 'COM7', + radioPrefix: wiped, + deleteAppData: true, + ), + ); + + final reloaded = FactoryResetStore().load(); + expect(reloaded?.deviceId, 'COM7'); + expect(reloaded?.radioPrefix, wiped); + expect(reloaded?.deleteAppData, isTrue); + + await store.clear(); + expect(FactoryResetStore().load(), isNull); + }); + + test('an unreadable pending record loads as none', () async { + await PrefsManager.instance.setString('factory_reset_pending', '{oops'); + expect(FactoryResetStore().load(), isNull); + }); +} diff --git a/untranslated.json b/untranslated.json index e97b1678..74e91850 100644 --- a/untranslated.json +++ b/untranslated.json @@ -79,6 +79,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -458,6 +473,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -837,6 +867,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -1216,6 +1261,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -1595,6 +1655,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -1974,6 +2049,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -2353,6 +2443,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -2732,6 +2837,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -3111,6 +3231,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -3490,6 +3625,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -3869,6 +4019,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -4248,6 +4413,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -4627,6 +4807,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -5006,6 +5201,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -5385,6 +5595,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -5764,6 +5989,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp", @@ -6143,6 +6383,21 @@ "settings_badgeBatteryReading", "settings_buzzerTones", "settings_buzzerTonesSubtitle", + "settings_factoryReset", + "settings_factoryResetSubtitle", + "settings_factoryResetConfirm", + "settings_factoryResetDeleteAppData", + "settings_factoryResetNotConnected", + "factoryResetSentTitle", + "factoryResetSentBody", + "factoryResetSentBleBody", + "factoryResetCompleteTitle", + "factoryResetCompleteBody", + "factoryResetCompleteKeptBody", + "factoryResetNotResetTitle", + "factoryResetNotResetBody", + "factoryResetDeleteFailedTitle", + "factoryResetDeleteFailedBody", "settings_blePin", "settings_blePinDefault", "settings_blePinHelp",