From 208f19a2bf0f892c85242662655d551686c742b2 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Wed, 4 Feb 2026 10:17:05 -0300 Subject: [PATCH] Introduce package manager spec/work, clarify states and update code --- .../plugins/AndroidPackageManagerPlugin.kt | 149 +++++++++++- .../android_package_manager.dart | 71 +++++- .../package_manager/install_operation.dart | 39 +++- .../package_manager/package_manager.dart | 14 +- lib/widgets/install_button.dart | 43 +++- spec/features/FEAT-001-package-manager.md | 212 ++++++++++++++++++ work/WORK-001-package-manager.md | 146 ++++++++++++ 7 files changed, 639 insertions(+), 35 deletions(-) create mode 100644 spec/features/FEAT-001-package-manager.md create mode 100644 work/WORK-001-package-manager.md 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 f8ca3e6..f9a1fbe 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,8 +50,10 @@ private const val COPY_BUFFER_SIZE = 65536 object InstallStatus { const val STARTED = "started" const val VERIFYING = "verifying" + const val VERIFYING_PROGRESS = "verifyingProgress" // Verification progress update const val PENDING_USER_ACTION = "pendingUserAction" const val INSTALLING = "installing" // User accepted, system is now installing + const val SYSTEM_PROCESSING = "systemProcessing" // Committed, cannot cancel const val ALREADY_IN_PROGRESS = "alreadyInProgress" const val SUCCESS = "success" const val FAILED = "failed" @@ -108,6 +110,12 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, /** Track sessions that have emitted INSTALLING status (to avoid duplicates) */ private val sessionsInstalling = mutableSetOf() + /** Track sessions that have been committed (abandonSession won't work for these) */ + private val committedSessions = mutableSetOf() + + /** Track sessions that have already emitted SUCCESS (to avoid duplicates from onFinished) */ + private val sessionsCompletedSuccessfully = mutableSetOf() + /** Session callback to detect progress after user confirms install */ private var sessionCallback: PackageInstaller.SessionCallback? = null @@ -166,8 +174,11 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, pendingUserActionIntents.remove(pkg) instance?.sessionsPendingUserAction?.remove(sessionId) instance?.sessionsInstalling?.remove(sessionId) + instance?.committedSessions?.remove(sessionId) - if (status != InstallStatus.SUCCESS) { + if (status == InstallStatus.SUCCESS) { + instance?.sessionsCompletedSuccessfully?.add(sessionId) + } else { abandonSession(sessionId) } } @@ -327,9 +338,25 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, } override fun onFinished(sessionId: Int, success: Boolean) { + val pkg = sessionToPackage[sessionId] + Log.d(TAG, "SessionCallback.onFinished: sessionId=$sessionId, success=$success, pkg=$pkg") + + // If install succeeded and we have a tracked package, emit SUCCESS + // This catches installs that completed after timeout or missed the broadcast + if (success && pkg != null && sessionId !in sessionsCompletedSuccessfully) { + sessionsCompletedSuccessfully.add(sessionId) + Log.d(TAG, "Emitting SUCCESS from onFinished for $pkg (sessionId=$sessionId)") + onInstallResult( + sessionId = sessionId, + status = InstallStatus.SUCCESS, + packageName = pkg + ) + } + // Clean up tracking sessionsPendingUserAction.remove(sessionId) sessionsInstalling.remove(sessionId) + committedSessions.remove(sessionId) } } @@ -379,7 +406,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, status: String, message: String? = null, errorCode: String? = null, - description: String? = null + description: String? = null, + progress: Double? = null ) { val emitNow = emit@{ // Update watchdog regardless of whether Dart is listening. @@ -405,6 +433,9 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, if (description != null) { event["description"] = description } + if (progress != null) { + event["progress"] = progress + } Log.d(TAG, "Emitting to Dart: $event") sink.success(event) } @@ -461,8 +492,23 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, return@postDelayed } + // Check if session is committed - if so, we can't abandon it + val sessionId = sessionToPackage.entries.find { it.value == appId }?.key + val isCommitted = sessionId != null && sessionId in committedSessions + + if (isCommitted) { + // Session is committed - Android is in control, we cannot abort + // Keep waiting and show "system processing" status + Log.w(TAG, "Watchdog: Session for $appId is committed (sessionId=$sessionId), system is processing. Cannot timeout.") + emitInstallStatus(appId, InstallStatus.SYSTEM_PROCESSING, "System is processing...") + // Extend deadline significantly - Android will eventually complete or fail + watchdogDeadlineMs[appId] = System.currentTimeMillis() + MAX_INSTALL_WATCHDOG_MS + scheduleWatchdog(appId, INSTALL_WATCHDOG_MS * 2) + return@postDelayed + } + // Deadline exceeded or no evidence of progress: fail fast and cleanup. - Log.w(TAG, "Watchdog timeout for $appId (hasSession=$hasSession, pendingUi=$hasPendingUi, verifyingAlive=$verifyingAlive)") + Log.w(TAG, "Watchdog timeout for $appId (hasSession=$hasSession, pendingUi=$hasPendingUi, verifyingAlive=$verifyingAlive, isCommitted=$isCommitted)") abandonExistingSession(appId) emitInstallStatus( appId, @@ -476,11 +522,12 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, private fun updateWatchdog(appId: String, status: String) { when (status) { - InstallStatus.VERIFYING -> { + InstallStatus.VERIFYING, InstallStatus.VERIFYING_PROGRESS -> { // Start watchdog for verify stage scheduleWatchdog(appId, VERIFY_WATCHDOG_MS) } - InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION, InstallStatus.INSTALLING -> { + InstallStatus.STARTED, InstallStatus.PENDING_USER_ACTION, + InstallStatus.INSTALLING, InstallStatus.SYSTEM_PROCESSING -> { // Start/refresh watchdog for install stage scheduleWatchdog(appId, INSTALL_WATCHDOG_MS) } @@ -542,6 +589,15 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, launchApp(packageName, result) } + "abortInstall" -> { + val packageName = call.argument("packageName") + if (packageName == null) { + result.error("MISSING_ARGUMENT", "packageName required", null) + return + } + abortInstall(packageName, result) + } + else -> result.notImplemented() } } @@ -699,7 +755,16 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, val progressPercent = ((bytesCopied * 100) / fileSize).toInt() if (progressPercent != lastProgressPercent && progressPercent % 5 == 0) { lastProgressPercent = progressPercent - Log.d(TAG, "Copy progress for $packageName: $progressPercent%") + Log.d(TAG, "Verify progress for $packageName: $progressPercent%") + // Emit progress to Dart + val progressFraction = bytesCopied.toDouble() / fileSize.toDouble() + mainHandler.post { + emitInstallStatus( + packageName, + InstallStatus.VERIFYING_PROGRESS, + progress = progressFraction + ) + } } } } @@ -754,6 +819,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, ) session.commit(pendingIntent.intentSender) + committedSessions.add(sessionId) // Mark as committed - abandonSession won't work now + Log.d(TAG, "Session $sessionId committed for $packageName") session.close() session = null // Prevent double-close in finally @@ -810,11 +877,21 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, private fun abandonExistingSession(packageName: String) { val session = findExistingSession(packageName) ?: return + val isCommitted = session.sessionId in committedSessions + + if (isCommitted) { + Log.d(TAG, "abandonExistingSession: Session ${session.sessionId} for $packageName is committed, abandon may not work") + } + try { context.packageManager.packageInstaller.abandonSession(session.sessionId) + Log.d(TAG, "abandonExistingSession: Abandoned session ${session.sessionId} for $packageName") sessionToPackage.remove(session.sessionId) pendingUserActionIntents.remove(packageName) - } catch (_: Exception) {} + committedSessions.remove(session.sessionId) + } catch (e: Exception) { + Log.w(TAG, "abandonExistingSession: Failed to abandon session ${session.sessionId} for $packageName: ${e.message}") + } } private fun cleanupStaleSessions() { @@ -825,6 +902,8 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, } sessionToPackage.clear() pendingUserActionIntents.clear() + committedSessions.clear() + sessionsCompletedSuccessfully.clear() } catch (_: Exception) {} } @@ -1009,6 +1088,62 @@ class AndroidPackageManagerPlugin : FlutterPlugin, MethodCallHandler, } } + // ═══════════════════════════════════════════════════════════════════════════════ + // ABORT INSTALL (User-initiated cancellation) + // ═══════════════════════════════════════════════════════════════════════════════ + + /** + * Explicitly abort an install operation and clear all tracking. + * This is best-effort - if the session is already committed, Android may still complete it. + */ + private fun abortInstall(packageName: String, result: Result) { + Log.d(TAG, "abortInstall called for $packageName") + + // Find and clean up the session + val sessionId = sessionToPackage.entries.find { it.value == packageName }?.key + val isCommitted = sessionId != null && sessionId in committedSessions + + // Clear all tracking for this package + clearAllTrackingForPackage(packageName) + + // Clear watchdog + clearWatchdog(packageName) + + // Try to abandon the session (will fail silently if already committed) + abandonExistingSession(packageName) + + // Emit cancelled status so Dart knows to clean up + emitInstallStatus(packageName, InstallStatus.CANCELLED, "Installation aborted by user") + + result.success(mapOf( + "success" to true, + "wasCommitted" to isCommitted, + "message" to if (isCommitted) { + "Session was already committed. Android may still complete the install." + } else { + "Install aborted successfully." + } + )) + } + + /** + * Clear all tracking maps for a given package. + */ + private fun clearAllTrackingForPackage(packageName: String) { + pendingUserActionIntents.remove(packageName) + + val sessionId = sessionToPackage.entries.find { it.value == packageName }?.key + if (sessionId != null) { + sessionToPackage.remove(sessionId) + sessionsPendingUserAction.remove(sessionId) + sessionsInstalling.remove(sessionId) + committedSessions.remove(sessionId) + sessionsCompletedSuccessfully.remove(sessionId) + } + + verificationThreads.remove(packageName) + } + // ═══════════════════════════════════════════════════════════════════════════════ // INSTALLED APPS // ═══════════════════════════════════════════════════════════════════════════════ diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index e8c345e..f68f6a0 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -14,8 +14,10 @@ import 'package:zapstore/services/package_manager/package_manager.dart'; enum InstallStatus { started, verifying, + verifyingProgress, // Verification progress update pendingUserAction, installing, // User accepted, system is now installing + systemProcessing, // Committed, cannot cancel alreadyInProgress, success, failed, @@ -27,8 +29,10 @@ extension InstallStatusX on InstallStatus { return switch (raw) { 'started' => InstallStatus.started, 'verifying' => InstallStatus.verifying, + 'verifyingProgress' => InstallStatus.verifyingProgress, 'pendingUserAction' => InstallStatus.pendingUserAction, 'installing' => InstallStatus.installing, + 'systemProcessing' => InstallStatus.systemProcessing, 'alreadyInProgress' => InstallStatus.alreadyInProgress, 'success' => InstallStatus.success, 'failed' => InstallStatus.failed, @@ -103,6 +107,7 @@ final class AndroidPackageManager extends PackageManager { final message = event['message'] as String?; final errorCode = event['errorCode'] as String?; final description = event['description'] as String?; + final progress = event['progress'] as double?; final status = InstallStatusX.tryParse(statusRaw); debugPrint( @@ -148,6 +153,14 @@ final class AndroidPackageManager extends PackageManager { } break; + case InstallStatus.verifyingProgress: + // Update verification progress + final existingVerify = existingOp; + if (existingVerify is Verifying && progress != null) { + setOperation(appId, existingVerify.copyWith(progress: progress)); + } + break; + case InstallStatus.started: // Install session started - transition to Installing if (filePath != null) { @@ -184,6 +197,17 @@ final class AndroidPackageManager extends PackageManager { } break; + case InstallStatus.systemProcessing: + // Install session is committed and taking longer than expected. + // Cannot be cancelled - system will eventually complete or fail. + if (filePath != null) { + setOperation( + appId, + SystemProcessing(target: target, filePath: filePath), + ); + } + break; + case InstallStatus.alreadyInProgress: // Real pending dialog exists - Kotlin will also send pendingUserAction event // which transitions to Installing. Nothing to do here. @@ -223,7 +247,7 @@ final class AndroidPackageManager extends PackageManager { if (filePath != null) { setOperation( appId, - AwaitingUserAction(target: target, filePath: filePath), + InstallCancelled(target: target, filePath: filePath), ); } else { clearOperation(appId); @@ -249,7 +273,11 @@ final class AndroidPackageManager extends PackageManager { void _tryAdvanceNextInstall() { // Check if any app is currently in an active install state final hasActiveInstall = state.operations.values.any( - (op) => op is Verifying || op is Installing || op is Uninstalling, + (op) => + op is Verifying || + op is Installing || + op is SystemProcessing || + op is Uninstalling, ); if (hasActiveInstall) { @@ -284,7 +312,7 @@ final class AndroidPackageManager extends PackageManager { NativeErrorCode.certMismatch => FailureType.certMismatch, NativeErrorCode.permissionDenied => FailureType.permissionDenied, NativeErrorCode.insufficientStorage => FailureType.insufficientStorage, - NativeErrorCode.incompatible => FailureType.installFailed, + NativeErrorCode.incompatible => FailureType.incompatible, NativeErrorCode.blocked => FailureType.permissionDenied, NativeErrorCode.installTimeout => FailureType.installFailed, _ => FailureType.installFailed, @@ -311,6 +339,12 @@ final class AndroidPackageManager extends PackageManager { if (lower.contains('permission') || lower.contains('denied')) { return FailureType.permissionDenied; } + if (lower.contains('incompatible') || + lower.contains('architecture') || + lower.contains('api level') || + lower.contains('sdk version')) { + return FailureType.incompatible; + } return FailureType.installFailed; } @@ -446,6 +480,35 @@ final class AndroidPackageManager extends PackageManager { syncInstalledPackages(); } + /// Explicitly abort an install operation. + /// This is best-effort - if the session is already committed, Android may still complete it. + Future abortInstall(String appId) async { + try { + final result = await _methodChannel + .invokeMethod>('abortInstall', { + 'packageName': appId, + }) + .timeout( + const Duration(seconds: 5), + onTimeout: () => {'success': false}, + ); + + final resultMap = Map.from(result ?? {}); + final wasCommitted = resultMap['wasCommitted'] as bool? ?? false; + + if (wasCommitted) { + debugPrint( + '[PackageManager] abortInstall: Session was committed, Android may still complete install for $appId', + ); + } + } catch (e) { + debugPrint('[PackageManager] abortInstall failed for $appId: $e'); + } + + // Clear operation on Dart side regardless of native result + clearOperation(appId); + } + // ═══════════════════════════════════════════════════════════════════════════ // PERMISSIONS // ═══════════════════════════════════════════════════════════════════════════ @@ -611,7 +674,7 @@ final class AndroidPackageManager extends PackageManager { if (op == null) continue; if (op is! Installing && op is! Verifying && - op is! AwaitingUserAction) { + op is! InstallCancelled) { continue; } diff --git a/lib/services/package_manager/install_operation.dart b/lib/services/package_manager/install_operation.dart index 817a884..256abe3 100644 --- a/lib/services/package_manager/install_operation.dart +++ b/lib/services/package_manager/install_operation.dart @@ -69,8 +69,21 @@ class DownloadPaused extends InstallOperation { /// Verifying downloaded file hash (happens in Kotlin, visible to UI) class Verifying extends InstallOperation { final String filePath; + final double progress; - const Verifying({required super.target, required this.filePath}); + const Verifying({ + required super.target, + required this.filePath, + this.progress = 0.0, + }); + + Verifying copyWith({double? progress}) { + return Verifying( + target: target, + filePath: filePath, + progress: progress ?? this.progress, + ); + } } // ═══════════════════════════════════════════════════════════════════════════════ @@ -109,12 +122,12 @@ class Installing extends InstallOperation { }) : startedAt = startedAt ?? DateTime.now(); } -/// System dialog was dismissed/backgrounded - user can retry -/// Different from Failed: this is recoverable with a tap -class AwaitingUserAction extends InstallOperation { +/// User cancelled the install dialog - file is still ready, no re-download needed. +/// User can tap "Install (retry)" to show the dialog again. +class InstallCancelled extends InstallOperation { final String filePath; - const AwaitingUserAction({required super.target, required this.filePath}); + const InstallCancelled({required super.target, required this.filePath}); } /// Uninstalling app (for force update: uninstall → install) @@ -124,6 +137,15 @@ class Uninstalling extends InstallOperation { const Uninstalling({required super.target, required this.filePath}); } +/// System is processing the install - cannot be cancelled. +/// This state is entered when the install session has been committed to Android +/// and is taking longer than expected. The system will eventually complete or fail. +class SystemProcessing extends InstallOperation { + final String filePath; + + const SystemProcessing({required super.target, required this.filePath}); +} + // ═══════════════════════════════════════════════════════════════════════════════ // FAILURE STATE (Dismiss available) // ═══════════════════════════════════════════════════════════════════════════════ @@ -169,6 +191,9 @@ enum FailureType { /// Not enough storage space insufficientStorage, + + /// Device is incompatible with the app (wrong architecture, API level, etc.) + incompatible, } // ═══════════════════════════════════════════════════════════════════════════════ @@ -185,6 +210,7 @@ extension InstallOperationX on InstallOperation { this is Downloading || this is Verifying || this is Installing || + this is SystemProcessing || this is Uninstalling; /// Whether this operation is in the verification phase @@ -196,7 +222,8 @@ extension InstallOperationX on InstallOperation { AwaitingPermission(:final filePath) => filePath, ReadyToInstall(:final filePath) => filePath, Installing(:final filePath) => filePath, - AwaitingUserAction(:final filePath) => filePath, + SystemProcessing(:final filePath) => filePath, + InstallCancelled(:final filePath) => filePath, Uninstalling(:final filePath) => filePath, OperationFailed(:final filePath) => filePath, _ => null, diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index 9b32f8f..c5f314e 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -183,8 +183,8 @@ abstract class PackageManager extends StateNotifier { .map((e) => e.key) .toList(); - List getAwaitingUserAction() => state.operations.entries - .where((e) => e.value is AwaitingUserAction) + List getInstallCancelled() => state.operations.entries + .where((e) => e.value is InstallCancelled) .map((e) => e.key) .toList(); @@ -365,10 +365,10 @@ abstract class PackageManager extends StateNotifier { await _performInstall(appId, op.target, op.filePath); } - /// Retry install from AwaitingUserAction state + /// Retry install from InstallCancelled state Future retryInstall(String appId) async { final op = getOperation(appId); - if (op is! AwaitingUserAction) return; + if (op is! InstallCancelled) return; if (!await File(op.filePath).exists()) { setOperation( @@ -839,7 +839,7 @@ abstract class PackageManager extends StateNotifier { if (message.contains('cancelled') || message.contains('ABORTED')) { setOperation( appId, - AwaitingUserAction(target: target, filePath: filePath), + InstallCancelled(target: target, filePath: filePath), ); return; } @@ -1132,10 +1132,10 @@ final readyToInstallCountProvider = Provider((ref) { ); }); -final awaitingUserActionCountProvider = Provider((ref) { +final installCancelledCountProvider = Provider((ref) { return ref.watch( packageManagerProvider.select( - (s) => s.operations.values.whereType().length, + (s) => s.operations.values.whereType().length, ), ); }); diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index bf17b02..cd6bb44 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -130,13 +130,22 @@ class InstallButton extends ConsumerWidget { onTap: () => _resumeDownload(ref), ), - Verifying() => _buildSimpleButton( - context, - 'Verifying...', - null, - fontSize: fontSize, - showSpinner: true, - ), + Verifying(:final progress) => progress > 0 + ? _buildProgressButton( + context, + ref, + progress: progress, + text: 'Verifying ${(progress * 100).round()}%', + fontSize: fontSize, + onTap: null, // Cannot pause/cancel verification + ) + : _buildSimpleButton( + context, + 'Verifying...', + null, + fontSize: fontSize, + showSpinner: true, + ), AwaitingPermission() => _buildSimpleButton( context, @@ -164,13 +173,19 @@ class InstallButton extends ConsumerWidget { showSpinner: true, ), - AwaitingUserAction() => _buildSimpleButton( + InstallCancelled() => _buildSimpleButton( context, - 'Tap to retry', + 'Install (retry)', () => _retryInstall(ref), fontSize: fontSize, - isWarning: true, - icon: Icons.refresh, + ), + + SystemProcessing() => _buildSimpleButton( + context, + 'System processing...', + null, + fontSize: fontSize, + showSpinner: true, ), Uninstalling() => _buildSimpleButton( @@ -614,6 +629,12 @@ class InstallButton extends ConsumerWidget { 'Certificate mismatch', description: 'The app signature does not match. Force update required.', ); + } else if (operation.type == FailureType.incompatible) { + context.showError( + 'Device incompatible', + description: + 'This app is not compatible with your device. It may require a different architecture or Android version.', + ); } else if (operation.description != null) { // Errors with descriptions (from Kotlin) are shown as toasts context.showError(operation.message, description: operation.description); diff --git a/spec/features/FEAT-001-package-manager.md b/spec/features/FEAT-001-package-manager.md new file mode 100644 index 0000000..dee984c --- /dev/null +++ b/spec/features/FEAT-001-package-manager.md @@ -0,0 +1,212 @@ +# FEAT-001 — Package Manager + +## Goal + +Allow users to install, update, and uninstall apps with clear progress feedback and reliable error handling. + +## Core Invariant: No Hanging States + +**Every operation MUST resolve to a terminal state.** The UI must never get stuck in an in-progress state indefinitely. + +- On success, present a launch action +- On failure, show an error with an actionable message +- On cancel, restore the pre-operation UI + +If the system stops responding, the operation must either: +1. **Advance** to completion when the system eventually responds, OR +2. **Fall back** to an error state with clear feedback + +There is no third option. Timeouts, crashes, backgrounding, network loss—all paths lead to a resolved state. + +### Timeouts + +- Operations must enforce timeouts to avoid hanging states. +- Timeout durations are defined in implementation (not in this spec). + +## State Model + +### Download States (per app) + +| State | Description | User Action | +|-------|-------------|-------------| +| `DownloadQueued` | Waiting for download slot (max 3 active) | Cancel available | +| `Downloading` | Actively downloading, shows progress % | Pause/Cancel available | +| `DownloadPaused` | Download paused by user | Resume/Cancel available | +| `Verifying` | Computing hash, shows progress % | Cannot cancel | + +**Transitions:** +- `Idle` → tap download → `DownloadQueued` or `Downloading` +- `DownloadQueued` → slot available → `Downloading` +- `DownloadQueued` → cancel → `Idle` +- `Downloading` ↔ `DownloadPaused` (pause/resume) +- `Downloading` → cancel → `Idle` (clears partial file) +- `Downloading` → success → `Verifying` +- `Downloading` → failure/timeout → `Error` +- `Verifying` → success → `AwaitingPermission` or `ReadyToInstall` +- `Verifying` → failure/timeout → `Error` +- App restart while downloading → `Idle` (partial file cleared) + +### Permission State (per app) + +| State | Description | User Action | +|-------|-------------|-------------| +| `AwaitingPermission` | User must grant "Install unknown apps" | Tap opens Android settings | + +**Transitions:** +- `Verifying` success + missing permission → `AwaitingPermission` +- `Verifying` success + has permission → `ReadyToInstall` +- `AwaitingPermission` → permission granted → `ReadyToInstall` +- `AwaitingPermission` → permission denied → `Error` + +### Install States (per app) + +| State | Description | User Action | +|-------|-------------|-------------| +| `ReadyToInstall` | File verified, queued for install slot | Automatic (no user action) | +| `Installing` | System install dialog shown or processing | Confirm/Cancel dialog | +| `InstallCancelled` | User cancelled dialog, file still ready | "Install (retry)" button | +| `SystemProcessing` | Committed to Android, cannot cancel | Wait for completion | +| `Installed` | Successfully installed | "Open" button | + +**Transitions:** +- `ReadyToInstall` → install slot available → `Installing` +- `Installing` → user confirms → continues `Installing` +- `Installing` → user cancels dialog → `InstallCancelled` +- `InstallCancelled` → user taps "Install (retry)" → `Installing` +- `Installing` (silent) → system completes → `Installed` +- `Installing` → failure/timeout → `Error` +- `Installing` → committed + slow → `SystemProcessing` +- `SystemProcessing` → completes → `Installed` +- `SystemProcessing` → failure/timeout → `Error` + +### Update State (per app) + +- `UpdateAvailable` → start update → `Downloading` +- `Installing` (silent not allowed) → manual dialog flow + - If the app was originally installed by Zapstore and platform allows, update may be silent + +### Uninstall State (per app) + +- `Installed` → uninstall → `Uninstalling` +- `Uninstalling` → user confirms → `Idle` +- `Uninstalling` → user cancels → `Installed` +- `Uninstalling` → failure/timeout → `Error` + +### Queue State (global) + +- Download queue: insertion order (best-effort), max 3 active +- Install queue: insertion order (best-effort), max 1 active + - Only one install dialog is shown at a time; others wait + +## Non-Goals + +- iOS/macOS/desktop support (Android only) +- Split APK support +- Background installs without any user awareness + +## User-Visible Behavior + +### Download + +- Progress bar shows percentage (0-100%) +- Pause/resume buttons available during download +- Cancel clears the download +- Multiple apps can download simultaneously (up to 3) +- Pause does not persist across app restarts +- Partial downloads are deleted on app restart + +### Verification + +- Progress bar shows percentage (0-100%) during hash verification +- Verification cannot be paused or cancelled +- Large APKs (100MB+) may show noticeable verification time + +### Install + +- System confirmation dialog appears for first-time installs +- If user cancels dialog, shows "Install (retry)" button (no re-download needed) +- Silent install skips dialog when Zapstore is the original installer + +### Update + +- Same flow as install +- Silent update possible if Zapstore originally installed the app +- If silent update is not allowed, proceed with manual update flow + +### Uninstall + +- System uninstall dialog appears +- If uninstall succeeds, the app is removed from the installed list +- If uninstall is canceled, there is no change + +### Force Update (Certificate Mismatch) + +- When app signature changed (different developer/build): + - Show a certificate mismatch error + - Force update action offered + - Tapping shows uninstall dialog first + - After uninstall, install proceeds automatically + - Warning: user data in the app will be lost + +## Edge Cases + +### Network/Download Issues + +- On download failure, provide a retry option +- On 404, retry from `cdn.zapstore.dev`, otherwise fail +- If network is lost mid-download, support resume when reconnected + +### Verification Failures + +- Hash mismatch suggests re-download +- Invalid file (not an APK) shows a clear error + +### Install Failures + +- Insufficient storage shows a clear error message +- Incompatible device shows a clear "Device incompatible" error message +- Blocked by device policy shows a clear error message +- If the user cancels the dialog, show "Install (retry)" (`InstallCancelled` state) + +### Permission Issues + +- If install unknown apps permission is missing, show `AwaitingPermission` state +- Tapping opens Android settings to grant permission +- If the user denies permission, show a clear error with instructions + +### App Lifecycle + +- If the app is backgrounded during install dialog, the dialog reappears on return +- If the app is killed during install, reset UI and clean up temp files on next launch +- If install takes too long, it cannot be canceled once committed + +### Concurrent Operations + +- Multiple downloads: up to 3 simultaneous, others queued +- Multiple installs: 1 at a time, others wait in queue +- Queue advances automatically after each completion + +## Acceptance Criteria + +- [ ] **No operation hangs indefinitely** — every state resolves to success, failure, or cancelled +- [ ] User sees download progress percentage +- [ ] User sees verification progress percentage +- [ ] User can pause and resume downloads +- [ ] User can cancel downloads +- [ ] Verification errors show clear message +- [ ] Install dialog appears for new apps +- [ ] Silent update works for Zapstore-owned apps +- [ ] Certificate mismatch offers force update option +- [ ] Force update uninstalls then installs +- [ ] Backgrounding app preserves install dialog +- [ ] Long installs transition to SystemProcessing (cannot cancel) +- [ ] All errors show actionable messages +- [ ] Multiple downloads queue correctly +- [ ] Multiple installs proceed one at a time + +## Notes + +- Silent install requires Android 12+ and Zapstore as original installer +- Certificate mismatch requires uninstall (app data lost) - ensure user understands +- Some device policies may block all installs from unknown sources + diff --git a/work/WORK-001-package-manager.md b/work/WORK-001-package-manager.md new file mode 100644 index 0000000..f41ce0f --- /dev/null +++ b/work/WORK-001-package-manager.md @@ -0,0 +1,146 @@ +# WORK-001 — Package Manager + +**Feature:** FEAT-001-package-manager.md +**Status:** Complete + +## Problem Solved + +Install sessions could complete in Android PackageInstaller even after Zapstore's watchdog timed out and reported failure. This caused: +1. Apps "silently" installed without user awareness +2. Certificate mismatch errors on retry (stale session still active) +3. Confusing UX where failed installs suddenly appeared as completed + +Root cause: `abandonSession()` doesn't work after `session.commit()` is called. + +## Tasks Completed + +- [x] 1. Track committed sessions separately + - Added `committedSessions` set to track sessions post-commit + - Added `sessionsCompletedSuccessfully` set to avoid duplicate SUCCESS events + - Mark sessions as committed after `session.commit()` + +- [x] 2. Modify watchdog behavior for committed sessions + - For committed sessions: emit INSTALLING status and extend timeout + - For non-committed sessions: existing timeout/fail behavior + +- [x] 3. Handle onFinished callback for missed SUCCESS events + - SessionCallback.onFinished now emits SUCCESS if not already emitted + - Catches installs that completed after timeout or missed broadcast + +- [x] 4. Add explicit abort method for user-initiated cancellation + - New method channel: `abortInstall` + - Clears all tracking for package (best effort) + - Returns `wasCommitted` flag to warn if Android may still complete + +## Decisions + +### 2026-02-03 — Committed session handling + +**Context:** `abandonSession()` is ineffective after commit. +**Options:** A) Ignore timeout for committed, B) Keep waiting, C) Show different status. +**Decision:** Option B+C - Keep waiting and show "System is processing". +**Rationale:** User needs feedback; Android will eventually complete or fail. No hanging states allowed. + +--- + +## Implementation Reference + +### Architecture + +Event-driven with native Kotlin as single source of truth. + +``` +┌─────────────────┐ ┌─────────────────┐ +│ Dart/Flutter │◄───────►│ Kotlin/Native │ +│ │ Events │ │ +│ PackageManager │◄────────│ AndroidPackage │ +│ (StateNotifier)│ │ ManagerPlugin │ +└─────────────────┘ └─────────────────┘ + │ │ + │ MethodChannel │ PackageInstaller + │ (commands) │ SessionCallback + │ │ BroadcastReceiver + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ background_ │ │ Android System │ +│ downloader │ │ PackageInstaller│ +└─────────────────┘ └─────────────────┘ +``` + +### State Machine + +``` +Idle → DownloadQueued → Downloading → Verifying → ReadyToInstall → Installing → Terminal + │ + pendingUserAction + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + SUCCESS FAILED CANCELLED +``` + +Every state MUST resolve to a terminal state. No exceptions. + +### Native Status Events + +| Status | Meaning | +|--------|---------| +| `verifying` | Hash verification in progress | +| `started` | Install session created | +| `pendingUserAction` | Waiting for user confirmation | +| `installing` | User accepted, system processing | +| `success` | Install completed | +| `failed` | Error occurred | +| `cancelled` | User cancelled | + +### Error Codes + +| Code | Meaning | +|------|---------| +| `downloadFailed` | Network/file error | +| `hashMismatch` | SHA-256 mismatch | +| `invalidFile` | Not valid APK | +| `installFailed` | Generic failure | +| `certMismatch` | Signature conflict | +| `permissionDenied` | No install permission | +| `insufficientStorage` | No space | +| `incompatible` | Device incompatible | +| `blocked` | Device policy | +| `installTimeout` | Watchdog timeout (uncommitted only) | + +### Watchdog Timeouts + +| Phase | Initial | Max | Behavior | +|-------|---------|-----|----------| +| Verify | 10s | 10s | Fail if thread dead | +| Install (uncommitted) | 10s | 120s | Abandon session, emit FAILED | +| Install (committed) | 10s | ∞ | Cannot abandon; extend deadline, show "processing" | + +### Session Tracking Maps + +| Map | Purpose | +|-----|---------| +| `sessionToPackage` | sessionId → packageName lookup | +| `pendingUserActionIntents` | Stored intents for dialog re-launch | +| `sessionsPendingUserAction` | Sessions awaiting user confirmation | +| `sessionsInstalling` | Sessions where user accepted | +| `committedSessions` | Sessions post-commit (cannot abandon) | +| `sessionsCompletedSuccessfully` | Prevents duplicate SUCCESS events | + +### Silent Install Conditions (Android 12+) + +1. `canRequestPackageInstalls()` permission granted +2. Zapstore was original installer +3. `USER_ACTION_NOT_REQUIRED` flag set +4. `PACKAGE_SOURCE_STORE` declared +5. `setRequestUpdateOwnership(true)` on API 34+ + +### Key Files + +| File | Responsibility | +|------|----------------| +| `AndroidPackageManagerPlugin.kt` | Native install orchestration | +| `InstallResultReceiver.kt` | Broadcast → status event mapping | +| `android_package_manager.dart` | Dart state management | +| `package_manager.dart` | Base class, download management | +| `install_operation.dart` | State machine types |