Explicit download queue tracking and dynamic maxConcurrentDownloads

This commit is contained in:
franzap
2026-02-04 11:09:41 -03:00
parent 208f19a2bf
commit 96b2c5eabc
10 changed files with 385 additions and 126 deletions
+3
View File
@@ -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
+151 -84
View File
@@ -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"))
+24
View File
@@ -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:
+1
View File
@@ -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:
+7 -1
View File
@@ -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
+58 -2
View File
@@ -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