mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
306 lines
15 KiB
Markdown
306 lines
15 KiB
Markdown
# WORK-001 — Package Manager
|
|
|
|
**Feature:** FEAT-001-package-manager.md
|
|
**Status:** Complete
|
|
|
|
## Problem Solved (Phase 1 - Complete)
|
|
|
|
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.
|
|
|
|
## Problem Solved (Phase 2 - Complete)
|
|
|
|
User report: "Update All" crashes on low-RAM devices (Lenovo Tab M10 Plus, Android 13).
|
|
|
|
Root causes identified and fixed:
|
|
1. Fixed concurrent download limit (3) overwhelms low-RAM devices → Dynamic limit based on RAM
|
|
2. State update floods when queueing many apps (20+) → Staggered 50ms delays
|
|
3. Race conditions in implicit queue derivation → Explicit queue lists with processing lock
|
|
|
|
## Tasks Completed (Phase 1)
|
|
|
|
- [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
|
|
|
|
## Tasks (Phase 2 - Batch Operation Robustness)
|
|
|
|
- [x] 1. Add device capability detection (Dart)
|
|
- Added `device_info_plus` package dependency
|
|
- Created `DeviceCapabilitiesCache` in `device_capabilities.dart`
|
|
- Estimates RAM from Android device characteristics (SDK version, ABIs)
|
|
- Calculates `maxConcurrentDownloads` based on RAM tier:
|
|
- < 3GB: 1 concurrent download
|
|
- 3-4GB: 2 concurrent downloads
|
|
- 4-6GB: 3 concurrent downloads
|
|
- > 6GB: 4 concurrent downloads
|
|
- Initialized at app startup in `appInitializationProvider`
|
|
|
|
- [x] 2. Implement explicit queue tracking
|
|
- Added `downloadQueue: List<String>` for ordered download queue
|
|
- Added `installQueue: List<String>` for ordered install queue
|
|
- Added `activeInstall: String?` as single source of truth for install slot
|
|
- Added `activeDownloads: Set<String>` for tracking active download slots
|
|
- Replaced implicit queue derivation with explicit membership
|
|
- Single `processQueue()` entry point with `_processingQueue` lock
|
|
- Protected members for subclass access
|
|
|
|
- [x] 3. Add staggered batch queueing
|
|
- Added 50ms delay between state updates in `queueDownloads()`
|
|
- Defined `batchQueueDelayMs` constant in `install_operation.dart`
|
|
- Prevents Riverpod rebuild flood on "Update All" with many apps
|
|
|
|
## Problem Solved (Phase 3 - Complete)
|
|
|
|
User report: Apps stuck in "Queued for update" / "Requesting update" state indefinitely after app restart.
|
|
|
|
Root cause: When the app is killed during install phase, native Android retains pending install sessions but Dart's operations map starts fresh. On restart, native sends `pendingUserAction` events for sessions that Dart doesn't track. Previously these were ignored, leaving orphaned sessions that blocked the PackageInstaller.
|
|
|
|
## Tasks Completed (Phase 3)
|
|
|
|
- [x] 1. Abort orphaned native sessions on event receipt
|
|
- Modified `_handleInstallEvent()` to call `abortInstall(appId)` when receiving events for untracked appIds
|
|
- Satisfies FEAT-001 requirement: "If the app is killed during install, reset UI and clean up temp files on next launch"
|
|
- Allows user to retry from clean state
|
|
|
|
- [x] 2. Prevent abort spam when native keeps sending events
|
|
- Added `_abortedOrphans: Set<String>` to track appIds already aborted
|
|
- Only call `abortInstall()` once per appId per session
|
|
- Clear from set in `setOperation()` override so future orphans can be aborted
|
|
|
|
## Problem Solved (Phase 4 - Complete)
|
|
|
|
User report: Multiple apps stuck in "Queued for update" state indefinitely, with no app actively installing.
|
|
|
|
Root cause: In `processQueue()`, `activeInstall` is set BEFORE calling `triggerInstall()`. If `triggerInstall()` returns early (operation changed, or downloaded file not found), `activeInstall` is never cleared. This permanently blocks the install queue since `activeInstall != null` prevents any new installs from starting.
|
|
|
|
## Tasks Completed (Phase 4)
|
|
|
|
- [x] 1. Add `clearInstallSlot()` helper method in base class
|
|
- Clears `activeInstall` if it matches the appId
|
|
- Removes appId from `installQueue`
|
|
- Calls `scheduleProcessQueue()` to advance to next app
|
|
- Replaces duplicate `_onInstallComplete()` in android subclass
|
|
|
|
- [x] 2. Fix `triggerInstall()` early return paths
|
|
- Call `clearInstallSlot(appId)` when operation is not ReadyToInstall
|
|
- Call `clearInstallSlot(appId)` when downloaded file is missing
|
|
- Prevents install queue from getting permanently stuck
|
|
|
|
## Problem Solved (Phase 5 - Complete)
|
|
|
|
Code audit found several potential hanging state bugs not covered by existing timeout mechanisms:
|
|
|
|
1. `_performInstall` catch block didn't call `clearInstallSlot`, causing queue hang if exception escapes
|
|
2. Restored downloads weren't added to `activeDownloads`, potentially exceeding concurrent limits
|
|
3. Download resume failures during restoration were silently swallowed, causing permanent hang
|
|
4. EventChannel disconnect was silently ignored with no reconnection
|
|
5. No Dart-side watchdog as fallback if native events stop arriving
|
|
|
|
## Tasks Completed (Phase 5)
|
|
|
|
- [x] 1. Fix `_performInstall` catch block to call `clearInstallSlot`
|
|
- Ensures install queue advances even if exception escapes `install()` internal handling
|
|
|
|
- [x] 2. Track restored downloads in `activeDownloads`
|
|
- Added `activeDownloads.add(appId)` in `_restoreOperation` for running downloads
|
|
- Prevents exceeding `maxConcurrentDownloads` limit after app restart
|
|
|
|
- [x] 3. Handle download resume failures during restoration
|
|
- On resume failure, transition to `OperationFailed` instead of silent failure
|
|
- Prevents operations stuck in `Downloading` state with no active task
|
|
|
|
- [x] 4. Add EventChannel reconnection logic
|
|
- Added `onError` and `onDone` handlers to event stream
|
|
- Automatic reconnection with 2-second delay on disconnect
|
|
- Prevents installs hanging forever if EventChannel breaks
|
|
|
|
- [x] 5. Add Dart-side watchdog timer as fallback
|
|
- Added `startedAt` field to `Verifying` and `SystemProcessing` states
|
|
- Added `needsWatchdog` and `startedAt` getters to extension for uniform access
|
|
- Single 5-minute timeout for all watchdog-monitored states
|
|
- Timer runs every 30s, only when there are operations needing watchdog
|
|
- Transitions stale operations to `OperationFailed` state
|
|
|
|
- [x] 6. Improve pause/resume error handling
|
|
- Added debug logging for pause failures
|
|
- Resume failure now transitions to `OperationFailed` instead of silent failure
|
|
|
|
## Decisions
|
|
|
|
### 2026-02-04 — Dart-side watchdog timer
|
|
|
|
**Context:** Native Kotlin has timeouts but if EventChannel breaks or native crashes before sending events, Dart operations hang forever.
|
|
**Options:** A) Trust native completely, B) Add Dart watchdog as fallback, C) Duplicate all timeout logic in Dart.
|
|
**Decision:** Option B - Dart-side watchdog as defense-in-depth.
|
|
**Rationale:** Minimal complexity (~50 lines), only runs when needed, uses generous timeouts (2-10min) so it doesn't interfere with native handling but catches catastrophic failures.
|
|
|
|
### 2026-02-04 — Install slot clearing on early return
|
|
|
|
**Context:** `triggerInstall()` can bail early if operation changed or file is missing, but `activeInstall` was set before the call, blocking the queue forever.
|
|
**Options:** A) Move `activeInstall` assignment inside `triggerInstall()` after validation, B) Have `triggerInstall()` clear slot on early returns, C) Return success boolean and only set slot if true.
|
|
**Decision:** Option B - Clear install slot on early returns.
|
|
**Rationale:** Minimal change, maintains existing flow, easy to audit. Added `clearInstallSlot()` to base class to consolidate the pattern.
|
|
|
|
### 2026-02-04 — Orphaned session cleanup
|
|
|
|
**Context:** Native install sessions persist across app restarts, but Dart state doesn't. Events arrive for untracked appIds.
|
|
**Options:** A) Ignore events (existing), B) Abort orphaned sessions, C) Attempt to restore operations from native state.
|
|
**Decision:** Option B - Abort orphaned sessions immediately.
|
|
**Rationale:** Simplest fix that satisfies "no hanging states" invariant. User can retry from clean state. Option C adds complexity and may restore stale/invalid state.
|
|
|
|
### 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.
|
|
|
|
### 2026-02-04 — Explicit vs implicit queue
|
|
|
|
**Context:** Current queue derived from operation state causes race conditions with 20+ apps.
|
|
**Options:** A) Keep implicit + add more defensive checks, B) Explicit queue lists + lock.
|
|
**Decision:** Option B - Explicit queue lists with processing lock.
|
|
**Rationale:** Simpler to reason about, eliminates race conditions, easier debugging.
|
|
|
|
### 2026-02-04 — Dynamic concurrent downloads
|
|
|
|
**Context:** Fixed limit of 3 crashes low-RAM devices; too conservative for high-end devices.
|
|
**Options:** A) Lower fixed limit, B) Dynamic based on RAM, C) User configurable.
|
|
**Decision:** Option B - Dynamic based on device RAM.
|
|
**Rationale:** Adapts to device capability without user intervention.
|
|
|
|
### 2026-02-04 — Verification chunk size
|
|
|
|
**Context:** Should chunk size (64KB) be dynamic based on device?
|
|
**Decision:** Keep fixed at 64KB.
|
|
**Rationale:** Memory savings negligible (640KB max for 10 concurrent ops), complexity not justified.
|
|
|
|
---
|
|
|
|
## 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 (Kotlin Native)
|
|
|
|
| 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" |
|
|
|
|
### Dart-side Watchdog (Fallback)
|
|
|
|
Single 5-minute timeout for all watchdog-monitored states (Verifying, Installing, SystemProcessing).
|
|
Timer runs every 30s, only when monitored operations exist.
|
|
Defense-in-depth for cases where EventChannel breaks or native crashes before sending events.
|
|
|
|
### 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 |
|