mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Fix refresh re-managed apps
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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<String> managedInstalledAppIds(
|
||||
Map<String, PackageInfo> installed,
|
||||
Set<String> unmanagedIds,
|
||||
) {
|
||||
return installed.keys.where((id) => !unmanagedIds.contains(id)).toSet();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CATEGORIZED UPDATES
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -107,6 +115,8 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
||||
|
||||
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<UpdatePollerState> {
|
||||
unawaited(_hydrateAndStartPolling());
|
||||
}
|
||||
}, fireImmediately: true);
|
||||
ref.listen<Set<String>>(unmanagedAppsProvider, (previous, next) {
|
||||
if (previous != next) _queueManagedCatalogRefresh();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _hydrateAndStartPolling() async {
|
||||
@@ -137,6 +150,7 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
||||
await refreshFromLocal();
|
||||
|
||||
_startPolling();
|
||||
_drainManagedCatalogRefresh();
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
@@ -182,6 +196,54 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
|
||||
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<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 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<UpdatePollerState> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user