Merge pull request #1119 from OffbandMesh/feat/1054-factory-reset
feat(#1057): factory reset the connected radiofix/1122-notify-logging
commit
6b8356fc87
@ -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<String, dynamic> toJson() => {
|
||||
'device_id': deviceId,
|
||||
'radio_prefix': radioPrefix,
|
||||
'delete_app_data': deleteAppData,
|
||||
};
|
||||
|
||||
static PendingFactoryReset? fromJson(Map<String, dynamic> 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<String, dynamic>,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> save(PendingFactoryReset pending) =>
|
||||
PrefsManager.instance.setString(_key, jsonEncode(pending.toJson()));
|
||||
|
||||
Future<void> clear() => PrefsManager.instance.remove(_key);
|
||||
}
|
||||
@ -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<int> 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;
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<Uint8List> 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<void> seedOldRadio() async {
|
||||
await BlobStore.instance.write('contacts$oldPrefix', '[]');
|
||||
await PrefsManager.instance.setString('channels$oldPrefix', '[]');
|
||||
}
|
||||
|
||||
Future<bool> 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<void> 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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<void> 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<Set<String>> 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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in new issue