diff --git a/.gitignore b/.gitignore
index 3308461..8c58739 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,7 @@ docs/
data/
public/*
!public/.gitkeep
+.tools/
+cxyz-android/.dart_tool/
+cxyz-android/.flutter-plugins-dependencies
+cxyz-android/build/
diff --git a/cxyz-android/.gitignore b/cxyz-android/.gitignore
new file mode 100644
index 0000000..7c169c1
--- /dev/null
+++ b/cxyz-android/.gitignore
@@ -0,0 +1,86 @@
+# Miscellaneous
+*.class
+*.log
+*.pyc
+*.swp
+.DS_Store
+.atom/
+.build/
+.buildlog/
+.history
+.svn/
+.swiftpm/
+migrate_working_dir/
+
+# IntelliJ / Android Studio
+*.iml
+*.ipr
+*.iws
+.idea/
+
+# VS Code (uncomment to ignore local workspace settings)
+#.vscode/
+
+# Flutter / Dart / Pub
+**/doc/api/
+**/ios/Flutter/.last_build_id
+.dart_tool/
+.flutter-plugins
+.flutter-plugins-dependencies
+.packages
+.pub-cache/
+.pub/
+.fvm/
+build/
+coverage/
+.widget_preview/
+
+# Local analysis / temp HOME used by scripts and tooling
+.tmp-home/
+.tmp/
+
+# Symbolication / obfuscation
+app.*.symbols
+app.*.map.json
+
+# Android build outputs (also covered under android/)
+/android/app/debug
+/android/app/profile
+/android/app/release
+**/android/**/gradle-wrapper.jar
+**/android/.gradle/
+**/android/captures/
+**/android/local.properties
+**/android/**/GeneratedPluginRegistrant.java
+**/android/.cxx/
+
+# Signing — never commit keystores or local key config
+**/android/key.properties
+**/*.keystore
+**/*.jks
+*.apk
+*.aab
+*.ap_
+
+# Secrets / local env
+.env
+.env.*
+!.env.example
+
+# Test / coverage extras
+coverage/
+.test_coverage/
+*.lcov
+
+# Platform ephemeral (if added later)
+**/ios/Pods/
+**/ios/.symlinks/
+**/ios/Flutter/Flutter.framework
+**/ios/Flutter/Flutter.podspec
+**/ios/Flutter/Generated.xcconfig
+**/ios/Flutter/ephemeral/
+**/ios/ServiceDefinitions.json
+**/ios/Runner/GeneratedPluginRegistrant.*
+**/macos/Flutter/ephemeral/
+**/linux/flutter/ephemeral/
+**/windows/flutter/ephemeral/
diff --git a/cxyz-android/.metadata b/cxyz-android/.metadata
new file mode 100644
index 0000000..eae9680
--- /dev/null
+++ b/cxyz-android/.metadata
@@ -0,0 +1,30 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "6655482ec06e547f90abf8ae7590466f4415978d"
+ channel: "[user-branch]"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 6655482ec06e547f90abf8ae7590466f4415978d
+ base_revision: 6655482ec06e547f90abf8ae7590466f4415978d
+ - platform: android
+ create_revision: 6655482ec06e547f90abf8ae7590466f4415978d
+ base_revision: 6655482ec06e547f90abf8ae7590466f4415978d
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/cxyz-android/README.md b/cxyz-android/README.md
new file mode 100644
index 0000000..e2619b7
--- /dev/null
+++ b/cxyz-android/README.md
@@ -0,0 +1,91 @@
+# cxyz Android (Flutter)
+
+On-device manga/comic reader/downloader — Flutter reimplementation of [cxyz](../) adapters for Android.
+
+## Features (v1)
+
+- **Catalog**: source picker → per-source menus (not one shared tab bar)
+- **Sources** (build-time): ComicLand, MangaRead, and/or MangaTown via `--dart-define=SOURCES=…` (or `SOURCES=all`)
+- **Series**: chapter list, sync, multi-select / download-all (unread filter)
+- **Reader**: gapless vertical webtoon + horizontal paged mode; progress + star
+- **Library**: starred series only; **History**: recently read
+- **Downloads**: running job + queue link; CBZ under public `Download/Comics/`
+- **Settings**: RTL + WebDAV sync (progress, library, history)
+
+## Stack
+
+Flutter 3 · Riverpod · go_router · Dio · Drift (SQLite) · archive (CBZ) · cached_network_image · html
+
+## Setup
+
+Use a local Flutter SDK (this repo may use `../.tools/flutter-sdk` on the machine that cloned it):
+
+```bash
+export PATH="/Users/Shared/LocalData/Projects/cxyz/.tools/bin:/Users/Shared/LocalData/Projects/cxyz/.tools/flutter-sdk/bin:$PATH"
+export CI=true BOT=true
+
+cd cxyz-android
+flutter pub get
+dart run build_runner build --delete-conflicting-outputs
+flutter run
+```
+
+Sideload APK only (not Play Store–friendly).
+
+## Build-time sources (`SOURCES`)
+
+Compile with Flutter `--dart-define`. Unset / invalid → **ComicLand only** (not all sources).
+
+Prefer the helper (copies a tagged filename and prints the path):
+
+```bash
+./scripts/build_apk.sh # → …/cxyz-comicland-release.apk
+./scripts/build_apk.sh mangaread # → …/cxyz-mangaread-release.apk
+./scripts/build_apk.sh comicland,mangaread # → …/cxyz-comicland-mangaread-release.apk
+./scripts/build_apk.sh all # → …/cxyz-all-release.apk
+./scripts/build_apk.sh mangaread --debug # → …/cxyz-mangaread-debug.apk
+```
+
+Or call Flutter directly (always writes `app-release.apk` / `app-debug.apk`):
+
+```bash
+flutter build apk
+flutter build apk --dart-define=SOURCES=mangaread
+flutter build apk --dart-define=SOURCES=comicland,mangaread,mangatown
+flutter build apk --dart-define=SOURCES=all
+flutter run --dart-define=SOURCES=all
+```
+
+With two or more sources enabled, Catalog opens a **source picker**; each source shows only its own menus (e.g. ComicLand: Newest / Popular / …; MangaRead: Latest / Search; MangaTown: Latest / Hot / Directory / …). A single-source build skips the picker and opens that source’s home.
+
+## ComicLand API
+
+Base: `https://api.comicland.org/api`
+
+| Endpoint | Use |
+|---|---|
+| `GET /comics/popular` | Popular shelf |
+| `GET /comics?offset&limit` | Latest / browse |
+| `GET /comic/search?q&offset&limit` | Search |
+| `GET /comic/detail?slug=` | Series + chapters |
+| `GET /chapter/pages_by_index?slug&index` | Page image URLs |
+
+CDN images need Referer/Origin `https://comicland.org/`.
+
+## MangaRead (HTML / Madara)
+
+Origin: `https://www.mangaread.org/`
+
+- Series: listing HTML `li.wp-manga-chapter`, fallback `POST …/ajax/chapters/`
+- Pages: chapter HTML with mobile UA → `img.wp-manga-chapter-img`
+- Catalog: Latest (`/manga/?m_orderby=latest`) + Search (`?s=&post_type=wp-manga`)
+- Cloudflare: direct Dio only; HTTP 403 surfaces a clear error (no FlareSolverr on phone)
+
+## MangaTown (HTML / mobile)
+
+Origin: `https://m.mangatown.com`
+
+- Catalog: Latest (`/latest`), Hot (`/hot`, `?tviews.za` / `?wviews.za` / `?mviews.za`), Directory (`/directory/{demo}-{genre}-0-{status}-{letter}-0`), Search (`/search?name=`)
+- Series: `/manga/{slug}` → `ul.detail-ch-list`
+- Pages: one image per reader page (`#viewer img#image`); page N at `/manga/{slug}/c005/N.html`
+- CDN images need Referer `https://m.mangatown.com/`
diff --git a/cxyz-android/analysis_options.yaml b/cxyz-android/analysis_options.yaml
new file mode 100644
index 0000000..841c177
--- /dev/null
+++ b/cxyz-android/analysis_options.yaml
@@ -0,0 +1,19 @@
+# Extra analysis options for Drift / Flutter.
+include: package:flutter_lints/flutter.yaml
+
+analyzer:
+ exclude:
+ - "**/*.g.dart"
+ - build/**
+ - android/**
+ - ios/**
+ - web/**
+ - windows/**
+ - macos/**
+ - linux/**
+ errors:
+ invalid_annotation_target: ignore
+
+linter:
+ rules:
+ prefer_const_constructors: false
diff --git a/cxyz-android/android/.gitignore b/cxyz-android/android/.gitignore
new file mode 100644
index 0000000..be3943c
--- /dev/null
+++ b/cxyz-android/android/.gitignore
@@ -0,0 +1,14 @@
+gradle-wrapper.jar
+/.gradle
+/captures/
+/gradlew
+/gradlew.bat
+/local.properties
+GeneratedPluginRegistrant.java
+.cxx/
+
+# Remember to never publicly share your keystore.
+# See https://flutter.dev/to/reference-keystore
+key.properties
+**/*.keystore
+**/*.jks
diff --git a/cxyz-android/android/app/build.gradle.kts b/cxyz-android/android/app/build.gradle.kts
new file mode 100644
index 0000000..392f8b7
--- /dev/null
+++ b/cxyz-android/android/app/build.gradle.kts
@@ -0,0 +1,49 @@
+plugins {
+ id("com.android.application")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "org.cxyz.cxyz_android"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "org.cxyz.cxyz_android"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = 26
+ targetSdk = flutter.targetSdkVersion
+ // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION
+ // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions)
+ // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true`
+ // flag during build.
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/cxyz-android/android/app/src/debug/AndroidManifest.xml b/cxyz-android/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/cxyz-android/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/cxyz-android/android/app/src/main/AndroidManifest.xml b/cxyz-android/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..8c1fbd7
--- /dev/null
+++ b/cxyz-android/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cxyz-android/android/app/src/main/kotlin/org/cxyz/cxyz_android/MainActivity.kt b/cxyz-android/android/app/src/main/kotlin/org/cxyz/cxyz_android/MainActivity.kt
new file mode 100644
index 0000000..6df3204
--- /dev/null
+++ b/cxyz-android/android/app/src/main/kotlin/org/cxyz/cxyz_android/MainActivity.kt
@@ -0,0 +1,153 @@
+package org.cxyz.cxyz_android
+
+import android.content.ContentValues
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+
+class MainActivity : FlutterActivity() {
+ private val channelName = "org.cxyz.cxyz_android/cbz_store"
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
+ .setMethodCallHandler { call, result ->
+ when (call.method) {
+ "saveCbzFromPath" -> {
+ val relativeDir = call.argument("relativeDir")
+ val displayName = call.argument("displayName")
+ val sourcePath = call.argument("sourcePath")
+ if (relativeDir == null || displayName == null || sourcePath == null) {
+ result.error(
+ "bad_args",
+ "relativeDir, displayName, sourcePath required",
+ null,
+ )
+ return@setMethodCallHandler
+ }
+ try {
+ result.success(saveCbzFromPath(relativeDir, displayName, sourcePath))
+ } catch (e: Exception) {
+ result.error("save_failed", e.message, null)
+ }
+ }
+ "readCbzToPath" -> {
+ val uriOrPath = call.argument("uri")
+ val destPath = call.argument("destPath")
+ if (uriOrPath == null || destPath == null) {
+ result.error("bad_args", "uri and destPath required", null)
+ return@setMethodCallHandler
+ }
+ try {
+ result.success(readCbzToPath(uriOrPath, destPath))
+ } catch (e: Exception) {
+ result.error("read_failed", e.message, null)
+ }
+ }
+ "deleteCbz" -> {
+ val uriOrPath = call.argument("uri")
+ if (uriOrPath == null) {
+ result.error("bad_args", "uri required", null)
+ return@setMethodCallHandler
+ }
+ try {
+ result.success(deleteCbz(uriOrPath))
+ } catch (e: Exception) {
+ result.error("delete_failed", e.message, null)
+ }
+ }
+ "sdkInt" -> result.success(Build.VERSION.SDK_INT)
+ else -> result.notImplemented()
+ }
+ }
+ }
+
+ private fun saveCbzFromPath(
+ relativeDir: String,
+ displayName: String,
+ sourcePath: String,
+ ): String {
+ val source = File(sourcePath)
+ if (!source.exists()) {
+ throw IllegalStateException("Source file missing: $sourcePath")
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ val resolver = applicationContext.contentResolver
+ val collection = MediaStore.Downloads.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
+ val values = ContentValues().apply {
+ put(MediaStore.MediaColumns.DISPLAY_NAME, displayName)
+ put(MediaStore.MediaColumns.MIME_TYPE, "application/vnd.comicbook+zip")
+ put(
+ MediaStore.MediaColumns.RELATIVE_PATH,
+ "${Environment.DIRECTORY_DOWNLOADS}/$relativeDir",
+ )
+ put(MediaStore.MediaColumns.IS_PENDING, 1)
+ }
+ val uri = resolver.insert(collection, values)
+ ?: throw IllegalStateException("MediaStore insert returned null")
+ resolver.openOutputStream(uri)?.use { out ->
+ FileInputStream(source).use { input -> input.copyTo(out) }
+ out.flush()
+ } ?: throw IllegalStateException("Could not open output stream for $uri")
+ values.clear()
+ values.put(MediaStore.MediaColumns.IS_PENDING, 0)
+ resolver.update(uri, values, null, null)
+ return uri.toString()
+ }
+
+ val downloads =
+ Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ val dir = File(downloads, relativeDir)
+ if (!dir.exists() && !dir.mkdirs()) {
+ throw IllegalStateException("Could not create ${dir.absolutePath}")
+ }
+ val file = File(dir, displayName)
+ FileInputStream(source).use { input ->
+ FileOutputStream(file).use { out ->
+ input.copyTo(out)
+ out.flush()
+ }
+ }
+ return file.absolutePath
+ }
+
+ private fun readCbzToPath(uriOrPath: String, destPath: String): String {
+ val dest = File(destPath)
+ dest.parentFile?.mkdirs()
+ if (uriOrPath.startsWith("content://")) {
+ val uri = Uri.parse(uriOrPath)
+ applicationContext.contentResolver.openInputStream(uri)?.use { input ->
+ FileOutputStream(dest).use { out ->
+ input.copyTo(out)
+ out.flush()
+ }
+ } ?: throw IllegalStateException("Could not open $uriOrPath")
+ } else {
+ File(uriOrPath).inputStream().use { input ->
+ FileOutputStream(dest).use { out ->
+ input.copyTo(out)
+ out.flush()
+ }
+ }
+ }
+ return dest.absolutePath
+ }
+
+ private fun deleteCbz(uriOrPath: String): Boolean {
+ return if (uriOrPath.startsWith("content://")) {
+ val uri = Uri.parse(uriOrPath)
+ applicationContext.contentResolver.delete(uri, null, null) > 0
+ } else {
+ val file = File(uriOrPath)
+ !file.exists() || file.delete()
+ }
+ }
+}
diff --git a/cxyz-android/android/app/src/main/res/drawable-v21/launch_background.xml b/cxyz-android/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 0000000..f74085f
--- /dev/null
+++ b/cxyz-android/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/cxyz-android/android/app/src/main/res/drawable/launch_background.xml b/cxyz-android/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 0000000..304732f
--- /dev/null
+++ b/cxyz-android/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/cxyz-android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/cxyz-android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..db77bb4
Binary files /dev/null and b/cxyz-android/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/cxyz-android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/cxyz-android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..17987b7
Binary files /dev/null and b/cxyz-android/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/cxyz-android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/cxyz-android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..09d4391
Binary files /dev/null and b/cxyz-android/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/cxyz-android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/cxyz-android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..d5f1c8d
Binary files /dev/null and b/cxyz-android/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/cxyz-android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/cxyz-android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..4d6372e
Binary files /dev/null and b/cxyz-android/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/cxyz-android/android/app/src/main/res/values-night/styles.xml b/cxyz-android/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..06952be
--- /dev/null
+++ b/cxyz-android/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/cxyz-android/android/app/src/main/res/values/styles.xml b/cxyz-android/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..cb1ef88
--- /dev/null
+++ b/cxyz-android/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/cxyz-android/android/app/src/profile/AndroidManifest.xml b/cxyz-android/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 0000000..399f698
--- /dev/null
+++ b/cxyz-android/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/cxyz-android/android/build.gradle.kts b/cxyz-android/android/build.gradle.kts
new file mode 100644
index 0000000..58d3009
--- /dev/null
+++ b/cxyz-android/android/build.gradle.kts
@@ -0,0 +1,59 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+
+// flutter_secure_storage 11 requests compileSdk 37; some SDKs install that
+// platform as android-37.0, which AGP cannot resolve. Cap plugins at 36.
+subprojects {
+ afterEvaluate {
+ val android = project.extensions.findByName("android") ?: return@afterEvaluate
+ try {
+ val method = android.javaClass.methods.firstOrNull {
+ it.name == "setCompileSdkVersion" || it.name == "setCompileSdk"
+ }
+ // Prefer the Int setter used by AGP 8+
+ val setCompileSdk = android.javaClass.methods.firstOrNull { m ->
+ m.name == "setCompileSdk" && m.parameterTypes.size == 1
+ }
+ if (setCompileSdk != null) {
+ val current = android.javaClass.methods
+ .firstOrNull { it.name == "getCompileSdk" && it.parameterCount == 0 }
+ ?.invoke(android)
+ val api = when (current) {
+ is Int -> current
+ is String -> current.toIntOrNull() ?: 0
+ else -> 0
+ }
+ if (api > 36) {
+ setCompileSdk.invoke(android, 36)
+ }
+ } else if (method != null) {
+ method.invoke(android, 36)
+ }
+ } catch (_: Exception) {
+ // Non-Android subprojects — ignore.
+ }
+ }
+}
+
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/cxyz-android/android/gradle.properties b/cxyz-android/android/gradle.properties
new file mode 100644
index 0000000..e96108c
--- /dev/null
+++ b/cxyz-android/android/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+# This newDsl flag was added by the Flutter template
+android.newDsl=false
+# This builtInKotlin flag was added by the Flutter template
+android.builtInKotlin=false
diff --git a/cxyz-android/android/gradle/wrapper/gradle-wrapper.properties b/cxyz-android/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..a20f2c4
--- /dev/null
+++ b/cxyz-android/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
diff --git a/cxyz-android/android/settings.gradle.kts b/cxyz-android/android/settings.gradle.kts
new file mode 100644
index 0000000..b28021a
--- /dev/null
+++ b/cxyz-android/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "9.1.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.4.0" apply false
+}
+
+include(":app")
diff --git a/cxyz-android/assets/sources/comicland.png b/cxyz-android/assets/sources/comicland.png
new file mode 100644
index 0000000..344c040
Binary files /dev/null and b/cxyz-android/assets/sources/comicland.png differ
diff --git a/cxyz-android/assets/sources/mangaread.png b/cxyz-android/assets/sources/mangaread.png
new file mode 100644
index 0000000..54b6c8e
Binary files /dev/null and b/cxyz-android/assets/sources/mangaread.png differ
diff --git a/cxyz-android/assets/sources/mangatown.png b/cxyz-android/assets/sources/mangatown.png
new file mode 100644
index 0000000..2642b62
Binary files /dev/null and b/cxyz-android/assets/sources/mangatown.png differ
diff --git a/cxyz-android/lib/main.dart b/cxyz-android/lib/main.dart
new file mode 100644
index 0000000..dbcf40c
--- /dev/null
+++ b/cxyz-android/lib/main.dart
@@ -0,0 +1,62 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import 'providers/providers.dart';
+import 'router.dart';
+
+void main() {
+ WidgetsFlutterBinding.ensureInitialized();
+ runApp(const ProviderScope(child: CxyzApp()));
+}
+
+class CxyzApp extends ConsumerStatefulWidget {
+ const CxyzApp({super.key});
+
+ @override
+ ConsumerState createState() => _CxyzAppState();
+}
+
+class _CxyzAppState extends ConsumerState with WidgetsBindingObserver {
+ @override
+ void initState() {
+ super.initState();
+ WidgetsBinding.instance.addObserver(this);
+ }
+
+ @override
+ void dispose() {
+ WidgetsBinding.instance.removeObserver(this);
+ super.dispose();
+ }
+
+ @override
+ void didChangeAppLifecycleState(AppLifecycleState state) {
+ if (state == AppLifecycleState.resumed) {
+ ref.read(webDavSyncProvider).scheduleSync();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final router = ref.watch(routerProvider);
+ return MaterialApp.router(
+ title: 'cxyz',
+ debugShowCheckedModeBanner: false,
+ theme: ThemeData(
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: const Color(0xFF1B6CA8),
+ brightness: Brightness.light,
+ ),
+ useMaterial3: true,
+ ),
+ darkTheme: ThemeData(
+ colorScheme: ColorScheme.fromSeed(
+ seedColor: const Color(0xFF1B6CA8),
+ brightness: Brightness.dark,
+ ),
+ useMaterial3: true,
+ ),
+ routerConfig: router,
+ );
+ }
+}
diff --git a/cxyz-android/lib/providers/providers.dart b/cxyz-android/lib/providers/providers.dart
new file mode 100644
index 0000000..e86e65e
--- /dev/null
+++ b/cxyz-android/lib/providers/providers.dart
@@ -0,0 +1,129 @@
+import 'dart:async';
+
+import 'package:drift/drift.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../data/database.dart';
+import '../services/cbz_store.dart';
+import '../services/download_queue.dart';
+import '../services/image_headers.dart';
+import '../services/ntfy_notifier.dart';
+import '../services/peek_mode.dart';
+import '../services/reader_direction.dart';
+import '../services/webdav_sync.dart';
+import '../sources/registry.dart';
+
+final sourceRegistryProvider = Provider((ref) {
+ return SourceRegistry();
+});
+
+final databaseProvider = Provider((ref) {
+ final db = AppDatabase();
+ ref.onDispose(db.close);
+ return db;
+});
+
+final cbzStoreProvider = Provider((ref) => CbzStore());
+
+final ntfyNotifierProvider = Provider((ref) {
+ return NtfyNotifier(store: ref.watch(cbzStoreProvider));
+});
+
+final downloadQueueProvider = ChangeNotifierProvider((ref) {
+ final queue = DownloadQueue(
+ db: ref.watch(databaseProvider),
+ sources: ref.watch(sourceRegistryProvider),
+ store: ref.watch(cbzStoreProvider),
+ ntfy: ref.watch(ntfyNotifierProvider),
+ );
+ // Clear leftover queue rows for chapters already on disk (e.g. older builds).
+ unawaited(queue.reconcileStaleJobs());
+ return queue;
+});
+
+/// Per-request headers come from [ComicCover] / reader via `httpHeaders`.
+final imageCacheManagerProvider = Provider((ref) {
+ return HeaderCacheManager(const {});
+});
+
+final libraryTitlesProvider = StreamProvider>((ref) {
+ final db = ref.watch(databaseProvider);
+ return (db.select(db.titles)
+ ..where((t) => t.inLibrary.equals(true))
+ ..orderBy([(t) => OrderingTerm.asc(t.title)]))
+ .watch();
+});
+
+final historyTitlesProvider = StreamProvider>((ref) {
+ final db = ref.watch(databaseProvider);
+ return (db.select(db.titles)
+ ..where((t) => t.lastReadAt.isNotNull())
+ ..orderBy([(t) => OrderingTerm.desc(t.lastReadAt)]))
+ .watch();
+});
+
+final queueJobsProvider = StreamProvider>((ref) {
+ final db = ref.watch(databaseProvider);
+ return db.watchQueueJobs();
+});
+
+final downloadedSeriesProvider =
+ StreamProvider>((ref) {
+ final db = ref.watch(databaseProvider);
+ return db.watchDownloadedSeries();
+});
+
+class PeekModeNotifier extends StateNotifier {
+ PeekModeNotifier() : super(false) {
+ _load();
+ }
+
+ Future _load() async {
+ state = await PeekMode.load();
+ }
+
+ Future toggle() async {
+ state = !state;
+ await PeekMode.save(state);
+ }
+
+ Future setEnabled(bool enabled) async {
+ state = enabled;
+ await PeekMode.save(enabled);
+ }
+}
+
+final peekModeProvider =
+ StateNotifierProvider((ref) => PeekModeNotifier());
+
+class ReadingDirectionNotifier extends StateNotifier {
+ ReadingDirectionNotifier() : super(ReadingDirection.ltr) {
+ _load();
+ }
+
+ Future _load() async {
+ state = await ReaderDirection.load();
+ }
+
+ Future setDirection(ReadingDirection direction) async {
+ state = direction;
+ await ReaderDirection.save(direction);
+ }
+
+ Future toggle() async {
+ await setDirection(
+ state == ReadingDirection.ltr
+ ? ReadingDirection.rtl
+ : ReadingDirection.ltr,
+ );
+ }
+}
+
+final readingDirectionProvider =
+ StateNotifierProvider(
+ (ref) => ReadingDirectionNotifier(),
+);
+
+final webDavSyncProvider = Provider((ref) {
+ return WebDavSyncService(ref.watch(databaseProvider));
+});
diff --git a/cxyz-android/lib/router.dart b/cxyz-android/lib/router.dart
new file mode 100644
index 0000000..024c9ce
--- /dev/null
+++ b/cxyz-android/lib/router.dart
@@ -0,0 +1,155 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:go_router/go_router.dart';
+
+import '../services/reader_direction.dart';
+import '../sources/source.dart';
+import '../sources/source_flags.dart';
+import '../ui/catalog/source_picker_page.dart';
+import '../ui/catalog_page.dart';
+import '../ui/downloads_page.dart';
+import '../ui/history_page.dart';
+import '../ui/library_page.dart';
+import '../ui/reader_page.dart';
+import '../ui/settings_page.dart';
+import '../ui/shell.dart';
+import '../ui/title_page.dart';
+
+final _rootKey = GlobalKey();
+
+SourceId? _parseSourceId(String? raw) {
+ if (raw == null) return null;
+ return SourceId.tryParse(raw);
+}
+
+CatalogMenuKind? _parseMenu(String? raw) {
+ if (raw == null) return null;
+ for (final k in CatalogMenuKind.values) {
+ if (k.pathSegment == raw) return k;
+ }
+ return null;
+}
+
+final routerProvider = Provider((ref) {
+ return GoRouter(
+ navigatorKey: _rootKey,
+ initialLocation: '/catalog',
+ routes: [
+ StatefulShellRoute.indexedStack(
+ builder: (context, state, navigationShell) {
+ return AppShell(navigationShell: navigationShell);
+ },
+ branches: [
+ StatefulShellBranch(
+ routes: [
+ // Sibling routes (not nested under picker) so back stack is clean.
+ GoRoute(
+ path: '/catalog',
+ redirect: (context, state) {
+ final enabled = enabledSourceIds();
+ if (enabled.length == 1) {
+ return '/catalog/${enabled.first.name}';
+ }
+ return null;
+ },
+ builder: (context, state) => const SourcePickerPage(),
+ ),
+ GoRoute(
+ path: '/catalog/:sourceId',
+ builder: (context, state) {
+ final id = _parseSourceId(state.pathParameters['sourceId']);
+ if (id == null) {
+ return const Scaffold(
+ body: Center(child: Text('Unknown source')),
+ );
+ }
+ final single = enabledSourceIds().length == 1;
+ return SourceHomePage(
+ sourceId: id,
+ embeddedAsRoot: single,
+ );
+ },
+ routes: [
+ GoRoute(
+ path: ':menu',
+ builder: (context, state) {
+ final id =
+ _parseSourceId(state.pathParameters['sourceId']);
+ final menu = _parseMenu(state.pathParameters['menu']);
+ if (id == null || menu == null) {
+ return const Scaffold(
+ body: Center(child: Text('Unknown catalog feed')),
+ );
+ }
+ return CatalogFeedPage(sourceId: id, menuKind: menu);
+ },
+ ),
+ ],
+ ),
+ ],
+ ),
+ StatefulShellBranch(
+ routes: [
+ GoRoute(
+ path: '/library',
+ builder: (context, state) => const LibraryPage(),
+ ),
+ ],
+ ),
+ StatefulShellBranch(
+ routes: [
+ GoRoute(
+ path: '/history',
+ builder: (context, state) => const HistoryPage(),
+ ),
+ ],
+ ),
+ StatefulShellBranch(
+ routes: [
+ GoRoute(
+ path: '/downloads',
+ builder: (context, state) => const DownloadsPage(),
+ routes: [
+ GoRoute(
+ path: 'queue',
+ builder: (context, state) => const DownloadsQueuePage(),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ GoRoute(
+ path: '/settings',
+ builder: (context, state) => const SettingsPage(),
+ ),
+ GoRoute(
+ path: '/title/:id',
+ builder: (context, state) {
+ final id = int.parse(state.pathParameters['id']!);
+ return TitlePage(titleId: id);
+ },
+ ),
+ GoRoute(
+ path: '/read/:titleId/:chapterId',
+ builder: (context, state) {
+ final titleId = int.parse(state.pathParameters['titleId']!);
+ final chapterId = int.parse(state.pathParameters['chapterId']!);
+ final modeParam = state.uri.queryParameters['mode'];
+ ReaderMode? initialMode;
+ if (modeParam == 'paged') {
+ initialMode = ReaderMode.paged;
+ } else if (modeParam == 'webtoon') {
+ initialMode = ReaderMode.webtoon;
+ }
+ return ReaderPage(
+ titleId: titleId,
+ chapterId: chapterId,
+ initialMode: initialMode,
+ );
+ },
+ ),
+ ],
+ );
+});
diff --git a/cxyz-android/lib/services/cbz_store.dart b/cxyz-android/lib/services/cbz_store.dart
new file mode 100644
index 0000000..0c0ebf6
--- /dev/null
+++ b/cxyz-android/lib/services/cbz_store.dart
@@ -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 sdkInt() async {
+ if (_sdkInt != null) return _sdkInt!;
+ try {
+ final v = await _channel.invokeMethod('sdkInt');
+ _sdkInt = v ?? 30;
+ } catch (_) {
+ _sdkInt = 30;
+ }
+ return _sdkInt!;
+ }
+
+ /// Request WRITE_EXTERNAL_STORAGE on API 26–28 only.
+ Future 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 saveCbzFromPath({
+ required String seriesDirName,
+ required String displayName,
+ required String sourcePath,
+ }) async {
+ await ensureWritePermission();
+ final relativeDir = 'Comics/$seriesDirName';
+ final uri = await _channel.invokeMethod('saveCbzFromPath', {
+ 'relativeDir': relativeDir,
+ 'displayName': displayName,
+ 'sourcePath': sourcePath,
+ });
+ if (uri == null || uri.isEmpty) {
+ throw StateError('Native saveCbzFromPath returned empty uri');
+ }
+ return uri;
+ }
+
+ Future 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('readCbzToPath', {
+ 'uri': uriOrPath,
+ 'destPath': tmp.path,
+ });
+ return await tmp.readAsBytes();
+ } finally {
+ try {
+ await tmp.delete();
+ } catch (_) {}
+ }
+ }
+
+ Future deleteCbz(String uriOrPath) async {
+ if (uriOrPath.startsWith('content://')) {
+ await _channel.invokeMethod('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 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 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 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 = [];
+ 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>(
+ 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> 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 = [];
+ 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, flush: true);
+ out.add(outPath);
+ }
+ out.sort();
+ return out;
+ }
+
+ Future> extractCbzToCache(String cbzPath, String cacheDirPath) async {
+ final bytes = await readCbzBytes(cbzPath);
+ return extractBytesToCache(bytes, cacheDirPath);
+ }
+
+ Future readCbzBytes(String cbzPath) async {
+ if (cbzPath.startsWith('content://')) {
+ return _public.readCbz(cbzPath);
+ }
+ return File(cbzPath).readAsBytes();
+ }
+
+ Future> 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 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 (_) {}
+ }
+ }
+}
diff --git a/cxyz-android/lib/services/download_queue.dart b/cxyz-android/lib/services/download_queue.dart
new file mode 100644
index 0000000..1606863
--- /dev/null
+++ b/cxyz-android/lib/services/download_queue.dart
@@ -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 _chapterIds = Queue();
+ final Set _cancelled = {};
+ /// chapterId → jobId for open jobs created at enqueue.
+ final Map _jobByChapter = {};
+ bool _running = false;
+ int? _activeChapterId;
+ double _progress = 0;
+ String _message = '';
+ int _sessionOk = 0;
+ int _sessionFailed = 0;
+ bool _sessionHadWork = false;
+ DateTime? _sessionStartedAt;
+ final List> _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 enqueueChapters(Iterable 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 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 retryChapter(int chapterId) async {
+ await enqueueChapters([chapterId]);
+ }
+
+ /// Remove offline download: delete CBZ + cache, reset chapter to pending.
+ Future 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 _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 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 _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>.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 _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 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;
+ }
+}
diff --git a/cxyz-android/lib/services/image_headers.dart b/cxyz-android/lib/services/image_headers.dart
new file mode 100644
index 0000000..2edbd43
--- /dev/null
+++ b/cxyz-android/lib/services/image_headers.dart
@@ -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 headers;
+}
+
+class _HeaderHttpFileService extends HttpFileService {
+ _HeaderHttpFileService(this.headers);
+
+ final Map headers;
+
+ @override
+ Future get(String url, {Map? headers}) {
+ final merged = {...this.headers, ...?headers};
+ return super.get(url, headers: merged);
+ }
+}
+
+ImageProvider headerNetworkImage(String url, Map headers) {
+ return NetworkImage(url, headers: headers);
+}
diff --git a/cxyz-android/lib/services/ntfy_notifier.dart b/cxyz-android/lib/services/ntfy_notifier.dart
new file mode 100644
index 0000000..3e32276
--- /dev/null
+++ b/cxyz-android/lib/services/ntfy_notifier.dart
@@ -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 = [
+ '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 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 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 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 = [];
+ 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 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 _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(
+ 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 _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(
+ 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 _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()
+ .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;
+}
diff --git a/cxyz-android/lib/services/ntfy_settings.dart b/cxyz-android/lib/services/ntfy_settings.dart
new file mode 100644
index 0000000..4a1e9c8
--- /dev/null
+++ b/cxyz-android/lib/services/ntfy_settings.dart
@@ -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 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 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);
+ }
+}
diff --git a/cxyz-android/lib/services/peek_mode.dart b/cxyz-android/lib/services/peek_mode.dart
new file mode 100644
index 0000000..1ddaf7f
--- /dev/null
+++ b/cxyz-android/lib/services/peek_mode.dart
@@ -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 load() async {
+ final prefs = await SharedPreferences.getInstance();
+ return prefs.getBool(prefKey) ?? false;
+ }
+
+ static Future save(bool enabled) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setBool(prefKey, enabled);
+ }
+}
diff --git a/cxyz-android/lib/services/reader_direction.dart b/cxyz-android/lib/services/reader_direction.dart
new file mode 100644
index 0000000..59dcc1c
--- /dev/null
+++ b/cxyz-android/lib/services/reader_direction.dart
@@ -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 load() async {
+ final prefs = await SharedPreferences.getInstance();
+ return ReadingDirection.fromStorage(prefs.getString(prefKey));
+ }
+
+ static Future save(ReadingDirection direction) async {
+ final prefs = await SharedPreferences.getInstance();
+ await prefs.setString(prefKey, direction.storageValue);
+ }
+}
diff --git a/cxyz-android/lib/services/reader_zoom.dart b/cxyz-android/lib/services/reader_zoom.dart
new file mode 100644
index 0000000..8b6e18f
--- /dev/null
+++ b/cxyz-android/lib/services/reader_zoom.dart
@@ -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 load() async {
+ final prefs = await SharedPreferences.getInstance();
+ final raw = prefs.getDouble(prefKey);
+ if (raw == null) return defaultScale;
+ return clamp(raw);
+ }
+
+ static Future 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%';
+ }
+}
diff --git a/cxyz-android/lib/services/source_reader_defaults.dart b/cxyz-android/lib/services/source_reader_defaults.dart
new file mode 100644
index 0000000..99f2363
--- /dev/null
+++ b/cxyz-android/lib/services/source_reader_defaults.dart
@@ -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;
+ }
+}
diff --git a/cxyz-android/lib/services/webdav_settings.dart b/cxyz-android/lib/services/webdav_settings.dart
new file mode 100644
index 0000000..c3ef738
--- /dev/null
+++ b/cxyz-android/lib/services/webdav_settings.dart
@@ -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 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 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 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());
+ }
+ }
+}
diff --git a/cxyz-android/lib/services/webdav_sync.dart b/cxyz-android/lib/services/webdav_sync.dart
new file mode 100644
index 0000000..6a4f6bd
--- /dev/null
+++ b/cxyz-android/lib/services/webdav_sync.dart
@@ -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 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>(
+ 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 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 _syncOnce(WebDavSettings settings, {int attempt = 0}) async {
+ final dio = _dio(settings);
+ final url = _fileUrl(settings);
+
+ String? etag;
+ Map remote = {
+ 'updatedAt': null,
+ 'titles': [],
+ 'chapters': [],
+ };
+
+ final get = await dio.get>(
+ 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;
+ }
+ }
+
+ final localBundle = await _exportLocal();
+ final merged = _merge(remote, localBundle);
+ await _applyRemote(merged);
+
+ final body = utf8.encode(const JsonEncoder.withIndent(' ').convert(merged));
+ final headers = {
+ '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>(
+ 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