diff --git a/lib/services/bookmarks_service.dart b/lib/services/bookmarks_service.dart index 9b91967..4e42a14 100644 --- a/lib/services/bookmarks_service.dart +++ b/lib/services/bookmarks_service.dart @@ -48,6 +48,16 @@ class BookmarksNotifier extends StateNotifier> { DateTime? _lastIssuedAt; 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) { if (stack == null || !stack.isDecrypted) return; if (_lastPersistedAt == null || @@ -122,6 +132,9 @@ final bookmarksProvider = StateNotifierProvider>( final notifier = BookmarksNotifier( (ids, createdAt) => _writeBookmarks(ref, ids, createdAt), ); + ref.listen(devicePubkeyProvider, (previous, next) { + if (previous != next) notifier.reset(); + }); ref.listen( _persistedBookmarksProvider, (_, stack) => notifier.acceptPersisted(stack), diff --git a/lib/services/device_backup_service.dart b/lib/services/device_backup_service.dart index 351d4a7..0983946 100644 --- a/lib/services/device_backup_service.dart +++ b/lib/services/device_backup_service.dart @@ -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 promptAndRestoreBackup({ + required Ref ref, + required String privateKeyHex, + }) { + return _amberRestore ??= _promptAndRestoreBackup(ref, privateKeyHex) + .whenComplete(() { + _amberRestore = null; + }); + } + + Future _promptAndRestoreBackup(Ref ref, String privateKeyHex) async { final context = rootNavigatorKey.currentState?.overlay?.context; if (context == null || !context.mounted) { throw const DeviceBackupException('Restore screen is unavailable.'); @@ -198,7 +217,13 @@ class DeviceBackupService { 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 ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey(); @@ -212,7 +237,12 @@ final deviceBackupServiceProvider = Provider( (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 maybeOfferDeviceBackup(Ref ref) async { final amberSigner = ref.read(Signer.activeSignerProvider); if (amberSigner == null) return; @@ -220,13 +250,37 @@ Future maybeOfferDeviceBackup(Ref ref) async { final service = ref.read(deviceBackupServiceProvider); if (service.isRestoringFromAmber) return; 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; } - await service.backupDeviceKey(ref: ref, amberSigner: amberSigner); - if (!ref.read(deviceStateProvider).isReady) { - unawaited(ref.read(deviceStateProvider.notifier).bootstrap()); + + final current = await ref + .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) { LogService.I.warn( 'device key backup failed', diff --git a/lib/services/device_private_sync_service.dart b/lib/services/device_private_sync_service.dart index 7e39ee5..a250c02 100644 --- a/lib/services/device_private_sync_service.dart +++ b/lib/services/device_private_sync_service.dart @@ -72,12 +72,30 @@ class DevicePrivateSyncNotifier extends StateNotifier { _activeRequest = request; 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, - source: const RemoteSource(relays: 'AppCatalog', stream: false), + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), subscriptionPrefix: 'app-device-private-boot', ); 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().length, + }, + ); + await ref.read(deviceStateProvider.notifier).restoreFromLocalEvent(); if (_cancelled) return; state = const DevicePrivateSyncState(DevicePrivateSyncPhase.success); diff --git a/lib/services/device_state_service.dart b/lib/services/device_state_service.dart index 7f4a81e..7f1651a 100644 --- a/lib/services/device_state_service.dart +++ b/lib/services/device_state_service.dart @@ -122,6 +122,7 @@ class DeviceStateNotifier extends StateNotifier { .savePortable( PortableSettings.fromJson(Map.from(decoded)), ); + ref.invalidate(localSettingsProvider); if (!_disposed) state = const DeviceStateStatus.ready(); return true; } catch (_) { diff --git a/lib/services/unmanaged_apps_service.dart b/lib/services/unmanaged_apps_service.dart index 9f81c91..b5e7eb9 100644 --- a/lib/services/unmanaged_apps_service.dart +++ b/lib/services/unmanaged_apps_service.dart @@ -77,6 +77,16 @@ class UnmanagedAppsNotifier extends StateNotifier> { DateTime? _lastIssuedAt; 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 appIds, DateTime? createdAt) { if (createdAt == null || _lastPersistedAt == null || @@ -169,6 +179,9 @@ final unmanagedAppsProvider = (appIds, createdAt) => _writeUnmanagedApps(ref, appIds, createdAt: createdAt), ); + ref.listen(devicePubkeyProvider, (previous, next) { + if (previous != next) notifier.reset(); + }); ref.listen<_UnmanagedAppsSnapshot?>(_persistedUnmanagedAppsProvider, ( _, snapshot, diff --git a/lib/widgets/device_restore_dialog.dart b/lib/widgets/device_restore_dialog.dart index 58cb7b2..3e488c6 100644 --- a/lib/widgets/device_restore_dialog.dart +++ b/lib/widgets/device_restore_dialog.dart @@ -38,7 +38,8 @@ class DeviceRestoreDialog extends HookConsumerWidget { ), const SizedBox(height: 10), 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), OutlinedButton.icon( diff --git a/spec/work/WORK-026-new-device-key-reminder.md b/spec/work/WORK-026-new-device-key-reminder.md index b11863c..a9a3e16 100644 --- a/spec/work/WORK-026-new-device-key-reminder.md +++ b/spec/work/WORK-026-new-device-key-reminder.md @@ -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 that points to the prior device's private state. -**Decision:** On a normal Amber sign-in, query for a signature-verified backup -first and only create one when none exists. Recovery must be started explicitly -from Device key management, which now exposes the Amber option. +**Decision:** On a normal Amber sign-in, decrypt any existing Amber backup. If +it holds a different device key, offer restore (never overwrite). If it matches +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 -portable settings unrecoverable. +**Rationale:** Sign-in is the path users expect for recovery. Preserving the +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. diff --git a/test/services/bookmarks_service_test.dart b/test/services/bookmarks_service_test.dart index 188c4ef..d07b98c 100644 --- a/test/services/bookmarks_service_test.dart +++ b/test/services/bookmarks_service_test.dart @@ -58,4 +58,15 @@ void main() { ); 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); + }); } diff --git a/test/services/device_backup_service_test.dart b/test/services/device_backup_service_test.dart index 1bd3fa1..4dace1c 100644 --- a/test/services/device_backup_service_test.dart +++ b/test/services/device_backup_service_test.dart @@ -3,8 +3,46 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:models/models.dart'; import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/services/device_backup_service.dart'; +import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/utils/debug_utils.dart'; +class _TrackingBackupService extends DeviceBackupService { + var backupCalls = 0; + var restorePromptCalls = 0; + String? fetchedBackup; + + @override + Future fetchAmberBackup({ + required Ref ref, + required Signer amberSigner, + }) async => fetchedBackup; + + @override + Future backupDeviceKey({ + required Ref ref, + required Signer amberSigner, + }) async { + backupCalls++; + } + + @override + Future promptAndRestoreBackup({ + required Ref ref, + required String privateKeyHex, + }) async { + restorePromptCalls++; + } +} + +class _FakeDeviceKeyService extends DeviceKeyService { + _FakeDeviceKeyService(this.privateKeyHex); + + final String privateKeyHex; + + @override + Future getOrCreatePrivateKey() async => privateKeyHex; +} + void main() { test('backup exceptions retain a user-safe message', () { 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); + }); } diff --git a/test/services/device_private_sync_service_test.dart b/test/services/device_private_sync_service_test.dart index 40c191c..76d0bdc 100644 --- a/test/services/device_private_sync_service_test.dart +++ b/test/services/device_private_sync_service_test.dart @@ -44,4 +44,42 @@ void main() { expect(calls, 1); 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 = []; + final sources = []; + 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()); + expect(notifier.state.phase, DevicePrivateSyncPhase.success); + }); } diff --git a/test/services/unmanaged_apps_service_test.dart b/test/services/unmanaged_apps_service_test.dart index 16b7047..0b104fb 100644 --- a/test/services/unmanaged_apps_service_test.dart +++ b/test/services/unmanaged_apps_service_test.dart @@ -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 { final firstWrite = Completer(); final writes = >[];