mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Revamped download service
This commit is contained in:
+1
-1
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user