diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index ab39a10..43394ed 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -18,7 +18,7 @@ pluginManagement { plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "8.7.3" apply false + id("com.android.application") version "8.9.1" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false } diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart index 8f0eb15..76a2ae2 100644 --- a/lib/constants/app_constants.dart +++ b/lib/constants/app_constants.dart @@ -28,6 +28,11 @@ const kInstalledAppsIdentifier = 'zapstore-installed-apps'; /// Identifier for the encrypted stack of apps the user chose as unmanaged const kUnmanagedAppsIdentifier = 'zapstore-unmanaged-apps'; +/// Identifier for device-private settings and recovery capsules +const kSettingsIdentifier = 'zapstore-settings'; + +/// Identifier for the local encrypted trusted-signer preference +const kTrustedSignersIdentifier = 'trusted-signers'; /// Authoritative relay for NIP-82 software application events (kind 32267). /// Used as the relay hint in naddr encoding so other clients can resolve app events. diff --git a/lib/main.dart b/lib/main.dart index 6c9cd46..c7e3e0a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -26,7 +26,9 @@ import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; import 'package:zapstore/services/deep_link_service.dart'; import 'package:zapstore/services/device_backup_service.dart'; import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_sync_service.dart'; import 'package:zapstore/services/app_catalog_relay_service.dart'; +import 'package:zapstore/utils/debug_utils.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/widgets/breathing_logo.dart'; @@ -169,6 +171,13 @@ class ZapstoreApp extends HookConsumerWidget { // Watch initialization state for error overlay display final initState = ref.watch(appInitializationProvider); + // One coordinator handles every successful Amber key transition, including + // auto sign-in and every manual sign-in entry point. + ref.listen(Signer.activePubkeyProvider, (previous, next) { + if (next == null || next == previous) return; + onSignInSuccess(ref.read(refProvider)); + }); + // Listen to app lifecycle and check for updates when app regains focus useEffect(() { final observer = _AppLifecycleObserver(ref); @@ -402,6 +411,9 @@ final storageReadyProvider = FutureProvider((ref) async { final appInitializationProvider = FutureProvider((ref) async { await ref.read(storageReadyProvider.future); + // Private relay ingestion is one-shot, non-streaming, and never gates UI. + unawaited(ref.read(devicePrivateSyncProvider.notifier).start()); + // Initialize device capabilities (used for dynamic download concurrency) await DeviceCapabilitiesCache.initialize(); @@ -464,7 +476,6 @@ Future _maybeCopySeedDatabase(String dbPath, {bool skip = false}) async { Future _attemptAutoSignIn(Ref ref) async { try { await ref.read(amberSignerProvider).attemptAutoSignIn(); - onSignInSuccess(ref); } catch (e, st) { // Auto sign-in fails on first install — that's fine, just continue. // Logged at debug because this is expected for new users. @@ -486,6 +497,7 @@ Future _attemptAutoSignIn(Ref ref) async { void onSignInSuccess(Ref ref) { final pubkey = ref.read(Signer.activePubkeyProvider); if (pubkey == null) return; + ref.read(deviceBackupServiceProvider).beginWork(); final storage = ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier; @@ -562,6 +574,10 @@ class _AppLifecycleObserver with WidgetsBindingObserver { unawaited(_ref.read(appCatalogRelayServiceProvider).checkForUpdates()); } else if (state == AppLifecycleState.paused) { _ref.read(appCatalogRelayServiceProvider).cancelCurrentCheck(); + _ref.read(devicePrivateSyncProvider.notifier).cancel(); + _ref + .read(deviceBackupServiceProvider) + .cancelCurrentWork(_ref.read(refProvider)); notifier.disconnect(); // Flush any pending log entries to disk before the OS may freeze // or kill us, so diagnostics survive backgrounding. diff --git a/lib/screens/app_stack_screen.dart b/lib/screens/app_stack_screen.dart index 0d0d213..d506dda 100644 --- a/lib/screens/app_stack_screen.dart +++ b/lib/screens/app_stack_screen.dart @@ -1,11 +1,9 @@ -import 'dart:convert'; - import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:skeletonizer/skeletonizer.dart'; +import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/nostr_route.dart'; @@ -17,7 +15,7 @@ import 'package:zapstore/widgets/common/time_utils.dart'; import 'package:zapstore/widgets/floating_overflow_menu.dart'; import 'package:zapstore/theme.dart'; -class AppStackScreen extends HookConsumerWidget { +class AppStackScreen extends ConsumerWidget { const AppStackScreen({super.key, required this.stackId, this.authorPubkey}); final String stackId; @@ -25,6 +23,8 @@ class AppStackScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final isDeviceOwned = + authorPubkey != null && authorPubkey == ref.watch(devicePubkeyProvider); final stackState = ref.watch( query( authors: authorPubkey != null ? {authorPubkey!} : null, @@ -32,10 +32,9 @@ class AppStackScreen extends HookConsumerWidget { '#d': {stackId}, }, limit: 1, - source: const LocalAndRemoteSource( - relays: 'AppCatalog', - stream: false, - ), + source: isDeviceOwned + ? const LocalSource() + : const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), subscriptionPrefix: 'app-stack-detail-$stackId', ), ); @@ -65,7 +64,7 @@ class AppStackScreen extends HookConsumerWidget { } /// Intermediate widget that loads apps with their release relationships -class _AppStackContentWithApps extends HookConsumerWidget { +class _AppStackContentWithApps extends ConsumerWidget { final AppStack stack; const _AppStackContentWithApps({required this.stack}); @@ -73,62 +72,22 @@ class _AppStackContentWithApps extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isEncrypted = stack.content.isNotEmpty; - - // For encrypted stacks, decrypt content to get app IDs - final decryptedAppIds = useState?>(null); - final decryptError = useState(null); - - useEffect(() { - if (!isEncrypted) return null; - - Future decrypt() async { - final signer = ref.read(Signer.activeSignerProvider); - final pubkey = ref.read(Signer.activePubkeyProvider); - - if (signer == null || pubkey == null) { - decryptError.value = 'Sign in required to view this stack'; - return; - } - - try { - final decrypted = await signer.nip44Decrypt(stack.content, pubkey); - final ids = (jsonDecode(decrypted) as List).cast().toSet(); - decryptedAppIds.value = ids; - } catch (e) { - decryptError.value = 'Failed to decrypt stack'; - } - } - - decrypt(); - return null; - }, [stack.content]); - - // Handle decrypt error - if (decryptError.value != null) { + if (isEncrypted && !stack.isDecrypted) { return _AppStackContent( stack: stack, apps: const [], - errorMessage: decryptError.value, - ); - } - - // For encrypted stacks, wait for decryption - if (isEncrypted && decryptedAppIds.value == null) { - return Scaffold( - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: _AppStackSkeleton(), - ), + errorMessage: + 'This private stack could not be decrypted on this device', ); } // Get app addressable IDs from either tags (public) or decrypted content (private) final appAddressableIds = isEncrypted - ? decryptedAppIds.value! + ? stack.privateAppIds.toSet() : stack.event - .getTagSetValues('a') - .where((id) => id.startsWith('32267:')) - .toSet(); + .getTagSetValues('a') + .where((id) => id.startsWith('32267:')) + .toSet(); if (appAddressableIds.isEmpty) { return _AppStackContent(stack: stack, apps: const []); @@ -229,8 +188,8 @@ class _NotFoundScaffold extends StatelessWidget { Text( 'This stack may have been deleted or is not available', style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), textAlign: TextAlign.center, ), ], @@ -316,7 +275,8 @@ class _AppStackContent extends HookConsumerWidget { _EmptyAppsPlaceholder() else ...sortedApps.map( - (app) => AppCard(app: app, showUpdateArrow: app.hasUpdate), + (app) => + AppCard(app: app, showUpdateArrow: app.hasUpdate), ), // Comments section - hidden for private/encrypted stacks if (stack.content.isEmpty) @@ -374,9 +334,9 @@ class _StackHeader extends StatelessWidget { @override Widget build(BuildContext context) { final subtitleColor = Theme.of(context).colorScheme.onSurfaceVariant; - final subtitleStyle = Theme.of(context).textTheme.bodySmall?.copyWith( - color: subtitleColor, - ); + final subtitleStyle = Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: subtitleColor); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -434,7 +394,9 @@ class _StackHeader extends StatelessWidget { const SizedBox(width: 4), Text( 'Private', - style: subtitleStyle?.copyWith(fontWeight: FontWeight.w600), + style: subtitleStyle?.copyWith( + fontWeight: FontWeight.w600, + ), ), ], ), @@ -447,8 +409,8 @@ class _StackHeader extends StatelessWidget { Text( description, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ], ], diff --git a/lib/screens/app_stacks_screen.dart b/lib/screens/app_stacks_screen.dart index 641f3f4..fee5db5 100644 --- a/lib/screens/app_stacks_screen.dart +++ b/lib/screens/app_stacks_screen.dart @@ -82,8 +82,10 @@ class StacksNotifier extends PagedSubscriptionNotifier { final items = await storage .query( req, - source: - const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), subscriptionPrefix: 'app-stacks-older', ) .timeout( @@ -158,6 +160,9 @@ class AppStacksScreen extends HookConsumerWidget { ? ref.watch( query( authors: {signedInPubkey}, + tags: { + '#h': {kZapstoreCommunityPubkey}, + }, where: (s) => stackNeedsMigration(s, platform), source: LocalAndRemoteSource( relays: {'social', 'AppCatalog'}, diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 0f76276..3aebc9f 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -263,6 +263,9 @@ class _StackMigrationWarning extends ConsumerWidget { final stacksState = ref.watch( query( authors: {pubkey}, + tags: { + '#h': {kZapstoreCommunityPubkey}, + }, where: (s) => stackNeedsMigration(s, platform), source: LocalAndRemoteSource(relays: {'AppCatalog'}, stream: false), subscriptionPrefix: 'app-user-stacks-migration-profile', @@ -1796,7 +1799,7 @@ class _UserStacksSection extends ConsumerWidget { final stacksState = ref.watch( query( authors: {devicePubkey}, - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + source: const LocalSource(), subscriptionPrefix: 'app-profile-user-stacks', ), ); diff --git a/lib/services/bookmarks_service.dart b/lib/services/bookmarks_service.dart index e519372..9a2427e 100644 --- a/lib/services/bookmarks_service.dart +++ b/lib/services/bookmarks_service.dart @@ -1,15 +1,16 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; -/// Reactive set of bookmarked app addressable IDs. -/// -/// Backed by an encrypted AppStack signed by the device key. Auto-decrypted -/// by EncryptableModel since the device signer is always registered. -final bookmarksProvider = Provider>((ref) { +final _persistedBookmarksProvider = Provider((ref) { final devicePubkey = ref.watch(devicePubkeyProvider); - if (devicePubkey == null) return const {}; + if (devicePubkey == null) return null; final stackState = ref.watch( query( @@ -17,21 +18,140 @@ final bookmarksProvider = Provider>((ref) { tags: { '#d': {kAppBookmarksIdentifier}, }, - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), + source: const LocalSource(), subscriptionPrefix: 'app-user-saved-apps', ), ); - final stack = switch (stackState) { - StorageLoading() => null, + return switch (stackState) { + StorageLoading(:final models) => models.firstOrNull, StorageError() => null, StorageData(:final models) => models.firstOrNull, }; - - if (stack == null) return const {}; - return stack.privateAppIds.toSet(); }); +typedef BookmarksWriter = + Future Function(Set appIds, DateTime createdAt); + +/// Owns bookmarked IDs and serializes replacement writes. +class BookmarksNotifier extends StateNotifier> { + BookmarksNotifier(this._write, {DateTime Function()? clock}) + : _clock = clock ?? DateTime.now, + super(const {}); + + static const _equality = SetEquality(); + final BookmarksWriter _write; + final DateTime Function() _clock; + Future _writeQueue = Future.value(); + Set _lastPersisted = const {}; + DateTime? _lastPersistedAt; + DateTime? _lastIssuedAt; + int _pendingWrites = 0; + + void acceptPersisted(AppStack? stack) { + if (stack == null || !stack.isDecrypted) return; + if (_lastPersistedAt == null || + !stack.createdAt.isBefore(_lastPersistedAt!)) { + _lastPersisted = Set.unmodifiable(stack.privateAppIds); + _lastPersistedAt = stack.createdAt; + } + if (_pendingWrites > 0 || + (_lastIssuedAt != null && stack.createdAt.isBefore(_lastIssuedAt!))) { + return; + } + _setState(stack.privateAppIds.toSet()); + } + + Future toggle(String appId) { + final updated = {...state}; + final removed = updated.remove(appId); + if (!removed) updated.add(appId); + _setState(updated); + + final createdAt = _nextCreatedAt(); + final snapshot = Set.unmodifiable(updated); + final completer = Completer(); + _pendingWrites++; + _writeQueue = _writeQueue.then((_) async { + try { + await _write(snapshot, createdAt); + completer.complete(!removed); + } catch (error, stackTrace) { + if (error is DevicePrivateSaveException && _pendingWrites == 1) { + _lastIssuedAt = _lastPersistedAt; + _setState(_lastPersisted); + } + completer.completeError(error, stackTrace); + } finally { + _pendingWrites--; + } + }); + return completer.future; + } + + DateTime _nextCreatedAt() { + final now = _clock(); + final candidate = DateTime.fromMillisecondsSinceEpoch( + (now.millisecondsSinceEpoch ~/ 1000) * 1000, + isUtc: now.isUtc, + ); + final baseline = switch ((_lastIssuedAt, _lastPersistedAt)) { + (final issued?, final persisted?) => + issued.isAfter(persisted) ? issued : persisted, + (final issued?, null) => issued, + (null, final persisted?) => persisted, + (null, null) => null, + }; + final next = baseline != null && !candidate.isAfter(baseline) + ? baseline.add(const Duration(seconds: 1)) + : candidate; + _lastIssuedAt = next; + return next; + } + + void _setState(Set value) { + if (!_equality.equals(state, value)) { + state = Set.unmodifiable(value); + } + } +} + +/// Reactive set of bookmarked app addressable IDs. +final bookmarksProvider = StateNotifierProvider>( + (ref) { + final notifier = BookmarksNotifier( + (ids, createdAt) => _writeBookmarks(ref, ids, createdAt), + ); + ref.listen( + _persistedBookmarksProvider, + (_, stack) => notifier.acceptPersisted(stack), + fireImmediately: true, + ); + return notifier; + }, +); + +Future _writeBookmarks( + Ref ref, + Set appIds, + DateTime createdAt, +) async { + final platform = ref.read(packageManagerProvider.notifier).platform; + final partial = PartialAppStack.withEncryptedApps( + name: 'Saved Apps', + identifier: kAppBookmarksIdentifier, + apps: appIds.toList(growable: false), + platform: platform, + ); + partial.event.createdAt = createdAt; + await ref.read(devicePrivateEventServiceProvider).signAndSave(partial); +} + +Future toggleBookmark(WidgetRef ref, App app) { + final appId = '${app.event.kind}:${app.pubkey}:${app.identifier}'; + return ref.read(bookmarksProvider.notifier).toggle(appId); +} + /// Extension to check if an app is saved extension SavedAppsChecker on WidgetRef { bool isAppSaved(App app) { diff --git a/lib/services/device_backup_service.dart b/lib/services/device_backup_service.dart index 1994307..d7d2d0a 100644 --- a/lib/services/device_backup_service.dart +++ b/lib/services/device_backup_service.dart @@ -1,3 +1,5 @@ +import 'dart:async'; +import 'dart:collection'; import 'dart:convert'; import 'dart:io'; @@ -8,206 +10,289 @@ import 'package:models/models.dart'; import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/router.dart'; import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; +import 'package:zapstore/services/device_private_sync_service.dart'; import 'package:zapstore/services/log_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/trusted_signers_service.dart'; import 'package:zapstore/widgets/device_backup_dialog.dart'; -const kSettingsIdentifier = 'zapstore-settings'; -const _kDeviceBackupsKey = 'deviceBackups'; +const _kSettingsFormatVersion = 2; +const _kRecoveriesKey = 'recoveries'; +const _kRecoveryAuthorization = 'zapstore-device-key-authorization-v1'; const _kLegacyInstalledAppsBackupIdentifier = 'zapstore-installed-backup'; const _kLegacyUnmanagedAppsIdentifier = 'zapstore-ignored-apps'; -/// Manages device key backup/restore via the Amber-signed settings event. -/// -/// The settings event is a single replaceable CustomData (kind 30078) event -/// with d-tag "zapstore-settings". Its content is always NIP-44 encrypted to -/// the Amber key and contains a JSON object. Device key backups live under -/// "deviceBackups": [{"pk": "", "name": "Pixel 7", "ts": 1715...}, ...]. +/// Manages device-signed settings recovery and legacy Amber stack migration. class DeviceBackupService { - /// Fetch the existing encrypted settings event for the given Amber pubkey. - Future fetchExistingSettings(Ref ref, String amberPubkey) async { - final storage = ref.read(storageNotifierProvider.notifier); - final results = await storage.query( + final Set>> _activeRequests = {}; + bool _cancelled = false; + + void beginWork() => _cancelled = false; + + void _checkCancelled() { + if (_cancelled) throw const DeviceBackupCancelled(); + } + + Future fetchExistingSettings( + Ref ref, + String devicePubkey, + ) async { + final results = await _queryTracked( + ref, RequestFilter( - authors: {amberPubkey}, + authors: {devicePubkey}, tags: { '#d': {kSettingsIdentifier}, }, + limit: 1, ).toRequest(), source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'app-settings-check', + subscriptionPrefix: 'app-device-settings-upsert', ); return results.firstOrNull; } - /// Decrypt the settings content. Invalid or legacy list content degrades - /// gracefully to an empty object. - Future> decryptSettings( - Signer signer, - CustomData settings, - ) async { - try { - final decrypted = await signer.nip44Decrypt( - settings.content, - signer.pubkey, - ); - final decoded = jsonDecode(decrypted); - if (decoded is Map) return decoded; - if (decoded is List) { - return {_kDeviceBackupsKey: decoded}; + /// Finds device-authored settings events recoverable by [amberSigner]. + Future> fetchRecoveryCandidates({ + required Ref ref, + required Signer amberSigner, + }) async { + _checkCancelled(); + final request = RequestFilter( + tags: { + '#d': {kSettingsIdentifier}, + '#p': {amberSigner.pubkey}, + }, + limit: 50, + ).toRequest(); + final settingsEvents = await _queryTracked( + ref, + request, + source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'app-device-recovery', + ); + + final candidates = {}; + for (final settings in settingsEvents) { + _checkCancelled(); + if (!verifySignedEvent(ref, settings.event) || + !Nip13.isValid( + settings.event, + minimumDifficulty: kPrivateEventPowDifficulty, + )) { + continue; + } + final envelope = _decodeSettingsEnvelope(settings.content); + if (envelope == null) continue; + final recoveries = envelope[_kRecoveriesKey]; + if (recoveries is! List) continue; + + for (final raw in recoveries.whereType()) { + final recovery = Map.from(raw); + if (recovery['p'] != amberSigner.pubkey) continue; + final ciphertext = recovery['content']; + if (ciphertext is! String || ciphertext.isEmpty) continue; + try { + final plaintext = await amberSigner.nip44Decrypt( + ciphertext, + settings.pubkey, + ); + _checkCancelled(); + final capsule = Map.from( + jsonDecode(plaintext) as Map, + ); + final privateKey = capsule['pk']; + final authorization = capsule['authorization']; + if (privateKey is! String || + privateKey.length != 64 || + Utils.derivePublicKey(privateKey) != settings.pubkey || + authorization is! Map || + !validateRecoveryAuthorization( + ref, + Map.from(authorization), + amberPubkey: amberSigner.pubkey, + devicePubkey: settings.pubkey, + )) { + continue; + } + candidates[privateKey] = DeviceBackupInfo( + privateKeyHex: privateKey, + deviceName: capsule['name'] as String? ?? 'Android device', + backedUpAt: capsule['ts'] is int + ? DateTime.fromMillisecondsSinceEpoch(capsule['ts'] as int) + : null, + ); + } catch (_) { + // Invalid or unrelated recovery capsules degrade gracefully. + } } - } catch (_) { - return {}; } - return {}; + return candidates.values.toList(growable: false); } - /// Decode device backup entries from an encrypted settings event. - Future>> decryptEntries( - Signer signer, - CustomData settings, - ) async { - final decodedSettings = await decryptSettings(signer, settings); - final entries = decodedSettings[_kDeviceBackupsKey]; - if (entries is! List) return []; - return entries.whereType>().toList(); - } - - /// Back up the current device key. Reads existing entries, upserts the - /// current device, then publishes the updated array. + /// Upserts a recovery capsule for the active Amber identity. Future backupDeviceKey({ required Ref ref, required Signer amberSigner, }) async { - final deviceKeyService = ref.read(deviceKeyServiceProvider); - final privateKeyHex = await deviceKeyService.getOrCreatePrivateKey(); + _checkCancelled(); + final privateEvents = ref.read(devicePrivateEventServiceProvider); + final keyService = ref.read(deviceKeyServiceProvider); + final privateKeyHex = await keyService.getOrCreatePrivateKey(); + final devicePubkey = privateEvents.devicePubkey; + final existing = await fetchExistingSettings(ref, devicePubkey); + final envelope = existing != null + ? _decodeSettingsEnvelope(existing.content) ?? _emptySettingsEnvelope() + : _emptySettingsEnvelope(); + final deviceName = await _getDeviceName(); - - // Load and preserve existing encrypted settings fields. - final existing = await fetchExistingSettings(ref, amberSigner.pubkey); - final settings = existing != null - ? await decryptSettings(amberSigner, existing) - : {}; - final entries = (settings[_kDeviceBackupsKey] as List?) - ?.whereType>() - .toList() ?? - >[]; - - // Upsert this device's entry (match by pk) - entries.removeWhere((e) => e['pk'] == privateKeyHex); - entries.add({ - 'pk': privateKeyHex, - 'name': deviceName, - 'ts': DateTime.now().millisecondsSinceEpoch, - }); - settings[_kDeviceBackupsKey] = entries; - - final encrypted = await amberSigner.nip44Encrypt( - jsonEncode(settings), + final timestamp = DateTime.now().millisecondsSinceEpoch; + final authorizationPartial = PartialNote(_kRecoveryAuthorization); + authorizationPartial.event.addTagValue('device', devicePubkey); + authorizationPartial.event.addTagValue('p', amberSigner.pubkey); + final authorization = await authorizationPartial.signWith(amberSigner); + _checkCancelled(); + final capsule = await privateEvents.encryptFor( + jsonEncode({ + 'pk': privateKeyHex, + 'name': deviceName, + 'ts': timestamp, + 'authorization': authorization.event.toMap(), + }), amberSigner.pubkey, ); + _checkCancelled(); + + final recoveries = + (envelope[_kRecoveriesKey] as List?) + ?.whereType() + .map((entry) => Map.from(entry)) + .toList() ?? + >[]; + recoveries.removeWhere((entry) => entry['p'] == amberSigner.pubkey); + recoveries.add({ + 'p': amberSigner.pubkey, + 'content': capsule, + 'ts': timestamp, + }); + envelope[_kRecoveriesKey] = recoveries; final partial = PartialCustomData( identifier: kSettingsIdentifier, - content: encrypted, + content: jsonEncode(envelope), ); - - final signed = await partial.signWith(amberSigner); - final storage = ref.read(storageNotifierProvider.notifier); - await storage.save({signed}); - storage.publish({signed}, relays: 'AppCatalog'); + for (final recovery in recoveries) { + final pubkey = recovery['p']; + if (pubkey is String) partial.event.addTagValue('p', pubkey); + } + partial.event.setTagValue('format', 'zapstore-device-settings-v2'); + partial.event.createdAt = privateEvents.nextReplaceableTimestamp( + existing?.createdAt, + ); + await privateEvents.signAndSave(partial); } - /// Move Amber-authored encrypted AppStacks to equivalent device-key stacks. - /// - /// Existing device-key stacks are merged, not overwritten. Legacy identifiers - /// from the pre-device-key branch are normalized to the current d-tags. + /// Merges every Amber-authored encrypted AppStack into device ownership. Future migratePrivateStacksToDeviceKey({ required Ref ref, required Signer amberSigner, }) async { - final keyService = ref.read(deviceKeyServiceProvider); - final devicePubkey = ref.read(devicePubkeyProvider); - if (devicePubkey == null) return false; - - if (await keyService.hasPrivateStacksMigrated( - amberSigner.pubkey, - devicePubkey, - )) { - return true; - } - - final deviceSigner = ref.read(Signer.signerProvider(devicePubkey)); - if (deviceSigner == null) return false; - - final storage = ref.read(storageNotifierProvider.notifier); - final platform = ref.read(packageManagerProvider.notifier).platform; - - final amberStacks = await storage.query( - RequestFilter(authors: {amberSigner.pubkey}).toRequest(), + _checkCancelled(); + final request = RequestFilter( + authors: {amberSigner.pubkey}, + ).toRequest(); + final amberStacks = await _queryTracked( + ref, + request, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), subscriptionPrefix: 'app-private-stack-migration', ); - final encryptedAmberStacks = amberStacks - .where((stack) => stack.content.isNotEmpty) - .toList(); - if (encryptedAmberStacks.isEmpty) { - return false; - } - + final privateEvents = ref.read(devicePrivateEventServiceProvider); + final storage = ref.read(storageNotifierProvider.notifier); + final platform = ref.read(packageManagerProvider.notifier).platform; var migratedAny = false; - for (final stack in encryptedAmberStacks) { - final identifier = _deviceIdentifierFor(stack.identifier); - final oldAppIds = stack.privateAppIds; - if (oldAppIds.isEmpty) continue; - final existingDeviceStacks = await storage.query( + for (final stack in amberStacks.where( + (stack) => stack.content.isNotEmpty, + )) { + _checkCancelled(); + if (!verifySignedEvent(ref, stack.event)) { + LogService.I.warn( + 'ignored unverified Amber private stack', + tag: 'backup', + fields: {'identifier': stack.identifier}, + ); + continue; + } + final oldAppIds = await decryptAmberStackAppIds(amberSigner, stack); + _checkCancelled(); + if (oldAppIds == null) { + LogService.I.warn( + 'could not decrypt Amber private stack; migration remains retryable', + tag: 'backup', + fields: {'identifier': stack.identifier}, + ); + continue; + } + + final identifier = _deviceIdentifierFor(stack.identifier); + final existing = (await storage.query( RequestFilter( - authors: {devicePubkey}, + authors: {privateEvents.devicePubkey}, tags: { '#d': {identifier}, }, + limit: 1, ).toRequest(), source: const LocalSource(), subscriptionPrefix: 'app-private-stack-migration-device', - ); - final existingAppIds = - existingDeviceStacks.firstOrNull?.privateAppIds ?? const []; - final mergedAppIds = [ - ...existingAppIds, - ...oldAppIds.where((id) => !existingAppIds.contains(id)), - ]; + )).firstOrNull; + if (existing != null) { + await existing.prepareAfterLoading(ref); + _checkCancelled(); + if (!existing.isDecrypted) { + LogService.I.warn( + 'could not decrypt device stack; migration remains retryable', + tag: 'backup', + fields: {'identifier': identifier}, + ); + continue; + } + } - if (mergedAppIds.length == existingAppIds.length) continue; + final merged = LinkedHashSet.of( + existing?.privateAppIds ?? const [], + )..addAll(oldAppIds); + if (existing != null && + merged.length == existing.privateAppIds.toSet().length) { + continue; + } final partial = PartialAppStack.withEncryptedApps( name: stack.name ?? identifier, identifier: identifier, - apps: mergedAppIds, - platform: platform, + description: stack.description, + apps: merged.toList(growable: false), + platform: stack.platform ?? platform, ); - - final signed = await partial.signWith(deviceSigner); - await storage.save({signed}); - storage.publish({signed}, relays: 'AppCatalog'); + partial.event.createdAt = privateEvents.nextReplaceableTimestamp( + existing?.createdAt, + ); + await privateEvents.signAndSave(partial); migratedAny = true; } - await keyService.markPrivateStacksMigrated( - amberSigner.pubkey, - devicePubkey, - ); if (migratedAny) { LogService.I.info( 'migrated Amber private stacks to device key', tag: 'backup', ); } - return true; + return migratedAny; } - /// Replace the stored device key and register the restored signer immediately. Future restoreDeviceKey({ required Ref ref, required String privateKeyHex, @@ -218,21 +303,115 @@ class DeviceBackupService { ref.read(devicePubkeyProvider.notifier).state = restoredSigner.pubkey; } + Future> _queryTracked>( + Ref ref, + Request request, { + required Source source, + required String subscriptionPrefix, + }) async { + _checkCancelled(); + _activeRequests.add(request); + try { + final results = await ref + .read(storageNotifierProvider.notifier) + .query( + request, + source: source, + subscriptionPrefix: subscriptionPrefix, + ); + _checkCancelled(); + return results; + } finally { + _activeRequests.remove(request); + } + } + + void cancelCurrentWork(Ref ref) { + _cancelled = true; + final storage = ref.read(storageNotifierProvider.notifier); + for (final request in _activeRequests.toList(growable: false)) { + unawaited(storage.cancel(request)); + } + _activeRequests.clear(); + ref.read(devicePrivateEventServiceProvider).cancelMining(); + } + Future _getDeviceName() async { try { if (Platform.isAndroid) { - final info = await DeviceInfoPlugin().androidInfo; - return info.model; + return (await DeviceInfoPlugin().androidInfo).model; } } catch (_) {} return 'Android device'; } } +/// Explicitly decrypts an imperatively queried Amber stack. +/// +/// Imperative storage queries do not run EncryptableModel preparation, so +/// migration must not rely on [AppStack.privateAppIds] being populated. +Future?> decryptAmberStackAppIds( + Signer amberSigner, + AppStack stack, +) async { + try { + final plaintext = await amberSigner.nip44Decrypt( + stack.content, + amberSigner.pubkey, + ); + final decoded = jsonDecode(plaintext); + return decoded is List ? decoded.whereType().toList() : null; + } catch (_) { + return null; + } +} + final deviceBackupServiceProvider = Provider( (ref) => DeviceBackupService(), ); +Map _emptySettingsEnvelope() => { + 'version': _kSettingsFormatVersion, + _kRecoveriesKey: >[], +}; + +Map? _decodeSettingsEnvelope(String content) { + try { + final decoded = jsonDecode(content); + if (decoded is! Map) return null; + final envelope = Map.from(decoded); + if (envelope['version'] != _kSettingsFormatVersion) return null; + return envelope; + } catch (_) { + return null; + } +} + +/// Verifies that Amber explicitly authorized the recovered device pubkey. +bool validateRecoveryAuthorization( + Ref ref, + Map authorization, { + required String amberPubkey, + required String devicePubkey, +}) { + try { + if (authorization['kind'] != 1 || + authorization['pubkey'] != amberPubkey || + authorization['content'] != _kRecoveryAuthorization) { + return false; + } + final event = PartialEvent>(authorization, 1); + if (!event.getTagSetValues('device').contains(devicePubkey) || + !event.getTagSetValues('p').contains(amberPubkey) || + authorization['id'] != Utils.getEventId(event, amberPubkey)) { + return false; + } + return ref.read(verifierProvider).verify(authorization); + } catch (_) { + return false; + } +} + String _deviceIdentifierFor(String identifier) { return switch (identifier) { _kLegacyInstalledAppsBackupIdentifier => kInstalledAppsIdentifier, @@ -241,7 +420,6 @@ String _deviceIdentifierFor(String identifier) { }; } -/// Decoded device entry for display in the restore dialog. class DeviceBackupInfo { DeviceBackupInfo({ required this.privateKeyHex, @@ -254,80 +432,74 @@ class DeviceBackupInfo { final DateTime? backedUpAt; } -/// Called after successful Amber sign-in. Automatically backs up the device -/// key, then shows a restore dialog only if other device entries exist. +final class DeviceBackupCancelled implements Exception { + const DeviceBackupCancelled(); +} + +/// Runs recovery, migration, and backup after every successful Amber sign-in. Future maybeOfferDeviceBackup(Ref ref) async { final amberPubkey = ref.read(Signer.activePubkeyProvider); - if (amberPubkey == null) return; + final amberSigner = ref.read(Signer.activeSignerProvider); + if (amberPubkey == null || amberSigner == null) return; final keyService = ref.read(deviceKeyServiceProvider); - final service = ref.read(deviceBackupServiceProvider); - final signer = ref.read(Signer.activeSignerProvider); - if (signer == null) return; try { - final hasBackupBeenOffered = await keyService.hasBackupBeenOffered( - amberPubkey, - ); - final backup = hasBackupBeenOffered - ? null - : await service.fetchExistingSettings(ref, amberPubkey); - - final entries = backup != null - ? await service.decryptEntries(signer, backup) - : const >[]; - final currentPrivateKey = await keyService.getOrCreatePrivateKey(); - - final otherDevices = entries - .where((e) => e['pk'] != currentPrivateKey) - .map( - (e) => DeviceBackupInfo( - privateKeyHex: e['pk'] as String, - deviceName: e['name'] as String? ?? 'Unknown device', - backedUpAt: e['ts'] != null - ? DateTime.fromMillisecondsSinceEpoch(e['ts'] as int) - : null, - ), - ) - .toList(); - - if (otherDevices.isNotEmpty) { - final context = rootNavigatorKey.currentState?.overlay?.context; - if (context == null || !context.mounted) { - return; - } - - final selectedBackup = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => DeviceBackupRestoreDialog(backups: otherDevices), + final alreadyOffered = await keyService.hasBackupBeenOffered(amberPubkey); + List candidates = const []; + try { + candidates = await service.fetchRecoveryCandidates( + ref: ref, + amberSigner: amberSigner, ); + } catch (error, stack) { + LogService.I.warn( + 'device recovery query failed', + tag: 'backup', + err: error, + stack: stack, + ); + } - if (selectedBackup != null) { - await service.restoreDeviceKey( - ref: ref, - privateKeyHex: selectedBackup.privateKeyHex, + final currentPrivateKey = await keyService.getOrCreatePrivateKey(); + final otherDevices = candidates + .where((entry) => entry.privateKeyHex != currentPrivateKey) + .toList(growable: false); + + if (!alreadyOffered && otherDevices.isNotEmpty) { + final context = rootNavigatorKey.currentState?.overlay?.context; + if (context != null && context.mounted) { + final selected = await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => DeviceBackupRestoreDialog(backups: otherDevices), ); + if (selected != null) { + await service.restoreDeviceKey( + ref: ref, + privateKeyHex: selected.privateKeyHex, + ); + await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey(); + } } } await service.migratePrivateStacksToDeviceKey( ref: ref, - amberSigner: signer, + amberSigner: amberSigner, ); - - if (hasBackupBeenOffered) return; - - // Always back up whichever device key is active after optional restore. - await service.backupDeviceKey(ref: ref, amberSigner: signer); - await keyService.markBackupOffered(amberPubkey); - } catch (e, st) { + await ref.read(trustServiceProvider).migrateLegacyAmberRecord(amberSigner); + await service.backupDeviceKey(ref: ref, amberSigner: amberSigner); + if (!alreadyOffered) { + await keyService.markBackupOffered(amberPubkey); + } + } catch (error, stack) { LogService.I.warn( - 'device backup/restore check failed', + 'device recovery/migration/backup failed', tag: 'backup', - err: e, - stack: st, + err: error, + stack: stack, ); } } diff --git a/lib/services/device_private_event_service.dart b/lib/services/device_private_event_service.dart new file mode 100644 index 0000000..23ebdb7 --- /dev/null +++ b/lib/services/device_private_event_service.dart @@ -0,0 +1,206 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/services/device_key_service.dart'; + +const kPrivateEventPowDifficulty = 16; + +final privateEventPowExecutorProvider = Provider(( + ref, +) { + final executor = IsolateProofOfWorkExecutor(); + ref.onDispose(executor.dispose); + return executor; +}); + +final devicePrivateEventServiceProvider = Provider(( + ref, +) { + return DevicePrivateEventService( + ref, + executor: ref.watch(privateEventPowExecutorProvider), + ); +}); + +/// Enforces the signing and publication contract for device-private events. +class DevicePrivateEventService { + DevicePrivateEventService( + this.ref, { + required ProofOfWorkExecutor executor, + this.difficulty = kPrivateEventPowDifficulty, + this.timeout = const Duration(seconds: 10), + this.maxAttempts = 1 << 21, + this.batchSize = 512, + }) : _executor = executor; + + final Ref ref; + final ProofOfWorkExecutor _executor; + final int difficulty; + final Duration timeout; + final int maxAttempts; + final int batchSize; + + String get devicePubkey { + final pubkey = ref.read(devicePubkeyProvider); + if (pubkey == null) { + throw const DevicePrivateEventException('Device key is unavailable.'); + } + return pubkey; + } + + Signer get deviceSigner { + final signer = ref.read(Signer.signerProvider(devicePubkey)); + if (signer == null) { + throw const DevicePrivateEventException('Device signer is unavailable.'); + } + return signer; + } + + ProofOfWorkOptions get proofOfWork => ProofOfWorkOptions( + difficulty: difficulty, + timeout: timeout, + maxAttempts: maxAttempts, + batchSize: batchSize, + executor: _executor, + ); + + Future encryptToDevice(String plaintext) => + deviceSigner.nip44Encrypt(plaintext, devicePubkey); + + Future decryptFromDevice(String ciphertext) => + deviceSigner.nip44Decrypt(ciphertext, devicePubkey); + + Future encryptFor(String plaintext, String recipientPubkey) => + deviceSigner.nip44Encrypt(plaintext, recipientPubkey); + + Future signAndSave>( + PartialModel partial, { + bool publish = true, + }) async { + _validatePartial(partial); + final signer = deviceSigner; + final signed = await partial.signWith(signer, proofOfWork: proofOfWork); + _validateSigned(signed); + + final storage = ref.read(storageNotifierProvider.notifier); + final saved = await storage.save({signed}); + if (!saved) { + throw const DevicePrivateSaveException( + 'Could not save private data locally.', + ); + } + + if (publish) { + final response = await storage.publish({signed}, relays: 'AppCatalog'); + final accepted = + response.results[signed.event.id]?.any((result) => result.accepted) ?? + false; + if (!accepted) { + throw const DevicePrivatePublishException( + 'Saved locally, but no AppCatalog relay accepted the event.', + ); + } + } + return signed; + } + + DateTime nextReplaceableTimestamp( + DateTime? existing, { + DateTime Function()? clock, + }) { + final now = (clock ?? DateTime.now)(); + final candidate = DateTime.fromMillisecondsSinceEpoch( + (now.millisecondsSinceEpoch ~/ 1000) * 1000, + isUtc: now.isUtc, + ); + if (existing == null || candidate.isAfter(existing)) return candidate; + return existing.add(const Duration(seconds: 1)); + } + + void cancelMining() { + final executor = _executor; + if (executor is IsolateProofOfWorkExecutor) { + executor.cancelAll(); + } + } + + void _validatePartial(PartialModel partial) { + final event = partial.event; + switch (event.kind) { + case 30267: + if (event.content.isEmpty) { + throw const DevicePrivateEventException( + 'Public app stacks must not use the private event signer.', + ); + } + if (event.containsTag('h')) { + throw const DevicePrivateEventException( + 'Encrypted app stacks must not carry a community h tag.', + ); + } + case 30078: + final identifier = event.identifier; + if (identifier != kSettingsIdentifier && + identifier != kTrustedSignersIdentifier) { + throw DevicePrivateEventException( + 'Unsupported private CustomData identifier: $identifier', + ); + } + default: + throw DevicePrivateEventException( + 'Unsupported private event kind: ${event.kind}', + ); + } + } + + void _validateSigned(Model signed) { + if (signed.pubkey != devicePubkey) { + throw const DevicePrivateEventException( + 'Private event was not signed by the device key.', + ); + } + if (!verifySignedEvent(ref, signed.event)) { + throw const DevicePrivateEventException( + 'Private event signature or event ID is invalid.', + ); + } + if (!Nip13.isValid(signed.event, minimumDifficulty: difficulty)) { + throw DevicePrivateEventException( + 'Private event does not meet $difficulty-bit proof of work.', + ); + } + } +} + +/// Verifies both the canonical Nostr ID and its BIP-340 signature. +bool verifySignedEvent(Ref ref, EventBase event) { + try { + final map = event.toMap(); + final pubkey = map['pubkey']; + if (pubkey is! String) return false; + final partial = PartialEvent>(map, event.kind) + ..tags = [for (final tag in event.tags) List.of(tag)]; + return map['id'] == Utils.getEventId(partial, pubkey) && + ref.read(verifierProvider).verify(map); + } catch (_) { + return false; + } +} + +class DevicePrivateEventException implements Exception { + const DevicePrivateEventException(this.message); + + final String message; + + @override + String toString() => message; +} + +final class DevicePrivateSaveException extends DevicePrivateEventException { + const DevicePrivateSaveException(super.message); +} + +final class DevicePrivatePublishException extends DevicePrivateEventException { + const DevicePrivatePublishException(super.message); +} diff --git a/lib/services/device_private_sync_service.dart b/lib/services/device_private_sync_service.dart new file mode 100644 index 0000000..956538d --- /dev/null +++ b/lib/services/device_private_sync_service.dart @@ -0,0 +1,201 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; +import 'package:zapstore/services/log_service.dart'; + +enum DevicePrivateSyncPhase { idle, syncing, success, error, cancelled } + +class DevicePrivateSyncState { + const DevicePrivateSyncState(this.phase, {this.error}); + + const DevicePrivateSyncState.idle() : this(DevicePrivateSyncPhase.idle); + + final DevicePrivateSyncPhase phase; + final Object? error; +} + +typedef DevicePrivateQuery = + Future>> Function( + Request> request, + Source source, + String subscriptionPrefix, + ); + +class DevicePrivateSyncNotifier extends StateNotifier { + DevicePrivateSyncNotifier(this.ref, {DevicePrivateQuery? query}) + : _query = query, + super(const DevicePrivateSyncState.idle()); + + final Ref ref; + final DevicePrivateQuery? _query; + Request>? _activeRequest; + Future? _runFuture; + bool _started = false; + bool _cancelled = false; + + Future start() async { + if (_started) return; + _started = true; + _runFuture = _runSync(); + await _runFuture; + } + + /// Explicit recovery exception for a device key restored after app boot. + Future syncRestoredKey() async { + cancel(); + await _runFuture; + _runFuture = _runSync(); + await _runFuture; + } + + Future _runSync() async { + _cancelled = false; + state = const DevicePrivateSyncState(DevicePrivateSyncPhase.syncing); + + final devicePubkey = ref.read(devicePubkeyProvider); + if (devicePubkey == null) { + state = const DevicePrivateSyncState( + DevicePrivateSyncPhase.error, + error: 'Device key is unavailable.', + ); + return; + } + + final request = RequestFilter>( + kinds: const {30267, 30078}, + authors: {devicePubkey}, + ).toRequest(); + _activeRequest = request; + + try { + await _queryStorage( + request, + source: const RemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'app-device-private-boot', + ); + if (_cancelled) return; + await _upgradeLegacyProofs(request); + if (_cancelled) return; + state = const DevicePrivateSyncState(DevicePrivateSyncPhase.success); + } catch (error, stack) { + if (_cancelled) return; + LogService.I.warn( + 'device private boot sync failed', + tag: 'private-sync', + err: error, + stack: stack, + ); + state = DevicePrivateSyncState( + DevicePrivateSyncPhase.error, + error: error, + ); + } finally { + _activeRequest = null; + } + } + + Future _upgradeLegacyProofs(Request> request) async { + final privateEvents = ref.read(devicePrivateEventServiceProvider); + final models = await _queryStorage( + request, + source: const LocalSource(), + subscriptionPrefix: 'app-device-private-upgrade', + ); + + for (final model in models) { + if (_cancelled || + Nip13.isValid( + model.event, + minimumDifficulty: kPrivateEventPowDifficulty, + )) { + continue; + } + + switch (model) { + case AppStack stack when stack.content.isNotEmpty: + await stack.prepareAfterLoading(ref); + if (!stack.isDecrypted) { + LogService.I.warn( + 'legacy private stack could not be decrypted for PoW upgrade', + tag: 'private-sync', + fields: {'identifier': stack.identifier}, + ); + continue; + } + final partial = PartialAppStack.withEncryptedApps( + name: stack.name ?? stack.identifier, + identifier: stack.identifier, + description: stack.description, + apps: stack.privateAppIds, + platform: stack.platform, + ); + partial.event.createdAt = privateEvents.nextReplaceableTimestamp( + stack.createdAt, + ); + await privateEvents.signAndSave(partial); + case CustomData data + when data.identifier == kSettingsIdentifier || + data.identifier == kTrustedSignersIdentifier: + final partial = PartialCustomData( + identifier: data.identifier, + content: data.content, + ); + for (final tag in data.event.tags) { + if (tag.first == 'd' || tag.first == 'nonce') continue; + partial.event.tags.add(List.of(tag)); + } + partial.event.createdAt = privateEvents.nextReplaceableTimestamp( + data.createdAt, + ); + await privateEvents.signAndSave( + partial, + publish: data.identifier != kTrustedSignersIdentifier, + ); + default: + break; + } + } + } + + Future>> _queryStorage( + Request> request, { + required Source source, + required String subscriptionPrefix, + }) { + final query = _query; + if (query != null) { + return query(request, source, subscriptionPrefix); + } + return ref + .read(storageNotifierProvider.notifier) + .query(request, source: source, subscriptionPrefix: subscriptionPrefix); + } + + void cancel() { + _cancelled = true; + final request = _activeRequest; + _activeRequest = null; + if (request != null) { + unawaited(ref.read(storageNotifierProvider.notifier).cancel(request)); + } + ref.read(devicePrivateEventServiceProvider).cancelMining(); + if (state.phase == DevicePrivateSyncPhase.syncing) { + state = const DevicePrivateSyncState(DevicePrivateSyncPhase.cancelled); + } + } + + @override + void dispose() { + cancel(); + super.dispose(); + } +} + +final devicePrivateSyncProvider = + StateNotifierProvider( + (ref) => DevicePrivateSyncNotifier(ref), + ); diff --git a/lib/services/trusted_signers_service.dart b/lib/services/trusted_signers_service.dart index 20d8ec0..0f62156 100644 --- a/lib/services/trusted_signers_service.dart +++ b/lib/services/trusted_signers_service.dart @@ -2,9 +2,9 @@ import 'dart:convert'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; - -/// Identifier used to store trusted signer preferences -const String kTrustedSignersIdentifier = 'trusted-signers'; +import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; /// Simple helper service to manage trusted signers persisted via CustomData class TrustedSignersService { @@ -12,45 +12,39 @@ class TrustedSignersService { final Ref ref; - /// Returns whether a signer pubkey is trusted by the active user + /// Returns whether a signer pubkey is trusted by this device. Future isSignerTrusted(String signerPubkey) async { final trusted = await _loadTrustedSigners(); return trusted.contains(signerPubkey); } - /// Adds a signer pubkey to the trusted list for the active user. - /// If no signer is available (not signed in), this method is a no-op. + /// Adds a signer pubkey to the device-private trusted list. Future addTrustedSigner(String signerPubkey) async { - final signer = ref.read(Signer.activeSignerProvider); - final activePubkey = ref.read(Signer.activePubkeyProvider); - if (signer == null || activePubkey == null) return; - final trusted = await _loadTrustedSigners(); if (trusted.contains(signerPubkey)) return; trusted.add(signerPubkey); + await _saveTrustedSigners(trusted); + } + Future _saveTrustedSigners(Set trusted) async { + final privateEvents = ref.read(devicePrivateEventServiceProvider); final content = jsonEncode({'trusted': trusted.toList()}); - + final encrypted = await privateEvents.encryptToDevice(content); final partial = PartialCustomData( identifier: kTrustedSignersIdentifier, - content: content, + content: encrypted, ); - - // Sign with the active signer so the data is addressable under the user's pubkey - final model = await partial.signWith(signer); - - // Save locally only (do not publish) - await model.save(); + await privateEvents.signAndSave(partial, publish: false); } Future> _loadTrustedSigners() async { - final activePubkey = ref.read(Signer.activePubkeyProvider); - if (activePubkey == null) return {}; + final devicePubkey = ref.read(devicePubkeyProvider); + if (devicePubkey == null) return {}; try { final request = Request([ RequestFilter( - authors: {activePubkey}, + authors: {devicePubkey}, tags: { '#d': {kTrustedSignersIdentifier}, }, @@ -66,7 +60,10 @@ class TrustedSignersService { if (models.isEmpty) return {}; final model = models.first; - final map = jsonDecode(model.content) as Map; + final decrypted = await ref + .read(devicePrivateEventServiceProvider) + .decryptFromDevice(model.content); + final map = jsonDecode(decrypted) as Map; final list = (map['trusted'] as List?)?.cast() ?? const []; return list.toSet(); @@ -74,6 +71,33 @@ class TrustedSignersService { return {}; } } + + /// Imports the legacy local Amber-authored preference without publishing it. + Future migrateLegacyAmberRecord(Signer amberSigner) async { + final storage = ref.read(storageNotifierProvider.notifier); + final legacy = await storage.query( + RequestFilter( + authors: {amberSigner.pubkey}, + tags: { + '#d': {kTrustedSignersIdentifier}, + }, + limit: 1, + ).toRequest(), + source: const LocalSource(), + ); + if (legacy.isEmpty) return; + + try { + final map = jsonDecode(legacy.first.content) as Map; + final oldTrusted = + (map['trusted'] as List?)?.whereType().toSet() ?? const {}; + if (oldTrusted.isEmpty) return; + final merged = {...await _loadTrustedSigners(), ...oldTrusted}; + await _saveTrustedSigners(merged); + } catch (_) { + // Malformed legacy local preferences are ignored. + } + } } final trustServiceProvider = Provider( diff --git a/lib/services/unmanaged_apps_service.dart b/lib/services/unmanaged_apps_service.dart index f8cb1e4..2253eb0 100644 --- a/lib/services/unmanaged_apps_service.dart +++ b/lib/services/unmanaged_apps_service.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/log_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; @@ -32,14 +33,15 @@ final _persistedUnmanagedAppsProvider = Provider<_UnmanagedAppsSnapshot?>(( tags: { '#d': {kUnmanagedAppsIdentifier}, }, - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), + source: const LocalSource(), subscriptionPrefix: 'app-unmanaged-apps', ), ); final stack = switch (stackState) { - StorageLoading() || StorageError() => null, + StorageLoading(:final models) || StorageData(:final models) => models.firstOrNull, + StorageError() => null, }; if (stack == null) { @@ -196,8 +198,9 @@ class UnmanagedAppsPublishException implements Exception { String toString() => message; } -bool wasUnmanagedStackAccepted(PublishResponse response, String eventId) { - return response.results[eventId]?.any((result) => result.accepted) ?? false; +bool wasUnmanagedStackAccepted(PublishResponse response, AppStack stack) { + return response.results[stack.event.id]?.any((result) => result.accepted) ?? + false; } PartialAppStack createUnmanagedAppsStack({ @@ -220,17 +223,6 @@ Future _writeUnmanagedApps( Set appIds, { required DateTime createdAt, }) async { - final devicePubkey = ref.read(devicePubkeyProvider); - if (devicePubkey == null) { - throw const UnmanagedAppsSaveException('Device key is unavailable.'); - } - - final signer = ref.read(Signer.signerProvider(devicePubkey)); - if (signer == null) { - throw const UnmanagedAppsSaveException('Device signer is unavailable.'); - } - - final storage = ref.read(storageNotifierProvider.notifier); final platform = ref.read(packageManagerProvider.notifier).platform; final partial = createUnmanagedAppsStack( appIds: appIds, @@ -238,19 +230,12 @@ Future _writeUnmanagedApps( createdAt: createdAt, ); - final signed = await partial.signWith(signer); - final saved = await storage.save({signed}); - if (!saved) { - throw const UnmanagedAppsSaveException( - 'Could not save the unmanaged apps list locally.', - ); - } - - final response = await storage.publish({signed}, relays: 'AppCatalog'); - if (!wasUnmanagedStackAccepted(response, signed.id)) { - throw const UnmanagedAppsPublishException( - 'Saved locally, but no AppCatalog relay accepted the event.', - ); + try { + await ref.read(devicePrivateEventServiceProvider).signAndSave(partial); + } on DevicePrivateSaveException catch (error) { + throw UnmanagedAppsSaveException(error.message); + } on DevicePrivatePublishException catch (error) { + throw UnmanagedAppsPublishException(error.message); } } diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 99a41ab..d5687db 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -8,6 +8,7 @@ import 'package:zapstore/services/catalog_fetcher.dart'; import 'package:zapstore/services/log_service.dart'; import 'package:zapstore/services/deletion_processor.dart'; import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/unmanaged_apps_service.dart'; import 'package:zapstore/services/settings_service.dart'; @@ -289,9 +290,6 @@ class UpdatePollerNotifier extends StateNotifier { final settings = await ref.read(settingsServiceProvider).load(); if (!settings.installedAppsBackupEnabled) return; - final signer = ref.read(Signer.signerProvider(devicePubkey)); - if (signer == null) return; - final pmNotifier = ref.read(packageManagerProvider.notifier); final installed = ref.read(packageManagerProvider).installed; final platform = pmNotifier.platform; @@ -324,9 +322,9 @@ class UpdatePollerNotifier extends StateNotifier { platform: platform, ); - final signed = await partialStack.signWith(signer); - await storage.save({signed}); - await storage.publish({signed}, relays: 'AppCatalog'); + await ref + .read(devicePrivateEventServiceProvider) + .signAndSave(partialStack); _lastBackedUpIds = appIds; } catch (e, st) { LogService.I.warn( @@ -387,9 +385,11 @@ final categorizedUpdatesProvider = Provider((ref) { final unmanagedIds = ref.watch(unmanagedAppsProvider); final catalogedIds = pollerState.catalogedIds; + // Keep the local App query independent of the unmanaged set. Otherwise a + // toggle changes its filter, briefly replaces the cached result with a + // loading query, and makes the updates list visibly jump. final catalogedInstalledIds = installed.keys .where(catalogedIds.contains) - .where((id) => !unmanagedIds.contains(id)) .toSet(); if (catalogedInstalledIds.isEmpty) { @@ -457,7 +457,7 @@ final categorizedUpdatesProvider = Provider((ref) { for (final app in apps) { final pkg = installed[app.identifier]; - if (pkg == null) continue; + if (pkg == null || unmanagedIds.contains(app.identifier)) continue; if (app.hasUpdate) { if (pkg.canInstallSilently) { diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart index 0032516..b093324 100644 --- a/lib/widgets/app_detail_widgets.dart +++ b/lib/widgets/app_detail_widgets.dart @@ -6,7 +6,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:zapstore/services/bookmarks_service.dart'; -import 'package:zapstore/services/device_key_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/theme.dart'; @@ -117,12 +116,8 @@ class SocialActionsRow extends HookConsumerWidget { Expanded( flex: 16, child: FilledButton( - onPressed: () => _handleSaveApp( - context, - ref, - app, - isPrivatelySaved, - ), + onPressed: () => + _handleSaveApp(context, ref, app, isPrivatelySaved), style: FilledButton.styleFrom( padding: EdgeInsets.zero, backgroundColor: isPrivatelySaved @@ -142,9 +137,7 @@ class SocialActionsRow extends HookConsumerWidget { ), ), child: Icon( - isPrivatelySaved - ? Icons.bookmark - : Icons.bookmark_border, + isPrivatelySaved ? Icons.bookmark : Icons.bookmark_border, size: 20, ), ), @@ -178,6 +171,7 @@ class SocialActionsRow extends HookConsumerWidget { ], ); } + Future _handleSaveApp( BuildContext context, WidgetRef ref, @@ -185,53 +179,11 @@ class SocialActionsRow extends HookConsumerWidget { bool isPrivatelySaved, ) async { try { - final devicePubkey = ref.read(devicePubkeyProvider); - if (devicePubkey == null) return; - - final signer = ref.read(Signer.signerProvider(devicePubkey)); - if (signer == null) return; - - final existingStacks = await ref.storage.query( - RequestFilter( - authors: {devicePubkey}, - tags: { - '#d': {kAppBookmarksIdentifier}, - }, - ).toRequest(), - source: const LocalSource(), - ); - final existingStack = existingStacks.firstOrNull; - - final existingAppIds = List.from( - existingStack?.privateAppIds ?? [], - ); - - final appAddressableId = - '${app.event.kind}:${app.pubkey}:${app.identifier}'; - - if (isPrivatelySaved) { - existingAppIds.remove(appAddressableId); - } else { - if (!existingAppIds.contains(appAddressableId)) { - existingAppIds.add(appAddressableId); - } - } - - final platform = ref.read(packageManagerProvider.notifier).platform; - final partialStack = PartialAppStack.withEncryptedApps( - name: 'Saved Apps', - identifier: kAppBookmarksIdentifier, - apps: existingAppIds, - platform: platform, - ); - - final signedStack = await partialStack.signWith(signer); - await ref.storage.save({signedStack}); - ref.storage.publish({signedStack}, relays: 'AppCatalog'); + final added = await toggleBookmark(ref, app); if (context.mounted) { context.showInfo( - isPrivatelySaved ? 'App removed from saved' : 'App saved privately', + added ? 'App saved privately' : 'App removed from saved', ); } } catch (e) { @@ -241,8 +193,6 @@ class SocialActionsRow extends HookConsumerWidget { } } - - Future _showAddToStackDialog(BuildContext context, App app) async { await showBaseDialog( context: context, diff --git a/lib/widgets/auth_widgets.dart b/lib/widgets/auth_widgets.dart index 2d967f4..c21b4bd 100644 --- a/lib/widgets/auth_widgets.dart +++ b/lib/widgets/auth_widgets.dart @@ -6,7 +6,6 @@ import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/main.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; -import 'package:zapstore/utils/debug_utils.dart'; /// Reusable sign-in prompt widget for dialogs and bottom sheets. /// Displays a styled tappable message prompting the user to sign in. @@ -40,7 +39,6 @@ class SignInPrompt extends HookConsumerWidget { isLoading.value = true; try { await ref.read(amberSignerProvider).signIn(); - onSignInSuccess(ref.read(refProvider)); } catch (e) { if (context.mounted) { context.showError( diff --git a/lib/widgets/bookmark_widgets.dart b/lib/widgets/bookmark_widgets.dart index cecdeaf..5ea2e1e 100644 --- a/lib/widgets/bookmark_widgets.dart +++ b/lib/widgets/bookmark_widgets.dart @@ -3,7 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; -import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -83,54 +83,12 @@ class SaveAppDialog extends HookConsumerWidget { bool isCurrentlySaved, ) async { try { - final devicePubkey = ref.read(devicePubkeyProvider); - if (devicePubkey == null) return; - - final signer = ref.read(Signer.signerProvider(devicePubkey)); - if (signer == null) return; - - final existingStacks = await ref.storage.query( - RequestFilter( - authors: {devicePubkey}, - tags: { - '#d': {kAppBookmarksIdentifier}, - }, - ).toRequest(), - source: const LocalSource(), - ); - final existingStack = existingStacks.firstOrNull; - - final existingAppIds = List.from( - existingStack?.privateAppIds ?? [], - ); - - final appAddressableId = - '${app.event.kind}:${app.pubkey}:${app.identifier}'; - - if (isCurrentlySaved) { - existingAppIds.remove(appAddressableId); - } else { - if (!existingAppIds.contains(appAddressableId)) { - existingAppIds.add(appAddressableId); - } - } - - final platform = ref.read(packageManagerProvider.notifier).platform; - final partialStack = PartialAppStack.withEncryptedApps( - name: 'Saved Apps', - identifier: kAppBookmarksIdentifier, - apps: existingAppIds, - platform: platform, - ); - - final signedStack = await partialStack.signWith(signer); - await ref.storage.save({signedStack}); - ref.storage.publish({signedStack}, relays: 'AppCatalog'); + final added = await toggleBookmark(ref, app); if (context.mounted) { Navigator.pop(context); context.showInfo( - isCurrentlySaved ? 'App removed from saved' : 'App saved privately', + added ? 'App saved privately' : 'App removed from saved', ); } } catch (e) { diff --git a/lib/widgets/common/stack_link_card.dart b/lib/widgets/common/stack_link_card.dart index 873567f..0de61e8 100644 --- a/lib/widgets/common/stack_link_card.dart +++ b/lib/widgets/common/stack_link_card.dart @@ -1,7 +1,4 @@ -import 'dart:convert'; - import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/utils/extensions.dart'; @@ -9,12 +6,8 @@ import 'package:zapstore/utils/nostr_route.dart'; /// Shared stack link card used in profile and user screens. /// Shows stack name, app count badge, and padlock icon for private stacks. -class StackLinkCard extends HookConsumerWidget { - const StackLinkCard({ - super.key, - required this.stack, - this.displayName, - }); +class StackLinkCard extends ConsumerWidget { + const StackLinkCard({super.key, required this.stack, this.displayName}); final AppStack stack; final String? displayName; @@ -23,35 +16,12 @@ class StackLinkCard extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final appCount = useState(null); - - useEffect(() { - if (_isEncrypted) { - Future decrypt() async { - final signer = ref.read(Signer.activeSignerProvider); - final pubkey = ref.read(Signer.activePubkeyProvider); - if (signer == null || pubkey == null) return; - - try { - final decrypted = await signer.nip44Decrypt(stack.content, pubkey); - final ids = (jsonDecode(decrypted) as List).cast(); - appCount.value = ids.length; - } catch (_) { - // Decryption failed, leave count as null - } - } - - decrypt(); - } else { - // Public stack: count 'a' tags - final count = stack.event - .getTagSetValues('a') - .where((id) => id.startsWith('32267:')) - .length; - appCount.value = count; - } - return null; - }, [stack.content, stack.id]); + final appCount = _isEncrypted + ? (stack.isDecrypted ? stack.privateAppIds.length : null) + : stack.event + .getTagSetValues('a') + .where((id) => id.startsWith('32267:')) + .length; final title = displayName ?? stack.name ?? stack.identifier; @@ -72,7 +42,9 @@ class StackLinkCard extends HookConsumerWidget { color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), border: Border.all( - color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2), + color: Theme.of( + context, + ).colorScheme.outline.withValues(alpha: 0.2), ), ), child: Row( @@ -98,18 +70,25 @@ class StackLinkCard extends HookConsumerWidget { color: Theme.of(context).colorScheme.onSurfaceVariant, ), ], - if (appCount.value != null && appCount.value! > 0) ...[ + if (appCount != null && appCount > 0) ...[ const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHigh, borderRadius: BorderRadius.circular(12), ), child: Text( - '${appCount.value}', + '$appCount', style: context.textTheme.labelSmall?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, ), ), diff --git a/lib/widgets/floating_overflow_menu.dart b/lib/widgets/floating_overflow_menu.dart index 68f02d2..ad49cd3 100644 --- a/lib/widgets/floating_overflow_menu.dart +++ b/lib/widgets/floating_overflow_menu.dart @@ -182,53 +182,10 @@ class FloatingOverflowMenu extends HookConsumerWidget { if (a == null) return; try { - final devicePubkey = ref.read(devicePubkeyProvider); - if (devicePubkey == null) return; - - final signer = ref.read(Signer.signerProvider(devicePubkey)); - if (signer == null) return; - - final existingStacks = await ref.storage.query( - RequestFilter( - authors: {devicePubkey}, - tags: { - '#d': {kAppBookmarksIdentifier}, - }, - ).toRequest(), - source: const LocalSource(), - ); - final existingStack = existingStacks.firstOrNull; - - final existingAppIds = List.from( - existingStack?.privateAppIds ?? [], - ); - - final appAddressableId = '${a.event.kind}:${a.pubkey}:${a.identifier}'; - - if (isCurrentlySaved) { - existingAppIds.remove(appAddressableId); - } else { - if (!existingAppIds.contains(appAddressableId)) { - existingAppIds.add(appAddressableId); - } - } - - final platform = ref.read(packageManagerProvider.notifier).platform; - final partialStack = PartialAppStack.withEncryptedApps( - name: 'Saved Apps', - identifier: kAppBookmarksIdentifier, - apps: existingAppIds, - platform: platform, - ); - - final signedStack = await partialStack.signWith(signer); - await ref.storage.save({signedStack}); - ref.storage.publish({signedStack}, relays: 'AppCatalog'); + final added = await toggleBookmark(ref, a); if (context.mounted) { - context.showInfo( - isCurrentlySaved ? 'App removed from saved' : 'App saved', - ); + context.showInfo(added ? 'App saved' : 'App removed from saved'); } } catch (e) { if (context.mounted) { diff --git a/lib/widgets/sign_in_button.dart b/lib/widgets/sign_in_button.dart index 589fdaa..decb844 100644 --- a/lib/widgets/sign_in_button.dart +++ b/lib/widgets/sign_in_button.dart @@ -6,7 +6,6 @@ import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/main.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; -import 'package:zapstore/utils/debug_utils.dart'; class SignInButton extends ConsumerWidget { const SignInButton({ @@ -40,7 +39,6 @@ class SignInButton extends ConsumerWidget { } else { try { await ref.read(amberSignerProvider).signIn(); - onSignInSuccess(ref.read(refProvider)); } catch (e) { if (context.mounted) { context.showError('Sign-in failed', technicalDetails: '$e'); diff --git a/plugins/installed_apps_plugin/android/build.gradle b/plugins/installed_apps_plugin/android/build.gradle index 38c93a5..8366fe7 100644 --- a/plugins/installed_apps_plugin/android/build.gradle +++ b/plugins/installed_apps_plugin/android/build.gradle @@ -7,7 +7,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.7.3' + classpath 'com.android.tools.build:gradle:8.9.1' } } diff --git a/pubspec.lock b/pubspec.lock index a105190..5ebbad5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -94,10 +94,10 @@ packages: dependency: "direct main" description: name: background_downloader - sha256: "2d17c211eb9bf1f947f154fb85d1bea96944fb1efb1b0714851caac993c02dc8" + sha256: d27fcaf94f738032f1e6db098230799bf31ae7bf184f173fc9a752fcdae75c0e url: "https://pub.dev" source: hosted - version: "9.4.3" + version: "9.5.6" bech32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8729927..c9b3570 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -50,7 +50,7 @@ dependencies: collection: ^1.19.1 qr_flutter: ^4.1.0 share_plus: ^12.0.0 - background_downloader: ^9.2.6 + background_downloader: ^9.5.2 connectivity_plus: ^7.0.0 app_links: ^6.4.1 flutter_secure_storage: ^9.2.4 # DO NOT UPDATE TO BROKEN ^10.0.0 diff --git a/spec/features/FEAT-006-device-key.md b/spec/features/FEAT-006-device-key.md index 28f6fef..e933f26 100644 --- a/spec/features/FEAT-006-device-key.md +++ b/spec/features/FEAT-006-device-key.md @@ -4,13 +4,14 @@ Decouple private data (bookmarks, unmanaged apps, installed backup, settings) from Amber sign-in by generating a local device key (nsec) that owns all private -encrypted events. Amber becomes purely the identity layer for public actions -(sharing stacks, zaps, web of trust). +encrypted events. Every private kind 30267 and 30078 event is signed by the +device key and carries NIP-13 proof of work. Amber is only the identity and +recovery layer for public actions and encrypted device-key backup capsules. ## Non-Goals -- Multi-device sync without Amber (backup/restore requires one Amber sign-in) -- Migrating existing Amber-signed private stacks (users start fresh or restore) +- Continuous or multi-device live sync +- Migrating legacy Amber-signed `zapstore-settings` events - Changing how public stacks work (still Amber-signed with h tag) - Implementing the publish queue (handled in purplebase) @@ -18,11 +19,15 @@ encrypted events. Amber becomes purely the identity layer for public actions - On first launch, a device key is silently generated and stored in secure storage - Bookmarks, unmanaged apps, and settings work immediately without sign-in +- Private data renders from SQLite; one non-streaming relay sync runs at app boot - Profile screen shows device key section with ability to copy nsec -- On first Amber sign-in, a dialog offers to: - - Back up this device (stores device nsec inside encrypted `zapstore-settings`) - - Restore from another device (if settings backups exist for this Amber key) +- On every Amber sign-in, the app performs one-shot recovery and legacy queries: + - Restore is offered when device-signed settings events contain a recovery + capsule for the Amber key + - The final device key is backed up in its device-signed settings event + - Amber-authored encrypted AppStacks are merged into device-owned stacks - Clearing app data (SQLite) does NOT delete device key or NWC string +- Background sync, migration, and proof-of-work jobs are bounded and cancellable ## Data Model @@ -30,22 +35,35 @@ encrypted events. Amber becomes purely the identity layer for public actions - NWC string: secure storage only (existing key) - Bookmarks: encrypted AppStack (30267), d=zapstore-bookmarks, signed by device key - Unmanaged apps: encrypted AppStack (30267), d=zapstore-unmanaged-apps, signed by device key -- Installed backup: encrypted AppStack (30267), d=zapstore-installed-backup, signed by device key -- App settings: encrypted CustomData (30078), d=zapstore-settings -- Device backup: entries inside encrypted `zapstore-settings`, signed by Amber key +- Installed backup: encrypted AppStack (30267), d=zapstore-installed-apps, signed by device key +- App settings: versioned private CustomData (30078), d=zapstore-settings, + signed by the device key +- Device backup: NIP-44 recovery capsules encrypted from the device key to Amber + identities inside `zapstore-settings`; matching `p` tags allow discovery. + Each capsule includes an Amber-signed authorization binding that identity to + the device pubkey, preventing third-party recovery-candidate injection. +- Trusted signers: encrypted, local-only CustomData (30078), d=trusted-signers, + signed by the device key +- Every private event commits to at least 16 bits of NIP-13 proof of work ## Signer Roles - Device signer (Bip340PrivateKeySigner): always available, never null. Signs all private events. Registered on boot, NOT set as active. - Amber signer (AmberSigner): optional. When present, is the active signer. Used - for public stacks, zaps, WoT queries. + for public stacks, zaps, WoT queries, and recovery-capsule encryption/decryption. +- NIP-13 mining runs in a Purplebase-owned worker isolate after encryption and + before signing; no mining loop runs on Flutter's main isolate. ## Filtering Strategy - Public stacks: filtered by #h tag (community pubkey) naturally excludes device stacks - Device private stacks: queried by authors: {devicePubkey} + specific #d tag - appStackEventFilter schema filter removed; #h tag filtering is sufficient +- Device private 30267/30078 events are fetched once with stream=false at boot. + Private UI consumers use LocalSource only. +- Amber sign-in recovery and migration are explicit one-shot exceptions; they + never open streaming subscriptions. ## Edge Cases @@ -54,6 +72,11 @@ encrypted events. Amber becomes purely the identity layer for public actions - Restore on device that already has data: ask user to confirm (replace or keep current) - Offline: events save locally, purplebase publish queue syncs when online - Multiple devices with same Amber key: each has own device key; backup stores device name +- Existing device-authored events without PoW are re-signed in the background; + invalid or undecryptable events are never overwritten. +- Legacy Amber-authored settings are ignored even if that loses old backups. +- Migration is idempotent and rechecks on every Amber sign-in; failed decrypts + remain retryable. ## Acceptance Criteria @@ -63,8 +86,14 @@ encrypted events. Amber becomes purely the identity layer for public actions - [ ] Unmanaged apps work without Amber sign-in - [ ] User can copy device nsec from profile screen - [ ] First Amber sign-in triggers backup/restore dialog -- [ ] Backup encrypts device nsec inside `zapstore-settings` to Amber key -- [ ] Restore decrypts `zapstore-settings` and imports device nsec +- [ ] Every Amber sign-in upserts a recovery capsule in device-signed settings +- [ ] Restore discovers device-signed settings by Amber `p` tag and imports nsec +- [ ] Legacy Amber-signed settings are ignored +- [ ] Amber private bookmarks, installed backups, unmanaged apps, and other + encrypted AppStacks migrate safely to the final device key +- [ ] All new private 30267/30078 events have valid 16-bit NIP-13 proof of work +- [ ] PoW mining does not run on Flutter's main isolate +- [ ] Private relay reads happen only at boot and explicit Amber sign-in recovery - [ ] appStackEventFilter removed; queries use #h tag filtering - [ ] EncryptableModel auto-decrypts device-key stacks (no manual decrypt calls) diff --git a/spec/work/WORK-010-unmanaged-apps.md b/spec/work/WORK-010-unmanaged-apps.md index f705857..3cd8ff5 100644 --- a/spec/work/WORK-010-unmanaged-apps.md +++ b/spec/work/WORK-010-unmanaged-apps.md @@ -29,6 +29,8 @@ cross-device sync. - [x] Include the platform tag and require explicit relay acceptance - [x] Surface local-save and relay-publish failures to the user - [x] Cover accumulation, overlapping writes, timestamps, tags, and failures +- [x] Load the decrypted local stack before the background AppCatalog refresh completes +- [x] Use a non-streaming background refresh for the unmanaged-apps query - [ ] Preserve catalog metadata for cataloged unmanaged apps in the Unmanaged Apps section - [ ] Extend native package scan with Android installer-source metadata - [ ] Default apps installed by known third-party app stores to unmanaged @@ -57,6 +59,9 @@ cross-device sync. map when Android reports no changes. - Android package enumeration runs on a lifecycle-owned worker so package, signature, and installer-source reads cannot block rendering. +- The unmanaged-apps query uses `LocalAndRemoteSource(stream: false)`: local + SQLite state is emitted immediately, while the one-shot AppCatalog query + refreshes it in the background without leaving a live subscription. ## Implementation Notes - Android can expose source through `PackageManager.getInstallSourceInfo(packageName)` on API 30+ and `getInstallerPackageName(packageName)` on older APIs. diff --git a/spec/work/WORK-011-device-key.md b/spec/work/WORK-011-device-key.md index de97a01..7379625 100644 --- a/spec/work/WORK-011-device-key.md +++ b/spec/work/WORK-011-device-key.md @@ -1,7 +1,7 @@ # WORK-011 - Device Key (Phase A + B + D) **Feature:** FEAT-006-device-key.md -**Status:** In Progress +**Status:** Complete ## Tasks @@ -38,13 +38,44 @@ - Queries Amber-authored encrypted AppStacks after Amber connection - Merges them into device-authored encrypted stacks using the device signer - Normalizes legacy installed/unmanaged d-tags to current identifiers - - Marks migration complete per Amber pubkey + device pubkey; empty results retry + - Rechecks idempotently on every sign-in; failed decrypts remain retryable - [x] 9. Store device key backups in encrypted settings - Files: `lib/services/device_backup_service.dart` - - Uses Amber-signed `CustomData` with `d=zapstore-settings` - - Encrypts the full JSON settings object to the Amber key before signing - - Stores device backup entries under `deviceBackups` -- [ ] 10. Self-review against INVARIANTS.md + - Uses device-signed `CustomData` with `d=zapstore-settings` + - Stores NIP-44 Amber recovery capsules authorized by Amber signatures + - Ignores legacy Amber-signed settings +- [x] 10. Self-review against INVARIANTS.md +- [x] 11. Centralize private 30267/30078 signing + - Device signer only, encrypted-stack h-tag rejection, 16-bit NIP-13 + - Purplebase worker executor keeps mining off Flutter's main isolate +- [x] 12. Add boot-only private event synchronization + - One cancellable stream=false request for device-authored 30267/30078 + - Private consumers use LocalSource only +- [x] 13. Replace Amber-owned settings backup + - Device-signed `zapstore-settings` with Amber recovery capsules and p tags + - Ignore all legacy Amber-signed settings +- [x] 14. Repair Amber migration + - Explicitly decrypt Amber stacks before merge + - Cover bookmarks, installed, unmanaged, and arbitrary encrypted stacks + - Recheck idempotently on every Amber sign-in +- [x] 15. Move trusted-signers CustomData to device ownership +- [x] 16. Consolidate bookmark writes and private stack reads +- [x] 17. Add lifecycle, failure, migration, and PoW tests + +## Test Coverage + +| Scenario | Expected | Status | +|----------|----------|--------| +| Device private write | Device author, valid PoW, local save | [x] | +| Invalid private policy | Unknown 30078 and encrypted h-tag rejected | [x] | +| PoW cancellation | Worker terminates and tags remain unchanged | [x] | +| Boot sync lifecycle | Single-flight and cancellable | [x] | +| Bookmark write overlap | Serialized monotonic replacements | [x] | +| Bookmark save failure | Optimistic state rolls back | [x] | +| Amber bookmark migration | Imperative result explicitly decrypts | [x] | +| Recovery injection | Amber authorization required | [x] | +| Recovery cancellation | No work restarts after cancellation | [x] | +| Full verification | App, models, and Purplebase suites pass | [x] | ## Decisions @@ -68,13 +99,53 @@ ### 2026-05-07 - Device backups live inside settings +**Status:** Superseded by the 2026-07-13 device-settings decision below. **Context:** Device key backup needs to be tied to the user's encrypted settings event, not a separate replaceable event. **Decision:** Store backup entries inside the Amber-signed `CustomData` event with `d=zapstore-settings`, under the `deviceBackups` JSON key. **Rationale:** Keeps backup state with settings and avoids a standalone `zapstore-device-backup` event. The entire settings JSON object is NIP-44 encrypted before signing. +### 2026-07-13 - All private events are device-owned + +**Context:** Amber ownership made private state depend on the current identity +and allowed private write paths to drift. +**Decision:** Every private kind 30267/30078 event is device-signed. Public +30267 stacks remain Amber-signed and carry the community h tag. +**Rationale:** The device key is always available and is the stable owner of +per-device state. + +### 2026-07-13 - Device settings contain recovery capsules + +**Context:** Amber must recover a device nsec without authoring the settings +event or owning device settings. +**Decision:** `zapstore-settings` is signed by the device key. Its versioned +envelope stores device-private data plus NIP-44 capsules addressed to Amber +keys and indexed by p tags. Capsules include an embedded Amber-signed +authorization binding the Amber identity to the device pubkey. +**Rationale:** Settings remain per-device while Amber remains an orthogonal +recovery recipient, while copied or attacker-created capsules cannot inject a +device key that Amber never authorized. + +### 2026-07-13 - Boot-only relay ingestion + +**Context:** Private state changes originate on the device and live +subscriptions waste relay and lifecycle resources. +**Decision:** Fetch device-authored private events once at boot with +`stream:false`; UI providers are local-only. Amber recovery/migration is the +only sign-in-time one-shot exception. +**Rationale:** Preserves local-first rendering and avoids duplicate or leaked +subscriptions. + +### 2026-07-13 - 16-bit off-isolate proof of work + +**Context:** Private events need NIP-13 without running CPU-bound mining on +Flutter's main isolate. +**Decision:** Apply 16-bit PoW through a cancellable Purplebase worker executor. +**Rationale:** 16 bits is a practical mobile spam cost; worker execution keeps +rendering responsive. + ## Spec Issues -- `spec/features/FEAT-006-device-key.md` still lists migration as a non-goal and uses legacy d-tags for installed/unmanaged apps. Implementation now follows the product direction from this work session: migrate private stacks to the device pubkey on Amber connection. +_None_ ## Progress Notes @@ -83,3 +154,7 @@ **2026-05-07:** Restore ordering hardened: if an Amber backup contains other device keys, the restore/keep-current choice happens before migration and backup. Migration completion is keyed by both Amber pubkey and final device pubkey, and empty migration results are left retryable. **2026-05-07:** Device key backup moved into the encrypted `zapstore-settings` CustomData event. No `zapstore-device-backup` event is written. + +**2026-07-13:** FEAT-006 was updated by explicit product authorization. The new +contract supersedes the earlier Amber-signed settings decision and ignores +legacy Amber settings even when that loses old backups. diff --git a/test/services/bookmarks_service_test.dart b/test/services/bookmarks_service_test.dart new file mode 100644 index 0000000..188c4ef --- /dev/null +++ b/test/services/bookmarks_service_test.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:zapstore/services/bookmarks_service.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; + +void main() { + test('optimistically toggles and serializes replacement writes', () async { + final writes = >[]; + final gates = >[]; + final notifier = BookmarksNotifier((ids, _) { + writes.add(ids); + final gate = Completer(); + gates.add(gate); + return gate.future; + }); + addTearDown(notifier.dispose); + + final first = notifier.toggle('app.one'); + final second = notifier.toggle('app.two'); + + expect(notifier.state, {'app.one', 'app.two'}); + await Future.value(); + expect(writes, [ + {'app.one'}, + ]); + + gates.first.complete(); + await first; + await Future.value(); + expect(writes, [ + {'app.one'}, + {'app.one', 'app.two'}, + ]); + + gates.last.complete(); + await second; + }); + + test('returns whether the app was added or removed', () async { + final notifier = BookmarksNotifier((_, _) async {}); + addTearDown(notifier.dispose); + + expect(await notifier.toggle('app.one'), isTrue); + expect(await notifier.toggle('app.one'), isFalse); + expect(notifier.state, isEmpty); + }); + + test('rolls back optimistic state when the local save fails', () async { + final notifier = BookmarksNotifier((_, _) { + throw const DevicePrivateSaveException('save failed'); + }); + addTearDown(notifier.dispose); + + await expectLater( + notifier.toggle('app.one'), + throwsA(isA()), + ); + expect(notifier.state, isEmpty); + }); +} diff --git a/test/services/device_backup_service_test.dart b/test/services/device_backup_service_test.dart new file mode 100644 index 0000000..f0d25a7 --- /dev/null +++ b/test/services/device_backup_service_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +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/utils/debug_utils.dart'; + +void main() { + setUpAll(() { + Model.register( + kind: 1, + constructor: Note.fromMap, + partialConstructor: PartialNote.fromMap, + ); + Model.register( + kind: 30267, + constructor: AppStack.fromMap, + partialConstructor: PartialAppStack.fromMap, + ); + }); + + test( + 'explicitly decrypts an imperatively loaded Amber bookmark stack', + () async { + final container = ProviderContainer( + overrides: [ + storageNotifierProvider.overrideWith(DummyStorageNotifier.new), + ], + ); + addTearDown(container.dispose); + await container + .read(storageNotifierProvider.notifier) + .initialize(StorageConfiguration()); + final amber = Bip340PrivateKeySigner( + '2' * 64, + container.read(refProvider), + ); + await amber.signIn(setAsActive: false); + + final signed = await PartialAppStack.withEncryptedApps( + name: 'Saved Apps', + identifier: kAppBookmarksIdentifier, + apps: const ['32267:publisher:app-one', '32267:publisher:app-two'], + ).signWith(amber); + final imperativelyLoaded = AppStack.fromMap( + signed.event.toMap(), + container.read(refProvider), + ); + + expect(imperativelyLoaded.privateAppIds, isEmpty); + expect(await decryptAmberStackAppIds(amber, imperativelyLoaded), [ + '32267:publisher:app-one', + '32267:publisher:app-two', + ]); + }, + ); + + test('requires Amber authorization for the recovered device key', () async { + final container = ProviderContainer( + overrides: [ + storageNotifierProvider.overrideWith(DummyStorageNotifier.new), + ], + ); + addTearDown(container.dispose); + await container + .read(storageNotifierProvider.notifier) + .initialize(StorageConfiguration()); + final amber = Bip340PrivateKeySigner('2' * 64, container.read(refProvider)); + await amber.signIn(setAsActive: false); + final devicePubkey = Utils.derivePublicKey('3' * 64); + final attackerPubkey = Utils.derivePublicKey('4' * 64); + + final partial = PartialNote('zapstore-device-key-authorization-v1'); + partial.event.addTagValue('device', devicePubkey); + partial.event.addTagValue('p', amber.pubkey); + final authorization = await partial.signWith(amber); + final map = authorization.event.toMap(); + + expect( + validateRecoveryAuthorization( + container.read(refProvider), + map, + amberPubkey: amber.pubkey, + devicePubkey: devicePubkey, + ), + isTrue, + ); + expect( + validateRecoveryAuthorization( + container.read(refProvider), + map, + amberPubkey: amber.pubkey, + devicePubkey: attackerPubkey, + ), + isFalse, + ); + }); + + test('cancellation prevents recovery work from restarting', () async { + final container = ProviderContainer( + overrides: [ + storageNotifierProvider.overrideWith(DummyStorageNotifier.new), + ], + ); + addTearDown(container.dispose); + await container + .read(storageNotifierProvider.notifier) + .initialize(StorageConfiguration()); + final amber = Bip340PrivateKeySigner('2' * 64, container.read(refProvider)); + await amber.signIn(setAsActive: false); + final service = DeviceBackupService()..beginWork(); + + service.cancelCurrentWork(container.read(refProvider)); + + await expectLater( + service.fetchRecoveryCandidates( + ref: container.read(refProvider), + amberSigner: amber, + ), + throwsA(isA()), + ); + }); +} diff --git a/test/services/device_private_event_service_test.dart b/test/services/device_private_event_service_test.dart new file mode 100644 index 0000000..690c559 --- /dev/null +++ b/test/services/device_private_event_service_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:models/models.dart'; +import 'package:purplebase/purplebase.dart'; +import 'package:zapstore/constants/app_constants.dart'; +import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_event_service.dart'; +import 'package:zapstore/utils/debug_utils.dart'; + +void main() { + late ProviderContainer container; + late Bip340PrivateKeySigner signer; + late IsolateProofOfWorkExecutor executor; + late DevicePrivateEventService service; + + setUp(() async { + container = ProviderContainer( + overrides: [ + storageNotifierProvider.overrideWith(DummyStorageNotifier.new), + ], + ); + await container + .read(storageNotifierProvider.notifier) + .initialize(StorageConfiguration()); + + signer = Bip340PrivateKeySigner('1' * 64, container.read(refProvider)); + await signer.signIn(setAsActive: false); + container.read(devicePubkeyProvider.notifier).state = signer.pubkey; + executor = IsolateProofOfWorkExecutor(); + service = DevicePrivateEventService( + container.read(refProvider), + executor: executor, + difficulty: 4, + maxAttempts: 100000, + ); + }); + + tearDown(() { + executor.dispose(); + container.dispose(); + }); + + test('production policy requires 16 proof-of-work bits', () { + expect(kPrivateEventPowDifficulty, 16); + }); + + test('device-signs, mines, and saves a private CustomData event', () async { + final ciphertext = await service.encryptToDevice('{"trusted":[]}'); + final signed = await service.signAndSave( + PartialCustomData( + identifier: kTrustedSignersIdentifier, + content: ciphertext, + ), + publish: false, + ); + + expect(signed.pubkey, signer.pubkey); + expect(Nip13.isValid(signed.event, minimumDifficulty: 4), isTrue); + final stored = await container + .read(storageNotifierProvider.notifier) + .query( + RequestFilter( + authors: {signer.pubkey}, + tags: { + '#d': {kTrustedSignersIdentifier}, + }, + ).toRequest(), + source: const LocalSource(), + ); + expect(stored.single.event.id, signed.event.id); + }); + + test('rejects encrypted stacks carrying a community tag', () async { + final partial = PartialAppStack.withEncryptedApps( + name: 'Saved Apps', + identifier: kAppBookmarksIdentifier, + apps: const ['32267:pubkey:app'], + )..event.addTagValue('h', kZapstoreCommunityPubkey); + + await expectLater( + service.signAndSave(partial, publish: false), + throwsA(isA()), + ); + }); + + test('rejects unknown private CustomData identifiers', () async { + await expectLater( + service.signAndSave( + PartialCustomData(identifier: 'unknown', content: 'secret'), + publish: false, + ), + throwsA(isA()), + ); + }); +} diff --git a/test/services/device_private_sync_service_test.dart b/test/services/device_private_sync_service_test.dart new file mode 100644 index 0000000..40c191c --- /dev/null +++ b/test/services/device_private_sync_service_test.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:models/models.dart'; +import 'package:zapstore/services/device_key_service.dart'; +import 'package:zapstore/services/device_private_sync_service.dart'; +import 'package:zapstore/utils/debug_utils.dart'; + +void main() { + test('boot sync is single-flight and cancellable', () async { + final container = ProviderContainer( + overrides: [ + storageNotifierProvider.overrideWith(DummyStorageNotifier.new), + ], + ); + addTearDown(container.dispose); + await container + .read(storageNotifierProvider.notifier) + .initialize(StorageConfiguration()); + container.read(devicePubkeyProvider.notifier).state = 'a' * 64; + + var calls = 0; + final remote = Completer>>(); + final notifier = DevicePrivateSyncNotifier( + container.read(refProvider), + query: (request, source, prefix) { + calls++; + return remote.future; + }, + ); + addTearDown(notifier.dispose); + + final first = notifier.start(); + await notifier.start(); + expect(calls, 1); + expect(notifier.state.phase, DevicePrivateSyncPhase.syncing); + + notifier.cancel(); + expect(notifier.state.phase, DevicePrivateSyncPhase.cancelled); + remote.complete(const []); + await first; + + expect(calls, 1); + expect(notifier.state.phase, DevicePrivateSyncPhase.cancelled); + }); +} diff --git a/test/services/unmanaged_apps_service_test.dart b/test/services/unmanaged_apps_service_test.dart index db68d44..16b7047 100644 --- a/test/services/unmanaged_apps_service_test.dart +++ b/test/services/unmanaged_apps_service_test.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/services/unmanaged_apps_service.dart'; @@ -118,23 +119,53 @@ void main() { }); group('wasUnmanagedStackAccepted', () { - test('requires explicit acceptance for the stack event', () { - final accepted = PublishResponse() - ..addEvent( - 'stack-id', - relayUrl: 'wss://relay.zapstore.dev', - accepted: true, - ); - final rejected = PublishResponse() - ..addEvent( - 'stack-id', - relayUrl: 'wss://relay.zapstore.dev', - accepted: false, + test( + 'uses the raw event ID for a parameterized replaceable stack', + () async { + final container = ProviderContainer( + overrides: [ + storageNotifierProvider.overrideWith(DummyStorageNotifier.new), + ], ); + addTearDown(container.dispose); + await container + .read(storageNotifierProvider.notifier) + .initialize(StorageConfiguration()); + final stack = createUnmanagedAppsStack( + appIds: {'app.one'}, + platform: 'android-arm64-v8a', + createdAt: DateTime.utc(2026, 7, 10, 20), + ).dummySign('a' * 64); - expect(wasUnmanagedStackAccepted(accepted, 'stack-id'), isTrue); - expect(wasUnmanagedStackAccepted(rejected, 'stack-id'), isFalse); - expect(wasUnmanagedStackAccepted(PublishResponse(), 'stack-id'), isFalse); - }); + expect(stack.id, isNot(stack.event.id)); + + final accepted = PublishResponse() + ..addEvent( + stack.event.id, + relayUrl: 'wss://relay.zapstore.dev', + accepted: true, + ); + final rejected = PublishResponse() + ..addEvent( + stack.event.id, + relayUrl: 'wss://relay.zapstore.dev', + accepted: false, + ); + final addressableIdResponse = PublishResponse() + ..addEvent( + stack.id, + relayUrl: 'wss://relay.zapstore.dev', + accepted: true, + ); + + expect(wasUnmanagedStackAccepted(accepted, stack), isTrue); + expect(wasUnmanagedStackAccepted(rejected, stack), isFalse); + expect( + wasUnmanagedStackAccepted(addressableIdResponse, stack), + isFalse, + ); + expect(wasUnmanagedStackAccepted(PublishResponse(), stack), isFalse); + }, + ); }); }