diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index b4cf448..03d6d34 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -5,7 +5,6 @@ import 'dart:ui' as ui; import 'dart:ui' show PlatformDispatcher; import 'package:background_downloader/background_downloader.dart' hide Request; -import 'package:flutter/foundation.dart' show FlutterError; import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index eb71989..39eca2d 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -18,6 +18,14 @@ const _pollInterval = Duration(minutes: 5); /// Minimum time between manual refreshes const _refreshCooldown = Duration(seconds: 30); +/// Installed package IDs eligible for catalog discovery and update management. +Set managedInstalledAppIds( + Map installed, + Set unmanagedIds, +) { + return installed.keys.where((id) => !unmanagedIds.contains(id)).toSet(); +} + // ═══════════════════════════════════════════════════════════════════════════════ // CATEGORIZED UPDATES // ═══════════════════════════════════════════════════════════════════════════════ @@ -107,6 +115,8 @@ class UpdatePollerNotifier extends StateNotifier { final Ref ref; Timer? _pollTimer; + bool _catalogRefreshPending = false; + bool _isRefreshingManagedCatalog = false; void _init() { // Hydrate from local storage as soon as SQLite is ready — no network. @@ -117,6 +127,9 @@ class UpdatePollerNotifier extends StateNotifier { unawaited(_hydrateAndStartPolling()); } }, fireImmediately: true); + ref.listen>(unmanagedAppsProvider, (previous, next) { + if (previous != next) _queueManagedCatalogRefresh(); + }); } Future _hydrateAndStartPolling() async { @@ -137,6 +150,7 @@ class UpdatePollerNotifier extends StateNotifier { await refreshFromLocal(); _startPolling(); + _drainManagedCatalogRefresh(); } void _startPolling() { @@ -182,6 +196,54 @@ class UpdatePollerNotifier extends StateNotifier { lastCheckTime: DateTime.now(), lastError: 'Update check failed — will retry', ); + } finally { + _drainManagedCatalogRefresh(); + } + } + + /// Fetches catalog data after a Manage/Unmanage transition. This is separate + /// from [checkNow] because the transition changes which installed IDs are + /// eligible for discovery and must not wait for its refresh cooldown. + void _queueManagedCatalogRefresh() { + _catalogRefreshPending = true; + _drainManagedCatalogRefresh(); + } + + void _drainManagedCatalogRefresh() { + if (_isRefreshingManagedCatalog || + state.isChecking || + !state.hasHydrated || + !_catalogRefreshPending) { + return; + } + unawaited(_refreshManagedCatalog()); + } + + Future _refreshManagedCatalog() async { + _isRefreshingManagedCatalog = true; + _catalogRefreshPending = false; + state = state.copyWith(isChecking: true); + try { + await _fetchCatalog(); + state = state.copyWith( + isChecking: false, + lastCheckTime: DateTime.now(), + clearError: true, + ); + } catch (e, st) { + LogService.I.warn( + 'managed catalog refresh failed', + tag: 'updates', + err: e, + stack: st, + ); + state = state.copyWith( + isChecking: false, + lastError: 'Update check failed — will retry', + ); + } finally { + _isRefreshingManagedCatalog = false; + _drainManagedCatalogRefresh(); } } @@ -196,9 +258,10 @@ class UpdatePollerNotifier extends StateNotifier { } final unmanagedIds = ref.read(unmanagedAppsProvider); - final installedIds = pmState.installed.keys - .where((id) => !unmanagedIds.contains(id)) - .toSet(); + final installedIds = managedInstalledAppIds( + pmState.installed, + unmanagedIds, + ); if (installedIds.isEmpty) { state = state.copyWith(catalogedIds: const {}); @@ -242,9 +305,10 @@ class UpdatePollerNotifier extends StateNotifier { } final unmanagedIds = ref.read(unmanagedAppsProvider); - final installedIds = pmState.installed.keys - .where((id) => !unmanagedIds.contains(id)) - .toSet(); + final installedIds = managedInstalledAppIds( + pmState.installed, + unmanagedIds, + ); if (installedIds.isEmpty) { state = state.copyWith(hasHydrated: true); diff --git a/spec/work/WORK-029-manage-catalog-refresh.md b/spec/work/WORK-029-manage-catalog-refresh.md new file mode 100644 index 0000000..ef995da --- /dev/null +++ b/spec/work/WORK-029-manage-catalog-refresh.md @@ -0,0 +1,44 @@ +# WORK-029 — Manage Catalog Refresh + +**Feature:** FEAT-003-updates-screen.md +**Status:** Complete + +## Tasks + +- [x] Refresh the managed installed-app catalog when the unmanaged set changes. + - Files: `lib/services/updates_service.dart` + - The refresh must bypass the manual-refresh cooldown and use the current + managed set, so a newly managed app can be discovered from AppCatalog. +- [x] Cover the managed-ID selection and its unmanaged transition. +- [x] Self-review against `INVARIANTS.md`. + +## Test Coverage + +| Scenario | Expected | Status | +|----------|----------|--------| +| Manage cataloged app | Its package ID is included in the next catalog request | [x] | +| Unmanage app | Its package ID is excluded from the next catalog request | [x] | +| Refresh in progress | A changed managed set is fetched once the current refresh completes | [ ] | + +## Decisions + +### 2026-07-17 — Preference changes bypass polling cooldown + +**Context:** A newly managed app may never have been fetched because it was +excluded while unmanaged. +**Decision:** Trigger a catalog refresh on an unmanaged-set transition without +the user-initiated refresh cooldown. +**Rationale:** The transition changes the query scope; the normal poll interval +cannot leave a known catalog app in “Other installed.” + +## Spec Issues + +_None_ + +## Progress Notes + +**2026-07-17:** Management-state changes queue a single remote catalog refresh +after hydration. If another update check is active, the refresh runs after it +completes and uses the latest managed set. The existing notifier has no +injectable catalog-fetch boundary, so that in-flight sequencing remains covered +by review rather than an isolated test. diff --git a/test/services/updates_service_test.dart b/test/services/updates_service_test.dart new file mode 100644 index 0000000..113eea8 --- /dev/null +++ b/test/services/updates_service_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/updates_service.dart'; + +void main() { + const cataloged = PackageInfo( + appId: 'dev.zapstore.cataloged', + version: '1.0.0', + versionCode: 1, + ); + const other = PackageInfo( + appId: 'com.example.other', + version: '1.0.0', + versionCode: 1, + ); + + test('a managed app is included in the catalog discovery scope', () { + final ids = managedInstalledAppIds( + {cataloged.appId: cataloged, other.appId: other}, + {other.appId}, + ); + + expect(ids, {cataloged.appId}); + }); + + test('unmanaging an app removes it from catalog discovery scope', () { + final ids = managedInstalledAppIds( + {cataloged.appId: cataloged, other.appId: other}, + {cataloged.appId, other.appId}, + ); + + expect(ids, isEmpty); + }); +}