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 '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';
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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