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:
2026-08-29 17:50:31 +07:00
parent 6e7f1ced35
commit a7c6058540
105 changed files with 16721 additions and 275 deletions
+388
View File
@@ -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 (_) {}
}
}
}