Device key restore UX

This commit is contained in:
franzap
2026-07-16 16:12:28 -03:00
parent 7c6ea8c8dd
commit 57b3311dd6
8 changed files with 404 additions and 209 deletions
+4 -6
View File
@@ -400,9 +400,10 @@ final storageReadyProvider = FutureProvider<void>((ref) async {
// Initialize device key signer — must be registered before any encrypted
// queries fire, so EncryptableModel.prepareAfterLoading can find it.
final deviceKey = await ref
.read(deviceKeyServiceProvider)
.getOrCreatePrivateKey();
final deviceKeyService = ref.read(deviceKeyServiceProvider);
final hasExistingDeviceKey = await deviceKeyService.hasPrivateKey();
ref.read(isNewDeviceKeyProvider.notifier).state = !hasExistingDeviceKey;
final deviceKey = await deviceKeyService.getOrCreatePrivateKey();
final deviceSigner = Bip340PrivateKeySigner(deviceKey, ref);
await deviceSigner.signIn(setAsActive: false);
ref.read(devicePubkeyProvider.notifier).state = deviceSigner.pubkey;
@@ -447,9 +448,6 @@ 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());
});
});
+149 -51
View File
@@ -15,15 +15,19 @@ import 'package:zapstore/services/background_update_service.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:purplebase/purplebase.dart';
import 'package:zapstore/main.dart';
import 'package:zapstore/services/device_backup_service.dart';
import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/device_private_sync_service.dart';
import 'package:zapstore/services/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';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/debug_utils.dart';
import 'package:zapstore/utils/nostr_route.dart';
import 'package:zapstore/widgets/common/profile_identity_row.dart';
import 'package:zapstore/widgets/common/stack_link_card.dart';
import 'package:zapstore/widgets/device_restore_dialog.dart';
import 'package:zapstore/theme.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/widgets/common/note_parser.dart';
@@ -63,13 +67,13 @@ class ProfileScreen extends ConsumerWidget {
const SizedBox(height: 16),
// App Catalog Relay Management Section
const RelayManagementCard(),
// Data Management Section
const _DataManagementSection(),
const SizedBox(height: 16),
// Data Management Section
const _DataManagementSection(),
// App Catalog Relay Management Section
const RelayManagementCard(),
const SizedBox(height: 24),
@@ -1412,6 +1416,7 @@ class _DeviceKeyCard extends HookConsumerWidget {
final shortened =
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
final isCopying = useState(false);
final isRestoring = useState(false);
final powElapsed = useState(Duration.zero);
useEffect(() {
final startedAt = deviceState.startedAt;
@@ -1510,55 +1515,148 @@ class _DeviceKeyCard extends HookConsumerWidget {
],
),
),
TextButton.icon(
onPressed: !deviceState.isReady || isCopying.value
? null
: () async {
final shouldCopy = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Copy device private key?'),
content: const Text(
'This nsec controls your private Zapstore data, including saved apps '
'and unmanaged apps. Anyone with it can read and modify that data.',
),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
Column(
mainAxisSize: MainAxisSize.min,
children: [
TextButton.icon(
onPressed: !deviceState.isReady || isCopying.value
? null
: () async {
final shouldCopy = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Copy device private key?'),
content: const Text(
'This nsec controls your private Zapstore data, including saved apps '
'and unmanaged apps. Anyone with it can read and modify that data.',
),
actions: [
TextButton(
onPressed: () =>
Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () =>
Navigator.pop(dialogContext, true),
child: const Text('Copy nsec'),
),
],
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Copy nsec'),
),
],
),
);
if (shouldCopy != true) return;
isCopying.value = true;
try {
final nsec = await ref
.read(deviceKeyServiceProvider)
.getNsec();
await Clipboard.setData(ClipboardData(text: nsec));
if (context.mounted) {
context.showInfo('Device nsec copied');
}
} catch (e) {
if (context.mounted) {
context.showError(
'Could not copy device key',
technicalDetails: e.toString(),
);
}
} finally {
isCopying.value = false;
}
},
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copy nsec'),
if (shouldCopy != true) return;
isCopying.value = true;
try {
final nsec = await ref
.read(deviceKeyServiceProvider)
.getNsec();
await Clipboard.setData(ClipboardData(text: nsec));
if (context.mounted) {
context.showInfo('Device nsec copied');
}
} catch (e) {
if (context.mounted) {
context.showError(
'Could not copy device key',
technicalDetails: e.toString(),
);
}
} finally {
isCopying.value = false;
}
},
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copy nsec'),
),
TextButton.icon(
onPressed: isRestoring.value
? null
: () async {
final result = await showDialog<DeviceRestoreResult>(
context: context,
builder: (_) => const DeviceRestoreDialog(),
);
if (result == null || !context.mounted) return;
if (result.action == DeviceRestoreAction.amber) {
isRestoring.value = true;
try {
await ref
.read(deviceBackupServiceProvider)
.restoreFromAmber(ref: ref.read(refProvider));
if (context.mounted) {
context.showInfo('Device key restored');
}
} catch (error, stack) {
app_logs.LogService.I.warn(
'Amber device key restore failed',
tag: 'backup',
err: error,
stack: stack,
);
if (context.mounted) {
context.showError(
'Could not restore device key',
technicalDetails: error.toString(),
);
}
} finally {
isRestoring.value = false;
}
return;
}
final privateKeyHex = ref
.read(deviceKeyServiceProvider)
.parsePrivateKey(result.key ?? '');
if (privateKeyHex == null) {
context.showError('Enter a valid device nsec.');
return;
}
isRestoring.value = true;
try {
await ref
.read(deviceBackupServiceProvider)
.restoreDeviceKey(
ref: ref.read(refProvider),
privateKeyHex: privateKeyHex,
);
await ref
.read(devicePrivateSyncProvider.notifier)
.syncRestoredKey();
if (!ref.read(deviceStateProvider).isReady) {
unawaited(
ref
.read(deviceStateProvider.notifier)
.bootstrap(),
);
}
if (context.mounted) {
context.showInfo('Device key restored');
}
} catch (error, stack) {
app_logs.LogService.I.warn(
'device key restore failed',
tag: 'backup',
err: error,
stack: stack,
);
if (context.mounted) {
context.showError(
'Could not restore device key',
technicalDetails: error.toString(),
);
}
} finally {
isRestoring.value = false;
}
},
icon: const Icon(Icons.restore, size: 18),
label: const Text('Restore'),
),
],
),
],
),
+130 -46
View File
@@ -10,6 +10,7 @@ import '../widgets/latest_releases_container.dart';
import '../widgets/search_app_card.dart';
import '../utils/extensions.dart';
import '../main.dart';
import '../services/device_key_service.dart';
import '../services/package_manager/package_manager.dart';
/// Main search and app discovery screen
@@ -67,58 +68,71 @@ class SearchScreen extends HookConsumerWidget {
backgroundColor: Colors.transparent,
body: Column(
children: [
// Professional search bar with better spacing
// Sticky search bar (+ optional device-key reminder)
Container(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 18),
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
final hasText = value.text.isNotEmpty;
child: Column(
children: [
ValueListenableBuilder<TextEditingValue>(
valueListenable: searchController,
builder: (context, value, _) {
final hasText = value.text.isNotEmpty;
return SearchBar(
controller: searchController,
focusNode: searchFocusNode,
hintText: 'Search apps',
leading: Icon(
Icons.search_rounded,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
trailing: [
if (hasText)
IconButton(
icon: Icon(
Icons.clear_rounded,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
return SearchBar(
controller: searchController,
focusNode: searchFocusNode,
hintText: 'Search apps',
leading: Icon(
Icons.search_rounded,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
trailing: [
if (hasText)
IconButton(
icon: Icon(
Icons.clear_rounded,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.6),
),
onPressed: () {
searchController.clear();
searchQuery.value = '';
searchFocusNode.requestFocus();
},
tooltip: 'Clear search',
),
],
onSubmitted: performSearch,
elevation: WidgetStateProperty.all(0),
backgroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: 0.8),
),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
onPressed: () {
searchController.clear();
searchQuery.value = '';
searchFocusNode.requestFocus();
},
tooltip: 'Clear search',
),
],
onSubmitted: performSearch,
elevation: WidgetStateProperty.all(0),
backgroundColor: WidgetStateProperty.all(
Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: 0.8),
);
},
),
if (ref.watch(isNewDeviceKeyProvider)) ...[
const SizedBox(height: 12),
_NewDeviceKeyReminder(
onTap: () => context.go('/profile'),
onDismiss: () =>
ref.read(isNewDeviceKeyProvider.notifier).state = false,
),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
),
);
},
],
],
),
),
// Scrollable content
@@ -163,6 +177,76 @@ class SearchScreen extends HookConsumerWidget {
}
}
class _NewDeviceKeyReminder extends StatelessWidget {
const _NewDeviceKeyReminder({required this.onTap, required this.onDismiss});
final VoidCallback onTap;
final VoidCallback onDismiss;
@override
Widget build(BuildContext context) {
// Dark yellow (hue ~50°), not muddy brown — reads as yellow on dark UI.
const toastBackground = Color(0xFFC9A000);
const toastForeground = Colors.white;
return Material(
color: toastBackground,
elevation: 2,
shadowColor: Colors.black38,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 4, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 1),
child: Icon(
Icons.vpn_key_outlined,
color: toastForeground,
size: 20,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Welcome to Zapstore',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: toastForeground,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
'Used Zapstore before? Restore your device key via nsec or by signing in with Amber.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: toastForeground.withValues(alpha: 0.92),
),
),
],
),
),
IconButton(
onPressed: onDismiss,
tooltip: 'Dismiss',
icon: const Icon(Icons.close_rounded, color: toastForeground),
constraints: const BoxConstraints(minWidth: 48, minHeight: 48),
padding: EdgeInsets.zero,
),
],
),
),
),
);
}
}
class _SearchResultsSection extends HookConsumerWidget {
const _SearchResultsSection({
required this.searchQuery,
+54 -84
View File
@@ -13,15 +13,17 @@ 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/settings_service.dart';
import 'package:zapstore/widgets/device_backup_dialog.dart';
import 'package:zapstore/widgets/device_restore_dialog.dart';
/// 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 {
Future<void>? _amberRestore;
bool get isRestoringFromAmber => _amberRestore != null;
/// Retained as a lifecycle hook for callers from the previous recovery
/// implementation. Device-key backup has no long-lived foreground request.
void beginWork() {}
@@ -126,106 +128,74 @@ class DeviceBackupService {
await signer.signIn(setAsActive: false);
ref.read(devicePubkeyProvider.notifier).state = pubkey;
}
/// Signs in to Amber, verifies its backup, and offers to replace the key.
///
/// This is explicitly user-initiated from Device key management. It prevents
/// the regular sign-in backup from overwriting the remote backup first.
Future<void> restoreFromAmber({required Ref ref}) {
return _amberRestore ??= _restoreFromAmber(ref).whenComplete(() {
_amberRestore = null;
});
}
Future<void> _restoreFromAmber(Ref ref) async {
await ref.read(amberSignerProvider).signIn();
final amberSigner = ref.read(Signer.activeSignerProvider);
if (amberSigner == null) {
throw const DeviceBackupException('Could not sign in with Amber.');
}
final privateKeyHex = await fetchAmberBackup(
ref: ref,
amberSigner: amberSigner,
);
if (privateKeyHex == null) {
throw const DeviceBackupException(
'No device key backup was found for this Amber identity.',
);
}
final context = rootNavigatorKey.currentState?.overlay?.context;
if (context == null || !context.mounted) {
throw const DeviceBackupException('Restore screen is unavailable.');
}
final restore = await showDialog<bool>(
context: context,
builder: (_) => DeviceBackupRestoreDialog(
onRestore: () => Navigator.of(context).pop(true),
onKeepCurrent: () => Navigator.of(context).pop(false),
),
);
if (restore != true) return;
await restoreDeviceKey(ref: ref, privateKeyHex: privateKeyHex);
await ref.read(devicePrivateSyncProvider.notifier).syncRestoredKey();
if (!ref.read(deviceStateProvider).isReady) {
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
}
}
}
final deviceBackupServiceProvider = Provider<DeviceBackupService>(
(ref) => DeviceBackupService(),
);
/// 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;
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;
}
}
/// Offers Amber recovery only once per fresh local installation.
/// Backs up the current device key after a normal Amber sign-in.
Future<void> maybeOfferDeviceBackup(Ref ref) async {
final amberSigner = ref.read(Signer.activeSignerProvider);
if (amberSigner == null) return;
final settings = ref.read(settingsServiceProvider);
final temp = await settings.loadTemp();
final service = ref.read(deviceBackupServiceProvider);
if (service.isRestoringFromAmber) return;
try {
if (!temp.restoreOnboardingComplete) {
final privateKeyHex = await service.fetchAmberBackup(
ref: ref,
amberSigner: amberSigner,
);
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),
),
);
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());
}
}
}
}
await settings.saveTemp(temp.copyWith(restoreOnboardingComplete: true));
}
await service.backupDeviceKey(ref: ref, amberSigner: amberSigner);
if (!ref.read(deviceStateProvider).isReady) {
unawaited(ref.read(deviceStateProvider.notifier).bootstrap());
}
} catch (error, stack) {
LogService.I.warn(
'device key backup or recovery failed',
'device key backup failed',
tag: 'backup',
err: error,
stack: stack,
+12
View File
@@ -12,6 +12,12 @@ const _kDeviceKey = 'device_key';
/// Manages the device private key. The nsec is intentionally isolated from
/// portable settings and temporary local state.
class DeviceKeyService {
/// Whether secure storage already contains a usable device key.
Future<bool> hasPrivateKey() async {
final existing = await _storage.read(key: _kDeviceKey);
return existing != null && existing.isNotEmpty;
}
/// Load existing device key or generate a new one. Returns hex private key.
Future<String> getOrCreatePrivateKey() async {
final existing = await _storage.read(key: _kDeviceKey);
@@ -113,3 +119,9 @@ final deviceKeyServiceProvider = Provider<DeviceKeyService>(
/// The device pubkey (hex). Available after storageReadyProvider resolves.
final devicePubkeyProvider = StateProvider<String?>((_) => null);
/// Whether this process generated the device key for a fresh installation.
///
/// This is intentionally session-only: once the new key is stored, subsequent
/// launches have no need to show the recovery reminder.
final isNewDeviceKeyProvider = StateProvider<bool>((_) => false);
-10
View File
@@ -51,14 +51,12 @@ class TempSettings {
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(
@@ -66,8 +64,6 @@ class TempSettings {
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() => {
@@ -77,7 +73,6 @@ class TempSettings {
if (deletionSyncedUntil != null)
'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch,
if (logLevel != LogLevel.debug) 'logLevel': logLevel.name,
if (restoreOnboardingComplete) 'restoreOnboardingComplete': true,
};
TempSettings copyWith({
@@ -85,14 +80,11 @@ class TempSettings {
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,
);
}
@@ -125,7 +117,6 @@ class LocalSettings {
bool? backgroundAutoUpdatesEnabled,
Set<String>? trustedSigners,
LogLevel? logLevel,
bool? restoreOnboardingComplete,
bool clearNwc = false,
}) => LocalSettings(
nwcConnectionString: clearNwc
@@ -140,7 +131,6 @@ class LocalSettings {
seenUntil: seenUntil,
deletionSyncedUntil: deletionSyncedUntil,
logLevel: logLevel,
restoreOnboardingComplete: restoreOnboardingComplete,
),
);
}
+6 -12
View File
@@ -6,7 +6,7 @@ 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 }
enum DeviceRestoreAction { pasteKey, amber }
class DeviceRestoreResult {
const DeviceRestoreResult(this.action, {this.key});
@@ -15,7 +15,7 @@ class DeviceRestoreResult {
final String? key;
}
/// First-run choice for recovering an existing device identity.
/// Lets a user replace the current device identity with a recovered one.
class DeviceRestoreDialog extends HookConsumerWidget {
const DeviceRestoreDialog({super.key});
@@ -29,7 +29,7 @@ class DeviceRestoreDialog extends HookConsumerWidget {
);
return BaseDialog(
title: const BaseDialogTitle('Restore device'),
title: const BaseDialogTitle('Restore device key'),
titleIcon: const Icon(Icons.restore, size: 20),
content: BaseDialogContent(
children: [
@@ -38,10 +38,7 @@ class DeviceRestoreDialog extends HookConsumerWidget {
'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 Text('Restoring replaces this device’s current key.'),
const SizedBox(height: 16),
TextField(
controller: controller,
@@ -88,11 +85,8 @@ class DeviceRestoreDialog extends HookConsumerWidget {
),
actions: [
TextButton(
onPressed: () => Navigator.pop(
context,
const DeviceRestoreResult(DeviceRestoreAction.startFresh),
),
child: const Text('Start fresh'),
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
],
);
@@ -0,0 +1,49 @@
# WORK-026 - New Device Key Reminder
**Feature:** FEAT-006-device-key.md
**Status:** In Progress
## Tasks
- [x] 1. Detect a missing device key before generating the fresh key
- Files: `lib/main.dart`, `lib/services/device_key_service.dart`
- Keep the result in memory for the current app session only.
- [x] 2. Replace the startup restore dialog with an inline reminder
- Files: `lib/screens/search_screen.dart`, `lib/services/device_backup_service.dart`
- Render the reminder between search and stacks only for a newly generated key.
- [x] 3. Support restoring a pasted nsec from Device key management
- Files: `lib/screens/profile_screen.dart`, `lib/widgets/device_restore_dialog.dart`
`lib/services/device_backup_service.dart`
- Validate, replace, and sync a pasted-nsec or Amber-restored key without
blocking the UI.
- [x] 4. Verify analysis and targeted tests
- `fvm flutter test test/services/device_key_service_test.dart
test/services/device_backup_service_test.dart
test/services/settings_service_test.dart`
- `HOME=/tmp fvm flutter analyze`
## Verification
- The startup flow captures `device_key` presence before generating the key;
only an absent key enables the session-only reminder.
- Pasting an invalid nsec surfaces an error without replacing the current key.
- A successful restore replaces the device key before the one-shot private-data
synchronization begins.
## Decisions
### 2026-07-15 - New-key reminder is session-only
**Context:** A first-run restore dialog blocks discovery and requires users to
choose a recovery method before using the store.
**Decision:** Capture whether `device_key` was absent before creating it, then
show a non-interactive reminder for the current app session. The key's presence
suppresses it on later launches; no onboarding state is written.
**Rationale:** Device-key recovery remains accessible from Data Management at
any time without treating Amber as a prerequisite or gating the UI.
The obsolete persisted `restoreOnboardingComplete` value is removed. Amber
recovery is now explicitly initiated from Device key management, which prevents
normal Amber sign-in backup from overwriting a recovery record first.