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
|
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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
+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"
|
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:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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