Files
cxyz/cxyz-android/lib/services/ntfy_notifier.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

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;
}