mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Introduce package manager spec/work, clarify states and update code
This commit is contained in:
+142
-7
@@ -50,8 +50,10 @@ private const val COPY_BUFFER_SIZE = 65536
|
||||
object InstallStatus {
|
||||
const val STARTED = "started"
|
||||
const val VERIFYING = "verifying"
|
||||
const val VERIFYING_PROGRESS = "verifyingProgress" // Verification progress update
|
||||
const val PENDING_USER_ACTION = "pendingUserAction"
|
||||
const val INSTALLING = "installing" // User accepted, system is now installing
|
||||
const val SYSTEM_PROCESSING = "systemProcessing" // Committed, cannot cancel
|
||||
const val ALREADY_IN_PROGRESS = "alreadyInProgress"
|
||||
const val SUCCESS = "success"
|
||||
const val FAILED = "failed"
|
||||
@@ -108,6 +110,12 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
/** Track sessions that have emitted INSTALLING status (to avoid duplicates) */
|
||||
private val sessionsInstalling = mutableSetOf<Int>()
|
||||
|
||||
/** Track sessions that have been committed (abandonSession won't work for these) */
|
||||
private val committedSessions = mutableSetOf<Int>()
|
||||
|
||||
/** Track sessions that have already emitted SUCCESS (to avoid duplicates from onFinished) */
|
||||
private val sessionsCompletedSuccessfully = mutableSetOf<Int>()
|
||||
|
||||
/** Session callback to detect progress after user confirms install */
|
||||
private var sessionCallback: PackageInstaller.SessionCallback? = null
|
||||
|
||||
@@ -166,8 +174,11 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
pendingUserActionIntents.remove(pkg)
|
||||
instance?.sessionsPendingUserAction?.remove(sessionId)
|
||||
instance?.sessionsInstalling?.remove(sessionId)
|
||||
instance?.committedSessions?.remove(sessionId)
|
||||
|
||||
if (status != InstallStatus.SUCCESS) {
|
||||
if (status == InstallStatus.SUCCESS) {
|
||||
instance?.sessionsCompletedSuccessfully?.add(sessionId)
|
||||
} else {
|
||||
abandonSession(sessionId)
|
||||
}
|
||||
}
|
||||
@@ -327,9 +338,25 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
}
|
||||
|
||||
override fun onFinished(sessionId: Int, success: Boolean) {
|
||||
val pkg = sessionToPackage[sessionId]
|
||||
Log.d(TAG, "SessionCallback.onFinished: sessionId=$sessionId, success=$success, pkg=$pkg")
|
||||
|
||||
// If install succeeded and we have a tracked package, emit SUCCESS
|
||||
// This catches installs that completed after timeout or missed the broadcast
|
||||
if (success && pkg != null && sessionId !in sessionsCompletedSuccessfully) {
|
||||
sessionsCompletedSuccessfully.add(sessionId)
|
||||
Log.d(TAG, "Emitting SUCCESS from onFinished for $pkg (sessionId=$sessionId)")
|
||||
onInstallResult(
|
||||
sessionId = sessionId,
|
||||
status = InstallStatus.SUCCESS,
|
||||
packageName = pkg
|
||||
)
|
||||
}
|
||||
|
||||
// Clean up tracking
|
||||
sessionsPendingUserAction.remove(sessionId)
|
||||
sessionsInstalling.remove(sessionId)
|
||||
committedSessions.remove(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +406,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
status: String,
|
||||
message: String? = null,
|
||||
errorCode: String? = null,
|
||||
description: String? = null
|
||||
description: String? = null,
|
||||
progress: Double? = null
|
||||
) {
|
||||
val emitNow = emit@{
|
||||
// Update watchdog regardless of whether Dart is listening.
|
||||
@@ -405,6 +433,9 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
if (description != null) {
|
||||
event["description"] = description
|
||||
}
|
||||
if (progress != null) {
|
||||
event["progress"] = progress
|
||||
}
|
||||
Log.d(TAG, "Emitting to Dart: $event")
|
||||
sink.success(event)
|
||||
}
|
||||
@@ -461,8 +492,23 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
return@postDelayed
|
||||
}
|
||||
|
||||
// Check if session is committed - if so, we can't abandon it
|
||||
val sessionId = sessionToPackage.entries.find { it.value == appId }?.key
|
||||
val isCommitted = sessionId != null && sessionId in committedSessions
|
||||
|
||||
if (isCommitted) {
|
||||
// Session is committed - Android is in control, we cannot abort
|
||||
// Keep waiting and show "system processing" status
|
||||
Log.w(TAG, "Watchdog: Session for $appId is committed (sessionId=$sessionId), system is processing. Cannot timeout.")
|
||||
emitInstallStatus(appId, InstallStatus.SYSTEM_PROCESSING, "System is processing...")
|
||||
// Extend deadline significantly - Android will eventually complete or fail
|
||||
watchdogDeadlineMs[appId] = System.currentTimeMillis() + MAX_INSTALL_WATCHDOG_MS
|
||||
scheduleWatchdog(appId, INSTALL_WATCHDOG_MS * 2)
|
||||
return@postDelayed
|
||||
}
|
||||
|
||||
// Deadline exceeded or no evidence of progress: fail fast and cleanup.
|
||||
Log.w(TAG, "Watchdog timeout for $appId (hasSession=$hasSession, pendingUi=$hasPendingUi, verifyingAlive=$verifyingAlive)")
|
||||
Log.w(TAG, "Watchdog timeout for $appId (hasSession=$hasSession, pendingUi=$hasPendingUi, verifyingAlive=$verifyingAlive, isCommitted=$isCommitted)")
|
||||
abandonExistingSession(appId)
|
||||
emitInstallStatus(
|
||||
appId,
|
||||
@@ -476,11 +522,12 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
|
||||
private fun updateWatchdog(appId: String, status: String) {
|
||||
when (status) {
|
||||
InstallStatus.VERIFYING -> {
|
||||
InstallStatus.VERIFYING, InstallStatus.VERIFYING_PROGRESS -> {
|
||||
// Start watchdog for verify stage
|
||||
scheduleWatchdog(appId, VERIFY_WATCHDOG_MS)
|
||||
}
|
||||
InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION, InstallStatus.INSTALLING -> {
|
||||
InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION,
|
||||
InstallStatus.INSTALLING, InstallStatus.SYSTEM_PROCESSING -> {
|
||||
// Start/refresh watchdog for install stage
|
||||
scheduleWatchdog(appId, INSTALL_WATCHDOG_MS)
|
||||
}
|
||||
@@ -542,6 +589,15 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
launchApp(packageName, result)
|
||||
}
|
||||
|
||||
"abortInstall" -> {
|
||||
val packageName = call.argument<String>("packageName")
|
||||
if (packageName == null) {
|
||||
result.error("MISSING_ARGUMENT", "packageName required", null)
|
||||
return
|
||||
}
|
||||
abortInstall(packageName, result)
|
||||
}
|
||||
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -699,7 +755,16 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
val progressPercent = ((bytesCopied * 100) / fileSize).toInt()
|
||||
if (progressPercent != lastProgressPercent && progressPercent % 5 == 0) {
|
||||
lastProgressPercent = progressPercent
|
||||
Log.d(TAG, "Copy progress for $packageName: $progressPercent%")
|
||||
Log.d(TAG, "Verify progress for $packageName: $progressPercent%")
|
||||
// Emit progress to Dart
|
||||
val progressFraction = bytesCopied.toDouble() / fileSize.toDouble()
|
||||
mainHandler.post {
|
||||
emitInstallStatus(
|
||||
packageName,
|
||||
InstallStatus.VERIFYING_PROGRESS,
|
||||
progress = progressFraction
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -754,6 +819,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
)
|
||||
|
||||
session.commit(pendingIntent.intentSender)
|
||||
committedSessions.add(sessionId) // Mark as committed - abandonSession won't work now
|
||||
Log.d(TAG, "Session $sessionId committed for $packageName")
|
||||
session.close()
|
||||
session = null // Prevent double-close in finally
|
||||
|
||||
@@ -810,11 +877,21 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
|
||||
private fun abandonExistingSession(packageName: String) {
|
||||
val session = findExistingSession(packageName) ?: return
|
||||
val isCommitted = session.sessionId in committedSessions
|
||||
|
||||
if (isCommitted) {
|
||||
Log.d(TAG, "abandonExistingSession: Session ${session.sessionId} for $packageName is committed, abandon may not work")
|
||||
}
|
||||
|
||||
try {
|
||||
context.packageManager.packageInstaller.abandonSession(session.sessionId)
|
||||
Log.d(TAG, "abandonExistingSession: Abandoned session ${session.sessionId} for $packageName")
|
||||
sessionToPackage.remove(session.sessionId)
|
||||
pendingUserActionIntents.remove(packageName)
|
||||
} catch (_: Exception) {}
|
||||
committedSessions.remove(session.sessionId)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "abandonExistingSession: Failed to abandon session ${session.sessionId} for $packageName: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun cleanupStaleSessions() {
|
||||
@@ -825,6 +902,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
}
|
||||
sessionToPackage.clear()
|
||||
pendingUserActionIntents.clear()
|
||||
committedSessions.clear()
|
||||
sessionsCompletedSuccessfully.clear()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
@@ -1009,6 +1088,62 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// ABORT INSTALL (User-initiated cancellation)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Explicitly abort an install operation and clear all tracking.
|
||||
* This is best-effort - if the session is already committed, Android may still complete it.
|
||||
*/
|
||||
private fun abortInstall(packageName: String, result: Result) {
|
||||
Log.d(TAG, "abortInstall called for $packageName")
|
||||
|
||||
// Find and clean up the session
|
||||
val sessionId = sessionToPackage.entries.find { it.value == packageName }?.key
|
||||
val isCommitted = sessionId != null && sessionId in committedSessions
|
||||
|
||||
// Clear all tracking for this package
|
||||
clearAllTrackingForPackage(packageName)
|
||||
|
||||
// Clear watchdog
|
||||
clearWatchdog(packageName)
|
||||
|
||||
// Try to abandon the session (will fail silently if already committed)
|
||||
abandonExistingSession(packageName)
|
||||
|
||||
// Emit cancelled status so Dart knows to clean up
|
||||
emitInstallStatus(packageName, InstallStatus.CANCELLED, "Installation aborted by user")
|
||||
|
||||
result.success(mapOf(
|
||||
"success" to true,
|
||||
"wasCommitted" to isCommitted,
|
||||
"message" to if (isCommitted) {
|
||||
"Session was already committed. Android may still complete the install."
|
||||
} else {
|
||||
"Install aborted successfully."
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all tracking maps for a given package.
|
||||
*/
|
||||
private fun clearAllTrackingForPackage(packageName: String) {
|
||||
pendingUserActionIntents.remove(packageName)
|
||||
|
||||
val sessionId = sessionToPackage.entries.find { it.value == packageName }?.key
|
||||
if (sessionId != null) {
|
||||
sessionToPackage.remove(sessionId)
|
||||
sessionsPendingUserAction.remove(sessionId)
|
||||
sessionsInstalling.remove(sessionId)
|
||||
committedSessions.remove(sessionId)
|
||||
sessionsCompletedSuccessfully.remove(sessionId)
|
||||
}
|
||||
|
||||
verificationThreads.remove(packageName)
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// INSTALLED APPS
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -14,8 +14,10 @@ import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
enum InstallStatus {
|
||||
started,
|
||||
verifying,
|
||||
verifyingProgress, // Verification progress update
|
||||
pendingUserAction,
|
||||
installing, // User accepted, system is now installing
|
||||
systemProcessing, // Committed, cannot cancel
|
||||
alreadyInProgress,
|
||||
success,
|
||||
failed,
|
||||
@@ -27,8 +29,10 @@ extension InstallStatusX on InstallStatus {
|
||||
return switch (raw) {
|
||||
'started' => InstallStatus.started,
|
||||
'verifying' => InstallStatus.verifying,
|
||||
'verifyingProgress' => InstallStatus.verifyingProgress,
|
||||
'pendingUserAction' => InstallStatus.pendingUserAction,
|
||||
'installing' => InstallStatus.installing,
|
||||
'systemProcessing' => InstallStatus.systemProcessing,
|
||||
'alreadyInProgress' => InstallStatus.alreadyInProgress,
|
||||
'success' => InstallStatus.success,
|
||||
'failed' => InstallStatus.failed,
|
||||
@@ -103,6 +107,7 @@ final class AndroidPackageManager extends PackageManager {
|
||||
final message = event['message'] as String?;
|
||||
final errorCode = event['errorCode'] as String?;
|
||||
final description = event['description'] as String?;
|
||||
final progress = event['progress'] as double?;
|
||||
final status = InstallStatusX.tryParse(statusRaw);
|
||||
|
||||
debugPrint(
|
||||
@@ -148,6 +153,14 @@ final class AndroidPackageManager extends PackageManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case InstallStatus.verifyingProgress:
|
||||
// Update verification progress
|
||||
final existingVerify = existingOp;
|
||||
if (existingVerify is Verifying && progress != null) {
|
||||
setOperation(appId, existingVerify.copyWith(progress: progress));
|
||||
}
|
||||
break;
|
||||
|
||||
case InstallStatus.started:
|
||||
// Install session started - transition to Installing
|
||||
if (filePath != null) {
|
||||
@@ -184,6 +197,17 @@ final class AndroidPackageManager extends PackageManager {
|
||||
}
|
||||
break;
|
||||
|
||||
case InstallStatus.systemProcessing:
|
||||
// Install session is committed and taking longer than expected.
|
||||
// Cannot be cancelled - system will eventually complete or fail.
|
||||
if (filePath != null) {
|
||||
setOperation(
|
||||
appId,
|
||||
SystemProcessing(target: target, filePath: filePath),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case InstallStatus.alreadyInProgress:
|
||||
// Real pending dialog exists - Kotlin will also send pendingUserAction event
|
||||
// which transitions to Installing. Nothing to do here.
|
||||
@@ -223,7 +247,7 @@ final class AndroidPackageManager extends PackageManager {
|
||||
if (filePath != null) {
|
||||
setOperation(
|
||||
appId,
|
||||
AwaitingUserAction(target: target, filePath: filePath),
|
||||
InstallCancelled(target: target, filePath: filePath),
|
||||
);
|
||||
} else {
|
||||
clearOperation(appId);
|
||||
@@ -249,7 +273,11 @@ final class AndroidPackageManager extends PackageManager {
|
||||
void _tryAdvanceNextInstall() {
|
||||
// Check if any app is currently in an active install state
|
||||
final hasActiveInstall = state.operations.values.any(
|
||||
(op) => op is Verifying || op is Installing || op is Uninstalling,
|
||||
(op) =>
|
||||
op is Verifying ||
|
||||
op is Installing ||
|
||||
op is SystemProcessing ||
|
||||
op is Uninstalling,
|
||||
);
|
||||
|
||||
if (hasActiveInstall) {
|
||||
@@ -284,7 +312,7 @@ final class AndroidPackageManager extends PackageManager {
|
||||
NativeErrorCode.certMismatch => FailureType.certMismatch,
|
||||
NativeErrorCode.permissionDenied => FailureType.permissionDenied,
|
||||
NativeErrorCode.insufficientStorage => FailureType.insufficientStorage,
|
||||
NativeErrorCode.incompatible => FailureType.installFailed,
|
||||
NativeErrorCode.incompatible => FailureType.incompatible,
|
||||
NativeErrorCode.blocked => FailureType.permissionDenied,
|
||||
NativeErrorCode.installTimeout => FailureType.installFailed,
|
||||
_ => FailureType.installFailed,
|
||||
@@ -311,6 +339,12 @@ final class AndroidPackageManager extends PackageManager {
|
||||
if (lower.contains('permission') || lower.contains('denied')) {
|
||||
return FailureType.permissionDenied;
|
||||
}
|
||||
if (lower.contains('incompatible') ||
|
||||
lower.contains('architecture') ||
|
||||
lower.contains('api level') ||
|
||||
lower.contains('sdk version')) {
|
||||
return FailureType.incompatible;
|
||||
}
|
||||
return FailureType.installFailed;
|
||||
}
|
||||
|
||||
@@ -446,6 +480,35 @@ final class AndroidPackageManager extends PackageManager {
|
||||
syncInstalledPackages();
|
||||
}
|
||||
|
||||
/// Explicitly abort an install operation.
|
||||
/// This is best-effort - if the session is already committed, Android may still complete it.
|
||||
Future<void> abortInstall(String appId) async {
|
||||
try {
|
||||
final result = await _methodChannel
|
||||
.invokeMethod<Map<Object?, Object?>>('abortInstall', {
|
||||
'packageName': appId,
|
||||
})
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => <Object?, Object?>{'success': false},
|
||||
);
|
||||
|
||||
final resultMap = Map<String, dynamic>.from(result ?? {});
|
||||
final wasCommitted = resultMap['wasCommitted'] as bool? ?? false;
|
||||
|
||||
if (wasCommitted) {
|
||||
debugPrint(
|
||||
'[PackageManager] abortInstall: Session was committed, Android may still complete install for $appId',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[PackageManager] abortInstall failed for $appId: $e');
|
||||
}
|
||||
|
||||
// Clear operation on Dart side regardless of native result
|
||||
clearOperation(appId);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// PERMISSIONS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -611,7 +674,7 @@ final class AndroidPackageManager extends PackageManager {
|
||||
if (op == null) continue;
|
||||
if (op is! Installing &&
|
||||
op is! Verifying &&
|
||||
op is! AwaitingUserAction) {
|
||||
op is! InstallCancelled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -69,8 +69,21 @@ class DownloadPaused extends InstallOperation {
|
||||
/// Verifying downloaded file hash (happens in Kotlin, visible to UI)
|
||||
class Verifying extends InstallOperation {
|
||||
final String filePath;
|
||||
final double progress;
|
||||
|
||||
const Verifying({required super.target, required this.filePath});
|
||||
const Verifying({
|
||||
required super.target,
|
||||
required this.filePath,
|
||||
this.progress = 0.0,
|
||||
});
|
||||
|
||||
Verifying copyWith({double? progress}) {
|
||||
return Verifying(
|
||||
target: target,
|
||||
filePath: filePath,
|
||||
progress: progress ?? this.progress,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -109,12 +122,12 @@ class Installing extends InstallOperation {
|
||||
}) : startedAt = startedAt ?? DateTime.now();
|
||||
}
|
||||
|
||||
/// System dialog was dismissed/backgrounded - user can retry
|
||||
/// Different from Failed: this is recoverable with a tap
|
||||
class AwaitingUserAction extends InstallOperation {
|
||||
/// User cancelled the install dialog - file is still ready, no re-download needed.
|
||||
/// User can tap "Install (retry)" to show the dialog again.
|
||||
class InstallCancelled extends InstallOperation {
|
||||
final String filePath;
|
||||
|
||||
const AwaitingUserAction({required super.target, required this.filePath});
|
||||
const InstallCancelled({required super.target, required this.filePath});
|
||||
}
|
||||
|
||||
/// Uninstalling app (for force update: uninstall → install)
|
||||
@@ -124,6 +137,15 @@ class Uninstalling extends InstallOperation {
|
||||
const Uninstalling({required super.target, required this.filePath});
|
||||
}
|
||||
|
||||
/// System is processing the install - cannot be cancelled.
|
||||
/// This state is entered when the install session has been committed to Android
|
||||
/// and is taking longer than expected. The system will eventually complete or fail.
|
||||
class SystemProcessing extends InstallOperation {
|
||||
final String filePath;
|
||||
|
||||
const SystemProcessing({required super.target, required this.filePath});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// FAILURE STATE (Dismiss available)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -169,6 +191,9 @@ enum FailureType {
|
||||
|
||||
/// Not enough storage space
|
||||
insufficientStorage,
|
||||
|
||||
/// Device is incompatible with the app (wrong architecture, API level, etc.)
|
||||
incompatible,
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -185,6 +210,7 @@ extension InstallOperationX on InstallOperation {
|
||||
this is Downloading ||
|
||||
this is Verifying ||
|
||||
this is Installing ||
|
||||
this is SystemProcessing ||
|
||||
this is Uninstalling;
|
||||
|
||||
/// Whether this operation is in the verification phase
|
||||
@@ -196,7 +222,8 @@ extension InstallOperationX on InstallOperation {
|
||||
AwaitingPermission(:final filePath) => filePath,
|
||||
ReadyToInstall(:final filePath) => filePath,
|
||||
Installing(:final filePath) => filePath,
|
||||
AwaitingUserAction(:final filePath) => filePath,
|
||||
SystemProcessing(:final filePath) => filePath,
|
||||
InstallCancelled(:final filePath) => filePath,
|
||||
Uninstalling(:final filePath) => filePath,
|
||||
OperationFailed(:final filePath) => filePath,
|
||||
_ => null,
|
||||
|
||||
@@ -183,8 +183,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
|
||||
List<String> getAwaitingUserAction() => state.operations.entries
|
||||
.where((e) => e.value is AwaitingUserAction)
|
||||
List<String> getInstallCancelled() => state.operations.entries
|
||||
.where((e) => e.value is InstallCancelled)
|
||||
.map((e) => e.key)
|
||||
.toList();
|
||||
|
||||
@@ -365,10 +365,10 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
await _performInstall(appId, op.target, op.filePath);
|
||||
}
|
||||
|
||||
/// Retry install from AwaitingUserAction state
|
||||
/// Retry install from InstallCancelled state
|
||||
Future<void> retryInstall(String appId) async {
|
||||
final op = getOperation(appId);
|
||||
if (op is! AwaitingUserAction) return;
|
||||
if (op is! InstallCancelled) return;
|
||||
|
||||
if (!await File(op.filePath).exists()) {
|
||||
setOperation(
|
||||
@@ -839,7 +839,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
if (message.contains('cancelled') || message.contains('ABORTED')) {
|
||||
setOperation(
|
||||
appId,
|
||||
AwaitingUserAction(target: target, filePath: filePath),
|
||||
InstallCancelled(target: target, filePath: filePath),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -1132,10 +1132,10 @@ final readyToInstallCountProvider = Provider<int>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
final awaitingUserActionCountProvider = Provider<int>((ref) {
|
||||
final installCancelledCountProvider = Provider<int>((ref) {
|
||||
return ref.watch(
|
||||
packageManagerProvider.select(
|
||||
(s) => s.operations.values.whereType<AwaitingUserAction>().length,
|
||||
(s) => s.operations.values.whereType<InstallCancelled>().length,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -130,13 +130,22 @@ class InstallButton extends ConsumerWidget {
|
||||
onTap: () => _resumeDownload(ref),
|
||||
),
|
||||
|
||||
Verifying() => _buildSimpleButton(
|
||||
context,
|
||||
'Verifying...',
|
||||
null,
|
||||
fontSize: fontSize,
|
||||
showSpinner: true,
|
||||
),
|
||||
Verifying(:final progress) => progress > 0
|
||||
? _buildProgressButton(
|
||||
context,
|
||||
ref,
|
||||
progress: progress,
|
||||
text: 'Verifying ${(progress * 100).round()}%',
|
||||
fontSize: fontSize,
|
||||
onTap: null, // Cannot pause/cancel verification
|
||||
)
|
||||
: _buildSimpleButton(
|
||||
context,
|
||||
'Verifying...',
|
||||
null,
|
||||
fontSize: fontSize,
|
||||
showSpinner: true,
|
||||
),
|
||||
|
||||
AwaitingPermission() => _buildSimpleButton(
|
||||
context,
|
||||
@@ -164,13 +173,19 @@ class InstallButton extends ConsumerWidget {
|
||||
showSpinner: true,
|
||||
),
|
||||
|
||||
AwaitingUserAction() => _buildSimpleButton(
|
||||
InstallCancelled() => _buildSimpleButton(
|
||||
context,
|
||||
'Tap to retry',
|
||||
'Install (retry)',
|
||||
() => _retryInstall(ref),
|
||||
fontSize: fontSize,
|
||||
isWarning: true,
|
||||
icon: Icons.refresh,
|
||||
),
|
||||
|
||||
SystemProcessing() => _buildSimpleButton(
|
||||
context,
|
||||
'System processing...',
|
||||
null,
|
||||
fontSize: fontSize,
|
||||
showSpinner: true,
|
||||
),
|
||||
|
||||
Uninstalling() => _buildSimpleButton(
|
||||
@@ -614,6 +629,12 @@ class InstallButton extends ConsumerWidget {
|
||||
'Certificate mismatch',
|
||||
description: 'The app signature does not match. Force update required.',
|
||||
);
|
||||
} else if (operation.type == FailureType.incompatible) {
|
||||
context.showError(
|
||||
'Device incompatible',
|
||||
description:
|
||||
'This app is not compatible with your device. It may require a different architecture or Android version.',
|
||||
);
|
||||
} else if (operation.description != null) {
|
||||
// Errors with descriptions (from Kotlin) are shown as toasts
|
||||
context.showError(operation.message, description: operation.description);
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
# FEAT-001 — Package Manager
|
||||
|
||||
## Goal
|
||||
|
||||
Allow users to install, update, and uninstall apps with clear progress feedback and reliable error handling.
|
||||
|
||||
## Core Invariant: No Hanging States
|
||||
|
||||
**Every operation MUST resolve to a terminal state.** The UI must never get stuck in an in-progress state indefinitely.
|
||||
|
||||
- On success, present a launch action
|
||||
- On failure, show an error with an actionable message
|
||||
- On cancel, restore the pre-operation UI
|
||||
|
||||
If the system stops responding, the operation must either:
|
||||
1. **Advance** to completion when the system eventually responds, OR
|
||||
2. **Fall back** to an error state with clear feedback
|
||||
|
||||
There is no third option. Timeouts, crashes, backgrounding, network loss—all paths lead to a resolved state.
|
||||
|
||||
### Timeouts
|
||||
|
||||
- Operations must enforce timeouts to avoid hanging states.
|
||||
- Timeout durations are defined in implementation (not in this spec).
|
||||
|
||||
## State Model
|
||||
|
||||
### Download States (per app)
|
||||
|
||||
| State | Description | User Action |
|
||||
|-------|-------------|-------------|
|
||||
| `DownloadQueued` | Waiting for download slot (max 3 active) | Cancel available |
|
||||
| `Downloading` | Actively downloading, shows progress % | Pause/Cancel available |
|
||||
| `DownloadPaused` | Download paused by user | Resume/Cancel available |
|
||||
| `Verifying` | Computing hash, shows progress % | Cannot cancel |
|
||||
|
||||
**Transitions:**
|
||||
- `Idle` → tap download → `DownloadQueued` or `Downloading`
|
||||
- `DownloadQueued` → slot available → `Downloading`
|
||||
- `DownloadQueued` → cancel → `Idle`
|
||||
- `Downloading` ↔ `DownloadPaused` (pause/resume)
|
||||
- `Downloading` → cancel → `Idle` (clears partial file)
|
||||
- `Downloading` → success → `Verifying`
|
||||
- `Downloading` → failure/timeout → `Error`
|
||||
- `Verifying` → success → `AwaitingPermission` or `ReadyToInstall`
|
||||
- `Verifying` → failure/timeout → `Error`
|
||||
- App restart while downloading → `Idle` (partial file cleared)
|
||||
|
||||
### Permission State (per app)
|
||||
|
||||
| State | Description | User Action |
|
||||
|-------|-------------|-------------|
|
||||
| `AwaitingPermission` | User must grant "Install unknown apps" | Tap opens Android settings |
|
||||
|
||||
**Transitions:**
|
||||
- `Verifying` success + missing permission → `AwaitingPermission`
|
||||
- `Verifying` success + has permission → `ReadyToInstall`
|
||||
- `AwaitingPermission` → permission granted → `ReadyToInstall`
|
||||
- `AwaitingPermission` → permission denied → `Error`
|
||||
|
||||
### Install States (per app)
|
||||
|
||||
| State | Description | User Action |
|
||||
|-------|-------------|-------------|
|
||||
| `ReadyToInstall` | File verified, queued for install slot | Automatic (no user action) |
|
||||
| `Installing` | System install dialog shown or processing | Confirm/Cancel dialog |
|
||||
| `InstallCancelled` | User cancelled dialog, file still ready | "Install (retry)" button |
|
||||
| `SystemProcessing` | Committed to Android, cannot cancel | Wait for completion |
|
||||
| `Installed` | Successfully installed | "Open" button |
|
||||
|
||||
**Transitions:**
|
||||
- `ReadyToInstall` → install slot available → `Installing`
|
||||
- `Installing` → user confirms → continues `Installing`
|
||||
- `Installing` → user cancels dialog → `InstallCancelled`
|
||||
- `InstallCancelled` → user taps "Install (retry)" → `Installing`
|
||||
- `Installing` (silent) → system completes → `Installed`
|
||||
- `Installing` → failure/timeout → `Error`
|
||||
- `Installing` → committed + slow → `SystemProcessing`
|
||||
- `SystemProcessing` → completes → `Installed`
|
||||
- `SystemProcessing` → failure/timeout → `Error`
|
||||
|
||||
### Update State (per app)
|
||||
|
||||
- `UpdateAvailable` → start update → `Downloading`
|
||||
- `Installing` (silent not allowed) → manual dialog flow
|
||||
- If the app was originally installed by Zapstore and platform allows, update may be silent
|
||||
|
||||
### Uninstall State (per app)
|
||||
|
||||
- `Installed` → uninstall → `Uninstalling`
|
||||
- `Uninstalling` → user confirms → `Idle`
|
||||
- `Uninstalling` → user cancels → `Installed`
|
||||
- `Uninstalling` → failure/timeout → `Error`
|
||||
|
||||
### Queue State (global)
|
||||
|
||||
- Download queue: insertion order (best-effort), max 3 active
|
||||
- Install queue: insertion order (best-effort), max 1 active
|
||||
- Only one install dialog is shown at a time; others wait
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- iOS/macOS/desktop support (Android only)
|
||||
- Split APK support
|
||||
- Background installs without any user awareness
|
||||
|
||||
## User-Visible Behavior
|
||||
|
||||
### Download
|
||||
|
||||
- Progress bar shows percentage (0-100%)
|
||||
- Pause/resume buttons available during download
|
||||
- Cancel clears the download
|
||||
- Multiple apps can download simultaneously (up to 3)
|
||||
- Pause does not persist across app restarts
|
||||
- Partial downloads are deleted on app restart
|
||||
|
||||
### Verification
|
||||
|
||||
- Progress bar shows percentage (0-100%) during hash verification
|
||||
- Verification cannot be paused or cancelled
|
||||
- Large APKs (100MB+) may show noticeable verification time
|
||||
|
||||
### Install
|
||||
|
||||
- System confirmation dialog appears for first-time installs
|
||||
- If user cancels dialog, shows "Install (retry)" button (no re-download needed)
|
||||
- Silent install skips dialog when Zapstore is the original installer
|
||||
|
||||
### Update
|
||||
|
||||
- Same flow as install
|
||||
- Silent update possible if Zapstore originally installed the app
|
||||
- If silent update is not allowed, proceed with manual update flow
|
||||
|
||||
### Uninstall
|
||||
|
||||
- System uninstall dialog appears
|
||||
- If uninstall succeeds, the app is removed from the installed list
|
||||
- If uninstall is canceled, there is no change
|
||||
|
||||
### Force Update (Certificate Mismatch)
|
||||
|
||||
- When app signature changed (different developer/build):
|
||||
- Show a certificate mismatch error
|
||||
- Force update action offered
|
||||
- Tapping shows uninstall dialog first
|
||||
- After uninstall, install proceeds automatically
|
||||
- Warning: user data in the app will be lost
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Network/Download Issues
|
||||
|
||||
- On download failure, provide a retry option
|
||||
- On 404, retry from `cdn.zapstore.dev`, otherwise fail
|
||||
- If network is lost mid-download, support resume when reconnected
|
||||
|
||||
### Verification Failures
|
||||
|
||||
- Hash mismatch suggests re-download
|
||||
- Invalid file (not an APK) shows a clear error
|
||||
|
||||
### Install Failures
|
||||
|
||||
- Insufficient storage shows a clear error message
|
||||
- Incompatible device shows a clear "Device incompatible" error message
|
||||
- Blocked by device policy shows a clear error message
|
||||
- If the user cancels the dialog, show "Install (retry)" (`InstallCancelled` state)
|
||||
|
||||
### Permission Issues
|
||||
|
||||
- If install unknown apps permission is missing, show `AwaitingPermission` state
|
||||
- Tapping opens Android settings to grant permission
|
||||
- If the user denies permission, show a clear error with instructions
|
||||
|
||||
### App Lifecycle
|
||||
|
||||
- If the app is backgrounded during install dialog, the dialog reappears on return
|
||||
- If the app is killed during install, reset UI and clean up temp files on next launch
|
||||
- If install takes too long, it cannot be canceled once committed
|
||||
|
||||
### Concurrent Operations
|
||||
|
||||
- Multiple downloads: up to 3 simultaneous, others queued
|
||||
- Multiple installs: 1 at a time, others wait in queue
|
||||
- Queue advances automatically after each completion
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] **No operation hangs indefinitely** — every state resolves to success, failure, or cancelled
|
||||
- [ ] User sees download progress percentage
|
||||
- [ ] User sees verification progress percentage
|
||||
- [ ] User can pause and resume downloads
|
||||
- [ ] User can cancel downloads
|
||||
- [ ] Verification errors show clear message
|
||||
- [ ] Install dialog appears for new apps
|
||||
- [ ] Silent update works for Zapstore-owned apps
|
||||
- [ ] Certificate mismatch offers force update option
|
||||
- [ ] Force update uninstalls then installs
|
||||
- [ ] Backgrounding app preserves install dialog
|
||||
- [ ] Long installs transition to SystemProcessing (cannot cancel)
|
||||
- [ ] All errors show actionable messages
|
||||
- [ ] Multiple downloads queue correctly
|
||||
- [ ] Multiple installs proceed one at a time
|
||||
|
||||
## Notes
|
||||
|
||||
- Silent install requires Android 12+ and Zapstore as original installer
|
||||
- Certificate mismatch requires uninstall (app data lost) - ensure user understands
|
||||
- Some device policies may block all installs from unknown sources
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
# WORK-001 — Package Manager
|
||||
|
||||
**Feature:** FEAT-001-package-manager.md
|
||||
**Status:** Complete
|
||||
|
||||
## Problem Solved
|
||||
|
||||
Install sessions could complete in Android PackageInstaller even after Zapstore's watchdog timed out and reported failure. This caused:
|
||||
1. Apps "silently" installed without user awareness
|
||||
2. Certificate mismatch errors on retry (stale session still active)
|
||||
3. Confusing UX where failed installs suddenly appeared as completed
|
||||
|
||||
Root cause: `abandonSession()` doesn't work after `session.commit()` is called.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
- [x] 1. Track committed sessions separately
|
||||
- Added `committedSessions` set to track sessions post-commit
|
||||
- Added `sessionsCompletedSuccessfully` set to avoid duplicate SUCCESS events
|
||||
- Mark sessions as committed after `session.commit()`
|
||||
|
||||
- [x] 2. Modify watchdog behavior for committed sessions
|
||||
- For committed sessions: emit INSTALLING status and extend timeout
|
||||
- For non-committed sessions: existing timeout/fail behavior
|
||||
|
||||
- [x] 3. Handle onFinished callback for missed SUCCESS events
|
||||
- SessionCallback.onFinished now emits SUCCESS if not already emitted
|
||||
- Catches installs that completed after timeout or missed broadcast
|
||||
|
||||
- [x] 4. Add explicit abort method for user-initiated cancellation
|
||||
- New method channel: `abortInstall`
|
||||
- Clears all tracking for package (best effort)
|
||||
- Returns `wasCommitted` flag to warn if Android may still complete
|
||||
|
||||
## Decisions
|
||||
|
||||
### 2026-02-03 — Committed session handling
|
||||
|
||||
**Context:** `abandonSession()` is ineffective after commit.
|
||||
**Options:** A) Ignore timeout for committed, B) Keep waiting, C) Show different status.
|
||||
**Decision:** Option B+C - Keep waiting and show "System is processing".
|
||||
**Rationale:** User needs feedback; Android will eventually complete or fail. No hanging states allowed.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Reference
|
||||
|
||||
### Architecture
|
||||
|
||||
Event-driven with native Kotlin as single source of truth.
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ Dart/Flutter │◄───────►│ Kotlin/Native │
|
||||
│ │ Events │ │
|
||||
│ PackageManager │◄────────│ AndroidPackage │
|
||||
│ (StateNotifier)│ │ ManagerPlugin │
|
||||
└─────────────────┘ └─────────────────┘
|
||||
│ │
|
||||
│ MethodChannel │ PackageInstaller
|
||||
│ (commands) │ SessionCallback
|
||||
│ │ BroadcastReceiver
|
||||
▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐
|
||||
│ background_ │ │ Android System │
|
||||
│ downloader │ │ PackageInstaller│
|
||||
└─────────────────┘ └─────────────────┘
|
||||
```
|
||||
|
||||
### State Machine
|
||||
|
||||
```
|
||||
Idle → DownloadQueued → Downloading → Verifying → ReadyToInstall → Installing → Terminal
|
||||
│
|
||||
pendingUserAction
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
SUCCESS FAILED CANCELLED
|
||||
```
|
||||
|
||||
Every state MUST resolve to a terminal state. No exceptions.
|
||||
|
||||
### Native Status Events
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| `verifying` | Hash verification in progress |
|
||||
| `started` | Install session created |
|
||||
| `pendingUserAction` | Waiting for user confirmation |
|
||||
| `installing` | User accepted, system processing |
|
||||
| `success` | Install completed |
|
||||
| `failed` | Error occurred |
|
||||
| `cancelled` | User cancelled |
|
||||
|
||||
### Error Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `downloadFailed` | Network/file error |
|
||||
| `hashMismatch` | SHA-256 mismatch |
|
||||
| `invalidFile` | Not valid APK |
|
||||
| `installFailed` | Generic failure |
|
||||
| `certMismatch` | Signature conflict |
|
||||
| `permissionDenied` | No install permission |
|
||||
| `insufficientStorage` | No space |
|
||||
| `incompatible` | Device incompatible |
|
||||
| `blocked` | Device policy |
|
||||
| `installTimeout` | Watchdog timeout (uncommitted only) |
|
||||
|
||||
### Watchdog Timeouts
|
||||
|
||||
| Phase | Initial | Max | Behavior |
|
||||
|-------|---------|-----|----------|
|
||||
| Verify | 10s | 10s | Fail if thread dead |
|
||||
| Install (uncommitted) | 10s | 120s | Abandon session, emit FAILED |
|
||||
| Install (committed) | 10s | ∞ | Cannot abandon; extend deadline, show "processing" |
|
||||
|
||||
### Session Tracking Maps
|
||||
|
||||
| Map | Purpose |
|
||||
|-----|---------|
|
||||
| `sessionToPackage` | sessionId → packageName lookup |
|
||||
| `pendingUserActionIntents` | Stored intents for dialog re-launch |
|
||||
| `sessionsPendingUserAction` | Sessions awaiting user confirmation |
|
||||
| `sessionsInstalling` | Sessions where user accepted |
|
||||
| `committedSessions` | Sessions post-commit (cannot abandon) |
|
||||
| `sessionsCompletedSuccessfully` | Prevents duplicate SUCCESS events |
|
||||
|
||||
### Silent Install Conditions (Android 12+)
|
||||
|
||||
1. `canRequestPackageInstalls()` permission granted
|
||||
2. Zapstore was original installer
|
||||
3. `USER_ACTION_NOT_REQUIRED` flag set
|
||||
4. `PACKAGE_SOURCE_STORE` declared
|
||||
5. `setRequestUpdateOwnership(true)` on API 34+
|
||||
|
||||
### Key Files
|
||||
|
||||
| File | Responsibility |
|
||||
|------|----------------|
|
||||
| `AndroidPackageManagerPlugin.kt` | Native install orchestration |
|
||||
| `InstallResultReceiver.kt` | Broadcast → status event mapping |
|
||||
| `android_package_manager.dart` | Dart state management |
|
||||
| `package_manager.dart` | Base class, download management |
|
||||
| `install_operation.dart` | State machine types |
|
||||
Reference in New Issue
Block a user