fix(downloads): defer verification challenge until app is foregrounded

When a download hit a verification challenge while the app was in the
background, the browser launch was attempted immediately (blocked or
silently dropped by the OS) and the 5-minute grant timeout kept running,
so the item failed without the user ever seeing the CAPTCHA.

Wait for AppLifecycleState.resumed before opening the challenge and
before starting the grant timeout, and post a local notification asking
the user to return to the app. If the process is killed while waiting,
the persisted queue already restores the item as queued on next launch.
This commit is contained in:
zarzet
2026-07-09 19:46:46 +07:00
parent 2f53be7c0d
commit d988b2c9d3
19 changed files with 195 additions and 0 deletions
+12
View File
@@ -6213,6 +6213,18 @@ abstract class AppLocalizations {
/// **'Downloads Finished ({completed} done, {failed} failed)'**
String notifDownloadsFinished(int completed, int failed);
/// Notification title shown when a download needs the user to complete a verification challenge but the app is in the background
///
/// In en, this message translates to:
/// **'Verification required'**
String get notifVerificationRequiredTitle;
/// Notification body prompting the user to return to the app to solve a verification challenge
///
/// In en, this message translates to:
/// **'Open the app to complete verification and resume downloads'**
String get notifVerificationRequiredBody;
/// Notification title when all downloads finish successfully
///
/// In en, this message translates to:
+7
View File
@@ -3685,6 +3685,13 @@ class AppLocalizationsAr extends AppLocalizations {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
+7
View File
@@ -3733,6 +3733,13 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Downloads abgeschlossen ($completed fertig, $failed fehlgeschlagen)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'Alle Downloads abgeschlossen';
+7
View File
@@ -3685,6 +3685,13 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
+7
View File
@@ -3685,6 +3685,13 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
+7
View File
@@ -3785,6 +3785,13 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Téléchargements terminés ($completed terminé, $failed en échec)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete =>
'Tous les téléchargements sont terminés';
+7
View File
@@ -3685,6 +3685,13 @@ class AppLocalizationsHi extends AppLocalizations {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
+7
View File
@@ -3668,6 +3668,13 @@ class AppLocalizationsId extends AppLocalizations {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
+7
View File
@@ -3672,6 +3672,13 @@ class AppLocalizationsJa extends AppLocalizations {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
+7
View File
@@ -3670,6 +3670,13 @@ class AppLocalizationsKo extends AppLocalizations {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
+7
View File
@@ -3685,6 +3685,13 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
+7
View File
@@ -3685,6 +3685,13 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
+7
View File
@@ -3740,6 +3740,13 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Загрузка завершена ($completed завершено, $failed не удалось)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'Все загрузки завершены';
+7
View File
@@ -3716,6 +3716,13 @@ class AppLocalizationsTr extends AppLocalizations {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
+7
View File
@@ -3738,6 +3738,13 @@ class AppLocalizationsUk extends AppLocalizations {
return 'Завантаження завершено ($completed завершено, $failed не вдалося)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'Усі завантаження завершено';
+7
View File
@@ -3685,6 +3685,13 @@ class AppLocalizationsZh extends AppLocalizations {
return 'Downloads Finished ($completed done, $failed failed)';
}
@override
String get notifVerificationRequiredTitle => 'Verification required';
@override
String get notifVerificationRequiredBody =>
'Open the app to complete verification and resume downloads';
@override
String get notifAllDownloadsComplete => 'All Downloads Complete';
+8
View File
@@ -4782,6 +4782,14 @@
}
}
},
"notifVerificationRequiredTitle": "Verification required",
"@notifVerificationRequiredTitle": {
"description": "Notification title shown when a download needs the user to complete a verification challenge but the app is in the background"
},
"notifVerificationRequiredBody": "Open the app to complete verification and resume downloads",
"@notifVerificationRequiredBody": {
"description": "Notification body prompting the user to return to the app to solve a verification challenge"
},
"notifAllDownloadsComplete": "All Downloads Complete",
"@notifAllDownloadsComplete": {
"description": "Notification title when all downloads finish successfully"
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:math';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
@@ -2095,10 +2096,39 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
/// Completes when the app is in the foreground. Verification challenges
/// can only be handled there: launching a browser from the background is
/// blocked by the OS and the challenge would expire unseen.
Future<void> _waitForForeground() async {
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
return;
}
final completer = Completer<void>();
final listener = AppLifecycleListener(
onResume: () {
if (!completer.isCompleted) completer.complete();
},
);
try {
await completer.future;
} finally {
listener.dispose();
}
}
Future<bool> _openVerificationAndWait(String extensionId) async {
final normalizedExtensionId = extensionId.trim();
if (normalizedExtensionId.isEmpty) return false;
if (WidgetsBinding.instance.lifecycleState != AppLifecycleState.resumed) {
_log.i(
'Verification required for $normalizedExtensionId while app is in '
'background; deferring challenge until the app is foregrounded',
);
unawaited(_notificationService.showVerificationRequired());
await _waitForForeground();
}
final grantEventFuture = PlatformBridge.extensionSessionGrantEvents()
.where((event) => event.extensionId == normalizedExtensionId)
.first
+40
View File
@@ -334,6 +334,46 @@ class NotificationService {
);
}
Future<void> showVerificationRequired() async {
if (!_isInitialized) await initialize();
unawaited(HapticFeedback.mediumImpact());
final title =
_l10n?.notifVerificationRequiredTitle ?? 'Verification required';
final body =
_l10n?.notifVerificationRequiredBody ??
'Open the app to complete verification and resume downloads';
const androidDetails = AndroidNotificationDetails(
channelId,
channelName,
channelDescription: channelDescription,
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
playSound: true,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _showSafely(
id: downloadProgressId,
title: title,
body: body,
details: details,
);
}
Future<void> showQueueCanceled({required int canceledCount}) async {
if (!_isInitialized) await initialize();
if (canceledCount <= 0) return;