Files
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

475 lines
15 KiB
Dart

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