mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Rewrite device keypair architecture
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+17
-1
@@ -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<String?>(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<void>((ref) async {
|
||||
final appInitializationProvider = FutureProvider<void>((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<void> _maybeCopySeedDatabase(String dbPath, {bool skip = false}) async {
|
||||
Future<void> _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<void> _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.
|
||||
|
||||
@@ -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<AppStack>(
|
||||
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<Set<String>?>(null);
|
||||
final decryptError = useState<String?>(null);
|
||||
|
||||
useEffect(() {
|
||||
if (!isEncrypted) return null;
|
||||
|
||||
Future<void> 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<String>().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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -82,8 +82,10 @@ class StacksNotifier extends PagedSubscriptionNotifier<AppStack> {
|
||||
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<AppStack>(
|
||||
authors: {signedInPubkey},
|
||||
tags: {
|
||||
'#h': {kZapstoreCommunityPubkey},
|
||||
},
|
||||
where: (s) => stackNeedsMigration(s, platform),
|
||||
source: LocalAndRemoteSource(
|
||||
relays: {'social', 'AppCatalog'},
|
||||
|
||||
@@ -263,6 +263,9 @@ class _StackMigrationWarning extends ConsumerWidget {
|
||||
final stacksState = ref.watch(
|
||||
query<AppStack>(
|
||||
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<AppStack>(
|
||||
authors: {devicePubkey},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
source: const LocalSource(),
|
||||
subscriptionPrefix: 'app-profile-user-stacks',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<Set<String>>((ref) {
|
||||
final _persistedBookmarksProvider = Provider<AppStack?>((ref) {
|
||||
final devicePubkey = ref.watch(devicePubkeyProvider);
|
||||
if (devicePubkey == null) return const {};
|
||||
if (devicePubkey == null) return null;
|
||||
|
||||
final stackState = ref.watch(
|
||||
query<AppStack>(
|
||||
@@ -17,21 +18,140 @@ final bookmarksProvider = Provider<Set<String>>((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<void> Function(Set<String> appIds, DateTime createdAt);
|
||||
|
||||
/// Owns bookmarked IDs and serializes replacement writes.
|
||||
class BookmarksNotifier extends StateNotifier<Set<String>> {
|
||||
BookmarksNotifier(this._write, {DateTime Function()? clock})
|
||||
: _clock = clock ?? DateTime.now,
|
||||
super(const {});
|
||||
|
||||
static const _equality = SetEquality<String>();
|
||||
final BookmarksWriter _write;
|
||||
final DateTime Function() _clock;
|
||||
Future<void> _writeQueue = Future.value();
|
||||
Set<String> _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<bool> toggle(String appId) {
|
||||
final updated = {...state};
|
||||
final removed = updated.remove(appId);
|
||||
if (!removed) updated.add(appId);
|
||||
_setState(updated);
|
||||
|
||||
final createdAt = _nextCreatedAt();
|
||||
final snapshot = Set<String>.unmodifiable(updated);
|
||||
final completer = Completer<bool>();
|
||||
_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<String> value) {
|
||||
if (!_equality.equals(state, value)) {
|
||||
state = Set.unmodifiable(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reactive set of bookmarked app addressable IDs.
|
||||
final bookmarksProvider = StateNotifierProvider<BookmarksNotifier, Set<String>>(
|
||||
(ref) {
|
||||
final notifier = BookmarksNotifier(
|
||||
(ids, createdAt) => _writeBookmarks(ref, ids, createdAt),
|
||||
);
|
||||
ref.listen<AppStack?>(
|
||||
_persistedBookmarksProvider,
|
||||
(_, stack) => notifier.acceptPersisted(stack),
|
||||
fireImmediately: true,
|
||||
);
|
||||
return notifier;
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> _writeBookmarks(
|
||||
Ref ref,
|
||||
Set<String> 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<bool> 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) {
|
||||
|
||||
@@ -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": "<hex>", "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<CustomData?> fetchExistingSettings(Ref ref, String amberPubkey) async {
|
||||
final storage = ref.read(storageNotifierProvider.notifier);
|
||||
final results = await storage.query(
|
||||
final Set<Request<Model<dynamic>>> _activeRequests = {};
|
||||
bool _cancelled = false;
|
||||
|
||||
void beginWork() => _cancelled = false;
|
||||
|
||||
void _checkCancelled() {
|
||||
if (_cancelled) throw const DeviceBackupCancelled();
|
||||
}
|
||||
|
||||
Future<CustomData?> fetchExistingSettings(
|
||||
Ref ref,
|
||||
String devicePubkey,
|
||||
) async {
|
||||
final results = await _queryTracked(
|
||||
ref,
|
||||
RequestFilter<CustomData>(
|
||||
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<Map<String, dynamic>> decryptSettings(
|
||||
Signer signer,
|
||||
CustomData settings,
|
||||
) async {
|
||||
try {
|
||||
final decrypted = await signer.nip44Decrypt(
|
||||
settings.content,
|
||||
signer.pubkey,
|
||||
);
|
||||
final decoded = jsonDecode(decrypted);
|
||||
if (decoded is Map<String, dynamic>) return decoded;
|
||||
if (decoded is List) {
|
||||
return {_kDeviceBackupsKey: decoded};
|
||||
/// Finds device-authored settings events recoverable by [amberSigner].
|
||||
Future<List<DeviceBackupInfo>> fetchRecoveryCandidates({
|
||||
required Ref ref,
|
||||
required Signer amberSigner,
|
||||
}) async {
|
||||
_checkCancelled();
|
||||
final request = RequestFilter<CustomData>(
|
||||
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 = <String, DeviceBackupInfo>{};
|
||||
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<Map>()) {
|
||||
final recovery = Map<String, dynamic>.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<String, dynamic>.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<String, dynamic>.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<List<Map<String, dynamic>>> decryptEntries(
|
||||
Signer signer,
|
||||
CustomData settings,
|
||||
) async {
|
||||
final decodedSettings = await decryptSettings(signer, settings);
|
||||
final entries = decodedSettings[_kDeviceBackupsKey];
|
||||
if (entries is! List) return [];
|
||||
return entries.whereType<Map<String, dynamic>>().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<void> 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)
|
||||
: <String, dynamic>{};
|
||||
final entries = (settings[_kDeviceBackupsKey] as List?)
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.toList() ??
|
||||
<Map<String, dynamic>>[];
|
||||
|
||||
// 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>()
|
||||
.map((entry) => Map<String, dynamic>.from(entry))
|
||||
.toList() ??
|
||||
<Map<String, dynamic>>[];
|
||||
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<bool> 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<AppStack>(authors: {amberSigner.pubkey}).toRequest(),
|
||||
_checkCancelled();
|
||||
final request = RequestFilter<AppStack>(
|
||||
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<AppStack>(
|
||||
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 <String>[];
|
||||
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<String>.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<void> restoreDeviceKey({
|
||||
required Ref ref,
|
||||
required String privateKeyHex,
|
||||
@@ -218,21 +303,115 @@ class DeviceBackupService {
|
||||
ref.read(devicePubkeyProvider.notifier).state = restoredSigner.pubkey;
|
||||
}
|
||||
|
||||
Future<List<E>> _queryTracked<E extends Model<dynamic>>(
|
||||
Ref ref,
|
||||
Request<E> 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<String> _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<List<String>?> 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<String>().toList() : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final deviceBackupServiceProvider = Provider<DeviceBackupService>(
|
||||
(ref) => DeviceBackupService(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _emptySettingsEnvelope() => {
|
||||
'version': _kSettingsFormatVersion,
|
||||
_kRecoveriesKey: <Map<String, dynamic>>[],
|
||||
};
|
||||
|
||||
Map<String, dynamic>? _decodeSettingsEnvelope(String content) {
|
||||
try {
|
||||
final decoded = jsonDecode(content);
|
||||
if (decoded is! Map) return null;
|
||||
final envelope = Map<String, dynamic>.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<String, dynamic> authorization, {
|
||||
required String amberPubkey,
|
||||
required String devicePubkey,
|
||||
}) {
|
||||
try {
|
||||
if (authorization['kind'] != 1 ||
|
||||
authorization['pubkey'] != amberPubkey ||
|
||||
authorization['content'] != _kRecoveryAuthorization) {
|
||||
return false;
|
||||
}
|
||||
final event = PartialEvent<Model<dynamic>>(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<void> 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 <Map<String, dynamic>>[];
|
||||
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<DeviceBackupInfo>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => DeviceBackupRestoreDialog(backups: otherDevices),
|
||||
final alreadyOffered = await keyService.hasBackupBeenOffered(amberPubkey);
|
||||
List<DeviceBackupInfo> 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<DeviceBackupInfo>(
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IsolateProofOfWorkExecutor>((
|
||||
ref,
|
||||
) {
|
||||
final executor = IsolateProofOfWorkExecutor();
|
||||
ref.onDispose(executor.dispose);
|
||||
return executor;
|
||||
});
|
||||
|
||||
final devicePrivateEventServiceProvider = Provider<DevicePrivateEventService>((
|
||||
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<String> encryptToDevice(String plaintext) =>
|
||||
deviceSigner.nip44Encrypt(plaintext, devicePubkey);
|
||||
|
||||
Future<String> decryptFromDevice(String ciphertext) =>
|
||||
deviceSigner.nip44Decrypt(ciphertext, devicePubkey);
|
||||
|
||||
Future<String> encryptFor(String plaintext, String recipientPubkey) =>
|
||||
deviceSigner.nip44Encrypt(plaintext, recipientPubkey);
|
||||
|
||||
Future<E> signAndSave<E extends Model<dynamic>>(
|
||||
PartialModel<E> 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<dynamic> 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<dynamic> 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<Model<dynamic>>(map, event.kind)
|
||||
..tags = [for (final tag in event.tags) List<String>.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);
|
||||
}
|
||||
@@ -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<List<Model<dynamic>>> Function(
|
||||
Request<Model<dynamic>> request,
|
||||
Source source,
|
||||
String subscriptionPrefix,
|
||||
);
|
||||
|
||||
class DevicePrivateSyncNotifier extends StateNotifier<DevicePrivateSyncState> {
|
||||
DevicePrivateSyncNotifier(this.ref, {DevicePrivateQuery? query})
|
||||
: _query = query,
|
||||
super(const DevicePrivateSyncState.idle());
|
||||
|
||||
final Ref ref;
|
||||
final DevicePrivateQuery? _query;
|
||||
Request<Model<dynamic>>? _activeRequest;
|
||||
Future<void>? _runFuture;
|
||||
bool _started = false;
|
||||
bool _cancelled = false;
|
||||
|
||||
Future<void> start() async {
|
||||
if (_started) return;
|
||||
_started = true;
|
||||
_runFuture = _runSync();
|
||||
await _runFuture;
|
||||
}
|
||||
|
||||
/// Explicit recovery exception for a device key restored after app boot.
|
||||
Future<void> syncRestoredKey() async {
|
||||
cancel();
|
||||
await _runFuture;
|
||||
_runFuture = _runSync();
|
||||
await _runFuture;
|
||||
}
|
||||
|
||||
Future<void> _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<Model<dynamic>>(
|
||||
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<void> _upgradeLegacyProofs(Request<Model<dynamic>> 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<String>.of(tag));
|
||||
}
|
||||
partial.event.createdAt = privateEvents.nextReplaceableTimestamp(
|
||||
data.createdAt,
|
||||
);
|
||||
await privateEvents.signAndSave(
|
||||
partial,
|
||||
publish: data.identifier != kTrustedSignersIdentifier,
|
||||
);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Model<dynamic>>> _queryStorage(
|
||||
Request<Model<dynamic>> 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<DevicePrivateSyncNotifier, DevicePrivateSyncState>(
|
||||
(ref) => DevicePrivateSyncNotifier(ref),
|
||||
);
|
||||
@@ -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<bool> 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<void> 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<void> _saveTrustedSigners(Set<String> 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<Set<String>> _loadTrustedSigners() async {
|
||||
final activePubkey = ref.read(Signer.activePubkeyProvider);
|
||||
if (activePubkey == null) return <String>{};
|
||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
||||
if (devicePubkey == null) return <String>{};
|
||||
|
||||
try {
|
||||
final request = Request<CustomData>([
|
||||
RequestFilter<CustomData>(
|
||||
authors: {activePubkey},
|
||||
authors: {devicePubkey},
|
||||
tags: {
|
||||
'#d': {kTrustedSignersIdentifier},
|
||||
},
|
||||
@@ -66,7 +60,10 @@ class TrustedSignersService {
|
||||
|
||||
if (models.isEmpty) return <String>{};
|
||||
final model = models.first;
|
||||
final map = jsonDecode(model.content) as Map<String, dynamic>;
|
||||
final decrypted = await ref
|
||||
.read(devicePrivateEventServiceProvider)
|
||||
.decryptFromDevice(model.content);
|
||||
final map = jsonDecode(decrypted) as Map<String, dynamic>;
|
||||
final list =
|
||||
(map['trusted'] as List?)?.cast<String>() ?? const <String>[];
|
||||
return list.toSet();
|
||||
@@ -74,6 +71,33 @@ class TrustedSignersService {
|
||||
return <String>{};
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports the legacy local Amber-authored preference without publishing it.
|
||||
Future<void> migrateLegacyAmberRecord(Signer amberSigner) async {
|
||||
final storage = ref.read(storageNotifierProvider.notifier);
|
||||
final legacy = await storage.query(
|
||||
RequestFilter<CustomData>(
|
||||
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<String, dynamic>;
|
||||
final oldTrusted =
|
||||
(map['trusted'] as List?)?.whereType<String>().toSet() ?? const {};
|
||||
if (oldTrusted.isEmpty) return;
|
||||
final merged = {...await _loadTrustedSigners(), ...oldTrusted};
|
||||
await _saveTrustedSigners(merged);
|
||||
} catch (_) {
|
||||
// Malformed legacy local preferences are ignored.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final trustServiceProvider = Provider<TrustedSignersService>(
|
||||
|
||||
@@ -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<void> _writeUnmanagedApps(
|
||||
Set<String> 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<void> _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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<UpdatePollerState> {
|
||||
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<UpdatePollerState> {
|
||||
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<CategorizedUpdates>((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<CategorizedUpdates>((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) {
|
||||
|
||||
@@ -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<void> _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<AppStack>(
|
||||
authors: {devicePubkey},
|
||||
tags: {
|
||||
'#d': {kAppBookmarksIdentifier},
|
||||
},
|
||||
).toRequest(),
|
||||
source: const LocalSource(),
|
||||
);
|
||||
final existingStack = existingStacks.firstOrNull;
|
||||
|
||||
final existingAppIds = List<String>.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<void> _showAddToStackDialog(BuildContext context, App app) async {
|
||||
await showBaseDialog(
|
||||
context: context,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<AppStack>(
|
||||
authors: {devicePubkey},
|
||||
tags: {
|
||||
'#d': {kAppBookmarksIdentifier},
|
||||
},
|
||||
).toRequest(),
|
||||
source: const LocalSource(),
|
||||
);
|
||||
final existingStack = existingStacks.firstOrNull;
|
||||
|
||||
final existingAppIds = List<String>.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) {
|
||||
|
||||
@@ -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<int?>(null);
|
||||
|
||||
useEffect(() {
|
||||
if (_isEncrypted) {
|
||||
Future<void> 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<String>();
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<AppStack>(
|
||||
authors: {devicePubkey},
|
||||
tags: {
|
||||
'#d': {kAppBookmarksIdentifier},
|
||||
},
|
||||
).toRequest(),
|
||||
source: const LocalSource(),
|
||||
);
|
||||
final existingStack = existingStacks.firstOrNull;
|
||||
|
||||
final existingAppIds = List<String>.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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = <Set<String>>[];
|
||||
final gates = <Completer<void>>[];
|
||||
final notifier = BookmarksNotifier((ids, _) {
|
||||
writes.add(ids);
|
||||
final gate = Completer<void>();
|
||||
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<void>.value();
|
||||
expect(writes, [
|
||||
{'app.one'},
|
||||
]);
|
||||
|
||||
gates.first.complete();
|
||||
await first;
|
||||
await Future<void>.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<DevicePrivateSaveException>()),
|
||||
);
|
||||
expect(notifier.state, isEmpty);
|
||||
});
|
||||
}
|
||||
@@ -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<DeviceBackupCancelled>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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<CustomData>(
|
||||
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<DevicePrivateEventException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects unknown private CustomData identifiers', () async {
|
||||
await expectLater(
|
||||
service.signAndSave(
|
||||
PartialCustomData(identifier: 'unknown', content: 'secret'),
|
||||
publish: false,
|
||||
),
|
||||
throwsA(isA<DevicePrivateEventException>()),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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<List<Model<dynamic>>>();
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user