import 'dart:io'; import 'package:archive/archive.dart'; import 'package:dio/dio.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import '../sources/comicland/urls.dart'; import '../sources/source.dart'; /// Saves CBZs to public Download/Comics via a native MediaStore channel. /// /// Transfers use temp file paths (not byte arrays) to avoid binder size limits. class PublicCbzStore { PublicCbzStore({MethodChannel? channel}) : _channel = channel ?? const MethodChannel('org.cxyz.cxyz_android/cbz_store'); final MethodChannel _channel; int? _sdkInt; Future sdkInt() async { if (_sdkInt != null) return _sdkInt!; try { final v = await _channel.invokeMethod('sdkInt'); _sdkInt = v ?? 30; } catch (_) { _sdkInt = 30; } return _sdkInt!; } /// Request WRITE_EXTERNAL_STORAGE on API 26–28 only. Future ensureWritePermission() async { if (!Platform.isAndroid) return; final sdk = await sdkInt(); if (sdk >= 29) return; final status = await Permission.storage.request(); if (!status.isGranted) { throw StateError('Storage permission is required to save CBZ files'); } } Future saveCbzFromPath({ required String seriesDirName, required String displayName, required String sourcePath, }) async { await ensureWritePermission(); final relativeDir = 'Comics/$seriesDirName'; final uri = await _channel.invokeMethod('saveCbzFromPath', { 'relativeDir': relativeDir, 'displayName': displayName, 'sourcePath': sourcePath, }); if (uri == null || uri.isEmpty) { throw StateError('Native saveCbzFromPath returned empty uri'); } return uri; } Future readCbz(String uriOrPath) async { if (!uriOrPath.startsWith('content://')) { return File(uriOrPath).readAsBytes(); } final tmp = await File( p.join( Directory.systemTemp.path, 'cxyz-read-${DateTime.now().millisecondsSinceEpoch}.cbz', ), ).create(); try { await _channel.invokeMethod('readCbzToPath', { 'uri': uriOrPath, 'destPath': tmp.path, }); return await tmp.readAsBytes(); } finally { try { await tmp.delete(); } catch (_) {} } } Future deleteCbz(String uriOrPath) async { if (uriOrPath.startsWith('content://')) { await _channel.invokeMethod('deleteCbz', {'uri': uriOrPath}); return; } final file = File(uriOrPath); if (await file.exists()) { await file.delete(); } } } class CbzStore { CbzStore({Dio? dio, PublicCbzStore? publicStore}) : _dio = dio ?? Dio(), _public = publicStore ?? PublicCbzStore(); final Dio _dio; final PublicCbzStore _public; Future comicsRoot() async { final docs = await getApplicationDocumentsDirectory(); final dir = Directory(p.join(docs.path, 'Comics')); if (!await dir.exists()) { await dir.create(recursive: true); } return dir; } Future cacheRoot() async { final cache = await getTemporaryDirectory(); final dir = Directory(p.join(cache.path, 'chapter_cache')); if (!await dir.exists()) { await dir.create(recursive: true); } return dir; } String cbzDisplayName({ required String chapterUrl, String? chapterNumber, String? label, }) { return transferLinkBasename( chapterNumber, label, '${sanitizeFsStem(stemForComicLandChapterCbz(chapterUrl))}.cbz', ); } Future resolveCacheDir({ required String comicTitle, required String chapterKey, }) async { final root = await cacheRoot(); final dir = Directory( p.join(root.path, sanitizeComicDirName(comicTitle), sanitizeFsStem(chapterKey)), ); return dir.path; } String extensionFromContentType(String? contentType) { if (contentType == null || contentType.isEmpty) return ''; final ct = contentType.split(';').first.trim().toLowerCase(); const map = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/pjpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif', 'image/avif': '.avif', }; return map[ct] ?? ''; } String extensionFromImageUrl(String imageUrl) { try { final ext = p.extension(Uri.parse(imageUrl).path).toLowerCase(); if (ext.isNotEmpty && ext.length <= 8 && RegExp(r'^\.[a-z0-9]+$').hasMatch(ext)) { return ext; } } catch (_) {} return ''; } /// Download images → pack CBZ → extract to cache. Returns paths and sizes. /// [cbzPath] may be a content:// URI (MediaStore) or a filesystem path. Future< ({ String cbzPath, String cacheDir, int imageCount, int rawBytes, int cbzBytes, String? firstImagePath, int firstImageBytes, })> downloadChapter({ required ComicSource source, required String comicTitle, required ChapterLink chapter, void Function(double progress, String message)? onProgress, bool Function()? isCancelled, }) async { onProgress?.call(0.02, 'Fetching…'); final urls = await source.fetchPageUrls(chapter.url); if (urls.isEmpty) { throw StateError('No chapter images were found for ${chapter.url}'); } final tmp = await Directory.systemTemp.createTemp('cxyz-chapter-'); try { final saved = []; var rawBytes = 0; for (var i = 0; i < urls.length; i++) { if (isCancelled?.call() == true) { throw StateError('Download cancelled'); } final imageUrl = urls[i]; onProgress?.call( 0.05 + (0.7 * (i / urls.length)), 'Downloading…', ); final res = await _dio.get>( imageUrl, options: Options( responseType: ResponseType.bytes, headers: source.imageHeaders, receiveTimeout: const Duration(seconds: 120), validateStatus: (s) => s != null && s >= 200 && s < 300, ), ); var ext = extensionFromImageUrl(imageUrl); if (ext.isEmpty) { ext = extensionFromContentType(res.headers.value('content-type')); if (ext.isEmpty) ext = '.bin'; } final filename = 'page-${(i + 1).toString().padLeft(4, '0')}$ext'; final file = File(p.join(tmp.path, filename)); final data = Uint8List.fromList(res.data ?? const []); await file.writeAsBytes(data, flush: true); rawBytes += data.length; saved.add(file); } onProgress?.call(0.8, 'Packing…'); final archive = Archive(); for (final file in saved) { final bytes = await file.readAsBytes(); archive.addFile( ArchiveFile(p.basename(file.path), bytes.length, bytes), ); } final encoded = ZipEncoder().encode(archive); final zipBytes = Uint8List.fromList(encoded); final cbzBytes = zipBytes.length; final packed = File(p.join(tmp.path, 'chapter.cbz')); await packed.writeAsBytes(zipBytes, flush: true); final seriesDir = sanitizeComicDirName(comicTitle); final displayName = cbzDisplayName( chapterUrl: chapter.url, chapterNumber: chapter.chapterNumber, label: chapter.title, ); String cbzPath; if (Platform.isAndroid) { cbzPath = await _public.saveCbzFromPath( seriesDirName: seriesDir, displayName: displayName, sourcePath: packed.path, ); } else { // Desktop / tests: keep writing under app documents. final root = await comicsRoot(); final titleDir = Directory(p.join(root.path, seriesDir)); if (!await titleDir.exists()) { await titleDir.create(recursive: true); } cbzPath = p.join(titleDir.path, displayName); await packed.copy(cbzPath); } onProgress?.call(0.9, 'Extracting…'); final cacheDir = await resolveCacheDir( comicTitle: comicTitle, chapterKey: chapter.chapterKey, ); final extracted = await extractBytesToCache(zipBytes, cacheDir); extracted.sort(); final firstImagePath = extracted.isEmpty ? null : extracted.first; final firstImageBytes = firstImagePath == null ? 0 : await File(firstImagePath).length(); onProgress?.call(1.0, 'Done'); return ( cbzPath: cbzPath, cacheDir: cacheDir, imageCount: saved.length, rawBytes: rawBytes, cbzBytes: cbzBytes, firstImagePath: firstImagePath, firstImageBytes: firstImageBytes, ); } finally { try { await tmp.delete(recursive: true); } catch (_) {} } } Future> extractBytesToCache( Uint8List bytes, String cacheDirPath, ) async { final dir = Directory(cacheDirPath); if (await dir.exists()) { await dir.delete(recursive: true); } await dir.create(recursive: true); final archive = ZipDecoder().decodeBytes(bytes); final out = []; for (final file in archive.files) { if (!file.isFile) continue; final name = p.basename(file.name); if (name.isEmpty || name.startsWith('.')) continue; final outPath = p.join(cacheDirPath, name); final outFile = File(outPath); await outFile.writeAsBytes(file.content as List, flush: true); out.add(outPath); } out.sort(); return out; } Future> extractCbzToCache(String cbzPath, String cacheDirPath) async { final bytes = await readCbzBytes(cbzPath); return extractBytesToCache(bytes, cacheDirPath); } Future readCbzBytes(String cbzPath) async { if (cbzPath.startsWith('content://')) { return _public.readCbz(cbzPath); } return File(cbzPath).readAsBytes(); } Future> pagesFromCacheOrCbz({ required String? cacheDir, required String? cbzPath, required String comicTitle, required String chapterKey, }) async { if (cacheDir != null) { final dir = Directory(cacheDir); if (await dir.exists()) { final files = await dir .list() .where((e) => e is File) .map((e) => e.path) .toList(); files.sort(); if (files.isNotEmpty) return files; } } if (cbzPath != null && cbzPath.isNotEmpty) { final exists = cbzPath.startsWith('content://') || await File(cbzPath).exists(); if (exists) { final target = cacheDir ?? await resolveCacheDir(comicTitle: comicTitle, chapterKey: chapterKey); return extractCbzToCache(cbzPath, target); } } return const []; } Future deleteDownload({ String? cbzPath, String? cacheDir, }) async { if (cacheDir != null) { try { final dir = Directory(cacheDir); if (await dir.exists()) { await dir.delete(recursive: true); } } catch (_) {} } if (cbzPath != null && cbzPath.isNotEmpty) { try { await _public.deleteCbz(cbzPath); } catch (_) {} } } }