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,419 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
import 'cbz_store.dart';
|
||||
import 'ntfy_settings.dart';
|
||||
|
||||
const int _kMaxCbzAttachBytes = 15 * 1024 * 1024;
|
||||
const int _kPreviewMaxEdge = 720;
|
||||
const int _kPreviewMaxBytes = 250 * 1024;
|
||||
|
||||
/// Encode for HTTP headers (Dart rejects non-ASCII / CR/LF).
|
||||
/// ntfy decodes RFC 2047 (`=?UTF-8?B?…?=`).
|
||||
String encodeNtfyHeader(String value) {
|
||||
final flat = value.replaceAll(RegExp(r'[\r\n]+'), '\n').trim();
|
||||
if (flat.isEmpty) return flat;
|
||||
final needsEncode =
|
||||
flat.codeUnits.any((c) => c < 0x20 || c > 0x7e);
|
||||
if (!needsEncode) return flat;
|
||||
final b64 = base64.encode(utf8.encode(flat));
|
||||
return '=?UTF-8?B?$b64?=';
|
||||
}
|
||||
|
||||
class ChapterCompletedPayload {
|
||||
const ChapterCompletedPayload({
|
||||
required this.seriesTitle,
|
||||
required this.chapterLabel,
|
||||
required this.imageCount,
|
||||
required this.rawBytes,
|
||||
required this.cbzBytes,
|
||||
this.cbzPath,
|
||||
this.cacheDir,
|
||||
this.coverUrl,
|
||||
});
|
||||
|
||||
final String seriesTitle;
|
||||
final String chapterLabel;
|
||||
final int imageCount;
|
||||
final int rawBytes;
|
||||
final int cbzBytes;
|
||||
final String? cbzPath;
|
||||
final String? cacheDir;
|
||||
final String? coverUrl;
|
||||
}
|
||||
|
||||
/// Fire-and-forget ntfy publisher for download queue events.
|
||||
class NtfyNotifier {
|
||||
NtfyNotifier({Dio? dio, CbzStore? store})
|
||||
: _dio = dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: const Duration(seconds: 20),
|
||||
receiveTimeout: const Duration(seconds: 60),
|
||||
sendTimeout: const Duration(seconds: 120),
|
||||
validateStatus: (s) => s != null && s >= 200 && s < 300,
|
||||
),
|
||||
),
|
||||
_store = store ?? CbzStore();
|
||||
|
||||
final Dio _dio;
|
||||
final CbzStore _store;
|
||||
|
||||
static String formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
final kb = bytes / 1024;
|
||||
if (kb < 1024) return '${kb.toStringAsFixed(kb < 10 ? 1 : 0)} KB';
|
||||
final mb = kb / 1024;
|
||||
if (mb < 1024) return '${mb.toStringAsFixed(mb < 10 ? 1 : 0)} MB';
|
||||
final gb = mb / 1024;
|
||||
return '${gb.toStringAsFixed(2)} GB';
|
||||
}
|
||||
|
||||
static String completedTitle(String series, String chapter) =>
|
||||
'Downloaded · $series · $chapter';
|
||||
|
||||
static String failedTitle(String series, String chapter) =>
|
||||
'Download failed · $series · $chapter';
|
||||
|
||||
static String drainedTitle({required int ok, required int failed}) =>
|
||||
'Queue finished · $ok downloaded · $failed failed';
|
||||
|
||||
static String completedBody({
|
||||
required int imageCount,
|
||||
required int rawBytes,
|
||||
required int cbzBytes,
|
||||
String? note,
|
||||
}) {
|
||||
final saved = rawBytes > 0
|
||||
? (((rawBytes - cbzBytes) / rawBytes) * 100).clamp(0, 100).round()
|
||||
: 0;
|
||||
final lines = <String>[
|
||||
'Pages: $imageCount',
|
||||
'Images: ${formatBytes(rawBytes)}',
|
||||
'CBZ: ${formatBytes(cbzBytes)}${rawBytes > 0 ? ' (saved $saved%)' : ''}',
|
||||
];
|
||||
if (note != null && note.isNotEmpty) lines.add(note);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
Future<void> chapterCompleted(ChapterCompletedPayload payload) async {
|
||||
final settings = await NtfySettings.load();
|
||||
if (!settings.enabled) return;
|
||||
final url = settings.urlForCompleted;
|
||||
if (url == null) return;
|
||||
|
||||
final title = completedTitle(payload.seriesTitle, payload.chapterLabel);
|
||||
var note = '';
|
||||
Uint8List? attachBytes;
|
||||
String? filename;
|
||||
String? attachUrl;
|
||||
|
||||
switch (settings.attachmentMode) {
|
||||
case NtfyAttachmentMode.none:
|
||||
break;
|
||||
case NtfyAttachmentMode.cover:
|
||||
final cover = payload.coverUrl?.trim();
|
||||
if (cover == null || cover.isEmpty) {
|
||||
note = 'Attachment skipped (no cover URL)';
|
||||
} else {
|
||||
attachUrl = cover;
|
||||
}
|
||||
break;
|
||||
case NtfyAttachmentMode.firstImage:
|
||||
final preview = await _buildFirstPagePreview(payload.cacheDir);
|
||||
if (preview == null) {
|
||||
note = 'Preview resize failed';
|
||||
} else {
|
||||
attachBytes = preview;
|
||||
filename = 'preview.jpg';
|
||||
}
|
||||
break;
|
||||
case NtfyAttachmentMode.cbz:
|
||||
final path = payload.cbzPath;
|
||||
if (path == null || path.isEmpty) {
|
||||
note = 'Attachment skipped (no CBZ path)';
|
||||
} else if (payload.cbzBytes > _kMaxCbzAttachBytes) {
|
||||
note = 'Attachment skipped (15 MB limit)';
|
||||
} else {
|
||||
try {
|
||||
attachBytes = await _store.readCbzBytes(path);
|
||||
if (attachBytes.length > _kMaxCbzAttachBytes) {
|
||||
attachBytes = null;
|
||||
note = 'Attachment skipped (15 MB limit)';
|
||||
} else {
|
||||
filename = _cbzFilename(payload.chapterLabel);
|
||||
}
|
||||
} catch (_) {
|
||||
note = 'Attachment skipped (read failed)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final body = completedBody(
|
||||
imageCount: payload.imageCount,
|
||||
rawBytes: payload.rawBytes,
|
||||
cbzBytes: payload.cbzBytes,
|
||||
note: note.isEmpty ? null : note,
|
||||
);
|
||||
|
||||
try {
|
||||
if (attachBytes != null && filename != null) {
|
||||
await _putAttachment(
|
||||
url: url,
|
||||
title: title,
|
||||
message: body,
|
||||
tags: 'white_check_mark',
|
||||
priority: '3',
|
||||
filename: filename,
|
||||
bytes: attachBytes,
|
||||
token: settings.authToken,
|
||||
);
|
||||
} else if (attachUrl != null) {
|
||||
await _postText(
|
||||
url: url,
|
||||
title: title,
|
||||
message: body,
|
||||
tags: 'white_check_mark',
|
||||
priority: '3',
|
||||
token: settings.authToken,
|
||||
attachUrl: attachUrl,
|
||||
);
|
||||
} else {
|
||||
await _postText(
|
||||
url: url,
|
||||
title: title,
|
||||
message: body,
|
||||
tags: 'white_check_mark',
|
||||
priority: '3',
|
||||
token: settings.authToken,
|
||||
);
|
||||
}
|
||||
} catch (e, st) {
|
||||
debugPrint('ntfy chapterCompleted failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> jobFailed({
|
||||
required String seriesTitle,
|
||||
required String chapterLabel,
|
||||
required String error,
|
||||
}) async {
|
||||
final settings = await NtfySettings.load();
|
||||
if (!settings.enabled) return;
|
||||
final url = settings.urlForFailed;
|
||||
if (url == null) return;
|
||||
|
||||
final trimmed = error.trim();
|
||||
final snippet =
|
||||
trimmed.length > 500 ? '${trimmed.substring(0, 500)}…' : trimmed;
|
||||
try {
|
||||
await _postText(
|
||||
url: url,
|
||||
title: failedTitle(seriesTitle, chapterLabel),
|
||||
message: snippet.isEmpty ? 'Unknown error' : snippet,
|
||||
tags: 'x',
|
||||
priority: '4',
|
||||
token: settings.authToken,
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint('ntfy jobFailed failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> queueDrained({
|
||||
required int ok,
|
||||
required int failed,
|
||||
Duration? duration,
|
||||
}) async {
|
||||
final settings = await NtfySettings.load();
|
||||
if (!settings.enabled) return;
|
||||
final url = settings.urlForDrained;
|
||||
if (url == null) return;
|
||||
|
||||
final lines = <String>[];
|
||||
if (duration != null) {
|
||||
lines.add('Finished in ${_formatDuration(duration)}');
|
||||
}
|
||||
lines.add('$ok downloaded · $failed failed');
|
||||
try {
|
||||
await _postText(
|
||||
url: url,
|
||||
title: drainedTitle(ok: ok, failed: failed),
|
||||
message: lines.join('\n'),
|
||||
tags: 'inbox_tray',
|
||||
priority: '3',
|
||||
token: settings.authToken,
|
||||
);
|
||||
} catch (e, st) {
|
||||
debugPrint('ntfy queueDrained failed: $e\n$st');
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a sample notification to [url] (for Settings Test buttons).
|
||||
Future<void> testTopic(String url, {required String kind}) async {
|
||||
final settings = await NtfySettings.load();
|
||||
final trimmed = url.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
throw StateError('URL is empty');
|
||||
}
|
||||
switch (kind) {
|
||||
case 'completed':
|
||||
await _postText(
|
||||
url: trimmed,
|
||||
title: completedTitle('Sample Series', 'Chapter 1'),
|
||||
message: completedBody(
|
||||
imageCount: 12,
|
||||
rawBytes: 5 * 1024 * 1024,
|
||||
cbzBytes: 4 * 1024 * 1024,
|
||||
note: 'Test notification (${settings.attachmentMode.label})',
|
||||
),
|
||||
tags: 'white_check_mark',
|
||||
priority: '3',
|
||||
token: settings.authToken,
|
||||
);
|
||||
case 'failed':
|
||||
await _postText(
|
||||
url: trimmed,
|
||||
title: failedTitle('Sample Series', 'Chapter 2'),
|
||||
message: 'Sample error for testing',
|
||||
tags: 'x',
|
||||
priority: '4',
|
||||
token: settings.authToken,
|
||||
);
|
||||
case 'drained':
|
||||
await _postText(
|
||||
url: trimmed,
|
||||
title: drainedTitle(ok: 3, failed: 1),
|
||||
message: 'Finished in 1m 5s\n3 downloaded · 1 failed',
|
||||
tags: 'inbox_tray',
|
||||
priority: '3',
|
||||
token: settings.authToken,
|
||||
);
|
||||
default:
|
||||
throw StateError('Unknown test kind: $kind');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _postText({
|
||||
required String url,
|
||||
required String title,
|
||||
required String message,
|
||||
required String tags,
|
||||
required String priority,
|
||||
required String token,
|
||||
String? attachUrl,
|
||||
}) async {
|
||||
await _dio.post<void>(
|
||||
url,
|
||||
data: message,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Title': encodeNtfyHeader(title),
|
||||
'Tags': tags,
|
||||
'Priority': priority,
|
||||
if (attachUrl != null && attachUrl.isNotEmpty)
|
||||
'Attach': encodeNtfyHeader(attachUrl),
|
||||
if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}',
|
||||
},
|
||||
contentType: 'text/plain; charset=utf-8',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _putAttachment({
|
||||
required String url,
|
||||
required String title,
|
||||
required String message,
|
||||
required String tags,
|
||||
required String priority,
|
||||
required String filename,
|
||||
required Uint8List bytes,
|
||||
required String token,
|
||||
}) async {
|
||||
await _dio.put<void>(
|
||||
url,
|
||||
data: bytes,
|
||||
options: Options(
|
||||
headers: {
|
||||
'Title': encodeNtfyHeader(title),
|
||||
// Multiline body must be header-safe (RFC 2047), not raw newlines.
|
||||
'Message': encodeNtfyHeader(message),
|
||||
'Tags': tags,
|
||||
'Priority': priority,
|
||||
'Filename': encodeNtfyHeader(filename),
|
||||
if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}',
|
||||
},
|
||||
contentType: 'application/octet-stream',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List?> _buildFirstPagePreview(String? cacheDir) async {
|
||||
if (cacheDir == null || cacheDir.isEmpty) return null;
|
||||
final dir = Directory(cacheDir);
|
||||
if (!await dir.exists()) return null;
|
||||
final files = await dir
|
||||
.list()
|
||||
.where((e) => e is File)
|
||||
.cast<File>()
|
||||
.toList();
|
||||
files.sort((a, b) => p.basename(a.path).compareTo(p.basename(b.path)));
|
||||
if (files.isEmpty) return null;
|
||||
final bytes = await files.first.readAsBytes();
|
||||
return compute(_resizePreviewIsolate, bytes);
|
||||
}
|
||||
|
||||
static String _cbzFilename(String chapterLabel) {
|
||||
final stem = chapterLabel
|
||||
.trim()
|
||||
.replaceAll(RegExp(r'[^a-zA-Z0-9._-]+'), '_')
|
||||
.replaceAll(RegExp(r'^_+|_+$'), '');
|
||||
final name = stem.isEmpty ? 'chapter' : stem;
|
||||
return name.toLowerCase().endsWith('.cbz') ? name : '$name.cbz';
|
||||
}
|
||||
|
||||
static String _formatDuration(Duration d) {
|
||||
final s = d.inSeconds;
|
||||
if (s < 60) return '${s}s';
|
||||
final m = s ~/ 60;
|
||||
final rem = s % 60;
|
||||
if (m < 60) return rem == 0 ? '${m}m' : '${m}m ${rem}s';
|
||||
final h = m ~/ 60;
|
||||
final rm = m % 60;
|
||||
return rm == 0 ? '${h}h' : '${h}h ${rm}m';
|
||||
}
|
||||
}
|
||||
|
||||
/// Isolate entry: decode → max edge 720 → JPEG ≤ 250 KB.
|
||||
Uint8List? _resizePreviewIsolate(Uint8List input) {
|
||||
return resizePreviewForTest(input);
|
||||
}
|
||||
|
||||
/// Visible for unit tests.
|
||||
Uint8List? resizePreviewForTest(Uint8List input) {
|
||||
final decoded = img.decodeImage(input);
|
||||
if (decoded == null) return null;
|
||||
var image = decoded;
|
||||
final longest = image.width > image.height ? image.width : image.height;
|
||||
if (longest > _kPreviewMaxEdge) {
|
||||
if (image.width >= image.height) {
|
||||
image = img.copyResize(image, width: _kPreviewMaxEdge);
|
||||
} else {
|
||||
image = img.copyResize(image, height: _kPreviewMaxEdge);
|
||||
}
|
||||
}
|
||||
var quality = 75;
|
||||
Uint8List? best;
|
||||
while (quality >= 40) {
|
||||
final encoded = Uint8List.fromList(img.encodeJpg(image, quality: quality));
|
||||
best = encoded;
|
||||
if (encoded.length <= _kPreviewMaxBytes) return encoded;
|
||||
quality -= 15;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
Reference in New Issue
Block a user