Merge pull request #273 from zapstore/fix/262-background-update-notification

Fix background update notifications (WorkManager isolate) + installed apps snapshot (Fixes #262)
This commit is contained in:
franzap
2026-01-20 23:24:19 +00:00
committed by GitHub
7 changed files with 231 additions and 58 deletions
+1 -1
View File
@@ -201,7 +201,7 @@ class ZapstoreHome extends StatelessWidget {
}
final appInitializationProvider = FutureProvider<void>((ref) async {
final dir = await getApplicationDocumentsDirectory();
final dir = await getApplicationSupportDirectory();
final dbPath = path.join(dir.path, 'zapstore.db');
// Clear storage if requested from a clear all operation
+2 -2
View File
@@ -9,7 +9,7 @@ const _markerFileName = '.clear_on_restart';
/// Checks if storage should be cleared on this launch, and clears the marker.
/// Call this BEFORE initializing storage.
Future<void> maybeClearStorage(String dbPath) async {
final dir = await getApplicationDocumentsDirectory();
final dir = await getApplicationSupportDirectory();
final marker = File('${dir.path}/$_markerFileName');
if (await marker.exists()) {
final dbFile = File(dbPath);
@@ -22,7 +22,7 @@ Future<void> maybeClearStorage(String dbPath) async {
/// Sets a marker file and triggers a native app restart.
Future<void> restartApp() async {
final dir = await getApplicationDocumentsDirectory();
final dir = await getApplicationSupportDirectory();
final marker = File('${dir.path}/$_markerFileName');
await marker.create();
await _channel.invokeMethod('restart');
+71 -43
View File
@@ -1,7 +1,9 @@
import 'dart:io' show Directory, Platform;
import 'dart:io' show Directory, File, Platform;
import 'dart:ui' as ui;
import 'package:background_downloader/background_downloader.dart';
import 'package:background_downloader/background_downloader.dart' hide Request;
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:path/path.dart' as path;
@@ -9,7 +11,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:purplebase/purplebase.dart';
import 'package:workmanager/workmanager.dart';
import 'package:zapstore/services/package_manager/android_package_manager.dart';
import 'package:zapstore/services/package_manager/background_package_manager.dart';
import 'package:zapstore/services/package_manager/dummy_package_manager.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';
@@ -42,6 +44,8 @@ const kAppCatalogRelaysKey = 'appCatalogRelays';
/// This MUST be a top-level function (not a class method).
@pragma('vm:entry-point')
void callbackDispatcher() {
WidgetsFlutterBinding.ensureInitialized();
ui.DartPluginRegistrant.ensureInitialized();
Workmanager().executeTask((task, inputData) async {
switch (task) {
case kBackgroundUpdateTaskName:
@@ -81,11 +85,8 @@ Future<bool> _performWeeklyCleanup() async {
try {
// Delete the file if it exists
final filePath = await task.filePath();
final file = Directory(filePath).parent.listSync().firstWhere(
(f) => f.path.endsWith(task.filename),
orElse: () => Directory(''),
);
if (file.path.isNotEmpty) {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
@@ -105,14 +106,14 @@ Future<bool> _performWeeklyCleanup() async {
);
if (await downloadDir.exists()) {
final files = downloadDir.listSync();
final entities = downloadDir.listSync();
final cutoff = DateTime.now().subtract(_staleDownloadThreshold);
for (final file in files) {
if (file is! Directory) {
final stat = await file.stat();
for (final entity in entities) {
if (entity is File) {
final stat = await entity.stat();
if (stat.modified.isBefore(cutoff)) {
await file.delete();
await entity.delete();
}
}
}
@@ -145,7 +146,7 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
storageNotifierProvider.overrideWith(PurplebaseStorageNotifier.new),
packageManagerProvider.overrideWith(
(ref) => Platform.isAndroid
? AndroidPackageManager(ref)
? BackgroundPackageManager(ref)
: DummyPackageManager(ref),
),
],
@@ -153,7 +154,7 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
try {
// Initialize Purplebase with same DB path as main app
final dir = await getApplicationDocumentsDirectory();
final dir = await getApplicationSupportDirectory();
final dbPath = path.join(dir.path, 'zapstore.db');
await container.read(
@@ -188,18 +189,40 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
// Load releases for all apps in a single query
// Load releases and their metadata/assets (required for hasUpdate)
if (apps.isNotEmpty) {
final addressableIds = apps
.map((app) => app.event.addressableId)
.toSet();
await storage.query(
RequestFilter<Release>(tags: {'#a': addressableIds}).toRequest(),
source: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
);
final releaseFilters = apps
.map((app) => app.latestRelease.req?.filters.firstOrNull)
.nonNulls
.toList();
if (releaseFilters.isNotEmpty) {
final List<Release> releases = await storage.query(
Request<Release>(releaseFilters),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
final metadataFilters = releases
.map((r) => r.latestMetadata.req?.filters.firstOrNull)
.nonNulls
.toList();
if (metadataFilters.isNotEmpty) {
await storage.query(
Request<FileMetadata>(metadataFilters),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
}
final assetFilters = releases
.map((r) => r.latestAsset.req?.filters.firstOrNull)
.nonNulls
.toList();
if (assetFilters.isNotEmpty) {
await storage.query(
Request<SoftwareAsset>(assetFilters),
source: const RemoteSource(relays: 'AppCatalog', stream: false),
);
}
}
}
// Re-query apps from local to ensure relationships are loaded
@@ -255,6 +278,7 @@ Future<void> _showUpdateNotification(
android: initializationSettingsAndroid,
);
await flutterLocalNotificationsPlugin.initialize(initializationSettings);
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
// Build notification content
final title = updateCount == 1
@@ -366,23 +390,7 @@ class BackgroundUpdateService {
// Navigation is handled by the app's normal launch flow
},
);
// Create notification channel on Android
final androidPlugin = flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
await androidPlugin.createNotificationChannel(
const AndroidNotificationChannel(
kUpdateNotificationChannelId,
kUpdateNotificationChannelName,
description: kUpdateNotificationChannelDescription,
importance: Importance.defaultImportance,
),
);
}
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
}
/// Cancel background update checks
@@ -405,6 +413,26 @@ class BackgroundUpdateService {
}
}
Future<void> _ensureUpdateNotificationChannel(
FlutterLocalNotificationsPlugin plugin,
) async {
final androidPlugin = plugin
.resolvePlatformSpecificImplementation<
AndroidFlutterLocalNotificationsPlugin
>();
if (androidPlugin != null) {
await androidPlugin.createNotificationChannel(
const AndroidNotificationChannel(
kUpdateNotificationChannelId,
kUpdateNotificationChannelName,
description: kUpdateNotificationChannelDescription,
importance: Importance.defaultImportance,
),
);
}
}
/// Provider for the background update service
final backgroundUpdateServiceProvider = Provider<BackgroundUpdateService>(
BackgroundUpdateService.new,
@@ -4,6 +4,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
/// Install status values from native side.
@@ -587,6 +588,7 @@ final class AndroidPackageManager extends PackageManager {
}
state = state.copyWith(installed: {...packages, ...preserved});
await InstalledPackagesSnapshot.save(state.installed);
// Clear operations for apps where the installed version matches the target version
// This catches installs that succeeded but we missed the event
@@ -0,0 +1,53 @@
import 'package:models/models.dart';
import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
/// Background-safe PackageManager that avoids EventChannel usage.
final class BackgroundPackageManager extends PackageManager {
BackgroundPackageManager(super.ref);
// Zapstore currently targets arm64 APKs for background checks.
@override
String get platform => 'android-arm64-v8a';
@override
String get packageExtension => '.apk';
@override
bool get supportsSilentInstall => false;
@override
Future<void> install(
String appId,
String filePath, {
required String expectedHash,
required int expectedSize,
required FileMetadata target,
}) {
throw UnsupportedError('Install not supported in background');
}
@override
Future<void> uninstall(String appId) {
throw UnsupportedError('Uninstall not supported in background');
}
@override
Future<void> launchApp(String appId) {
throw UnsupportedError('Launch not supported in background');
}
@override
Future<void> requestPermission() {
throw UnsupportedError('Permission not supported in background');
}
@override
Future<bool> hasPermission() async => false;
@override
Future<void> syncInstalledPackages() async {
final installed = await InstalledPackagesSnapshot.load();
state = state.copyWith(installed: installed);
}
}
@@ -0,0 +1,90 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
class InstalledPackagesSnapshot {
static const _fileName = 'installed_packages_snapshot.json';
static Future<File> _file() async {
final dir = await getApplicationSupportDirectory();
return File(path.join(dir.path, _fileName));
}
static Future<void> save(Map<String, PackageInfo> installed) async {
try {
final file = await _file();
final tmp = File('${file.path}.tmp');
final list = installed.values
.map(
(p) => <String, dynamic>{
'appId': p.appId,
'name': p.name,
'version': p.version,
'versionCode': p.versionCode,
'signatureHash': p.signatureHash,
'canInstallSilently': p.canInstallSilently,
},
)
.toList(growable: false);
final payload = jsonEncode({
'v': 1,
'savedAt': DateTime.now().millisecondsSinceEpoch,
'installed': list,
});
await tmp.writeAsString(payload, flush: true);
if (await file.exists()) {
await file.delete();
}
await tmp.rename(file.path);
} catch (e) {
// Best-effort snapshot only.
if (kDebugMode) {
debugPrint('[InstalledPackagesSnapshot] Save failed: $e');
}
}
}
static Future<Map<String, PackageInfo>> load() async {
try {
final file = await _file();
if (!await file.exists()) return {};
final raw = await file.readAsString();
final decoded = jsonDecode(raw);
if (decoded is! Map) return {};
final version = decoded['v'];
if (version != null && version is! int && kDebugMode) {
debugPrint('[InstalledPackagesSnapshot] Unknown schema: $version');
}
final installed = decoded['installed'];
if (installed is! List) return {};
final result = <String, PackageInfo>{};
for (final item in installed) {
if (item is! Map) continue;
final map = Map<String, dynamic>.from(item);
final appId = map['appId'] as String?;
if (appId == null || appId.isEmpty) continue;
result[appId] = PackageInfo(
appId: appId,
name: map['name'] as String?,
version: (map['version'] as String?) ?? '0.0.0',
versionCode: map['versionCode'] as int?,
signatureHash: (map['signatureHash'] as String?) ?? '',
installTime: null,
canInstallSilently: (map['canInstallSilently'] as bool?) ?? false,
);
}
return result;
} catch (e) {
if (kDebugMode) {
debugPrint('[InstalledPackagesSnapshot] Load failed: $e');
}
return {};
}
}
}
+12 -12
View File
@@ -581,26 +581,26 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.9"
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "3.0.2"
lints:
dependency: transitive
description:
@@ -653,10 +653,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: transitive
description:
@@ -1141,10 +1141,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.4"
version: "0.7.7"
timezone:
dependency: transitive
description:
@@ -1261,10 +1261,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.2.0"
vm_service:
dependency: transitive
description: