Add "installing" state to package manager, fix jumping bug in updates screen and show when app is waiting to be installed

This commit is contained in:
franzap
2026-01-20 18:07:37 -03:00
parent 3001d938dd
commit 6593ba43af
5 changed files with 162 additions and 52 deletions
@@ -50,6 +50,7 @@ object InstallStatus {
const val STARTED = "started"
const val VERIFYING = "verifying"
const val PENDING_USER_ACTION = "pendingUserAction"
const val INSTALLING = "installing" // User accepted, system is now installing
const val ALREADY_IN_PROGRESS = "alreadyInProgress"
const val SUCCESS = "success"
const val FAILED = "failed"
@@ -99,6 +100,15 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
/** Track verification threads so we can "ping" and avoid false timeouts */
private val verificationThreads = mutableMapOf<String, Thread>()
/** Track sessions that are pending user action (so we can detect when user accepts) */
private val sessionsPendingUserAction = mutableSetOf<Int>()
/** Track sessions that have emitted INSTALLING status (to avoid duplicates) */
private val sessionsInstalling = mutableSetOf<Int>()
/** Session callback to detect progress after user confirms install */
private var sessionCallback: PackageInstaller.SessionCallback? = null
private var eventSink: EventChannel.EventSink? = null
@@ -140,6 +150,9 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
if (status == InstallStatus.PENDING_USER_ACTION && confirmIntent != null) {
pendingUserActionIntents[pkg] = confirmIntent
// Track this session as pending user action so SessionCallback can detect acceptance
instance?.sessionsPendingUserAction?.add(sessionId)
// Auto-launch dialog if app is in foreground
if (isAppInForeground) {
launchConfirmDialog(pkg, confirmIntent)
@@ -150,6 +163,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
if (status in listOf(InstallStatus.SUCCESS, InstallStatus.FAILED, InstallStatus.CANCELLED)) {
sessionToPackage.remove(sessionId)
pendingUserActionIntents.remove(pkg)
instance?.sessionsPendingUserAction?.remove(sessionId)
instance?.sessionsInstalling?.remove(sessionId)
if (status != InstallStatus.SUCCESS) {
abandonSession(sessionId)
@@ -182,12 +197,27 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
private fun launchConfirmDialog(packageName: String, intent: Intent) {
val ctx = appContext ?: return
try {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
ctx.startActivity(intent)
Log.d(TAG, "Launched confirmation dialog for $packageName")
} catch (e: Exception) {
Log.w(TAG, "Failed to launch confirmation dialog for $packageName", e)
val inst = instance
// Post with delay to ensure the system is ready to show the dialog.
// Without this, the dialog may not appear when the broadcast arrives
// immediately after session.commit() while the app is still processing.
val launcher: () -> Unit = {
try {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
ctx.startActivity(intent)
Log.d(TAG, "Launched confirmation dialog for $packageName")
} catch (e: Exception) {
Log.w(TAG, "Failed to launch confirmation dialog for $packageName", e)
}
Unit
}
if (inst != null) {
inst.mainHandler.postDelayed(launcher, 100)
} else {
// Fallback if instance not available
Handler(Looper.getMainLooper()).postDelayed(launcher, 100)
}
}
@@ -223,6 +253,9 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
ProcessLifecycleOwner.get().lifecycle.addObserver(this)
}
// Register session callback to detect when user accepts install dialog
registerSessionCallback()
cleanupStaleSessions()
Log.d(TAG, "AndroidPackageManagerPlugin initialized")
}
@@ -231,6 +264,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
mainHandler.post {
ProcessLifecycleOwner.get().lifecycle.removeObserver(this)
}
unregisterSessionCallback()
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
eventSink = null
@@ -262,6 +296,57 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
Log.d(TAG, "App backgrounded")
}
// ═══════════════════════════════════════════════════════════════════════════════
// SESSION CALLBACK (Detect when user accepts install dialog)
// ═══════════════════════════════════════════════════════════════════════════════
private fun registerSessionCallback() {
sessionCallback = object : PackageInstaller.SessionCallback() {
override fun onCreated(sessionId: Int) {}
override fun onBadgingChanged(sessionId: Int) {}
override fun onActiveChanged(sessionId: Int, active: Boolean) {}
override fun onProgressChanged(sessionId: Int, progress: Float) {
// When we see progress on a session that was pending user action,
// it means the user accepted and the system is now installing.
val pkg = sessionToPackage[sessionId] ?: return
// Only emit INSTALLING once per session
if (sessionId in sessionsPendingUserAction && sessionId !in sessionsInstalling) {
sessionsInstalling.add(sessionId)
sessionsPendingUserAction.remove(sessionId)
Log.d(TAG, "User accepted install for $pkg (progress=$progress)")
onInstallResult(
sessionId = sessionId,
status = InstallStatus.INSTALLING,
packageName = pkg,
message = "Installing..."
)
}
}
override fun onFinished(sessionId: Int, success: Boolean) {
// Clean up tracking
sessionsPendingUserAction.remove(sessionId)
sessionsInstalling.remove(sessionId)
}
}
context.packageManager.packageInstaller.registerSessionCallback(sessionCallback!!, mainHandler)
Log.d(TAG, "Registered session callback")
}
private fun unregisterSessionCallback() {
sessionCallback?.let {
try {
context.packageManager.packageInstaller.unregisterSessionCallback(it)
} catch (e: Exception) {
Log.w(TAG, "Failed to unregister session callback", e)
}
}
sessionCallback = null
}
// ═══════════════════════════════════════════════════════════════════════════════
// EVENT CHANNEL STREAM HANDLER
// ═══════════════════════════════════════════════════════════════════════════════
@@ -394,7 +479,7 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler,
// Start watchdog for verify stage
scheduleWatchdog(appId, VERIFY_WATCHDOG_MS)
}
InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION -> {
InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION, InstallStatus.INSTALLING -> {
// Start/refresh watchdog for install stage
scheduleWatchdog(appId, INSTALL_WATCHDOG_MS)
}
+22 -26
View File
@@ -147,16 +147,7 @@ class _UpdatesContent extends HookConsumerWidget {
.map((entry) => entry.key)
.toSet();
if (activeAppIds.isEmpty) {
return _UpdatesListBody(
automaticUpdates: automaticUpdates,
manualUpdates: manualUpdates,
installingApps: const [],
upToDateApps: upToDateApps,
uncatalogedApps: uncatalogedApps,
);
}
// Always use the same widget type to preserve scroll position
return _UpdatesListBodyWithInstallingAppIds(
installingAppIds: activeAppIds,
automaticUpdates: automaticUpdates,
@@ -184,27 +175,32 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final installingAppsState = ref.watch(
query<App>(
tags: {'#d': installingAppIds},
and: (app) => {app.latestRelease.query()},
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
subscriptionPrefix: 'installing-apps',
),
);
final updateAppIds = {
...automaticUpdates.map((a) => a.identifier),
...manualUpdates.map((a) => a.identifier),
};
final installingApps = installingAppsState.models
.where(
(app) =>
installingAppIds.contains(app.identifier) &&
!updateAppIds.contains(app.identifier),
)
.toList();
// Only query for installing apps if there are active operations
final List<App> installingApps;
if (installingAppIds.isEmpty) {
installingApps = const [];
} else {
final installingAppsState = ref.watch(
query<App>(
tags: {'#d': installingAppIds},
and: (app) => {app.latestRelease.query()},
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
subscriptionPrefix: 'installing-apps',
),
);
installingApps = installingAppsState.models
.where(
(app) =>
installingAppIds.contains(app.identifier) &&
!updateAppIds.contains(app.identifier),
)
.toList();
}
return _UpdatesListBody(
automaticUpdates: automaticUpdates,
@@ -14,6 +14,7 @@ enum InstallStatus {
started,
verifying,
pendingUserAction,
installing, // User accepted, system is now installing
alreadyInProgress,
success,
failed,
@@ -26,6 +27,7 @@ extension InstallStatusX on InstallStatus {
'started' => InstallStatus.started,
'verifying' => InstallStatus.verifying,
'pendingUserAction' => InstallStatus.pendingUserAction,
'installing' => InstallStatus.installing,
'alreadyInProgress' => InstallStatus.alreadyInProgress,
'success' => InstallStatus.success,
'failed' => InstallStatus.failed,
@@ -170,6 +172,17 @@ final class AndroidPackageManager extends PackageManager {
}
break;
case InstallStatus.installing:
// User accepted the install dialog, system is now installing.
// Transition to Installing with isSilent=true to show "Installing..."
if (filePath != null) {
setOperation(
appId,
Installing(target: target, filePath: filePath, isSilent: true),
);
}
break;
case InstallStatus.alreadyInProgress:
// Real pending dialog exists - Kotlin will also send pendingUserAction event
// which transitions to Installing. Nothing to do here.
@@ -731,25 +731,46 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
}
void _processQueuedDownload() {
var activeDownloads = countOperations<Downloading>();
final activeDownloads = countOperations<Downloading>();
if (activeDownloads >= maxConcurrentDownloads) return;
// Start downloads until we fill all available slots
// Collect queued items first to avoid iterating while modifying state
final queuedItems = <String, DownloadQueued>{};
for (final entry in state.operations.entries) {
if (activeDownloads >= maxConcurrentDownloads) break;
if (entry.value is DownloadQueued) {
final queued = entry.value as DownloadQueued;
final downloadUrl = queued.target.urls.firstOrNull;
if (downloadUrl != null) {
queuedItems[entry.key] = entry.value as DownloadQueued;
}
}
if (queuedItems.isEmpty) return;
// Start downloads until we fill all available slots
var started = 0;
for (final entry in queuedItems.entries) {
if (activeDownloads + started >= maxConcurrentDownloads) break;
final queued = entry.value;
final downloadUrl = queued.target.urls.firstOrNull;
if (downloadUrl != null) {
unawaited(
_startDownloadTask(
entry.key,
queued.target,
downloadUrl,
displayName: queued.displayName,
);
activeDownloads++;
}
),
);
started++;
} else {
// No URL - fail the operation so it doesn't stay queued forever
setOperation(
entry.key,
OperationFailed(
target: queued.target,
type: FailureType.downloadFailed,
message: 'No download URL available',
),
);
}
}
}
+4 -9
View File
@@ -146,12 +146,12 @@ class InstallButton extends ConsumerWidget {
isWarning: true,
),
ReadyToInstall() => _buildAsyncButton(
ReadyToInstall() => _buildSimpleButton(
context,
ref,
text: isInstalled ? 'Update' : 'Install',
onPressed: () => _triggerInstall(ref),
'Waiting...',
null, // Not tappable - system advances automatically
fontSize: fontSize,
showSpinner: true,
),
Installing(:final isSilent) => _buildSimpleButton(
@@ -539,11 +539,6 @@ class InstallButton extends ConsumerWidget {
pm.resumeDownload(app.identifier);
}
Future<void> _triggerInstall(WidgetRef ref) async {
final pm = ref.read(packageManagerProvider.notifier);
await pm.triggerInstall(app.identifier);
}
Future<void> _retryInstall(WidgetRef ref) async {
final pm = ref.read(packageManagerProvider.notifier);
await pm.retryInstall(app.identifier);