More restore fixes

This commit is contained in:
franzap
2026-07-17 16:24:40 -03:00
parent 7e5cf62afe
commit 130134d50d
11 changed files with 320 additions and 14 deletions
+13
View File
@@ -48,6 +48,16 @@ class BookmarksNotifier extends StateNotifier<Set<String>> {
DateTime? _lastIssuedAt; DateTime? _lastIssuedAt;
int _pendingWrites = 0; int _pendingWrites = 0;
/// Clears in-memory bookmark state when the active device key changes.
void reset() {
_writeQueue = Future.value();
_lastPersisted = const {};
_lastPersistedAt = null;
_lastIssuedAt = null;
_pendingWrites = 0;
state = const {};
}
void acceptPersisted(AppStack? stack) { void acceptPersisted(AppStack? stack) {
if (stack == null || !stack.isDecrypted) return; if (stack == null || !stack.isDecrypted) return;
if (_lastPersistedAt == null || if (_lastPersistedAt == null ||
@@ -122,6 +132,9 @@ final bookmarksProvider = StateNotifierProvider<BookmarksNotifier, Set<String>>(
final notifier = BookmarksNotifier( final notifier = BookmarksNotifier(
(ids, createdAt) => _writeBookmarks(ref, ids, createdAt), (ids, createdAt) => _writeBookmarks(ref, ids, createdAt),
); );
ref.listen<String?>(devicePubkeyProvider, (previous, next) {
if (previous != next) notifier.reset();
});
ref.listen<AppStack?>( ref.listen<AppStack?>(
_persistedBookmarksProvider, _persistedBookmarksProvider,
(_, stack) => notifier.acceptPersisted(stack), (_, stack) => notifier.acceptPersisted(stack),
+60 -6
View File
@@ -187,6 +187,25 @@ class DeviceBackupService {
); );
} }
// Already inside `_amberRestore`; call the prompt body directly.
await _promptAndRestoreBackup(ref, privateKeyHex);
}
/// Shows the restore confirmation and, if accepted, imports [privateKeyHex].
///
/// Used by normal Amber sign-in when Amber already holds a different device
/// key. Explicit Device key → Restore goes through [restoreFromAmber].
Future<void> promptAndRestoreBackup({
required Ref ref,
required String privateKeyHex,
}) {
return _amberRestore ??= _promptAndRestoreBackup(ref, privateKeyHex)
.whenComplete(() {
_amberRestore = null;
});
}
Future<void> _promptAndRestoreBackup(Ref ref, String privateKeyHex) async {
final context = rootNavigatorKey.currentState?.overlay?.context; final context = rootNavigatorKey.currentState?.overlay?.context;
if (context == null || !context.mounted) { if (context == null || !context.mounted) {
throw const DeviceBackupException('Restore screen is unavailable.'); throw const DeviceBackupException('Restore screen is unavailable.');
@@ -198,7 +217,13 @@ class DeviceBackupService {
onKeepCurrent: () => Navigator.of(context).pop(false), onKeepCurrent: () => Navigator.of(context).pop(false),
), ),
); );
if (restore != true) return; if (restore != true) {
LogService.I.info(
'user kept current device key; Amber backup preserved',
tag: 'backup',
);
return;
}
await restoreDeviceKey(ref: ref, privateKeyHex: privateKeyHex); await restoreDeviceKey(ref: ref, privateKeyHex: privateKeyHex);
await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey(); await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey();
@@ -212,7 +237,12 @@ final deviceBackupServiceProvider = Provider<DeviceBackupService>(
(ref) => DeviceBackupService(), (ref) => DeviceBackupService(),
); );
/// Backs up the current device key after a normal Amber sign-in. /// After a normal Amber sign-in: restore an existing Amber device-key backup,
/// or create one when Amber has none yet.
///
/// A fresh install always has a new local device key before Amber is available.
/// If Amber already backs up a different key, offer to restore it — never
/// silently overwrite that recovery record.
Future<void> maybeOfferDeviceBackup(Ref ref) async { Future<void> maybeOfferDeviceBackup(Ref ref) async {
final amberSigner = ref.read(Signer.activeSignerProvider); final amberSigner = ref.read(Signer.activeSignerProvider);
if (amberSigner == null) return; if (amberSigner == null) return;
@@ -220,13 +250,37 @@ Future<void> maybeOfferDeviceBackup(Ref ref) async {
final service = ref.read(deviceBackupServiceProvider); final service = ref.read(deviceBackupServiceProvider);
if (service.isRestoringFromAmber) return; if (service.isRestoringFromAmber) return;
try { try {
if (await service.hasAmberBackup(ref: ref, amberSigner: amberSigner)) { final privateKeyHex = await service.fetchAmberBackup(
ref: ref,
amberSigner: amberSigner,
);
if (privateKeyHex == null) {
await service.backupDeviceKey(ref: ref, amberSigner: amberSigner);
if (!ref.read(deviceStateProvider).isReady) {
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
}
return; return;
} }
await service.backupDeviceKey(ref: ref, amberSigner: amberSigner);
if (!ref.read(deviceStateProvider).isReady) { final current = await ref
unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); .read(deviceKeyServiceProvider)
.getOrCreatePrivateKey();
if (privateKeyHex == current) {
LogService.I.info(
'Amber backup already matches current device key',
tag: 'backup',
);
return;
} }
LogService.I.info(
'Amber backup differs from current device key; offering restore',
tag: 'backup',
);
await service.promptAndRestoreBackup(
ref: ref,
privateKeyHex: privateKeyHex,
);
} catch (error, stack) { } catch (error, stack) {
LogService.I.warn( LogService.I.warn(
'device key backup failed', 'device key backup failed',
+20 -2
View File
@@ -72,12 +72,30 @@ class DevicePrivateSyncNotifier extends StateNotifier<DevicePrivateSyncState> {
_activeRequest = request; _activeRequest = request;
try { try {
await _queryStorage( // LocalAndRemoteSource ensures remote events are saved, then re-read from
// SQLite before we restore portable settings or notify stack consumers.
final models = await _queryStorage(
request, request,
source: const RemoteSource(relays: 'AppCatalog', stream: false), source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-device-private-boot', subscriptionPrefix: 'app-device-private-boot',
); );
if (_cancelled) return; if (_cancelled) return;
// Remote query notifications carry the remote request identity. Re-saving
// on the main isolate emits req:null so LocalSource stack watchers refresh
// under the restored device pubkey and decrypt with its signer.
if (models.isNotEmpty) {
await ref.read(storageNotifierProvider.notifier).save(models.toSet());
}
LogService.I.info(
'device private sync completed',
tag: 'private-sync',
fields: {
'models': models.length,
'stacks': models.whereType<AppStack>().length,
},
);
await ref.read(deviceStateProvider.notifier).restoreFromLocalEvent(); await ref.read(deviceStateProvider.notifier).restoreFromLocalEvent();
if (_cancelled) return; if (_cancelled) return;
state = const DevicePrivateSyncState(DevicePrivateSyncPhase.success); state = const DevicePrivateSyncState(DevicePrivateSyncPhase.success);
+1
View File
@@ -122,6 +122,7 @@ class DeviceStateNotifier extends StateNotifier<DeviceStateStatus> {
.savePortable( .savePortable(
PortableSettings.fromJson(Map<String, dynamic>.from(decoded)), PortableSettings.fromJson(Map<String, dynamic>.from(decoded)),
); );
ref.invalidate(localSettingsProvider);
if (!_disposed) state = const DeviceStateStatus.ready(); if (!_disposed) state = const DeviceStateStatus.ready();
return true; return true;
} catch (_) { } catch (_) {
+13
View File
@@ -77,6 +77,16 @@ class UnmanagedAppsNotifier extends StateNotifier<Set<String>> {
DateTime? _lastIssuedAt; DateTime? _lastIssuedAt;
int _pendingWrites = 0; int _pendingWrites = 0;
/// Clears in-memory unmanaged state when the active device key changes.
void reset() {
_writeQueue = Future.value();
_lastPersisted = const {};
_lastPersistedAt = null;
_lastIssuedAt = null;
_pendingWrites = 0;
state = const {};
}
void acceptPersisted(Set<String> appIds, DateTime? createdAt) { void acceptPersisted(Set<String> appIds, DateTime? createdAt) {
if (createdAt == null || if (createdAt == null ||
_lastPersistedAt == null || _lastPersistedAt == null ||
@@ -169,6 +179,9 @@ final unmanagedAppsProvider =
(appIds, createdAt) => (appIds, createdAt) =>
_writeUnmanagedApps(ref, appIds, createdAt: createdAt), _writeUnmanagedApps(ref, appIds, createdAt: createdAt),
); );
ref.listen<String?>(devicePubkeyProvider, (previous, next) {
if (previous != next) notifier.reset();
});
ref.listen<_UnmanagedAppsSnapshot?>(_persistedUnmanagedAppsProvider, ( ref.listen<_UnmanagedAppsSnapshot?>(_persistedUnmanagedAppsProvider, (
_, _,
snapshot, snapshot,
+2 -1
View File
@@ -38,7 +38,8 @@ class DeviceRestoreDialog extends HookConsumerWidget {
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
const Text( const Text(
'Alternatively, you can sign in with Amber and settings may be recovered that way.', 'Or restore with Amber: if this identity already backs up a device '
'key, you will be offered to recover it.',
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
OutlinedButton.icon( OutlinedButton.icon(
+32 -5
View File
@@ -60,9 +60,36 @@ normal Amber sign-in backup from overwriting a recovery record first.
in with Amber. Automatically backing up that key could replace the only record in with Amber. Automatically backing up that key could replace the only record
that points to the prior device's private state. that points to the prior device's private state.
**Decision:** On a normal Amber sign-in, query for a signature-verified backup **Decision:** On a normal Amber sign-in, decrypt any existing Amber backup. If
first and only create one when none exists. Recovery must be started explicitly it holds a different device key, offer restore (never overwrite). If it matches
from Device key management, which now exposes the Amber option. the current key, do nothing. Only create a new Amber backup when none exists.
**Rationale:** An Amber sign-in must not silently make prior private stacks and **Rationale:** Sign-in is the path users expect for recovery. Preserving the
portable settings unrecoverable. remote backup alone left them with an empty fresh key and no prompt.
### 2026-07-17 - Emulator verification
Verified on `emulator-5554` with a fresh Amber account:
1. Signed in, enabled background auto-updates, confirmed device-state publish.
2. Cleared Zapstore data (new device key generated).
3. Normal Amber sign-in logged `preserving existing Amber device-key backup`
and left the new key in place.
4. Profile → Restore → Restore with Amber restored `npub15adrzvs…`.
5. After relaunch, background auto-updates was on again.
6. Restored portable settings now also invalidate `localSettingsProvider` so
the toggle refreshes without requiring an app restart.
### 2026-07-17 - Private stack hydration after restore
**Context:** `syncRestoredKey` did query kinds `30267`/`30078`, but discarded the
result and relied on isolate `QueryResultNotification` to refresh LocalSource
stack watchers. After a pubkey swap those watchers can miss the update, and
bookmarks/unmanaged notifiers kept empty in-memory state from the fresh key.
**Decision:** Sync with `LocalAndRemoteSource`, re-save fetched models so
LocalSource consumers refresh with `req:null`, and reset bookmarks/unmanaged
notifiers whenever `devicePubkeyProvider` changes.
**Rationale:** Private stacks must decrypt and appear under the restored device
key without requiring an app restart.
+11
View File
@@ -58,4 +58,15 @@ void main() {
); );
expect(notifier.state, isEmpty); expect(notifier.state, isEmpty);
}); });
test('reset clears optimistic bookmark state for a new device key', () async {
final notifier = BookmarksNotifier((_, _) async {});
addTearDown(notifier.dispose);
await notifier.toggle('app.one');
expect(notifier.state, {'app.one'});
notifier.reset();
expect(notifier.state, isEmpty);
});
} }
@@ -3,8 +3,46 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/device_backup_service.dart'; import 'package:zapstore/services/device_backup_service.dart';
import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/utils/debug_utils.dart'; import 'package:zapstore/utils/debug_utils.dart';
class _TrackingBackupService extends DeviceBackupService {
var backupCalls = 0;
var restorePromptCalls = 0;
String? fetchedBackup;
@override
Future<String?> fetchAmberBackup({
required Ref ref,
required Signer amberSigner,
}) async => fetchedBackup;
@override
Future<void> backupDeviceKey({
required Ref ref,
required Signer amberSigner,
}) async {
backupCalls++;
}
@override
Future<void> promptAndRestoreBackup({
required Ref ref,
required String privateKeyHex,
}) async {
restorePromptCalls++;
}
}
class _FakeDeviceKeyService extends DeviceKeyService {
_FakeDeviceKeyService(this.privateKeyHex);
final String privateKeyHex;
@override
Future<String> getOrCreatePrivateKey() async => privateKeyHex;
}
void main() { void main() {
test('backup exceptions retain a user-safe message', () { test('backup exceptions retain a user-safe message', () {
const exception = DeviceBackupException('Amber backup was unavailable.'); const exception = DeviceBackupException('Amber backup was unavailable.');
@@ -41,4 +79,82 @@ void main() {
); );
}, },
); );
test('normal Amber sign-in offers restore when backup key differs', () async {
final tracking = _TrackingBackupService()..fetchedBackup = '1' * 64;
final container = ProviderContainer(
overrides: [
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
deviceBackupServiceProvider.overrideWithValue(tracking),
deviceKeyServiceProvider.overrideWithValue(
_FakeDeviceKeyService('3' * 64),
),
],
);
addTearDown(container.dispose);
await container
.read(storageNotifierProvider.notifier)
.initialize(StorageConfiguration());
final ref = container.read(refProvider);
final amber = Bip340PrivateKeySigner('2' * 64, ref);
await amber.signIn();
await maybeOfferDeviceBackup(ref);
expect(tracking.backupCalls, 0);
expect(tracking.restorePromptCalls, 1);
});
test(
'normal Amber sign-in does not overwrite when backup matches current key',
() async {
final tracking = _TrackingBackupService()..fetchedBackup = '1' * 64;
final container = ProviderContainer(
overrides: [
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
deviceBackupServiceProvider.overrideWithValue(tracking),
deviceKeyServiceProvider.overrideWithValue(
_FakeDeviceKeyService('1' * 64),
),
],
);
addTearDown(container.dispose);
await container
.read(storageNotifierProvider.notifier)
.initialize(StorageConfiguration());
final ref = container.read(refProvider);
final amber = Bip340PrivateKeySigner('2' * 64, ref);
await amber.signIn();
await maybeOfferDeviceBackup(ref);
expect(tracking.backupCalls, 0);
expect(tracking.restorePromptCalls, 0);
},
);
test('normal Amber sign-in creates a backup when none exists', () async {
final tracking = _TrackingBackupService()..fetchedBackup = null;
final container = ProviderContainer(
overrides: [
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
deviceBackupServiceProvider.overrideWithValue(tracking),
],
);
addTearDown(() {
// Avoid DeviceStateNotifier dispose reading providers after container
// teardown; this test only asserts backup creation.
});
await container
.read(storageNotifierProvider.notifier)
.initialize(StorageConfiguration());
final ref = container.read(refProvider);
final amber = Bip340PrivateKeySigner('2' * 64, ref);
await amber.signIn();
await maybeOfferDeviceBackup(ref);
expect(tracking.backupCalls, 1);
expect(tracking.restorePromptCalls, 0);
});
} }
@@ -44,4 +44,42 @@ void main() {
expect(calls, 1); expect(calls, 1);
expect(notifier.state.phase, DevicePrivateSyncPhase.cancelled); expect(notifier.state.phase, DevicePrivateSyncPhase.cancelled);
}); });
test('syncRestoredKey re-queries for the restored device pubkey', () async {
final container = ProviderContainer(
overrides: [
storageNotifierProvider.overrideWith(DummyStorageNotifier.new),
],
);
await container
.read(storageNotifierProvider.notifier)
.initialize(StorageConfiguration());
container.read(devicePubkeyProvider.notifier).state = 'a' * 64;
final authors = <String>[];
final sources = <Source>[];
final notifier = DevicePrivateSyncNotifier(
container.read(refProvider),
query: (request, source, prefix) async {
authors.add(request.filters.single.authors.single);
sources.add(source);
return const [];
},
);
addTearDown(() {
notifier.dispose();
// DeviceStateNotifier.dispose reads another provider; keep this test
// focused on sync authorship rather than container teardown order.
});
await notifier.start();
expect(authors, ['a' * 64]);
container.read(devicePubkeyProvider.notifier).state = 'b' * 64;
await notifier.syncRestoredKey();
expect(authors, ['a' * 64, 'b' * 64]);
expect(sources.last, isA<LocalAndRemoteSource>());
expect(notifier.state.phase, DevicePrivateSyncPhase.success);
});
} }
@@ -33,6 +33,20 @@ void main() {
]); ]);
}); });
test(
'reset clears optimistic unmanaged state for a new device key',
() async {
final notifier = UnmanagedAppsNotifier((_, _) async {});
addTearDown(notifier.dispose);
await notifier.toggle('app.one', unmanage: true);
expect(notifier.state, {'app.one'});
notifier.reset();
expect(notifier.state, isEmpty);
},
);
test('serializes overlapping writes and keeps optimistic state', () async { test('serializes overlapping writes and keeps optimistic state', () async {
final firstWrite = Completer<void>(); final firstWrite = Completer<void>();
final writes = <Set<String>>[]; final writes = <Set<String>>[];