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 (_) {}
}
}
}
@@ -0,0 +1,443 @@
import 'dart:async';
import 'dart:collection';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import '../data/database.dart';
import '../sources/registry.dart';
import '../sources/source.dart';
import 'cbz_store.dart';
import 'ntfy_notifier.dart';
class DownloadQueue extends ChangeNotifier {
DownloadQueue({
required AppDatabase db,
required SourceRegistry sources,
required this._store,
NtfyNotifier? ntfy,
}) : _db = db,
_sources = sources,
_ntfy = ntfy ?? NtfyNotifier(store: _store);
final AppDatabase _db;
final SourceRegistry _sources;
final CbzStore _store;
final NtfyNotifier _ntfy;
final Queue<int> _chapterIds = Queue<int>();
final Set<int> _cancelled = {};
/// chapterId → jobId for open jobs created at enqueue.
final Map<int, int> _jobByChapter = {};
bool _running = false;
int? _activeChapterId;
double _progress = 0;
String _message = '';
int _sessionOk = 0;
int _sessionFailed = 0;
bool _sessionHadWork = false;
DateTime? _sessionStartedAt;
final List<Future<void>> _pendingNtfy = [];
int? get activeChapterId => _activeChapterId;
double get progress => _progress;
String get message => _message;
bool get isRunning => _running;
int get queuedCount => _chapterIds.length;
String _label(String seriesTitle, String chapterLabel) =>
'$seriesTitle — $chapterLabel';
Future<void> enqueueChapters(Iterable<int> chapterIds) async {
for (final id in chapterIds) {
if (_chapterIds.contains(id) || id == _activeChapterId) continue;
_cancelled.remove(id);
final chapter = await _db.getChapter(id);
if (chapter == null) continue;
final title = await _db.getTitle(chapter.titleId);
if (title == null) continue;
_chapterIds.add(id);
await (_db.update(_db.chapters)..where((c) => c.id.equals(id))).write(
const ChaptersCompanion(
status: Value('pending'),
error: Value(null),
),
);
final existing = await _db.findOpenJobForChapter(id);
late final int jobId;
if (existing != null) {
jobId = existing.id;
await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId))).write(
JobsCompanion(
titleId: Value(title.id),
chapterId: Value(id),
status: const Value('queued'),
progress: const Value(0),
message: Value(_label(title.title, chapter.label)),
finishedAt: const Value(null),
),
);
} else {
jobId = await _db.into(_db.jobs).insert(
JobsCompanion.insert(
type: 'download',
titleId: Value(title.id),
chapterId: Value(id),
status: const Value('queued'),
message: Value(_label(title.title, chapter.label)),
),
);
}
_jobByChapter[id] = jobId;
}
notifyListeners();
unawaited(_pump());
}
/// Cancel / dismiss a queue row for [chapterId].
///
/// Always closes open jobs. Only resets chapter status when it was still
/// pending/downloading/failed — never wipes an already-downloaded chapter.
Future<void> cancelChapter(int chapterId) async {
_cancelled.add(chapterId);
_chapterIds.removeWhere((id) => id == chapterId);
_jobByChapter.remove(chapterId);
await (_db.update(_db.jobs)
..where(
(j) =>
j.chapterId.equals(chapterId) &
j.status.isIn(['queued', 'running', 'failed']),
))
.write(
JobsCompanion(
status: const Value('done'),
message: const Value('Cancelled'),
finishedAt: Value(DateTime.now()),
),
);
final chapter = await _db.getChapter(chapterId);
if (chapter != null && chapter.status != 'downloaded') {
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId)))
.write(
const ChaptersCompanion(status: Value('pending'), error: Value(null)),
);
}
notifyListeners();
}
Future<void> retryChapter(int chapterId) async {
await enqueueChapters([chapterId]);
}
/// Remove offline download: delete CBZ + cache, reset chapter to pending.
Future<void> deleteDownload(int chapterId) async {
final chapter = await _db.getChapter(chapterId);
if (chapter == null) return;
await _store.deleteDownload(
cbzPath: chapter.cbzPath,
cacheDir: chapter.cacheDir,
);
await _closeOpenJobs(chapterId, message: 'Deleted');
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
const ChaptersCompanion(
status: Value('pending'),
cbzPath: Value(null),
cacheDir: Value(null),
downloadedAt: Value(null),
error: Value(null),
),
);
}
/// Mark all open jobs for [chapterId] as done (dismisses Queue rows).
Future<void> _closeOpenJobs(
int chapterId, {
required String message,
double? progress,
}) async {
await (_db.update(_db.jobs)
..where(
(j) =>
j.chapterId.equals(chapterId) &
j.status.isIn(['queued', 'running', 'failed']),
))
.write(
JobsCompanion(
status: const Value('done'),
progress: progress != null ? Value(progress) : const Value.absent(),
message: Value(message),
finishedAt: Value(DateTime.now()),
),
);
}
/// Drop stale queue rows for chapters that are already downloaded.
Future<void> reconcileStaleJobs() async {
final open = await (_db.select(_db.jobs)
..where((j) => j.status.isIn(['queued', 'running', 'failed'])))
.get();
for (final job in open) {
final chapterId = job.chapterId;
if (chapterId == null) continue;
final chapter = await _db.getChapter(chapterId);
if (chapter?.status == 'downloaded') {
await _closeOpenJobs(chapterId, message: chapter!.label, progress: 1);
}
}
}
Future<void> _pump() async {
if (_running) return;
_running = true;
_sessionOk = 0;
_sessionFailed = 0;
_sessionHadWork = false;
_sessionStartedAt = DateTime.now();
_pendingNtfy.clear();
notifyListeners();
try {
while (_chapterIds.isNotEmpty) {
final chapterId = _chapterIds.removeFirst();
if (_cancelled.contains(chapterId)) {
await _closeOpenJobs(chapterId, message: 'Cancelled');
final chapter = await _db.getChapter(chapterId);
if (chapter != null && chapter.status != 'downloaded') {
await (_db.update(_db.chapters)
..where((c) => c.id.equals(chapterId)))
.write(
const ChaptersCompanion(
status: Value('pending'),
error: Value(null),
),
);
}
_jobByChapter.remove(chapterId);
continue;
}
_sessionHadWork = true;
await _downloadOne(chapterId);
}
} finally {
final hadWork = _sessionHadWork;
final ok = _sessionOk;
final failed = _sessionFailed;
final started = _sessionStartedAt;
final pending = List<Future<void>>.from(_pendingNtfy);
_pendingNtfy.clear();
_running = false;
_activeChapterId = null;
_progress = 0;
_message = '';
_sessionOk = 0;
_sessionFailed = 0;
_sessionHadWork = false;
_sessionStartedAt = null;
notifyListeners();
if (hadWork) {
// Per-chapter completed/failed webhooks first, then queue-empty.
await Future.wait(pending);
final duration =
started == null ? null : DateTime.now().difference(started);
await _ntfy.queueDrained(ok: ok, failed: failed, duration: duration);
}
}
}
Future<void> _downloadOne(int chapterId) async {
_activeChapterId = chapterId;
_progress = 0;
final chapter = await _db.getChapter(chapterId);
if (chapter == null) {
await _closeOpenJobs(chapterId, message: 'Chapter missing');
return;
}
final title = await _db.getTitle(chapter.titleId);
if (title == null) {
await _closeOpenJobs(chapterId, message: 'Series missing');
return;
}
final label = _label(title.title, chapter.label);
_message = label;
notifyListeners();
var jobId = _jobByChapter[chapterId];
if (jobId == null) {
final existing = await _db.findOpenJobForChapter(chapterId);
if (existing != null) {
jobId = existing.id;
} else {
jobId = await _db.into(_db.jobs).insert(
JobsCompanion.insert(
type: 'download',
titleId: Value(title.id),
chapterId: Value(chapterId),
status: const Value('running'),
message: Value(label),
),
);
}
_jobByChapter[chapterId] = jobId;
}
await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write(
JobsCompanion(
titleId: Value(title.id),
status: const Value('running'),
progress: const Value(0),
message: Value(label),
finishedAt: const Value(null),
),
);
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
const ChaptersCompanion(status: Value('downloading'), error: Value(null)),
);
try {
final source = _sources.get(SourceId.values.byName(title.source));
final link = ChapterLink(
url: chapter.url,
title: chapter.label,
chapterKey: chapter.chapterKey,
chapterNumber: chapter.chapterNumber,
);
final result = await _store.downloadChapter(
source: source,
comicTitle: title.title,
chapter: link,
onProgress: (p, _) {
_progress = p;
_message = label;
notifyListeners();
unawaited(
(_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write(
JobsCompanion(progress: Value(p), message: Value(label)),
),
);
},
isCancelled: () => _cancelled.contains(chapterId),
);
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
ChaptersCompanion(
status: const Value('downloaded'),
cbzPath: Value(result.cbzPath),
cacheDir: Value(result.cacheDir),
downloadedAt: Value(DateTime.now()),
error: const Value(null),
),
);
// Close every open job for this chapter (covers stale duplicates).
await _closeOpenJobs(chapterId, message: label, progress: 1);
_sessionOk++;
_pendingNtfy.add(
_ntfy.chapterCompleted(
ChapterCompletedPayload(
seriesTitle: title.title,
chapterLabel: chapter.label,
imageCount: result.imageCount,
rawBytes: result.rawBytes,
cbzBytes: result.cbzBytes,
cbzPath: result.cbzPath,
cacheDir: result.cacheDir,
coverUrl: title.coverUrl,
),
),
);
} catch (e) {
final cancelled = _cancelled.contains(chapterId);
await (_db.update(_db.chapters)..where((c) => c.id.equals(chapterId))).write(
ChaptersCompanion(
status: Value(cancelled ? 'pending' : 'failed'),
error: Value(cancelled ? null : e.toString()),
),
);
await (_db.update(_db.jobs)..where((j) => j.id.equals(jobId!))).write(
JobsCompanion(
status: Value(cancelled ? 'done' : 'failed'),
message: Value(cancelled ? 'Cancelled' : label),
finishedAt: Value(DateTime.now()),
),
);
if (!cancelled) {
_sessionFailed++;
_pendingNtfy.add(
_ntfy.jobFailed(
seriesTitle: title.title,
chapterLabel: chapter.label,
error: e.toString(),
),
);
}
} finally {
_cancelled.remove(chapterId);
_jobByChapter.remove(chapterId);
}
}
/// Upsert series into library from a remote [SeriesDetail].
Future<int> upsertSeries(SeriesDetail series) async {
final existing = await _db.findTitleByUrl(series.listingUrl);
late final int titleId;
if (existing == null) {
titleId = await _db.into(_db.titles).insert(
TitlesCompanion.insert(
source: series.source.id,
url: series.listingUrl,
slug: series.slug,
title: series.title,
coverUrl: Value(series.coverUrl),
description: Value(series.description),
lastCheckedAt: Value(DateTime.now()),
),
);
} else {
titleId = existing.id;
await (_db.update(_db.titles)..where((t) => t.id.equals(titleId))).write(
TitlesCompanion(
title: Value(series.title),
coverUrl: Value(series.coverUrl),
description: Value(series.description),
lastCheckedAt: Value(DateTime.now()),
),
);
}
for (final ch in series.chapters) {
final rows = await (_db.select(_db.chapters)
..where(
(c) =>
c.titleId.equals(titleId) & c.chapterKey.equals(ch.chapterKey),
))
.get();
if (rows.isEmpty) {
await _db.into(_db.chapters).insert(
ChaptersCompanion.insert(
titleId: titleId,
chapterKey: ch.chapterKey,
chapterNumber: Value(ch.chapterNumber),
label: ch.title,
url: ch.url,
),
);
} else {
await (_db.update(_db.chapters)..where((c) => c.id.equals(rows.first.id)))
.write(
ChaptersCompanion(
label: Value(ch.title),
url: Value(ch.url),
chapterNumber: Value(ch.chapterNumber),
),
);
}
}
return titleId;
}
}
@@ -0,0 +1,31 @@
import 'package:flutter/painting.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
/// Cache manager that attaches ComicLand CDN Referer/Origin headers.
class HeaderCacheManager extends CacheManager {
HeaderCacheManager(this.headers)
: super(
Config(
'cxyzHeaderCache',
fileService: _HeaderHttpFileService(headers),
),
);
final Map<String, String> headers;
}
class _HeaderHttpFileService extends HttpFileService {
_HeaderHttpFileService(this.headers);
final Map<String, String> headers;
@override
Future<FileServiceResponse> get(String url, {Map<String, String>? headers}) {
final merged = <String, String>{...this.headers, ...?headers};
return super.get(url, headers: merged);
}
}
ImageProvider headerNetworkImage(String url, Map<String, String> headers) {
return NetworkImage(url, headers: headers);
}
@@ -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;
}
@@ -0,0 +1,100 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// What to attach on chapter-completed ntfy notifications.
enum NtfyAttachmentMode {
none,
cover,
firstImage,
cbz;
String get storageValue => name;
static NtfyAttachmentMode fromStorage(String? raw) {
for (final m in NtfyAttachmentMode.values) {
if (m.name == raw) return m;
}
return NtfyAttachmentMode.none;
}
String get label {
switch (this) {
case NtfyAttachmentMode.none:
return 'None';
case NtfyAttachmentMode.cover:
return 'Series cover';
case NtfyAttachmentMode.firstImage:
return 'First page';
case NtfyAttachmentMode.cbz:
return 'CBZ file';
}
}
}
class NtfySettings {
static const _kEnabled = 'ntfy_enabled';
static const _kCompletedUrl = 'ntfy_completed_url';
static const _kFailedUrl = 'ntfy_failed_url';
static const _kDrainedUrl = 'ntfy_drained_url';
static const _kAttachment = 'ntfy_attachment_mode';
static const _tokenKey = 'ntfy_auth_token';
static const _secure = FlutterSecureStorage();
final bool enabled;
final String completedUrl;
final String failedUrl;
final String drainedUrl;
final NtfyAttachmentMode attachmentMode;
final String authToken;
const NtfySettings({
required this.enabled,
required this.completedUrl,
required this.failedUrl,
required this.drainedUrl,
required this.attachmentMode,
required this.authToken,
});
String? get urlForCompleted => _nonEmpty(completedUrl);
String? get urlForFailed => _nonEmpty(failedUrl);
String? get urlForDrained => _nonEmpty(drainedUrl);
static String? _nonEmpty(String raw) {
final t = raw.trim();
return t.isEmpty ? null : t;
}
static Future<NtfySettings> load() async {
final prefs = await SharedPreferences.getInstance();
final token = await _secure.read(key: _tokenKey) ?? '';
return NtfySettings(
enabled: prefs.getBool(_kEnabled) ?? false,
completedUrl: prefs.getString(_kCompletedUrl) ?? '',
failedUrl: prefs.getString(_kFailedUrl) ?? '',
drainedUrl: prefs.getString(_kDrainedUrl) ?? '',
attachmentMode: NtfyAttachmentMode.fromStorage(
prefs.getString(_kAttachment),
),
authToken: token,
);
}
static Future<void> save({
required bool enabled,
required String completedUrl,
required String failedUrl,
required String drainedUrl,
required NtfyAttachmentMode attachmentMode,
required String authToken,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kEnabled, enabled);
await prefs.setString(_kCompletedUrl, completedUrl.trim());
await prefs.setString(_kFailedUrl, failedUrl.trim());
await prefs.setString(_kDrainedUrl, drainedUrl.trim());
await prefs.setString(_kAttachment, attachmentMode.storageValue);
await _secure.write(key: _tokenKey, value: authToken);
}
}
+16
View File
@@ -0,0 +1,16 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Privacy mode: hide/blur comic covers in lists.
class PeekMode {
static const prefKey = 'peek_mode_enabled';
static Future<bool> load() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(prefKey) ?? false;
}
static Future<void> save(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(prefKey, enabled);
}
}
@@ -0,0 +1,39 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Vertical continuous scroll vs horizontal page turns.
enum ReaderMode {
webtoon,
paged;
String get storageValue => this == ReaderMode.paged ? 'paged' : 'webtoon';
static ReaderMode fromStorage(String? raw) {
return raw == 'paged' ? ReaderMode.paged : ReaderMode.webtoon;
}
}
/// Global / per-title paged-reader tap/swipe direction.
enum ReadingDirection {
ltr,
rtl;
String get storageValue => name;
static ReadingDirection fromStorage(String? raw) {
return raw == 'rtl' ? ReadingDirection.rtl : ReadingDirection.ltr;
}
}
class ReaderDirection {
static const prefKey = 'reader_reading_direction';
static Future<ReadingDirection> load() async {
final prefs = await SharedPreferences.getInstance();
return ReadingDirection.fromStorage(prefs.getString(prefKey));
}
static Future<void> save(ReadingDirection direction) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(prefKey, direction.storageValue);
}
}
@@ -0,0 +1,32 @@
import 'package:shared_preferences/shared_preferences.dart';
/// Layout zoom for the in-app reader (`pageWidth = screenWidth * scale`).
class ReaderZoom {
static const prefKey = 'reader_zoom_scale';
static const double min = 0.4;
static const double max = 1.5;
static const double step = 0.1;
static const double defaultScale = 1.0;
static double clamp(double value) {
final stepped = (value / step).round() * step;
return stepped.clamp(min, max);
}
static Future<double> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getDouble(prefKey);
if (raw == null) return defaultScale;
return clamp(raw);
}
static Future<void> save(double scale) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(prefKey, clamp(scale));
}
static String percentLabel(double scale) {
final pct = (clamp(scale) * 100).round();
return '$pct%';
}
}
@@ -0,0 +1,53 @@
import '../sources/source.dart';
import 'reader_direction.dart';
/// Built-in reader prefs per catalog source (used when a title has no override).
class SourceReaderDefaults {
const SourceReaderDefaults._();
/// ComicLand → vertical webtoon; MangaRead / MangaTown → horizontal paged.
static ReaderMode modeFor(String sourceId) {
switch (SourceId.tryParse(sourceId)) {
case SourceId.mangaread:
case SourceId.mangatown:
return ReaderMode.paged;
case SourceId.comicland:
case null:
return ReaderMode.webtoon;
}
}
/// MangaRead / MangaTown → RTL; ComicLand falls back to global Settings.
static ReadingDirection? directionFor(String sourceId) {
switch (SourceId.tryParse(sourceId)) {
case SourceId.mangaread:
case SourceId.mangatown:
return ReadingDirection.rtl;
case SourceId.comicland:
case null:
return null;
}
}
/// Resolve stored title mode, else [modeFor].
static ReaderMode resolveMode(String sourceId, String? storedMode) {
final raw = storedMode?.trim() ?? '';
if (raw == 'paged' || raw == 'webtoon') {
return ReaderMode.fromStorage(raw);
}
return modeFor(sourceId);
}
/// Resolve stored title direction, else [directionFor], else [globalFallback].
static ReadingDirection resolveDirection(
String sourceId,
String? storedDirection, {
required ReadingDirection globalFallback,
}) {
final raw = storedDirection?.trim() ?? '';
if (raw == 'ltr' || raw == 'rtl') {
return ReadingDirection.fromStorage(raw);
}
return directionFor(sourceId) ?? globalFallback;
}
}
@@ -0,0 +1,92 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
class WebDavSettings {
static const _kEnabled = 'webdav_enabled';
static const _kUrl = 'webdav_url';
static const _kUsername = 'webdav_username';
static const _kPath = 'webdav_path';
static const _kLastSync = 'webdav_last_sync';
static const _kLastError = 'webdav_last_error';
static const _kLastStatus = 'webdav_last_status';
static const _passwordKey = 'webdav_password';
static const defaultPath = '/cxyz/progress.json';
static const _secure = FlutterSecureStorage();
final bool enabled;
final String url;
final String username;
final String password;
final String path;
final DateTime? lastSyncAt;
final String? lastError;
final String? lastStatus;
const WebDavSettings({
required this.enabled,
required this.url,
required this.username,
required this.password,
required this.path,
this.lastSyncAt,
this.lastError,
this.lastStatus,
});
bool get isConfigured =>
url.trim().isNotEmpty && username.trim().isNotEmpty;
static Future<WebDavSettings> load() async {
final prefs = await SharedPreferences.getInstance();
final password = await _secure.read(key: _passwordKey) ?? '';
final lastSyncRaw = prefs.getString(_kLastSync);
return WebDavSettings(
enabled: prefs.getBool(_kEnabled) ?? false,
url: prefs.getString(_kUrl) ?? '',
username: prefs.getString(_kUsername) ?? '',
password: password,
path: prefs.getString(_kPath) ?? defaultPath,
lastSyncAt:
lastSyncRaw == null ? null : DateTime.tryParse(lastSyncRaw),
lastError: prefs.getString(_kLastError),
lastStatus: prefs.getString(_kLastStatus),
);
}
static Future<void> save({
required bool enabled,
required String url,
required String username,
required String password,
required String path,
}) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kEnabled, enabled);
await prefs.setString(_kUrl, url.trim());
await prefs.setString(_kUsername, username.trim());
await prefs.setString(
_kPath,
path.trim().isEmpty ? defaultPath : path.trim(),
);
await _secure.write(key: _passwordKey, value: password);
}
static Future<void> setSyncResult({
String? status,
String? error,
DateTime? syncedAt,
}) async {
final prefs = await SharedPreferences.getInstance();
if (status != null) await prefs.setString(_kLastStatus, status);
if (error != null) {
await prefs.setString(_kLastError, error);
} else {
await prefs.remove(_kLastError);
}
if (syncedAt != null) {
await prefs.setString(_kLastSync, syncedAt.toUtc().toIso8601String());
}
}
}
+474
View File
@@ -0,0 +1,474 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart';
import '../data/database.dart';
import 'webdav_settings.dart';
class WebDavSyncService {
WebDavSyncService(this._db);
final AppDatabase _db;
bool _busy = false;
Timer? _debounce;
void scheduleSync() {
_debounce?.cancel();
_debounce = Timer(const Duration(seconds: 10), () {
unawaited(sync());
});
}
Future<String> testConnection() async {
final settings = await WebDavSettings.load();
if (!settings.isConfigured) {
throw StateError('Enter WebDAV URL and username first');
}
final dio = _dio(settings);
final url = _fileUrl(settings);
try {
final res = await dio.request(
url,
options: Options(
method: 'PROPFIND',
headers: {'Depth': '0'},
validateStatus: (s) => s != null && s < 500,
),
);
// Some hosts reject PROPFIND; try GET as fallback.
if (res.statusCode == 404 || res.statusCode == 405 || res.statusCode == 501) {
final get = await dio.get<List<int>>(
url,
options: Options(
responseType: ResponseType.bytes,
validateStatus: (s) => s != null && (s == 200 || s == 404),
),
);
if (get.statusCode == 200 || get.statusCode == 404) {
return 'Connection OK';
}
throw StateError('HTTP ${get.statusCode}');
}
if (res.statusCode != null && res.statusCode! >= 200 && res.statusCode! < 300) {
return 'Connection OK';
}
if (res.statusCode == 401 || res.statusCode == 403) {
throw StateError('Auth failed (HTTP ${res.statusCode})');
}
// Parent exists even if file missing
if (res.statusCode == 404) return 'Connection OK (file not created yet)';
throw StateError('HTTP ${res.statusCode}');
} on DioException catch (e) {
throw StateError(e.message ?? e.toString());
}
}
Future<void> sync() async {
if (_busy) return;
final settings = await WebDavSettings.load();
if (!settings.enabled || !settings.isConfigured) return;
_busy = true;
try {
await _syncOnce(settings);
await WebDavSettings.setSyncResult(
status: 'Synced',
error: null,
syncedAt: DateTime.now(),
);
} catch (e) {
await WebDavSettings.setSyncResult(
status: 'Failed',
error: e.toString(),
);
rethrow;
} finally {
_busy = false;
}
}
Future<void> _syncOnce(WebDavSettings settings, {int attempt = 0}) async {
final dio = _dio(settings);
final url = _fileUrl(settings);
String? etag;
Map<String, dynamic> remote = {
'updatedAt': null,
'titles': <dynamic>[],
'chapters': <dynamic>[],
};
final get = await dio.get<List<int>>(
url,
options: Options(
responseType: ResponseType.bytes,
validateStatus: (s) => s != null && (s == 200 || s == 404),
),
);
if (get.statusCode == 200 && get.data != null) {
etag = get.headers.value('etag');
final text = utf8.decode(get.data!);
if (text.trim().isNotEmpty) {
remote = jsonDecode(text) as Map<String, dynamic>;
}
}
final localBundle = await _exportLocal();
final merged = _merge(remote, localBundle);
await _applyRemote(merged);
final body = utf8.encode(const JsonEncoder.withIndent(' ').convert(merged));
final headers = <String, dynamic>{
'Content-Type': 'application/json',
};
if (etag != null && etag.isNotEmpty) {
headers['If-Match'] = etag;
} else if (get.statusCode == 404) {
// Create only if absent
headers['If-None-Match'] = '*';
}
try {
final put = await dio.put<List<int>>(
url,
data: body,
options: Options(
headers: headers,
validateStatus: (s) =>
s != null && ((s >= 200 && s < 300) || s == 412),
),
);
if (put.statusCode == 412) {
if (attempt >= 2) {
throw StateError('Sync conflict: too many retries');
}
await _syncOnce(settings, attempt: attempt + 1);
}
} on DioException catch (e) {
// Weak hosts may ignore precondition; retry once without If-Match.
if (attempt == 0 && etag != null) {
await dio.put(
url,
data: body,
options: Options(headers: {'Content-Type': 'application/json'}),
);
} else {
throw StateError(e.message ?? e.toString());
}
}
}
Dio _dio(WebDavSettings settings) {
return Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 20),
receiveTimeout: const Duration(seconds: 30),
headers: {
'Authorization':
'Basic ${base64Encode(utf8.encode('${settings.username}:${settings.password}'))}',
},
),
);
}
String _fileUrl(WebDavSettings settings) {
final base = settings.url.trim().replaceAll(RegExp(r'/+$'), '');
var path = settings.path.trim().isEmpty
? WebDavSettings.defaultPath
: settings.path.trim();
if (!path.startsWith('/')) path = '/$path';
return '$base$path';
}
Future<Map<String, dynamic>> _exportLocal() async {
final titles = await _db.select(_db.titles).get();
final chapters = await _db.select(_db.chapters).get();
final titleById = {for (final t in titles) t.id: t};
final titleMaps = <Map<String, dynamic>>[];
for (final t in titles) {
String? chapterKey;
if (t.lastReadChapterId != null) {
final ch = await _db.getChapter(t.lastReadChapterId!);
chapterKey = ch?.chapterKey;
}
titleMaps.add({
'source': t.source,
'url': t.url,
'slug': t.slug,
'title': t.title,
'coverUrl': t.coverUrl,
'inLibrary': t.inLibrary,
'readerMode': t.readerMode,
'readingDirection': t.readingDirection,
'lastReadChapterKey': chapterKey,
'lastReadAt': t.lastReadAt?.toUtc().toIso8601String(),
'libraryUpdatedAt': t.libraryUpdatedAt?.toUtc().toIso8601String(),
});
}
final chapterMaps = <Map<String, dynamic>>[];
for (final c in chapters) {
final t = titleById[c.titleId];
if (t == null) continue;
if (c.lastReadAt == null && c.lastPage == 0 && !c.completed) continue;
chapterMaps.add({
'source': t.source,
'seriesUrl': t.url,
'chapterKey': c.chapterKey,
'label': c.label,
'url': c.url,
'chapterNumber': c.chapterNumber,
'lastPage': c.lastPage,
'pageCount': c.pageCount,
'completed': c.completed,
'lastReadAt': c.lastReadAt?.toUtc().toIso8601String(),
});
}
return {
'updatedAt': DateTime.now().toUtc().toIso8601String(),
'titles': titleMaps,
'chapters': chapterMaps,
};
}
Map<String, dynamic> _merge(
Map<String, dynamic> remote,
Map<String, dynamic> local,
) {
final remoteTitles = _asMapList(remote['titles']);
final localTitles = _asMapList(local['titles']);
final titles = <String, Map<String, dynamic>>{};
for (final t in [...remoteTitles, ...localTitles]) {
final key = '${t['source']}|${t['url']}';
final existing = titles[key];
if (existing == null) {
titles[key] = Map<String, dynamic>.from(t);
continue;
}
titles[key] = _mergeTitle(existing, t);
}
final remoteChapters = _asMapList(remote['chapters']);
final localChapters = _asMapList(local['chapters']);
final chapters = <String, Map<String, dynamic>>{};
for (final c in [...remoteChapters, ...localChapters]) {
final key = '${c['source']}|${c['seriesUrl']}|${c['chapterKey']}';
final existing = chapters[key];
if (existing == null) {
chapters[key] = Map<String, dynamic>.from(c);
continue;
}
chapters[key] = _newerBy(existing, c, 'lastReadAt');
}
return {
'updatedAt': DateTime.now().toUtc().toIso8601String(),
'titles': titles.values.toList(),
'chapters': chapters.values.toList(),
};
}
Map<String, dynamic> _mergeTitle(
Map<String, dynamic> a,
Map<String, dynamic> b,
) {
final out = Map<String, dynamic>.from(a);
// Metadata: prefer non-empty
for (final field in ['title', 'coverUrl', 'slug']) {
final bv = b[field];
if ((out[field] == null || (out[field] as String?)?.isEmpty == true) &&
bv is String &&
bv.isNotEmpty) {
out[field] = bv;
}
}
// History / continue by lastReadAt
final newerRead = _newerBy(a, b, 'lastReadAt');
out['lastReadAt'] = newerRead['lastReadAt'];
out['lastReadChapterKey'] = newerRead['lastReadChapterKey'];
out['readerMode'] = newerRead['readerMode'] ?? out['readerMode'];
out['readingDirection'] =
newerRead['readingDirection'] ?? out['readingDirection'];
// Library star by libraryUpdatedAt
final newerLib = _newerBy(a, b, 'libraryUpdatedAt');
out['inLibrary'] = newerLib['inLibrary'] ?? false;
out['libraryUpdatedAt'] = newerLib['libraryUpdatedAt'];
return out;
}
Map<String, dynamic> _newerBy(
Map<String, dynamic> a,
Map<String, dynamic> b,
String field,
) {
final at = _parseTime(a[field]);
final bt = _parseTime(b[field]);
if (bt == null) return a;
if (at == null) return b;
return bt.isAfter(at) ? b : a;
}
DateTime? _parseTime(dynamic v) {
if (v is! String || v.isEmpty) return null;
return DateTime.tryParse(v)?.toUtc();
}
List<Map<String, dynamic>> _asMapList(dynamic v) {
if (v is! List) return const [];
return v
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
Future<void> _applyRemote(Map<String, dynamic> merged) async {
final titleMaps = _asMapList(merged['titles']);
final chapterMaps = _asMapList(merged['chapters']);
for (final t in titleMaps) {
final source = t['source'] as String? ?? '';
final url = t['url'] as String? ?? '';
if (source.isEmpty || url.isEmpty) continue;
final existing = await _db.findTitleBySourceUrl(source, url);
final lastReadAt = _parseTime(t['lastReadAt']);
final libraryUpdatedAt = _parseTime(t['libraryUpdatedAt']);
final inLibrary = t['inLibrary'] == true;
late final int titleId;
if (existing == null) {
titleId = await _db.into(_db.titles).insert(
TitlesCompanion.insert(
source: source,
url: url,
slug: (t['slug'] as String?) ?? '',
title: (t['title'] as String?) ?? url,
coverUrl: Value(t['coverUrl'] as String?),
readerMode: Value(
(t['readerMode'] as String?) ?? '',
),
readingDirection: Value(t['readingDirection'] as String?),
lastReadAt: Value(lastReadAt),
inLibrary: Value(inLibrary),
libraryUpdatedAt: Value(libraryUpdatedAt),
),
);
} else {
titleId = existing.id;
await (_db.update(_db.titles)..where((x) => x.id.equals(titleId)))
.write(
TitlesCompanion(
title: Value(
(t['title'] as String?)?.isNotEmpty == true
? t['title'] as String
: existing.title,
),
coverUrl: Value(
(t['coverUrl'] as String?) ?? existing.coverUrl,
),
slug: Value(
(t['slug'] as String?)?.isNotEmpty == true
? t['slug'] as String
: existing.slug,
),
readerMode: Value(
(t['readerMode'] as String?) ?? existing.readerMode,
),
readingDirection: Value(
(t['readingDirection'] as String?) ?? existing.readingDirection,
),
lastReadAt: Value(lastReadAt ?? existing.lastReadAt),
inLibrary: Value(inLibrary),
libraryUpdatedAt: Value(libraryUpdatedAt ?? existing.libraryUpdatedAt),
),
);
}
final chapterKey = t['lastReadChapterKey'] as String?;
if (chapterKey != null && chapterKey.isNotEmpty) {
final rows = await (_db.select(_db.chapters)
..where(
(c) =>
c.titleId.equals(titleId) & c.chapterKey.equals(chapterKey),
))
.get();
if (rows.isNotEmpty) {
await (_db.update(_db.titles)..where((x) => x.id.equals(titleId)))
.write(
TitlesCompanion(lastReadChapterId: Value(rows.first.id)),
);
}
}
}
for (final c in chapterMaps) {
final source = c['source'] as String? ?? '';
final seriesUrl = c['seriesUrl'] as String? ?? '';
final chapterKey = c['chapterKey'] as String? ?? '';
if (source.isEmpty || seriesUrl.isEmpty || chapterKey.isEmpty) continue;
var title = await _db.findTitleBySourceUrl(source, seriesUrl);
if (title == null) {
final titleId = await _db.into(_db.titles).insert(
TitlesCompanion.insert(
source: source,
url: seriesUrl,
slug: '',
title: seriesUrl,
),
);
title = await _db.getTitle(titleId);
}
if (title == null) continue;
final rows = await (_db.select(_db.chapters)
..where(
(ch) =>
ch.titleId.equals(title!.id) &
ch.chapterKey.equals(chapterKey),
))
.get();
final lastReadAt = _parseTime(c['lastReadAt']);
final lastPage = (c['lastPage'] as num?)?.toInt() ?? 0;
final pageCount = (c['pageCount'] as num?)?.toInt() ?? 0;
final completed = c['completed'] == true;
if (rows.isEmpty) {
await _db.into(_db.chapters).insert(
ChaptersCompanion.insert(
titleId: title.id,
chapterKey: chapterKey,
label: (c['label'] as String?) ?? chapterKey,
url: (c['url'] as String?) ?? seriesUrl,
chapterNumber: Value(c['chapterNumber'] as String?),
lastPage: Value(lastPage),
pageCount: Value(pageCount),
completed: Value(completed),
lastReadAt: Value(lastReadAt),
),
);
} else {
final local = rows.first;
final remoteTime = lastReadAt;
final localTime = local.lastReadAt;
final takeRemote = remoteTime != null &&
(localTime == null || remoteTime.isAfter(localTime));
if (takeRemote) {
await (_db.update(_db.chapters)..where((x) => x.id.equals(local.id)))
.write(
ChaptersCompanion(
lastPage: Value(lastPage),
pageCount: Value(pageCount > 0 ? pageCount : local.pageCount),
completed: Value(completed),
lastReadAt: Value(lastReadAt),
label: Value(
(c['label'] as String?)?.isNotEmpty == true
? c['label'] as String
: local.label,
),
),
);
}
}
}
}
}