mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Explicit download queue tracking and dynamic maxConcurrentDownloads
This commit is contained in:
@@ -242,6 +242,9 @@ final appInitializationProvider = FutureProvider<void>((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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<DeviceCapabilities> 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<PackageManagerState> {
|
||||
PackageManager(this.ref) : super(const PackageManagerState()) {
|
||||
_downloaderInit = _initializeDownloader();
|
||||
@@ -105,16 +108,44 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
late final FileDownloader _downloader;
|
||||
late final Future<void> _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<String> downloadQueue = [];
|
||||
|
||||
/// Ordered install queue (appIds waiting for install slot)
|
||||
@protected
|
||||
final List<String> installQueue = [];
|
||||
|
||||
/// Currently active downloads (appIds)
|
||||
@protected
|
||||
final Set<String> 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<void> _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<PackageManagerState> {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check download slots - only count active downloads, not queued
|
||||
final activeDownloads = countOperations<Downloading>();
|
||||
|
||||
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<void> queueDownloads(
|
||||
List<({String appId, FileMetadata target, String? displayName})> items,
|
||||
) async {
|
||||
@@ -261,9 +283,11 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
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<PackageManagerState> {
|
||||
),
|
||||
);
|
||||
} 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<void> pauseDownload(String appId) async {
|
||||
@@ -334,7 +365,13 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// Remove from queues
|
||||
downloadQueue.remove(appId);
|
||||
activeDownloads.remove(appId);
|
||||
clearOperation(appId);
|
||||
|
||||
// Advance queue
|
||||
scheduleProcessQueue();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -472,20 +509,13 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
// 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<PackageManagerState> {
|
||||
return;
|
||||
}
|
||||
// CDN also returned 404 - fail the operation
|
||||
activeDownloads.remove(appId);
|
||||
setOperation(
|
||||
appId,
|
||||
OperationFailed(
|
||||
@@ -655,7 +686,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
message: 'File not found (404)',
|
||||
),
|
||||
);
|
||||
_processQueuedDownload();
|
||||
scheduleProcessQueue();
|
||||
break;
|
||||
|
||||
case TaskStatus.failed:
|
||||
@@ -665,6 +696,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
|
||||
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<PackageManagerState> {
|
||||
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<PackageManagerState> {
|
||||
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<PackageManagerState> {
|
||||
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<void> 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<Downloading>();
|
||||
if (activeDownloads >= maxConcurrentDownloads) return;
|
||||
|
||||
// Collect queued items first to avoid iterating while modifying state
|
||||
final queuedItems = <String, DownloadQueued>{};
|
||||
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<PackageManagerState> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<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
|
||||
|
||||
## 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
|
||||
|
||||
Reference in New Issue
Block a user