mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Rewrite package queuing
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
+247
-26
@@ -50,20 +50,72 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler {
|
||||
// Store pending install results keyed by sessionId - completed when broadcast arrives
|
||||
private val pendingInstallResults = mutableMapOf<Int, Result>()
|
||||
|
||||
// Map sessionId to packageName for reverse lookup
|
||||
private val sessionToPackage = mutableMapOf<Int, String>()
|
||||
|
||||
// Store pending user action intents keyed by packageName - for re-launching after foreground
|
||||
private val pendingUserActionIntents = mutableMapOf<String, Intent>()
|
||||
|
||||
// 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<String, Boolean>()
|
||||
|
||||
// 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<String, Any?>) {
|
||||
fun completeInstallResult(sessionId: Int, resultMap: Map<String, Any?>, 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<Boolean>("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
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Map<String, DownloadInfo>> {
|
||||
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<Map<String, DownloadInfo>> {
|
||||
// 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<Map<String, DownloadInfo>> {
|
||||
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<Map<String, DownloadInfo>> {
|
||||
// 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<Map<String, DownloadInfo>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Map<String, DownloadInfo>> {
|
||||
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)) {
|
||||
|
||||
@@ -34,24 +34,23 @@ final class AndroidPackageManager extends PackageManager {
|
||||
throw Exception('APK file not found: $filePath');
|
||||
}
|
||||
|
||||
final result = await _channel
|
||||
.invokeMethod<Map<Object?, Object?>>('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<Map<Object?, Object?>>('install', {
|
||||
'filePath': filePath,
|
||||
'packageName': appId,
|
||||
'expectedHash': expectedHash,
|
||||
'expectedSize': expectedSize,
|
||||
'skipVerification': skipVerification,
|
||||
});
|
||||
|
||||
final resultMap = Map<String, dynamic>.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<bool> retryPendingInstall(String appId) async {
|
||||
/// Returns structured info so callers can avoid double-prompts and bad state resets.
|
||||
Future<RetryPendingInstallResult> retryPendingInstall(String appId) async {
|
||||
try {
|
||||
final result = await _channel
|
||||
.invokeMethod<Map<Object?, Object?>>('retryPendingInstall', {
|
||||
@@ -196,9 +195,26 @@ final class AndroidPackageManager extends PackageManager {
|
||||
});
|
||||
|
||||
final resultMap = Map<String, dynamic>.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<void> 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<String, dynamic> 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;
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
Reference in New Issue
Block a user