Remove broken installed app backup feature

This commit is contained in:
franzap
2026-07-15 13:53:44 -03:00
parent 5f8b3eeee6
commit 7c6ea8c8dd
8 changed files with 58 additions and 252 deletions
-3
View File
@@ -22,9 +22,6 @@ const kZapstoreCommunityPubkey =
/// Identifier for storing user saved apps
const kAppBookmarksIdentifier = 'zapstore-bookmarks';
/// Identifier for the encrypted stack of installed apps
const kInstalledAppsIdentifier = 'zapstore-installed-apps';
/// Identifier for the encrypted stack of apps the user chose as unmanaged
const kUnmanagedAppsIdentifier = 'zapstore-unmanaged-apps';
+6 -50
View File
@@ -16,7 +16,6 @@ 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';
@@ -46,7 +45,7 @@ class ProfileScreen extends ConsumerWidget {
const SizedBox(height: 24),
// User Stacks Section (Saved Apps + Installed Apps)
// User Stacks Section
const _UserStacksSection(),
const SizedBox(height: 24),
@@ -1655,46 +1654,6 @@ class _BackgroundAutoUpdatesToggle extends ConsumerWidget {
}
}
class _InstalledAppsBackupToggle extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final devicePubkey = ref.watch(devicePubkeyProvider);
if (devicePubkey == null) return const SizedBox.shrink();
final settingsAsync = ref.watch(localSettingsProvider);
final ready = ref.watch(deviceStateProvider).isReady;
final enabled =
settingsAsync.valueOrNull?.installedAppsBackupEnabled ?? false;
return SwitchListTile(
secondary: CircleAvatar(
radius: 18,
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.12),
child: Icon(
Icons.cloud_upload,
color: Theme.of(context).colorScheme.primary,
size: 20,
),
),
title: const Text('Back up installed apps'),
value: enabled,
contentPadding: EdgeInsets.zero,
onChanged: !ready
? null
: (value) async {
await ref
.read(deviceStateProvider.notifier)
.updatePortable(
(s) => s.copyWith(installedAppsBackupEnabled: value),
);
ref.invalidate(localSettingsProvider);
},
);
}
}
class _DataManagementSection extends ConsumerWidget {
const _DataManagementSection();
@@ -1713,8 +1672,6 @@ class _DataManagementSection extends ConsumerWidget {
const SizedBox(height: 16),
const _DeviceKeyCard(),
const SizedBox(height: 8),
_InstalledAppsBackupToggle(),
const SizedBox(height: 8),
const _BackgroundAutoUpdatesToggle(),
const SizedBox(height: 8),
ListTile(
@@ -1864,7 +1821,10 @@ class _UserStacksSection extends ConsumerWidget {
// Filter to only encrypted (private) stacks
final privateStacks = stacksState.models
.where((s) => s.content.isNotEmpty)
.where(
(s) =>
s.content.isNotEmpty && s.identifier != 'zapstore-installed-apps',
)
.toList();
// Don't show section if no private stacks exist
@@ -1872,12 +1832,10 @@ class _UserStacksSection extends ConsumerWidget {
return const SizedBox.shrink();
}
// Sort: Saved Apps first, then Installed Apps, then others alphabetically
// Sort: Saved Apps first, then Unmanaged Apps, then others alphabetically
privateStacks.sort((a, b) {
if (a.identifier == kAppBookmarksIdentifier) return -1;
if (b.identifier == kAppBookmarksIdentifier) return 1;
if (a.identifier == kInstalledAppsIdentifier) return -1;
if (b.identifier == kInstalledAppsIdentifier) return 1;
if (a.identifier == kUnmanagedAppsIdentifier) return -1;
if (b.identifier == kUnmanagedAppsIdentifier) return 1;
return (a.name ?? a.identifier).compareTo(b.name ?? b.identifier);
@@ -1899,8 +1857,6 @@ class _UserStacksSection extends ConsumerWidget {
final stack = entry.value;
final displayName = stack.identifier == kAppBookmarksIdentifier
? 'Saved Apps'
: stack.identifier == kInstalledAppsIdentifier
? 'Installed Apps'
: stack.identifier == kUnmanagedAppsIdentifier
? 'Unmanaged Apps'
: null;
-54
View File
@@ -16,7 +16,6 @@ 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';
import 'package:zapstore/widgets/legacy_installed_apps_dialog.dart';
/// Backs up the device key to the active Amber identity and restores it again.
///
@@ -127,45 +126,6 @@ class DeviceBackupService {
await signer.signIn(setAsActive: false);
ref.read(devicePubkeyProvider.notifier).state = pubkey;
}
Future<List<String>> fetchLegacyInstalledAppIds({
required Ref ref,
required Signer amberSigner,
}) async {
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 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 [];
}
}
}
final deviceBackupServiceProvider = Provider<DeviceBackupService>(
@@ -257,20 +217,6 @@ Future<void> maybeOfferDeviceBackup(Ref ref) async {
}
}
}
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.backupDeviceKey(ref: ref, amberSigner: amberSigner);
-11
View File
@@ -12,20 +12,16 @@ const _storage = FlutterSecureStorage(
/// Portable preferences, mirrored by DeviceStateService.
class PortableSettings {
final bool installedAppsBackupEnabled;
final bool backgroundAutoUpdatesEnabled;
final Set<String> trustedSigners;
const PortableSettings({
this.installedAppsBackupEnabled = false,
this.backgroundAutoUpdatesEnabled = false,
this.trustedSigners = const {},
});
factory PortableSettings.fromJson(Map<String, dynamic> json) {
return PortableSettings(
installedAppsBackupEnabled:
json['installedAppsBackupEnabled'] as bool? ?? false,
backgroundAutoUpdatesEnabled:
json['backgroundAutoUpdatesEnabled'] as bool? ?? false,
trustedSigners:
@@ -35,18 +31,14 @@ class PortableSettings {
}
Map<String, dynamic> toJson() => {
'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,
@@ -120,7 +112,6 @@ class LocalSettings {
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;
@@ -131,7 +122,6 @@ class LocalSettings {
DateTime? lastAppOpened,
DateTime? seenUntil,
DateTime? deletionSyncedUntil,
bool? installedAppsBackupEnabled,
bool? backgroundAutoUpdatesEnabled,
Set<String>? trustedSigners,
LogLevel? logLevel,
@@ -142,7 +132,6 @@ class LocalSettings {
? null
: (nwcConnectionString ?? this.nwcConnectionString),
portable: portable.copyWith(
installedAppsBackupEnabled: installedAppsBackupEnabled,
backgroundAutoUpdatesEnabled: backgroundAutoUpdatesEnabled,
trustedSigners: trustedSigners,
),
-68
View File
@@ -7,8 +7,6 @@ import 'package:purplebase/purplebase.dart';
import 'package:zapstore/services/catalog_fetcher.dart';
import 'package:zapstore/services/log_service.dart';
import 'package:zapstore/services/deletion_processor.dart';
import 'package:zapstore/services/device_key_service.dart';
import 'package:zapstore/services/device_private_event_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/unmanaged_apps_service.dart';
import 'package:zapstore/services/settings_service.dart';
@@ -109,7 +107,6 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
final Ref ref;
Timer? _pollTimer;
List<String> _lastBackedUpIds = [];
void _init() {
// Hydrate from local storage as soon as SQLite is ready — no network.
@@ -173,7 +170,6 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
clearError: true,
hasHydrated: true,
);
unawaited(_backupInstalledApps());
} catch (e, st) {
LogService.I.warn(
'update check failed',
@@ -280,70 +276,6 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
}
}
/// Best-effort backup of installed apps as an encrypted private stack.
/// Runs after each successful update check. Only publishes when the set
/// of cataloged installed apps has changed since the last backup.
Future<void> _backupInstalledApps() async {
final devicePubkey = ref.read(devicePubkeyProvider);
if (devicePubkey == null) return;
final settings = await ref.read(settingsServiceProvider).load();
if (!settings.installedAppsBackupEnabled) return;
final pmNotifier = ref.read(packageManagerProvider.notifier);
final installed = ref.read(packageManagerProvider).installed;
final platform = pmNotifier.platform;
final storage = ref.read(storageNotifierProvider.notifier);
try {
final apps = await storage.query(
RequestFilter<App>(
tags: {
'#d': installed.keys.toSet(),
'#f': {platform},
},
).toRequest(),
source: const LocalSource(),
subscriptionPrefix: 'app-backup-resolve',
);
final appIds =
apps
.map((a) => '${a.event.kind}:${a.event.pubkey}:${a.identifier}')
.toList()
..sort();
if (_listEquals(appIds, _lastBackedUpIds)) return;
final partialStack = PartialAppStack.withEncryptedApps(
name: 'Installed Apps',
identifier: kInstalledAppsIdentifier,
apps: appIds,
platform: platform,
);
await ref
.read(devicePrivateEventServiceProvider)
.saveDraftAndQueue(partialStack);
_lastBackedUpIds = appIds;
} catch (e, st) {
LogService.I.warn(
'installed apps backup failed',
tag: 'updates',
err: e,
stack: st,
);
}
}
static bool _listEquals(List<String> a, List<String> b) {
if (a.length != b.length) return false;
for (var i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false;
}
return true;
}
@override
void dispose() {
_pollTimer?.cancel();
@@ -1,61 +0,0 @@
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'),
),
],
);
}
}
@@ -0,0 +1,51 @@
# WORK-025 — Remove Installed Apps Backup
**Feature:** FEAT-006-device-key.md
**Status:** In Progress
## Tasks
- [x] 1. Remove the Installed Apps private-stack and backup setting UI.
- Files: `lib/screens/profile_screen.dart`
- [x] 2. Remove Installed Apps backup persistence and periodic publishing.
- Files: `lib/services/settings_service.dart`, `lib/services/updates_service.dart`
- [x] 3. Remove legacy Amber Installed Apps recovery.
- Files: `lib/services/device_backup_service.dart`, `lib/widgets/legacy_installed_apps_dialog.dart`
- [x] 4. Remove obsolete identifiers and update tests.
- Files: `lib/constants/app_constants.dart`, `test/services/settings_service_test.dart`
- [ ] 5. Self-review against the local-first, lifecycle, and data-robustness invariants.
## Test Coverage
| Scenario | Expected | Status |
|----------|----------|--------|
| Profile with old Installed Apps stack | Legacy stack is hidden | [x] |
| Portable settings round-trip | Remaining settings serialize and deserialize | [x] |
| Update refresh | Updates continue without publishing an Installed Apps stack | [x] |
| Legacy device restore | Device-key restore completes without Installed Apps recovery UI | [x] |
## Decisions
### 2026-07-15 — Preserve installation and update management
**Context:** Installed Apps backup is separate from package scanning, installation, and the Updates screen.
**Decision:** Remove only the private-stack backup, profile presentation, and legacy recovery paths.
**Rationale:** This preserves core app distribution behavior while eliminating the deprecated backup feature.
### 2026-07-15 — Hide persisted legacy stacks
**Context:** Existing devices may still contain `zapstore-installed-apps` events.
**Decision:** Filter the legacy identifier from the private-stack profile list after removing new writes.
**Rationale:** Old data should not reappear as an unnamed or unsupported feature.
## Spec Issues
_None_
## Progress Notes
**2026-07-15:** Removed the backup UI, setting, update publisher, and legacy Amber recovery path. Existing legacy stacks are hidden from the profile. `fvm flutter analyze` and `HOME=/tmp fvm flutter test` pass.
## On Merge
Delete this work packet. Promote any non-obvious decision to `spec/knowledge/` if needed.
+1 -5
View File
@@ -22,13 +22,9 @@ void main() {
});
test('uses lower camel case portable JSON keys', () {
const settings = PortableSettings(
installedAppsBackupEnabled: true,
trustedSigners: {'a'},
);
const settings = PortableSettings(trustedSigners: {'a'});
expect(settings.toJson(), {
'installedAppsBackupEnabled': true,
'backgroundAutoUpdatesEnabled': false,
'trustedSigners': ['a'],
});