Fix refresh re-managed apps

This commit is contained in:
franzap
2026-07-17 17:05:58 -03:00
parent 0490a3459c
commit 9a99e59f25
4 changed files with 148 additions and 7 deletions
@@ -5,7 +5,6 @@ import 'dart:ui' as ui;
import 'dart:ui' show PlatformDispatcher; import 'dart:ui' show PlatformDispatcher;
import 'package:background_downloader/background_downloader.dart' hide Request; import 'package:background_downloader/background_downloader.dart' hide Request;
import 'package:flutter/foundation.dart' show FlutterError;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.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';
+70 -6
View File
@@ -18,6 +18,14 @@ const _pollInterval = Duration(minutes: 5);
/// Minimum time between manual refreshes /// Minimum time between manual refreshes
const _refreshCooldown = Duration(seconds: 30); const _refreshCooldown = Duration(seconds: 30);
/// Installed package IDs eligible for catalog discovery and update management.
Set<String> managedInstalledAppIds(
Map<String, PackageInfo> installed,
Set<String> unmanagedIds,
) {
return installed.keys.where((id) => !unmanagedIds.contains(id)).toSet();
}
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
// CATEGORIZED UPDATES // CATEGORIZED UPDATES
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
@@ -107,6 +115,8 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
final Ref ref; final Ref ref;
Timer? _pollTimer; Timer? _pollTimer;
bool _catalogRefreshPending = false;
bool _isRefreshingManagedCatalog = false;
void _init() { void _init() {
// Hydrate from local storage as soon as SQLite is ready — no network. // Hydrate from local storage as soon as SQLite is ready — no network.
@@ -117,6 +127,9 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
unawaited(_hydrateAndStartPolling()); unawaited(_hydrateAndStartPolling());
} }
}, fireImmediately: true); }, fireImmediately: true);
ref.listen<Set<String>>(unmanagedAppsProvider, (previous, next) {
if (previous != next) _queueManagedCatalogRefresh();
});
} }
Future<void> _hydrateAndStartPolling() async { Future<void> _hydrateAndStartPolling() async {
@@ -137,6 +150,7 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
await refreshFromLocal(); await refreshFromLocal();
_startPolling(); _startPolling();
_drainManagedCatalogRefresh();
} }
void _startPolling() { void _startPolling() {
@@ -182,6 +196,54 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
lastCheckTime: DateTime.now(), lastCheckTime: DateTime.now(),
lastError: 'Update check failed — will retry', 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<void> _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<UpdatePollerState> {
} }
final unmanagedIds = ref.read(unmanagedAppsProvider); final unmanagedIds = ref.read(unmanagedAppsProvider);
final installedIds = pmState.installed.keys final installedIds = managedInstalledAppIds(
.where((id) => !unmanagedIds.contains(id)) pmState.installed,
.toSet(); unmanagedIds,
);
if (installedIds.isEmpty) { if (installedIds.isEmpty) {
state = state.copyWith(catalogedIds: const {}); state = state.copyWith(catalogedIds: const {});
@@ -242,9 +305,10 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
} }
final unmanagedIds = ref.read(unmanagedAppsProvider); final unmanagedIds = ref.read(unmanagedAppsProvider);
final installedIds = pmState.installed.keys final installedIds = managedInstalledAppIds(
.where((id) => !unmanagedIds.contains(id)) pmState.installed,
.toSet(); unmanagedIds,
);
if (installedIds.isEmpty) { if (installedIds.isEmpty) {
state = state.copyWith(hasHydrated: true); state = state.copyWith(hasHydrated: true);
@@ -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.
+34
View File
@@ -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);
});
}