Skip to content

Commit 47bfcb6

Browse files
authored
Improve background download UX: silent captcha, retry logic, and modern notifications
Improve background download UX: silent captcha, retry logic, and modern notifications
2 parents 36720e2 + 8af1154 commit 47bfcb6

6 files changed

Lines changed: 515 additions & 110 deletions

File tree

lib/main.dart

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import 'package:openlib/ui/themes.dart';
2121
import 'package:openlib/services/files.dart'
2222
show moveFilesToAndroidInternalStorage;
2323
import 'package:openlib/services/download_manager.dart';
24+
import 'package:openlib/services/download_notification.dart';
2425
import 'package:openlib/state/state.dart'
2526
show
2627
selectedIndexProvider,
@@ -41,6 +42,7 @@ void main() async {
4142
MyLibraryDb dataBase = MyLibraryDb.instance;
4243

4344
await DownloadManager().initialize();
45+
4446
bool isDarkMode =
4547
await dataBase.getPreference('darkMode') == 0 ? false : true;
4648
bool openPdfwithExternalapp = await dataBase
@@ -123,6 +125,90 @@ class _MainScreenState extends ConsumerState<MainScreen> {
123125
SettingsPage()
124126
];
125127

128+
@override
129+
void initState() {
130+
super.initState();
131+
// Request notification permission after first frame
132+
WidgetsBinding.instance.addPostFrameCallback((_) {
133+
_checkAndRequestNotificationPermission();
134+
});
135+
}
136+
137+
Future<void> _checkAndRequestNotificationPermission() async {
138+
// Check if we should show the permission dialog
139+
final prefs = MyLibraryDb.instance;
140+
final hasAskedBefore = await prefs.getPreference('hasAskedNotificationPermission')
141+
.catchError((_) => 0);
142+
143+
if (hasAskedBefore == 0) {
144+
// Check current permission status
145+
final notificationService = DownloadNotificationService();
146+
final currentStatus = await notificationService.checkNotificationPermission();
147+
148+
if (!currentStatus && mounted) {
149+
// Show the contextual dialog first
150+
_showNotificationPermissionDialog();
151+
} else {
152+
// Already granted, just mark as asked
153+
await prefs.savePreference('hasAskedNotificationPermission', 1);
154+
}
155+
}
156+
}
157+
158+
void _showNotificationPermissionDialog() {
159+
showDialog(
160+
context: context,
161+
builder: (BuildContext context) {
162+
return AlertDialog(
163+
title: Text(
164+
'Enable Notifications',
165+
style: TextStyle(
166+
fontWeight: FontWeight.bold,
167+
color: Theme.of(context).colorScheme.secondary,
168+
),
169+
),
170+
content: Text(
171+
'Openlib needs notification permission to show download progress in the background. This helps you track your book downloads even when the app is minimized.',
172+
style: TextStyle(
173+
fontSize: 13,
174+
color: Theme.of(context).colorScheme.tertiary.withOpacity(0.78),
175+
),
176+
),
177+
actions: [
178+
TextButton(
179+
onPressed: () {
180+
Navigator.of(context).pop();
181+
// Don't mark as asked so we can ask again later
182+
},
183+
child: Text(
184+
'Maybe Later',
185+
style: TextStyle(
186+
color: Theme.of(context).colorScheme.tertiary.withOpacity(0.67),
187+
),
188+
),
189+
),
190+
TextButton(
191+
onPressed: () async {
192+
Navigator.of(context).pop();
193+
// Request permission when user clicks Enable
194+
await DownloadNotificationService().requestNotificationPermission();
195+
// Mark that we've asked
196+
await MyLibraryDb.instance.savePreference('hasAskedNotificationPermission', 1);
197+
},
198+
child: Text(
199+
'Enable',
200+
style: TextStyle(
201+
fontWeight: FontWeight.bold,
202+
color: Theme.of(context).colorScheme.secondary,
203+
),
204+
),
205+
),
206+
],
207+
);
208+
},
209+
);
210+
}
211+
126212
@override
127213
Widget build(BuildContext context) {
128214
final isDarkMode = Theme.of(context).brightness == Brightness.dark;

lib/services/download_manager.dart

Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -193,14 +193,15 @@ class DownloadManager {
193193
}
194194

195195
Future<void> _startDownload(DownloadTask task) async {
196+
Dio? dio;
196197
try {
197198
if (task.mirrors.isEmpty) {
198199
_updateTaskStatus(task.id, DownloadStatus.failed,
199200
errorMessage: 'No mirrors available!');
200201
return;
201202
}
202203

203-
Dio dio = Dio();
204+
dio = Dio();
204205
String path = await _getFilePath('${task.md5}.${task.format}');
205206
List<String> orderedMirrors = _reorderMirrors(task.mirrors);
206207

@@ -226,37 +227,98 @@ class DownloadManager {
226227
return;
227228
}
228229

229-
_updateTaskStatus(task.id, DownloadStatus.downloading);
230-
230+
// Try to download from each mirror until successful
231+
bool downloadSuccessful = false;
232+
int mirrorIndex = orderedMirrors.indexOf(workingMirror);
233+
234+
// Create a single cancel token for the entire mirror retry sequence
231235
CancelToken cancelToken = CancelToken();
232236
_activeDownloads[task.id] =
233237
_activeDownloads[task.id]!.copyWith(cancelToken: cancelToken);
234-
235-
await dio.download(
236-
workingMirror,
237-
path,
238-
options: Options(headers: {
239-
'Connection': 'Keep-Alive',
240-
'User-Agent':
241-
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36'
242-
}),
243-
onReceiveProgress: (rcv, total) {
244-
if (!(rcv.isNaN || rcv.isInfinite) &&
245-
!(total.isNaN || total.isInfinite)) {
246-
double progress = rcv / total;
247-
_updateTaskProgress(task.id, progress, rcv, total);
248-
249-
_notificationService.showDownloadNotification(
238+
239+
while (mirrorIndex < orderedMirrors.length && !downloadSuccessful) {
240+
final currentMirror = orderedMirrors[mirrorIndex];
241+
242+
try {
243+
_updateTaskStatus(task.id, DownloadStatus.downloading);
244+
245+
await dio.download(
246+
currentMirror,
247+
path,
248+
options: Options(headers: {
249+
'Connection': 'Keep-Alive',
250+
'User-Agent':
251+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36'
252+
}),
253+
onReceiveProgress: (rcv, total) {
254+
if (!(rcv.isNaN || rcv.isInfinite) &&
255+
!(total.isNaN || total.isInfinite)) {
256+
double progress = rcv / total;
257+
_updateTaskProgress(task.id, progress, rcv, total);
258+
259+
_notificationService.showDownloadNotification(
260+
id: task.id.hashCode,
261+
title: task.title,
262+
body: 'Downloading...',
263+
progress: (progress * 100).toInt(),
264+
);
265+
}
266+
},
267+
deleteOnError: true,
268+
cancelToken: cancelToken,
269+
);
270+
271+
// Download completed successfully
272+
downloadSuccessful = true;
273+
274+
} on DioException catch (e) {
275+
if (e.type == DioExceptionType.cancel) {
276+
_updateTaskStatus(task.id, DownloadStatus.cancelled);
277+
await _notificationService.cancelNotification(task.id.hashCode);
278+
return;
279+
}
280+
281+
// Try next mirror if available
282+
mirrorIndex++;
283+
if (mirrorIndex < orderedMirrors.length) {
284+
_updateTaskStatus(task.id, DownloadStatus.downloadingMirrors);
285+
await _notificationService.showDownloadNotification(
250286
id: task.id.hashCode,
251287
title: task.title,
252-
body: 'Downloading...',
253-
progress: (progress * 100).toInt(),
288+
body: 'Retrying with alternate mirror...',
289+
progress: 0,
254290
);
291+
292+
// Wait up to 2 seconds before retrying, but check for cancellation
293+
const totalDelay = Duration(seconds: 2);
294+
const stepDelay = Duration(milliseconds: 100);
295+
var elapsed = Duration.zero;
296+
while (elapsed < totalDelay) {
297+
await Future.delayed(stepDelay);
298+
elapsed += stepDelay;
299+
300+
// Check if task was cancelled during the delay
301+
if (!_activeDownloads.containsKey(task.id) ||
302+
_activeDownloads[task.id]?.cancelToken?.isCancelled == true) {
303+
_updateTaskStatus(task.id, DownloadStatus.cancelled);
304+
await _notificationService.cancelNotification(task.id.hashCode);
305+
return;
306+
}
307+
}
308+
} else {
309+
// No more mirrors to try; mark task as failed before re-throwing
310+
_updateTaskStatus(task.id, DownloadStatus.failed,
311+
errorMessage: 'All mirrors failed!');
312+
await _notificationService.showDownloadNotification(
313+
id: task.id.hashCode,
314+
title: task.title,
315+
body: 'Download failed: All mirrors exhausted',
316+
progress: -1,
317+
);
318+
rethrow;
255319
}
256-
},
257-
deleteOnError: true,
258-
cancelToken: cancelToken,
259-
);
320+
}
321+
}
260322

261323
if (!_activeDownloads.containsKey(task.id)) {
262324
return;
@@ -321,6 +383,9 @@ class DownloadManager {
321383
body: 'Download failed',
322384
progress: -1,
323385
);
386+
} finally {
387+
// Always close the Dio instance to prevent resource leaks
388+
dio?.close();
324389
}
325390
}
326391

lib/services/download_notification.dart

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
66

77
// Package imports:
88
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
9+
import 'package:permission_handler/permission_handler.dart';
910

1011
class DownloadNotificationService {
1112
static final DownloadNotificationService _instance =
@@ -18,6 +19,25 @@ class DownloadNotificationService {
1819

1920
bool _initialized = false;
2021

22+
Future<bool> requestNotificationPermission() async {
23+
if (!Platform.isAndroid) return true;
24+
25+
final status = await Permission.notification.status;
26+
if (status.isGranted) {
27+
return true;
28+
}
29+
30+
final result = await Permission.notification.request();
31+
return result.isGranted;
32+
}
33+
34+
Future<bool> checkNotificationPermission() async {
35+
if (!Platform.isAndroid) return true;
36+
37+
final status = await Permission.notification.status;
38+
return status.isGranted;
39+
}
40+
2141
Future<void> initialize() async {
2242
if (_initialized) return;
2343

@@ -65,6 +85,11 @@ class DownloadNotificationService {
6585
autoCancel: false,
6686
playSound: false,
6787
enableVibration: false,
88+
icon: '@mipmap/launcher_icon',
89+
styleInformation: BigTextStyleInformation(
90+
body ?? '',
91+
contentTitle: title,
92+
),
6893
);
6994
} else {
7095
androidDetails = AndroidNotificationDetails(
@@ -77,6 +102,11 @@ class DownloadNotificationService {
77102
autoCancel: true,
78103
playSound: progress == -1,
79104
enableVibration: false,
105+
icon: '@mipmap/launcher_icon',
106+
styleInformation: BigTextStyleInformation(
107+
body ?? '',
108+
contentTitle: title,
109+
),
80110
);
81111
}
82112

0 commit comments

Comments
 (0)