import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:path_provider/path_provider.dart'; import 'segmented_download.dart'; import '../data/content_policy.dart'; import 'dart:convert'; /// The native multipart worker starts with HEAD, so only opt into it when /// that exact capability is advertised. A failed/unsupported probe is not a /// failed download: the caller schedules an ordinary streaming GET instead. Future supportsParallelDownload(String url) async { final uri = Uri.tryParse(url); if (uri == null || !{'http', 'https'}.contains(uri.scheme) || uri.host.isEmpty || !ContentPolicy.allowsUrl(url)) { return false; } final client = HttpClient()..connectionTimeout = const Duration(seconds: 3); try { final request = await client .openUrl('HEAD', uri) .timeout(const Duration(seconds: 4)); request.headers.set(HttpHeaders.acceptEncodingHeader, 'identity'); final response = await request.close().timeout(const Duration(seconds: 4)); return response.statusCode == HttpStatus.ok && response.contentLength > 0 && (response.headers[HttpHeaders.acceptRangesHeader] ?? const []) .expand((value) => value.toLowerCase().split(',')) .any((value) => value.trim() == 'bytes'); } catch (_) { return false; } finally { client.close(force: true); } } Future exists(String path) async => await File(path).exists() && await File(path).length() > 0; Future size(String path) => File(path).length(); Future delete(String path) async { final file = File(path); if (await file.exists()) await file.delete(); } const _segmentGroup = 'reelbox.offline.resources'; Future? _packageEngine; final _nativeTasks = >{}; final _activePackages = {}; Future _engine() => _packageEngine ??= () async { final support = await getApplicationSupportDirectory(); return SegmentedDownload( root: Directory('${support.path}/offline_packages'), transfer: _nativeTransfer, cancelTransfers: _cancelTransfers, ); }(); Future _cancelTransfers(String id) async { final ids = _nativeTasks[id]?.toList() ?? []; if (ids.isNotEmpty && !await FileDownloader().cancelTasksWithIds(ids)) { throw StateError('Android could not cancel an offline resource task.'); } } Future _nativeTransfer( OfflinePackage p, OfflineResource r, String destination, ) async { final original = Task.createFromJson( Map.from(p.metadata['task'] as Map), ); final metadata = original.metaData; final meta = Map.from(jsonDecode(metadata) as Map); if (!ContentPolicy.allowsTitleJson({ 'title': meta['title'], 'type': meta['titleKind'], }) || !ContentPolicy.allowsText(meta['episodeTitle'] as String? ?? '') || !ContentPolicy.allowsUrl(r.url)) { throw UnsupportedError( 'Excluded offline metadata cannot start a native job.', ); } final id = '${p.id}_${r.filename}_${DateTime.now().microsecondsSinceEpoch}'; final enabled = (p.metadata['notifications'] as bool?) ?? false; final task = DownloadTask( taskId: id, url: r.url, headers: { 'Accept-Encoding': 'identity', if (r.offset != null) 'Range': 'bytes=${r.offset}-${r.offset! + r.length! - 1}', }, // Per-run names keep orphan OS writers away from the resumed destination. filename: '${File(destination).uri.pathSegments.last}.$id.part', directory: File(destination).parent.path, baseDirectory: BaseDirectory.root, group: _segmentGroup, requiresWiFi: original.requiresWiFi, retries: 2, allowPause: false, updates: Updates.statusAndProgress, displayName: original.displayName, metaData: metadata, notificationConfig: enabled ? TaskNotificationConfig( running: const TaskNotification( 'Reelbox • saving offline resource', '{displayName}\n{progress}', ), error: const TaskNotification( 'Reelbox • offline resource failed', '{displayName}', ), progressBar: true, tapOpensFile: false, ) : null, ); (_nativeTasks[p.id] ??= {}).add(id); try { final update = await FileDownloader().download(task); if (update.status != TaskStatus.complete) { throw StateError('Native resource download ${update.status.name}.'); } final code = update.responseStatusCode; if (r.offset != null) { final range = update.responseHeaders?['content-range'] ?? ''; if (code != 206 || !RegExp('^bytes ${r.offset}-${r.offset! + r.length! - 1}/[0-9]+\$') .hasMatch(range)) { throw const FormatException( 'Server did not honor the resource byte range.', ); } } else if (code != 200) { throw StateError('Native resource HTTP $code.'); } final expected = int.tryParse( update.responseHeaders?['content-length'] ?? '', ); final downloaded = File(await task.filePath()); if (expected != null && expected != await downloaded.length()) { throw const FormatException('Truncated native resource.'); } await downloaded.rename(destination); } finally { _nativeTasks[p.id]?.remove(id); await FileDownloader().database.deleteRecordWithId(id); } } Map _snapshot(OfflinePackage p) => { ...p.toJson(), 'path': p.path, 'directory': p.directory, 'receivedBytes': p.receivedBytes, 'progress': p.progress, }; Future>> recoverPackages() async { // Interrupted child jobs must not race a newly resumed writer to the same .part. final downloader = FileDownloader(); final tasks = await downloader.allTasks(group: _segmentGroup); final orphans = tasks .where((t) => !_nativeTasks.values.any((ids) => ids.contains(t.taskId))) .toList(); if (orphans.isNotEmpty && !await downloader.cancelTasksWithIds( orphans.map((t) => t.taskId).toList(), )) { throw StateError( 'Cannot recover while Android still owns an old resource job.', ); } return (await (await _engine()).recover()).map(_snapshot).toList(); } Future> preparePackage({ required String id, required String url, required String type, required Map metadata, required int parallel, }) async { final engine = await _engine(); engine.parallel = parallel.clamp(1, 8); return _snapshot( await engine.prepare(id: id, url: url, type: type, metadata: metadata), ); } Future runPackage( String id, void Function(Map) onProgress, ) async { if (!_activePackages.add(id)) { throw StateError('Offline package is already active.'); } try { await (await _engine()).run( id, onProgress: (p) => onProgress(_snapshot(p)), ); } finally { _activePackages.remove(id); } } Future pausePackage(String id, {bool cancel = false}) async => (await _engine()).stop(id, cancel: cancel); Future deletePackage(String id) async => (await _engine()).delete(id); Future exportPackage(String id) async => (await _engine()).export(id); Future validatePackage(String id) async => (await _engine()).validate(id); Future> loadPackage(String id) async => _snapshot(await (await _engine()).load(id));