diff --git a/lib/main.dart b/lib/main.dart index 55c76c1..d5dd794 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -242,6 +242,9 @@ final appInitializationProvider = FutureProvider((ref) async { ).future, ); + // Initialize device capabilities (used for dynamic download concurrency) + await DeviceCapabilitiesCache.initialize(); + // These run in background - don't block UI final packageManager = ref.read(packageManagerProvider.notifier); unawaited(packageManager.syncInstalledPackages()); diff --git a/lib/services/package_manager/android_package_manager.dart b/lib/services/package_manager/android_package_manager.dart index f68f6a0..ff314cc 100644 --- a/lib/services/package_manager/android_package_manager.dart +++ b/lib/services/package_manager/android_package_manager.dart @@ -225,7 +225,7 @@ final class AndroidPackageManager extends PackageManager { // but our state machine doesn't depend on it. unawaited(syncInstalledPackages()); // Advance to next queued install - _advanceAfterDelay(); + _onInstallComplete(appId); break; case InstallStatus.failed: @@ -240,7 +240,7 @@ final class AndroidPackageManager extends PackageManager { ), ); // Advance to next queued install - _advanceAfterDelay(); + _onInstallComplete(appId); break; case InstallStatus.cancelled: @@ -253,50 +253,24 @@ final class AndroidPackageManager extends PackageManager { clearOperation(appId); } // Advance to next queued install - _advanceAfterDelay(); + _onInstallComplete(appId); break; } } @override void onInstallReady(String appId) { - _tryAdvanceNextInstall(); + // Use base class queue processing + super.onInstallReady(appId); } - /// Advance to next install after a delay, giving Android time to clean up. - void _advanceAfterDelay() { - Future.delayed(const Duration(seconds: 1), _tryAdvanceNextInstall); - } - - /// Try to start the next app in ReadyToInstall state. - /// Only advances if no other app is currently installing (one dialog at a time). - 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 SystemProcessing || - op is Uninstalling, - ); - - if (hasActiveInstall) { - debugPrint('[PackageManager] Not advancing - install already active'); - return; + /// Clear active install tracking and advance queue. + void _onInstallComplete(String appId) { + if (activeInstall == appId) { + activeInstall = null; } - - // Get next app ready to install - final readyToInstall = getReadyToInstall(); - if (readyToInstall.isEmpty) { - debugPrint('[PackageManager] No apps waiting in ReadyToInstall'); - return; - } - - final nextAppId = readyToInstall.first; - debugPrint('[PackageManager] Advancing to next install: $nextAppId'); - - // Trigger install (fire-and-forget, events will drive state) - unawaited(triggerInstall(nextAppId)); + installQueue.remove(appId); + scheduleProcessQueue(); } /// Convert native error code to FailureType. diff --git a/lib/services/package_manager/device_capabilities.dart b/lib/services/package_manager/device_capabilities.dart new file mode 100644 index 0000000..7c844b1 --- /dev/null +++ b/lib/services/package_manager/device_capabilities.dart @@ -0,0 +1,126 @@ +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; + +/// Device capability information for adaptive behavior. +/// Cached at startup since these values don't change during session. +class DeviceCapabilities { + const DeviceCapabilities({ + required this.totalRamMB, + required this.maxConcurrentDownloads, + }); + + /// Total device RAM in megabytes + final int totalRamMB; + + /// Recommended concurrent downloads based on device capability + final int maxConcurrentDownloads; + + /// Default capabilities for fallback (conservative) + static const fallback = DeviceCapabilities( + totalRamMB: 0, + maxConcurrentDownloads: 2, + ); + + @override + String toString() => + 'DeviceCapabilities(ram: ${totalRamMB}MB, maxDownloads: $maxConcurrentDownloads)'; +} + +/// Singleton cache for device capabilities. +/// Call [DeviceCapabilitiesCache.initialize] once at app startup. +class DeviceCapabilitiesCache { + DeviceCapabilitiesCache._(); + + static DeviceCapabilities? _cached; + + /// Get cached capabilities, or fallback if not initialized. + static DeviceCapabilities get capabilities => + _cached ?? DeviceCapabilities.fallback; + + /// Initialize device capabilities. Safe to call multiple times. + static Future initialize() async { + if (_cached != null) return _cached!; + + try { + final deviceInfo = DeviceInfoPlugin(); + final info = await deviceInfo.deviceInfo; + + int totalRamMB = 0; + + // Extract RAM from platform-specific info + if (info is AndroidDeviceInfo) { + // Android: systemFeatures doesn't have RAM directly, but we can + // check for low RAM device flag via isPhysicalDevice and other heuristics. + // The device_info_plus package on Android doesn't expose total RAM directly. + // We'll use a heuristic based on SDK version and physical device status. + totalRamMB = _estimateAndroidRam(info); + } else { + // Non-Android platforms: use conservative default + totalRamMB = 4000; + } + + final maxDownloads = _calculateMaxConcurrentDownloads(totalRamMB); + + _cached = DeviceCapabilities( + totalRamMB: totalRamMB, + maxConcurrentDownloads: maxDownloads, + ); + + debugPrint('[DeviceCapabilities] Initialized: $_cached'); + return _cached!; + } catch (e) { + debugPrint('[DeviceCapabilities] Failed to detect: $e, using fallback'); + _cached = DeviceCapabilities.fallback; + return _cached!; + } + } + + /// Estimate Android RAM based on device characteristics. + /// Since device_info_plus doesn't expose RAM directly on Android, + /// we use heuristics based on device age and type. + static int _estimateAndroidRam(AndroidDeviceInfo info) { + // SDK version gives us a rough idea of device era + final sdkInt = info.version.sdkInt; + + // Emulators and non-physical devices: assume decent RAM + if (!info.isPhysicalDevice) { + return 4000; + } + + // Low-end device indicators + final isLowEnd = info.supportedAbis.length == 1 || // Single ABI = older device + sdkInt < 28; // Android 9 or older + + if (isLowEnd) { + return 2000; // Assume 2GB for low-end + } + + // Modern devices (Android 10+) + if (sdkInt >= 29) { + // Most devices from 2019+ have at least 4GB + // High-end (2021+, Android 12+) typically have 6-8GB + if (sdkInt >= 31) { + return 6000; // Android 12+ devices + } + return 4000; // Android 10-11 devices + } + + // Default for Android 9 devices + return 3000; + } + + /// Calculate max concurrent downloads based on RAM tier. + static int _calculateMaxConcurrentDownloads(int totalRamMB) { + // Conservative tiers to prevent crashes on constrained devices + if (totalRamMB < 3000) return 1; // < 3GB: single download + if (totalRamMB < 4000) return 2; // 3-4GB: 2 concurrent + if (totalRamMB < 6000) return 3; // 4-6GB: 3 concurrent + return 4; // 6GB+: 4 concurrent + } + + /// Reset cache (for testing) + @visibleForTesting + static void reset() { + _cached = null; + } +} diff --git a/lib/services/package_manager/install_operation.dart b/lib/services/package_manager/install_operation.dart index 256abe3..0d13b68 100644 --- a/lib/services/package_manager/install_operation.dart +++ b/lib/services/package_manager/install_operation.dart @@ -3,8 +3,8 @@ import 'package:models/models.dart'; /// Stale download threshold - operations older than this will be cleaned up const staleOperationThreshold = Duration(days: 7); -/// Maximum concurrent downloads allowed -const maxConcurrentDownloads = 3; +/// Delay between queueing operations to prevent UI flood +const batchQueueDelayMs = 50; // ═══════════════════════════════════════════════════════════════════════════════ // INSTALL OPERATION STATE MACHINE diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index c5f314e..474144e 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -6,10 +6,12 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; +import 'package:zapstore/services/package_manager/device_capabilities.dart'; import 'package:zapstore/services/package_manager/dummy_package_manager.dart'; import 'package:zapstore/services/package_manager/install_operation.dart'; import 'package:zapstore/utils/version_utils.dart'; +export 'device_capabilities.dart'; export 'install_operation.dart'; // ═══════════════════════════════════════════════════════════════════════════════ @@ -96,6 +98,7 @@ class PackageManagerState extends Equatable { /// Architecture: /// - Download phase: Managed by background_downloader (can pause/resume/cancel) /// - Install phase: Platform-specific, event-driven (no hanging awaits) +/// - Explicit queues: Ordered lists for downloads and installs (not derived from state) abstract class PackageManager extends StateNotifier { PackageManager(this.ref) : super(const PackageManagerState()) { _downloaderInit = _initializeDownloader(); @@ -105,16 +108,44 @@ abstract class PackageManager extends StateNotifier { late final FileDownloader _downloader; late final Future _downloaderInit; + // ═══════════════════════════════════════════════════════════════════════════ + // EXPLICIT QUEUE TRACKING + // Queues are the source of truth for order; operations map is for UI state. + // Protected for subclass access (e.g., AndroidPackageManager). + // ═══════════════════════════════════════════════════════════════════════════ + + /// Ordered download queue (appIds waiting for download slot) + @protected + final List downloadQueue = []; + + /// Ordered install queue (appIds waiting for install slot) + @protected + final List installQueue = []; + + /// Currently active downloads (appIds) + @protected + final Set activeDownloads = {}; + + /// Currently active install (only 1 allowed due to Android PackageInstaller) + @protected + String? activeInstall; + + /// Lock to prevent concurrent queue processing + bool _processingQueue = false; + + /// Dynamic max concurrent downloads based on device capability + int get maxConcurrentDownloads => + DeviceCapabilitiesCache.capabilities.maxConcurrentDownloads; + Future _ensureDownloaderReady() => _downloaderInit; /// Hook called when an app transitions into [ReadyToInstall]. /// - /// Default behavior is to immediately start installation. Platforms that must - /// serialize installs (Android PackageInstaller UI) should override this to - /// queue/advance one install at a time. + /// Default behavior is to process the queue, which will start the install + /// if no other install is active. Subclasses can override for custom behavior. @protected void onInstallReady(String appId) { - unawaited(triggerInstall(appId)); + unawaited(processQueue()); } // ═══════════════════════════════════════════════════════════════════════════ @@ -229,29 +260,20 @@ abstract class PackageManager extends StateNotifier { return false; } - // Check download slots - only count active downloads, not queued - final activeDownloads = countOperations(); - - if (activeDownloads >= maxConcurrentDownloads) { - setOperation( - appId, - DownloadQueued(target: target, displayName: displayName), - ); - return true; - } - - await _startDownloadTask( + // Add to explicit queue and set UI state + downloadQueue.add(appId); + setOperation( appId, - target, - downloadUrl, - displayName: displayName, + DownloadQueued(target: target, displayName: displayName), ); + + // Process queue to potentially start this download immediately + unawaited(processQueue()); return true; } - /// Queue multiple downloads at once - immediately marks all as queued, - /// then starts up to [maxConcurrentDownloads] actual downloads. - /// This prevents UI confusion when "Update All" is tapped. + /// Queue multiple downloads at once - staggered to prevent UI flood. + /// This is the primary method for "Update All" functionality. Future queueDownloads( List<({String appId, FileMetadata target, String? displayName})> items, ) async { @@ -261,9 +283,11 @@ abstract class PackageManager extends StateNotifier { final toQueue = items.where((item) => !hasOperation(item.appId)).toList(); if (toQueue.isEmpty) return; - // First, mark ALL items as queued immediately for responsive UI - for (final item in toQueue) { + // Queue items with staggered delays to prevent UI flood + for (var i = 0; i < toQueue.length; i++) { + final item = toQueue[i]; final downloadUrl = item.target.urls.firstOrNull; + if (downloadUrl == null || downloadUrl.isEmpty) { setOperation( item.appId, @@ -274,15 +298,22 @@ abstract class PackageManager extends StateNotifier { ), ); } else { + // Add to explicit queue + downloadQueue.add(item.appId); setOperation( item.appId, DownloadQueued(target: item.target, displayName: item.displayName), ); } + + // Stagger state updates to prevent Riverpod rebuild flood + if (i < toQueue.length - 1) { + await Future.delayed(const Duration(milliseconds: batchQueueDelayMs)); + } } - // Now process the queue to start actual downloads - _processQueuedDownload(); + // Process queue to start actual downloads + unawaited(processQueue()); } Future pauseDownload(String appId) async { @@ -334,7 +365,13 @@ abstract class PackageManager extends StateNotifier { } catch (_) {} } + // Remove from queues + downloadQueue.remove(appId); + activeDownloads.remove(appId); clearOperation(appId); + + // Advance queue + scheduleProcessQueue(); } // ═══════════════════════════════════════════════════════════════════════════ @@ -472,20 +509,13 @@ abstract class PackageManager extends StateNotifier { // Advance the requested app first (for responsive UX) if (toAdvance.containsKey(appId)) { final (target, filePath) = toAdvance.remove(appId)!; - // Permission is granted now: transition to ReadyToInstall and let the - // platform decide whether to auto-start or queue. - setOperation(appId, ReadyToInstall(target: target, filePath: filePath)); - onInstallReady(appId); + _addToInstallQueue(appId, target, filePath); } // Advance remaining apps for (final entry in toAdvance.entries) { final (target, filePath) = entry.value; - setOperation( - entry.key, - ReadyToInstall(target: target, filePath: filePath), - ); - onInstallReady(entry.key); + _addToInstallQueue(entry.key, target, filePath); } } @@ -647,6 +677,7 @@ abstract class PackageManager extends StateNotifier { return; } // CDN also returned 404 - fail the operation + activeDownloads.remove(appId); setOperation( appId, OperationFailed( @@ -655,7 +686,7 @@ abstract class PackageManager extends StateNotifier { message: 'File not found (404)', ), ); - _processQueuedDownload(); + scheduleProcessQueue(); break; case TaskStatus.failed: @@ -665,6 +696,7 @@ abstract class PackageManager extends StateNotifier { error = exception.toString(); if (error.length > 200) error = '${error.substring(0, 197)}...'; } + activeDownloads.remove(appId); setOperation( appId, OperationFailed( @@ -673,12 +705,14 @@ abstract class PackageManager extends StateNotifier { message: error, ), ); - _processQueuedDownload(); + scheduleProcessQueue(); break; case TaskStatus.canceled: + activeDownloads.remove(appId); + downloadQueue.remove(appId); clearOperation(appId); - _processQueuedDownload(); + scheduleProcessQueue(); break; default: @@ -713,9 +747,12 @@ abstract class PackageManager extends StateNotifier { FileMetadata target, DownloadTask task, ) async { + // Remove from active downloads + activeDownloads.remove(appId); + try { final filePath = await task.filePath(); - _processQueuedDownload(); + // Proceed to install (this will add to install queue when ready) await _proceedToInstall(appId, target, filePath); } catch (e) { setOperation( @@ -726,53 +763,75 @@ abstract class PackageManager extends StateNotifier { message: 'Failed to access downloaded file: $e', ), ); - _processQueuedDownload(); + } + + // Advance download queue + scheduleProcessQueue(); + } + + /// Unified queue processor with lock to prevent race conditions. + /// Handles both download and install queues. + @protected + Future processQueue() async { + // Lock to prevent concurrent processing + if (_processingQueue) return; + _processingQueue = true; + + try { + // Process download queue: fill available slots + while (activeDownloads.length < maxConcurrentDownloads && + downloadQueue.isNotEmpty) { + final appId = downloadQueue.removeAt(0); + final op = getOperation(appId); + + if (op is! DownloadQueued) { + // Operation was cancelled or changed, skip + continue; + } + + final downloadUrl = op.target.urls.firstOrNull; + if (downloadUrl == null) { + setOperation( + appId, + OperationFailed( + target: op.target, + type: FailureType.downloadFailed, + message: 'No download URL available', + ), + ); + continue; + } + + activeDownloads.add(appId); + await _startDownloadTask( + appId, + op.target, + downloadUrl, + displayName: op.displayName, + ); + } + + // Process install queue: only 1 at a time (Android PackageInstaller limit) + if (activeInstall == null && installQueue.isNotEmpty) { + final appId = installQueue.removeAt(0); + final op = getOperation(appId); + + if (op is ReadyToInstall) { + activeInstall = appId; + debugPrint('[PackageManager] Starting install for $appId'); + unawaited(triggerInstall(appId)); + } + // If operation changed, it will be picked up on next process cycle + } + } finally { + _processingQueue = false; } } - void _processQueuedDownload() { - final activeDownloads = countOperations(); - if (activeDownloads >= maxConcurrentDownloads) return; - - // Collect queued items first to avoid iterating while modifying state - final queuedItems = {}; - for (final entry in state.operations.entries) { - if (entry.value is DownloadQueued) { - 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, - ), - ); - 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', - ), - ); - } - } + /// Schedule queue processing after a delay (for advancing after completion). + @protected + void scheduleProcessQueue() { + Future.delayed(const Duration(milliseconds: 500), processQueue); } // ═══════════════════════════════════════════════════════════════════════════ @@ -812,7 +871,15 @@ abstract class PackageManager extends StateNotifier { return; } - // Permission was already granted before we checked - just advance this app + // Permission was already granted - add to install queue + _addToInstallQueue(appId, target, filePath); + } + + /// Add app to install queue and trigger processing. + void _addToInstallQueue(String appId, FileMetadata target, String filePath) { + if (!installQueue.contains(appId)) { + installQueue.add(appId); + } setOperation(appId, ReadyToInstall(target: target, filePath: filePath)); onInstallReady(appId); } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 763252e..ca0ef37 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -7,6 +7,7 @@ import Foundation import app_links import connectivity_plus +import device_info_plus import flutter_local_notifications import flutter_secure_storage_darwin import path_provider_foundation @@ -19,6 +20,7 @@ import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/pubspec.lock b/pubspec.lock index f4a64b8..df49716 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -258,6 +258,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c" + url: "https://pub.dev" + source: hosted + version: "12.3.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" easy_image_viewer: dependency: "direct main" description: @@ -1305,6 +1321,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" workmanager: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index bfbda4a..301fcc0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -55,6 +55,7 @@ dependencies: markdown: ^7.3.0 workmanager: ^0.9.0+3 flutter_local_notifications: ^19.5.0 + device_info_plus: ^12.0.0 dependency_overrides: models: diff --git a/spec/features/FEAT-001-package-manager.md b/spec/features/FEAT-001-package-manager.md index dee984c..52041a9 100644 --- a/spec/features/FEAT-001-package-manager.md +++ b/spec/features/FEAT-001-package-manager.md @@ -182,10 +182,16 @@ There is no third option. Timeouts, crashes, backgrounding, network loss—all p ### Concurrent Operations -- Multiple downloads: up to 3 simultaneous, others queued +- Multiple downloads: limited based on device capability (1-4 simultaneous), others queued - Multiple installs: 1 at a time, others wait in queue +- Queue order: best-effort insertion order (not guaranteed) - Queue advances automatically after each completion +### Device Adaptation + +- Download concurrency adapts to device RAM to prevent crashes on low-capability devices +- Behavior degrades gracefully on constrained devices, never hangs + ## Acceptance Criteria - [ ] **No operation hangs indefinitely** — every state resolves to success, failure, or cancelled diff --git a/work/WORK-001-package-manager.md b/work/WORK-001-package-manager.md index f41ce0f..85e8822 100644 --- a/work/WORK-001-package-manager.md +++ b/work/WORK-001-package-manager.md @@ -3,7 +3,7 @@ **Feature:** FEAT-001-package-manager.md **Status:** Complete -## Problem Solved +## 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 @@ -12,7 +12,16 @@ Install sessions could complete in Android PackageInstaller even after Zapstore' Root cause: `abandonSession()` doesn't work after `session.commit()` is called. -## Tasks Completed +## 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 @@ -32,6 +41,33 @@ Root cause: `abandonSession()` doesn't work after `session.commit()` is called. - 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` for ordered download queue + - Added `installQueue: List` for ordered install queue + - Added `activeInstall: String?` as single source of truth for install slot + - Added `activeDownloads: Set` 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 + ## Decisions ### 2026-02-03 — Committed session handling @@ -41,6 +77,26 @@ Root cause: `abandonSession()` doesn't work after `session.commit()` is called. **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