Retry download from CDN on hash mismatch

When a download from the original URL (e.g. GitHub) fails hash verification,
retry from cdn.zapstore.dev before showing an error to the user. This handles
transient corruption or CDN cache issues.

- Add cdnRetryTracker in PackageManager to track whether a download came from CDN
- Add retryDownloadFromCdn() to re-download from CDN URL (hash-based)
- In AndroidPackageManager, on HASH_MISMATCH: if not from CDN, retry from CDN;
  if already from CDN, show error (no infinite loop)
- Encode isCdnRetry in task metadata so completion handler can set cdnRetryTracker

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Henrique Velloso
2026-02-24 17:36:04 -03:00
co-authored by Cursor
parent b4d43928c8
commit 288cb8ef16
2 changed files with 33 additions and 6 deletions
@@ -321,8 +321,18 @@ final class AndroidPackageManager extends PackageManager {
case InstallStatus.failed:
final failureType = _errorCodeToFailureType(errorCode, message);
// Hash mismatch from original source — retry from CDN before failing
if (failureType == FailureType.hashMismatch) {
if (cdnRetryTracker[appId] != true) {
_deleteFile(filePath);
clearInstallSlot(appId);
unawaited(retryDownloadFromCdn(appId, target));
break;
}
}
final userMessage = _getUserFriendlyMessage(failureType);
// Preserve technical details: combine original message and description
final technicalDetails = [
if (message != null) message,
if (description != null) description,
@@ -131,6 +131,12 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
@protected
String? activeInstall;
/// Tracks whether the last download for an appId came from CDN.
/// Set in [_handleDownloadComplete] from task metadata, read by subclasses
/// to decide whether to retry from CDN on hash mismatch.
@protected
final Map<String, bool> cdnRetryTracker = {};
/// Lock to prevent concurrent queue processing
bool _processingQueue = false;
@@ -304,6 +310,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
}
void clearOperation(String appId) {
cdnRetryTracker.remove(appId);
state = state.copyWith(
operations: Map.from(state.operations)..remove(appId),
);
@@ -702,6 +709,14 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
}
}
/// Re-download from CDN after a hash mismatch on the original source.
@protected
Future<void> retryDownloadFromCdn(String appId, FileMetadata target) {
activeDownloads.add(appId);
final cdnUrl = 'https://cdn.zapstore.dev/${target.hash}';
return _startDownloadTask(appId, target, cdnUrl, isCdnRetry: true);
}
// ═══════════════════════════════════════════════════════════════════════════
// DOWNLOAD INTERNALS
// ═══════════════════════════════════════════════════════════════════════════
@@ -854,10 +869,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
case TaskStatus.notFound:
// 404 error - retry with CDN fallback if not already tried
if (!isCdnRetry) {
final cdnUrl = 'https://cdn.zapstore.dev/${target.hash}';
unawaited(
_startDownloadTask(appId, target, cdnUrl, isCdnRetry: true),
);
unawaited(retryDownloadFromCdn(appId, target));
return;
}
// CDN also returned 404 - fail the operation
@@ -938,6 +950,10 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
// Remove from active downloads
activeDownloads.remove(appId);
// Track CDN origin so subclasses can retry from CDN on hash mismatch
final (_, _, isCdnRetry) = _parseTaskMetadata(task.metaData);
cdnRetryTracker[appId] = isCdnRetry;
try {
final filePath = await task.filePath();
// Proceed to install (this will add to install queue when ready)
@@ -979,7 +995,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
}
final downloadUrl = op.target.urls.firstOrNull;
if (downloadUrl == null) {
if (downloadUrl == null || downloadUrl.isEmpty) {
setOperation(
appId,
OperationFailed(
@@ -997,6 +1013,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
op.target,
downloadUrl,
displayName: op.displayName,
isCdnRetry: false,
);
}