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.
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
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 (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user