feat: auto-backup installed apps on install/uninstall

Back up the list of installed apps to Nostr (encrypted AppStack) when
batch installs complete or apps are uninstalled. Toggle in profile, off
by default. Restore screen fetches backup and lets users reinstall apps.

Changes by file:

- installed_apps_backup_service: Listener for batch completion and
  installed-list changes; debounced backup (3s); restore-only batches
  ignored; timer cancelled on provider dispose; logs reduced to errors
  and completion only; null-aware assignment for baseline keys.

- secure_storage_service: Persistence for backup-enabled toggle (off
  by default).

- profile_screen: Toggle to enable/disable backup; entry point for
  restore flow.

- restore_installed_apps_screen: Restore UI with shared AppBar; lists
  apps to install and already installed; cards navigate to app detail.

- main_scaffold: Watches backup listener provider so it stays active.

- package_manager: Removed unused installSourceProvider; install source
  handling for restore operations.

- install_operation, android_package_manager, install_button,
  batch_progress_banner: InstallSource.restore support for restore flow.

- app_card: Factory constructors (forRestore, forDiscovery, etc.) to
  simplify restore and other screens.

- router, installed_packages_snapshot: Supporting wiring for the feature.
This commit is contained in:
Henrique Velloso
2026-02-26 18:27:41 -03:00
parent 57878a774e
commit 84906164bb
13 changed files with 531 additions and 216 deletions
-21
View File
@@ -11,7 +11,6 @@ import 'package:zapstore/screens/user_screen.dart';
import 'package:zapstore/screens/search_screen.dart'; import 'package:zapstore/screens/search_screen.dart';
import 'package:zapstore/screens/updates_screen.dart'; import 'package:zapstore/screens/updates_screen.dart';
import 'package:zapstore/screens/profile_screen.dart'; import 'package:zapstore/screens/profile_screen.dart';
import 'package:zapstore/screens/restore_installed_apps_screen.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
/// Root paths for each navigation branch (used for back navigation handling) /// Root paths for each navigation branch (used for back navigation handling)
@@ -84,25 +83,6 @@ GoRoute _stackDetailRoute() {
); );
} }
/// Helper to build restore installed apps route
GoRoute _restoreRoute() {
return GoRoute(
path: 'restore',
redirect: (context, state) {
final extra = state.extra;
if (extra is! List<String> || extra.isEmpty) return '/profile';
return null;
},
pageBuilder: (context, state) {
final ids = state.extra as List<String>;
return _noTransitionPage(
state: state,
child: RestoreInstalledAppsScreen(addressableIds: ids),
);
},
);
}
/// Helper to build user route /// Helper to build user route
GoRoute _userRoute() { GoRoute _userRoute() {
return GoRoute( return GoRoute(
@@ -180,7 +160,6 @@ final routerProvider = Provider<GoRouter>((ref) {
_appDetailRoute(), _appDetailRoute(),
_stackDetailRoute(), _stackDetailRoute(),
_userRoute(), _userRoute(),
_restoreRoute(),
], ],
), ),
], ],
+3
View File
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/router.dart'; import 'package:zapstore/router.dart';
import 'package:zapstore/services/installed_apps_backup_service.dart';
import 'package:zapstore/services/updates_service.dart'; import 'package:zapstore/services/updates_service.dart';
import 'package:zapstore/widgets/common/badges.dart'; import 'package:zapstore/widgets/common/badges.dart';
import '../widgets/common/profile_avatar.dart'; import '../widgets/common/profile_avatar.dart';
@@ -118,6 +119,7 @@ class MobileScaffold extends ConsumerWidget {
// Watch categorized to keep poller alive (poller is watched by categorized) // Watch categorized to keep poller alive (poller is watched by categorized)
final categorized = ref.watch(categorizedUpdatesProvider); final categorized = ref.watch(categorizedUpdatesProvider);
final poller = ref.watch(updatePollerProvider); final poller = ref.watch(updatePollerProvider);
ref.watch(installedAppsBackupListenerProvider);
final updateCount = ref.watch(updateCountProvider); final updateCount = ref.watch(updateCountProvider);
final isLoadingUpdates = categorized.showSkeleton || poller.isChecking; final isLoadingUpdates = categorized.showSkeleton || poller.isChecking;
@@ -308,6 +310,7 @@ class DesktopScaffold extends ConsumerWidget {
// Watch categorized to keep poller alive (poller is watched by categorized) // Watch categorized to keep poller alive (poller is watched by categorized)
final categorized = ref.watch(categorizedUpdatesProvider); final categorized = ref.watch(categorizedUpdatesProvider);
final poller = ref.watch(updatePollerProvider); final poller = ref.watch(updatePollerProvider);
ref.watch(installedAppsBackupListenerProvider);
final updateCount = ref.watch(updateCountProvider); final updateCount = ref.watch(updateCountProvider);
final isLoadingUpdates = categorized.showSkeleton || poller.isChecking; final isLoadingUpdates = categorized.showSkeleton || poller.isChecking;
+56 -22
View File
@@ -15,6 +15,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:purplebase/purplebase.dart'; import 'package:purplebase/purplebase.dart';
import 'package:zapstore/main.dart'; import 'package:zapstore/main.dart';
import 'package:zapstore/services/bookmarks_service.dart'; import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/screens/restore_installed_apps_screen.dart';
import 'package:zapstore/services/installed_apps_backup_service.dart'; import 'package:zapstore/services/installed_apps_backup_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
@@ -186,17 +187,11 @@ class _AuthenticationSection extends ConsumerWidget {
FilledButton.icon( FilledButton.icon(
onPressed: () => _signOut(context, ref), onPressed: () => _signOut(context, ref),
icon: const Icon(Icons.logout, size: 14), icon: const Icon(Icons.logout, size: 14),
label: const Text( label: const Text('Sign Out', style: TextStyle(fontSize: 12)),
'Sign Out',
style: TextStyle(fontSize: 12),
),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Colors.red.shade900, backgroundColor: Colors.red.shade900,
foregroundColor: Colors.white, foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
horizontal: 12,
vertical: 6,
),
minimumSize: const Size(0, 0), minimumSize: const Size(0, 0),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
@@ -1366,10 +1361,7 @@ class _InstalledAppsTile extends ConsumerWidget {
await onAction(context, ref); await onAction(context, ref);
} catch (e) { } catch (e) {
if (context.mounted) { if (context.mounted) {
context.showError( context.showError('Operation failed', technicalDetails: '$e');
'Operation failed',
technicalDetails: '$e',
);
} }
} }
}, },
@@ -1381,10 +1373,9 @@ class _InstalledAppsTile extends ConsumerWidget {
return ListTile( return ListTile(
leading: CircleAvatar( leading: CircleAvatar(
radius: 18, radius: 18,
backgroundColor: Theme.of(context) backgroundColor: Theme.of(
.colorScheme context,
.primary ).colorScheme.primary.withValues(alpha: 0.12),
.withValues(alpha: 0.12),
child: isLoading child: isLoading
? SizedBox( ? SizedBox(
width: 20, width: 20,
@@ -1394,17 +1385,16 @@ class _InstalledAppsTile extends ConsumerWidget {
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
) )
: Icon( : Icon(icon, color: Theme.of(context).colorScheme.primary),
icon,
color: Theme.of(context).colorScheme.primary,
),
), ),
title: AutoSizeText( title: AutoSizeText(
title, title,
style: TextStyle( style: TextStyle(
color: isSignedIn color: isSignedIn
? Theme.of(context).colorScheme.onSurface ? Theme.of(context).colorScheme.onSurface
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5), : Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
maxLines: 1, maxLines: 1,
@@ -1424,6 +1414,10 @@ class _DataManagementSection extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final pubkey = ref.watch(Signer.activePubkeyProvider);
final backupEnabled =
ref.watch(backupSettingsProvider).valueOrNull ?? false;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -1435,6 +1429,23 @@ class _DataManagementSection extends ConsumerWidget {
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
if (pubkey != null)
SwitchListTile(
secondary: Icon(
Icons.cloud_upload_outlined,
color: Theme.of(context).colorScheme.primary,
),
title: const Text('Enable installed apps backup'),
subtitle: const Text(
'Automatically syncs your installed apps list to Nostr after each install or uninstall.',
),
value: backupEnabled,
onChanged: (value) {
ref.read(backupSettingsProvider.notifier).setEnabled(value);
},
contentPadding: EdgeInsets.zero,
),
if (pubkey != null) const SizedBox(height: 8),
_InstalledAppsTile( _InstalledAppsTile(
icon: Icons.backup, icon: Icons.backup,
title: 'Backup installed apps', title: 'Backup installed apps',
@@ -1459,7 +1470,30 @@ class _DataManagementSection extends ConsumerWidget {
context.showInfo('No backup found'); context.showInfo('No backup found');
return; return;
} }
context.push('/profile/restore', extra: ids); await showDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: false,
builder: (dialogContext) => PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
Navigator.of(dialogContext, rootNavigator: true).pop();
}
},
child: Dialog(
backgroundColor: AppColors.darkBackground,
insetPadding: EdgeInsets.zero,
child: RestoreInstalledAppsScreen(
addressableIds: ids,
onClose: () => Navigator.of(
dialogContext,
rootNavigator: true,
).pop(),
),
),
),
);
}, },
), ),
ListTile( ListTile(
+70 -46
View File
@@ -9,16 +9,31 @@ import 'package:zapstore/widgets/batch_progress_banner.dart';
import 'package:zapstore/widgets/common/badges.dart'; import 'package:zapstore/widgets/common/badges.dart';
import 'package:zapstore/theme.dart'; import 'package:zapstore/theme.dart';
/// Shared AppBar for restore screen (empty, loading, error, content).
PreferredSizeWidget _restoreAppBar({VoidCallback? onClose}) {
return AppBar(
title: const Text('Restore from backup'),
leading: onClose != null
? IconButton(icon: const Icon(Icons.close), onPressed: onClose)
: null,
automaticallyImplyLeading: onClose == null,
);
}
/// Full-screen restore from backup. Mirrors [AppStackScreen] pattern: /// Full-screen restore from backup. Mirrors [AppStackScreen] pattern:
/// query with nested relations, skeleton on loading, sort uninstalled first. /// query with nested relations, skeleton on loading, sort uninstalled first.
class RestoreInstalledAppsScreen extends HookConsumerWidget { class RestoreInstalledAppsScreen extends HookConsumerWidget {
const RestoreInstalledAppsScreen({ const RestoreInstalledAppsScreen({
super.key, super.key,
required this.addressableIds, required this.addressableIds,
this.onClose,
}); });
final List<String> addressableIds; final List<String> addressableIds;
/// Called when user closes the modal. Required when shown as overlay.
final VoidCallback? onClose;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final appIdentifiers = addressableIds final appIdentifiers = addressableIds
@@ -28,15 +43,9 @@ class RestoreInstalledAppsScreen extends HookConsumerWidget {
if (appIdentifiers.isEmpty) { if (appIdentifiers.isEmpty) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: _restoreAppBar(onClose: onClose),
title: const Text('Restore from backup'),
automaticallyImplyLeading: false,
),
body: Center( body: Center(
child: Text( child: Text('No apps to restore', style: context.textTheme.bodyLarge),
'No apps to restore',
style: context.textTheme.bodyLarge,
),
), ),
); );
} }
@@ -64,38 +73,33 @@ class RestoreInstalledAppsScreen extends HookConsumerWidget {
return switch (appsState) { return switch (appsState) {
StorageLoading() => Scaffold( StorageLoading() => Scaffold(
appBar: AppBar( appBar: _restoreAppBar(onClose: onClose),
title: const Text('Restore from backup'), body: SingleChildScrollView(
automaticallyImplyLeading: false, padding: const EdgeInsets.only(top: 8, bottom: 32),
), child: _RestoreSkeleton(),
body: SingleChildScrollView(
padding: const EdgeInsets.only(top: 8, bottom: 32),
child: _RestoreSkeleton(),
),
), ),
),
StorageError(:final exception) => Scaffold( StorageError(:final exception) => Scaffold(
appBar: AppBar( appBar: _restoreAppBar(onClose: onClose),
title: const Text('Restore from backup'), body: Center(
automaticallyImplyLeading: false, child: Column(
), mainAxisAlignment: MainAxisAlignment.center,
body: Center( children: [
child: Column( const Icon(Icons.error_outline, size: 64),
mainAxisAlignment: MainAxisAlignment.center, const SizedBox(height: 12),
children: [ Padding(
const Icon(Icons.error_outline, size: 64), padding: const EdgeInsets.symmetric(horizontal: 24),
const SizedBox(height: 12), child: Text(exception.toString(), textAlign: TextAlign.center),
Padding( ),
padding: const EdgeInsets.symmetric(horizontal: 24), ],
child: Text(exception.toString(), textAlign: TextAlign.center),
),
],
),
), ),
), ),
),
StorageData(:final models) => _RestoreContent( StorageData(:final models) => _RestoreContent(
allApps: models, allApps: models,
appIdentifiers: appIdentifiers, appIdentifiers: appIdentifiers,
), onClose: onClose,
),
}; };
} }
} }
@@ -104,15 +108,29 @@ class _RestoreContent extends HookConsumerWidget {
const _RestoreContent({ const _RestoreContent({
required this.allApps, required this.allApps,
required this.appIdentifiers, required this.appIdentifiers,
this.onClose,
}); });
final List<App> allApps; final List<App> allApps;
final Set<String> appIdentifiers; final Set<String> appIdentifiers;
final VoidCallback? onClose;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final installedIds = final installedIds = ref
ref.watch(packageManagerProvider).installed.keys.toSet(); .watch(packageManagerProvider)
.installed
.keys
.toSet();
final completedOperationIds = ref.watch(
packageManagerProvider.select(
(s) => s.operations.entries
.where((entry) => entry.value is Completed)
.map((entry) => entry.key)
.toSet(),
),
);
final effectiveInstalledIds = {...installedIds, ...completedOperationIds};
final appsMap = {for (final app in allApps) app.identifier: app}; final appsMap = {for (final app in allApps) app.identifier: app};
@@ -120,26 +138,27 @@ class _RestoreContent extends HookConsumerWidget {
final toInstall = appIdentifiers final toInstall = appIdentifiers
.map((id) => appsMap[id]) .map((id) => appsMap[id])
.whereType<App>() .whereType<App>()
.where((app) => !installedIds.contains(app.identifier)) .where((app) => !effectiveInstalledIds.contains(app.identifier))
.toList(); .toList();
final alreadyInstalled = appIdentifiers final alreadyInstalled = appIdentifiers
.map((id) => appsMap[id]) .map((id) => appsMap[id])
.whereType<App>() .whereType<App>()
.where((app) => installedIds.contains(app.identifier)) .where((app) => effectiveInstalledIds.contains(app.identifier))
.toList(); .toList();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: _restoreAppBar(onClose: onClose),
title: const Text('Restore from backup'),
automaticallyImplyLeading: false,
),
body: ListView( body: ListView(
padding: const EdgeInsets.only(top: 8, bottom: 32), padding: const EdgeInsets.only(top: 8, bottom: 32),
children: [ children: [
// "Install All" button — same style as UpdateAllRow in updates screen // "Install All" button — same style as UpdateAllRow in updates screen
if (toInstall.length > 1) if (toInstall.length > 1)
UpdateAllRow(allUpdates: toInstall, label: 'Install All'), UpdateAllRow(
allUpdates: toInstall,
label: 'Install All',
installSource: InstallSource.restore,
),
// "To install" section // "To install" section
if (toInstall.isNotEmpty) if (toInstall.isNotEmpty)
@@ -152,11 +171,14 @@ class _RestoreContent extends HookConsumerWidget {
(app) => AppCard( (app) => AppCard(
key: ValueKey('restore_${app.identifier}'), key: ValueKey('restore_${app.identifier}'),
app: app, app: app,
installSource: InstallSource.restore,
showUpdateButton: true, showUpdateButton: true,
showInstallWhenNotInstalled: true,
showUpdateArrow: false, showUpdateArrow: false,
showSignedBy: false, showSignedBy: false,
showZapEncouragement: false, showZapEncouragement: false,
showDescription: false, showDescription: false,
onTap: () {}, // No navigation; Install button stays tappable
), ),
), ),
@@ -177,6 +199,7 @@ class _RestoreContent extends HookConsumerWidget {
showSignedBy: false, showSignedBy: false,
showZapEncouragement: false, showZapEncouragement: false,
showDescription: false, showDescription: false,
ignorePointer: true, // Informational only; no tap
), ),
), ),
], ],
@@ -189,8 +212,9 @@ class _RestoreContent extends HookConsumerWidget {
child: Text( child: Text(
'No apps found in backup', 'No apps found in backup',
style: context.textTheme.bodyLarge?.copyWith( style: context.textTheme.bodyLarge?.copyWith(
color: color: Theme.of(
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6), context,
).colorScheme.onSurface.withValues(alpha: 0.6),
), ),
), ),
), ),
+129 -1
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -5,8 +6,135 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart'; import 'package:models/models.dart';
import 'package:zapstore/constants/app_constants.dart'; import 'package:zapstore/constants/app_constants.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/secure_storage_service.dart';
import 'package:zapstore/utils/extensions.dart'; import 'package:zapstore/utils/extensions.dart';
const _logPrefix = '[InstalledAppsBackup]';
/// Persisted setting for auto-backup (off by default). Survives sign-out.
class BackupSettingsNotifier extends AsyncNotifier<bool> {
@override
Future<bool> build() async {
final storage = ref.read(secureStorageServiceProvider);
return storage.getBackupEnabled();
}
Future<void> setEnabled(bool enabled) async {
await ref.read(secureStorageServiceProvider).setBackupEnabled(enabled);
ref.invalidateSelf();
}
}
final backupSettingsProvider =
AsyncNotifierProvider<BackupSettingsNotifier, bool>(
BackupSettingsNotifier.new,
);
const _backupDebounceDuration = Duration(seconds: 3);
/// Listens to batch completion and uninstalls; triggers backup when enabled and signed in.
/// Debounces rapid triggers (e.g. multiple uninstalls) to avoid repeated sign prompts.
/// Watched by MainScaffold to keep alive.
final installedAppsBackupListenerProvider = Provider<void>((ref) {
Timer? debounceTimer;
var activeBatchAppIds = <String>{};
Set<String>? baselineInstalledKeys;
void scheduleBackup() {
debounceTimer?.cancel();
debounceTimer = Timer(_backupDebounceDuration, () {
debounceTimer = null;
_maybeBackup(ref);
});
}
ref.listen<BatchProgress?>(batchProgressProvider, (prev, next) {
if (next?.hasInProgress == true) {
activeBatchAppIds = ref
.read(packageManagerProvider)
.operations
.entries
.where((entry) => entry.value.isInProgress)
.map((entry) => entry.key)
.toSet();
}
final hasBatchCompleted =
prev?.hasInProgress == true && next?.isAllComplete == true;
if (hasBatchCompleted) {
if (baselineInstalledKeys == null) return;
if (_isRestoreOnlyBatch(ref, activeBatchAppIds)) {
activeBatchAppIds = <String>{};
baselineInstalledKeys =
ref.read(packageManagerProvider).installed.keys.toSet();
return;
}
baselineInstalledKeys =
ref.read(packageManagerProvider).installed.keys.toSet();
scheduleBackup();
activeBatchAppIds = <String>{};
}
});
ref.listen<bool>(
packageManagerProvider.select((s) => s.isScanning),
(prev, next) {
if (prev == true && next == false) {
final keys = ref.read(packageManagerProvider).installed.keys.toSet();
baselineInstalledKeys ??= keys;
}
},
);
ref.listen<int>(
packageManagerProvider.select((s) => s.installed.length),
(prev, next) {
if (baselineInstalledKeys == null || prev == null || prev == next) return;
final currentKeys =
ref.read(packageManagerProvider).installed.keys.toSet();
final added = currentKeys.difference(baselineInstalledKeys!);
final removed = baselineInstalledKeys!.difference(currentKeys);
if (added.isEmpty && removed.isEmpty) return;
// Skip isScanning only for additions (avoids false triggers during sync).
// Removals are always from user uninstall — must trigger backup.
if (removed.isEmpty && ref.read(packageManagerProvider).isScanning) return;
final batch = ref.read(batchProgressProvider);
if (batch != null && batch.hasInProgress) return;
baselineInstalledKeys = currentKeys;
scheduleBackup();
},
);
ref.onDispose(() {
debounceTimer?.cancel();
});
});
void _maybeBackup(Ref ref) {
final enabledAsync = ref.read(backupSettingsProvider);
final enabled = enabledAsync.valueOrNull ?? false;
final pubkey = ref.read(Signer.activePubkeyProvider);
if (!enabled || pubkey == null) return;
unawaited(
_backupInstalledApps(ref).then((count) {
debugPrint('$_logPrefix Backup completed: $count apps');
}).catchError((e, st) {
debugPrint('$_logPrefix Backup error: $e');
debugPrint('$_logPrefix $st');
}),
);
}
bool _isRestoreOnlyBatch(Ref ref, Set<String> appIds) {
if (appIds.isEmpty) return false;
final pm = ref.read(packageManagerProvider.notifier);
return appIds.every(
(appId) => pm.getOperationSource(appId) == InstallSource.restore,
);
}
/// Provider that exposes the backup function with correct Ref. /// Provider that exposes the backup function with correct Ref.
final backupInstalledAppsProvider = Provider<Future<int> Function()>((ref) { final backupInstalledAppsProvider = Provider<Future<int> Function()>((ref) {
return () => _backupInstalledApps(ref); return () => _backupInstalledApps(ref);
@@ -116,7 +244,7 @@ Future<List<String>> _restoreInstalledApps(Ref ref) async {
); );
addressableIds = (jsonDecode(decryptedContent) as List).cast<String>(); addressableIds = (jsonDecode(decryptedContent) as List).cast<String>();
} catch (e) { } catch (e) {
debugPrint('[InstalledAppsBackup] Restore decrypt failed: $e'); debugPrint('$_logPrefix Restore decrypt failed: $e');
return []; return [];
} }
@@ -358,11 +358,15 @@ final class AndroidPackageManager extends PackageManager {
} }
@override @override
void setOperation(String appId, InstallOperation op) { void setOperation(
String appId,
InstallOperation op, {
InstallSource? source,
}) {
// Clear from aborted orphans when a new operation is set, // Clear from aborted orphans when a new operation is set,
// so we can abort again if it becomes orphaned in a future session. // so we can abort again if it becomes orphaned in a future session.
_abortedOrphans.remove(appId); _abortedOrphans.remove(appId);
super.setOperation(appId, op); super.setOperation(appId, op, source: source);
} }
@override @override
@@ -1,5 +1,18 @@
import 'package:models/models.dart'; import 'package:models/models.dart';
/// Source that initiated an install/download operation.
enum InstallSource {
normal,
restore;
static InstallSource fromMetadata(String? value) {
return switch (value) {
'restore' => InstallSource.restore,
_ => InstallSource.normal,
};
}
}
/// Stale download threshold - operations older than this will be cleaned up /// Stale download threshold - operations older than this will be cleaned up
const staleOperationThreshold = Duration(days: 7); const staleOperationThreshold = Duration(days: 7);
@@ -17,7 +17,7 @@ class InstalledPackagesSnapshot {
static Future<void> save(Map<String, PackageInfo> installed) async { static Future<void> save(Map<String, PackageInfo> installed) async {
try { try {
final file = await _file(); final file = await _file();
final tmp = File('${file.path}.tmp'); await Directory(path.dirname(file.path)).create(recursive: true);
final list = installed.values final list = installed.values
.map( .map(
(p) => <String, dynamic>{ (p) => <String, dynamic>{
@@ -35,11 +35,7 @@ class InstalledPackagesSnapshot {
'savedAt': DateTime.now().millisecondsSinceEpoch, 'savedAt': DateTime.now().millisecondsSinceEpoch,
'installed': list, 'installed': list,
}); });
await tmp.writeAsString(payload, flush: true); await file.writeAsString(payload, flush: true);
if (await file.exists()) {
await file.delete();
}
await tmp.rename(file.path);
} catch (e) { } catch (e) {
// Best-effort snapshot only. // Best-effort snapshot only.
if (kDebugMode) { if (kDebugMode) {
@@ -134,6 +134,9 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
/// Lock to prevent concurrent queue processing /// Lock to prevent concurrent queue processing
bool _processingQueue = false; bool _processingQueue = false;
/// Tracks the source for each app operation (normal vs restore).
final Map<String, InstallSource> _operationSourceByAppId = {};
/// Dynamic max concurrent downloads based on device capability /// Dynamic max concurrent downloads based on device capability
int get maxConcurrentDownloads => int get maxConcurrentDownloads =>
DeviceCapabilitiesCache.capabilities.maxConcurrentDownloads; DeviceCapabilitiesCache.capabilities.maxConcurrentDownloads;
@@ -294,16 +297,30 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
.map((e) => e.key) .map((e) => e.key)
.toList(); .toList();
InstallSource getOperationSource(String appId) {
return _operationSourceByAppId[appId] ?? InstallSource.normal;
}
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
// STATE MANAGEMENT (Public for subclass use) // STATE MANAGEMENT (Public for subclass use)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
void setOperation(String appId, InstallOperation op) { void setOperation(
String appId,
InstallOperation op, {
InstallSource? source,
}) {
if (source != null) {
_operationSourceByAppId[appId] = source;
} else {
_operationSourceByAppId.putIfAbsent(appId, () => InstallSource.normal);
}
state = state.copyWith(operations: {...state.operations, appId: op}); state = state.copyWith(operations: {...state.operations, appId: op});
_updateWatchdogTimer(); _updateWatchdogTimer();
} }
void clearOperation(String appId) { void clearOperation(String appId) {
_operationSourceByAppId.remove(appId);
state = state.copyWith( state = state.copyWith(
operations: Map.from(state.operations)..remove(appId), operations: Map.from(state.operations)..remove(appId),
); );
@@ -314,11 +331,20 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
/// Called after the batch completion display timeout. /// Called after the batch completion display timeout.
/// Terminal states: Completed, OperationFailed, InstallCancelled. /// Terminal states: Completed, OperationFailed, InstallCancelled.
void clearCompletedOperations() { void clearCompletedOperations() {
final completedIds = state.operations.entries
.where(
(entry) =>
entry.value is Completed ||
entry.value is OperationFailed ||
entry.value is InstallCancelled,
)
.map((entry) => entry.key)
.toSet();
final remaining = Map.of(state.operations) final remaining = Map.of(state.operations)
..removeWhere( ..removeWhere((appId, _) => completedIds.contains(appId));
(_, op) => for (final appId in completedIds) {
op is Completed || op is OperationFailed || op is InstallCancelled, _operationSourceByAppId.remove(appId);
); }
state = state.copyWith(operations: remaining); state = state.copyWith(operations: remaining);
_updateWatchdogTimer(); _updateWatchdogTimer();
} }
@@ -333,6 +359,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
String appId, String appId,
FileMetadata target, { FileMetadata target, {
String? displayName, String? displayName,
InstallSource source = InstallSource.normal,
}) async { }) async {
await _ensureDownloaderReady(); await _ensureDownloaderReady();
final existing = getOperation(appId); final existing = getOperation(appId);
@@ -366,6 +393,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
setOperation( setOperation(
appId, appId,
DownloadQueued(target: target, displayName: displayName), DownloadQueued(target: target, displayName: displayName),
source: source,
); );
// Process queue to potentially start this download immediately // Process queue to potentially start this download immediately
@@ -376,7 +404,14 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
/// Queue multiple downloads at once - staggered to prevent UI flood. /// Queue multiple downloads at once - staggered to prevent UI flood.
/// This is the primary method for "Update All" functionality. /// This is the primary method for "Update All" functionality.
Future<void> queueDownloads( Future<void> queueDownloads(
List<({String appId, FileMetadata target, String? displayName})> items, List<
({
String appId,
FileMetadata target,
String? displayName,
InstallSource source,
})
> items,
) async { ) async {
await _ensureDownloaderReady(); await _ensureDownloaderReady();
@@ -404,6 +439,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
setOperation( setOperation(
item.appId, item.appId,
DownloadQueued(target: item.target, displayName: item.displayName), DownloadQueued(target: item.target, displayName: item.displayName),
source: item.source,
); );
} }
@@ -718,6 +754,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
appId, appId,
target.id, target.id,
isCdnRetry: isCdnRetry, isCdnRetry: isCdnRetry,
source: getOperationSource(appId),
); );
final task = DownloadTask( final task = DownloadTask(
@@ -771,9 +808,10 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
final metaData = update.task.metaData; final metaData = update.task.metaData;
bool isCdnRetry = false; bool isCdnRetry = false;
if (metaData.isNotEmpty) { if (metaData.isNotEmpty) {
final (decodedAppId, _, cdnRetry) = _parseTaskMetadata(metaData); final (decodedAppId, _, cdnRetry, source) = _parseTaskMetadata(metaData);
isCdnRetry = cdnRetry; isCdnRetry = cdnRetry;
if (decodedAppId != null) { if (decodedAppId != null) {
_operationSourceByAppId.putIfAbsent(decodedAppId, () => source);
final op = getOperation(decodedAppId); final op = getOperation(decodedAppId);
// Ensure the operation actually matches this taskId (metadata could be stale). // Ensure the operation actually matches this taskId (metadata could be stale).
if (op is Downloading && op.taskId == update.task.taskId) { if (op is Downloading && op.taskId == update.task.taskId) {
@@ -1181,7 +1219,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
continue; continue;
} }
final (appId, metadataId, _) = _parseTaskMetadata(metaData); final (appId, metadataId, _, source) = _parseTaskMetadata(metaData);
if (appId == null) { if (appId == null) {
await _cleanupTask(task); await _cleanupTask(task);
continue; continue;
@@ -1199,7 +1237,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
continue; continue;
} }
await _restoreOperation(appId, record, task, fileMetadata); await _restoreOperation(appId, record, task, fileMetadata, source);
} }
} catch (e) { } catch (e) {
debugPrint('Failed to restore operations: $e'); debugPrint('Failed to restore operations: $e');
@@ -1211,6 +1249,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
TaskRecord record, TaskRecord record,
DownloadTask task, DownloadTask task,
FileMetadata fileMetadata, FileMetadata fileMetadata,
InstallSource source,
) async { ) async {
switch (record.status) { switch (record.status) {
case TaskStatus.complete: case TaskStatus.complete:
@@ -1237,6 +1276,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
progress: record.progress, progress: record.progress,
taskId: task.taskId, taskId: task.taskId,
), ),
source: source,
); );
try { try {
await _downloader.resume(task); await _downloader.resume(task);
@@ -1263,6 +1303,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
progress: record.progress, progress: record.progress,
taskId: task.taskId, taskId: task.taskId,
), ),
source: source,
); );
break; break;
@@ -1304,19 +1345,29 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
String appId, String appId,
String metadataId, { String metadataId, {
bool isCdnRetry = false, bool isCdnRetry = false,
InstallSource source = InstallSource.normal,
}) { }) {
return '$appId|$metadataId|${isCdnRetry ? '1' : '0'}'; return '$appId|$metadataId|${isCdnRetry ? '1' : '0'}|${source.name}';
} }
(String? appId, String? metadataId, bool isCdnRetry) _parseTaskMetadata( (String? appId, String? metadataId, bool isCdnRetry, InstallSource source)
_parseTaskMetadata(
String metaData, String metaData,
) { ) {
final parts = metaData.split('|'); final parts = metaData.split('|');
if (parts.length >= 2) { if (parts.length >= 2) {
final isCdnRetry = parts.length >= 3 && parts[2] == '1'; final isCdnRetry = parts.length >= 3 && parts[2] == '1';
return (parts[0], parts[1], isCdnRetry); final source = InstallSource.fromMetadata(
parts.length >= 4 ? parts[3] : null,
);
return (parts[0], parts[1], isCdnRetry, source);
} }
return (metaData.isNotEmpty ? metaData : null, null, false); return (
metaData.isNotEmpty ? metaData : null,
null,
false,
InstallSource.normal,
);
} }
Future<FileMetadata?> _loadFileMetadata( Future<FileMetadata?> _loadFileMetadata(
+20
View File
@@ -126,6 +126,26 @@ class SecureStorageService {
value: jsonEncode(relays.toList()), value: jsonEncode(relays.toList()),
); );
} }
// =========================================================================
// Installed Apps Backup
// =========================================================================
static const _backupEnabledKey = 'installed_apps_backup_enabled';
/// Whether installed apps backup is enabled (off by default).
Future<bool> getBackupEnabled() async {
final value = await _storage.read(key: _backupEnabledKey);
return value == 'true';
}
/// Store the installed apps backup toggle state.
Future<void> setBackupEnabled(bool enabled) async {
await _storage.write(
key: _backupEnabledKey,
value: enabled.toString(),
);
}
} }
/// Persists the AmberSigner pubkey in flutter_secure_storage. /// Persists the AmberSigner pubkey in flutter_secure_storage.
+157 -104
View File
@@ -25,6 +25,17 @@ class AppCard extends HookConsumerWidget {
final bool showUpdateButton; final bool showUpdateButton;
final bool showZapEncouragement; final bool showZapEncouragement;
final bool showDescription; final bool showDescription;
final InstallSource installSource;
/// When provided, used instead of default navigation on tap.
/// Use when AppCard is shown outside GoRouter tree (e.g. in a dialog).
final VoidCallback? onTap;
/// When true, tap is disabled (e.g. informational display only).
final bool ignorePointer;
/// When true, show install button even when app has no update (e.g. restore "to install").
final bool showInstallWhenNotInstalled;
const AppCard({ const AppCard({
super.key, super.key,
@@ -35,6 +46,10 @@ class AppCard extends HookConsumerWidget {
this.showUpdateButton = false, this.showUpdateButton = false,
this.showZapEncouragement = false, this.showZapEncouragement = false,
this.showDescription = true, this.showDescription = true,
this.installSource = InstallSource.normal,
this.onTap,
this.ignorePointer = false,
this.showInstallWhenNotInstalled = false,
}); });
@override @override
@@ -54,121 +69,139 @@ class AppCard extends HookConsumerWidget {
? _stripMarkdown(app!.description) ? _stripMarkdown(app!.description)
: 'No description available'; : 'No description available';
Widget buildCard(Profile? publisher, bool isPublisherLoading) => GestureDetector( Widget buildCard(Profile? publisher, bool isPublisherLoading) {
onTap: () { final card = GestureDetector(
final segments = GoRouterState.of(context).uri.pathSegments; onTap: ignorePointer
final first = segments.isNotEmpty ? segments.first : 'search'; ? null
// Prefer naddr so the detail screen can uniquely identify the app : (onTap ??
// (identifier + author) and not accidentally resolve to a different () {
// publisher's app with the same identifier. final segments = GoRouterState.of(context).uri.pathSegments;
final naddr = Utils.encodeShareableIdentifier( final first = segments.isNotEmpty
AddressInput( ? segments.first
identifier: app!.identifier, : 'search';
author: app!.pubkey, final naddr = Utils.encodeShareableIdentifier(
kind: app!.event.kind, AddressInput(
relays: const [], identifier: app!.identifier,
), author: app!.pubkey,
); kind: app!.event.kind,
context.push('/$first/app/$naddr'); relays: const [],
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
border: Border.all(
color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header row: Icon + Name/Version (icon matches header height)
LayoutBuilder(
builder: (context, constraints) {
// Icon takes a little over 20% of available width
final iconSize = (constraints.maxWidth * 0.21).clamp(
50.0,
68.0,
);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App Icon (stretches to match name + version height, ~20% width)
_buildAppIcon(context, iconSize),
const SizedBox(width: 14),
// App Name and Version (always stacked)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// App name with optional "by publisher" inline
_buildAppNameWithPublisher(context, publisher, isPublisherLoading),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: VersionPillWidget(
app: app!,
showUpdateArrow: showUpdateArrow,
),
),
],
), ),
), );
], context.push('/$first/app/$naddr');
); }),
}, child: Container(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.6),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.2),
width: 1,
), ),
),
// App Description rendered as plain text (markdown stripped) child: Column(
if (showDescription) ...[ crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 10), children: [
Text( // Header row: Icon + Name/Version (icon matches header height)
descriptionText,
style: descriptionStyle,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: true,
),
],
// Update button (for apps with updates or currently downloading/installing)
if (showUpdateButton)
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// Match the text column start (icon width + spacing) // Icon takes a little over 20% of available width
final iconSize = (constraints.maxWidth * 0.21).clamp( final iconSize = (constraints.maxWidth * 0.21).clamp(
50.0, 50.0,
68.0, 68.0,
); );
final leftInset = iconSize + 14; return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// App Icon (stretches to match name + version height, ~20% width)
_buildAppIcon(context, iconSize),
return Padding( const SizedBox(width: 14),
padding: EdgeInsets.only(left: leftInset),
child: SizedBox( // App Name and Version (always stacked)
width: (constraints.maxWidth - leftInset).clamp( Expanded(
0.0, child: Column(
constraints.maxWidth, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// App name with optional "by publisher" inline
_buildAppNameWithPublisher(
context,
publisher,
isPublisherLoading,
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: VersionPillWidget(
app: app!,
showUpdateArrow: showUpdateArrow,
),
),
],
),
), ),
child: _AppCardUpdateButtonSection(app: app!), ],
),
); );
}, },
), ),
// Zap encouragement (only for downloading/installing developer-signed apps) // App Description rendered as plain text (markdown stripped)
if (showZapEncouragement) if (showDescription) ...[
_AppCardZapEncouragementSection(app: app!, publisher: publisher), const SizedBox(height: 10),
], Text(
descriptionText,
style: descriptionStyle,
overflow: TextOverflow.ellipsis,
maxLines: 2,
softWrap: true,
),
],
// Update button (for apps with updates or currently downloading/installing)
if (showUpdateButton || showInstallWhenNotInstalled)
LayoutBuilder(
builder: (context, constraints) {
// Match the text column start (icon width + spacing)
final iconSize = (constraints.maxWidth * 0.21).clamp(
50.0,
68.0,
);
final leftInset = iconSize + 14;
return Padding(
padding: EdgeInsets.only(left: leftInset),
child: SizedBox(
width: (constraints.maxWidth - leftInset).clamp(
0.0,
constraints.maxWidth,
),
child: _AppCardUpdateButtonSection(
app: app!,
installSource: installSource,
showWhenNotInstalled: showInstallWhenNotInstalled,
),
),
);
},
),
// Zap encouragement (only for downloading/installing developer-signed apps)
if (showZapEncouragement)
_AppCardZapEncouragementSection(
app: app!,
publisher: publisher,
),
],
),
), ),
), );
); return card;
}
if (!needsPublisher) return buildCard(null, false); if (!needsPublisher) return buildCard(null, false);
@@ -452,15 +485,24 @@ class AppCard extends HookConsumerWidget {
} }
class _AppCardUpdateButtonSection extends ConsumerWidget { class _AppCardUpdateButtonSection extends ConsumerWidget {
const _AppCardUpdateButtonSection({required this.app}); const _AppCardUpdateButtonSection({
required this.app,
this.installSource = InstallSource.normal,
this.showWhenNotInstalled = false,
});
final App app; final App app;
final InstallSource installSource;
final bool showWhenNotInstalled;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final operation = ref.watch(installOperationProvider(app.identifier)); final operation = ref.watch(installOperationProvider(app.identifier));
final installedPkg = ref.watch(installedPackageProvider(app.identifier));
final hasOperation = operation != null; final hasOperation = operation != null;
final shouldShow = app.hasUpdate || hasOperation; final isInstalled = installedPkg != null;
final shouldShow =
app.hasUpdate || hasOperation || (showWhenNotInstalled && !isInstalled);
if (!shouldShow) return const SizedBox.shrink(); if (!shouldShow) return const SizedBox.shrink();
@@ -472,6 +514,7 @@ class _AppCardUpdateButtonSection extends ConsumerWidget {
child: _CompactInstallButton( child: _CompactInstallButton(
app: app, app: app,
release: app.latestRelease.value, release: app.latestRelease.value,
installSource: installSource,
), ),
), ),
], ],
@@ -512,12 +555,22 @@ class _AppCardZapEncouragementSection extends ConsumerWidget {
class _CompactInstallButton extends ConsumerWidget { class _CompactInstallButton extends ConsumerWidget {
final App app; final App app;
final Release? release; final Release? release;
final InstallSource installSource;
const _CompactInstallButton({required this.app, this.release}); const _CompactInstallButton({
required this.app,
this.release,
this.installSource = InstallSource.normal,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return InstallButton(app: app, release: release, compact: true); return InstallButton(
app: app,
release: release,
compact: true,
installSource: installSource,
);
} }
} }
+3
View File
@@ -12,10 +12,12 @@ class UpdateAllRow extends ConsumerWidget {
super.key, super.key,
required this.allUpdates, required this.allUpdates,
this.label = 'Update All', this.label = 'Update All',
this.installSource = InstallSource.normal,
}); });
final List<App> allUpdates; final List<App> allUpdates;
final String label; final String label;
final InstallSource installSource;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -36,6 +38,7 @@ class UpdateAllRow extends ConsumerWidget {
appId: app.identifier, appId: app.identifier,
target: app.latestFileMetadata!, target: app.latestFileMetadata!,
displayName: app.name, displayName: app.name,
source: installSource,
), ),
) )
.toList(); .toList();
+8 -1
View File
@@ -18,11 +18,13 @@ class InstallButton extends ConsumerWidget {
required this.app, required this.app,
this.release, this.release,
this.compact = false, this.compact = false,
this.installSource = InstallSource.normal,
}); });
final App app; final App app;
final Release? release; final Release? release;
final bool compact; final bool compact;
final InstallSource installSource;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -564,7 +566,12 @@ class InstallButton extends ConsumerWidget {
FileMetadata fileMetadata, FileMetadata fileMetadata,
) async { ) async {
final pm = ref.read(packageManagerProvider.notifier); final pm = ref.read(packageManagerProvider.notifier);
await pm.startDownload(app.identifier, fileMetadata, displayName: app.name); await pm.startDownload(
app.identifier,
fileMetadata,
displayName: app.name,
source: installSource,
);
} }
void _pauseDownload(WidgetRef ref) { void _pauseDownload(WidgetRef ref) {