mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Replace capsule recovery with portable device state, Amber key backup, and legacy installed-app restore
This commit is contained in:
@@ -28,11 +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 device-owned encrypted portable settings record.
|
||||
const kDeviceStateIdentifier = 'zapstore-device-state';
|
||||
|
||||
/// Identifier for the local encrypted trusted-signer preference
|
||||
const kTrustedSignersIdentifier = 'trusted-signers';
|
||||
/// Identifier for the Amber-owned encrypted device-key backup record.
|
||||
const kDeviceKeyBackupIdentifier = 'zapstore-device-key-backup';
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -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_event_service.dart';
|
||||
import 'package:zapstore/services/device_private_sync_service.dart';
|
||||
import 'package:zapstore/services/device_state_service.dart';
|
||||
import 'package:zapstore/services/app_catalog_relay_service.dart';
|
||||
import 'package:zapstore/utils/debug_utils.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
@@ -193,6 +195,9 @@ class ZapstoreApp extends HookConsumerWidget {
|
||||
// Check initial connectivity state
|
||||
connectivity.checkConnectivity().then((results) {
|
||||
notifier.connect();
|
||||
unawaited(
|
||||
ref.read(devicePrivateEventServiceProvider).processPendingEvents(),
|
||||
);
|
||||
unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
||||
});
|
||||
|
||||
@@ -200,6 +205,9 @@ class ZapstoreApp extends HookConsumerWidget {
|
||||
subscription = connectivity.onConnectivityChanged.listen((results) {
|
||||
notifier.connect();
|
||||
if (results.any((result) => result != ConnectivityResult.none)) {
|
||||
unawaited(
|
||||
ref.read(devicePrivateEventServiceProvider).processPendingEvents(),
|
||||
);
|
||||
unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
||||
}
|
||||
});
|
||||
@@ -411,8 +419,13 @@ final storageReadyProvider = FutureProvider<void>((ref) async {
|
||||
final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
await ref.read(storageReadyProvider.future);
|
||||
|
||||
// There is no bootstrap publication. Mark portable state usable before
|
||||
// background draft mining and relay work begin.
|
||||
await ref.read(deviceStateProvider.notifier).bootstrap();
|
||||
|
||||
// Private relay ingestion is one-shot, non-streaming, and never gates UI.
|
||||
unawaited(ref.read(devicePrivateSyncProvider.notifier).start());
|
||||
unawaited(ref.read(devicePrivateEventServiceProvider).processPendingEvents());
|
||||
|
||||
// Initialize device capabilities (used for dynamic download concurrency)
|
||||
await DeviceCapabilitiesCache.initialize();
|
||||
@@ -434,6 +447,9 @@ final appInitializationProvider = FutureProvider<void>((ref) async {
|
||||
await _attemptAutoSignIn(ref);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (ref.read(Signer.activePubkeyProvider) == null) {
|
||||
unawaited(maybeOfferInitialDeviceRestore(ref));
|
||||
}
|
||||
unawaited(ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
||||
});
|
||||
});
|
||||
@@ -571,6 +587,9 @@ class _AppLifecycleObserver with WidgetsBindingObserver {
|
||||
|
||||
// Reconnect storage/relay connections
|
||||
notifier.connect();
|
||||
unawaited(
|
||||
_ref.read(devicePrivateEventServiceProvider).processPendingEvents(),
|
||||
);
|
||||
unawaited(_ref.read(appCatalogRelayServiceProvider).checkForUpdates());
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
_ref.read(appCatalogRelayServiceProvider).cancelCurrentCheck();
|
||||
|
||||
+133
-75
@@ -16,6 +16,8 @@ import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:purplebase/purplebase.dart';
|
||||
import 'package:zapstore/main.dart';
|
||||
import 'package:zapstore/services/device_key_service.dart';
|
||||
import 'package:zapstore/services/device_private_event_service.dart';
|
||||
import 'package:zapstore/services/device_state_service.dart';
|
||||
import 'package:zapstore/services/log_service.dart' as app_logs;
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
@@ -1404,12 +1406,38 @@ class _DeviceKeyCard extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final devicePubkey = ref.watch(devicePubkeyProvider);
|
||||
final deviceState = ref.watch(deviceStateProvider);
|
||||
if (devicePubkey == null) return const SizedBox.shrink();
|
||||
|
||||
final npub = bech32Encode('npub', devicePubkey);
|
||||
final shortened =
|
||||
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
|
||||
final isCopying = useState(false);
|
||||
final powElapsed = useState(Duration.zero);
|
||||
useEffect(() {
|
||||
final startedAt = deviceState.startedAt;
|
||||
if (deviceState.phase != DeviceStatePhase.bootstrapping ||
|
||||
startedAt == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
void updateElapsed() {
|
||||
powElapsed.value = DateTime.now().difference(startedAt);
|
||||
}
|
||||
|
||||
updateElapsed();
|
||||
final timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
updateElapsed();
|
||||
});
|
||||
return timer.cancel;
|
||||
}, [deviceState.phase, deviceState.startedAt]);
|
||||
|
||||
String formatElapsed(Duration elapsed) {
|
||||
final seconds = elapsed.inSeconds;
|
||||
final minutes = seconds ~/ 60;
|
||||
final remainingSeconds = seconds % 60;
|
||||
return '$minutes:${remainingSeconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
@@ -1445,20 +1473,46 @@ class _DeviceKeyCard extends HookConsumerWidget {
|
||||
style: TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
shortened,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
if (deviceState.phase == DeviceStatePhase.bootstrapping) ...[
|
||||
const Text('Preparing device backup…'),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
formatElapsed(powElapsed.value),
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
] else if (deviceState.isReady)
|
||||
Text(
|
||||
shortened,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Device backup failed: ${deviceState.error}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: isCopying.value
|
||||
onPressed: !deviceState.isReady || isCopying.value
|
||||
? null
|
||||
: () async {
|
||||
final shouldCopy = await showDialog<bool>(
|
||||
@@ -1504,13 +1558,7 @@ class _DeviceKeyCard extends HookConsumerWidget {
|
||||
isCopying.value = false;
|
||||
}
|
||||
},
|
||||
icon: isCopying.value
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.copy, size: 18),
|
||||
icon: const Icon(Icons.copy, size: 18),
|
||||
label: const Text('Copy nsec'),
|
||||
),
|
||||
],
|
||||
@@ -1525,6 +1573,7 @@ class _BackgroundAutoUpdatesToggle extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settingsAsync = ref.watch(localSettingsProvider);
|
||||
final ready = ref.watch(deviceStateProvider).isReady;
|
||||
final enabled =
|
||||
settingsAsync.valueOrNull?.backgroundAutoUpdatesEnabled ?? false;
|
||||
|
||||
@@ -1543,61 +1592,65 @@ class _BackgroundAutoUpdatesToggle extends ConsumerWidget {
|
||||
title: const Text('Background auto-updates'),
|
||||
value: enabled,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) async {
|
||||
if (value && !enabled) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Turn on background auto-updates?'),
|
||||
content: const Text(
|
||||
'Zapstore will check for updates and apply them in the '
|
||||
'background. The first check will start immediately when '
|
||||
'Wi-Fi is available. After that, checks run approximately '
|
||||
'every 24 hours. You can turn this off at any time.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Turn on'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(settingsServiceProvider)
|
||||
.update((s) => s.copyWith(backgroundAutoUpdatesEnabled: value));
|
||||
ref.invalidate(localSettingsProvider);
|
||||
|
||||
if (value) {
|
||||
unawaited(() async {
|
||||
try {
|
||||
await ref
|
||||
.read(backgroundUpdateServiceProvider)
|
||||
.scheduleImmediateAutoUpdate();
|
||||
} catch (error, stack) {
|
||||
app_logs.LogService.I.warn(
|
||||
'initial background auto-update scheduling failed',
|
||||
tag: 'background_updates',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.showError(
|
||||
'Background auto-updates are enabled, but the first check '
|
||||
'could not be scheduled.',
|
||||
onChanged: !ready
|
||||
? null
|
||||
: (value) async {
|
||||
if (value && !enabled) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Turn on background auto-updates?'),
|
||||
content: const Text(
|
||||
'Zapstore will check for updates and apply them in the '
|
||||
'background. The first check will start immediately when '
|
||||
'Wi-Fi is available. After that, checks run approximately '
|
||||
'every 24 hours. You can turn this off at any time.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Turn on'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
}
|
||||
}
|
||||
}());
|
||||
}
|
||||
},
|
||||
|
||||
await ref
|
||||
.read(deviceStateProvider.notifier)
|
||||
.updatePortable(
|
||||
(s) => s.copyWith(backgroundAutoUpdatesEnabled: value),
|
||||
);
|
||||
ref.invalidate(localSettingsProvider);
|
||||
|
||||
if (value) {
|
||||
unawaited(() async {
|
||||
try {
|
||||
await ref
|
||||
.read(backgroundUpdateServiceProvider)
|
||||
.scheduleImmediateAutoUpdate();
|
||||
} catch (error, stack) {
|
||||
app_logs.LogService.I.warn(
|
||||
'initial background auto-update scheduling failed',
|
||||
tag: 'background_updates',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
if (context.mounted) {
|
||||
context.showError(
|
||||
'Background auto-updates are enabled, but the first check '
|
||||
'could not be scheduled.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1609,6 +1662,7 @@ class _InstalledAppsBackupToggle extends ConsumerWidget {
|
||||
if (devicePubkey == null) return const SizedBox.shrink();
|
||||
|
||||
final settingsAsync = ref.watch(localSettingsProvider);
|
||||
final ready = ref.watch(deviceStateProvider).isReady;
|
||||
final enabled =
|
||||
settingsAsync.valueOrNull?.installedAppsBackupEnabled ?? false;
|
||||
|
||||
@@ -1627,12 +1681,16 @@ class _InstalledAppsBackupToggle extends ConsumerWidget {
|
||||
title: const Text('Back up installed apps'),
|
||||
value: enabled,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) async {
|
||||
await ref
|
||||
.read(settingsServiceProvider)
|
||||
.update((s) => s.copyWith(installedAppsBackupEnabled: value));
|
||||
ref.invalidate(localSettingsProvider);
|
||||
},
|
||||
onChanged: !ready
|
||||
? null
|
||||
: (value) async {
|
||||
await ref
|
||||
.read(deviceStateProvider.notifier)
|
||||
.updatePortable(
|
||||
(s) => s.copyWith(installedAppsBackupEnabled: value),
|
||||
);
|
||||
ref.invalidate(localSettingsProvider);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,505 +1,297 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/constants/app_constants.dart';
|
||||
import 'package:zapstore/main.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/device_state_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/services/settings_service.dart';
|
||||
import 'package:zapstore/widgets/device_backup_dialog.dart';
|
||||
import 'package:zapstore/widgets/device_restore_dialog.dart';
|
||||
import 'package:zapstore/widgets/legacy_installed_apps_dialog.dart';
|
||||
|
||||
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-signed settings recovery and legacy Amber stack migration.
|
||||
/// Backs up the device key to the active Amber identity and restores it again.
|
||||
///
|
||||
/// The relay record is authored by Amber, unlike portable device state which is
|
||||
/// always authored by the recovered device key.
|
||||
class DeviceBackupService {
|
||||
final Set<Request<Model<dynamic>>> _activeRequests = {};
|
||||
bool _cancelled = false;
|
||||
/// Retained as a lifecycle hook for callers from the previous recovery
|
||||
/// implementation. Device-key backup has no long-lived foreground request.
|
||||
void beginWork() {}
|
||||
|
||||
void beginWork() => _cancelled = false;
|
||||
/// Device-key backup publishes are short-lived and do not own a cancellable
|
||||
/// request. Bootstrap mining is owned by DeviceStateNotifier.
|
||||
void cancelCurrentWork(Ref ref) {}
|
||||
|
||||
void _checkCancelled() {
|
||||
if (_cancelled) throw const DeviceBackupCancelled();
|
||||
}
|
||||
|
||||
Future<CustomData?> fetchExistingSettings(
|
||||
Ref ref,
|
||||
String devicePubkey,
|
||||
) async {
|
||||
final results = await _queryTracked(
|
||||
ref,
|
||||
RequestFilter<CustomData>(
|
||||
authors: {devicePubkey},
|
||||
tags: {
|
||||
'#d': {kSettingsIdentifier},
|
||||
},
|
||||
limit: 1,
|
||||
).toRequest(),
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'app-device-settings-upsert',
|
||||
);
|
||||
return results.firstOrNull;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates.values.toList(growable: false);
|
||||
}
|
||||
|
||||
/// Upserts a recovery capsule for the active Amber identity.
|
||||
Future<void> backupDeviceKey({
|
||||
required Ref ref,
|
||||
required Signer amberSigner,
|
||||
}) async {
|
||||
_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();
|
||||
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(),
|
||||
}),
|
||||
final privateKeyHex = await ref
|
||||
.read(deviceKeyServiceProvider)
|
||||
.getOrCreatePrivateKey();
|
||||
final ciphertext = await amberSigner.nip44Encrypt(
|
||||
jsonEncode({'privateKeyHex': privateKeyHex}),
|
||||
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: jsonEncode(envelope),
|
||||
);
|
||||
for (final recovery in recoveries) {
|
||||
final pubkey = recovery['p'];
|
||||
if (pubkey is String) partial.event.addTagValue('p', pubkey);
|
||||
final signed = await PartialCustomData(
|
||||
identifier: kDeviceKeyBackupIdentifier,
|
||||
content: ciphertext,
|
||||
).signWith(amberSigner);
|
||||
if (!verifySignedEvent(ref, signed.event)) {
|
||||
throw const DeviceBackupException('Could not verify Amber key backup.');
|
||||
}
|
||||
final storage = ref.read(storageNotifierProvider.notifier);
|
||||
final saved = await storage.save({signed});
|
||||
if (!saved) {
|
||||
throw const DeviceBackupException(
|
||||
'Could not save Amber key backup locally.',
|
||||
);
|
||||
}
|
||||
final response = await storage.publish({signed}, relays: 'AppCatalog');
|
||||
final accepted =
|
||||
response.results[signed.event.id]?.any((result) => result.accepted) ??
|
||||
false;
|
||||
if (!accepted) {
|
||||
throw const DeviceBackupException(
|
||||
'Saved locally, but no AppCatalog relay accepted the Amber key backup.',
|
||||
);
|
||||
}
|
||||
partial.event.setTagValue('format', 'zapstore-device-settings-v2');
|
||||
partial.event.createdAt = privateEvents.nextReplaceableTimestamp(
|
||||
existing?.createdAt,
|
||||
);
|
||||
await privateEvents.signAndSave(partial);
|
||||
}
|
||||
|
||||
/// Merges every Amber-authored encrypted AppStack into device ownership.
|
||||
Future<bool> migratePrivateStacksToDeviceKey({
|
||||
Future<String?> fetchAmberBackup({
|
||||
required Ref ref,
|
||||
required Signer amberSigner,
|
||||
}) async {
|
||||
_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 privateEvents = ref.read(devicePrivateEventServiceProvider);
|
||||
final storage = ref.read(storageNotifierProvider.notifier);
|
||||
final platform = ref.read(packageManagerProvider.notifier).platform;
|
||||
var migratedAny = false;
|
||||
|
||||
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},
|
||||
final results = await ref
|
||||
.read(storageNotifierProvider.notifier)
|
||||
.query(
|
||||
RequestFilter<CustomData>(
|
||||
authors: {amberSigner.pubkey},
|
||||
tags: {
|
||||
'#d': {kDeviceKeyBackupIdentifier},
|
||||
},
|
||||
limit: 1,
|
||||
).toRequest(),
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: 'AppCatalog',
|
||||
stream: false,
|
||||
),
|
||||
subscriptionPrefix: 'app-device-key-backup',
|
||||
);
|
||||
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: {privateEvents.devicePubkey},
|
||||
tags: {
|
||||
'#d': {identifier},
|
||||
},
|
||||
limit: 1,
|
||||
).toRequest(),
|
||||
source: const LocalSource(),
|
||||
subscriptionPrefix: 'app-private-stack-migration-device',
|
||||
)).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;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
description: stack.description,
|
||||
apps: merged.toList(growable: false),
|
||||
platform: stack.platform ?? platform,
|
||||
);
|
||||
partial.event.createdAt = privateEvents.nextReplaceableTimestamp(
|
||||
existing?.createdAt,
|
||||
);
|
||||
await privateEvents.signAndSave(partial);
|
||||
migratedAny = true;
|
||||
final backup = results.firstOrNull;
|
||||
if (backup == null ||
|
||||
backup.pubkey != amberSigner.pubkey ||
|
||||
!verifySignedEvent(ref, backup.event)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (migratedAny) {
|
||||
LogService.I.info(
|
||||
'migrated Amber private stacks to device key',
|
||||
tag: 'backup',
|
||||
try {
|
||||
final plaintext = await amberSigner.nip44Decrypt(
|
||||
backup.content,
|
||||
amberSigner.pubkey,
|
||||
);
|
||||
final decoded = jsonDecode(plaintext);
|
||||
if (decoded is! Map) return null;
|
||||
final privateKeyHex = decoded['privateKeyHex'];
|
||||
if (privateKeyHex is! String ||
|
||||
privateKeyHex.length != 64 ||
|
||||
Utils.derivePublicKey(privateKeyHex).isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return privateKeyHex;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return migratedAny;
|
||||
}
|
||||
|
||||
Future<void> restoreDeviceKey({
|
||||
required Ref ref,
|
||||
required String privateKeyHex,
|
||||
}) async {
|
||||
if (privateKeyHex.length != 64) {
|
||||
throw const DeviceBackupException(
|
||||
'Device key must be 64 hexadecimal characters.',
|
||||
);
|
||||
}
|
||||
final pubkey = Utils.derivePublicKey(privateKeyHex);
|
||||
await ref.read(deviceKeyServiceProvider).replacePrivateKey(privateKeyHex);
|
||||
final restoredSigner = Bip340PrivateKeySigner(privateKeyHex, ref);
|
||||
await restoredSigner.signIn(setAsActive: false);
|
||||
ref.read(devicePubkeyProvider.notifier).state = restoredSigner.pubkey;
|
||||
final signer = Bip340PrivateKeySigner(privateKeyHex, ref);
|
||||
await signer.signIn(setAsActive: false);
|
||||
ref.read(devicePubkeyProvider.notifier).state = pubkey;
|
||||
}
|
||||
|
||||
Future<List<E>> _queryTracked<E extends Model<dynamic>>(
|
||||
Ref ref,
|
||||
Request<E> request, {
|
||||
required Source source,
|
||||
required String subscriptionPrefix,
|
||||
Future<List<String>> fetchLegacyInstalledAppIds({
|
||||
required Ref ref,
|
||||
required Signer amberSigner,
|
||||
}) async {
|
||||
_checkCancelled();
|
||||
_activeRequests.add(request);
|
||||
final results = await ref
|
||||
.read(storageNotifierProvider.notifier)
|
||||
.query(
|
||||
RequestFilter<AppStack>(
|
||||
authors: {amberSigner.pubkey},
|
||||
tags: {
|
||||
'#d': {kInstalledAppsIdentifier},
|
||||
},
|
||||
limit: 1,
|
||||
).toRequest(),
|
||||
source: const LocalAndRemoteSource(
|
||||
relays: 'AppCatalog',
|
||||
stream: false,
|
||||
),
|
||||
subscriptionPrefix: 'app-legacy-installed-recovery',
|
||||
);
|
||||
final stack = results.firstOrNull;
|
||||
if (stack == null || !verifySignedEvent(ref, stack.event)) return const [];
|
||||
try {
|
||||
final results = await ref
|
||||
.read(storageNotifierProvider.notifier)
|
||||
.query(
|
||||
request,
|
||||
source: source,
|
||||
subscriptionPrefix: subscriptionPrefix,
|
||||
);
|
||||
_checkCancelled();
|
||||
return results;
|
||||
} finally {
|
||||
_activeRequests.remove(request);
|
||||
final plaintext = await amberSigner.nip44Decrypt(
|
||||
stack.content,
|
||||
amberSigner.pubkey,
|
||||
);
|
||||
final decoded = jsonDecode(plaintext);
|
||||
return decoded is List
|
||||
? decoded
|
||||
.whereType<String>()
|
||||
.where((id) => id.startsWith('32267:'))
|
||||
.toList()
|
||||
: const [];
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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>>[],
|
||||
};
|
||||
/// Runs the one-time recovery choice after the navigator overlay is available.
|
||||
Future<void> maybeOfferInitialDeviceRestore(Ref ref) async {
|
||||
final settings = ref.read(settingsServiceProvider);
|
||||
final temp = await settings.loadTemp();
|
||||
if (temp.restoreOnboardingComplete) return;
|
||||
final context = rootNavigatorKey.currentState?.overlay?.context;
|
||||
if (context == null || !context.mounted) return;
|
||||
|
||||
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;
|
||||
final result = await showDialog<DeviceRestoreResult>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const DeviceRestoreDialog(),
|
||||
);
|
||||
if (result == null) return;
|
||||
|
||||
switch (result.action) {
|
||||
case DeviceRestoreAction.startFresh:
|
||||
await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true));
|
||||
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
|
||||
break;
|
||||
case DeviceRestoreAction.pasteKey:
|
||||
final privateKeyHex = ref
|
||||
.read(deviceKeyServiceProvider)
|
||||
.parsePrivateKey(result.key ?? '');
|
||||
if (privateKeyHex == null) {
|
||||
LogService.I.warn('invalid pasted device key', tag: 'backup');
|
||||
return;
|
||||
}
|
||||
await ref
|
||||
.read(deviceBackupServiceProvider)
|
||||
.restoreDeviceKey(ref: ref, privateKeyHex: privateKeyHex);
|
||||
await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey();
|
||||
final restored = await ref
|
||||
.read(deviceStateProvider.notifier)
|
||||
.restoreFromLocalEvent();
|
||||
if (!restored) {
|
||||
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
|
||||
}
|
||||
await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true));
|
||||
break;
|
||||
case DeviceRestoreAction.amber:
|
||||
await ref.read(amberSignerProvider).signIn();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
_kLegacyUnmanagedAppsIdentifier => kUnmanagedAppsIdentifier,
|
||||
_ => identifier,
|
||||
};
|
||||
}
|
||||
|
||||
class DeviceBackupInfo {
|
||||
DeviceBackupInfo({
|
||||
required this.privateKeyHex,
|
||||
required this.deviceName,
|
||||
this.backedUpAt,
|
||||
});
|
||||
|
||||
final String privateKeyHex;
|
||||
final String deviceName;
|
||||
final DateTime? backedUpAt;
|
||||
}
|
||||
|
||||
final class DeviceBackupCancelled implements Exception {
|
||||
const DeviceBackupCancelled();
|
||||
}
|
||||
|
||||
/// Runs recovery, migration, and backup after every successful Amber sign-in.
|
||||
/// Offers Amber recovery only once per fresh local installation.
|
||||
Future<void> maybeOfferDeviceBackup(Ref ref) async {
|
||||
final amberPubkey = ref.read(Signer.activePubkeyProvider);
|
||||
final amberSigner = ref.read(Signer.activeSignerProvider);
|
||||
if (amberPubkey == null || amberSigner == null) return;
|
||||
if (amberSigner == null) return;
|
||||
|
||||
final keyService = ref.read(deviceKeyServiceProvider);
|
||||
final settings = ref.read(settingsServiceProvider);
|
||||
final temp = await settings.loadTemp();
|
||||
final service = ref.read(deviceBackupServiceProvider);
|
||||
|
||||
try {
|
||||
final alreadyOffered = await keyService.hasBackupBeenOffered(amberPubkey);
|
||||
List<DeviceBackupInfo> candidates = const [];
|
||||
try {
|
||||
candidates = await service.fetchRecoveryCandidates(
|
||||
if (!temp.restoreOnboardingComplete) {
|
||||
final privateKeyHex = await service.fetchAmberBackup(
|
||||
ref: ref,
|
||||
amberSigner: amberSigner,
|
||||
);
|
||||
} catch (error, stack) {
|
||||
LogService.I.warn(
|
||||
'device recovery query failed',
|
||||
tag: 'backup',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
if (privateKeyHex != null) {
|
||||
final context = rootNavigatorKey.currentState?.overlay?.context;
|
||||
if (context != null && context.mounted) {
|
||||
final restore = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => DeviceBackupRestoreDialog(
|
||||
onRestore: () => Navigator.of(context).pop(true),
|
||||
onKeepCurrent: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
);
|
||||
await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey();
|
||||
if (restore == true) {
|
||||
await service.restoreDeviceKey(
|
||||
ref: ref,
|
||||
privateKeyHex: privateKeyHex,
|
||||
);
|
||||
await ref
|
||||
.read(devicePrivateSyncProvider.notifier)
|
||||
.syncRestoredKey();
|
||||
if (!ref.read(deviceStateProvider).isReady) {
|
||||
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
final legacyInstalledApps = await service.fetchLegacyInstalledAppIds(
|
||||
ref: ref,
|
||||
amberSigner: amberSigner,
|
||||
);
|
||||
if (legacyInstalledApps.isNotEmpty) {
|
||||
final context = rootNavigatorKey.currentState?.overlay?.context;
|
||||
if (context != null && context.mounted) {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
LegacyInstalledAppsDialog(appIds: legacyInstalledApps),
|
||||
);
|
||||
}
|
||||
}
|
||||
await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true));
|
||||
}
|
||||
|
||||
await service.migratePrivateStacksToDeviceKey(
|
||||
ref: ref,
|
||||
amberSigner: amberSigner,
|
||||
);
|
||||
await ref.read(trustServiceProvider).migrateLegacyAmberRecord(amberSigner);
|
||||
await service.backupDeviceKey(ref: ref, amberSigner: amberSigner);
|
||||
if (!alreadyOffered) {
|
||||
await keyService.markBackupOffered(amberPubkey);
|
||||
if (!ref.read(deviceStateProvider).isReady) {
|
||||
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
|
||||
}
|
||||
} catch (error, stack) {
|
||||
LogService.I.warn(
|
||||
'device recovery/migration/backup failed',
|
||||
'device key backup or recovery failed',
|
||||
tag: 'backup',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class DeviceBackupException implements Exception {
|
||||
const DeviceBackupException(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:models/models.dart';
|
||||
@@ -9,39 +7,18 @@ const _storage = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
const _kSecurePrefsKey = 'zapstore_secure_prefs';
|
||||
const _kDeviceKey = 'device_key';
|
||||
|
||||
/// Manages all device-local secrets in a single secure storage entry.
|
||||
///
|
||||
/// Stored as a JSON object with keys:
|
||||
/// - `nsec`: device private key (hex)
|
||||
/// - `backup_offered`: list of Amber pubkeys already offered backup dialog
|
||||
/// - `private_stacks_migrated`: amber/device pubkey pairs already migrated
|
||||
/// Manages the device private key. The nsec is intentionally isolated from
|
||||
/// portable settings and temporary local state.
|
||||
class DeviceKeyService {
|
||||
Map<String, dynamic>? _cache;
|
||||
|
||||
Future<Map<String, dynamic>> _load() async {
|
||||
if (_cache != null) return _cache!;
|
||||
final raw = await _storage.read(key: _kSecurePrefsKey);
|
||||
_cache = (raw != null && raw.isNotEmpty)
|
||||
? jsonDecode(raw) as Map<String, dynamic>
|
||||
: <String, dynamic>{};
|
||||
return _cache!;
|
||||
}
|
||||
|
||||
Future<void> _persist() async {
|
||||
await _storage.write(key: _kSecurePrefsKey, value: jsonEncode(_cache));
|
||||
}
|
||||
|
||||
/// Load existing device key or generate a new one. Returns hex private key.
|
||||
Future<String> getOrCreatePrivateKey() async {
|
||||
final prefs = await _load();
|
||||
final existing = prefs['nsec'] as String?;
|
||||
final existing = await _storage.read(key: _kDeviceKey);
|
||||
if (existing != null && existing.isNotEmpty) return existing;
|
||||
|
||||
final privateKeyHex = Utils.generateRandomHex64();
|
||||
prefs['nsec'] = privateKeyHex;
|
||||
await _persist();
|
||||
await _storage.write(key: _kDeviceKey, value: privateKeyHex);
|
||||
return privateKeyHex;
|
||||
}
|
||||
|
||||
@@ -53,58 +30,81 @@ class DeviceKeyService {
|
||||
|
||||
/// Replace the current device key (used during restore from backup).
|
||||
Future<void> replacePrivateKey(String privateKeyHex) async {
|
||||
final prefs = await _load();
|
||||
prefs['nsec'] = privateKeyHex;
|
||||
await _persist();
|
||||
await _storage.write(key: _kDeviceKey, value: privateKeyHex);
|
||||
}
|
||||
|
||||
/// Whether the backup/restore dialog has been offered for [pubkey].
|
||||
Future<bool> hasBackupBeenOffered(String pubkey) async {
|
||||
final prefs = await _load();
|
||||
final list = (prefs['backup_offered'] as List?)?.cast<String>() ?? [];
|
||||
return list.contains(pubkey);
|
||||
}
|
||||
|
||||
/// Mark that the backup dialog was shown for [pubkey].
|
||||
Future<void> markBackupOffered(String pubkey) async {
|
||||
final prefs = await _load();
|
||||
final list = (prefs['backup_offered'] as List?)?.cast<String>() ?? [];
|
||||
if (!list.contains(pubkey)) {
|
||||
list.add(pubkey);
|
||||
prefs['backup_offered'] = list;
|
||||
await _persist();
|
||||
/// Parses either the internal 64-character hex form or a copied `nsec`.
|
||||
String? parsePrivateKey(String value) {
|
||||
final input = value.trim().replaceFirst(
|
||||
RegExp(r'^nostr:', caseSensitive: false),
|
||||
'',
|
||||
);
|
||||
if (RegExp(r'^[0-9a-fA-F]{64}$').hasMatch(input)) {
|
||||
return input.toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether Amber-authored private stacks have been migrated to [devicePubkey].
|
||||
Future<bool> hasPrivateStacksMigrated(
|
||||
String amberPubkey,
|
||||
String devicePubkey,
|
||||
) async {
|
||||
final prefs = await _load();
|
||||
final list =
|
||||
(prefs['private_stacks_migrated'] as List?)?.cast<String>() ?? [];
|
||||
return list.contains(_migrationKey(amberPubkey, devicePubkey));
|
||||
}
|
||||
|
||||
/// Mark private stack migration complete for [devicePubkey].
|
||||
Future<void> markPrivateStacksMigrated(
|
||||
String amberPubkey,
|
||||
String devicePubkey,
|
||||
) async {
|
||||
final prefs = await _load();
|
||||
final list =
|
||||
(prefs['private_stacks_migrated'] as List?)?.cast<String>() ?? [];
|
||||
final key = _migrationKey(amberPubkey, devicePubkey);
|
||||
if (!list.contains(key)) {
|
||||
list.add(key);
|
||||
prefs['private_stacks_migrated'] = list;
|
||||
await _persist();
|
||||
if (!input.toLowerCase().startsWith('nsec1')) return null;
|
||||
final values = <int>[];
|
||||
const alphabet = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||||
for (final char in input.substring(5).toLowerCase().codeUnits) {
|
||||
final value = alphabet.indexOf(String.fromCharCode(char));
|
||||
if (value < 0) return null;
|
||||
values.add(value);
|
||||
}
|
||||
if (values.length < 6) return null;
|
||||
if (!_hasValidBech32Checksum('nsec', values)) return null;
|
||||
final payload = values.sublist(0, values.length - 6);
|
||||
final bytes = _convertBits(payload, from: 5, to: 8);
|
||||
if (bytes == null || bytes.length != 32) return null;
|
||||
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
String _migrationKey(String amberPubkey, String devicePubkey) =>
|
||||
'$amberPubkey:$devicePubkey';
|
||||
static List<int>? _convertBits(
|
||||
List<int> values, {
|
||||
required int from,
|
||||
required int to,
|
||||
}) {
|
||||
var accumulator = 0;
|
||||
var bits = 0;
|
||||
final output = <int>[];
|
||||
final maxValue = (1 << to) - 1;
|
||||
for (final value in values) {
|
||||
if (value < 0 || value >> from != 0) return null;
|
||||
accumulator = (accumulator << from) | value;
|
||||
bits += from;
|
||||
while (bits >= to) {
|
||||
bits -= to;
|
||||
output.add((accumulator >> bits) & maxValue);
|
||||
}
|
||||
}
|
||||
if (bits >= from || ((accumulator << (to - bits)) & maxValue) != 0) {
|
||||
return null;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static bool _hasValidBech32Checksum(String hrp, List<int> values) {
|
||||
const generators = [
|
||||
0x3b6a57b2,
|
||||
0x26508e6d,
|
||||
0x1ea119fa,
|
||||
0x3d4233dd,
|
||||
0x2a1462b3,
|
||||
];
|
||||
var checksum = 1;
|
||||
final expandedHrp = <int>[
|
||||
...hrp.codeUnits.map((codeUnit) => codeUnit >> 5),
|
||||
0,
|
||||
...hrp.codeUnits.map((codeUnit) => codeUnit & 31),
|
||||
];
|
||||
for (final value in [...expandedHrp, ...values]) {
|
||||
final top = checksum >> 25;
|
||||
checksum = (checksum & 0x1ffffff) << 5 ^ value;
|
||||
for (var i = 0; i < generators.length; i++) {
|
||||
if ((top >> i) & 1 == 1) checksum ^= generators[i];
|
||||
}
|
||||
}
|
||||
return checksum == 1;
|
||||
}
|
||||
}
|
||||
|
||||
final deviceKeyServiceProvider = Provider<DeviceKeyService>(
|
||||
|
||||
@@ -2,9 +2,9 @@ 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/device_state_service.dart';
|
||||
import 'package:zapstore/services/log_service.dart';
|
||||
|
||||
enum DevicePrivateSyncPhase { idle, syncing, success, error, cancelled }
|
||||
@@ -78,7 +78,7 @@ class DevicePrivateSyncNotifier extends StateNotifier<DevicePrivateSyncState> {
|
||||
subscriptionPrefix: 'app-device-private-boot',
|
||||
);
|
||||
if (_cancelled) return;
|
||||
await _upgradeLegacyProofs(request);
|
||||
await ref.read(deviceStateProvider.notifier).restoreFromLocalEvent();
|
||||
if (_cancelled) return;
|
||||
state = const DevicePrivateSyncState(DevicePrivateSyncPhase.success);
|
||||
} catch (error, stack) {
|
||||
@@ -98,69 +98,6 @@ class DevicePrivateSyncNotifier extends StateNotifier<DevicePrivateSyncState> {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
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';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
|
||||
enum DeviceStatePhase { bootstrapping, ready, error }
|
||||
|
||||
class DeviceStateStatus {
|
||||
const DeviceStateStatus(this.phase, {this.error, this.startedAt});
|
||||
|
||||
const DeviceStateStatus.bootstrapping()
|
||||
: phase = DeviceStatePhase.bootstrapping,
|
||||
error = null,
|
||||
startedAt = null;
|
||||
|
||||
const DeviceStateStatus.ready()
|
||||
: phase = DeviceStatePhase.ready,
|
||||
error = null,
|
||||
startedAt = null;
|
||||
|
||||
final DeviceStatePhase phase;
|
||||
final Object? error;
|
||||
final DateTime? startedAt;
|
||||
|
||||
bool get isReady => phase == DeviceStatePhase.ready;
|
||||
}
|
||||
|
||||
/// Owns the portable settings snapshot and queues its relay synchronization.
|
||||
class DeviceStateNotifier extends StateNotifier<DeviceStateStatus> {
|
||||
DeviceStateNotifier(this.ref)
|
||||
: super(const DeviceStateStatus.bootstrapping());
|
||||
|
||||
final Ref ref;
|
||||
Future<void>? _bootstrapFuture;
|
||||
Future<void> _publishQueue = Future.value();
|
||||
bool _disposed = false;
|
||||
|
||||
Future<void> bootstrap() {
|
||||
final existing = _bootstrapFuture;
|
||||
if (existing != null) return existing;
|
||||
if (!_disposed) {
|
||||
state = DeviceStateStatus(
|
||||
DeviceStatePhase.bootstrapping,
|
||||
startedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
return _bootstrapFuture = _bootstrap();
|
||||
}
|
||||
|
||||
Future<void> _bootstrap() async {
|
||||
try {
|
||||
if (!_disposed) state = const DeviceStateStatus.ready();
|
||||
} catch (error, stack) {
|
||||
LogService.I.warn(
|
||||
'device-state bootstrap failed',
|
||||
tag: 'device-state',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
if (!_disposed) {
|
||||
state = DeviceStateStatus(DeviceStatePhase.error, error: error);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists first. Remote publishing never gates the local preference change.
|
||||
Future<void> updatePortable(
|
||||
PortableSettings Function(PortableSettings current) updater,
|
||||
) async {
|
||||
if (!state.isReady) return;
|
||||
final settingsService = ref.read(settingsServiceProvider);
|
||||
final updated = updater(await settingsService.loadPortable());
|
||||
await settingsService.savePortable(updated);
|
||||
|
||||
_publishQueue = _publishQueue.then((_) => _publish(settings: updated));
|
||||
unawaited(
|
||||
_publishQueue.catchError((error, stack) {
|
||||
LogService.I.warn(
|
||||
'device-state publish failed',
|
||||
tag: 'device-state',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> restoreFromLocalEvent() async {
|
||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
||||
if (devicePubkey == null) return false;
|
||||
final models = await ref
|
||||
.read(storageNotifierProvider.notifier)
|
||||
.query(
|
||||
RequestFilter<CustomData>(
|
||||
authors: {devicePubkey},
|
||||
tags: {
|
||||
'#d': {kDeviceStateIdentifier},
|
||||
},
|
||||
limit: 1,
|
||||
).toRequest(),
|
||||
source: const LocalSource(),
|
||||
subscriptionPrefix: 'app-device-state-local',
|
||||
);
|
||||
final event = models.firstOrNull;
|
||||
if (event == null || !verifySignedEvent(ref, event.event)) return false;
|
||||
try {
|
||||
final plaintext = await ref
|
||||
.read(devicePrivateEventServiceProvider)
|
||||
.decryptFromDevice(event.content);
|
||||
final decoded = jsonDecode(plaintext);
|
||||
if (decoded is! Map) return false;
|
||||
await ref
|
||||
.read(settingsServiceProvider)
|
||||
.savePortable(
|
||||
PortableSettings.fromJson(Map<String, dynamic>.from(decoded)),
|
||||
);
|
||||
if (!_disposed) state = const DeviceStateStatus.ready();
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _publish({required PortableSettings settings}) async {
|
||||
final privateEvents = ref.read(devicePrivateEventServiceProvider);
|
||||
final encrypted = await privateEvents.encryptToDevice(
|
||||
jsonEncode(settings.toJson()),
|
||||
);
|
||||
final partial = PartialCustomData(
|
||||
identifier: kDeviceStateIdentifier,
|
||||
content: encrypted,
|
||||
);
|
||||
await privateEvents.saveDraftAndQueue(partial);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
ref.read(devicePrivateEventServiceProvider).cancelMining();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
final deviceStateProvider =
|
||||
StateNotifierProvider<DeviceStateNotifier, DeviceStateStatus>(
|
||||
(ref) => DeviceStateNotifier(ref),
|
||||
);
|
||||
+183
-130
@@ -10,54 +10,122 @@ const _storage = FlutterSecureStorage(
|
||||
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
|
||||
);
|
||||
|
||||
/// All local settings stored as a single JSON blob in secure storage.
|
||||
class LocalSettings {
|
||||
final String? nwcConnectionString;
|
||||
final DateTime? lastAppOpened;
|
||||
final DateTime? seenUntil;
|
||||
final DateTime? deletionSyncedUntil;
|
||||
/// Portable preferences, mirrored by DeviceStateService.
|
||||
class PortableSettings {
|
||||
final bool installedAppsBackupEnabled;
|
||||
final bool backgroundAutoUpdatesEnabled;
|
||||
final LogLevel logLevel;
|
||||
final Set<String> trustedSigners;
|
||||
|
||||
const LocalSettings({
|
||||
this.nwcConnectionString,
|
||||
this.lastAppOpened,
|
||||
this.seenUntil,
|
||||
this.deletionSyncedUntil,
|
||||
const PortableSettings({
|
||||
this.installedAppsBackupEnabled = false,
|
||||
this.backgroundAutoUpdatesEnabled = false,
|
||||
this.logLevel = LogLevel.debug,
|
||||
this.trustedSigners = const {},
|
||||
});
|
||||
|
||||
bool get hasNwcString => nwcConnectionString?.isNotEmpty == true;
|
||||
|
||||
factory LocalSettings.fromJson(Map<String, dynamic> json) {
|
||||
return LocalSettings(
|
||||
nwcConnectionString: json['nwc'] as String?,
|
||||
lastAppOpened: _parseDateTime(json['lastAppOpened']),
|
||||
seenUntil: _parseDateTime(json['seenUntil']),
|
||||
deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']),
|
||||
installedAppsBackupEnabled: json['backupEnabled'] as bool? ?? false,
|
||||
factory PortableSettings.fromJson(Map<String, dynamic> json) {
|
||||
return PortableSettings(
|
||||
installedAppsBackupEnabled:
|
||||
json['installedAppsBackupEnabled'] as bool? ?? false,
|
||||
backgroundAutoUpdatesEnabled:
|
||||
json['backgroundAutoUpdates'] as bool? ?? false,
|
||||
logLevel: LogLevel.parse(json['logLevel'] as String?) ?? LogLevel.debug,
|
||||
json['backgroundAutoUpdatesEnabled'] as bool? ?? false,
|
||||
trustedSigners:
|
||||
(json['trustedSigners'] as List?)?.whereType<String>().toSet() ??
|
||||
const {},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (nwcConnectionString != null) 'nwc': nwcConnectionString,
|
||||
'installedAppsBackupEnabled': installedAppsBackupEnabled,
|
||||
'backgroundAutoUpdatesEnabled': backgroundAutoUpdatesEnabled,
|
||||
'trustedSigners': trustedSigners.toList()..sort(),
|
||||
};
|
||||
|
||||
PortableSettings copyWith({
|
||||
bool? installedAppsBackupEnabled,
|
||||
bool? backgroundAutoUpdatesEnabled,
|
||||
Set<String>? trustedSigners,
|
||||
}) => PortableSettings(
|
||||
installedAppsBackupEnabled:
|
||||
installedAppsBackupEnabled ?? this.installedAppsBackupEnabled,
|
||||
backgroundAutoUpdatesEnabled:
|
||||
backgroundAutoUpdatesEnabled ?? this.backgroundAutoUpdatesEnabled,
|
||||
trustedSigners: trustedSigners ?? this.trustedSigners,
|
||||
);
|
||||
}
|
||||
|
||||
/// Per-install operational values. This data is deliberately never backed up.
|
||||
class TempSettings {
|
||||
final DateTime? lastAppOpened;
|
||||
final DateTime? seenUntil;
|
||||
final DateTime? deletionSyncedUntil;
|
||||
final LogLevel logLevel;
|
||||
final bool restoreOnboardingComplete;
|
||||
|
||||
const TempSettings({
|
||||
this.lastAppOpened,
|
||||
this.seenUntil,
|
||||
this.deletionSyncedUntil,
|
||||
this.logLevel = LogLevel.debug,
|
||||
this.restoreOnboardingComplete = false,
|
||||
});
|
||||
|
||||
factory TempSettings.fromJson(Map<String, dynamic> json) => TempSettings(
|
||||
lastAppOpened: _parseDateTime(json['lastAppOpened']),
|
||||
seenUntil: _parseDateTime(json['seenUntil']),
|
||||
deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']),
|
||||
logLevel: LogLevel.parse(json['logLevel'] as String?) ?? LogLevel.debug,
|
||||
restoreOnboardingComplete:
|
||||
json['restoreOnboardingComplete'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (lastAppOpened != null)
|
||||
'lastAppOpened': lastAppOpened!.millisecondsSinceEpoch,
|
||||
if (seenUntil != null) 'seenUntil': seenUntil!.millisecondsSinceEpoch,
|
||||
if (deletionSyncedUntil != null)
|
||||
'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch,
|
||||
if (installedAppsBackupEnabled) 'backupEnabled': true,
|
||||
if (backgroundAutoUpdatesEnabled) 'backgroundAutoUpdates': true,
|
||||
// Only persist non-default value to keep blob small.
|
||||
if (logLevel != LogLevel.debug) 'logLevel': logLevel.name,
|
||||
if (restoreOnboardingComplete) 'restoreOnboardingComplete': true,
|
||||
};
|
||||
|
||||
TempSettings copyWith({
|
||||
DateTime? lastAppOpened,
|
||||
DateTime? seenUntil,
|
||||
DateTime? deletionSyncedUntil,
|
||||
LogLevel? logLevel,
|
||||
bool? restoreOnboardingComplete,
|
||||
}) => TempSettings(
|
||||
lastAppOpened: lastAppOpened ?? this.lastAppOpened,
|
||||
seenUntil: seenUntil ?? this.seenUntil,
|
||||
deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil,
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
restoreOnboardingComplete:
|
||||
restoreOnboardingComplete ?? this.restoreOnboardingComplete,
|
||||
);
|
||||
}
|
||||
|
||||
/// Compatibility view over the three deliberate local storage entries.
|
||||
class LocalSettings {
|
||||
final String? nwcConnectionString;
|
||||
final TempSettings temp;
|
||||
final PortableSettings portable;
|
||||
|
||||
const LocalSettings({
|
||||
this.nwcConnectionString,
|
||||
this.temp = const TempSettings(),
|
||||
this.portable = const PortableSettings(),
|
||||
});
|
||||
|
||||
DateTime? get lastAppOpened => temp.lastAppOpened;
|
||||
DateTime? get seenUntil => temp.seenUntil;
|
||||
DateTime? get deletionSyncedUntil => temp.deletionSyncedUntil;
|
||||
LogLevel get logLevel => temp.logLevel;
|
||||
bool get installedAppsBackupEnabled => portable.installedAppsBackupEnabled;
|
||||
bool get backgroundAutoUpdatesEnabled =>
|
||||
portable.backgroundAutoUpdatesEnabled;
|
||||
Set<String> get trustedSigners => portable.trustedSigners;
|
||||
bool get hasNwcString => nwcConnectionString?.isNotEmpty == true;
|
||||
|
||||
LocalSettings copyWith({
|
||||
String? nwcConnectionString,
|
||||
DateTime? lastAppOpened,
|
||||
@@ -65,130 +133,115 @@ class LocalSettings {
|
||||
DateTime? deletionSyncedUntil,
|
||||
bool? installedAppsBackupEnabled,
|
||||
bool? backgroundAutoUpdatesEnabled,
|
||||
Set<String>? trustedSigners,
|
||||
LogLevel? logLevel,
|
||||
bool? restoreOnboardingComplete,
|
||||
bool clearNwc = false,
|
||||
}) {
|
||||
return LocalSettings(
|
||||
nwcConnectionString: clearNwc
|
||||
? null
|
||||
: (nwcConnectionString ?? this.nwcConnectionString),
|
||||
lastAppOpened: lastAppOpened ?? this.lastAppOpened,
|
||||
seenUntil: seenUntil ?? this.seenUntil,
|
||||
deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil,
|
||||
installedAppsBackupEnabled:
|
||||
installedAppsBackupEnabled ?? this.installedAppsBackupEnabled,
|
||||
backgroundAutoUpdatesEnabled:
|
||||
backgroundAutoUpdatesEnabled ?? this.backgroundAutoUpdatesEnabled,
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? _parseDateTime(dynamic value) =>
|
||||
value is int ? DateTime.fromMillisecondsSinceEpoch(value) : null;
|
||||
}) => LocalSettings(
|
||||
nwcConnectionString: clearNwc
|
||||
? null
|
||||
: (nwcConnectionString ?? this.nwcConnectionString),
|
||||
portable: portable.copyWith(
|
||||
installedAppsBackupEnabled: installedAppsBackupEnabled,
|
||||
backgroundAutoUpdatesEnabled: backgroundAutoUpdatesEnabled,
|
||||
trustedSigners: trustedSigners,
|
||||
),
|
||||
temp: temp.copyWith(
|
||||
lastAppOpened: lastAppOpened,
|
||||
seenUntil: seenUntil,
|
||||
deletionSyncedUntil: deletionSyncedUntil,
|
||||
logLevel: logLevel,
|
||||
restoreOnboardingComplete: restoreOnboardingComplete,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Service for reading and writing local settings.
|
||||
class SettingsService {
|
||||
static const _key = 'settings';
|
||||
static const _discardedLegacyRelaysKey = 'app_catalog_relays';
|
||||
|
||||
// Legacy keys for migration
|
||||
static const _legacyNwcKey = 'nwc_connection_string';
|
||||
static const _legacyLastAppOpenedKey = 'last_app_opened';
|
||||
static const _legacySeenUntilKey = 'seen_until';
|
||||
static const _legacyDeletionSyncedUntilKey = 'deletion_synced_until';
|
||||
static const _legacyBackupKey = 'installed_apps_backup_enabled';
|
||||
static const settingsKey = 'settings';
|
||||
static const tempSettingsKey = 'temp_settings';
|
||||
static const nwcKey = 'nwc';
|
||||
|
||||
Future<LocalSettings> load() async {
|
||||
final json = await _storage.read(key: _key);
|
||||
if (json != null && json.isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(json) as Map<String, dynamic>;
|
||||
final settings = LocalSettings.fromJson(decoded);
|
||||
try {
|
||||
if (decoded.containsKey('relays')) {
|
||||
await save(settings);
|
||||
}
|
||||
await _storage.delete(key: _discardedLegacyRelaysKey);
|
||||
} catch (error, stack) {
|
||||
LogService.I.warn(
|
||||
'legacy relay settings cleanup failed',
|
||||
tag: 'settings',
|
||||
err: error,
|
||||
stack: stack,
|
||||
);
|
||||
}
|
||||
return settings;
|
||||
} catch (_) {
|
||||
return const LocalSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate from legacy format if present
|
||||
return _migrateFromLegacy();
|
||||
}
|
||||
|
||||
Future<LocalSettings> _migrateFromLegacy() async {
|
||||
final nwc = await _storage.read(key: _legacyNwcKey);
|
||||
final lastAppOpened = await _storage.read(key: _legacyLastAppOpenedKey);
|
||||
final seenUntil = await _storage.read(key: _legacySeenUntilKey);
|
||||
final deletionSynced = await _storage.read(
|
||||
key: _legacyDeletionSyncedUntilKey,
|
||||
);
|
||||
final backupEnabled = await _storage.read(key: _legacyBackupKey);
|
||||
|
||||
// No legacy data found
|
||||
if ([
|
||||
nwc,
|
||||
lastAppOpened,
|
||||
seenUntil,
|
||||
deletionSynced,
|
||||
backupEnabled,
|
||||
].every((v) => v == null || v.isEmpty)) {
|
||||
return const LocalSettings();
|
||||
}
|
||||
|
||||
final settings = LocalSettings(
|
||||
nwcConnectionString: (nwc?.isNotEmpty == true) ? nwc : null,
|
||||
lastAppOpened: _parseLegacyDateTime(lastAppOpened),
|
||||
seenUntil: _parseLegacyDateTime(seenUntil),
|
||||
deletionSyncedUntil: _parseLegacyDateTime(deletionSynced),
|
||||
installedAppsBackupEnabled: backupEnabled == 'true',
|
||||
);
|
||||
|
||||
// Save migrated settings and clean up legacy keys
|
||||
await save(settings);
|
||||
await Future.wait([
|
||||
_storage.delete(key: _legacyNwcKey),
|
||||
_storage.delete(key: _legacyLastAppOpenedKey),
|
||||
_storage.delete(key: _legacySeenUntilKey),
|
||||
_storage.delete(key: _legacyDeletionSyncedUntilKey),
|
||||
_storage.delete(key: _legacyBackupKey),
|
||||
final values = await Future.wait([
|
||||
_storage.read(key: settingsKey),
|
||||
_storage.read(key: tempSettingsKey),
|
||||
_storage.read(key: nwcKey),
|
||||
]);
|
||||
|
||||
return settings;
|
||||
return LocalSettings(
|
||||
portable: _decodePortable(values[0]),
|
||||
temp: _decodeTemp(values[1]),
|
||||
nwcConnectionString: values[2]?.isNotEmpty == true ? values[2] : null,
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? _parseLegacyDateTime(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
final ms = int.tryParse(value);
|
||||
return ms != null ? DateTime.fromMillisecondsSinceEpoch(ms) : null;
|
||||
}
|
||||
Future<PortableSettings> loadPortable() async =>
|
||||
_decodePortable(await _storage.read(key: settingsKey));
|
||||
|
||||
Future<TempSettings> loadTemp() async =>
|
||||
_decodeTemp(await _storage.read(key: tempSettingsKey));
|
||||
|
||||
Future<void> save(LocalSettings settings) async {
|
||||
await _storage.write(key: _key, value: jsonEncode(settings.toJson()));
|
||||
await Future.wait([
|
||||
_storage.write(
|
||||
key: settingsKey,
|
||||
value: jsonEncode(settings.portable.toJson()),
|
||||
),
|
||||
_storage.write(
|
||||
key: tempSettingsKey,
|
||||
value: jsonEncode(settings.temp.toJson()),
|
||||
),
|
||||
if (settings.nwcConnectionString?.isNotEmpty == true)
|
||||
_storage.write(key: nwcKey, value: settings.nwcConnectionString!)
|
||||
else
|
||||
_storage.delete(key: nwcKey),
|
||||
]);
|
||||
}
|
||||
|
||||
Future<void> savePortable(PortableSettings settings) =>
|
||||
_storage.write(key: settingsKey, value: jsonEncode(settings.toJson()));
|
||||
|
||||
Future<void> saveTemp(TempSettings settings) => _storage.write(
|
||||
key: tempSettingsKey,
|
||||
value: jsonEncode(settings.toJson()),
|
||||
);
|
||||
|
||||
Future<void> saveNwc(String? connectionString) =>
|
||||
connectionString?.isNotEmpty == true
|
||||
? _storage.write(key: nwcKey, value: connectionString!)
|
||||
: _storage.delete(key: nwcKey);
|
||||
|
||||
Future<LocalSettings> update(
|
||||
LocalSettings Function(LocalSettings) updater,
|
||||
) async {
|
||||
final current = await load();
|
||||
final updated = updater(current);
|
||||
final updated = updater(await load());
|
||||
await save(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
static PortableSettings _decodePortable(String? raw) {
|
||||
try {
|
||||
return raw == null || raw.isEmpty
|
||||
? const PortableSettings()
|
||||
: PortableSettings.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return const PortableSettings();
|
||||
}
|
||||
}
|
||||
|
||||
static TempSettings _decodeTemp(String? raw) {
|
||||
try {
|
||||
return raw == null || raw.isEmpty
|
||||
? const TempSettings()
|
||||
: TempSettings.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return const TempSettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? _parseDateTime(dynamic value) =>
|
||||
value is int ? DateTime.fromMillisecondsSinceEpoch(value) : null;
|
||||
|
||||
/// Persists the AmberSigner pubkey in secure storage.
|
||||
class SecureStoragePubkeyPersistence implements AmberPubkeyPersistence {
|
||||
static const _key = 'amber_pubkey';
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
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/device_state_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
|
||||
/// Simple helper service to manage trusted signers persisted via CustomData
|
||||
/// Trusted signers are persisted inside the portable device-state JSON.
|
||||
class TrustedSignersService {
|
||||
const TrustedSignersService(this.ref);
|
||||
|
||||
@@ -14,89 +10,24 @@ class TrustedSignersService {
|
||||
|
||||
/// Returns whether a signer pubkey is trusted by this device.
|
||||
Future<bool> isSignerTrusted(String signerPubkey) async {
|
||||
final trusted = await _loadTrustedSigners();
|
||||
return trusted.contains(signerPubkey);
|
||||
return (await ref.read(settingsServiceProvider).loadPortable())
|
||||
.trustedSigners
|
||||
.contains(signerPubkey);
|
||||
}
|
||||
|
||||
/// Adds a signer pubkey to the device-private trusted list.
|
||||
/// Adds a signer pubkey to the portable trusted list.
|
||||
Future<void> addTrustedSigner(String signerPubkey) async {
|
||||
final trusted = await _loadTrustedSigners();
|
||||
final trusted = {
|
||||
...(await ref.read(settingsServiceProvider).loadPortable())
|
||||
.trustedSigners,
|
||||
};
|
||||
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: encrypted,
|
||||
);
|
||||
await privateEvents.signAndSave(partial, publish: false);
|
||||
}
|
||||
|
||||
Future<Set<String>> _loadTrustedSigners() async {
|
||||
final devicePubkey = ref.read(devicePubkeyProvider);
|
||||
if (devicePubkey == null) return <String>{};
|
||||
|
||||
try {
|
||||
final request = Request<CustomData>([
|
||||
RequestFilter<CustomData>(
|
||||
authors: {devicePubkey},
|
||||
tags: {
|
||||
'#d': {kTrustedSignersIdentifier},
|
||||
},
|
||||
limit: 1,
|
||||
),
|
||||
]);
|
||||
|
||||
final storage = ref.read(storageNotifierProvider.notifier);
|
||||
final List<CustomData> models = await storage.query(
|
||||
request,
|
||||
source: const LocalSource(),
|
||||
);
|
||||
|
||||
if (models.isEmpty) return <String>{};
|
||||
final model = models.first;
|
||||
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();
|
||||
} catch (_) {
|
||||
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.
|
||||
}
|
||||
await ref
|
||||
.read(deviceStateProvider.notifier)
|
||||
.updatePortable(
|
||||
(settings) => settings.copyWith(trustedSigners: trusted),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,70 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:zapstore/services/device_backup_service.dart';
|
||||
import 'package:zapstore/widgets/common/base_dialog.dart';
|
||||
|
||||
/// Dialog shown on first Amber sign-in when other device backups are found.
|
||||
/// Offers to restore a device key from a previous device.
|
||||
class DeviceBackupRestoreDialog extends ConsumerWidget {
|
||||
const DeviceBackupRestoreDialog({super.key, required this.backups});
|
||||
/// Dialog shown when Amber can recover a previous device key.
|
||||
class DeviceBackupRestoreDialog extends StatelessWidget {
|
||||
const DeviceBackupRestoreDialog({
|
||||
super.key,
|
||||
required this.onRestore,
|
||||
required this.onKeepCurrent,
|
||||
});
|
||||
|
||||
final List<DeviceBackupInfo> backups;
|
||||
final VoidCallback onRestore;
|
||||
final VoidCallback onKeepCurrent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
return BaseDialog(
|
||||
title: const BaseDialogTitle('Restore Device Key'),
|
||||
titleIcon: const Icon(Icons.restore, size: 20),
|
||||
content: BaseDialogContent(
|
||||
children: [
|
||||
const Text(
|
||||
'Found device keys from other devices linked to this identity. '
|
||||
'Restore one to sync your bookmarks and settings.',
|
||||
'Amber has a device key backup. Restore it to recover your '
|
||||
'bookmarks and portable settings.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
...backups.map((info) => _BackupTile(
|
||||
info: info,
|
||||
onRestore: () {
|
||||
Navigator.pop(context, info);
|
||||
},
|
||||
)),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: onKeepCurrent,
|
||||
child: const Text('Keep current key'),
|
||||
),
|
||||
FilledButton(onPressed: onRestore, child: const Text('Restore')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackupTile extends StatelessWidget {
|
||||
const _BackupTile({required this.info, this.onRestore});
|
||||
|
||||
final DeviceBackupInfo info;
|
||||
final VoidCallback? onRestore;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
String? dateStr;
|
||||
if (info.backedUpAt != null) {
|
||||
final dt = info.backedUpAt!;
|
||||
dateStr =
|
||||
'${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.smartphone),
|
||||
title: Text(info.deviceName),
|
||||
subtitle: dateStr != null ? Text('Backed up: $dateStr') : null,
|
||||
trailing: FilledButton.tonal(
|
||||
onPressed: onRestore,
|
||||
child: const Text('Restore'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:zapstore/constants/app_constants.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
import 'package:zapstore/widgets/common/base_dialog.dart';
|
||||
|
||||
enum DeviceRestoreAction { startFresh, pasteKey, amber }
|
||||
|
||||
class DeviceRestoreResult {
|
||||
const DeviceRestoreResult(this.action, {this.key});
|
||||
|
||||
final DeviceRestoreAction action;
|
||||
final String? key;
|
||||
}
|
||||
|
||||
/// First-run choice for recovering an existing device identity.
|
||||
class DeviceRestoreDialog extends HookConsumerWidget {
|
||||
const DeviceRestoreDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = useTextEditingController();
|
||||
final amberInstalled = ref.watch(
|
||||
packageManagerProvider.select(
|
||||
(state) => state.installed.containsKey(kAmberPackageId),
|
||||
),
|
||||
);
|
||||
|
||||
return BaseDialog(
|
||||
title: const BaseDialogTitle('Restore device'),
|
||||
titleIcon: const Icon(Icons.restore, size: 20),
|
||||
content: BaseDialogContent(
|
||||
children: [
|
||||
const Text(
|
||||
'Restore your saved apps and portable settings with a copied '
|
||||
'device nsec or Amber.',
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Older local settings are not migrated. If you are updating, '
|
||||
'copy your old device nsec before continuing.',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Device nsec',
|
||||
hintText: 'nsec1…',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
final value = controller.text.trim();
|
||||
if (value.isEmpty) return;
|
||||
Navigator.pop(
|
||||
context,
|
||||
DeviceRestoreResult(DeviceRestoreAction.pasteKey, key: value),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.key),
|
||||
label: const Text('Restore pasted key'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
if (amberInstalled) {
|
||||
Navigator.pop(
|
||||
context,
|
||||
const DeviceRestoreResult(DeviceRestoreAction.amber),
|
||||
);
|
||||
} else {
|
||||
context.push('/profile/app/$kAmberNaddr');
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.login),
|
||||
label: Text(
|
||||
amberInstalled
|
||||
? 'Restore with Amber'
|
||||
: 'Install Amber to restore',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(
|
||||
context,
|
||||
const DeviceRestoreResult(DeviceRestoreAction.startFresh),
|
||||
),
|
||||
child: const Text('Start fresh'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/widgets/app_stack_container.dart';
|
||||
import 'package:zapstore/widgets/common/base_dialog.dart';
|
||||
import 'package:zapstore/widgets/install_button.dart';
|
||||
|
||||
/// Lets a restored device install apps from an Amber-era installed-app backup.
|
||||
class LegacyInstalledAppsDialog extends ConsumerWidget {
|
||||
const LegacyInstalledAppsDialog({super.key, required this.appIds});
|
||||
|
||||
final List<String> appIds;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final (:authors, :identifiers) = decomposeAddressableIds(appIds);
|
||||
final appsState = ref.watch(
|
||||
query<App>(
|
||||
authors: authors,
|
||||
tags: {'#d': identifiers},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
|
||||
subscriptionPrefix: 'app-legacy-installed-recovery',
|
||||
),
|
||||
);
|
||||
final apps = appsState.models.toList();
|
||||
|
||||
return BaseDialog(
|
||||
title: const BaseDialogTitle('Restore apps'),
|
||||
titleIcon: const Icon(Icons.restore, size: 20),
|
||||
content: BaseDialogContent(
|
||||
children: [
|
||||
Text(
|
||||
'Found ${appIds.length} apps from your previous device. '
|
||||
'Choose which ones to install.',
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (appsState is StorageLoading && apps.isEmpty)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (apps.isEmpty)
|
||||
const Text(
|
||||
'App details are unavailable right now. Try again later.',
|
||||
)
|
||||
else
|
||||
...apps.map(
|
||||
(app) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(app.name ?? app.identifier),
|
||||
trailing: InstallButton(app: app, compact: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Done'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,104 +2,110 @@
|
||||
|
||||
## Goal
|
||||
|
||||
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. 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.
|
||||
Provide one portable, encrypted device state that restores with either a copied
|
||||
device nsec or Amber, while keeping private app stacks in Purplebase and all
|
||||
device-only data local.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- 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)
|
||||
|
||||
## User-Visible Behavior
|
||||
|
||||
- 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 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
|
||||
- Migrating prior `zapstore-settings`, `trusted-signers`, or capsule data
|
||||
- Backing up NWC, operational state, or actual installed-package detection
|
||||
- Continuous live sync or changing public community stacks
|
||||
- A user-facing way to cancel bootstrap mining
|
||||
|
||||
## Data Model
|
||||
|
||||
- Device nsec: secure storage only (`zapstore_secure_prefs` JSON blob, field `nsec`, hex)
|
||||
- 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-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
|
||||
- Secure storage keys:
|
||||
- `settings`: lower-camel-case portable JSON: `backgroundAutoUpdatesEnabled`,
|
||||
`installedAppsBackupEnabled`, and `trustedSigners`.
|
||||
- `temp_settings`: lower-camel-case device-only JSON, including `logLevel`,
|
||||
`lastAppOpened`, `seenUntil`, `deletionSyncedUntil`, and restore onboarding.
|
||||
- `nwc`: local-only NWC connection string.
|
||||
- `device_key`: local device nsec in hex.
|
||||
- `amber_pubkey`: local Amber reconnect identity.
|
||||
- Device state: kind `30078`, `d=zapstore-device-state`, authored and signed by
|
||||
the device key; its content is the NIP-44 self-encrypted `settings` JSON.
|
||||
- Amber key backup: kind `30078`, `d=zapstore-device-key-backup`, authored and
|
||||
signed by Amber; its content is NIP-44 self-encrypted JSON with
|
||||
`privateKeyHex`.
|
||||
- Private Purplebase stacks remain kind `30267`, authored by the device key:
|
||||
`zapstore-bookmarks`, `zapstore-installed-apps`, and
|
||||
`zapstore-unmanaged-apps`.
|
||||
- The device-owned AppCatalog relay list is kind `10067` and has no `d` tag.
|
||||
- Purplebase stores the latest local-only, PoW-less draft for each pending
|
||||
device-key event. The drafts are never published to a relay.
|
||||
- Secure storage records each pending event's kind and, when present, `d` tag
|
||||
in a list namespaced by the device pubkey derived from the active device key.
|
||||
This small marker survives process restart and identifies the latest local
|
||||
draft that must be mined and published.
|
||||
- JSON uses lower camel case; Nostr tag values use kebab case.
|
||||
|
||||
## Signer Roles
|
||||
## User-Visible Behavior
|
||||
|
||||
- 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, 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.
|
||||
- A new device key does not publish an empty device-state event.
|
||||
- A persistable device-state or private-stack change first saves its latest
|
||||
local-only PoW-less draft and records a secure-storage pending marker.
|
||||
Local persistence completes without waiting for mining or relay acceptance.
|
||||
- The device-event queue rebuilds each marked draft as a 20-bit-PoW event in a
|
||||
background isolate. Private state and app stacks publish to AppCatalog;
|
||||
device relay lists publish to the bootstrap relay. Pending markers resume
|
||||
processing after process restart and when relay connectivity returns.
|
||||
- A fresh install offers paste nsec, restore with Amber, or start fresh.
|
||||
If Amber is unavailable, its option opens the Amber install page.
|
||||
- Pasted nsec or Amber recovery imports the device key, then fetches and
|
||||
decrypts device state and the three private stacks.
|
||||
- On Amber sign-in during restore onboarding, a non-empty legacy
|
||||
`zapstore-installed-apps` stack is offered as “Restore apps from previous
|
||||
device.” The user selects apps to install; it is never mistaken for packages
|
||||
installed on this device or overwritten with an empty stack.
|
||||
- SQLite remains a local-first cache. Offline restore and publishing show
|
||||
explicit retry/error states without preventing use of local data.
|
||||
|
||||
## Filtering Strategy
|
||||
## PoW and Lifecycle
|
||||
|
||||
- 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.
|
||||
- Every device-key event submitted to a relay requires 20-bit NIP-13 proof of
|
||||
work, including device-state events, encrypted private app stacks, and the
|
||||
device-owned AppCatalog relay list.
|
||||
Purplebase-local drafts are the sole exception and must never be published.
|
||||
- The Zapstore device-event queue uses secure-storage pending markers and
|
||||
Purplebase-local drafts. Relay failures retain the marker; replay is driven
|
||||
by app startup and connectivity/reconnection signals, never polling or
|
||||
artificial delays. A marker is cleared only after AppCatalog acceptance.
|
||||
- Mining has no timeout or user cancellation. It runs outside the Flutter UI
|
||||
isolate and is cancelled when its owning service/provider is disposed to
|
||||
avoid a lifecycle leak. Cancellation leaves the local mutation intact and
|
||||
the operation eligible to be re-enqueued.
|
||||
- The client verifies a restored event's signature and expected author; relay
|
||||
admission proof is not a client restore criterion.
|
||||
|
||||
## Edge Cases
|
||||
## Upgrade from 1.0.6
|
||||
|
||||
- Device key lost (app uninstalled without backup): data unrecoverable, fresh start
|
||||
- Amber uninstalled while backup dialog pending: backup deferred to next sign-in
|
||||
- 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.
|
||||
- The release intentionally starts fresh: it does not migrate any prior secure
|
||||
storage, device key, Amber identity, private settings event, trusted signers,
|
||||
recovery capsules, or migration flags.
|
||||
- Users lose NWC, both settings toggles, log level, update/notification and
|
||||
deletion cursors, trusted signers, old device nsec, and Amber reconnect state.
|
||||
- Existing device-owned stacks are recoverable only when the user exported the
|
||||
old nsec before updating. Existing Amber-installed-app stacks remain
|
||||
discoverable after Amber sign-in during restore onboarding.
|
||||
- Release notes and an in-app warning must explain the loss and tell users to
|
||||
copy their device nsec before updating.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Device key generated on first launch and persisted in secure storage
|
||||
- [ ] Device key survives SQLite clear / app restart
|
||||
- [ ] Bookmarks work without Amber sign-in
|
||||
- [ ] Unmanaged apps work without Amber sign-in
|
||||
- [ ] User can copy device nsec from profile screen
|
||||
- [ ] First Amber sign-in triggers backup/restore dialog
|
||||
- [ ] 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)
|
||||
|
||||
## Phases
|
||||
|
||||
- A: Device key generation + service + registration at boot + copy nsec UI
|
||||
- B: Migrate bookmarks/unmanaged/backup to device key (drop Amber requirement)
|
||||
- C: Amber backup/restore dialog + CustomData events
|
||||
- D: Remove appStackEventFilter, clean up sign-in gating in UI
|
||||
- [ ] A new device key publishes no empty bootstrap event
|
||||
- [ ] Every device-key event submitted to a relay has at least 20 bits of valid
|
||||
NIP-13 proof of work; local drafts are never published
|
||||
- [ ] Mining runs off the Flutter UI isolate and is lifecycle-safe
|
||||
- [ ] Pending kind-and-identifier markers survive app restart and retry from
|
||||
their latest Purplebase-local drafts on startup or reconnection without
|
||||
polling
|
||||
- [ ] Local state changes remain usable while proof mining or publishing is
|
||||
pending, cancelled, or failing
|
||||
- [ ] Portable settings and trusted signers restore from `zapstore-device-state`
|
||||
- [ ] NWC and all temp settings never appear in device-state
|
||||
- [ ] Pasted-nsec and Amber restore both recover the same device state
|
||||
- [ ] Amber backup contains only its self-encrypted `privateKeyHex`
|
||||
- [ ] Legacy installed-app recovery offers selected installs without claiming
|
||||
those apps are locally installed
|
||||
- [ ] New JSON and tag casing follows the defined convention
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# WORK-019 - Simplify Device Backup
|
||||
|
||||
**Feature:** FEAT-006-device-key.md
|
||||
**Status:** In Progress
|
||||
|
||||
## Tasks
|
||||
|
||||
- [x] 1. Replace legacy secure-storage layouts
|
||||
- Files: `lib/services/settings_service.dart`,
|
||||
`lib/services/device_key_service.dart`
|
||||
- Create the `settings`, `temp_settings`, `nwc`, `device_key`, and
|
||||
`amber_pubkey` layout; intentionally do not migrate existing values.
|
||||
- [x] 2. Create portable device-state persistence
|
||||
- Files: `lib/services/device_private_event_service.dart`,
|
||||
`lib/services/device_private_sync_service.dart`, new state service
|
||||
- Persist portable state locally, self-encrypt and publish
|
||||
`30078/zapstore-device-state`, and fold in trusted signers.
|
||||
- [x] 3. Implement bootstrap admission
|
||||
- Files: `lib/main.dart`, device-state/private-event services, device-key UI
|
||||
- Mine the first empty device-state event at 28-bit PoW in the background
|
||||
isolate; queue later private publications, disable settings until accepted,
|
||||
and cancel only on lifecycle disposal.
|
||||
- [x] 4. Replace capsule recovery with Amber key backup
|
||||
- Files: `lib/services/device_backup_service.dart`,
|
||||
`lib/widgets/device_backup_dialog.dart`, `lib/screens/profile_screen.dart`
|
||||
- Publish Amber-authored `30078/zapstore-device-key-backup`, restore via
|
||||
Amber or pasted nsec, and route absent Amber to its install page.
|
||||
- [x] 5. Add legacy installed-app recovery
|
||||
- Files: app restore UI, package/install orchestration, `temp_settings`
|
||||
- During incomplete restore onboarding and Amber sign-in, offer apps from a
|
||||
non-empty legacy Amber `zapstore-installed-apps` stack without treating
|
||||
them as installed or overwriting them with an empty device stack.
|
||||
- [x] 6. Remove obsolete paths
|
||||
- Delete capsule authorization, recovery-candidate, trusted-signers event,
|
||||
old settings-event, and migration-marker logic.
|
||||
- [x] 7. Add release warning and tests
|
||||
- Warn current users that all old secure storage is discarded and nsec must be
|
||||
copied before upgrade. Cover schema, restore, bootstrap, offline/error,
|
||||
lifecycle cancellation, Amber-install routing, and recovery-install UI.
|
||||
- [ ] 8. Self-review against INVARIANTS.md
|
||||
|
||||
## Test Coverage
|
||||
|
||||
| Scenario | Expected | Status |
|
||||
|----------|----------|--------|
|
||||
| New key bootstrap | Empty state is first accepted private event | [ ] |
|
||||
| Bootstrap pending | Settings disabled; local UI remains responsive | [ ] |
|
||||
| Bootstrap lifecycle disposal | Mining isolate is cancelled without leaking | [ ] |
|
||||
| Settings publish failure | Local portable state remains available; retry is explicit | [ ] |
|
||||
| Pasted nsec restore | Signature-verified state and stacks restore | [ ] |
|
||||
| Amber restore | Verified Amber backup imports the same nsec | [ ] |
|
||||
| Amber absent | Restore UI opens Amber install page | [ ] |
|
||||
| Legacy installed backup | User selects apps to install; none are falsely installed | [ ] |
|
||||
| NWC/temp exclusion | Neither is serialized to device state | [ ] |
|
||||
| 1.0.6 update | Old values are discarded after explicit warning | [ ] |
|
||||
|
||||
## Decisions
|
||||
|
||||
### 2026-07-14 - Fixed bootstrap proof
|
||||
|
||||
**Context:** Per-update proof mining makes normal settings changes expensive.
|
||||
**Options:** Mine every replacement; await a relay proof challenge; bootstrap once.
|
||||
**Decision:** Mine an empty device-state event at fixed 28-bit difficulty before
|
||||
any remote private write, then rely on relay admission and rate limiting.
|
||||
**Rationale:** It gives the relay a simple first-event cost while ordinary
|
||||
settings writes remain local-first and cheap.
|
||||
|
||||
### 2026-07-14 - No user cancellation or timeout
|
||||
|
||||
**Context:** Bootstrap is setup work, not an interruptible user operation.
|
||||
**Decision:** Show indefinite progress without timeout or a cancel button.
|
||||
**Rationale:** Avoids retry and partial-state UI complexity. The owning
|
||||
service/provider still cancels the isolate on disposal, as required for
|
||||
lifecycle safety.
|
||||
|
||||
## Spec Issues
|
||||
|
||||
_None_
|
||||
|
||||
## Progress Notes
|
||||
|
||||
**2026-07-14:** Contract rewritten for a clean-break migration. Relay changes
|
||||
are tracked separately; this work assumes it admits the first private event
|
||||
only when its NIP-13 difficulty is at least 28 bits.
|
||||
|
||||
**2026-07-14:** Implemented separate secure-storage entries, portable
|
||||
device-state persistence, 28-bit bootstrap admission, nsec parsing, and the
|
||||
Amber-authored key-backup record. Verification is blocked because the checked
|
||||
out workspace has no `../purplebase` path dependency.
|
||||
@@ -1,123 +1,9 @@
|
||||
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>()),
|
||||
);
|
||||
test('backup exceptions retain a user-safe message', () {
|
||||
const exception = DeviceBackupException('Amber backup was unavailable.');
|
||||
expect(exception.toString(), 'Amber backup was unavailable.');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/services/device_key_service.dart';
|
||||
|
||||
void main() {
|
||||
final service = DeviceKeyService();
|
||||
|
||||
test('parses a copied nsec into lower-case hex', () {
|
||||
const hex =
|
||||
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
expect(service.parsePrivateKey(bech32Encode('nsec', hex)), hex);
|
||||
});
|
||||
|
||||
test('rejects malformed nsec checksums', () {
|
||||
expect(service.parsePrivateKey('nsec1invalid'), isNull);
|
||||
});
|
||||
}
|
||||
@@ -1,33 +1,43 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:zapstore/services/log_service.dart';
|
||||
import 'package:zapstore/services/settings_service.dart';
|
||||
|
||||
void main() {
|
||||
group('LocalSettings', () {
|
||||
group('PortableSettings', () {
|
||||
test('backgroundAutoUpdatesEnabled defaults to false', () {
|
||||
const settings = LocalSettings();
|
||||
const settings = PortableSettings();
|
||||
expect(settings.backgroundAutoUpdatesEnabled, isFalse);
|
||||
});
|
||||
|
||||
test('round-trips backgroundAutoUpdatesEnabled in JSON', () {
|
||||
const settings = LocalSettings(backgroundAutoUpdatesEnabled: true);
|
||||
final restored = LocalSettings.fromJson(settings.toJson());
|
||||
const settings = PortableSettings(backgroundAutoUpdatesEnabled: true);
|
||||
final restored = PortableSettings.fromJson(settings.toJson());
|
||||
expect(restored.backgroundAutoUpdatesEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('copyWith toggles backgroundAutoUpdatesEnabled', () {
|
||||
const settings = LocalSettings();
|
||||
const settings = PortableSettings();
|
||||
final updated = settings.copyWith(backgroundAutoUpdatesEnabled: true);
|
||||
expect(updated.backgroundAutoUpdatesEnabled, isTrue);
|
||||
});
|
||||
|
||||
test('discards legacy relay settings', () {
|
||||
final restored = LocalSettings.fromJson({
|
||||
'relays': ['wss://legacy.example'],
|
||||
'logLevel': 'info',
|
||||
});
|
||||
test('uses lower camel case portable JSON keys', () {
|
||||
const settings = PortableSettings(
|
||||
installedAppsBackupEnabled: true,
|
||||
trustedSigners: {'a'},
|
||||
);
|
||||
|
||||
expect(restored.toJson(), isNot(contains('relays')));
|
||||
expect(restored.toJson()['logLevel'], 'info');
|
||||
expect(settings.toJson(), {
|
||||
'installedAppsBackupEnabled': true,
|
||||
'backgroundAutoUpdatesEnabled': false,
|
||||
'trustedSigners': ['a'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('temp settings keep log level out of portable data', () {
|
||||
const temp = TempSettings(logLevel: LogLevel.info);
|
||||
expect(temp.toJson()['logLevel'], 'info');
|
||||
expect(const PortableSettings().toJson(), isNot(contains('logLevel')));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user