mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
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:
+1
-1
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user