From c91934b5515e6a357d2f0d90a061596c16a1a8c0 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Tue, 9 Jun 2026 09:14:37 -0300 Subject: [PATCH] Add background auto-updates with Wi-Fi-only downloads and silent or staged installs --- .../kotlin/dev/zapstore/app/MainActivity.kt | 5 +- .../plugins/AndroidPackageManagerPlugin.kt | 194 +++++++++++ lib/screens/app_detail_screen.dart | 3 +- lib/screens/profile_screen.dart | 40 +++ .../background_auto_update_executor.dart | 194 +++++++++++ lib/services/background_native_installer.dart | 121 +++++++ .../background_pending_install_store.dart | 186 +++++++++++ lib/services/background_update_service.dart | 316 +++++++++++++++--- .../background_package_manager.dart | 48 ++- lib/services/settings_service.dart | 8 + .../android/build.gradle | 40 +++ .../android/src/main/AndroidManifest.xml | 1 + .../AndroidPackageManagerRegistrantPlugin.kt | 46 +++ .../lib/installed_apps_plugin.dart | 2 + plugins/installed_apps_plugin/pubspec.yaml | 18 + pubspec.lock | 7 + pubspec.yaml | 2 + .../FEAT-007-background-auto-updates.md | 108 ++++++ spec/work/WORK-013-background-auto-updates.md | 38 +++ test/services/settings_service_test.dart | 23 ++ 20 files changed, 1342 insertions(+), 58 deletions(-) create mode 100644 lib/services/background_auto_update_executor.dart create mode 100644 lib/services/background_native_installer.dart create mode 100644 lib/services/background_pending_install_store.dart create mode 100644 plugins/installed_apps_plugin/android/build.gradle create mode 100644 plugins/installed_apps_plugin/android/src/main/AndroidManifest.xml create mode 100644 plugins/installed_apps_plugin/android/src/main/kotlin/dev/zapstore/app/plugins/registration/AndroidPackageManagerRegistrantPlugin.kt create mode 100644 plugins/installed_apps_plugin/lib/installed_apps_plugin.dart create mode 100644 plugins/installed_apps_plugin/pubspec.yaml create mode 100644 spec/features/FEAT-007-background-auto-updates.md create mode 100644 spec/work/WORK-013-background-auto-updates.md create mode 100644 test/services/settings_service_test.dart diff --git a/android/app/src/main/kotlin/dev/zapstore/app/MainActivity.kt b/android/app/src/main/kotlin/dev/zapstore/app/MainActivity.kt index d901f14..66276e8 100644 --- a/android/app/src/main/kotlin/dev/zapstore/app/MainActivity.kt +++ b/android/app/src/main/kotlin/dev/zapstore/app/MainActivity.kt @@ -1,6 +1,5 @@ package dev.zapstore.app -import dev.zapstore.app.plugins.AndroidPackageManagerPlugin import dev.zapstore.app.plugins.AppRestartPlugin import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -9,8 +8,8 @@ class MainActivity: FlutterActivity() { override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) - // Register plugins - flutterEngine.plugins.add(AndroidPackageManagerPlugin()) + // Register the activity-only plugin. AndroidPackageManagerPlugin is + // auto-registered so WorkManager background engines receive it too. flutterEngine.plugins.add(AppRestartPlugin()) } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt index 5de6064..ff38a98 100644 --- a/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt +++ b/android/app/src/main/kotlin/dev/zapstore/app/plugins/AndroidPackageManagerPlugin.kt @@ -140,6 +140,10 @@ class AndroidPackageManagerPlugin : private var appContext: Context? = null + /** Blocking install results for background auto-updates */ + private val awaitLatches = ConcurrentHashMap() + private val awaitResults = ConcurrentHashMap>() + /** * Weak reference to the current Activity. Used by launchConfirmDialog to start * the PackageInstaller confirmation dialog in the same task as the app, which @@ -172,6 +176,12 @@ class AndroidPackageManagerPlugin : // Handle pending user action - store intent for potential re-launch if (status == InstallStatus.PENDING_USER_ACTION && confirmIntent != null) { pendingUserActionIntents[pkg] = confirmIntent + resolveAwait( + pkg, + status, + message ?: "User confirmation pending", + errorCode + ) // Track this session as pending user action so SessionCallback can detect // acceptance @@ -191,6 +201,7 @@ class AndroidPackageManagerPlugin : InstallStatus.CANCELLED ) ) { + resolveAwait(pkg, status, message, errorCode) sessionToPackage.remove(sessionId) pendingUserActionIntents.remove(pkg) instance?.sessionsPendingUserAction?.remove(sessionId) @@ -285,6 +296,25 @@ class AndroidPackageManagerPlugin : } fun isAppInForeground(): Boolean = isAppInForeground + + private fun resolveAwait( + packageName: String, + status: String, + message: String?, + errorCode: String? + ) { + val latch = awaitLatches.remove(packageName) ?: return + val result = + mutableMapOf( + "success" to (status == InstallStatus.SUCCESS), + "cancelled" to (status == InstallStatus.CANCELLED), + "needsUserAction" to (status == InstallStatus.PENDING_USER_ACTION), + ) + if (message != null) result["error"] = message + if (errorCode != null) result["errorCode"] = errorCode + awaitResults[packageName] = result + latch.countDown() + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -663,6 +693,38 @@ class AndroidPackageManagerPlugin : } abortInstall(packageName, result) } + "verifyApk" -> { + val filePath = call.argument("filePath") + val expectedHash = call.argument("expectedHash") + val expectedCertHashes = + call.argument>("expectedCertHashes") ?: emptyList() + if (filePath == null || expectedHash == null) { + result.error("MISSING_ARGUMENT", "filePath and expectedHash required", null) + return + } + verifyApkOnly(filePath, expectedHash, expectedCertHashes, result) + } + "installAndAwait" -> { + val filePath = call.argument("filePath") + val packageName = call.argument("packageName") + val expectedHash = call.argument("expectedHash") + val expectedSize = call.argument("expectedSize")?.toLong() + val expectedCertHashes = + call.argument>("expectedCertHashes") ?: emptyList() + + if (filePath == null || packageName == null) { + result.error("MISSING_ARGUMENT", "filePath and packageName required", null) + return + } + installApkAndAwait( + filePath, + packageName, + expectedHash, + expectedSize, + expectedCertHashes, + result + ) + } else -> result.notImplemented() } } @@ -671,6 +733,138 @@ class AndroidPackageManagerPlugin : // INSTALLATION // ═══════════════════════════════════════════════════════════════════════════════ + private fun verifyApkOnly( + filePath: String, + expectedHash: String, + expectedCertHashes: List, + result: Result + ) { + Thread { + val file = File(filePath) + if (!file.exists()) { + mainHandler.post { + result.success(mapOf("valid" to false, "error" to "APK file not found")) + } + return@Thread + } + if (!isValidApkFormat(file)) { + mainHandler.post { + result.success(mapOf("valid" to false, "error" to "Invalid APK file")) + } + return@Thread + } + if (expectedCertHashes.isNotEmpty()) { + val actualCertHashes = extractApkCertHashes(file.absolutePath) + if (actualCertHashes == null) { + mainHandler.post { + result.success( + mapOf( + "valid" to false, + "error" to "Could not verify APK certificate" + ) + ) + } + return@Thread + } + val matched = + actualCertHashes.any { actual -> + expectedCertHashes.any { expected -> + actual.equals(expected, ignoreCase = true) + } + } + if (!matched) { + mainHandler.post { + result.success( + mapOf( + "valid" to false, + "error" to "Certificate mismatch" + ) + ) + } + return@Thread + } + } + val digest = MessageDigest.getInstance("SHA-256") + FileInputStream(file).use { fis -> + val buffer = ByteArray(COPY_BUFFER_SIZE) + var bytesRead: Int + while (fis.read(buffer).also { bytesRead = it } != -1) { + digest.update(buffer, 0, bytesRead) + } + } + val actualHash = digest.digest().joinToString("") { "%02x".format(it) } + val valid = actualHash.equals(expectedHash, ignoreCase = true) + mainHandler.post { + val response = mutableMapOf("valid" to valid) + if (!valid) response["error"] = "Hash verification failed" + result.success(response) + } + } + .start() + } + + private fun installApkAndAwait( + filePath: String, + packageName: String, + expectedHash: String?, + expectedSize: Long?, + expectedCertHashes: List, + result: Result + ) { + val latch = CountDownLatch(1) + awaitLatches[packageName] = latch + + installApk(filePath, packageName, expectedHash, expectedSize, expectedCertHashes, object : Result { + override fun success(response: Any?) { + val responseMap = + (response as? Map<*, *>)?.entries?.associate { + it.key.toString() to it.value + } + if (responseMap?.get("started") != true) { + awaitLatches.remove(packageName) + awaitResults[packageName] = + responseMap + ?: mapOf( + "success" to false, + "error" to "Failed to start install" + ) + latch.countDown() + } + } + + override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { + awaitLatches.remove(packageName) + awaitResults[packageName] = + mapOf( + "success" to false, + "error" to (errorMessage ?: errorCode) + ) + latch.countDown() + } + + override fun notImplemented() { + awaitLatches.remove(packageName) + awaitResults[packageName] = + mapOf("success" to false, "error" to "not implemented") + latch.countDown() + } + }) + + Thread { + val completed = latch.await(10, TimeUnit.MINUTES) + val awaitResult = + if (completed) { + awaitResults.remove(packageName) + ?: mapOf("success" to false, "error" to "No result") + } else { + awaitLatches.remove(packageName) + mapOf("success" to false, "error" to "Install timed out") + } + mainHandler.post { result.success(awaitResult) } + } + .start() + } + private fun installApk( filePath: String, packageName: String, diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 6359172..87f49b9 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; @@ -146,7 +147,7 @@ class _AppDetailContent extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - final showDebugSections = isDebugMode(signedInPubkey); + final showDebugSections = kDebugMode || isDebugMode(signedInPubkey); // Query author profile from social relays final authorState = ref.watch( diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 9d60b09..0e438ac 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -1514,6 +1514,44 @@ class _DeviceKeyCard extends HookConsumerWidget { } } +class _BackgroundAutoUpdatesToggle extends ConsumerWidget { + const _BackgroundAutoUpdatesToggle(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settingsAsync = ref.watch(localSettingsProvider); + final enabled = + settingsAsync.valueOrNull?.backgroundAutoUpdatesEnabled ?? false; + + return SwitchListTile( + secondary: CircleAvatar( + radius: 18, + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.12), + child: Icon( + Icons.system_update_alt, + color: Theme.of(context).colorScheme.primary, + size: 20, + ), + ), + title: const Text('Background auto-updates'), + subtitle: const Text( + 'When on, periodic background checks download and apply updates. ' + 'Manual updates are downloaded and shown as ready to install.', + ), + value: enabled, + contentPadding: EdgeInsets.zero, + onChanged: (value) async { + await ref + .read(settingsServiceProvider) + .update((s) => s.copyWith(backgroundAutoUpdatesEnabled: value)); + ref.invalidate(localSettingsProvider); + }, + ); + } +} + class _InstalledAppsBackupToggle extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { @@ -1569,6 +1607,8 @@ class _DataManagementSection extends ConsumerWidget { const SizedBox(height: 8), _InstalledAppsBackupToggle(), const SizedBox(height: 8), + const _BackgroundAutoUpdatesToggle(), + const SizedBox(height: 8), ListTile( leading: CircleAvatar( radius: 18, diff --git a/lib/services/background_auto_update_executor.dart b/lib/services/background_auto_update_executor.dart new file mode 100644 index 0000000..bd1c913 --- /dev/null +++ b/lib/services/background_auto_update_executor.dart @@ -0,0 +1,194 @@ +import 'package:collection/collection.dart'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:models/models.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:zapstore/services/background_native_installer.dart'; +import 'package:zapstore/services/background_pending_install_store.dart'; +import 'package:zapstore/services/log_service.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Summary of a background auto-update run. +class BackgroundAutoUpdateResult { + const BackgroundAutoUpdateResult({ + this.updatedAppIds = const [], + this.readyAppIds = const [], + this.failedAppIds = const [], + }); + + final List updatedAppIds; + final List readyAppIds; + final List failedAppIds; + + bool get hasWork => + updatedAppIds.isNotEmpty || + readyAppIds.isNotEmpty || + failedAppIds.isNotEmpty; +} + +/// Download and apply updates from the WorkManager background isolate. +class BackgroundAutoUpdateExecutor { + static const _downloadTimeout = Duration(minutes: 15); + + static Future run({ + required Map updatableInstallables, + required Map installed, + required Map displayNames, + }) async { + final updatedAppIds = []; + final readyAppIds = []; + final failedAppIds = []; + final pendingInstalls = await BackgroundPendingInstallStore.loadAll(); + + for (final entry in updatableInstallables.entries) { + final appId = entry.key; + final target = entry.value; + final pkg = installed[appId]; + if (pkg == null) continue; + + final pending = pendingInstalls[appId]; + if (!pkg.canInstallSilently && pending?.hash == target.hash) { + // This exact manual update is already downloaded and verified. It was + // reported when first staged, so avoid downloading and notifying again. + continue; + } + + final displayName = displayNames[appId] ?? pkg.name ?? appId; + + try { + final filePath = await _downloadApk(appId, target); + if (filePath == null) { + failedAppIds.add(appId); + continue; + } + + final verified = await BackgroundNativeInstaller.verifyApk( + filePath: filePath, + expectedHash: target.hash, + expectedCertHashes: target.certificateHashes.toList(), + ); + if (!verified) { + await _deleteFile(filePath); + failedAppIds.add(appId); + continue; + } + + if (pkg.canInstallSilently) { + final result = await BackgroundNativeInstaller.installAndAwait( + appId: appId, + filePath: filePath, + expectedHash: target.hash, + expectedSize: target.size ?? 0, + expectedCertHashes: target.certificateHashes.toList(), + ); + if (result.success) { + updatedAppIds.add(appId); + await _deleteFile(filePath); + } else { + await _deleteFile(filePath); + failedAppIds.add(appId); + } + } else { + final staged = await BackgroundPendingInstallStore.save( + PendingBackgroundInstall.fromInstallable( + appId: appId, + displayName: displayName, + filePath: filePath, + target: target, + ), + ); + if (staged) { + readyAppIds.add(appId); + } else { + await _deleteFile(filePath); + failedAppIds.add(appId); + } + } + } catch (e, st) { + LogService.I.warn( + 'background auto-update failed for app', + tag: 'background_updates', + fields: {'appId': appId}, + err: e, + stack: st, + ); + failedAppIds.add(appId); + } + } + + return BackgroundAutoUpdateResult( + updatedAppIds: updatedAppIds, + readyAppIds: readyAppIds, + failedAppIds: failedAppIds, + ); + } + + static String? resolveDownloadUrl(Installable target) { + final first = target.urls.firstOrNull; + if (first == null || first.isEmpty) return null; + if (Uri.tryParse(first)?.host == 'cdn.zapstore.dev') return first; + return 'https://cdn.zapstore.dev/${target.hash}?redirect=true'; + } + + static Future _downloadApk(String appId, Installable target) async { + final url = resolveDownloadUrl(target); + if (url == null) return null; + + final dir = await _downloadDir(); + final fileName = '${target.hash}.apk'; + final filePath = path.join(dir.path, '${appId}_$fileName'); + final file = File(filePath); + + if (await file.exists()) { + await file.delete(); + } + + final client = http.Client(); + try { + final request = http.Request('GET', Uri.parse(url)); + request.headers['X-Zapstore-Client'] = 'app'; + request.headers['X-Zapstore-Download-Type'] = 'update'; + + final response = await client.send(request).timeout(_downloadTimeout); + + if (response.statusCode != 200) { + LogService.I.warn( + 'background download failed', + tag: 'background_updates', + fields: {'appId': appId, 'status': response.statusCode.toString()}, + ); + return null; + } + + final sink = file.openWrite(); + try { + await response.stream.pipe(sink); + } finally { + await sink.close(); + } + return filePath; + } finally { + client.close(); + } + } + + static Future _downloadDir() async { + final support = await getApplicationSupportDirectory(); + final dir = Directory(path.join(support.path, 'background_updates')); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + static Future _deleteFile(String? filePath) async { + if (filePath == null) return; + try { + final file = File(filePath); + if (await file.exists()) await file.delete(); + } catch (_) {} + } +} diff --git a/lib/services/background_native_installer.dart b/lib/services/background_native_installer.dart new file mode 100644 index 0000000..b08fbd1 --- /dev/null +++ b/lib/services/background_native_installer.dart @@ -0,0 +1,121 @@ +import 'package:flutter/services.dart'; +import 'package:zapstore/services/log_service.dart'; + +/// Native install helpers usable from the WorkManager background isolate. +class BackgroundNativeInstaller { + static const _channel = MethodChannel('android_package_manager'); + + /// Verify APK hash and signing metadata without installing. + static Future verifyApk({ + required String filePath, + required String expectedHash, + required List expectedCertHashes, + }) async { + try { + final result = await _channel.invokeMethod>( + 'verifyApk', + { + 'filePath': filePath, + 'expectedHash': expectedHash, + 'expectedCertHashes': expectedCertHashes, + }, + ); + final map = Map.from(result ?? {}); + return map['valid'] == true; + } catch (e, st) { + LogService.I.warn( + 'background APK verify failed', + tag: 'background_updates', + fields: {'filePath': filePath}, + err: e, + stack: st, + ); + return false; + } + } + + /// Install silently and block until a terminal result is known. + static Future installAndAwait({ + required String appId, + required String filePath, + required String expectedHash, + required int expectedSize, + required List expectedCertHashes, + }) async { + try { + final result = await _channel.invokeMethod>( + 'installAndAwait', + { + 'filePath': filePath, + 'packageName': appId, + 'expectedHash': expectedHash, + 'expectedSize': expectedSize, + 'expectedCertHashes': expectedCertHashes, + }, + ); + final map = Map.from(result ?? {}); + return BackgroundInstallResult( + success: map['success'] == true, + cancelled: map['cancelled'] == true, + needsUserAction: map['needsUserAction'] == true, + error: map['error'] as String?, + ); + } catch (e, st) { + LogService.I.warn( + 'background installAndAwait failed', + tag: 'background_updates', + fields: {'appId': appId}, + err: e, + stack: st, + ); + return BackgroundInstallResult(success: false, error: e.toString()); + } + } + + /// Launch the Android install confirmation dialog for a prepared manual update. + static Future launchPreparedInstall({ + required String appId, + required String filePath, + required String expectedHash, + required int expectedSize, + required List expectedCertHashes, + }) async { + try { + final result = await _channel.invokeMethod>( + 'install', + { + 'filePath': filePath, + 'packageName': appId, + 'expectedHash': expectedHash, + 'expectedSize': expectedSize, + 'expectedCertHashes': expectedCertHashes, + }, + ); + final map = Map.from(result ?? {}); + return map['started'] == true || map['alreadyInProgress'] == true; + } catch (e, st) { + LogService.I.warn( + 'launch prepared install failed', + tag: 'background_updates', + fields: {'appId': appId}, + err: e, + stack: st, + ); + return false; + } + } +} + +class BackgroundInstallResult { + const BackgroundInstallResult({ + required this.success, + this.cancelled = false, + this.needsUserAction = false, + this.error, + }); + + final bool success; + final bool cancelled; + final bool needsUserAction; + final String? error; +} diff --git a/lib/services/background_pending_install_store.dart b/lib/services/background_pending_install_store.dart new file mode 100644 index 0000000..8642262 --- /dev/null +++ b/lib/services/background_pending_install_store.dart @@ -0,0 +1,186 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:models/models.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:zapstore/services/log_service.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// A manual update downloaded and verified in the background, awaiting user +/// confirmation via the Android system install dialog. +class PendingBackgroundInstall { + const PendingBackgroundInstall({ + required this.appId, + required this.displayName, + required this.filePath, + required this.hash, + required this.size, + required this.version, + required this.versionCode, + required this.certificateHashes, + required this.installableId, + }); + + final String appId; + final String displayName; + final String filePath; + final String hash; + final int size; + final String version; + final int? versionCode; + final List certificateHashes; + final String installableId; + + Map toJson() => { + 'appId': appId, + 'displayName': displayName, + 'filePath': filePath, + 'hash': hash, + 'size': size, + 'version': version, + if (versionCode != null) 'versionCode': versionCode, + 'certificateHashes': certificateHashes, + 'installableId': installableId, + }; + + factory PendingBackgroundInstall.fromJson(Map json) { + return PendingBackgroundInstall( + appId: json['appId'] as String, + displayName: json['displayName'] as String? ?? json['appId'] as String, + filePath: json['filePath'] as String, + hash: json['hash'] as String, + size: (json['size'] as num?)?.toInt() ?? 0, + version: json['version'] as String? ?? '0.0.0', + versionCode: (json['versionCode'] as num?)?.toInt(), + certificateHashes: + (json['certificateHashes'] as List?)?.cast() ?? const [], + installableId: json['installableId'] as String? ?? json['hash'] as String, + ); + } + + static PendingBackgroundInstall fromInstallable({ + required String appId, + required String displayName, + required String filePath, + required Installable target, + }) { + return PendingBackgroundInstall( + appId: appId, + displayName: displayName, + filePath: filePath, + hash: target.hash, + size: target.size ?? 0, + version: target.version, + versionCode: target.versionCode, + certificateHashes: target.certificateHashes.toList(), + installableId: target.id, + ); + } +} + +/// Persists manual background installs until the user taps the notification. +class BackgroundPendingInstallStore { + static const _fileName = 'background_pending_installs.json'; + + static Future _file() async { + final dir = await getApplicationSupportDirectory(); + return File(path.join(dir.path, _fileName)); + } + + static Future> loadAll() async { + try { + final file = await _file(); + if (!await file.exists()) return {}; + final decoded = jsonDecode(await file.readAsString()); + if (decoded is! Map) return {}; + final entries = decoded['pending']; + if (entries is! Map) return {}; + final result = {}; + for (final entry in entries.entries) { + if (entry.value is! Map) continue; + try { + final pending = PendingBackgroundInstall.fromJson( + Map.from(entry.value as Map), + ); + if (await File(pending.filePath).exists()) { + result[pending.appId] = pending; + } + } catch (_) {} + } + return result; + } catch (e, st) { + LogService.I.warn( + 'failed to load pending background installs', + tag: 'background_updates', + err: e, + stack: st, + ); + return {}; + } + } + + static Future save(PendingBackgroundInstall pending) async { + final all = await loadAll(); + final previous = all[pending.appId]; + all[pending.appId] = pending; + final saved = await _writeAll(all); + + if (saved && previous != null && previous.filePath != pending.filePath) { + try { + final oldFile = File(previous.filePath); + if (await oldFile.exists()) await oldFile.delete(); + } catch (e, st) { + LogService.I.warn( + 'failed to delete superseded background update', + tag: 'background_updates', + fields: {'appId': pending.appId}, + err: e, + stack: st, + ); + } + } + return saved; + } + + static Future remove(String appId) async { + final all = await loadAll(); + if (all.remove(appId) == null) return; + await _writeAll(all); + } + + static Future removeMany(Iterable appIds) async { + final all = await loadAll(); + var changed = false; + for (final appId in appIds) { + if (all.remove(appId) != null) changed = true; + } + if (!changed) return; + await _writeAll(all); + } + + static Future _writeAll( + Map all, + ) async { + try { + final file = await _file(); + final tmp = File('${file.path}.tmp'); + final payload = jsonEncode({ + 'v': 1, + 'pending': {for (final e in all.entries) e.key: e.value.toJson()}, + }); + await tmp.writeAsString(payload, flush: true); + if (await file.exists()) await file.delete(); + await tmp.rename(file.path); + return true; + } catch (e, st) { + LogService.I.warn( + 'failed to save pending background installs', + tag: 'background_updates', + err: e, + stack: st, + ); + return false; + } + } +} diff --git a/lib/services/background_update_service.dart b/lib/services/background_update_service.dart index b86e18d..34956eb 100644 --- a/lib/services/background_update_service.dart +++ b/lib/services/background_update_service.dart @@ -17,6 +17,9 @@ import 'package:path_provider/path_provider.dart'; import 'package:purplebase/purplebase.dart'; import 'package:workmanager/workmanager.dart'; import 'package:zapstore/router.dart'; +import 'package:zapstore/services/background_auto_update_executor.dart'; +import 'package:zapstore/services/background_native_installer.dart'; +import 'package:zapstore/services/background_pending_install_store.dart'; import 'package:zapstore/services/log_service.dart'; import 'package:zapstore/services/package_manager/background_package_manager.dart'; import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; @@ -28,12 +31,18 @@ import 'package:zapstore/utils/extensions.dart'; /// Unique task name for background update checking const kBackgroundUpdateTaskName = 'dev.zapstore.backgroundUpdateCheck'; +/// Unique task name for unmetered background auto-updates +const kBackgroundAutoUpdateTaskName = 'dev.zapstore.backgroundAutoUpdate'; + /// Unique task name for weekly cleanup const kWeeklyCleanupTaskName = 'dev.zapstore.weeklyCleanup'; /// Unique task identifier const kBackgroundUpdateTaskId = 'backgroundUpdateCheck'; +/// Unique task identifier for unmetered background auto-updates +const kBackgroundAutoUpdateTaskId = 'backgroundAutoUpdate'; + /// Unique task identifier for weekly cleanup const kWeeklyCleanupTaskId = 'weeklyCleanup'; @@ -52,6 +61,13 @@ const _inactivityThreshold = Duration(hours: 24); /// Notification payload for deep linking to updates screen const _kNotificationPayload = 'updates'; +/// Notification payload prefix for ready-to-install manual background updates. +/// Format: `background-ready:appId1,appId2` +const _kNotificationPayloadReadyPrefix = 'background-ready:'; + +/// Notification payload when auto-update ran but needs no user install action. +const _kNotificationPayloadResults = 'background-results'; + /// Input data key for AppCatalog relay URLs const kAppCatalogRelaysKey = 'appCatalogRelays'; @@ -116,46 +132,61 @@ void callbackDispatcher() { Isolate.current.addErrorListener(port.sendPort); _workmanagerErrorPort = port; - runZonedGuarded(() { - Workmanager().executeTask((task, inputData) async { - try { - switch (task) { - case kBackgroundUpdateTaskName: - final relayUrls = - (inputData?[kAppCatalogRelaysKey] as List?) - ?.cast() - .toSet(); - return await _checkForUpdatesInBackground(relayUrls); - case kWeeklyCleanupTaskName: - return await _performWeeklyCleanup(); - default: - return false; + runZonedGuarded( + () { + Workmanager().executeTask((task, inputData) async { + try { + switch (task) { + case kBackgroundUpdateTaskName: + final relayUrls = + (inputData?[kAppCatalogRelaysKey] as List?) + ?.cast() + .toSet(); + return await _checkForUpdatesInBackground( + relayUrls, + autoUpdateWorker: false, + ); + case kBackgroundAutoUpdateTaskName: + final relayUrls = + (inputData?[kAppCatalogRelaysKey] as List?) + ?.cast() + .toSet(); + return await _checkForUpdatesInBackground( + relayUrls, + autoUpdateWorker: true, + ); + case kWeeklyCleanupTaskName: + return await _performWeeklyCleanup(); + default: + return false; + } + } catch (e, st) { + LogService.I.error( + 'background task failed', + tag: 'workmanager', + fields: {'task': task}, + err: e, + stack: st, + ); + return false; + } finally { + // Ensure entries from this task hit disk before the isolate + // tears down. + await LogService.I.flush(); } - } catch (e, st) { - LogService.I.error( - 'background task failed', - tag: 'workmanager', - fields: {'task': task}, - err: e, - stack: st, - ); - return false; - } finally { - // Ensure entries from this task hit disk before the isolate - // tears down. - await LogService.I.flush(); - } - }); - }, (error, stack) { - LogService.I.fatal( - 'uncaught error', - tag: 'crash', - fields: const {'source': 'zone'}, - err: error, - stack: stack, - ); - LogService.I.flushSync(); - }); + }); + }, + (error, stack) { + LogService.I.fatal( + 'uncaught error', + tag: 'crash', + fields: const {'source': 'zone'}, + err: error, + stack: stack, + ); + LogService.I.flushSync(); + }, + ); } /// Perform weekly cleanup of stale downloads @@ -230,9 +261,20 @@ Future _performWeeklyCleanup() async { /// /// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to /// default relay if not provided. -Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { +Future _checkForUpdatesInBackground( + Set? appCatalogRelays, { + required bool autoUpdateWorker, +}) async { try { final relays = appCatalogRelays ?? {'wss://relay.zapstore.dev'}; + final settings = await SettingsService().load(); + + // Notification-only checks keep their existing connected-network schedule. + // Auto-update work runs in a separate worker constrained to unmetered + // networks. Exactly one worker proceeds for the current setting. + if (settings.backgroundAutoUpdatesEnabled != autoUpdateWorker) { + return true; + } final container = ProviderContainer( overrides: [ @@ -291,10 +333,26 @@ Future _checkForUpdatesInBackground(Set? appCatalogRelays) async { ).toRequest(), source: const LocalSource(), ); - await _showUpdateNotificationIfNeeded( - updatableApps, - updatableInstallables, - ); + + if (autoUpdateWorker) { + final displayNames = { + for (final app in updatableApps) + app.identifier: app.name ?? app.identifier, + }; + final result = await BackgroundAutoUpdateExecutor.run( + updatableInstallables: updatableInstallables, + installed: pmState.installed, + displayNames: displayNames, + ); + if (result.hasWork) { + await _showAutoUpdateResultNotification(result, updatableApps); + } + } else { + await _showUpdateNotificationIfNeeded( + updatableApps, + updatableInstallables, + ); + } } return true; @@ -325,7 +383,8 @@ Future _showUpdateNotificationIfNeeded( // Skip if user recently opened the app if (settings.lastAppOpened != null && - DateTime.now().difference(settings.lastAppOpened!) < _inactivityThreshold) { + DateTime.now().difference(settings.lastAppOpened!) < + _inactivityThreshold) { return; } @@ -347,7 +406,8 @@ Future _showUpdateNotificationIfNeeded( } // Must be newer than last app open (if any) - user may have seen it in UI - if (settings.lastAppOpened != null && !releaseTime.isAfter(settings.lastAppOpened!)) { + if (settings.lastAppOpened != null && + !releaseTime.isAfter(settings.lastAppOpened!)) { return false; } @@ -396,6 +456,92 @@ Future _showUpdateNotificationIfNeeded( await settingsService.update((s) => s.copyWith(seenUntil: DateTime.now())); } +/// Show a notification summarizing background auto-update results. +Future _showAutoUpdateResultNotification( + BackgroundAutoUpdateResult result, + List updatableApps, +) async { + final plugin = FlutterLocalNotificationsPlugin(); + const initSettings = InitializationSettings( + android: AndroidInitializationSettings('@drawable/ic_notification'), + ); + await plugin.initialize(initSettings); + await _ensureUpdateNotificationChannel(plugin); + + final nameById = { + for (final app in updatableApps) app.identifier: app.name ?? app.identifier, + }; + + String name(String appId) => nameById[appId] ?? appId; + + final parts = []; + if (result.updatedAppIds.isNotEmpty) { + final names = result.updatedAppIds.map(name).toList(); + parts.add( + result.updatedAppIds.length == 1 + ? 'Updated ${names.first}' + : 'Updated ${names.length} apps: ${_formatNameList(names)}', + ); + } + if (result.readyAppIds.isNotEmpty) { + final names = result.readyAppIds.map(name).toList(); + parts.add( + result.readyAppIds.length == 1 + ? '${names.first} is ready to update — tap to install' + : '${names.length} apps ready to update — tap to install', + ); + } + if (result.failedAppIds.isNotEmpty) { + parts.add( + result.failedAppIds.length == 1 + ? 'Failed to update ${name(result.failedAppIds.first)}' + : 'Failed to update ${result.failedAppIds.length} apps', + ); + } + + final body = parts.join('\n'); + + final title = switch (( + result.updatedAppIds.isNotEmpty, + result.readyAppIds.isNotEmpty, + )) { + (true, true) => 'Updates applied', + (true, false) => + result.updatedAppIds.length == 1 ? 'App updated' : 'Apps updated', + (false, true) => + result.readyAppIds.length == 1 ? 'Update ready' : 'Updates ready', + _ => 'Update check finished', + }; + + final payload = result.readyAppIds.isNotEmpty + ? '$_kNotificationPayloadReadyPrefix${result.readyAppIds.join(',')}' + : _kNotificationPayloadResults; + + await plugin.show( + 0, + title, + body, + NotificationDetails( + android: AndroidNotificationDetails( + kUpdateNotificationChannelId, + kUpdateNotificationChannelName, + channelDescription: kUpdateNotificationChannelDescription, + importance: Importance.defaultImportance, + priority: Priority.defaultPriority, + showWhen: true, + autoCancel: true, + styleInformation: BigTextStyleInformation(body), + ), + ), + payload: payload, + ); +} + +String _formatNameList(List names) { + if (names.length <= 3) return names.join(', '); + return '${names.take(3).join(', ')} and ${names.length - 3} more'; +} + /// Service for managing background update checks class BackgroundUpdateService { BackgroundUpdateService(this.ref); @@ -438,6 +584,22 @@ class BackgroundUpdateService { inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()}, ); + // Auto-update downloads and installs must only run on an unmetered network. + // This worker exits immediately while the setting is disabled. + await Workmanager().registerPeriodicTask( + kBackgroundAutoUpdateTaskId, + kBackgroundAutoUpdateTaskName, + frequency: const Duration(hours: 24), + constraints: Constraints( + networkType: NetworkType.unmetered, + requiresBatteryNotLow: true, + ), + existingWorkPolicy: ExistingPeriodicWorkPolicy.keep, + backoffPolicy: BackoffPolicy.exponential, + initialDelay: const Duration(hours: 1), + inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()}, + ); + // Register weekly cleanup task await Workmanager().registerPeriodicTask( kWeeklyCleanupTaskId, @@ -480,21 +642,68 @@ class BackgroundUpdateService { // Check if app was launched from a notification (terminated state) final launchDetails = await flutterLocalNotificationsPlugin .getNotificationAppLaunchDetails(); - if (launchDetails?.didNotificationLaunchApp == true && - launchDetails?.notificationResponse?.payload == _kNotificationPayload) { - _navigateToUpdates(); + if (launchDetails?.didNotificationLaunchApp == true) { + final payload = launchDetails?.notificationResponse?.payload; + if (payload != null) { + _handleNotificationPayload(payload); + } } await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin); } - /// Handle notification tap - navigate to updates screen + /// Handle notification tap - navigate or launch pending installs static void _handleNotificationTap(NotificationResponse response) { - if (response.payload == _kNotificationPayload) { + final payload = response.payload; + if (payload == null || payload.isEmpty) return; + _handleNotificationPayload(payload); + } + + static void _handleNotificationPayload(String payload) { + if (payload.startsWith(_kNotificationPayloadReadyPrefix)) { + final raw = payload.substring(_kNotificationPayloadReadyPrefix.length); + final appIds = raw.split(',').where((id) => id.isNotEmpty).toList(); + // A notification can cold-start the app before its Activity, method + // channels, and router have finished attaching. Defer to the next frame + // so the prepared install can safely launch Android system UI. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(launchPendingBackgroundInstalls(appIds)); + }); + return; + } + + if (payload == _kNotificationPayload || + payload == _kNotificationPayloadResults) { _navigateToUpdates(); } } + /// Launch Android install dialogs for manual updates prepared in the background. + static Future launchPendingBackgroundInstalls( + List appIds, + ) async { + final pending = await BackgroundPendingInstallStore.loadAll(); + final targets = appIds.isEmpty ? pending.keys.toList() : appIds; + + for (final appId in targets) { + final install = pending[appId]; + if (install == null) continue; + + await BackgroundNativeInstaller.launchPreparedInstall( + appId: install.appId, + filePath: install.filePath, + expectedHash: install.hash, + expectedSize: install.size, + expectedCertHashes: install.certificateHashes, + ); + } + + final context = rootNavigatorKey.currentContext; + if (context != null && context.mounted) { + GoRouter.of(context).go('/updates'); + } + } + /// Navigate to the updates screen static void _navigateToUpdates() { // Use the root navigator key to navigate @@ -506,7 +715,10 @@ class BackgroundUpdateService { /// Cancel background update checks Future cancelBackgroundChecks() async { - await Workmanager().cancelByUniqueName(kBackgroundUpdateTaskId); + await Future.wait([ + Workmanager().cancelByUniqueName(kBackgroundUpdateTaskId), + Workmanager().cancelByUniqueName(kBackgroundAutoUpdateTaskId), + ]); } } diff --git a/lib/services/package_manager/background_package_manager.dart b/lib/services/package_manager/background_package_manager.dart index 89781e8..ed06c73 100644 --- a/lib/services/package_manager/background_package_manager.dart +++ b/lib/services/package_manager/background_package_manager.dart @@ -1,11 +1,15 @@ import 'package:models/models.dart'; +import 'package:flutter/services.dart'; import 'package:zapstore/services/package_manager/installed_packages_snapshot.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/services/log_service.dart'; /// Background-safe PackageManager that avoids EventChannel usage. final class BackgroundPackageManager extends PackageManager { BackgroundPackageManager(super.ref); + static const _methodChannel = MethodChannel('android_package_manager'); + // Zapstore currently targets arm64 APKs for background checks. @override String get platform => 'android-arm64-v8a'; @@ -47,7 +51,47 @@ final class BackgroundPackageManager extends PackageManager { @override Future syncInstalledPackages() async { - final installed = await InstalledPackagesSnapshot.load(); - state = state.copyWith(installed: installed); + try { + final installedApps = + await _methodChannel + .invokeMethod>('getInstalledApps', { + 'includeSystemApps': false, + }) + .timeout(const Duration(seconds: 10)) ?? + []; + + final packages = {}; + for (final appObj in installedApps) { + final app = Map.from(appObj as Map); + final appId = + app['bundleId'] as String? ?? app['packageName'] as String? ?? ''; + if (appId.isEmpty) continue; + + final rawHashes = app['signatureHashes']; + packages[appId] = PackageInfo( + appId: appId, + name: app['name'] as String?, + version: app['versionName'] as String? ?? '0.0.0', + versionCode: app['versionCode'] as int?, + signatureHashes: rawHashes is List + ? rawHashes.cast().toList() + : const [], + installTime: null, + canInstallSilently: app['canInstallSilently'] as bool? ?? false, + ); + } + + state = state.copyWith(installed: packages); + await InstalledPackagesSnapshot.save(packages); + } catch (e, st) { + LogService.I.warn( + 'native background package scan failed; using snapshot', + tag: 'background_updates', + err: e, + stack: st, + ); + final installed = await InstalledPackagesSnapshot.load(); + state = state.copyWith(installed: installed); + } } } diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 73f2623..b91e7a8 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -18,6 +18,7 @@ class LocalSettings { final DateTime? seenUntil; final DateTime? deletionSyncedUntil; final bool installedAppsBackupEnabled; + final bool backgroundAutoUpdatesEnabled; final LogLevel logLevel; const LocalSettings({ @@ -27,6 +28,7 @@ class LocalSettings { this.seenUntil, this.deletionSyncedUntil, this.installedAppsBackupEnabled = false, + this.backgroundAutoUpdatesEnabled = false, this.logLevel = LogLevel.debug, }); @@ -40,6 +42,8 @@ class LocalSettings { seenUntil: _parseDateTime(json['seenUntil']), deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']), installedAppsBackupEnabled: json['backupEnabled'] as bool? ?? false, + backgroundAutoUpdatesEnabled: + json['backgroundAutoUpdates'] as bool? ?? false, logLevel: LogLevel.parse(json['logLevel'] as String?) ?? LogLevel.debug, ); } @@ -53,6 +57,7 @@ class LocalSettings { if (deletionSyncedUntil != null) 'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch, if (installedAppsBackupEnabled) 'backupEnabled': true, + if (backgroundAutoUpdatesEnabled) 'backgroundAutoUpdates': true, // Only persist non-default value to keep blob small. if (logLevel != LogLevel.debug) 'logLevel': logLevel.name, }; @@ -64,6 +69,7 @@ class LocalSettings { DateTime? seenUntil, DateTime? deletionSyncedUntil, bool? installedAppsBackupEnabled, + bool? backgroundAutoUpdatesEnabled, LogLevel? logLevel, bool clearNwc = false, }) { @@ -76,6 +82,8 @@ class LocalSettings { deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil, installedAppsBackupEnabled: installedAppsBackupEnabled ?? this.installedAppsBackupEnabled, + backgroundAutoUpdatesEnabled: + backgroundAutoUpdatesEnabled ?? this.backgroundAutoUpdatesEnabled, logLevel: logLevel ?? this.logLevel, ); } diff --git a/plugins/installed_apps_plugin/android/build.gradle b/plugins/installed_apps_plugin/android/build.gradle new file mode 100644 index 0000000..38c93a5 --- /dev/null +++ b/plugins/installed_apps_plugin/android/build.gradle @@ -0,0 +1,40 @@ +group = 'dev.zapstore.app.plugins' +version = '1.0-SNAPSHOT' + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.7.3' + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace = 'dev.zapstore.app.plugins.registration' + compileSdk = flutter.compileSdkVersion + + defaultConfig { + minSdk = flutter.minSdkVersion + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } +} diff --git a/plugins/installed_apps_plugin/android/src/main/AndroidManifest.xml b/plugins/installed_apps_plugin/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/plugins/installed_apps_plugin/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/plugins/installed_apps_plugin/android/src/main/kotlin/dev/zapstore/app/plugins/registration/AndroidPackageManagerRegistrantPlugin.kt b/plugins/installed_apps_plugin/android/src/main/kotlin/dev/zapstore/app/plugins/registration/AndroidPackageManagerRegistrantPlugin.kt new file mode 100644 index 0000000..54b8832 --- /dev/null +++ b/plugins/installed_apps_plugin/android/src/main/kotlin/dev/zapstore/app/plugins/registration/AndroidPackageManagerRegistrantPlugin.kt @@ -0,0 +1,46 @@ +package dev.zapstore.app.plugins.registration + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding + +/** + * Registers the app-owned package manager plugin with every Flutter engine, + * including headless WorkManager engines. + * + * Reflection keeps the reusable registration package independent of the app + * module at compile time. The concrete plugin is bundled in the app module. + */ +class AndroidPackageManagerRegistrantPlugin : FlutterPlugin, ActivityAware { + private var delegate: FlutterPlugin? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + val plugin = + Class.forName("dev.zapstore.app.plugins.AndroidPackageManagerPlugin") + .getDeclaredConstructor() + .newInstance() as FlutterPlugin + delegate = plugin + plugin.onAttachedToEngine(binding) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + delegate?.onDetachedFromEngine(binding) + delegate = null + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + (delegate as? ActivityAware)?.onAttachedToActivity(binding) + } + + override fun onDetachedFromActivityForConfigChanges() { + (delegate as? ActivityAware)?.onDetachedFromActivityForConfigChanges() + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) { + (delegate as? ActivityAware)?.onReattachedToActivityForConfigChanges(binding) + } + + override fun onDetachedFromActivity() { + (delegate as? ActivityAware)?.onDetachedFromActivity() + } +} diff --git a/plugins/installed_apps_plugin/lib/installed_apps_plugin.dart b/plugins/installed_apps_plugin/lib/installed_apps_plugin.dart new file mode 100644 index 0000000..1709079 --- /dev/null +++ b/plugins/installed_apps_plugin/lib/installed_apps_plugin.dart @@ -0,0 +1,2 @@ +/// Build-time registration package for Zapstore's native Android package manager. +library; diff --git a/plugins/installed_apps_plugin/pubspec.yaml b/plugins/installed_apps_plugin/pubspec.yaml new file mode 100644 index 0000000..129d9ff --- /dev/null +++ b/plugins/installed_apps_plugin/pubspec.yaml @@ -0,0 +1,18 @@ +name: installed_apps_plugin +description: Registers Zapstore's Android package manager with every Flutter engine. +version: 0.0.1 +publish_to: none + +environment: + sdk: ^3.8.1 + +dependencies: + flutter: + sdk: flutter + +flutter: + plugin: + platforms: + android: + package: dev.zapstore.app.plugins.registration + pluginClass: AndroidPackageManagerRegistrantPlugin diff --git a/pubspec.lock b/pubspec.lock index 9a85257..a105190 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -577,6 +577,13 @@ packages: url: "https://pub.dev" source: hosted version: "4.7.2" + installed_apps_plugin: + dependency: "direct main" + description: + path: "plugins/installed_apps_plugin" + relative: true + source: path + version: "0.0.1" intl: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index e356a47..8729927 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,6 +17,8 @@ dependencies: flutter: sdk: flutter + installed_apps_plugin: + path: plugins/installed_apps_plugin flutter_riverpod: ^2.6.1 flutter_hooks: ^0.21.2 path_provider: ^2.1.5 diff --git a/spec/features/FEAT-007-background-auto-updates.md b/spec/features/FEAT-007-background-auto-updates.md new file mode 100644 index 0000000..ce8211f --- /dev/null +++ b/spec/features/FEAT-007-background-auto-updates.md @@ -0,0 +1,108 @@ +# FEAT-007 — Background Auto-Updates + +## Goal + +Let users opt in to having periodic background update checks download and apply +updates automatically, with a notification that reports what happened instead of +only announcing that updates are available. + +## Non-Goals + +- Enabling auto-updates by default (toggle is off until the user turns it on) +- Foreground auto-install without user action (FEAT-003; Update All still + requires an explicit tap while the app is open) +- Per-app auto-update settings (all-or-nothing via the profile toggle) +- Background uninstall or force-update flows +- iOS or non-Android platforms + +## User-Visible Behavior + +### Profile setting + +- **Background auto-updates** toggle in Profile → Data Management +- Default: **off** +- Subtitle explains that background checks download and apply updates; manual + updates are downloaded and shown as ready to install + +### When toggle is OFF + +- FEAT-002 behavior is unchanged: background checks may notify about available + updates (subject to the 24h inactivity and freshness rules), and tapping opens + the Updates screen + +### When toggle is ON + +- Periodic WorkManager checks (same 24h schedule as FEAT-002) **perform** update + work instead of only notifying about availability +- Automatic APK downloads and installs run only on an unmetered Wi-Fi + connection; cellular and other metered connections may check for update + availability but must not download APKs +- The 24h inactivity gate does **not** block performing updates; the user opted in +- For each available update (same catalog / versionCode rules as the Updates screen): + - **Silent updates** (app was installed by Zapstore and supports silent + install): download, verify hash, install in background without user interaction + - **Manual updates**: download, verify hash, stage as **ready to update**; do + **not** show the Android install dialog until the user acts +- After a check that did work, user receives a **result notification** summarizing: + - Apps successfully updated + - Apps ready to update (with “tap to install” guidance) + - Apps that failed to update +- If there is nothing to update, or no work was performed, no result notification +- Tapping a result notification that includes ready-to-update apps: + - Opens the app + - Triggers the Android system install prompt for staged manual updates + - Navigates to the Updates screen +- Tapping a result-only notification (updates applied, no manual pending): + navigates to the Updates screen + +### Security + +- APKs are verified (hash, and signing metadata when declared) before staging or + installing — same guarantees as FEAT-001 +- Silent background install only for apps that already qualify for silent install + in the foreground Updates flow + +## Edge Cases + +- Toggle turned off after manual updates were staged → staged files remain until + used, cleared, or aged out; no new background apply runs +- Toggle turned on with no network → check fails; WorkManager retries per existing + backoff policy; no silent failure +- Toggle turned on while only a cellular or metered connection is available → + update availability may be checked, but APK download and installation wait for + unmetered Wi-Fi +- Download fails → app listed under failures in result notification; no partial + install +- Hash or certificate verification fails → staged file deleted; app listed as failed +- Silent install fails in background → app listed as failed; no install dialog +- Manual update staged but user never taps notification → APK remains in pending + store; user can still update from the Updates screen if the file is present +- Multiple manual updates ready → notification tap starts install flow; Android + shows one system prompt at a time (FEAT-001 install queue semantics) +- No installed apps → no background update work, no notification +- Device policy blocks installs → failure surfaced in result notification +- User lacks “install unknown apps” permission → manual staging may succeed but + install prompt on tap follows FEAT-001 permission flow + +## Acceptance Criteria + +- [ ] Profile toggle **Background auto-updates** exists and defaults to off +- [ ] When off, FEAT-002 notification behavior is unchanged +- [ ] When on, background checks download and apply silent updates without user + interaction, only while connected to unmetered Wi-Fi +- [ ] When on, manual updates are downloaded and reported as ready to update, + without showing the install dialog until the user taps the notification; + background download occurs only on unmetered Wi-Fi +- [ ] When on, result notification summarizes updated / ready / failed outcomes +- [ ] Tapping a ready-to-update notification triggers the Android install prompt +- [ ] APK hash is verified before any background install or staging +- [ ] Update availability still uses versionCode comparison only (INVARIANTS) + +## Notes + +- Builds on FEAT-002 (WorkManager schedule) and FEAT-001 (install state machine, + silent vs manual categorization) +- FEAT-003 non-goal “auto-install without user action” applies to foreground + behavior; this feature is an explicit opt-in for background apply only +- Setting key: `backgroundAutoUpdatesEnabled` in `LocalSettings` +- Implementation: `spec/work/WORK-013-background-auto-updates.md` diff --git a/spec/work/WORK-013-background-auto-updates.md b/spec/work/WORK-013-background-auto-updates.md new file mode 100644 index 0000000..9c47e12 --- /dev/null +++ b/spec/work/WORK-013-background-auto-updates.md @@ -0,0 +1,38 @@ +# WORK-013 — Background Auto-Updates + +**Feature spec:** `spec/features/FEAT-007-background-auto-updates.md` + +## Goal + +Profile toggle (off by default) that makes background update checks download and +apply updates, with a result notification instead of "updates available". + +## Tasks + +- [x] Add `backgroundAutoUpdatesEnabled` to LocalSettings (default false) +- [x] Profile screen toggle +- [x] Background executor: download, silent install, stage manual installs +- [x] Native `installAndAwait` + `verifyApk` for background isolate +- [x] Pending manual install store + notification tap → system prompt +- [x] Result notification when auto-updates enabled +- [x] Restrict background APK downloads and installs to unmetered networks +- [x] Refresh installed package versions natively before each background run +- [x] Reuse already-staged manual updates instead of downloading them again +- [x] Register the native package manager in headless WorkManager engines +- [x] Emulator UAT: schedule constraints, staging, notification, and install prompt +- [ ] Manual UAT on device + +## Decisions + +- When enabled, skip the 24h inactivity gate for performing work; the user opted in. +- When disabled, keep FEAT-002 notification behavior unchanged. +- Manual updates: download + verify in background; install dialog only on notification tap. +- Silent updates: full install in background via `installAndAwait`. +- Keep notification-only checks on any connected network; use a separate + unmetered WorkManager task for auto-update downloads and installs. +- Fall back to the local installed-package snapshot only when the native + background package scan fails. +- Certificate metadata extraction must succeed when certificate hashes are + declared; otherwise staging fails closed. +- Use a generated plugin registrant bridge so activity and headless Flutter + engines share the same app-owned Android package manager implementation. diff --git a/test/services/settings_service_test.dart b/test/services/settings_service_test.dart new file mode 100644 index 0000000..717ed2f --- /dev/null +++ b/test/services/settings_service_test.dart @@ -0,0 +1,23 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:zapstore/services/settings_service.dart'; + +void main() { + group('LocalSettings', () { + test('backgroundAutoUpdatesEnabled defaults to false', () { + const settings = LocalSettings(); + expect(settings.backgroundAutoUpdatesEnabled, isFalse); + }); + + test('round-trips backgroundAutoUpdatesEnabled in JSON', () { + const settings = LocalSettings(backgroundAutoUpdatesEnabled: true); + final restored = LocalSettings.fromJson(settings.toJson()); + expect(restored.backgroundAutoUpdatesEnabled, isTrue); + }); + + test('copyWith toggles backgroundAutoUpdatesEnabled', () { + const settings = LocalSettings(); + final updated = settings.copyWith(backgroundAutoUpdatesEnabled: true); + expect(updated.backgroundAutoUpdatesEnabled, isTrue); + }); + }); +}