- 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.
420 lines
12 KiB
Dart
420 lines
12 KiB
Dart
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;
|
|
}
|