Files
cxyz/cxyz-android/lib/services/cbz_store.dart
T
nsrb a7c6058540 Add cxyz Android project structure and initial configuration
- Introduced a new Flutter project for the cxyz Android manga/comic reader, including essential files such as pubspec.yaml, AndroidManifest.xml, and Gradle configuration.
- Added .gitignore files to exclude unnecessary files and directories for both the main project and Android-specific files.
- Implemented initial README.md with project description, features, setup instructions, and API details for ComicLand, MangaRead, and MangaTown.
- Configured analysis options and dependencies in pubspec.yaml to support Flutter development.
- Established a pnpm workspace and lockfile for dependency management.
- Included various Android resources such as icons, styles, and launch backgrounds for a complete application setup.
2026-08-29 17:50:31 +07:00

389 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<int> sdkInt() async {
if (_sdkInt != null) return _sdkInt!;
try {
final v = await _channel.invokeMethod<int>('sdkInt');
_sdkInt = v ?? 30;
} catch (_) {
_sdkInt = 30;
}
return _sdkInt!;
}
/// Request WRITE_EXTERNAL_STORAGE on API 26–28 only.
Future<void> 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<String> saveCbzFromPath({
required String seriesDirName,
required String displayName,
required String sourcePath,
}) async {
await ensureWritePermission();
final relativeDir = 'Comics/$seriesDirName';
final uri = await _channel.invokeMethod<String>('saveCbzFromPath', {
'relativeDir': relativeDir,
'displayName': displayName,
'sourcePath': sourcePath,
});
if (uri == null || uri.isEmpty) {
throw StateError('Native saveCbzFromPath returned empty uri');
}
return uri;
}
Future<Uint8List> 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<String>('readCbzToPath', {
'uri': uriOrPath,
'destPath': tmp.path,
});
return await tmp.readAsBytes();
} finally {
try {
await tmp.delete();
} catch (_) {}
}
}
Future<void> deleteCbz(String uriOrPath) async {
if (uriOrPath.startsWith('content://')) {
await _channel.invokeMethod<bool>('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<Directory> 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<Directory> 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<String> 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 = <File>[];
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<List<int>>(
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<List<String>> 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 = <String>[];
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<int>, flush: true);
out.add(outPath);
}
out.sort();
return out;
}
Future<List<String>> extractCbzToCache(String cbzPath, String cacheDirPath) async {
final bytes = await readCbzBytes(cbzPath);
return extractBytesToCache(bytes, cacheDirPath);
}
Future<Uint8List> readCbzBytes(String cbzPath) async {
if (cbzPath.startsWith('content://')) {
return _public.readCbz(cbzPath);
}
return File(cbzPath).readAsBytes();
}
Future<List<String>> 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<void> 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 (_) {}
}
}
}