mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Add background auto-updates with Wi-Fi-only downloads and silent or staged installs
This commit is contained in:
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,10 @@ class AndroidPackageManagerPlugin :
|
||||
|
||||
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
|
||||
* 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<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)
|
||||
}
|
||||
"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()
|
||||
}
|
||||
}
|
||||
@@ -671,6 +733,138 @@ class AndroidPackageManagerPlugin :
|
||||
// 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(
|
||||
filePath: String,
|
||||
packageName: String,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,7 +132,8 @@ void callbackDispatcher() {
|
||||
Isolate.current.addErrorListener(port.sendPort);
|
||||
_workmanagerErrorPort = port;
|
||||
|
||||
runZonedGuarded(() {
|
||||
runZonedGuarded(
|
||||
() {
|
||||
Workmanager().executeTask((task, inputData) async {
|
||||
try {
|
||||
switch (task) {
|
||||
@@ -125,7 +142,19 @@ void callbackDispatcher() {
|
||||
(inputData?[kAppCatalogRelaysKey] as List<dynamic>?)
|
||||
?.cast<String>()
|
||||
.toSet();
|
||||
return await _checkForUpdatesInBackground(relayUrls);
|
||||
return await _checkForUpdatesInBackground(
|
||||
relayUrls,
|
||||
autoUpdateWorker: 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:
|
||||
@@ -146,7 +175,8 @@ void callbackDispatcher() {
|
||||
await LogService.I.flush();
|
||||
}
|
||||
});
|
||||
}, (error, stack) {
|
||||
},
|
||||
(error, stack) {
|
||||
LogService.I.fatal(
|
||||
'uncaught error',
|
||||
tag: 'crash',
|
||||
@@ -155,7 +185,8 @@ void callbackDispatcher() {
|
||||
stack: stack,
|
||||
);
|
||||
LogService.I.flushSync();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Perform weekly cleanup of stale downloads
|
||||
@@ -230,9 +261,20 @@ Future<bool> _performWeeklyCleanup() async {
|
||||
///
|
||||
/// [appCatalogRelays] - Relay URLs resolved from main isolate. Falls back to
|
||||
/// default relay if not provided.
|
||||
Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
||||
Future<bool> _checkForUpdatesInBackground(
|
||||
Set<String>? 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,11 +333,27 @@ Future<bool> _checkForUpdatesInBackground(Set<String>? appCatalogRelays) async {
|
||||
).toRequest(),
|
||||
source: const LocalSource(),
|
||||
);
|
||||
|
||||
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;
|
||||
} finally {
|
||||
@@ -325,7 +383,8 @@ Future<void> _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<void> _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<void> _showUpdateNotificationIfNeeded(
|
||||
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
|
||||
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<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
|
||||
static void _navigateToUpdates() {
|
||||
// Use the root navigator key to navigate
|
||||
@@ -506,7 +715,10 @@ class BackgroundUpdateService {
|
||||
|
||||
/// Cancel background update checks
|
||||
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: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<void> syncInstalledPackages() async {
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
+46
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user