mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
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:
+92
-7
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user