Rewrite package queuing

This commit is contained in:
franzap
2025-12-14 14:31:24 -03:00
parent e9b4b072ab
commit aa2c5eab53
6 changed files with 421 additions and 107 deletions
+5
View File
@@ -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")
}
@@ -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)
}
}
}
+71 -11
View File
@@ -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;
}
+9 -33
View File
@@ -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')) {