Revamped download service

This commit is contained in:
franzap
2025-12-16 15:32:09 -03:00
parent ca30f6049d
commit 661d134ef0
12 changed files with 1011 additions and 1020 deletions
+1 -1
View File
@@ -12,7 +12,7 @@ import 'package:purplebase/purplebase.dart';
import 'package:amber_signer/amber_signer.dart';
import 'package:zapstore/services/app_restart_service.dart';
import 'package:zapstore/services/background_update_service.dart';
import 'package:zapstore/services/download_service.dart';
import 'package:zapstore/services/download/download_service.dart';
import 'package:zapstore/router.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/updates_service.dart';
+1 -1
View File
@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/services/download_service.dart';
import 'package:zapstore/services/download/download_service.dart';
import 'package:zapstore/services/updates_service.dart';
import 'package:zapstore/theme.dart';
import 'package:zapstore/utils/extensions.dart';
+99 -1
View File
@@ -1,5 +1,6 @@
import 'dart:io' show Platform;
import 'dart:io' show Directory, Platform;
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
@@ -15,15 +16,24 @@ import 'package:zapstore/utils/extensions.dart';
/// Unique task name for background update checking
const kBackgroundUpdateTaskName = 'dev.zapstore.backgroundUpdateCheck';
/// Unique task name for weekly cleanup
const kWeeklyCleanupTaskName = 'dev.zapstore.weeklyCleanup';
/// Unique task identifier
const kBackgroundUpdateTaskId = 'backgroundUpdateCheck';
/// Unique task identifier for weekly cleanup
const kWeeklyCleanupTaskId = 'weeklyCleanup';
/// Notification channel for update notifications
const kUpdateNotificationChannelId = 'zapstore_updates';
const kUpdateNotificationChannelName = 'App Updates';
const kUpdateNotificationChannelDescription =
'Notifications for available app updates';
/// Stale download threshold for cleanup
const _staleDownloadThreshold = Duration(days: 7);
/// The entry point for WorkManager background tasks.
/// This MUST be a top-level function (not a class method).
@pragma('vm:entry-point')
@@ -32,12 +42,86 @@ void callbackDispatcher() {
switch (task) {
case kBackgroundUpdateTaskName:
return await _checkForUpdatesInBackground();
case kWeeklyCleanupTaskName:
return await _performWeeklyCleanup();
default:
return Future.value(false);
}
});
}
/// Perform weekly cleanup of stale downloads
Future<bool> _performWeeklyCleanup() async {
try {
final downloader = FileDownloader();
// Get all tracked records from background_downloader's database
final trackedRecords = await downloader.database.allRecords(
group: FileDownloader.defaultGroup,
);
for (final record in trackedRecords) {
final task = record.task;
if (task is! DownloadTask) continue;
final taskAge = DateTime.now().difference(record.task.creationTime);
if (taskAge > _staleDownloadThreshold) {
try {
// Cancel if active
await downloader.cancelTaskWithId(task.taskId);
} catch (_) {}
try {
// Delete the file if it exists
final filePath = await task.filePath();
final file = Directory(filePath).parent.listSync().firstWhere(
(f) => f.path.endsWith(task.filename),
orElse: () => Directory(''),
);
if (file.path.isNotEmpty) {
await file.delete();
}
} catch (_) {}
try {
// Remove from database
await downloader.database.deleteRecordWithId(task.taskId);
} catch (_) {}
}
}
// Also clean up orphaned APK files in download directory
try {
final cacheDir = await getApplicationCacheDirectory();
final downloadDir = Directory(
path.join(cacheDir.path, 'flutter_background_downloader'),
);
if (await downloadDir.exists()) {
final files = downloadDir.listSync();
final cutoff = DateTime.now().subtract(_staleDownloadThreshold);
for (final file in files) {
if (file is! Directory) {
final stat = await file.stat();
if (stat.modified.isBefore(cutoff)) {
await file.delete();
}
}
}
}
} catch (_) {
// Ignore cleanup failures for orphaned files
}
return true;
} catch (e) {
// Cleanup failed - return false for retry
return false;
}
}
/// Background update check logic - runs in a separate isolate
Future<bool> _checkForUpdatesInBackground() async {
try {
@@ -226,6 +310,20 @@ class BackgroundUpdateService {
backoffPolicy: BackoffPolicy.exponential,
initialDelay: const Duration(minutes: 15),
);
// Register weekly cleanup task
await Workmanager().registerPeriodicTask(
kWeeklyCleanupTaskId,
kWeeklyCleanupTaskName,
frequency: const Duration(days: 7),
constraints: Constraints(
requiresBatteryNotLow: true,
requiresCharging: true, // Only run when charging for cleanup
),
existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
backoffPolicy: BackoffPolicy.exponential,
initialDelay: const Duration(hours: 24), // Start first cleanup after 24h
);
}
/// Initialize local notifications plugin
+72
View File
@@ -0,0 +1,72 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:models/models.dart';
/// Stale download threshold - downloads older than this will be cleaned up
const staleDownloadThreshold = Duration(days: 7);
/// Maximum concurrent downloads allowed
const maxConcurrentDownloads = 3;
/// Wrapper around DownloadTask with app-specific metadata
class DownloadInfo {
const DownloadInfo({
required this.appId,
required this.task,
required this.fileMetadata,
this.status = TaskStatus.enqueued,
this.progress = 0.0,
this.isInstalling = false,
this.isReadyToInstall = false,
this.skipVerificationOnInstall = false,
this.errorDetails,
});
final String appId;
final DownloadTask task;
final FileMetadata fileMetadata;
final TaskStatus status;
final double progress;
final bool isInstalling;
final bool isReadyToInstall;
final bool skipVerificationOnInstall;
final String? errorDetails;
String get taskId => task.taskId;
String get fileName => task.filename;
DownloadInfo copyWith({
TaskStatus? status,
double? progress,
bool? isInstalling,
bool? isReadyToInstall,
bool? skipVerificationOnInstall,
String? errorDetails,
}) {
return DownloadInfo(
appId: appId,
task: task,
fileMetadata: fileMetadata,
status: status ?? this.status,
progress: progress ?? this.progress,
isInstalling: isInstalling ?? this.isInstalling,
isReadyToInstall: isReadyToInstall ?? this.isReadyToInstall,
skipVerificationOnInstall:
skipVerificationOnInstall ?? this.skipVerificationOnInstall,
errorDetails: errorDetails ?? this.errorDetails,
);
}
}
/// Queued download waiting for a slot
class QueuedDownload {
final String appId;
final String appName;
final FileMetadata fileMetadata;
const QueuedDownload({
required this.appId,
required this.appName,
required this.fileMetadata,
});
}
@@ -0,0 +1,166 @@
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:models/models.dart';
import 'download_info.dart';
/// Handles persistence and restoration of download state from background_downloader's database
class DownloadPersistence {
DownloadPersistence(this._downloader, this._storage);
final FileDownloader _downloader;
final StorageNotifier _storage;
/// Restore download state from background_downloader's persisted database.
/// Returns a map of appId -> DownloadInfo for restored downloads.
Future<Map<String, DownloadInfo>> restoreState() async {
final restoredState = <String, DownloadInfo>{};
try {
final trackedRecords = await _downloader.database.allRecords(
group: FileDownloader.defaultGroup,
);
for (final record in trackedRecords) {
final task = record.task;
if (task is! DownloadTask) continue;
// Parse metadata JSON to get appId and metadataId
final metaDataJson = task.metaData;
if (metaDataJson.isEmpty) {
await cleanupTask(task);
continue;
}
final (appId, metadataId) = _parseMetadata(metaDataJson);
if (appId == null) {
await cleanupTask(task);
continue;
}
// Check if task is stale
final taskAge = DateTime.now().difference(record.task.creationTime);
if (taskAge > staleDownloadThreshold) {
await cleanupTask(task);
continue;
}
// Try to load FileMetadata from purplebase
final fileMetadata = await _loadFileMetadata(metadataId, task.filename);
if (fileMetadata == null) {
await cleanupTask(task);
continue;
}
// Check if file exists for completed tasks
if (record.status == TaskStatus.complete) {
if (!await _fileExists(task)) {
await _downloader.database.deleteRecordWithId(task.taskId);
continue;
}
}
// Create DownloadInfo
restoredState[appId] = DownloadInfo(
appId: appId,
task: task,
fileMetadata: fileMetadata,
status: record.status,
progress: record.progress,
isReadyToInstall: record.status == TaskStatus.complete,
);
}
} catch (e) {
debugPrint('Failed to restore download state: $e');
}
return restoredState;
}
/// Parse metadata JSON, handling both legacy and new formats
(String? appId, String? metadataId) _parseMetadata(String metaDataJson) {
try {
final decoded = jsonDecode(metaDataJson) as Map<String, dynamic>;
return (decoded['appId'] as String?, decoded['metadataId'] as String?);
} catch (_) {
// Legacy format - metaData is just the appId
return (metaDataJson, null);
}
}
/// Load FileMetadata from purplebase by ID or hash fallback
Future<FileMetadata?> _loadFileMetadata(
String? metadataId,
String filename,
) async {
// Try by metadataId first
if (metadataId != null) {
try {
final results = _storage.querySync(
RequestFilter<FileMetadata>(ids: {metadataId}).toRequest(),
);
if (results.isNotEmpty) return results.first;
} catch (_) {}
}
// Fallback: extract hash from filename
final hash = extractHashFromFilename(filename);
if (hash != null) {
try {
final results = _storage.querySync(
RequestFilter<FileMetadata>(search: hash).toRequest(),
);
if (results.isNotEmpty) return results.first;
} catch (_) {}
}
return null;
}
/// Check if the downloaded file exists
Future<bool> _fileExists(DownloadTask task) async {
try {
final filePath = await task.filePath();
return await File(filePath).exists();
} catch (_) {
return false;
}
}
/// Clean up a stale or invalid task
Future<void> cleanupTask(DownloadTask task) async {
try {
await _downloader.cancelTaskWithId(task.taskId);
} catch (_) {}
try {
final filePath = await task.filePath();
final file = File(filePath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
try {
await _downloader.database.deleteRecordWithId(task.taskId);
} catch (_) {}
}
/// Extract hash from filename (format: {hash}.apk)
static String? extractHashFromFilename(String filename) {
final dotIndex = filename.lastIndexOf('.');
if (dotIndex > 0) {
return filename.substring(0, dotIndex);
}
return filename.isNotEmpty ? filename : null;
}
/// Encode metadata for storage in DownloadTask
static String encodeMetadata(String appId, String metadataId) {
return jsonEncode({'appId': appId, 'metadataId': metadataId});
}
}
+427
View File
@@ -0,0 +1,427 @@
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import '../../utils/extensions.dart';
import '../package_manager/package_manager.dart';
import 'download_info.dart';
import 'download_persistence.dart';
import 'installation_queue.dart';
export 'download_info.dart';
/// Download service - orchestrates downloads and installations
class DownloadService extends StateNotifier<Map<String, DownloadInfo>> {
DownloadService(this.ref) : super({}) {
_installQueue = InstallationQueue(ref);
_initialize();
}
final Ref ref;
late final FileDownloader _downloader;
late final DownloadPersistence _persistence;
late final InstallationQueue _installQueue;
// Download queue for overflow (>3 concurrent)
final List<QueuedDownload> _downloadQueue = [];
@override
void dispose() {
_downloader.unregisterCallbacks();
super.dispose();
}
Future<void> _initialize() async {
_downloader = FileDownloader();
// Configure downloader
try {
await _downloader.configure(
globalConfig: [
(Config.requestTimeout, const Duration(seconds: 20)),
(Config.resourceTimeout, const Duration(minutes: 30)),
(Config.checkAvailableSpace, Config.never),
],
androidConfig: [
(Config.useCacheDir, false),
],
);
} catch (_) {}
// Configure notifications
_downloader.configureNotificationForGroup(
FileDownloader.defaultGroup,
running: const TaskNotification('Downloading {filename}', 'File: {filename}'),
complete: const TaskNotification('Download complete', 'File: {filename}'),
error: const TaskNotification('Download failed', 'File: {filename}'),
paused: const TaskNotification('Download paused', 'File: {filename}'),
progressBar: true,
);
// Set up callbacks
_downloader.registerCallbacks(
taskStatusCallback: _handleTaskUpdate,
taskProgressCallback: _handleTaskUpdate,
);
// Set up installation queue callbacks
_installQueue.setCallbacks(
updateState: (appId, updater) {
final current = state[appId];
if (current != null) {
state = {...state, appId: updater(current)};
}
},
removeFromState: (appId) {
state = Map.from(state)..remove(appId);
},
getState: (appId) => state[appId],
);
// Restore persisted downloads
_persistence = DownloadPersistence(
_downloader,
ref.read(storageNotifierProvider.notifier),
);
await _restoreDownloads();
}
Future<void> _restoreDownloads() async {
final restored = await _persistence.restoreState();
state = restored;
// Resume incomplete downloads
for (final entry in restored.entries) {
final info = entry.value;
if (_shouldResume(info.status)) {
try {
final isActive = await _downloader.taskForId(info.taskId) != null;
if (!isActive) {
await _downloader.resume(info.task);
}
} catch (e) {
state = {
...state,
entry.key: info.copyWith(
status: TaskStatus.failed,
errorDetails: 'Could not resume download. Please retry.',
),
};
}
}
}
}
bool _shouldResume(TaskStatus status) {
return status == TaskStatus.paused ||
status == TaskStatus.running ||
status == TaskStatus.enqueued ||
status == TaskStatus.waitingToRetry;
}
// ============ Public API ============
/// Set app foreground state (called from lifecycle observer)
Future<void> setAppForeground(bool inForeground) async {
final stalledApps = state.entries
.where((e) => e.value.isInstalling)
.map((e) => e.key)
.toList();
await _installQueue.setAppForeground(inForeground);
if (inForeground && stalledApps.isNotEmpty) {
await _installQueue.handleStalledApps(stalledApps);
}
}
/// Start downloading an app
Future<void> downloadApp(App app, Release release) async {
final fileMetadata = app.latestFileMetadata;
if (fileMetadata == null) {
throw Exception('No file metadata available for this release');
}
final appId = app.identifier;
// Check if already downloading or queued
if (state.containsKey(appId)) {
final existing = state[appId]!;
if (existing.status == TaskStatus.running ||
existing.status == TaskStatus.enqueued) {
return;
}
}
if (_downloadQueue.any((q) => q.appId == appId)) {
return;
}
// Check concurrent limit
final activeCount = _countActiveDownloads();
if (activeCount >= maxConcurrentDownloads) {
_downloadQueue.add(QueuedDownload(
appId: appId,
appName: app.name ?? appId,
fileMetadata: fileMetadata,
));
return;
}
await _startDownload(appId, app.name ?? appId, fileMetadata);
}
/// Pause a download
Future<void> pauseDownload(String appId) async {
final info = state[appId];
if (info == null) return;
try {
await _downloader.pause(info.task);
} catch (_) {}
}
/// Resume a paused download
Future<void> resumeDownload(String appId) async {
final info = state[appId];
if (info == null) return;
try {
await _downloader.resume(info.task);
} catch (_) {}
}
/// Cancel a download
Future<void> cancelDownload(String appId) async {
final info = state[appId];
if (info == null) return;
try {
await _downloader.cancel(info.task);
state = Map.from(state)..remove(appId);
final filePath = await info.task.filePath();
await File(filePath).delete().catchError((_) => File(filePath));
} catch (_) {}
}
/// Install from downloaded file
Future<void> installFromDownloaded(String appId) async {
final info = state[appId];
if (info == null || !info.isReadyToInstall) return;
try {
final filePath = await info.task.filePath();
if (!await File(filePath).exists()) {
state = Map.from(state)..remove(appId);
return;
}
state = {
...state,
appId: info.copyWith(isReadyToInstall: false, errorDetails: null),
};
_installQueue.enqueue(appId);
} catch (_) {
state = {
...state,
appId: info.copyWith(isInstalling: false, isReadyToInstall: true),
};
}
}
/// Mark a download as ready to install (for reckless mode)
void markReadyToInstall(String appId, {bool skipVerificationOnInstall = false}) {
final info = state[appId];
if (info == null) return;
state = {
...state,
appId: info.copyWith(
isInstalling: false,
isReadyToInstall: true,
skipVerificationOnInstall: skipVerificationOnInstall,
errorDetails: null,
),
};
}
/// Clear error for retry
void clearError(String appId) {
final info = state[appId];
if (info?.errorDetails != null) {
state = {...state, appId: info!.copyWith(errorDetails: null)};
}
}
/// Get download info (prefer using downloadInfoProvider)
DownloadInfo? getDownloadInfo(String appId) => state[appId];
/// Download with explicit metadata (used for alternative artifact selection)
Future<void> downloadAppWithMetadata(
String appId,
String appName,
FileMetadata fileMetadata,
) async {
// Check if already downloading or queued
if (state.containsKey(appId)) {
final existing = state[appId]!;
if (existing.status == TaskStatus.running ||
existing.status == TaskStatus.enqueued) {
return;
}
}
if (_downloadQueue.any((q) => q.appId == appId)) {
return;
}
// Check concurrent limit
final activeCount = _countActiveDownloads();
if (activeCount >= maxConcurrentDownloads) {
_downloadQueue.add(QueuedDownload(
appId: appId,
appName: appName,
fileMetadata: fileMetadata,
));
return;
}
await _startDownload(appId, appName, fileMetadata);
}
// ============ Internal ============
int _countActiveDownloads() {
return state.values.where((info) =>
info.status == TaskStatus.running ||
info.status == TaskStatus.enqueued ||
info.status == TaskStatus.waitingToRetry).length;
}
Future<void> _startDownload(
String appId,
String appName,
FileMetadata fileMetadata,
) async {
final packageManager = ref.read(packageManagerProvider.notifier);
final platform = packageManager.platform;
String? downloadUrl;
if (fileMetadata.platforms.contains(platform) ||
fileMetadata.platforms.isEmpty) {
downloadUrl = fileMetadata.urls.firstOrNull;
}
if (downloadUrl == null || downloadUrl.isEmpty) return;
final fileName = '${fileMetadata.hash}${packageManager.packageExtension}';
final metaData = DownloadPersistence.encodeMetadata(appId, fileMetadata.id);
final task = DownloadTask(
taskId: '${appId}_${DateTime.now().millisecondsSinceEpoch}_${UniqueKey()}',
url: downloadUrl,
filename: fileName,
updates: Updates.statusAndProgress,
requiresWiFi: false,
retries: 3,
allowPause: true,
metaData: metaData,
displayName: appName,
);
state = {
...state,
appId: DownloadInfo(
appId: appId,
task: task,
fileMetadata: fileMetadata,
progress: 0.0,
),
};
try {
final result = await _downloader.enqueue(task);
if (!result) {
state = Map.from(state)..remove(appId);
}
} catch (_) {
state = Map.from(state)..remove(appId);
}
}
void _startNextQueuedDownload() {
if (_downloadQueue.isEmpty) return;
if (_countActiveDownloads() < maxConcurrentDownloads) {
final next = _downloadQueue.removeAt(0);
_startDownload(next.appId, next.appName, next.fileMetadata);
}
}
void _handleTaskUpdate(TaskUpdate update) {
String? appId;
for (final entry in state.entries) {
if (entry.value.taskId == update.task.taskId) {
appId = entry.key;
break;
}
}
if (appId == null) return;
final current = state[appId]!;
if (update is TaskStatusUpdate) {
_handleStatusUpdate(appId, current, update);
} else if (update is TaskProgressUpdate) {
_handleProgressUpdate(appId, current, update);
}
}
void _handleStatusUpdate(String appId, DownloadInfo current, TaskStatusUpdate update) {
switch (update.status) {
case TaskStatus.failed:
String error = 'Download failed';
if (update.exception != null) {
error = update.exception.toString();
if (error.length > 200) error = '${error.substring(0, 197)}...';
}
state = {...state, appId: current.copyWith(status: update.status, errorDetails: error)};
_startNextQueuedDownload();
case TaskStatus.canceled:
state = {...state, appId: current.copyWith(status: update.status)};
_startNextQueuedDownload();
case TaskStatus.complete:
state = {...state, appId: current.copyWith(status: update.status)};
_installQueue.handleDownloadComplete(appId);
_startNextQueuedDownload();
default:
state = {...state, appId: current.copyWith(status: update.status)};
}
}
void _handleProgressUpdate(String appId, DownloadInfo current, TaskProgressUpdate update) {
double progress = current.progress;
if (update.progress.isFinite && !update.progress.isNaN && update.progress >= 0.0) {
progress = update.progress.clamp(0.0, 1.0);
}
state = {...state, appId: current.copyWith(progress: progress)};
}
}
/// Provider for the download service
final downloadServiceProvider =
StateNotifierProvider<DownloadService, Map<String, DownloadInfo>>(
DownloadService.new,
);
/// Provider for getting download info for a specific app
final downloadInfoProvider = Provider.family<DownloadInfo?, String>((ref, appId) {
return ref.watch(downloadServiceProvider)[appId];
});
@@ -0,0 +1,241 @@
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../package_manager/android_package_manager.dart';
import '../package_manager/package_manager.dart';
import '../secure_storage_service.dart';
import 'download_info.dart';
/// Callback to update state in the main service
typedef StateUpdater = void Function(
String appId,
DownloadInfo Function(DownloadInfo) updater,
);
/// Callback to remove from state
typedef StateRemover = void Function(String appId);
/// Callback to get current state
typedef StateGetter = DownloadInfo? Function(String appId);
/// Manages sequential installation of downloaded apps
class InstallationQueue {
InstallationQueue(this._ref);
final Ref _ref;
final List<String> _queue = [];
final Set<String> _pendingInstallations = {};
bool _isProcessing = false;
bool _isAppInForeground = true;
// Callbacks set by the service
late StateUpdater _updateState;
late StateRemover _removeFromState;
late StateGetter _getState;
void setCallbacks({
required StateUpdater updateState,
required StateRemover removeFromState,
required StateGetter getState,
}) {
_updateState = updateState;
_removeFromState = removeFromState;
_getState = getState;
}
/// Set app foreground state
Future<void> setAppForeground(bool inForeground) async {
final wasBackground = !_isAppInForeground;
_isAppInForeground = inForeground;
final packageManager = _ref.read(packageManagerProvider.notifier);
if (packageManager is AndroidPackageManager) {
await packageManager.setAppForegroundState(inForeground);
}
if (inForeground && wasBackground) {
await _handleReturnToForeground();
}
}
/// Handle download completion - add to installation queue
Future<void> handleDownloadComplete(String appId) async {
final downloadInfo = _getState(appId);
if (downloadInfo == null) return;
final packageManager = _ref.read(packageManagerProvider.notifier);
final canSilent = await packageManager.canInstallSilently(appId);
// If backgrounded and not silent install, defer
if (!_isAppInForeground && !canSilent) {
_pendingInstallations.add(appId);
_updateState(appId, (info) => info.copyWith(isReadyToInstall: true));
return;
}
// Check if permission dialog needs to be shown (Android, non-silent, first time)
if (Platform.isAndroid && !canSilent) {
final secureStorage = _ref.read(secureStorageServiceProvider);
final hasSeenDialog = await secureStorage.hasSeenInstallPermissionDialog();
if (!hasSeenDialog) {
_updateState(appId, (info) => info.copyWith(isReadyToInstall: true));
return;
}
}
// Add to queue and process
if (!_queue.contains(appId)) {
_queue.add(appId);
}
_processQueue();
}
/// Add to installation queue manually (e.g., from installFromDownloaded)
void enqueue(String appId) {
if (!_queue.contains(appId)) {
_queue.add(appId);
}
_processQueue();
}
/// Handle returning to foreground
Future<void> _handleReturnToForeground() async {
// Process pending installations
if (_pendingInstallations.isNotEmpty) {
for (final appId in _pendingInstallations) {
if (!_queue.contains(appId)) {
_queue.add(appId);
}
}
_pendingInstallations.clear();
_processQueue();
}
}
/// Process stalled apps (called by service with list of stalled appIds)
Future<void> handleStalledApps(List<String> stalledAppIds) async {
final packageManager = _ref.read(packageManagerProvider.notifier);
for (final appId in stalledAppIds) {
if (packageManager is AndroidPackageManager) {
final retry = await packageManager.retryPendingInstall(appId);
if (retry.hasPending && (retry.relaunched || retry.promptAlreadyShown)) {
continue;
}
if (retry.hasPending && retry.sessionPending) {
_updateState(appId, (info) => info.copyWith(
isInstalling: false,
isReadyToInstall: true,
));
continue;
}
}
// No pending prompt - mark ready to install
_updateState(appId, (info) => info.copyWith(
isInstalling: false,
isReadyToInstall: true,
));
}
// Reset lock if there were stalled apps without prompts
final stalledWithoutPrompt = stalledAppIds.where((appId) {
final info = _getState(appId);
return info != null && !info.isInstalling && info.isReadyToInstall;
}).toList();
if (stalledWithoutPrompt.isNotEmpty) {
_isProcessing = false;
}
_processQueue();
}
/// Process installation queue sequentially
Future<void> _processQueue() async {
if (_isProcessing || _queue.isEmpty) return;
_isProcessing = true;
try {
while (_queue.isNotEmpty) {
final appId = _queue.removeAt(0);
final downloadInfo = _getState(appId);
if (downloadInfo == null) continue;
try {
final filePath = await downloadInfo.task.filePath();
_updateState(appId, (info) => info.copyWith(
isInstalling: true,
isReadyToInstall: false,
));
final packageManager = _ref.read(packageManagerProvider.notifier);
await packageManager.install(
appId,
filePath,
expectedHash: downloadInfo.fileMetadata.hash,
expectedSize: downloadInfo.fileMetadata.size ?? 0,
skipVerification: downloadInfo.skipVerificationOnInstall,
);
// Clean up file after successful install
try {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
}
} catch (_) {}
_removeFromState(appId);
if (_queue.isNotEmpty) {
await Future.delayed(const Duration(milliseconds: 500));
}
} catch (e) {
_handleInstallError(appId, downloadInfo, e.toString());
}
}
} finally {
_isProcessing = false;
}
}
void _handleInstallError(String appId, DownloadInfo downloadInfo, String errorMessage) {
// Already in progress - put back and stop
if (errorMessage.contains('INSTALL_ALREADY_IN_PROGRESS')) {
_queue.insert(0, appId);
_updateState(appId, (info) => info.copyWith(
isInstalling: true,
isReadyToInstall: false,
));
return;
}
final isCertMismatch =
errorMessage.contains('signatures do not match') ||
errorMessage.contains('INSTALL_FAILED_UPDATE_INCOMPATIBLE') ||
errorMessage.contains('UPDATE_INCOMPATIBLE');
final wasCancelled = errorMessage.contains('cancelled');
if (wasCancelled) {
_removeFromState(appId);
} else {
_updateState(appId, (info) => info.copyWith(
isInstalling: false,
isReadyToInstall: true,
errorDetails: isCertMismatch
? 'CERTIFICATE_MISMATCH'
: errorMessage.replaceFirst('Exception: ', ''),
));
}
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -9,7 +9,7 @@ import 'package:skeletonizer/skeletonizer.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:zapstore/utils/extensions.dart';
import 'package:zapstore/utils/url_utils.dart';
import 'package:zapstore/services/download_service.dart';
import 'package:zapstore/services/download/download_service.dart';
import 'package:zapstore/widgets/zap_widgets.dart';
import 'author_container.dart';
+1 -1
View File
@@ -8,7 +8,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:zapstore/services/bookmarks_service.dart';
import 'package:zapstore/services/download_service.dart';
import 'package:zapstore/services/download/download_service.dart';
import 'package:zapstore/services/notification_service.dart';
import 'package:zapstore/theme.dart';
import 'package:zapstore/utils/extensions.dart';
+1 -1
View File
@@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/download_service.dart';
import 'package:zapstore/services/download/download_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/services/secure_storage_service.dart';
import 'package:zapstore/services/trust_service.dart';
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:background_downloader/background_downloader.dart';
import 'package:models/models.dart';
import 'package:zapstore/services/download_service.dart';
import 'package:zapstore/services/download/download_service.dart';
import 'package:zapstore/services/package_manager/package_manager.dart';
import 'package:zapstore/utils/extensions.dart';