Introduce package manager spec/work, clarify states and update code

This commit is contained in:
franzap
2026-02-04 10:17:05 -03:00
parent b4b2ef4c81
commit 208f19a2bf
7 changed files with 639 additions and 35 deletions
@@ -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<Int>()
/** Track sessions that have been committed (abandonSession won't work for these) */
private val committedSessions = mutableSetOf<Int>()
/** Track sessions that have already emitted SUCCESS (to avoid duplicates from onFinished) */
private val sessionsCompletedSuccessfully = mutableSetOf<Int>()
/** 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<String>("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
// ═══════════════════════════════════════════════════════════════════════════════
@@ -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<void> abortInstall(String appId) async {
try {
final result = await _methodChannel
.invokeMethod<Map<Object?, Object?>>('abortInstall', {
'packageName': appId,
})
.timeout(
const Duration(seconds: 5),
onTimeout: () => <Object?, Object?>{'success': false},
);
final resultMap = Map<String, dynamic>.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;
}
@@ -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,
@@ -183,8 +183,8 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
.map((e) => e.key)
.toList();
List<String> getAwaitingUserAction() => state.operations.entries
.where((e) => e.value is AwaitingUserAction)
List<String> getInstallCancelled() => state.operations.entries
.where((e) => e.value is InstallCancelled)
.map((e) => e.key)
.toList();
@@ -365,10 +365,10 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
await _performInstall(appId, op.target, op.filePath);
}
/// Retry install from AwaitingUserAction state
/// Retry install from InstallCancelled state
Future<void> 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<PackageManagerState> {
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<int>((ref) {
);
});
final awaitingUserActionCountProvider = Provider<int>((ref) {
final installCancelledCountProvider = Provider<int>((ref) {
return ref.watch(
packageManagerProvider.select(
(s) => s.operations.values.whereType<AwaitingUserAction>().length,
(s) => s.operations.values.whereType<InstallCancelled>().length,
),
);
});
+32 -11
View File
@@ -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);
+212
View File
@@ -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
+146
View File
@@ -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 |