mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
More restore fixes
This commit is contained in:
@@ -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),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 (_) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>>[];
|
||||||
|
|||||||
Reference in New Issue
Block a user