From aa2c5eab5361883f3849fbd5c7dc8e06eecad786 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sun, 14 Dec 2025 14:31:24 -0300 Subject: [PATCH] Rewrite package queuing --- android/app/build.gradle.kts | 5 + .../plugins/AndroidPackageManagerPlugin.kt | 273 ++++++++++++++++-- .../alpha/plugins/InstallResultReceiver.kt | 49 ++-- lib/services/download_service.dart | 82 +++++- .../android_package_manager.dart | 77 +++-- lib/widgets/install_button.dart | 42 +-- 6 files changed, 421 insertions(+), 107 deletions(-) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 5a9ac8c..4585738 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -23,6 +23,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true } kotlinOptions { @@ -94,3 +95,7 @@ android { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt index 896c1ee..71ab5ef 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/AndroidPackageManagerPlugin.kt @@ -50,20 +50,72 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { // Store pending install results keyed by sessionId - completed when broadcast arrives private val pendingInstallResults = mutableMapOf() + // Map sessionId to packageName for reverse lookup + private val sessionToPackage = mutableMapOf() + // Store pending user action intents keyed by packageName - for re-launching after foreground private val pendingUserActionIntents = mutableMapOf() + + // Track whether the pending user action prompt was deferred (app backgrounded or launch failed). + // Only deferred prompts should be re-launched on app resume to avoid double system dialogs. + private val pendingUserActionDeferred = mutableMapOf() + + // Track app foreground state - set by Dart via setAppForegroundState + private var isAppInForeground = true + + // Reference to context for static methods (set during onAttachedToEngine) + private var appContext: Context? = null + + /** + * Set the app's foreground state. Called from Dart when lifecycle changes. + */ + fun setAppForegroundState(foreground: Boolean) { + isAppInForeground = foreground + Log.d(TAG, "App foreground state: $foreground") + } + + /** + * Check if the app is currently in foreground. + */ + fun isAppInForeground(): Boolean = isAppInForeground + + /** + * Check if we're already waiting for a result for this package. + */ + fun hasPendingResultForPackage(packageName: String): Boolean { + return sessionToPackage.containsValue(packageName) + } /** * Called by InstallResultReceiver when installation completes/fails. * Completes the pending method channel result so Dart await finishes. */ - fun completeInstallResult(sessionId: Int, resultMap: Map) { + fun completeInstallResult(sessionId: Int, resultMap: Map, context: Context? = null) { val result = pendingInstallResults.remove(sessionId) - // Also clear any pending user action intent for this package - val packageName = resultMap["packageName"] as? String + val packageName = resultMap["packageName"] as? String ?: sessionToPackage[sessionId] + + // Clear all tracking for this session/package + sessionToPackage.remove(sessionId) if (packageName != null) { pendingUserActionIntents.remove(packageName) + pendingUserActionDeferred.remove(packageName) + Log.d(TAG, "Cleared all tracking for $packageName (session $sessionId)") } + + // For failed/cancelled installs, try to abandon the session to clean up + val isSuccess = resultMap["isSuccess"] as? Boolean ?: false + val ctx = context ?: appContext + if (!isSuccess && ctx != null) { + try { + val packageInstaller = ctx.packageManager.packageInstaller + packageInstaller.abandonSession(sessionId) + Log.d(TAG, "Abandoned failed session $sessionId") + } catch (e: Exception) { + // Session might already be abandoned or completed + Log.d(TAG, "Could not abandon session $sessionId: ${e.message}") + } + } + if (result != null) { Log.d(TAG, "Completing install result for session $sessionId: $resultMap") result.success(resultMap) @@ -80,18 +132,22 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { } /** - * Store a pending user action intent for later re-launch + * Store a pending user action intent for later re-launch. */ fun storePendingUserActionIntent(packageName: String, intent: Intent) { pendingUserActionIntents[packageName] = intent Log.d(TAG, "Stored pending user action intent for $packageName") } - + /** - * Get and remove a pending user action intent + * Mark whether a pending user action prompt was deferred. + * + * - deferred=true: prompt was NOT shown (or failed to show). Safe to re-launch on resume. + * - deferred=false: prompt was shown while in foreground. Do NOT re-launch on resume. */ - fun getPendingUserActionIntent(packageName: String): Intent? { - return pendingUserActionIntents.remove(packageName) + fun setPendingUserActionDeferred(packageName: String, deferred: Boolean) { + pendingUserActionDeferred[packageName] = deferred + Log.d(TAG, "Pending user action deferred for $packageName: $deferred") } /** @@ -106,9 +162,90 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "android_package_manager") channel.setMethodCallHandler(this) context = flutterPluginBinding.applicationContext + appContext = context + + // Clean up ALL existing sessions on startup - start fresh + abandonAllSessions() Log.d(TAG, "AndroidPackageManagerPlugin initialized") } + + /** + * Abandon ALL PackageInstaller sessions owned by this app. + * Called on startup to ensure clean state. + */ + private fun abandonAllSessions() { + try { + val packageInstaller = context.packageManager.packageInstaller + val mySessions = packageInstaller.mySessions + var abandonedCount = 0 + + for (session in mySessions) { + // Never abandon active sessions: doing so can cause "session files in use" and/or + // break in-progress installs when the engine re-attaches. + if (session.isActive) { + Log.d(TAG, "Startup cleanup: skipping active session ${session.sessionId} for ${session.appPackageName}") + continue + } + Log.d(TAG, "Abandoning startup session ${session.sessionId} for ${session.appPackageName}") + try { + packageInstaller.abandonSession(session.sessionId) + abandonedCount++ + } catch (e: Exception) { + Log.w(TAG, "Failed to abandon session ${session.sessionId}", e) + } + } + + // Clear all in-memory state + pendingInstallResults.clear() + sessionToPackage.clear() + pendingUserActionIntents.clear() + pendingUserActionDeferred.clear() + + if (abandonedCount > 0) { + Log.d(TAG, "Startup cleanup: abandoned $abandonedCount sessions") + } + } catch (e: Exception) { + Log.w(TAG, "Failed to cleanup sessions on startup", e) + } + } + + /** + * Find an existing session for a package, if any. + * Returns the session info or null if no session exists. + */ + private fun findExistingSession(packageName: String): PackageInstaller.SessionInfo? { + return try { + val packageInstaller = context.packageManager.packageInstaller + packageInstaller.mySessions.find { it.appPackageName == packageName } + } catch (e: Exception) { + Log.w(TAG, "Failed to check existing sessions", e) + null + } + } + + /** + * Abandon any existing session for a package. + * Returns true if a session was abandoned. + */ + private fun abandonExistingSession(packageName: String): Boolean { + val existingSession = findExistingSession(packageName) + if (existingSession != null) { + try { + val packageInstaller = context.packageManager.packageInstaller + packageInstaller.abandonSession(existingSession.sessionId) + pendingInstallResults.remove(existingSession.sessionId) + sessionToPackage.remove(existingSession.sessionId) + pendingUserActionIntents.remove(packageName) + pendingUserActionDeferred.remove(packageName) + Log.d(TAG, "Abandoned existing session ${existingSession.sessionId} for $packageName") + return true + } catch (e: Exception) { + Log.w(TAG, "Failed to abandon session for $packageName", e) + } + } + return false + } override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) @@ -168,6 +305,11 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { } retryPendingInstall(packageName, result) } + "setAppForegroundState" -> { + val foreground = call.argument("foreground") ?: true + setAppForegroundState(foreground) + result.success(null) + } else -> { result.notImplemented() } @@ -474,6 +616,43 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { false } + // CRITICAL: Check if we're already waiting for a result for this package + // This prevents double-prompts and "session files in use" errors + if (hasPendingResultForPackage(packageName)) { + Log.d(TAG, "Already have pending result for $packageName") + + // Only re-show the dialog if the prompt was actually deferred. + // If the prompt was already shown in the foreground, re-launching it causes double prompts. + val pendingIntent = pendingUserActionIntents[packageName] + val deferred = pendingUserActionDeferred[packageName] == true + if (pendingIntent != null && deferred && isAppInForeground()) { + Log.d(TAG, "Re-launching deferred dialog for $packageName") + try { + pendingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + context.startActivity(pendingIntent) + pendingUserActionDeferred[packageName] = false + } catch (e: Exception) { + Log.w(TAG, "Failed to re-launch dialog", e) + } + } + + // Tell Dart that installation is already in progress - don't start a new one + result.success(mapOf( + "isSuccess" to false, + "errorMessage" to "Installation already in progress", + "packageName" to packageName, + "alreadyInProgress" to true + )) + return + } + + // Check for orphaned system session (no pending result, but session exists) + val existingSession = findExistingSession(packageName) + if (existingSession != null) { + Log.d(TAG, "Found orphaned session ${existingSession.sessionId} for $packageName, abandoning it") + abandonExistingSession(packageName) + } + Log.d(TAG, "Installing $packageName (update=$isUpdate)") // Match Accrescent's session setup exactly @@ -493,6 +672,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { val sessionId = packageInstaller.createSession(sessionParams) val session = packageInstaller.openSession(sessionId) + Log.d(TAG, "Created new session $sessionId for $packageName") + // Transfer the APK file - match Accrescent's approach exactly FileInputStream(apkFile).use { fileInputStream -> // Use Os.fstat for accurate file size (like Accrescent) @@ -512,7 +693,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { // Store pending result BEFORE committing - will be completed by InstallResultReceiver pendingInstallResults[sessionId] = result - Log.d(TAG, "Stored pending result for session $sessionId, awaiting broadcast...") + sessionToPackage[sessionId] = packageName + Log.d(TAG, "Stored pending result for session $sessionId ($packageName), awaiting broadcast...") // Create pending intent for installation result val intent = Intent(context.applicationContext, InstallResultReceiver::class.java).apply { @@ -520,7 +702,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { putExtra("packageName", packageName) putExtra("isUpdate", isUpdate) } - val pendingIntent = PendingIntent.getBroadcast( + val pendingIntentForBroadcast = PendingIntent.getBroadcast( context.applicationContext, sessionId, // Use sessionId as requestCode for uniqueness intent, @@ -529,7 +711,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { // Commit the session - result will be delivered via broadcast to InstallResultReceiver // which will call completeInstallResult() to finish the method channel call - session.commit(pendingIntent.intentSender) + session.commit(pendingIntentForBroadcast.intentSender) session.close() // DO NOT call result.success() here - wait for InstallResultReceiver broadcast @@ -688,33 +870,72 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler { } /** - * Re-launch a pending install prompt that was stored when app was backgrounded. - * This allows the user to see the install confirmation dialog again. + * Launch a pending install prompt when app returns to foreground. + * Called from Dart after setAppForegroundState(true). + * + * Returns success if: + * - There's a pending user action intent (stored when app was backgrounded) + * - OR there's an active session in the system */ private fun retryPendingInstall(packageName: String, result: Result) { - val pendingIntent = getPendingUserActionIntent(packageName) + // First check if we have a stored user action intent + val pendingIntent = pendingUserActionIntents[packageName] if (pendingIntent != null) { - try { - pendingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(pendingIntent) - result.success(mapOf( - "isSuccess" to true, - "hasPending" to true, - "packageName" to packageName - )) - } catch (e: Exception) { - Log.e(TAG, "Failed to re-launch install prompt for $packageName", e) + val deferred = pendingUserActionDeferred[packageName] == true + if (!deferred) { + // Prompt was already shown while app was in foreground. + // Do not re-launch it on resume (prevents double system dialogs). result.success(mapOf( "isSuccess" to false, "hasPending" to true, - "errorMessage" to "Failed to launch install prompt: ${e.message}" + "relaunched" to false, + "promptAlreadyShown" to true, + "packageName" to packageName )) + return } + + Log.d(TAG, "Re-launching deferred user action for $packageName") + try { + pendingIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + context.startActivity(pendingIntent) + pendingUserActionDeferred[packageName] = false + result.success(mapOf( + "isSuccess" to true, + "hasPending" to true, + "relaunched" to true, + "promptAlreadyShown" to false, + "packageName" to packageName + )) + return + } catch (e: Exception) { + Log.e(TAG, "Failed to launch install prompt for $packageName", e) + // Clear the stale intent + pendingUserActionIntents.remove(packageName) + pendingUserActionDeferred.remove(packageName) + } + } + + // Check if there's a system session waiting + val existingSession = findExistingSession(packageName) + if (existingSession != null) { + Log.d(TAG, "Found system session ${existingSession.sessionId} for $packageName but no user action intent") + // Session exists but we don't have the intent - user needs to re-trigger install + result.success(mapOf( + "isSuccess" to false, + "hasPending" to true, + "sessionPending" to true, + "relaunched" to false, + "promptAlreadyShown" to false, + "packageName" to packageName + )) } else { - // No pending intent found - installation might have completed or was never started + // No pending intent and no active session result.success(mapOf( "isSuccess" to false, "hasPending" to false, + "relaunched" to false, + "promptAlreadyShown" to false, "packageName" to packageName )) } diff --git a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt index ee34034..0136719 100644 --- a/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt +++ b/android/app/src/main/kotlin/dev/zapstore/alpha/plugins/InstallResultReceiver.kt @@ -19,8 +19,11 @@ private const val TAG = "InstallResultReceiver" * This receiver completes the pending method channel Result stored in * AndroidPackageManagerPlugin, allowing Dart's await to finish. * - * For STATUS_PENDING_USER_ACTION (installer takeover prompt), we launch the - * confirmation dialog but DON'T complete the Result yet - we wait for the + * For STATUS_PENDING_USER_ACTION (installer takeover prompt): + * - If app is in foreground: launch confirmation dialog immediately + * - If app is backgrounded: store intent for later launch via retryPendingInstall + * + * We DON'T complete the Result for pending user action - we wait for the * actual success/failure that follows after user confirms/cancels. */ class InstallResultReceiver : BroadcastReceiver() { @@ -40,7 +43,7 @@ class InstallResultReceiver : BroadcastReceiver() { PackageInstaller.STATUS_PENDING_USER_ACTION -> { Log.d(TAG, "User confirmation required for $packageName (installer takeover)") - // Launch confirmation dialog - user needs to approve + // Extract confirmation dialog intent val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) } else { @@ -48,15 +51,25 @@ class InstallResultReceiver : BroadcastReceiver() { intent.getParcelableExtra(Intent.EXTRA_INTENT) } if (confirmIntent != null) { - // Store the intent so it can be re-launched if app is backgrounded + // Always store the intent for potential re-launch from retryPendingInstall AndroidPackageManagerPlugin.storePendingUserActionIntent(packageName, confirmIntent) - confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - try { - context.startActivity(confirmIntent) - } catch (e: Exception) { - Log.w(TAG, "Failed to launch confirmation dialog", e) - showUserActionNotification(context, sessionId, confirmIntent, packageName) + // Only launch dialog if app is in foreground + if (AndroidPackageManagerPlugin.isAppInForeground()) { + confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + try { + context.startActivity(confirmIntent) + // Prompt was shown while in foreground: do NOT re-launch on resume. + AndroidPackageManagerPlugin.setPendingUserActionDeferred(packageName, false) + } catch (e: Exception) { + Log.w(TAG, "Failed to launch confirmation dialog", e) + // Launch failed -> treat as deferred so it can be re-launched later. + AndroidPackageManagerPlugin.setPendingUserActionDeferred(packageName, true) + showUserActionNotification(context, sessionId, confirmIntent, packageName) + } + } else { + Log.d(TAG, "App backgrounded, deferring user action for $packageName") + AndroidPackageManagerPlugin.setPendingUserActionDeferred(packageName, true) } } @@ -82,7 +95,7 @@ class InstallResultReceiver : BroadcastReceiver() { "isSuccess" to false, "errorMessage" to (message ?: "Installation failed"), "packageName" to packageName - )) + ), context) } PackageInstaller.STATUS_FAILURE_ABORTED -> { @@ -92,7 +105,7 @@ class InstallResultReceiver : BroadcastReceiver() { "errorMessage" to "Installation was cancelled by user", "packageName" to packageName, "cancelled" to true - )) + ), context) } PackageInstaller.STATUS_FAILURE_BLOCKED -> { @@ -101,7 +114,7 @@ class InstallResultReceiver : BroadcastReceiver() { "isSuccess" to false, "errorMessage" to (message ?: "Installation blocked by device policy"), "packageName" to packageName - )) + ), context) } PackageInstaller.STATUS_FAILURE_CONFLICT -> { @@ -110,7 +123,7 @@ class InstallResultReceiver : BroadcastReceiver() { "isSuccess" to false, "errorMessage" to (message ?: "Installation conflicts with existing package"), "packageName" to packageName - )) + ), context) } PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> { @@ -119,7 +132,7 @@ class InstallResultReceiver : BroadcastReceiver() { "isSuccess" to false, "errorMessage" to (message ?: "Package is incompatible with this device"), "packageName" to packageName - )) + ), context) } PackageInstaller.STATUS_FAILURE_INVALID -> { @@ -128,7 +141,7 @@ class InstallResultReceiver : BroadcastReceiver() { "isSuccess" to false, "errorMessage" to (message ?: "Invalid installation package"), "packageName" to packageName - )) + ), context) } PackageInstaller.STATUS_FAILURE_STORAGE -> { @@ -137,7 +150,7 @@ class InstallResultReceiver : BroadcastReceiver() { "isSuccess" to false, "errorMessage" to (message ?: "Insufficient storage space"), "packageName" to packageName - )) + ), context) } else -> { @@ -146,7 +159,7 @@ class InstallResultReceiver : BroadcastReceiver() { "isSuccess" to false, "errorMessage" to (message ?: "Installation failed with status $status"), "packageName" to packageName - )) + ), context) } } } diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index a7529c1..4fd112d 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -45,6 +45,7 @@ class DownloadInfo { this.progress = 0.0, this.isInstalling = false, this.isReadyToInstall = false, + this.skipVerificationOnInstall = false, this.errorDetails, }); @@ -55,6 +56,7 @@ class DownloadInfo { final double progress; final bool isInstalling; final bool isReadyToInstall; // Downloaded but installation failed/canceled + final bool skipVerificationOnInstall; final String? errorDetails; // Store error details for display String get taskId => task.taskId; @@ -79,6 +81,7 @@ class DownloadInfo { double? progress, bool? isInstalling, bool? isReadyToInstall, + bool? skipVerificationOnInstall, String? errorDetails, }) { return DownloadInfo( @@ -89,6 +92,8 @@ class DownloadInfo { progress: progress ?? this.progress, isInstalling: isInstalling ?? this.isInstalling, isReadyToInstall: isReadyToInstall ?? this.isReadyToInstall, + skipVerificationOnInstall: + skipVerificationOnInstall ?? this.skipVerificationOnInstall, errorDetails: errorDetails ?? this.errorDetails, ); } @@ -121,6 +126,13 @@ class DownloadService extends StateNotifier> { final wasBackground = !_isAppInForeground; _isAppInForeground = inForeground; + // Notify native side of foreground state change + // This allows InstallResultReceiver to decide whether to launch dialogs immediately + final packageManager = ref.read(packageManagerProvider.notifier); + if (packageManager is AndroidPackageManager) { + await packageManager.setAppForegroundState(inForeground); + } + // When returning to foreground, handle stalled installations and process pending if (inForeground && wasBackground) { await _handleReturnToForeground(); @@ -142,10 +154,29 @@ class DownloadService extends StateNotifier> { // Try to re-launch the pending install prompt // This re-uses the existing Android session instead of creating a new one if (packageManager is AndroidPackageManager) { - final relaunched = await packageManager.retryPendingInstall(appId); - if (relaunched) { - // Successfully re-launched the prompt, keep isInstalling: true - // The existing await in _processInstallQueue will complete when user confirms + final retry = await packageManager.retryPendingInstall(appId); + + // If there's a pending prompt/session, DO NOT reset the install state. + // - promptAlreadyShown: system dialog is/was already on screen (relaunching causes double prompts) + // - relaunched: we just re-launched a deferred prompt + if (retry.hasPending && + (retry.relaunched || retry.promptAlreadyShown)) { + continue; + } + + // If the system still has a session but we no longer have the confirm intent, the user must + // re-trigger install (we can't safely recover the intent). Mark ready-to-install. + if (retry.hasPending && retry.sessionPending) { + final downloadInfo = state[appId]; + if (downloadInfo != null) { + state = { + ...state, + appId: downloadInfo.copyWith( + isInstalling: false, + isReadyToInstall: true, + ), + }; + } continue; } } @@ -695,6 +726,7 @@ class DownloadService extends StateNotifier> { filePath, expectedHash: fileMetadata.hash, expectedSize: fileMetadata.size ?? 0, + skipVerification: downloadInfo.skipVerificationOnInstall, ); // Installation succeeded - clean up downloaded file @@ -718,6 +750,21 @@ class DownloadService extends StateNotifier> { // Installation failed for this app - continue with others final errorMessage = e.toString(); + // Another install session already exists (typically from a previous UI path). + // Do NOT advance the queue or delete the APK: keep state as installing and stop. + if (errorMessage.contains('INSTALL_ALREADY_IN_PROGRESS')) { + // Put it back so we can retry when the existing session resolves. + _installQueue.insert(0, appId); + state = { + ...state, + appId: downloadInfo.copyWith( + isInstalling: true, + isReadyToInstall: false, + ), + }; + break; + } + // Detect certificate mismatch final isCertificateMismatch = errorMessage.contains('signatures do not match') || @@ -855,16 +902,23 @@ class DownloadService extends StateNotifier> { } } - /// Mark download as installing (for reckless mode retry) - void markInstalling(String appId) { + /// Explicitly mark a downloaded file as ready to install. + /// + /// Used by flows where the user explicitly chooses to proceed (e.g. "reckless" override) + /// and we want to drive installation through the single install queue. + void markReadyToInstall( + String appId, { + bool skipVerificationOnInstall = false, + }) { final downloadInfo = state[appId]; if (downloadInfo != null) { state = { ...state, appId: downloadInfo.copyWith( - isInstalling: true, - isReadyToInstall: false, - errorDetails: null, + isInstalling: false, + isReadyToInstall: true, + skipVerificationOnInstall: skipVerificationOnInstall, + errorDetails: null, // clear previous error to avoid confusing UI ), }; } @@ -885,8 +939,14 @@ class DownloadService extends StateNotifier> { return; } - // Update state to mark as no longer ready to install (about to install) - state = {...state, appId: downloadInfo.copyWith(isReadyToInstall: false)}; + // Update state to mark as no longer ready to install (queued for install) + state = { + ...state, + appId: downloadInfo.copyWith( + isReadyToInstall: false, + errorDetails: null, + ), + }; // Add to installation queue instead of installing directly if (!_installQueue.contains(appId)) { diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index 8795765..508299f 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -34,24 +34,23 @@ final class AndroidPackageManager extends PackageManager { throw Exception('APK file not found: $filePath'); } - final result = await _channel - .invokeMethod>('install', { - 'filePath': filePath, - 'packageName': appId, - 'expectedHash': expectedHash, - 'expectedSize': expectedSize, - 'skipVerification': skipVerification, - }) - .timeout( - const Duration(minutes: 5), - onTimeout: () => { - 'isSuccess': false, - 'errorMessage': 'Installation timed out - user did not respond', - }, - ); + final result = await _channel.invokeMethod>('install', { + 'filePath': filePath, + 'packageName': appId, + 'expectedHash': expectedHash, + 'expectedSize': expectedSize, + 'skipVerification': skipVerification, + }); final resultMap = Map.from(result ?? {}); + // If installation is already in progress, do NOT treat this call as success. + // Returning silently would cause callers to delete APK files / advance queues while the + // original session is still active, which can lead to "session files in use" races. + if (resultMap['alreadyInProgress'] == true) { + throw Exception('INSTALL_ALREADY_IN_PROGRESS'); + } + if (!(resultMap['isSuccess'] == true)) { final error = resultMap['errorMessage'] ?? 'Installation failed'; throw Exception(error); @@ -187,8 +186,8 @@ final class AndroidPackageManager extends PackageManager { } /// Re-launch a pending install prompt that was backgrounded. - /// Returns true if there was a pending prompt and it was re-launched. - Future retryPendingInstall(String appId) async { + /// Returns structured info so callers can avoid double-prompts and bad state resets. + Future retryPendingInstall(String appId) async { try { final result = await _channel .invokeMethod>('retryPendingInstall', { @@ -196,9 +195,26 @@ final class AndroidPackageManager extends PackageManager { }); final resultMap = Map.from(result ?? {}); - return resultMap['hasPending'] == true && resultMap['isSuccess'] == true; + return RetryPendingInstallResult.fromMap(resultMap); } catch (e) { - return false; + return const RetryPendingInstallResult( + hasPending: false, + relaunched: false, + sessionPending: false, + promptAlreadyShown: false, + ); + } + } + + /// Set app foreground state on the native side. + /// Called from DownloadService when app lifecycle changes. + Future setAppForegroundState(bool foreground) async { + try { + await _channel.invokeMethod('setAppForegroundState', { + 'foreground': foreground, + }); + } catch (e) { + // Ignore errors - best effort } } @@ -268,3 +284,26 @@ final class AndroidPackageManager extends PackageManager { } } } + +final class RetryPendingInstallResult { + const RetryPendingInstallResult({ + required this.hasPending, + required this.relaunched, + required this.sessionPending, + required this.promptAlreadyShown, + }); + + factory RetryPendingInstallResult.fromMap(Map map) { + return RetryPendingInstallResult( + hasPending: map['hasPending'] == true, + relaunched: map['relaunched'] == true, + sessionPending: map['sessionPending'] == true, + promptAlreadyShown: map['promptAlreadyShown'] == true, + ); + } + + final bool hasPending; + final bool relaunched; + final bool sessionPending; + final bool promptAlreadyShown; +} diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index ec8308b..9a544d6 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -616,30 +616,16 @@ class InstallButton extends ConsumerWidget { '⚠️ Proceed anyway (RECKLESS)', () async { // Retry installation with skipVerification=true - final filePath = await downloadInfo.task.filePath(); - final packageManager = ref.read( - packageManagerProvider.notifier, - ); - // Update download state to show installing final downloadService = ref.read( downloadServiceProvider.notifier, ); - downloadService.markInstalling(app.identifier); - - try { - await packageManager.install( - app.identifier, - filePath, - expectedHash: downloadInfo.fileMetadata.hash, - expectedSize: downloadInfo.fileMetadata.size ?? 0, - skipVerification: true, // SKIP VERIFICATION - ); - } catch (e) { - if (context.mounted) { - context.showError('Installation failed: $e'); - } - } + // Explicitly allow install for this override flow and install via the queue. + downloadService.markReadyToInstall( + app.identifier, + skipVerificationOnInstall: true, + ); + await downloadService.installFromDownloaded(app.identifier); }, ), ] @@ -771,23 +757,13 @@ class InstallButton extends ConsumerWidget { throw Exception('Download not found'); } - final filePath = await downloadInfo.task.filePath(); - final fileMetadata = downloadInfo.fileMetadata; - downloadService.clearError(app.identifier); await packageManager.uninstall(app.identifier); - downloadService.markInstalling(app.identifier); - - await packageManager.install( - app.identifier, - filePath, - expectedHash: fileMetadata.hash, - expectedSize: fileMetadata.size ?? 0, - ); - - downloadService.cancelDownload(app.identifier); + // Queue install through DownloadService (single source of truth for installs). + downloadService.markReadyToInstall(app.identifier); + await downloadService.installFromDownloaded(app.identifier); } catch (e) { final message = e.toString(); if (context.mounted && !message.contains('cancelled')) {