Add background auto-updates with Wi-Fi-only downloads and silent or staged installs

This commit is contained in:
franzap
2026-06-09 09:14:37 -03:00
parent 2accb83009
commit c91934b551
20 changed files with 1342 additions and 58 deletions
@@ -1,6 +1,5 @@
package dev.zapstore.app package dev.zapstore.app
import dev.zapstore.app.plugins.AndroidPackageManagerPlugin
import dev.zapstore.app.plugins.AppRestartPlugin import dev.zapstore.app.plugins.AppRestartPlugin
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
@@ -9,8 +8,8 @@ class MainActivity: FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
// Register plugins // Register the activity-only plugin. AndroidPackageManagerPlugin is
flutterEngine.plugins.add(AndroidPackageManagerPlugin()) // auto-registered so WorkManager background engines receive it too.
flutterEngine.plugins.add(AppRestartPlugin()) flutterEngine.plugins.add(AppRestartPlugin())
} }
} }
@@ -140,6 +140,10 @@ class AndroidPackageManagerPlugin :
private var appContext: Context? = null private var appContext: Context? = null
/** Blocking install results for background auto-updates */
private val awaitLatches = ConcurrentHashMap<String, CountDownLatch>()
private val awaitResults = ConcurrentHashMap<String, Map<String, Any?>>()
/** /**
* Weak reference to the current Activity. Used by launchConfirmDialog to start * Weak reference to the current Activity. Used by launchConfirmDialog to start
* the PackageInstaller confirmation dialog in the same task as the app, which * 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 // Handle pending user action - store intent for potential re-launch
if (status == InstallStatus.PENDING_USER_ACTION && confirmIntent != null) { if (status == InstallStatus.PENDING_USER_ACTION && confirmIntent != null) {
pendingUserActionIntents[pkg] = confirmIntent pendingUserActionIntents[pkg] = confirmIntent
resolveAwait(
pkg,
status,
message ?: "User confirmation pending",
errorCode
)
// Track this session as pending user action so SessionCallback can detect // Track this session as pending user action so SessionCallback can detect
// acceptance // acceptance
@@ -191,6 +201,7 @@ class AndroidPackageManagerPlugin :
InstallStatus.CANCELLED InstallStatus.CANCELLED
) )
) { ) {
resolveAwait(pkg, status, message, errorCode)
sessionToPackage.remove(sessionId) sessionToPackage.remove(sessionId)
pendingUserActionIntents.remove(pkg) pendingUserActionIntents.remove(pkg)
instance?.sessionsPendingUserAction?.remove(sessionId) instance?.sessionsPendingUserAction?.remove(sessionId)
@@ -285,6 +296,25 @@ class AndroidPackageManagerPlugin :
} }
fun isAppInForeground(): Boolean = isAppInForeground fun isAppInForeground(): Boolean = isAppInForeground
private fun resolveAwait(
packageName: String,
status: String,
message: String?,
errorCode: String?
) {
val latch = awaitLatches.remove(packageName) ?: return
val result =
mutableMapOf<String, Any?>(
"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) abortInstall(packageName, result)
} }
"verifyApk" -> {
val filePath = call.argument<String>("filePath")
val expectedHash = call.argument<String>("expectedHash")
val expectedCertHashes =
call.argument<List<String>>("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<String>("filePath")
val packageName = call.argument<String>("packageName")
val expectedHash = call.argument<String>("expectedHash")
val expectedSize = call.argument<Number>("expectedSize")?.toLong()
val expectedCertHashes =
call.argument<List<String>>("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() else -> result.notImplemented()
} }
} }
@@ -671,6 +733,138 @@ class AndroidPackageManagerPlugin :
// INSTALLATION // INSTALLATION
// ═══════════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════════
private fun verifyApkOnly(
filePath: String,
expectedHash: String,
expectedCertHashes: List<String>,
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<String, Any?>("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<String>,
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( private fun installApk(
filePath: String, filePath: String,
packageName: String, packageName: String,
+2 -1
View File
@@ -1,4 +1,5 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -146,7 +147,7 @@ class _AppDetailContent extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider); final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
final showDebugSections = isDebugMode(signedInPubkey); final showDebugSections = kDebugMode || isDebugMode(signedInPubkey);
// Query author profile from social relays // Query author profile from social relays
final authorState = ref.watch( final authorState = ref.watch(
+40
View File
@@ -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 { class _InstalledAppsBackupToggle extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -1569,6 +1607,8 @@ class _DataManagementSection extends ConsumerWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
_InstalledAppsBackupToggle(), _InstalledAppsBackupToggle(),
const SizedBox(height: 8), const SizedBox(height: 8),
const _BackgroundAutoUpdatesToggle(),
const SizedBox(height: 8),
ListTile( ListTile(
leading: CircleAvatar( leading: CircleAvatar(
radius: 18, radius: 18,
@@ -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<String> updatedAppIds;
final List<String> readyAppIds;
final List<String> 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<BackgroundAutoUpdateResult> run({
required Map<String, Installable> updatableInstallables,
required Map<String, PackageInfo> installed,
required Map<String, String> displayNames,
}) async {
final updatedAppIds = <String>[];
final readyAppIds = <String>[];
final failedAppIds = <String>[];
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<String?> _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<Directory> _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<void> _deleteFile(String? filePath) async {
if (filePath == null) return;
try {
final file = File(filePath);
if (await file.exists()) await file.delete();
} catch (_) {}
}
}
@@ -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<bool> verifyApk({
required String filePath,
required String expectedHash,
required List<String> expectedCertHashes,
}) async {
try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>(
'verifyApk',
{
'filePath': filePath,
'expectedHash': expectedHash,
'expectedCertHashes': expectedCertHashes,
},
);
final map = Map<String, dynamic>.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<BackgroundInstallResult> installAndAwait({
required String appId,
required String filePath,
required String expectedHash,
required int expectedSize,
required List<String> expectedCertHashes,
}) async {
try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>(
'installAndAwait',
{
'filePath': filePath,
'packageName': appId,
'expectedHash': expectedHash,
'expectedSize': expectedSize,
'expectedCertHashes': expectedCertHashes,
},
);
final map = Map<String, dynamic>.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<bool> launchPreparedInstall({
required String appId,
required String filePath,
required String expectedHash,
required int expectedSize,
required List<String> expectedCertHashes,
}) async {
try {
final result = await _channel.invokeMethod<Map<Object?, Object?>>(
'install',
{
'filePath': filePath,
'packageName': appId,
'expectedHash': expectedHash,
'expectedSize': expectedSize,
'expectedCertHashes': expectedCertHashes,
},
);
final map = Map<String, dynamic>.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;
}
@@ -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<String> certificateHashes;
final String installableId;
Map<String, dynamic> 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<String, dynamic> 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<String>() ?? 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> _file() async {
final dir = await getApplicationSupportDirectory();
return File(path.join(dir.path, _fileName));
}
static Future<Map<String, PendingBackgroundInstall>> 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 = <String, PendingBackgroundInstall>{};
for (final entry in entries.entries) {
if (entry.value is! Map) continue;
try {
final pending = PendingBackgroundInstall.fromJson(
Map<String, dynamic>.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<bool> 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<void> remove(String appId) async {
final all = await loadAll();
if (all.remove(appId) == null) return;
await _writeAll(all);
}
static Future<void> removeMany(Iterable<String> 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<bool> _writeAll(
Map<String, PendingBackgroundInstall> 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;
}
}
}
+264 -52
View File
@@ -17,6 +17,9 @@ import 'package:path_provider/path_provider.dart';
import 'package:purplebase/purplebase.dart'; import 'package:purplebase/purplebase.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
import 'package:zapstore/router.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/log_service.dart';
import 'package:zapstore/services/package_manager/background_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/dummy_package_manager.dart';
@@ -28,12 +31,18 @@ import 'package:zapstore/utils/extensions.dart';
/// Unique task name for background update checking /// Unique task name for background update checking
const kBackgroundUpdateTaskName = 'dev.zapstore.backgroundUpdateCheck'; const kBackgroundUpdateTaskName = 'dev.zapstore.backgroundUpdateCheck';
/// Unique task name for unmetered background auto-updates
const kBackgroundAutoUpdateTaskName = 'dev.zapstore.backgroundAutoUpdate';
/// Unique task name for weekly cleanup /// Unique task name for weekly cleanup
const kWeeklyCleanupTaskName = 'dev.zapstore.weeklyCleanup'; const kWeeklyCleanupTaskName = 'dev.zapstore.weeklyCleanup';
/// Unique task identifier /// Unique task identifier
const kBackgroundUpdateTaskId = 'backgroundUpdateCheck'; const kBackgroundUpdateTaskId = 'backgroundUpdateCheck';
/// Unique task identifier for unmetered background auto-updates
const kBackgroundAutoUpdateTaskId = 'backgroundAutoUpdate';
/// Unique task identifier for weekly cleanup /// Unique task identifier for weekly cleanup
const kWeeklyCleanupTaskId = 'weeklyCleanup'; const kWeeklyCleanupTaskId = 'weeklyCleanup';
@@ -52,6 +61,13 @@ const _inactivityThreshold = Duration(hours: 24);
/// Notification payload for deep linking to updates screen /// Notification payload for deep linking to updates screen
const _kNotificationPayload = 'updates'; 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 /// Input data key for AppCatalog relay URLs
const kAppCatalogRelaysKey = 'appCatalogRelays'; const kAppCatalogRelaysKey = 'appCatalogRelays';
@@ -116,46 +132,61 @@ void callbackDispatcher() {
Isolate.current.addErrorListener(port.sendPort); Isolate.current.addErrorListener(port.sendPort);
_workmanagerErrorPort = port; _workmanagerErrorPort = port;
runZonedGuarded(() { runZonedGuarded(
Workmanager().executeTask((task, inputData) async { () {
try { Workmanager().executeTask((task, inputData) async {
switch (task) { try {
case kBackgroundUpdateTaskName: switch (task) {
final relayUrls = case kBackgroundUpdateTaskName:
(inputData?[kAppCatalogRelaysKey] as List<dynamic>?) final relayUrls =
?.cast<String>() (inputData?[kAppCatalogRelaysKey] as List<dynamic>?)
.toSet(); ?.cast<String>()
return await _checkForUpdatesInBackground(relayUrls); .toSet();
case kWeeklyCleanupTaskName: return await _checkForUpdatesInBackground(
return await _performWeeklyCleanup(); relayUrls,
default: autoUpdateWorker: false,
return false; );
case kBackgroundAutoUpdateTaskName:
final relayUrls =
(inputData?[kAppCatalogRelaysKey] as List<dynamic>?)
?.cast<String>()
.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', (error, stack) {
tag: 'workmanager', LogService.I.fatal(
fields: {'task': task}, 'uncaught error',
err: e, tag: 'crash',
stack: st, fields: const {'source': 'zone'},
); err: error,
return false; stack: stack,
} finally { );
// Ensure entries from this task hit disk before the isolate LogService.I.flushSync();
// 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();
});
} }
/// Perform weekly cleanup of stale downloads /// Perform weekly cleanup of stale downloads
@@ -230,9 +261,20 @@ Future<bool> _performWeeklyCleanup() async {
/// ///
/// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to /// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to
/// default relay if not provided. /// default relay if not provided.
Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async { Future<bool> _checkForUpdatesInBackground(
Set<String>? appCatalogRelays, {
required bool autoUpdateWorker,
}) async {
try { try {
final relays = appCatalogRelays ?? {'wss://relay.zapstore.dev'}; 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( final container = ProviderContainer(
overrides: [ overrides: [
@@ -291,10 +333,26 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
).toRequest(), ).toRequest(),
source: const LocalSource(), source: const LocalSource(),
); );
await _showUpdateNotificationIfNeeded(
updatableApps, if (autoUpdateWorker) {
updatableInstallables, 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; return true;
@@ -325,7 +383,8 @@ Future<void> _showUpdateNotificationIfNeeded(
// Skip if user recently opened the app // Skip if user recently opened the app
if (settings.lastAppOpened != null && if (settings.lastAppOpened != null &&
DateTime.now().difference(settings.lastAppOpened!) < _inactivityThreshold) { DateTime.now().difference(settings.lastAppOpened!) <
_inactivityThreshold) {
return; return;
} }
@@ -347,7 +406,8 @@ Future<void> _showUpdateNotificationIfNeeded(
} }
// Must be newer than last app open (if any) - user may have seen it in UI // 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; return false;
} }
@@ -396,6 +456,92 @@ Future<void> _showUpdateNotificationIfNeeded(
await settingsService.update((s) => s.copyWith(seenUntil: DateTime.now())); await settingsService.update((s) => s.copyWith(seenUntil: DateTime.now()));
} }
/// Show a notification summarizing background auto-update results.
Future<void> _showAutoUpdateResultNotification(
BackgroundAutoUpdateResult result,
List<App> 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 = <String>[];
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<String> names) {
if (names.length <= 3) return names.join(', ');
return '${names.take(3).join(', ')} and ${names.length - 3} more';
}
/// Service for managing background update checks /// Service for managing background update checks
class BackgroundUpdateService { class BackgroundUpdateService {
BackgroundUpdateService(this.ref); BackgroundUpdateService(this.ref);
@@ -438,6 +584,22 @@ class BackgroundUpdateService {
inputData: {kAppCatalogRelaysKey: appCatalogRelays.toList()}, 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 // Register weekly cleanup task
await Workmanager().registerPeriodicTask( await Workmanager().registerPeriodicTask(
kWeeklyCleanupTaskId, kWeeklyCleanupTaskId,
@@ -480,21 +642,68 @@ class BackgroundUpdateService {
// Check if app was launched from a notification (terminated state) // Check if app was launched from a notification (terminated state)
final launchDetails = await flutterLocalNotificationsPlugin final launchDetails = await flutterLocalNotificationsPlugin
.getNotificationAppLaunchDetails(); .getNotificationAppLaunchDetails();
if (launchDetails?.didNotificationLaunchApp == true && if (launchDetails?.didNotificationLaunchApp == true) {
launchDetails?.notificationResponse?.payload == _kNotificationPayload) { final payload = launchDetails?.notificationResponse?.payload;
_navigateToUpdates(); if (payload != null) {
_handleNotificationPayload(payload);
}
} }
await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin); await _ensureUpdateNotificationChannel(flutterLocalNotificationsPlugin);
} }
/// Handle notification tap - navigate to updates screen /// Handle notification tap - navigate or launch pending installs
static void _handleNotificationTap(NotificationResponse response) { 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(); _navigateToUpdates();
} }
} }
/// Launch Android install dialogs for manual updates prepared in the background.
static Future<void> launchPendingBackgroundInstalls(
List<String> 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 /// Navigate to the updates screen
static void _navigateToUpdates() { static void _navigateToUpdates() {
// Use the root navigator key to navigate // Use the root navigator key to navigate
@@ -506,7 +715,10 @@ class BackgroundUpdateService {
/// Cancel background update checks /// Cancel background update checks
Future<void> cancelBackgroundChecks() async { Future<void> cancelBackgroundChecks() async {
await Workmanager().cancelByUniqueName(kBackgroundUpdateTaskId); await Future.wait([
Workmanager().cancelByUniqueName(kBackgroundUpdateTaskId),
Workmanager().cancelByUniqueName(kBackgroundAutoUpdateTaskId),
]);
} }
} }
@@ -1,11 +1,15 @@
import 'package:models/models.dart'; 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/installed_packages_snapshot.dart';
import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/log_service.dart';
/// Background-safe PackageManager that avoids EventChannel usage. /// Background-safe PackageManager that avoids EventChannel usage.
final class BackgroundPackageManager extends PackageManager { final class BackgroundPackageManager extends PackageManager {
BackgroundPackageManager(super.ref); BackgroundPackageManager(super.ref);
static const _methodChannel = MethodChannel('android_package_manager');
// Zapstore currently targets arm64 APKs for background checks. // Zapstore currently targets arm64 APKs for background checks.
@override @override
String get platform => 'android-arm64-v8a'; String get platform => 'android-arm64-v8a';
@@ -47,7 +51,47 @@ final class BackgroundPackageManager extends PackageManager {
@override @override
Future<void> syncInstalledPackages() async { Future<void> syncInstalledPackages() async {
final installed = await InstalledPackagesSnapshot.load(); try {
state = state.copyWith(installed: installed); final installedApps =
await _methodChannel
.invokeMethod<List<Object?>>('getInstalledApps', {
'includeSystemApps': false,
})
.timeout(const Duration(seconds: 10)) ??
[];
final packages = <String, PackageInfo>{};
for (final appObj in installedApps) {
final app = Map<String, dynamic>.from(appObj as Map<Object?, Object?>);
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<String>().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);
}
} }
} }
+8
View File
@@ -18,6 +18,7 @@ class LocalSettings {
final DateTime? seenUntil; final DateTime? seenUntil;
final DateTime? deletionSyncedUntil; final DateTime? deletionSyncedUntil;
final bool installedAppsBackupEnabled; final bool installedAppsBackupEnabled;
final bool backgroundAutoUpdatesEnabled;
final LogLevel logLevel; final LogLevel logLevel;
const LocalSettings({ const LocalSettings({
@@ -27,6 +28,7 @@ class LocalSettings {
this.seenUntil, this.seenUntil,
this.deletionSyncedUntil, this.deletionSyncedUntil,
this.installedAppsBackupEnabled = false, this.installedAppsBackupEnabled = false,
this.backgroundAutoUpdatesEnabled = false,
this.logLevel = LogLevel.debug, this.logLevel = LogLevel.debug,
}); });
@@ -40,6 +42,8 @@ class LocalSettings {
seenUntil: _parseDateTime(json['seenUntil']), seenUntil: _parseDateTime(json['seenUntil']),
deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']), deletionSyncedUntil: _parseDateTime(json['deletionSyncedUntil']),
installedAppsBackupEnabled: json['backupEnabled'] as bool? ?? false, installedAppsBackupEnabled: json['backupEnabled'] as bool? ?? false,
backgroundAutoUpdatesEnabled:
json['backgroundAutoUpdates'] as bool? ?? false,
logLevel: LogLevel.parse(json['logLevel'] as String?) ?? LogLevel.debug, logLevel: LogLevel.parse(json['logLevel'] as String?) ?? LogLevel.debug,
); );
} }
@@ -53,6 +57,7 @@ class LocalSettings {
if (deletionSyncedUntil != null) if (deletionSyncedUntil != null)
'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch, 'deletionSyncedUntil': deletionSyncedUntil!.millisecondsSinceEpoch,
if (installedAppsBackupEnabled) 'backupEnabled': true, if (installedAppsBackupEnabled) 'backupEnabled': true,
if (backgroundAutoUpdatesEnabled) 'backgroundAutoUpdates': true,
// Only persist non-default value to keep blob small. // Only persist non-default value to keep blob small.
if (logLevel != LogLevel.debug) 'logLevel': logLevel.name, if (logLevel != LogLevel.debug) 'logLevel': logLevel.name,
}; };
@@ -64,6 +69,7 @@ class LocalSettings {
DateTime? seenUntil, DateTime? seenUntil,
DateTime? deletionSyncedUntil, DateTime? deletionSyncedUntil,
bool? installedAppsBackupEnabled, bool? installedAppsBackupEnabled,
bool? backgroundAutoUpdatesEnabled,
LogLevel? logLevel, LogLevel? logLevel,
bool clearNwc = false, bool clearNwc = false,
}) { }) {
@@ -76,6 +82,8 @@ class LocalSettings {
deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil, deletionSyncedUntil: deletionSyncedUntil ?? this.deletionSyncedUntil,
installedAppsBackupEnabled: installedAppsBackupEnabled:
installedAppsBackupEnabled ?? this.installedAppsBackupEnabled, installedAppsBackupEnabled ?? this.installedAppsBackupEnabled,
backgroundAutoUpdatesEnabled:
backgroundAutoUpdatesEnabled ?? this.backgroundAutoUpdatesEnabled,
logLevel: logLevel ?? this.logLevel, logLevel: logLevel ?? this.logLevel,
); );
} }
@@ -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'
}
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -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()
}
}
@@ -0,0 +1,2 @@
/// Build-time registration package for Zapstore's native Android package manager.
library;
@@ -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
+7
View File
@@ -577,6 +577,13 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.7.2" 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: intl:
dependency: "direct main" dependency: "direct main"
description: description:
+2
View File
@@ -17,6 +17,8 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
installed_apps_plugin:
path: plugins/installed_apps_plugin
flutter_riverpod: ^2.6.1 flutter_riverpod: ^2.6.1
flutter_hooks: ^0.21.2 flutter_hooks: ^0.21.2
path_provider: ^2.1.5 path_provider: ^2.1.5
@@ -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`
@@ -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.
+23
View File
@@ -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);
});
});
}