diff --git a/CMakeLists.txt b/CMakeLists.txt
index 89b7362..242a9f4 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -17,6 +17,13 @@ endif()
find_package(Qt6 REQUIRED COMPONENTS Widgets Network Sql Svg Concurrent)
+# libsodium provides the cross-platform crypto primitives (Argon2id +
+# XChaCha20-Poly1305) used for the end-to-end encrypted library sync. It is
+# located via pkg-config (available on Linux and MSYS2/mingw for the Windows
+# build), which matches the byte-for-byte envelope used by the Android client.
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(SODIUM REQUIRED IMPORTED_TARGET libsodium)
+
qt_standard_project_setup()
set(SOURCES
@@ -68,6 +75,13 @@ set(SOURCES
src/ui/StarRating.h
src/ui/StarRating.cpp
+ src/sync/CryptoEnvelope.h
+ src/sync/CryptoEnvelope.cpp
+ src/sync/LibrarySerializer.h
+ src/sync/LibrarySerializer.cpp
+ src/sync/SyncClient.h
+ src/sync/SyncClient.cpp
+
resources/resources.qrc
)
@@ -87,6 +101,7 @@ target_link_libraries(umt PRIVATE
Qt6::Sql
Qt6::Svg
Qt6::Concurrent
+ PkgConfig::SODIUM
)
install(TARGETS umt
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..0111941
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,12 @@
+*.iml
+.gradle/
+/local.properties
+/.idea/
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/app/build/
+/.kotlin/
diff --git a/android/README.md b/android/README.md
new file mode 100644
index 0000000..54a218e
--- /dev/null
+++ b/android/README.md
@@ -0,0 +1,100 @@
+# Ultimate Media Tracker – Android
+
+Mobile Companion-App zur Desktop-Anwendung, gebaut mit **Kotlin**, **Jetpack
+Compose** und **Material 3**. Die App zeigt deine Bibliothek an, die du in der
+Desktop-App als JSON exportierst und hier importierst.
+
+## Status (MVP-Gerüst)
+
+Enthalten ist ein lauffähiges Grundgerüst mit:
+
+- Datenmodell, das 1:1 zur Desktop-App passt (`MediaType`, `WatchStatus`,
+ Segmente/Einheiten, Fortschritt).
+- Lokale Speicherung mit **Room**.
+- **JSON-Import** über den System-Dateidialog (`kotlinx.serialization`).
+- **Navigation** (Bibliotheksliste → Detailansicht) mit Navigation-Compose.
+- Material-3-Theme (dunkel/hell + Material-You-Dynamic-Color ab Android 12).
+- Bibliotheks-Übersicht als Karten-Grid mit Such- und Typ-Filter.
+- Detailansicht mit Cover, Status, Bewertung, Fortschrittsbalken und
+ **standardmäßig zugeklappten** Staffeln/Bänden (wie in der Desktop-App).
+
+Eine mitgelieferte Beispielbibliothek (`app/src/main/assets/sample-library.json`)
+wird beim ersten Start geladen, damit die App nicht leer ist.
+
+## Sync (lokal oder verschlüsselte Cloud)
+
+Unter **Einstellungen** (Zahnrad oben rechts) wählst du den Speicherort:
+
+- **Nur lokal (SQLite):** Die Bibliothek bleibt ausschließlich auf dem Gerät.
+- **Cloud-Sync:** Die komplette Bibliothek wird als ein Snapshot
+ **Ende-zu-Ende verschlüsselt** (Argon2id + XChaCha20-Poly1305 via libsodium)
+ und über den Kombi-Server (`/sync/library`) abgeglichen. Der Server speichert
+ nur Chiffretext – Passphrase und Klartext verlassen das Gerät nie.
+
+Für den Cloud-Sync trägst du **Server-URL** und **Zugriffstoken** (beides aus der
+Proxy-Anmeldung, identisch zur Desktop-App) sowie eine frei gewählte
+**Sync-Passphrase** ein. Dieselbe Passphrase muss auf allen Geräten gesetzt sein.
+Server-URL, Token und Passphrase liegen in `EncryptedSharedPreferences`
+(Android-Keystore).
+
+Synchronisiert wird die **ganze Bibliothek als Snapshot** mit Revisionsnummer
+(Last-Write-Wins). Beim Hochladen prüft der Server die Revision; bei einem
+Konflikt (zwischenzeitliche Änderung) bietet die App an, den Serverstand zu laden
+oder lokal zu überschreiben. Das Wolken-Symbol oben rechts lädt direkt herunter.
+
+## Öffnen & Bauen
+
+1. Den Ordner `android/` in **Android Studio** (Ladybug oder neuer) öffnen.
+2. Android Studio erzeugt beim ersten Sync den Gradle-Wrapper und lädt die
+ Abhängigkeiten. Alternativ per CLI: `gradle wrapper` im `android/`-Ordner,
+ danach `./gradlew assembleDebug`.
+3. Auf einem Gerät/Emulator mit **mindestens Android 8.0 (API 26)** ausführen.
+
+## JSON-Exportformat
+
+Die App liest eine Datei dieser Form (entspricht dem Desktop-`MediaItem`):
+
+```json
+{
+ "version": 1,
+ "exportedAt": "2026-06-20T12:00:00Z",
+ "items": [
+ {
+ "type": "Series", // Movie | Series | Manga | Book | Game
+ "title": "Solo Leveling",
+ "originalTitle": "...",
+ "localizedTitles": { "de": "...", "en": "..." },
+ "coverUrl": "https://.../cover.jpg",
+ "genres": ["Action"],
+ "tags": [],
+ "franchise": "",
+ "status": "InProgress", // PlanToWatch | InProgress | Completed | OnHold | Dropped
+ "rating": 9, // 0..10
+ "favorite": true,
+ "year": 2024,
+ "overview": "...",
+ "notes": "",
+ "segments": [
+ { "number": 1, "title": "Staffel 1", "units": [
+ { "number": 1, "title": "Folge 1", "watched": true, "watchedDate": "" }
+ ]}
+ ],
+ "customFields": {},
+ "dateAdded": "",
+ "dateCompleted": "",
+ "externalId": "127532",
+ "externalSource": "tmdb"
+ }
+ ]
+}
+```
+
+Wichtig: Der Import **ersetzt** die gesamte lokale Bibliothek (alles-oder-nichts;
+bei einem Parse-Fehler bleiben die vorhandenen Daten erhalten). `coverPath` aus
+der Desktop-DB wird ignoriert – auf dem Handy zählt `coverUrl`.
+
+## Noch offen / nächste Schritte
+
+- Bearbeiten/Fortschritt auf dem Handy abhaken (aktuell read-only); Änderungen
+ werden danach hochgeladen.
+- Online-Suche über den TMDB-Proxy / AniList / RAWG / OpenLibrary.
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 0000000..62e4b6c
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,80 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.kotlin.android)
+ alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.kotlin.serialization)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "com.umt.tracker"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.umt.tracker"
+ minSdk = 26
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ vectorDrawables { useSupportLibrary = true }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+ buildFeatures {
+ compose = true
+ }
+ packaging {
+ resources {
+ excludes += "/META-INF/{AL2.0,LGPL2.1}"
+ }
+ }
+}
+
+dependencies {
+ implementation(libs.androidx.core.ktx)
+ implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.lifecycle.runtime.compose)
+ implementation(libs.androidx.lifecycle.viewmodel.compose)
+ implementation(libs.androidx.activity.compose)
+
+ implementation(platform(libs.androidx.compose.bom))
+ implementation(libs.androidx.ui)
+ implementation(libs.androidx.ui.graphics)
+ implementation(libs.androidx.ui.tooling.preview)
+ implementation(libs.androidx.material3)
+ implementation(libs.androidx.material.icons.extended)
+ implementation(libs.androidx.navigation.compose)
+
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ ksp(libs.androidx.room.compiler)
+
+ implementation(libs.kotlinx.serialization.json)
+ implementation(libs.coil.compose)
+
+ // lazysodium-android pulls JNA as a plain jar; on Android we need the aar
+ // (it carries the native .so), so exclude the transitive jar and add the aar.
+ implementation(libs.lazysodium.android) {
+ exclude(group = "net.java.dev.jna", module = "jna")
+ }
+ implementation(libs.jna) { artifact { type = "aar" } }
+ implementation(libs.androidx.security.crypto)
+
+ debugImplementation(libs.androidx.ui.tooling)
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..af4cfd9
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,9 @@
+# Keep kotlinx.serialization generated serializers for our DTOs.
+-keepattributes *Annotation*, InnerClasses
+-dontnote kotlinx.serialization.**
+-keepclassmembers class com.umt.tracker.data.json.** {
+ *** Companion;
+}
+-keepclasseswithmembers class com.umt.tracker.data.json.** {
+ kotlinx.serialization.KSerializer serializer(...);
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..1786210
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/assets/sample-library.json b/android/app/src/main/assets/sample-library.json
new file mode 100644
index 0000000..f804622
--- /dev/null
+++ b/android/app/src/main/assets/sample-library.json
@@ -0,0 +1,104 @@
+{
+ "version": 1,
+ "exportedAt": "2026-06-20T12:00:00Z",
+ "items": [
+ {
+ "type": "Series",
+ "title": "Solo Leveling",
+ "originalTitle": "Ore dake Level Up na Ken",
+ "coverUrl": "https://image.tmdb.org/t/p/w500/geCRueG3Tv0BTC9DztWqVJj37N0.jpg",
+ "genres": ["Animation", "Action", "Fantasy"],
+ "status": "InProgress",
+ "rating": 9,
+ "favorite": true,
+ "year": 2024,
+ "overview": "Ein schwacher Jäger erhält die Fähigkeit, als Einziger immer stärker zu werden.",
+ "segments": [
+ {
+ "number": 1,
+ "title": "Staffel 1",
+ "units": [
+ { "number": 1, "title": "Ich bin niemand", "watched": true },
+ { "number": 2, "title": "Wenn ich stark genug wäre", "watched": true },
+ { "number": 3, "title": "Es ist, als wäre ich neu geboren", "watched": false }
+ ]
+ }
+ ],
+ "externalId": "127532",
+ "externalSource": "tmdb"
+ },
+ {
+ "type": "Series",
+ "title": "JoJo's Bizarre Adventure",
+ "originalTitle": "JoJo no Kimyō na Bōken",
+ "coverUrl": "",
+ "genres": ["Animation", "Action", "Adventure"],
+ "status": "InProgress",
+ "rating": 10,
+ "favorite": true,
+ "year": 2012,
+ "overview": "Die Joestar-Familie kämpft über Generationen gegen übernatürliche Feinde.",
+ "segments": [
+ {
+ "number": 1,
+ "title": "Phantom Blood",
+ "units": [
+ { "number": 1, "title": "Dio the Invader", "watched": true },
+ { "number": 2, "title": "A Letter from the Past", "watched": true }
+ ]
+ },
+ {
+ "number": 2,
+ "title": "Battle Tendency",
+ "units": [
+ { "number": 1, "title": "New York's JoJo", "watched": false },
+ { "number": 2, "title": "The Game Master", "watched": false }
+ ]
+ }
+ ],
+ "externalId": "45782",
+ "externalSource": "tmdb"
+ },
+ {
+ "type": "Movie",
+ "title": "Cargo",
+ "coverUrl": "https://image.tmdb.org/t/p/w500/3Pj4cWWXdySVVhCJVbB6aHrZqkA.jpg",
+ "genres": ["Drama", "Horror"],
+ "status": "Completed",
+ "rating": 7,
+ "favorite": false,
+ "year": 2017,
+ "overview": "Ein Vater sucht in einem postapokalyptischen Australien Schutz für sein Baby.",
+ "dateCompleted": "2026-05-01",
+ "externalId": "424781",
+ "externalSource": "tmdb"
+ },
+ {
+ "type": "Game",
+ "title": "Minecraft",
+ "coverUrl": "https://media.rawg.io/media/games/b4e/b4e4c73d5aa4ec66bbf75375c4847a2b.jpg",
+ "genres": ["Sandbox", "Survival"],
+ "status": "InProgress",
+ "rating": 8,
+ "favorite": false,
+ "year": 2011,
+ "overview": "Baue, erkunde und überlebe in einer prozedural erzeugten Klötzchenwelt.",
+ "externalId": "22509",
+ "externalSource": "rawg"
+ },
+ {
+ "type": "Book",
+ "title": "Harry Potter und der Stein der Weisen",
+ "originalTitle": "Harry Potter and the Philosopher's Stone",
+ "coverUrl": "https://covers.openlibrary.org/b/id/10521270-L.jpg",
+ "genres": ["Fantasy"],
+ "status": "Completed",
+ "rating": 9,
+ "favorite": true,
+ "year": 1997,
+ "overview": "Ein Waisenjunge erfährt, dass er ein Zauberer ist, und beginnt in Hogwarts.",
+ "externalId": "OL82563W",
+ "externalSource": "openlibrary"
+ }
+ ]
+}
diff --git a/android/app/src/main/java/com/umt/tracker/Graph.kt b/android/app/src/main/java/com/umt/tracker/Graph.kt
new file mode 100644
index 0000000..82fe125
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/Graph.kt
@@ -0,0 +1,28 @@
+package com.umt.tracker
+
+import android.content.Context
+import com.umt.tracker.data.LibraryRepository
+import com.umt.tracker.data.local.UmtDatabase
+import com.umt.tracker.data.sync.SyncClient
+import com.umt.tracker.data.sync.SyncSettings
+
+/**
+ * Tiny manual service locator. Keeps the scaffold dependency-injection-free while
+ * still giving the whole app one shared repository instance.
+ */
+object Graph {
+ lateinit var repository: LibraryRepository
+ private set
+ lateinit var syncSettings: SyncSettings
+ private set
+ lateinit var syncClient: SyncClient
+ private set
+
+ fun provide(context: Context) {
+ val app = context.applicationContext
+ val db = UmtDatabase.get(app)
+ repository = LibraryRepository(app, db.mediaDao())
+ syncSettings = SyncSettings(app)
+ syncClient = SyncClient(repository, syncSettings)
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/MainActivity.kt b/android/app/src/main/java/com/umt/tracker/MainActivity.kt
new file mode 100644
index 0000000..08fe34b
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/MainActivity.kt
@@ -0,0 +1,25 @@
+package com.umt.tracker
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Surface
+import androidx.compose.ui.Modifier
+import com.umt.tracker.ui.UmtNavHost
+import com.umt.tracker.ui.theme.UmtTheme
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ enableEdgeToEdge()
+ super.onCreate(savedInstanceState)
+ setContent {
+ UmtTheme {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ UmtNavHost()
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/UmtApp.kt b/android/app/src/main/java/com/umt/tracker/UmtApp.kt
new file mode 100644
index 0000000..a8e722a
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/UmtApp.kt
@@ -0,0 +1,18 @@
+package com.umt.tracker
+
+import android.app.Application
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+
+class UmtApp : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ Graph.provide(this)
+ // Seed the bundled sample library on first launch (no-op if data exists).
+ CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
+ Graph.repository.seedSampleIfEmpty()
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt b/android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt
new file mode 100644
index 0000000..3243874
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt
@@ -0,0 +1,81 @@
+package com.umt.tracker.data
+
+import android.content.Context
+import com.umt.tracker.data.json.LibraryExport
+import com.umt.tracker.data.json.LibraryJson
+import com.umt.tracker.data.json.toDomain
+import com.umt.tracker.data.json.toDto
+import com.umt.tracker.data.local.MediaDao
+import com.umt.tracker.data.local.toDomain
+import com.umt.tracker.data.local.toEntity
+import com.umt.tracker.domain.MediaItem
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+import java.io.InputStream
+
+/** Outcome of a JSON import, surfaced to the UI for a confirmation message. */
+sealed interface ImportResult {
+ data class Success(val imported: Int) : ImportResult
+ data class Failure(val message: String) : ImportResult
+}
+
+/**
+ * Single source of truth for the library. Backed by Room; the only way data
+ * enters today is a JSON import produced by the desktop app.
+ */
+class LibraryRepository(
+ private val context: Context,
+ private val dao: MediaDao,
+) {
+ fun observeLibrary(): Flow> =
+ dao.observeAll().map { rows -> rows.map { it.toDomain() } }
+
+ fun observeItem(id: Long): Flow =
+ dao.observeById(id).map { it?.toDomain() }
+
+ /**
+ * Replaces the whole library with the contents of [stream]. Import is
+ * all-or-nothing: a parse error leaves the existing data untouched.
+ */
+ suspend fun importFrom(stream: InputStream): ImportResult = withContext(Dispatchers.IO) {
+ val text = stream.bufferedReader().use { it.readText() }
+ replaceFromJson(text)
+ }
+
+ /**
+ * Replaces the whole library with [text] (a JSON [LibraryExport]). Used by
+ * both the file importer and the sync pull. All-or-nothing.
+ */
+ suspend fun replaceFromJson(text: String): ImportResult = withContext(Dispatchers.IO) {
+ try {
+ val export = LibraryJson.instance.decodeFromString(LibraryExport.serializer(), text)
+ val entities = export.items.map { it.toDomain().toEntity() }
+ dao.clear()
+ dao.upsertAll(entities)
+ ImportResult.Success(entities.size)
+ } catch (e: Exception) {
+ ImportResult.Failure(e.message ?: "Unbekannter Fehler beim Import")
+ }
+ }
+
+ /** Serializes the entire library to the canonical JSON snapshot for sync push. */
+ suspend fun snapshotJson(): String = withContext(Dispatchers.IO) {
+ val items = dao.getAll().map { it.toDomain().toDto() }
+ val export = LibraryExport(
+ version = 1,
+ exportedAt = java.time.Instant.now().toString(),
+ items = items,
+ )
+ LibraryJson.instance.encodeToString(LibraryExport.serializer(), export)
+ }
+
+ /** Seeds the bundled sample library on first launch so the app isn't empty. */
+ suspend fun seedSampleIfEmpty() = withContext(Dispatchers.IO) {
+ if (dao.count() > 0) return@withContext
+ runCatching {
+ context.assets.open("sample-library.json").use { importFrom(it) }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/data/json/LibraryExport.kt b/android/app/src/main/java/com/umt/tracker/data/json/LibraryExport.kt
new file mode 100644
index 0000000..585c31b
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/data/json/LibraryExport.kt
@@ -0,0 +1,120 @@
+package com.umt.tracker.data.json
+
+import com.umt.tracker.domain.MediaItem
+import com.umt.tracker.domain.MediaType
+import com.umt.tracker.domain.ProgressUnit
+import com.umt.tracker.domain.Segment
+import com.umt.tracker.domain.WatchStatus
+import kotlinx.serialization.Serializable
+
+/**
+ * The on-disk shape of a library export produced by the desktop app. Field names
+ * and string values mirror the desktop MediaItem so a file can round-trip.
+ */
+@Serializable
+data class LibraryExport(
+ val version: Int = 1,
+ val exportedAt: String = "",
+ val items: List = emptyList(),
+)
+
+@Serializable
+data class MediaItemDto(
+ val type: String = "Movie",
+ val title: String = "",
+ val originalTitle: String = "",
+ val localizedTitles: Map = emptyMap(),
+ val coverUrl: String = "",
+ val genres: List = emptyList(),
+ val tags: List = emptyList(),
+ val franchise: String = "",
+ val status: String = "PlanToWatch",
+ val rating: Int = 0,
+ val favorite: Boolean = false,
+ val year: Int = 0,
+ val overview: String = "",
+ val notes: String = "",
+ val segments: List = emptyList(),
+ val customFields: Map = emptyMap(),
+ val dateAdded: String = "",
+ val dateCompleted: String = "",
+ val externalId: String = "",
+ val externalSource: String = "",
+)
+
+@Serializable
+data class SegmentDto(
+ val number: Int = 0,
+ val title: String = "",
+ val units: List = emptyList(),
+)
+
+@Serializable
+data class UnitDto(
+ val number: Int = 0,
+ val title: String = "",
+ val watched: Boolean = false,
+ val watchedDate: String = "",
+)
+
+/** Maps a domain item to its wire DTO (id is dropped — it is device-local). */
+fun MediaItem.toDto(): MediaItemDto = MediaItemDto(
+ type = type.storage,
+ title = title,
+ originalTitle = originalTitle,
+ localizedTitles = localizedTitles,
+ coverUrl = coverUrl,
+ genres = genres,
+ tags = tags,
+ franchise = franchise,
+ status = status.storage,
+ rating = rating,
+ favorite = favorite,
+ year = year,
+ overview = overview,
+ notes = notes,
+ segments = segments.map { seg ->
+ SegmentDto(
+ number = seg.number,
+ title = seg.title,
+ units = seg.units.map { u -> UnitDto(u.number, u.title, u.watched, u.watchedDate) },
+ )
+ },
+ customFields = customFields,
+ dateAdded = dateAdded,
+ dateCompleted = dateCompleted,
+ externalId = externalId,
+ externalSource = externalSource,
+)
+
+/** Maps a parsed DTO to the domain model (id is left 0 so Room assigns one). */
+fun MediaItemDto.toDomain(): MediaItem = MediaItem(
+ type = MediaType.fromStorage(type),
+ title = title,
+ originalTitle = originalTitle,
+ localizedTitles = localizedTitles,
+ coverUrl = coverUrl,
+ genres = genres,
+ tags = tags,
+ franchise = franchise,
+ status = WatchStatus.fromStorage(status),
+ rating = rating,
+ favorite = favorite,
+ year = year,
+ overview = overview,
+ notes = notes,
+ segments = segments.map { seg ->
+ Segment(
+ number = seg.number,
+ title = seg.title,
+ units = seg.units.map { u ->
+ ProgressUnit(u.number, u.title, u.watched, u.watchedDate)
+ },
+ )
+ },
+ customFields = customFields,
+ dateAdded = dateAdded,
+ dateCompleted = dateCompleted,
+ externalId = externalId,
+ externalSource = externalSource,
+)
diff --git a/android/app/src/main/java/com/umt/tracker/data/json/LibraryJson.kt b/android/app/src/main/java/com/umt/tracker/data/json/LibraryJson.kt
new file mode 100644
index 0000000..f27d4ae
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/data/json/LibraryJson.kt
@@ -0,0 +1,12 @@
+package com.umt.tracker.data.json
+
+import kotlinx.serialization.json.Json
+
+/** Shared lenient Json instance used for both the export format and DB columns. */
+object LibraryJson {
+ val instance: Json = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ isLenient = true
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt b/android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt
new file mode 100644
index 0000000..d63b6e6
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt
@@ -0,0 +1,33 @@
+package com.umt.tracker.data.local
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface MediaDao {
+
+ @Query("SELECT * FROM media ORDER BY title COLLATE NOCASE")
+ fun observeAll(): Flow>
+
+ @Query("SELECT * FROM media WHERE id = :id")
+ fun observeById(id: Long): Flow
+
+ @Query("SELECT * FROM media ORDER BY title COLLATE NOCASE")
+ suspend fun getAll(): List
+
+ @Query("SELECT COUNT(*) FROM media")
+ suspend fun count(): Int
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertAll(items: List)
+
+ @Update
+ suspend fun update(item: MediaEntity)
+
+ @Query("DELETE FROM media")
+ suspend fun clear()
+}
diff --git a/android/app/src/main/java/com/umt/tracker/data/local/MediaEntity.kt b/android/app/src/main/java/com/umt/tracker/data/local/MediaEntity.kt
new file mode 100644
index 0000000..456b392
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/data/local/MediaEntity.kt
@@ -0,0 +1,94 @@
+package com.umt.tracker.data.local
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import com.umt.tracker.data.json.LibraryJson
+import com.umt.tracker.domain.MediaItem
+import com.umt.tracker.domain.MediaType
+import com.umt.tracker.domain.Segment
+import com.umt.tracker.domain.WatchStatus
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.builtins.MapSerializer
+import kotlinx.serialization.builtins.serializer
+
+/**
+ * Room row for one tracked title. Scalar fields that we filter/sort on get their
+ * own columns; the richer nested/collection fields are stored as JSON strings so
+ * the schema stays flat and trivially matches the desktop export.
+ */
+@Entity(tableName = "media")
+data class MediaEntity(
+ @PrimaryKey(autoGenerate = true) val id: Long = 0,
+ val type: String,
+ val title: String,
+ val originalTitle: String,
+ val localizedTitlesJson: String,
+ val coverUrl: String,
+ val genresJson: String,
+ val tagsJson: String,
+ val franchise: String,
+ val status: String,
+ val rating: Int,
+ val favorite: Boolean,
+ val year: Int,
+ val overview: String,
+ val notes: String,
+ val segmentsJson: String,
+ val customFieldsJson: String,
+ val dateAdded: String,
+ val dateCompleted: String,
+ val externalId: String,
+ val externalSource: String,
+)
+
+private val stringListSerializer = ListSerializer(String.serializer())
+private val stringMapSerializer = MapSerializer(String.serializer(), String.serializer())
+private val segmentListSerializer = ListSerializer(Segment.serializer())
+
+fun MediaEntity.toDomain(): MediaItem = MediaItem(
+ id = id,
+ type = MediaType.fromStorage(type),
+ title = title,
+ originalTitle = originalTitle,
+ localizedTitles = LibraryJson.instance.decodeFromString(stringMapSerializer, localizedTitlesJson),
+ coverUrl = coverUrl,
+ genres = LibraryJson.instance.decodeFromString(stringListSerializer, genresJson),
+ tags = LibraryJson.instance.decodeFromString(stringListSerializer, tagsJson),
+ franchise = franchise,
+ status = WatchStatus.fromStorage(status),
+ rating = rating,
+ favorite = favorite,
+ year = year,
+ overview = overview,
+ notes = notes,
+ segments = LibraryJson.instance.decodeFromString(segmentListSerializer, segmentsJson),
+ customFields = LibraryJson.instance.decodeFromString(stringMapSerializer, customFieldsJson),
+ dateAdded = dateAdded,
+ dateCompleted = dateCompleted,
+ externalId = externalId,
+ externalSource = externalSource,
+)
+
+fun MediaItem.toEntity(): MediaEntity = MediaEntity(
+ id = id,
+ type = type.storage,
+ title = title,
+ originalTitle = originalTitle,
+ localizedTitlesJson = LibraryJson.instance.encodeToString(stringMapSerializer, localizedTitles),
+ coverUrl = coverUrl,
+ genresJson = LibraryJson.instance.encodeToString(stringListSerializer, genres),
+ tagsJson = LibraryJson.instance.encodeToString(stringListSerializer, tags),
+ franchise = franchise,
+ status = status.storage,
+ rating = rating,
+ favorite = favorite,
+ year = year,
+ overview = overview,
+ notes = notes,
+ segmentsJson = LibraryJson.instance.encodeToString(segmentListSerializer, segments),
+ customFieldsJson = LibraryJson.instance.encodeToString(stringMapSerializer, customFields),
+ dateAdded = dateAdded,
+ dateCompleted = dateCompleted,
+ externalId = externalId,
+ externalSource = externalSource,
+)
diff --git a/android/app/src/main/java/com/umt/tracker/data/local/UmtDatabase.kt b/android/app/src/main/java/com/umt/tracker/data/local/UmtDatabase.kt
new file mode 100644
index 0000000..4947a4e
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/data/local/UmtDatabase.kt
@@ -0,0 +1,25 @@
+package com.umt.tracker.data.local
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+
+@Database(entities = [MediaEntity::class], version = 1, exportSchema = false)
+abstract class UmtDatabase : RoomDatabase() {
+ abstract fun mediaDao(): MediaDao
+
+ companion object {
+ @Volatile
+ private var instance: UmtDatabase? = null
+
+ fun get(context: Context): UmtDatabase =
+ instance ?: synchronized(this) {
+ instance ?: Room.databaseBuilder(
+ context.applicationContext,
+ UmtDatabase::class.java,
+ "umt-library.db"
+ ).fallbackToDestructiveMigration().build().also { instance = it }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt b/android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt
new file mode 100644
index 0000000..f156006
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt
@@ -0,0 +1,116 @@
+package com.umt.tracker.data.sync
+
+import com.goterl.lazysodium.LazySodiumAndroid
+import com.goterl.lazysodium.SodiumAndroid
+import com.goterl.lazysodium.interfaces.PwHash
+import com.sun.jna.NativeLong
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+
+/**
+ * Versioned, zero-knowledge encryption envelope shared byte-for-byte with the
+ * desktop app. A library snapshot is encrypted on the device with a passphrase;
+ * the server only ever stores the resulting opaque ciphertext.
+ *
+ * Byte layout (big-endian integers):
+ *
+ * magic "UMTS" (4) | version u8 = 1 | opslimit u32 | memlimit u32 |
+ * salt[16] | nonce[24] | ciphertext (XChaCha20-Poly1305 AEAD, incl. 16-byte tag)
+ *
+ * KDF: Argon2id (libsodium crypto_pwhash, ALG 1.3) → 32-byte key.
+ * AEAD: XChaCha20-Poly1305-IETF, no associated data.
+ *
+ * The opslimit/memlimit are stored in the header so a snapshot stays decryptable
+ * even if the defaults below ever change. Both platforms must agree on this
+ * exact layout and the Argon2id algorithm so either can decrypt the other's data.
+ */
+object CryptoEnvelope {
+
+ private val sodium = LazySodiumAndroid(SodiumAndroid())
+
+ private val MAGIC = byteArrayOf('U'.code.toByte(), 'M'.code.toByte(), 'T'.code.toByte(), 'S'.code.toByte())
+ private const val VERSION: Byte = 1
+
+ private const val SALT_BYTES = 16 // crypto_pwhash_SALTBYTES
+ private const val NONCE_BYTES = 24 // crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
+ private const val KEY_BYTES = 32 // crypto_aead_xchacha20poly1305_ietf_KEYBYTES
+ private const val ABYTES = 16 // crypto_aead_xchacha20poly1305_ietf_ABYTES
+
+ // libsodium INTERACTIVE limits — a balance of security and phone CPU cost.
+ private const val OPSLIMIT = 2L // crypto_pwhash_OPSLIMIT_INTERACTIVE
+ private const val MEMLIMIT = 67108864L // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB)
+
+ private const val HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES
+
+ class CryptoException(message: String) : Exception(message)
+
+ /** Encrypts [plaintext] under [passphrase] and returns the full envelope. */
+ fun encrypt(plaintext: ByteArray, passphrase: String): ByteArray {
+ val salt = sodium.randomBytesBuf(SALT_BYTES)
+ val nonce = sodium.randomBytesBuf(NONCE_BYTES)
+ val key = deriveKey(passphrase, salt, OPSLIMIT, MEMLIMIT)
+
+ val cipher = ByteArray(plaintext.size + ABYTES)
+ val cipherLen = LongArray(1)
+ val ok = sodium.cryptoAeadXChaCha20Poly1305IetfEncrypt(
+ cipher, cipherLen,
+ plaintext, plaintext.size.toLong(),
+ ByteArray(0), 0L,
+ null, nonce, key,
+ )
+ if (!ok) throw CryptoException("Verschlüsselung fehlgeschlagen")
+
+ return ByteBuffer.allocate(HEADER_BYTES + cipher.size).order(ByteOrder.BIG_ENDIAN).apply {
+ put(MAGIC)
+ put(VERSION)
+ putInt(OPSLIMIT.toInt())
+ putInt(MEMLIMIT.toInt())
+ put(salt)
+ put(nonce)
+ put(cipher)
+ }.array()
+ }
+
+ /** Decrypts an envelope produced by [encrypt] (on either platform). */
+ fun decrypt(envelope: ByteArray, passphrase: String): ByteArray {
+ if (envelope.size < HEADER_BYTES) throw CryptoException("Daten unvollständig")
+ val buf = ByteBuffer.wrap(envelope).order(ByteOrder.BIG_ENDIAN)
+
+ val magic = ByteArray(4).also { buf.get(it) }
+ if (!magic.contentEquals(MAGIC)) throw CryptoException("Unbekanntes Format")
+ val version = buf.get()
+ if (version != VERSION) throw CryptoException("Nicht unterstützte Version: $version")
+ val ops = buf.int.toLong() and 0xFFFFFFFFL
+ val mem = buf.int.toLong() and 0xFFFFFFFFL
+ val salt = ByteArray(SALT_BYTES).also { buf.get(it) }
+ val nonce = ByteArray(NONCE_BYTES).also { buf.get(it) }
+ val cipher = ByteArray(buf.remaining()).also { buf.get(it) }
+ if (cipher.size < ABYTES) throw CryptoException("Daten unvollständig")
+
+ val key = deriveKey(passphrase, salt, ops, mem)
+ val plain = ByteArray(cipher.size - ABYTES)
+ val plainLen = LongArray(1)
+ val ok = sodium.cryptoAeadXChaCha20Poly1305IetfDecrypt(
+ plain, plainLen,
+ null,
+ cipher, cipher.size.toLong(),
+ ByteArray(0), 0L,
+ nonce, key,
+ )
+ if (!ok) throw CryptoException("Entschlüsselung fehlgeschlagen (falsche Passphrase?)")
+ return plain
+ }
+
+ private fun deriveKey(passphrase: String, salt: ByteArray, ops: Long, mem: Long): ByteArray {
+ val pw = passphrase.toByteArray(Charsets.UTF_8)
+ val key = ByteArray(KEY_BYTES)
+ val ok = sodium.cryptoPwHash(
+ key, KEY_BYTES,
+ pw, pw.size,
+ salt, ops, NativeLong(mem),
+ PwHash.Alg.PWHASH_ALG_ARGON2ID13,
+ )
+ if (!ok) throw CryptoException("Schlüsselableitung fehlgeschlagen")
+ return key
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/data/sync/SyncClient.kt b/android/app/src/main/java/com/umt/tracker/data/sync/SyncClient.kt
new file mode 100644
index 0000000..0baa59e
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/data/sync/SyncClient.kt
@@ -0,0 +1,139 @@
+package com.umt.tracker.data.sync
+
+import com.umt.tracker.data.ImportResult
+import com.umt.tracker.data.LibraryRepository
+import com.umt.tracker.data.json.LibraryJson
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.Serializable
+import java.io.IOException
+import java.net.HttpURLConnection
+import java.net.URL
+import android.util.Base64
+
+/** Outcome of a sync operation, surfaced to the UI. */
+sealed interface SyncResult {
+ data class Success(val message: String) : SyncResult
+ /** Server rejected the push because its revision moved on. */
+ data class Conflict(val serverRevision: Long) : SyncResult
+ data class Error(val message: String) : SyncResult
+}
+
+@Serializable
+private data class GetResponse(val revision: Long = 0, val updatedAt: String = "", val payload: String = "")
+
+@Serializable
+private data class PutRequest(val baseRevision: Long, val payload: String)
+
+@Serializable
+private data class PutResponse(val revision: Long = 0)
+
+@Serializable
+private data class ConflictResponse(val error: String = "", val revision: Long = 0)
+
+/**
+ * Talks to the combo server's /sync/library endpoint. The whole library travels
+ * as one end-to-end-encrypted snapshot; the server only ever holds ciphertext.
+ */
+class SyncClient(
+ private val repo: LibraryRepository,
+ private val settings: SyncSettings,
+) {
+ /** Pulls the server snapshot and replaces the local library with it. */
+ suspend fun pull(): SyncResult = withContext(Dispatchers.IO) {
+ if (!settings.isConfigured) return@withContext SyncResult.Error("Sync ist nicht vollständig konfiguriert.")
+ try {
+ val conn = open("GET")
+ when (val code = conn.responseCode) {
+ HttpURLConnection.HTTP_OK -> {
+ val body = conn.inputStream.bufferedReader().use { it.readText() }
+ val resp = LibraryJson.instance.decodeFromString(GetResponse.serializer(), body)
+ val cipher = Base64.decode(resp.payload, Base64.DEFAULT)
+ val plain = CryptoEnvelope.decrypt(cipher, settings.passphrase)
+ when (val r = repo.replaceFromJson(String(plain, Charsets.UTF_8))) {
+ is ImportResult.Success -> {
+ settings.revision = resp.revision
+ SyncResult.Success("${r.imported} Einträge vom Server geladen.")
+ }
+ is ImportResult.Failure -> SyncResult.Error("Daten ungültig: ${r.message}")
+ }
+ }
+ HttpURLConnection.HTTP_NOT_FOUND ->
+ SyncResult.Error("Auf dem Server liegt noch keine Bibliothek. Lade zuerst hoch.")
+ HttpURLConnection.HTTP_UNAUTHORIZED ->
+ SyncResult.Error("Token ungültig oder abgelaufen.")
+ else -> SyncResult.Error("Serverfehler ($code).")
+ }
+ } catch (e: CryptoEnvelope.CryptoException) {
+ SyncResult.Error(e.message ?: "Entschlüsselung fehlgeschlagen.")
+ } catch (e: IOException) {
+ SyncResult.Error("Netzwerkfehler: ${e.message}")
+ }
+ }
+
+ /**
+ * Encrypts the whole local library and pushes it. [force] ignores the local
+ * revision and overwrites the server (used to resolve a conflict).
+ */
+ suspend fun push(force: Boolean = false): SyncResult = withContext(Dispatchers.IO) {
+ if (!settings.isConfigured) return@withContext SyncResult.Error("Sync ist nicht vollständig konfiguriert.")
+ try {
+ val json = repo.snapshotJson()
+ val cipher = CryptoEnvelope.encrypt(json.toByteArray(Charsets.UTF_8), settings.passphrase)
+ val base = if (force) currentServerRevision() else settings.revision
+ val req = PutRequest(
+ baseRevision = base,
+ payload = Base64.encodeToString(cipher, Base64.NO_WRAP),
+ )
+ val conn = open("PUT")
+ conn.doOutput = true
+ conn.setRequestProperty("Content-Type", "application/json")
+ conn.outputStream.use {
+ it.write(LibraryJson.instance.encodeToString(PutRequest.serializer(), req).toByteArray())
+ }
+ when (val code = conn.responseCode) {
+ HttpURLConnection.HTTP_OK -> {
+ val body = conn.inputStream.bufferedReader().use { it.readText() }
+ val resp = LibraryJson.instance.decodeFromString(PutResponse.serializer(), body)
+ settings.revision = resp.revision
+ SyncResult.Success("Bibliothek hochgeladen (Revision ${resp.revision}).")
+ }
+ HttpURLConnection.HTTP_CONFLICT -> {
+ val body = conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "{}"
+ val resp = LibraryJson.instance.decodeFromString(ConflictResponse.serializer(), body)
+ SyncResult.Conflict(resp.revision)
+ }
+ HttpURLConnection.HTTP_UNAUTHORIZED ->
+ SyncResult.Error("Token ungültig oder abgelaufen.")
+ else -> SyncResult.Error("Serverfehler ($code).")
+ }
+ } catch (e: CryptoEnvelope.CryptoException) {
+ SyncResult.Error(e.message ?: "Verschlüsselung fehlgeschlagen.")
+ } catch (e: IOException) {
+ SyncResult.Error("Netzwerkfehler: ${e.message}")
+ }
+ }
+
+ /** Reads the server's current revision without touching local data. */
+ private fun currentServerRevision(): Long {
+ val conn = open("GET")
+ return if (conn.responseCode == HttpURLConnection.HTTP_OK) {
+ val body = conn.inputStream.bufferedReader().use { it.readText() }
+ LibraryJson.instance.decodeFromString(GetResponse.serializer(), body).revision
+ } else {
+ 0L
+ }
+ }
+
+ private fun open(method: String): HttpURLConnection {
+ val url = URL(settings.serverUrl.trimEnd('/') + "/sync/library")
+ return (url.openConnection() as HttpURLConnection).apply {
+ requestMethod = method
+ connectTimeout = 15_000
+ readTimeout = 30_000
+ setRequestProperty("Authorization", "Bearer ${settings.bearerToken}")
+ setRequestProperty("Accept", "application/json")
+ setRequestProperty("User-Agent", "UltimateMediaTracker-Android/1.0")
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/data/sync/SyncSettings.kt b/android/app/src/main/java/com/umt/tracker/data/sync/SyncSettings.kt
new file mode 100644
index 0000000..12348c0
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/data/sync/SyncSettings.kt
@@ -0,0 +1,73 @@
+package com.umt.tracker.data.sync
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+
+/** Whether the library lives only on this device or syncs through the backend. */
+enum class StorageMode {
+ LOCAL,
+ CLOUD;
+
+ companion object {
+ fun fromName(name: String?): StorageMode =
+ entries.firstOrNull { it.name == name } ?: LOCAL
+ }
+}
+
+/**
+ * Persists sync configuration. The passphrase and bearer token are sensitive, so
+ * everything is kept in an EncryptedSharedPreferences file backed by the Android
+ * Keystore. The passphrase never leaves the device in plaintext.
+ */
+class SyncSettings(context: Context) {
+
+ private val prefs: SharedPreferences = run {
+ val key = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ EncryptedSharedPreferences.create(
+ context,
+ "umt_sync_settings",
+ key,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+ }
+
+ var storageMode: StorageMode
+ get() = StorageMode.fromName(prefs.getString(KEY_MODE, null))
+ set(value) = prefs.edit().putString(KEY_MODE, value.name).apply()
+
+ var serverUrl: String
+ get() = prefs.getString(KEY_URL, "") ?: ""
+ set(value) = prefs.edit().putString(KEY_URL, value.trim().trimEnd('/')).apply()
+
+ var bearerToken: String
+ get() = prefs.getString(KEY_TOKEN, "") ?: ""
+ set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
+
+ var passphrase: String
+ get() = prefs.getString(KEY_PASSPHRASE, "") ?: ""
+ set(value) = prefs.edit().putString(KEY_PASSPHRASE, value).apply()
+
+ /** Last revision known to this device, for optimistic-concurrency pushes. */
+ var revision: Long
+ get() = prefs.getLong(KEY_REVISION, 0L)
+ set(value) = prefs.edit().putLong(KEY_REVISION, value).apply()
+
+ val isCloud: Boolean get() = storageMode == StorageMode.CLOUD
+
+ /** True when every field needed for a sync round-trip is present. */
+ val isConfigured: Boolean
+ get() = serverUrl.isNotBlank() && bearerToken.isNotBlank() && passphrase.isNotBlank()
+
+ private companion object {
+ const val KEY_MODE = "storage_mode"
+ const val KEY_URL = "server_url"
+ const val KEY_TOKEN = "bearer_token"
+ const val KEY_PASSPHRASE = "passphrase"
+ const val KEY_REVISION = "revision"
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/domain/Media.kt b/android/app/src/main/java/com/umt/tracker/domain/Media.kt
new file mode 100644
index 0000000..5497e44
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/domain/Media.kt
@@ -0,0 +1,160 @@
+package com.umt.tracker.domain
+
+import kotlinx.serialization.Serializable
+
+/**
+ * The four-plus top-level kinds of media tracked by the app. [storage] is the
+ * exact string used in the JSON export and the desktop database, so the two
+ * stay interchangeable.
+ */
+enum class MediaType(val storage: String) {
+ MOVIE("Movie"),
+ SERIES("Series"),
+ MANGA("Manga"),
+ BOOK("Book"),
+ GAME("Game");
+
+ companion object {
+ fun fromStorage(s: String): MediaType =
+ entries.firstOrNull { it.storage == s } ?: MOVIE
+ }
+}
+
+/** German plural label, e.g. for filter chips ("Filme", "Serien"). */
+val MediaType.labelPlural: String
+ get() = when (this) {
+ MediaType.MOVIE -> "Filme"
+ MediaType.SERIES -> "Serien"
+ MediaType.MANGA -> "Mangas"
+ MediaType.BOOK -> "Bücher"
+ MediaType.GAME -> "Spiele"
+ }
+
+/** German singular label for a single item ("Film", "Serie"). */
+val MediaType.labelSingular: String
+ get() = when (this) {
+ MediaType.MOVIE -> "Film"
+ MediaType.SERIES -> "Serie"
+ MediaType.MANGA -> "Manga"
+ MediaType.BOOK -> "Buch"
+ MediaType.GAME -> "Spiel"
+ }
+
+/** Label for the grouping container (Staffel vs Band vs Teil). */
+val MediaType.segmentLabel: String
+ get() = when (this) {
+ MediaType.SERIES -> "Staffel"
+ MediaType.MANGA -> "Band"
+ MediaType.BOOK -> "Teil"
+ MediaType.GAME -> "Akt"
+ MediaType.MOVIE -> "Abschnitt"
+ }
+
+/** Label for the leaf unit (Folge vs Kapitel). */
+val MediaType.unitLabel: String
+ get() = when (this) {
+ MediaType.SERIES -> "Folge"
+ MediaType.MANGA -> "Kapitel"
+ MediaType.BOOK -> "Kapitel"
+ MediaType.GAME -> "Kapitel"
+ MediaType.MOVIE -> "Einheit"
+ }
+
+/** Accent colour per media type as an ARGB long, mapped to Compose Color in the UI. */
+val MediaType.accentArgb: Long
+ get() = when (this) {
+ MediaType.MOVIE -> 0xFFFF9F0A
+ MediaType.SERIES -> 0xFFBF5AF2
+ MediaType.MANGA -> 0xFFFF375F
+ MediaType.BOOK -> 0xFF64D2FF
+ MediaType.GAME -> 0xFF32D74B
+ }
+
+/** User-facing progress status. */
+enum class WatchStatus(val storage: String) {
+ PLAN_TO_WATCH("PlanToWatch"),
+ IN_PROGRESS("InProgress"),
+ COMPLETED("Completed"),
+ ON_HOLD("OnHold"),
+ DROPPED("Dropped");
+
+ companion object {
+ fun fromStorage(s: String): WatchStatus =
+ entries.firstOrNull { it.storage == s } ?: PLAN_TO_WATCH
+ }
+}
+
+val WatchStatus.label: String
+ get() = when (this) {
+ WatchStatus.PLAN_TO_WATCH -> "Geplant"
+ WatchStatus.IN_PROGRESS -> "Läuft"
+ WatchStatus.COMPLETED -> "Abgeschlossen"
+ WatchStatus.ON_HOLD -> "Pausiert"
+ WatchStatus.DROPPED -> "Abgebrochen"
+ }
+
+val WatchStatus.accentArgb: Long
+ get() = when (this) {
+ WatchStatus.PLAN_TO_WATCH -> 0xFF8E8E93
+ WatchStatus.IN_PROGRESS -> 0xFF0A84FF
+ WatchStatus.COMPLETED -> 0xFF30D158
+ WatchStatus.ON_HOLD -> 0xFFFFD60A
+ WatchStatus.DROPPED -> 0xFFFF453A
+ }
+
+/** A leaf unit of progress: one episode, chapter, etc. Named to avoid kotlin.Unit. */
+@Serializable
+data class ProgressUnit(
+ val number: Int = 0,
+ val title: String = "",
+ val watched: Boolean = false,
+ val watchedDate: String = "",
+)
+
+/** A container grouping units: a season, a manga volume, a book part. */
+@Serializable
+data class Segment(
+ val number: Int = 0,
+ val title: String = "",
+ val units: List = emptyList(),
+) {
+ val watchedCount: Int get() = units.count { it.watched }
+ val fullyWatched: Boolean get() = units.isNotEmpty() && watchedCount == units.size
+}
+
+/** The central record. One per tracked title. */
+data class MediaItem(
+ val id: Long = 0,
+ val type: MediaType = MediaType.MOVIE,
+ val title: String = "",
+ val originalTitle: String = "",
+ val localizedTitles: Map = emptyMap(),
+ val coverUrl: String = "",
+ val genres: List = emptyList(),
+ val tags: List = emptyList(),
+ val franchise: String = "",
+ val status: WatchStatus = WatchStatus.PLAN_TO_WATCH,
+ val rating: Int = 0,
+ val favorite: Boolean = false,
+ val year: Int = 0,
+ val overview: String = "",
+ val notes: String = "",
+ val segments: List = emptyList(),
+ val customFields: Map = emptyMap(),
+ val dateAdded: String = "",
+ val dateCompleted: String = "",
+ val externalId: String = "",
+ val externalSource: String = "",
+) {
+ val totalUnits: Int get() = segments.sumOf { it.units.size }
+ val watchedUnits: Int get() = segments.sumOf { it.watchedCount }
+ val hasSegments: Boolean get() = segments.isNotEmpty()
+
+ /** Fraction watched in 0f..1f. Items without units fall back to their status. */
+ val progress: Float
+ get() = if (totalUnits == 0) {
+ if (status == WatchStatus.COMPLETED) 1f else 0f
+ } else {
+ watchedUnits.toFloat() / totalUnits.toFloat()
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt b/android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt
new file mode 100644
index 0000000..2acdee3
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt
@@ -0,0 +1,42 @@
+package com.umt.tracker.ui
+
+import androidx.compose.runtime.Composable
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import com.umt.tracker.ui.detail.DetailScreen
+import com.umt.tracker.ui.library.LibraryScreen
+import com.umt.tracker.ui.settings.SettingsScreen
+
+private object Routes {
+ const val LIBRARY = "library"
+ const val DETAIL = "detail/{itemId}"
+ const val SETTINGS = "settings"
+ fun detail(id: Long) = "detail/$id"
+}
+
+@Composable
+fun UmtNavHost() {
+ val navController = rememberNavController()
+
+ NavHost(navController = navController, startDestination = Routes.LIBRARY) {
+ composable(Routes.LIBRARY) {
+ LibraryScreen(
+ onItemClick = { id -> navController.navigate(Routes.detail(id)) },
+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
+ )
+ }
+ composable(Routes.SETTINGS) {
+ SettingsScreen(onBack = { navController.popBackStack() })
+ }
+ composable(
+ route = Routes.DETAIL,
+ arguments = listOf(navArgument("itemId") { type = NavType.LongType }),
+ ) { backStackEntry ->
+ val id = backStackEntry.arguments?.getLong("itemId") ?: return@composable
+ DetailScreen(itemId = id, onBack = { navController.popBackStack() })
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/ui/components/Common.kt b/android/app/src/main/java/com/umt/tracker/ui/components/Common.kt
new file mode 100644
index 0000000..60023d1
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/components/Common.kt
@@ -0,0 +1,30 @@
+package com.umt.tracker.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+
+/** A small rounded colour chip used for media-type and status labels. */
+@Composable
+fun TagChip(
+ text: String,
+ color: Color,
+ modifier: Modifier = Modifier,
+) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelMedium,
+ color = Color.White,
+ modifier = modifier
+ .clip(RoundedCornerShape(6.dp))
+ .background(color)
+ .padding(horizontal = 8.dp, vertical = 3.dp),
+ )
+}
diff --git a/android/app/src/main/java/com/umt/tracker/ui/components/MediaCard.kt b/android/app/src/main/java/com/umt/tracker/ui/components/MediaCard.kt
new file mode 100644
index 0000000..d05e2bf
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/components/MediaCard.kt
@@ -0,0 +1,142 @@
+package com.umt.tracker.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import com.umt.tracker.domain.MediaItem
+import com.umt.tracker.domain.accentArgb
+import com.umt.tracker.domain.labelSingular
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MediaCard(
+ item: MediaItem,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Card(
+ onClick = onClick,
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(14.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ ) {
+ Column {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .aspectRatio(2f / 3f)
+ .clip(RoundedCornerShape(topStart = 14.dp, topEnd = 14.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (item.coverUrl.isNotBlank()) {
+ AsyncImage(
+ model = item.coverUrl,
+ contentDescription = item.title,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else {
+ Text(
+ text = item.type.labelSingular,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ TagChip(
+ text = item.type.labelSingular,
+ color = Color(item.type.accentArgb),
+ modifier = Modifier
+ .align(Alignment.TopStart)
+ .padding(6.dp),
+ )
+
+ if (item.favorite) {
+ Icon(
+ imageVector = Icons.Filled.Favorite,
+ contentDescription = "Favorit",
+ tint = Color(0xFFFF453A),
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(6.dp),
+ )
+ }
+ }
+
+ Column(modifier = Modifier.padding(10.dp)) {
+ Text(
+ text = item.title,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 2.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = if (item.year > 0) item.year.toString() else "",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ if (item.rating > 0) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Filled.Star,
+ contentDescription = null,
+ tint = Color(0xFFFFD60A),
+ modifier = Modifier.padding(end = 2.dp),
+ )
+ Text(
+ text = item.rating.toString(),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+
+ if (item.hasSegments) {
+ LinearProgressIndicator(
+ progress = item.progress,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 8.dp),
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt
new file mode 100644
index 0000000..35e5861
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt
@@ -0,0 +1,303 @@
+package com.umt.tracker.ui.detail
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.CheckCircle
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.RadioButtonUnchecked
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import coil.compose.AsyncImage
+import com.umt.tracker.domain.MediaItem
+import com.umt.tracker.domain.Segment
+import com.umt.tracker.domain.accentArgb
+import com.umt.tracker.domain.label
+import com.umt.tracker.domain.labelSingular
+import com.umt.tracker.domain.segmentLabel
+import com.umt.tracker.domain.unitLabel
+import com.umt.tracker.ui.components.TagChip
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DetailScreen(
+ itemId: Long,
+ onBack: () -> Unit,
+ viewModel: DetailViewModel = viewModel(factory = DetailViewModel.factory(itemId)),
+) {
+ val item by viewModel.item.collectAsStateWithLifecycle()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(item?.title ?: "") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ ),
+ )
+ },
+ ) { padding ->
+ val current = item
+ if (current == null) {
+ Box(Modifier.fillMaxSize().padding(padding), Alignment.Center) {
+ Text("Nicht gefunden", color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ } else {
+ DetailContent(current, Modifier.padding(padding))
+ }
+ }
+}
+
+@Composable
+private fun DetailContent(item: MediaItem, modifier: Modifier = Modifier) {
+ // Seasons/volumes start collapsed for overview, mirroring the desktop app.
+ val expanded = remember { mutableStateMapOf() }
+
+ LazyColumn(
+ modifier = modifier.fillMaxSize(),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ Row {
+ Box(
+ modifier = Modifier
+ .width(120.dp)
+ .height(180.dp)
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.surfaceVariant),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (item.coverUrl.isNotBlank()) {
+ AsyncImage(
+ model = item.coverUrl,
+ contentDescription = item.title,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ }
+ Column(modifier = Modifier.padding(start = 16.dp)) {
+ Text(
+ text = item.title,
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ if (item.originalTitle.isNotBlank() && item.originalTitle != item.title) {
+ Text(
+ text = item.originalTitle,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Row(
+ modifier = Modifier.padding(top = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ TagChip(item.type.labelSingular, Color(item.type.accentArgb))
+ TagChip(item.status.label, Color(item.status.accentArgb))
+ }
+ if (item.year > 0) {
+ Text(
+ text = item.year.toString(),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+ if (item.rating > 0) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(top = 4.dp),
+ ) {
+ Icon(Icons.Filled.Star, null, tint = Color(0xFFFFD60A))
+ Text(
+ text = "${item.rating} / 10",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 4.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+
+ if (item.hasSegments) {
+ item {
+ Column {
+ Text(
+ text = "Fortschritt: ${item.watchedUnits} / ${item.totalUnits}",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ LinearProgressIndicator(
+ progress = item.progress,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 6.dp),
+ )
+ }
+ }
+ items(item.segments, key = { it.number }) { segment ->
+ SegmentRow(
+ segment = segment,
+ typeSegmentLabel = item.type.segmentLabel,
+ typeUnitLabel = item.type.unitLabel,
+ expanded = expanded[segment.number] == true,
+ onToggle = { expanded[segment.number] = !(expanded[segment.number] ?: false) },
+ )
+ }
+ }
+
+ if (item.overview.isNotBlank()) {
+ item {
+ Column {
+ Text(
+ text = "Beschreibung",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ Text(
+ text = item.overview,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+ }
+ }
+
+ if (item.notes.isNotBlank()) {
+ item {
+ Column {
+ Text(
+ text = "Notizen",
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onBackground,
+ )
+ Text(
+ text = item.notes,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SegmentRow(
+ segment: Segment,
+ typeSegmentLabel: String,
+ typeUnitLabel: String,
+ expanded: Boolean,
+ onToggle: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.surface),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onToggle)
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ val name = segment.title.ifBlank { "$typeSegmentLabel ${segment.number}" }
+ Text(
+ text = name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ Text(
+ text = "${segment.watchedCount} / ${segment.units.size} $typeUnitLabel",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ Icon(
+ imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ AnimatedVisibility(visible = expanded) {
+ Column(modifier = Modifier.padding(bottom = 8.dp)) {
+ segment.units.forEach { unit ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = if (unit.watched) Icons.Filled.CheckCircle
+ else Icons.Filled.RadioButtonUnchecked,
+ contentDescription = null,
+ tint = if (unit.watched) Color(0xFF30D158)
+ else MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(20.dp),
+ )
+ Text(
+ text = unit.title.ifBlank { "$typeUnitLabel ${unit.number}" },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(start = 10.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt
new file mode 100644
index 0000000..41d8b84
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt
@@ -0,0 +1,29 @@
+package com.umt.tracker.ui.detail
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.umt.tracker.Graph
+import com.umt.tracker.data.LibraryRepository
+import com.umt.tracker.domain.MediaItem
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+
+class DetailViewModel(repo: LibraryRepository, itemId: Long) : ViewModel() {
+
+ val item: StateFlow =
+ repo.observeItem(itemId).stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = null,
+ )
+
+ companion object {
+ fun factory(itemId: Long) = object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T =
+ DetailViewModel(Graph.repository, itemId) as T
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt
new file mode 100644
index 0000000..02c94cf
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt
@@ -0,0 +1,196 @@
+package com.umt.tracker.ui.library
+
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.grid.GridCells
+import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.FileDownload
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.Sync
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.umt.tracker.domain.MediaType
+import com.umt.tracker.domain.labelPlural
+import com.umt.tracker.ui.components.MediaCard
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LibraryScreen(
+ onItemClick: (Long) -> Unit,
+ onOpenSettings: () -> Unit,
+ viewModel: LibraryViewModel = viewModel(factory = LibraryViewModel.Factory),
+) {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ val message by viewModel.messages.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ // System file picker → hand the chosen JSON to the view model for import.
+ val picker = rememberLauncherForActivityResult(
+ ActivityResultContracts.OpenDocument()
+ ) { uri ->
+ if (uri != null) {
+ viewModel.import(uri) { context.contentResolver.openInputStream(it) }
+ }
+ }
+
+ LaunchedEffect(message) {
+ message?.let {
+ snackbarHostState.showSnackbar(it)
+ viewModel.consumeMessage()
+ }
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = { Text("Media Tracker") },
+ actions = {
+ if (state.syncEnabled) {
+ IconButton(onClick = viewModel::syncNow) {
+ Icon(Icons.Filled.Sync, contentDescription = "Jetzt synchronisieren")
+ }
+ }
+ IconButton(onClick = { picker.launch(arrayOf("application/json", "*/*")) }) {
+ Icon(Icons.Filled.FileDownload, contentDescription = "Bibliothek importieren")
+ }
+ IconButton(onClick = onOpenSettings) {
+ Icon(Icons.Filled.Settings, contentDescription = "Einstellungen")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ ),
+ )
+ },
+ ) { padding ->
+ Column(modifier = Modifier.padding(padding).fillMaxSize()) {
+
+ OutlinedTextField(
+ value = state.query,
+ onValueChange = viewModel::onQueryChange,
+ singleLine = true,
+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
+ placeholder = { Text("Suchen…") },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 4.dp),
+ )
+
+ TypeFilterRow(
+ selected = state.typeFilter,
+ onSelect = viewModel::onTypeFilter,
+ )
+
+ when {
+ state.loading -> Box(Modifier.fillMaxSize(), Alignment.Center) {
+ CircularProgressIndicator()
+ }
+
+ state.items.isEmpty() -> EmptyState(
+ onImport = { picker.launch(arrayOf("application/json", "*/*")) },
+ )
+
+ else -> LazyVerticalGrid(
+ columns = GridCells.Adaptive(minSize = 150.dp),
+ contentPadding = PaddingValues(12.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ modifier = Modifier.fillMaxSize(),
+ ) {
+ items(state.items, key = { it.id }) { item ->
+ MediaCard(item = item, onClick = { onItemClick(item.id) })
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun TypeFilterRow(
+ selected: MediaType?,
+ onSelect: (MediaType?) -> Unit,
+) {
+ val entries: List = listOf(null) + MediaType.entries
+ LazyRow(
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ items(entries) { type ->
+ FilterChip(
+ selected = selected == type,
+ onClick = { onSelect(type) },
+ label = { Text(type?.labelPlural ?: "Alle") },
+ colors = FilterChipDefaults.filterChipColors(),
+ )
+ }
+ }
+}
+
+@Composable
+private fun EmptyState(onImport: () -> Unit) {
+ Box(Modifier.fillMaxSize().padding(32.dp), Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(
+ text = "Deine Bibliothek ist leer",
+ style = MaterialTheme.typography.titleLarge,
+ color = MaterialTheme.colorScheme.onBackground,
+ textAlign = TextAlign.Center,
+ )
+ Text(
+ text = "Exportiere die Bibliothek in der Desktop-App als JSON und " +
+ "importiere sie hier über das Download-Symbol oben rechts.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ androidx.compose.material3.Button(
+ onClick = onImport,
+ modifier = Modifier.padding(top = 16.dp),
+ ) {
+ Icon(Icons.Filled.FileDownload, contentDescription = null)
+ Text("JSON importieren", modifier = Modifier.padding(start = 8.dp))
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt
new file mode 100644
index 0000000..6572a1c
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt
@@ -0,0 +1,106 @@
+package com.umt.tracker.ui.library
+
+import android.net.Uri
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.umt.tracker.Graph
+import com.umt.tracker.data.ImportResult
+import com.umt.tracker.data.LibraryRepository
+import com.umt.tracker.data.sync.SyncClient
+import com.umt.tracker.data.sync.SyncResult
+import com.umt.tracker.data.sync.SyncSettings
+import com.umt.tracker.domain.MediaItem
+import com.umt.tracker.domain.MediaType
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+
+data class LibraryUiState(
+ val items: List = emptyList(),
+ val query: String = "",
+ val typeFilter: MediaType? = null,
+ val loading: Boolean = true,
+ val syncEnabled: Boolean = false,
+)
+
+class LibraryViewModel(
+ private val repo: LibraryRepository,
+ private val syncSettings: SyncSettings,
+ private val syncClient: SyncClient,
+) : ViewModel() {
+
+ private val query = MutableStateFlow("")
+ private val typeFilter = MutableStateFlow(null)
+
+ /** One-shot messages (e.g. import result) for a snackbar. */
+ val messages = MutableStateFlow(null)
+
+ val uiState: StateFlow =
+ combine(repo.observeLibrary(), query, typeFilter) { items, q, type ->
+ val filtered = items.filter { item ->
+ (type == null || item.type == type) &&
+ (q.isBlank() || item.title.contains(q, ignoreCase = true) ||
+ item.originalTitle.contains(q, ignoreCase = true))
+ }
+ LibraryUiState(
+ items = filtered,
+ query = q,
+ typeFilter = type,
+ loading = false,
+ syncEnabled = syncSettings.isCloud && syncSettings.isConfigured,
+ )
+ }.stateIn(
+ scope = viewModelScope,
+ started = SharingStarted.WhileSubscribed(5_000),
+ initialValue = LibraryUiState(),
+ )
+
+ fun onQueryChange(value: String) {
+ query.value = value
+ }
+
+ fun onTypeFilter(type: MediaType?) {
+ typeFilter.value = type
+ }
+
+ fun import(uri: Uri, openStream: (Uri) -> java.io.InputStream?) {
+ viewModelScope.launch {
+ val stream = openStream(uri)
+ if (stream == null) {
+ messages.value = "Datei konnte nicht geöffnet werden."
+ return@launch
+ }
+ messages.value = when (val r = stream.use { repo.importFrom(it) }) {
+ is ImportResult.Success -> "${r.imported} Einträge importiert."
+ is ImportResult.Failure -> "Import fehlgeschlagen: ${r.message}"
+ }
+ }
+ }
+
+ /** One-tap pull from the server (when cloud sync is configured). */
+ fun syncNow() {
+ viewModelScope.launch {
+ messages.value = when (val r = syncClient.pull()) {
+ is SyncResult.Success -> r.message
+ is SyncResult.Conflict -> "Konflikt – bitte in den Einstellungen auflösen."
+ is SyncResult.Error -> r.message
+ }
+ }
+ }
+
+ fun consumeMessage() {
+ messages.value = null
+ }
+
+ companion object {
+ val Factory = object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T =
+ LibraryViewModel(Graph.repository, Graph.syncSettings, Graph.syncClient) as T
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsScreen.kt b/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..feacfae
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsScreen.kt
@@ -0,0 +1,185 @@
+package com.umt.tracker.ui.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.umt.tracker.data.sync.StorageMode
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ onBack: () -> Unit,
+ viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory),
+) {
+ val state by viewModel.state.collectAsStateWithLifecycle()
+ val snackbarHostState = remember { SnackbarHostState() }
+
+ LaunchedEffect(state.message) {
+ state.message?.let {
+ snackbarHostState.showSnackbar(it)
+ viewModel.consumeMessage()
+ }
+ }
+
+ state.conflictRevision?.let { serverRev ->
+ AlertDialog(
+ onDismissRequest = viewModel::dismissConflict,
+ title = { Text("Konflikt beim Hochladen") },
+ text = {
+ Text(
+ "Auf dem Server gibt es eine neuere Version (Revision $serverRev). " +
+ "Du kannst sie laden oder deine lokale Bibliothek hochladen und sie überschreiben."
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = viewModel::forcePush) { Text("Lokal hochladen (überschreiben)") }
+ },
+ dismissButton = {
+ TextButton(onClick = { viewModel.dismissConflict(); viewModel.pull() }) { Text("Server laden") }
+ },
+ )
+ }
+
+ Scaffold(
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ topBar = {
+ TopAppBar(
+ title = { Text("Einstellungen") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Zurück")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = MaterialTheme.colorScheme.background,
+ titleContentColor = MaterialTheme.colorScheme.onBackground,
+ ),
+ )
+ },
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .padding(padding)
+ .fillMaxSize()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text("Speicherort", style = MaterialTheme.typography.titleMedium)
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ FilterChip(
+ selected = state.storageMode == StorageMode.LOCAL,
+ onClick = { viewModel.onStorageMode(StorageMode.LOCAL) },
+ label = { Text("Nur lokal (SQLite)") },
+ )
+ FilterChip(
+ selected = state.storageMode == StorageMode.CLOUD,
+ onClick = { viewModel.onStorageMode(StorageMode.CLOUD) },
+ label = { Text("Cloud-Sync") },
+ )
+ }
+
+ // Server config (URL + token) is available in both modes: in local
+ // mode it serves only as the TMDB proxy, in cloud mode it is also the
+ // sync endpoint.
+ Text("Server (TMDB-Proxy)", style = MaterialTheme.typography.titleMedium)
+ Text(
+ if (state.storageMode == StorageMode.CLOUD) {
+ "Server und Token bekommst du über die Anmeldung im Proxy-Server. " +
+ "Sie dienen sowohl der TMDB-Suche als auch dem verschlüsselten Sync."
+ } else {
+ "Optional: Hinterlege deinen Proxy-Server, um Filme/Serien über TMDB zu " +
+ "suchen, ohne einen eigenen API-Key zu brauchen. Deine Bibliothek " +
+ "bleibt dabei ausschließlich auf diesem Gerät."
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = state.serverUrl,
+ onValueChange = viewModel::onServerUrl,
+ label = { Text("Server-URL") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = state.bearerToken,
+ onValueChange = viewModel::onToken,
+ label = { Text("Zugriffstoken") },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ if (state.storageMode == StorageMode.CLOUD) {
+ Text(
+ "Deine Bibliothek wird Ende-zu-Ende verschlüsselt. Die Passphrase " +
+ "verlässt dein Gerät nie.",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ OutlinedTextField(
+ value = state.passphrase,
+ onValueChange = viewModel::onPassphrase,
+ label = { Text("Sync-Passphrase") },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Text(
+ "Lokale Revision: ${state.revision}",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ if (state.busy) {
+ CircularProgressIndicator()
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+ Button(
+ onClick = viewModel::push,
+ modifier = Modifier.weight(1f),
+ ) { Text("Hochladen") }
+ OutlinedButton(
+ onClick = viewModel::pull,
+ modifier = Modifier.weight(1f),
+ ) { Text("Herunterladen") }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..84be1d9
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,100 @@
+package com.umt.tracker.ui.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.umt.tracker.Graph
+import com.umt.tracker.data.sync.StorageMode
+import com.umt.tracker.data.sync.SyncClient
+import com.umt.tracker.data.sync.SyncResult
+import com.umt.tracker.data.sync.SyncSettings
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+data class SettingsUiState(
+ val storageMode: StorageMode = StorageMode.LOCAL,
+ val serverUrl: String = "",
+ val bearerToken: String = "",
+ val passphrase: String = "",
+ val revision: Long = 0,
+ val busy: Boolean = false,
+ val message: String? = null,
+ /** Set when a push hit a 409; the UI offers overwrite vs. pull. */
+ val conflictRevision: Long? = null,
+)
+
+class SettingsViewModel(
+ private val settings: SyncSettings,
+ private val client: SyncClient,
+) : ViewModel() {
+
+ private val _state = MutableStateFlow(
+ SettingsUiState(
+ storageMode = settings.storageMode,
+ serverUrl = settings.serverUrl,
+ bearerToken = settings.bearerToken,
+ passphrase = settings.passphrase,
+ revision = settings.revision,
+ )
+ )
+ val state: StateFlow = _state.asStateFlow()
+
+ fun onStorageMode(mode: StorageMode) {
+ settings.storageMode = mode
+ _state.update { it.copy(storageMode = mode) }
+ }
+
+ fun onServerUrl(value: String) {
+ settings.serverUrl = value
+ _state.update { it.copy(serverUrl = value) }
+ }
+
+ fun onToken(value: String) {
+ settings.bearerToken = value
+ _state.update { it.copy(bearerToken = value) }
+ }
+
+ fun onPassphrase(value: String) {
+ settings.passphrase = value
+ _state.update { it.copy(passphrase = value) }
+ }
+
+ fun pull() = run { client.pull() }
+
+ fun push() = run { client.push(force = false) }
+
+ /** Resolve a conflict by overwriting the server with the local library. */
+ fun forcePush() {
+ _state.update { it.copy(conflictRevision = null) }
+ run { client.push(force = true) }
+ }
+
+ fun dismissConflict() = _state.update { it.copy(conflictRevision = null) }
+
+ fun consumeMessage() = _state.update { it.copy(message = null) }
+
+ private inline fun run(crossinline op: suspend () -> SyncResult) {
+ viewModelScope.launch {
+ _state.update { it.copy(busy = true) }
+ val result = op()
+ _state.update { s ->
+ when (result) {
+ is SyncResult.Success -> s.copy(busy = false, message = result.message, revision = settings.revision)
+ is SyncResult.Conflict -> s.copy(busy = false, conflictRevision = result.serverRevision)
+ is SyncResult.Error -> s.copy(busy = false, message = result.message)
+ }
+ }
+ }
+ }
+
+ companion object {
+ val Factory = object : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T =
+ SettingsViewModel(Graph.syncSettings, Graph.syncClient) as T
+ }
+ }
+}
diff --git a/android/app/src/main/java/com/umt/tracker/ui/theme/Color.kt b/android/app/src/main/java/com/umt/tracker/ui/theme/Color.kt
new file mode 100644
index 0000000..6f3867d
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/theme/Color.kt
@@ -0,0 +1,22 @@
+package com.umt.tracker.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+// Brand accent (matches the desktop app's blue) and dark surfaces.
+val UmtBlue = Color(0xFF0A84FF)
+val UmtBlueDark = Color(0xFF0060DF)
+val UmtGreen = Color(0xFF30D158)
+
+val DarkBackground = Color(0xFF161618)
+val DarkSurface = Color(0xFF1C1C1E)
+val DarkSurfaceVariant = Color(0xFF2C2C2E)
+val DarkOutline = Color(0xFF3A3A3C)
+val DarkOnSurface = Color(0xFFF2F2F7)
+val DarkOnSurfaceVariant = Color(0xFF8E8E93)
+
+val LightBackground = Color(0xFFF7F7F9)
+val LightSurface = Color(0xFFFFFFFF)
+val LightSurfaceVariant = Color(0xFFE9E9EE)
+val LightOutline = Color(0xFFCBCBD2)
+val LightOnSurface = Color(0xFF1A1A1C)
+val LightOnSurfaceVariant = Color(0xFF5A5A60)
diff --git a/android/app/src/main/java/com/umt/tracker/ui/theme/Theme.kt b/android/app/src/main/java/com/umt/tracker/ui/theme/Theme.kt
new file mode 100644
index 0000000..75e1c07
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/theme/Theme.kt
@@ -0,0 +1,68 @@
+package com.umt.tracker.ui.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColors = darkColorScheme(
+ primary = UmtBlue,
+ onPrimary = Color.White,
+ primaryContainer = UmtBlueDark,
+ onPrimaryContainer = Color.White,
+ secondary = UmtGreen,
+ onSecondary = Color.Black,
+ background = DarkBackground,
+ onBackground = DarkOnSurface,
+ surface = DarkSurface,
+ onSurface = DarkOnSurface,
+ surfaceVariant = DarkSurfaceVariant,
+ onSurfaceVariant = DarkOnSurfaceVariant,
+ outline = DarkOutline,
+)
+
+private val LightColors = lightColorScheme(
+ primary = UmtBlue,
+ onPrimary = Color.White,
+ primaryContainer = Color(0xFFD6E7FF),
+ onPrimaryContainer = Color(0xFF00305F),
+ secondary = UmtGreen,
+ onSecondary = Color.Black,
+ background = LightBackground,
+ onBackground = LightOnSurface,
+ surface = LightSurface,
+ onSurface = LightOnSurface,
+ surfaceVariant = LightSurfaceVariant,
+ onSurfaceVariant = LightOnSurfaceVariant,
+ outline = LightOutline,
+)
+
+@Composable
+fun UmtTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ // Material You dynamic colour on Android 12+; falls back to the brand palette.
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit,
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val ctx = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(ctx) else dynamicLightColorScheme(ctx)
+ }
+
+ darkTheme -> DarkColors
+ else -> LightColors
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = UmtTypography,
+ content = content,
+ )
+}
diff --git a/android/app/src/main/java/com/umt/tracker/ui/theme/Type.kt b/android/app/src/main/java/com/umt/tracker/ui/theme/Type.kt
new file mode 100644
index 0000000..286be47
--- /dev/null
+++ b/android/app/src/main/java/com/umt/tracker/ui/theme/Type.kt
@@ -0,0 +1,41 @@
+package com.umt.tracker.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+// Lightly tuned default Material 3 type scale; uses the platform system font.
+val UmtTypography = Typography(
+ headlineMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Bold,
+ fontSize = 26.sp,
+ lineHeight = 32.sp,
+ ),
+ titleLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 20.sp,
+ lineHeight = 26.sp,
+ ),
+ titleMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 16.sp,
+ lineHeight = 22.sp,
+ ),
+ bodyMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 14.sp,
+ lineHeight = 20.sp,
+ ),
+ labelMedium = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Medium,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ ),
+)
diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..21dcd79
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..1519ad2
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,4 @@
+
+
+ #161618
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..cf0468b
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ Media Tracker
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..06c2abf
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 0000000..b3b9732
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,9 @@
+// Top-level build file. Plugin versions are declared once in gradle/libs.versions.toml
+// and applied per-module; here they are only registered (apply false).
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.kotlin.android) apply false
+ alias(libs.plugins.kotlin.compose) apply false
+ alias(libs.plugins.kotlin.serialization) apply false
+ alias(libs.plugins.ksp) apply false
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..7353e15
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,8 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+org.gradle.caching=true
+# Gradle 8.11/AGP 8.7 do not support JDK 26 (the system default here), so pin
+# the build JDK to 21. Adjust if your JDK 21 path differs or you remove JDK 26.
+org.gradle.java.home=/usr/lib/jvm/java-21-openjdk
+android.useAndroidX=true
+kotlin.code.style=official
+android.nonTransitiveRClass=true
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
new file mode 100644
index 0000000..5482281
--- /dev/null
+++ b/android/gradle/libs.versions.toml
@@ -0,0 +1,47 @@
+[versions]
+agp = "8.7.3"
+kotlin = "2.0.21"
+ksp = "2.0.21-1.0.28"
+# Pinned to versions that build against compileSdk 34 (the platform installed
+# locally), so no extra SDK platform download is needed.
+coreKtx = "1.13.1"
+lifecycle = "2.8.7"
+activityCompose = "1.9.0"
+composeBom = "2024.06.00"
+navigation = "2.7.7"
+room = "2.6.1"
+serialization = "1.7.3"
+coil = "2.7.0"
+lazysodium = "5.1.0"
+jna = "5.13.0"
+securityCrypto = "1.1.0-alpha06"
+
+[libraries]
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
+androidx-ui = { group = "androidx.compose.ui", name = "ui" }
+androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
+androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
+androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
+coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
+lazysodium-android = { group = "com.goterl", name = "lazysodium-android", version.ref = "lazysodium" }
+jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" }
+androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..e2847c8
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..f5feea6
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..9b42019
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 0000000..af30bc7
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,23 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "UltimateMediaTracker"
+include(":app")
diff --git a/build-windows.sh b/build-windows.sh
index 379177c..6f5139f 100755
--- a/build-windows.sh
+++ b/build-windows.sh
@@ -11,6 +11,13 @@
# dist/ portable build (zip & ship this folder)
# UltimateMediaTracker-Setup.exe NSIS installer (if makensis is installed)
#
+# Prerequisites (MSYS2 MINGW64):
+# pacman -S mingw-w64-x86_64-qt6 mingw-w64-x86_64-qt6-tools \
+# mingw-w64-x86_64-libsodium mingw-w64-x86_64-cmake \
+# mingw-w64-x86_64-ninja mingw-w64-x86_64-pkgconf
+# libsodium provides the crypto for the encrypted library sync; its DLL is
+# picked up automatically by the ldd bundling loop below.
+#
set -euo pipefail
cd "$(dirname "$0")"
diff --git a/server/.env.example b/server/.env.example
index 138f0ea..4a121b3 100644
--- a/server/.env.example
+++ b/server/.env.example
@@ -31,3 +31,13 @@ SESSION_SECRET=change-me-to-a-long-random-string
# How long an issued desktop-app token stays valid (in days).
TOKEN_TTL_DAYS=90
+
+# --- Library-Sync (optional) -------------------------------------------
+# PostgreSQL-DSN für den verschlüsselten Bibliotheks-Sync. Leer lassen, um
+# den Sync zu deaktivieren (dann läuft der Server als reiner TMDB-Proxy).
+# Der Server speichert nur Chiffretext (Ende-zu-Ende, Zero-Knowledge).
+# Bei Nutzung von docker-compose.yml wird dies automatisch gesetzt.
+# DATABASE_URL=postgres://umt:change-me@db:5432/umt?sslmode=disable
+
+# Passwort des Postgres-Containers (nur für docker-compose.yml relevant).
+POSTGRES_PASSWORD=change-me
diff --git a/server/config.go b/server/config.go
index f49d9ab..9ed7c2b 100644
--- a/server/config.go
+++ b/server/config.go
@@ -22,6 +22,7 @@ type Config struct {
SessionKey []byte // HMAC secret for signing access tokens / state cookies
AllowGroups []string // if set, the user must be in one of these Authentik groups
TokenTTL time.Duration // lifetime of issued desktop-app tokens
+ DatabaseURL string // PostgreSQL DSN for library sync; empty disables sync
}
func getenv(key, def string) string {
@@ -41,6 +42,7 @@ func loadConfig() (*Config, error) {
ClientID: getenv("OIDC_CLIENT_ID", ""),
ClientSecret: getenv("OIDC_CLIENT_SECRET", ""),
RedirectURL: getenv("OIDC_REDIRECT_URL", ""),
+ DatabaseURL: getenv("DATABASE_URL", ""),
}
if c.RedirectURL == "" {
diff --git a/server/docker-compose.yml b/server/docker-compose.yml
new file mode 100644
index 0000000..7c59089
--- /dev/null
+++ b/server/docker-compose.yml
@@ -0,0 +1,44 @@
+# Kombi-Server: TMDB-Proxy + verschlüsselter Bibliotheks-Sync.
+# Auf Unraid: dieses Compose-File über "Compose Manager" einbinden oder die
+# beiden Container (db + app) einzeln anlegen. Werte aus .env beziehen.
+services:
+ db:
+ image: postgres:16-alpine
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: umt
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me}
+ POSTGRES_DB: umt
+ volumes:
+ - umt_pgdata:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U umt -d umt"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+
+ app:
+ build: .
+ image: umt-combo-server:latest
+ restart: unless-stopped
+ depends_on:
+ db:
+ condition: service_healthy
+ ports:
+ - "8080:8080"
+ environment:
+ LISTEN_ADDR: ":8080"
+ PUBLIC_URL: ${PUBLIC_URL}
+ TMDB_API_KEY: ${TMDB_API_KEY}
+ OIDC_ISSUER: ${OIDC_ISSUER}
+ OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
+ OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
+ OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL}
+ SESSION_SECRET: ${SESSION_SECRET}
+ ALLOWED_GROUPS: ${ALLOWED_GROUPS:-}
+ TOKEN_TTL_DAYS: ${TOKEN_TTL_DAYS:-90}
+ # Library-Sync: leer lassen, um den Sync zu deaktivieren.
+ DATABASE_URL: postgres://umt:${POSTGRES_PASSWORD:-change-me}@db:5432/umt?sslmode=disable
+
+volumes:
+ umt_pgdata:
diff --git a/server/go.mod b/server/go.mod
index d92dc83..a28e8d4 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -4,10 +4,16 @@ go 1.22
require (
github.com/coreos/go-oidc/v3 v3.11.0
+ github.com/jackc/pgx/v5 v5.6.0
golang.org/x/oauth2 v0.21.0
)
require (
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
+ github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.25.0 // indirect
+ golang.org/x/sync v0.7.0 // indirect
+ golang.org/x/text v0.16.0 // indirect
)
diff --git a/server/go.sum b/server/go.sum
index 041e4f9..22404f5 100644
--- a/server/go.sum
+++ b/server/go.sum
@@ -1,18 +1,36 @@
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
+github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
+github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
+github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
+github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/server/main.go b/server/main.go
index 4e5b65d..fdbbcc6 100644
--- a/server/main.go
+++ b/server/main.go
@@ -27,6 +27,7 @@ type server struct {
verifier *oidc.IDTokenVerifier
oauth oauth2.Config
client *http.Client
+ store *store // nil when DATABASE_URL is unset (sync disabled)
}
func main() {
@@ -55,6 +56,18 @@ func main() {
client: &http.Client{Timeout: 15 * time.Second},
}
+ if cfg.DatabaseURL != "" {
+ st, err := openStore(cfg.DatabaseURL)
+ if err != nil {
+ log.Fatalf("database connection failed: %v", err)
+ }
+ defer st.Close()
+ s.store = st
+ log.Printf("library sync enabled (PostgreSQL)")
+ } else {
+ log.Printf("library sync disabled (DATABASE_URL unset)")
+ }
+
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
@@ -63,6 +76,7 @@ func main() {
mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/callback", s.handleCallback)
mux.HandleFunc("/3/", s.handleProxy)
+ mux.HandleFunc("/sync/library", s.handleSyncLibrary)
log.Printf("umt-tmdb-proxy listening on %s (public %s)", cfg.ListenAddr, cfg.PublicURL)
srv := &http.Server{
diff --git a/server/store.go b/server/store.go
new file mode 100644
index 0000000..793eb9a
--- /dev/null
+++ b/server/store.go
@@ -0,0 +1,136 @@
+package main
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "time"
+
+ _ "github.com/jackc/pgx/v5/stdlib"
+)
+
+// errRevisionConflict is returned by putBlob when the caller's baseRevision no
+// longer matches the revision currently stored for that user. The client must
+// re-pull and resolve before pushing again (optimistic concurrency).
+var errRevisionConflict = errors.New("revision conflict")
+
+// libraryBlob is the encrypted, opaque library snapshot stored for one user.
+// The server never sees the plaintext or the passphrase — payload is ciphertext.
+type libraryBlob struct {
+ Revision int64
+ UpdatedAt time.Time
+ Payload []byte
+}
+
+// store wraps the PostgreSQL connection used for the zero-knowledge library
+// sync. It is optional: when DATABASE_URL is unset the server runs as a plain
+// TMDB proxy and store is nil.
+type store struct {
+ db *sql.DB
+}
+
+// openStore connects to PostgreSQL and ensures the schema exists.
+func openStore(dsn string) (*store, error) {
+ db, err := sql.Open("pgx", dsn)
+ if err != nil {
+ return nil, err
+ }
+ db.SetMaxOpenConns(10)
+ db.SetConnMaxIdleTime(5 * time.Minute)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := db.PingContext(ctx); err != nil {
+ db.Close()
+ return nil, err
+ }
+ if err := migrate(ctx, db); err != nil {
+ db.Close()
+ return nil, err
+ }
+ return &store{db: db}, nil
+}
+
+func (s *store) Close() error { return s.db.Close() }
+
+func migrate(ctx context.Context, db *sql.DB) error {
+ _, err := db.ExecContext(ctx, `
+CREATE TABLE IF NOT EXISTS library_blobs (
+ user_subject TEXT PRIMARY KEY,
+ user_email TEXT,
+ revision BIGINT NOT NULL,
+ updated_at TIMESTAMPTZ NOT NULL,
+ payload BYTEA NOT NULL
+);`)
+ return err
+}
+
+// getBlob loads the stored snapshot for a user. It returns (nil, nil) when the
+// user has never pushed a library yet.
+func (s *store) getBlob(ctx context.Context, subject string) (*libraryBlob, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT revision, updated_at, payload FROM library_blobs WHERE user_subject = $1`,
+ subject)
+ var b libraryBlob
+ switch err := row.Scan(&b.Revision, &b.UpdatedAt, &b.Payload); err {
+ case nil:
+ return &b, nil
+ case sql.ErrNoRows:
+ return nil, nil
+ default:
+ return nil, err
+ }
+}
+
+// putBlob writes a new snapshot using optimistic concurrency: the write only
+// succeeds when the stored revision equals baseRevision (or the row is absent
+// and baseRevision is 0). On success the revision is incremented and returned.
+// On mismatch it returns the current revision and errRevisionConflict.
+func (s *store) putBlob(ctx context.Context, subject, email string, baseRevision int64, payload []byte) (int64, error) {
+ tx, err := s.db.BeginTx(ctx, nil)
+ if err != nil {
+ return 0, err
+ }
+ defer tx.Rollback()
+
+ var current int64
+ var exists bool
+ row := tx.QueryRowContext(ctx,
+ `SELECT revision FROM library_blobs WHERE user_subject = $1 FOR UPDATE`, subject)
+ switch err := row.Scan(¤t); err {
+ case nil:
+ exists = true
+ case sql.ErrNoRows:
+ exists = false
+ default:
+ return 0, err
+ }
+
+ if exists {
+ if current != baseRevision {
+ return current, errRevisionConflict
+ }
+ } else if baseRevision != 0 {
+ // Client thinks it is updating an existing snapshot, but none exists.
+ return 0, errRevisionConflict
+ }
+
+ next := baseRevision + 1
+ now := time.Now().UTC()
+ if exists {
+ _, err = tx.ExecContext(ctx,
+ `UPDATE library_blobs SET user_email = $2, revision = $3, updated_at = $4, payload = $5 WHERE user_subject = $1`,
+ subject, email, next, now, payload)
+ } else {
+ _, err = tx.ExecContext(ctx,
+ `INSERT INTO library_blobs (user_subject, user_email, revision, updated_at, payload) VALUES ($1, $2, $3, $4, $5)`,
+ subject, email, next, now, payload)
+ }
+ if err != nil {
+ return 0, err
+ }
+ if err := tx.Commit(); err != nil {
+ return 0, err
+ }
+ return next, nil
+}
diff --git a/server/sync.go b/server/sync.go
new file mode 100644
index 0000000..acaa3e0
--- /dev/null
+++ b/server/sync.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+)
+
+// Maximum accepted snapshot size (encrypted). Generous for a personal library.
+const maxSyncPayload = 16 << 20 // 16 MiB
+
+type syncGetResponse struct {
+ Revision int64 `json:"revision"`
+ UpdatedAt string `json:"updatedAt"`
+ Payload string `json:"payload"` // base64 ciphertext
+}
+
+type syncPutRequest struct {
+ BaseRevision int64 `json:"baseRevision"`
+ Payload string `json:"payload"` // base64 ciphertext
+}
+
+type syncPutResponse struct {
+ Revision int64 `json:"revision"`
+}
+
+type syncConflictResponse struct {
+ Error string `json:"error"`
+ Revision int64 `json:"revision"`
+}
+
+// authSubject validates the Bearer token and returns the user's subject/email.
+func (s *server) authSubject(r *http.Request) (sub, email string, ok bool) {
+ bearer := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer"))
+ claims, err := verifyToken(s.cfg.SessionKey, bearer)
+ if err != nil {
+ return "", "", false
+ }
+ return claims.Subject, claims.Email, true
+}
+
+// handleSyncLibrary dispatches GET/PUT for the encrypted library snapshot.
+func (s *server) handleSyncLibrary(w http.ResponseWriter, r *http.Request) {
+ if s.store == nil {
+ http.Error(w, "sync is not configured on this server", http.StatusNotImplemented)
+ return
+ }
+ sub, email, ok := s.authSubject(r)
+ if !ok {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ s.handleSyncGet(w, r, sub)
+ case http.MethodPut:
+ s.handleSyncPut(w, r, sub, email)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (s *server) handleSyncGet(w http.ResponseWriter, r *http.Request, sub string) {
+ blob, err := s.store.getBlob(r.Context(), sub)
+ if err != nil {
+ http.Error(w, "storage error", http.StatusInternalServerError)
+ return
+ }
+ if blob == nil {
+ http.Error(w, "no snapshot yet", http.StatusNotFound)
+ return
+ }
+ writeJSON(w, http.StatusOK, syncGetResponse{
+ Revision: blob.Revision,
+ UpdatedAt: blob.UpdatedAt.UTC().Format(time.RFC3339),
+ Payload: base64.StdEncoding.EncodeToString(blob.Payload),
+ })
+}
+
+func (s *server) handleSyncPut(w http.ResponseWriter, r *http.Request, sub, email string) {
+ var req syncPutRequest
+ body := http.MaxBytesReader(w, r.Body, maxSyncPayload+(1<<16))
+ if err := json.NewDecoder(body).Decode(&req); err != nil {
+ http.Error(w, "bad request body", http.StatusBadRequest)
+ return
+ }
+ payload, err := base64.StdEncoding.DecodeString(req.Payload)
+ if err != nil {
+ http.Error(w, "payload is not valid base64", http.StatusBadRequest)
+ return
+ }
+ if len(payload) == 0 {
+ http.Error(w, "empty payload", http.StatusBadRequest)
+ return
+ }
+ if len(payload) > maxSyncPayload {
+ http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+
+ next, err := s.store.putBlob(r.Context(), sub, email, req.BaseRevision, payload)
+ if err == errRevisionConflict {
+ writeJSON(w, http.StatusConflict, syncConflictResponse{
+ Error: "revision conflict",
+ Revision: next,
+ })
+ return
+ }
+ if err != nil {
+ http.Error(w, "storage error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, http.StatusOK, syncPutResponse{Revision: next})
+}
+
+func writeJSON(w http.ResponseWriter, status int, v any) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(v)
+}
diff --git a/src/core/Settings.cpp b/src/core/Settings.cpp
index 074d7e9..99cf13c 100644
--- a/src/core/Settings.cpp
+++ b/src/core/Settings.cpp
@@ -72,6 +72,28 @@ void AppSettings::setRawgApiKey(const QString &key) {
m_s.setValue(QStringLiteral("providers/rawgKey"), key);
}
+QString AppSettings::storageMode() const {
+ return m_s.value(QStringLiteral("sync/storageMode"),
+ QStringLiteral("local")).toString();
+}
+void AppSettings::setStorageMode(const QString &mode) {
+ m_s.setValue(QStringLiteral("sync/storageMode"), mode);
+}
+
+QString AppSettings::syncPassphrase() const {
+ return m_s.value(QStringLiteral("sync/passphrase")).toString();
+}
+void AppSettings::setSyncPassphrase(const QString &pass) {
+ m_s.setValue(QStringLiteral("sync/passphrase"), pass);
+}
+
+qlonglong AppSettings::syncRevision() const {
+ return m_s.value(QStringLiteral("sync/revision"), 0).toLongLong();
+}
+void AppSettings::setSyncRevision(qlonglong revision) {
+ m_s.setValue(QStringLiteral("sync/revision"), revision);
+}
+
bool AppSettings::autoFetchCovers() const {
return m_s.value(QStringLiteral("providers/autoCovers"), true).toBool();
}
diff --git a/src/core/Settings.h b/src/core/Settings.h
index e84cd4c..ae97488 100644
--- a/src/core/Settings.h
+++ b/src/core/Settings.h
@@ -46,6 +46,20 @@ public:
QString rawgApiKey() const;
void setRawgApiKey(const QString &key);
+ // --- Library sync ---
+ // Where the library lives: "local" (SQLite only) or "cloud" (E2E sync).
+ QString storageMode() const; // "local" | "cloud"
+ void setStorageMode(const QString &mode);
+
+ // Passphrase used to derive the end-to-end encryption key. Never leaves the
+ // device in plaintext; the server only ever sees ciphertext.
+ QString syncPassphrase() const;
+ void setSyncPassphrase(const QString &pass);
+
+ // Last library revision this device knows about (optimistic concurrency).
+ qlonglong syncRevision() const;
+ void setSyncRevision(qlonglong revision);
+
bool autoFetchCovers() const;
void setAutoFetchCovers(bool on);
diff --git a/src/sync/CryptoEnvelope.cpp b/src/sync/CryptoEnvelope.cpp
new file mode 100644
index 0000000..ba87f55
--- /dev/null
+++ b/src/sync/CryptoEnvelope.cpp
@@ -0,0 +1,158 @@
+#include "sync/CryptoEnvelope.h"
+
+#include
+#include
+
+#include
+
+namespace umt {
+namespace CryptoEnvelope {
+
+namespace {
+
+const char MAGIC[4] = { 'U', 'M', 'T', 'S' };
+const quint8 VERSION = 1;
+
+constexpr int SALT_BYTES = 16; // crypto_pwhash_SALTBYTES
+constexpr int NONCE_BYTES = 24; // crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
+constexpr int KEY_BYTES = 32; // crypto_aead_xchacha20poly1305_ietf_KEYBYTES
+constexpr int ABYTES = 16; // crypto_aead_xchacha20poly1305_ietf_ABYTES
+
+// libsodium INTERACTIVE limits — a balance of security and CPU cost. Must match
+// the Android client so snapshots are interchangeable.
+constexpr quint64 OPSLIMIT = 2ULL; // crypto_pwhash_OPSLIMIT_INTERACTIVE
+constexpr quint64 MEMLIMIT = 67108864ULL; // crypto_pwhash_MEMLIMIT_INTERACTIVE (64 MiB)
+
+constexpr int HEADER_BYTES = 4 + 1 + 4 + 4 + SALT_BYTES + NONCE_BYTES;
+
+void setError(QString *error, const QString &msg) {
+ if (error) *error = msg;
+}
+
+bool ensureInit(QString *error) {
+ static const int rc = sodium_init();
+ if (rc < 0) {
+ setError(error, QStringLiteral("Krypto-Bibliothek konnte nicht initialisiert werden"));
+ return false;
+ }
+ return true;
+}
+
+QByteArray deriveKey(const QString &passphrase, const QByteArray &salt,
+ quint64 ops, quint64 mem, QString *error) {
+ const QByteArray pw = passphrase.toUtf8();
+ QByteArray key(KEY_BYTES, Qt::Uninitialized);
+ const int rc = crypto_pwhash(
+ reinterpret_cast(key.data()), KEY_BYTES,
+ pw.constData(), static_cast(pw.size()),
+ reinterpret_cast(salt.constData()),
+ ops, static_cast(mem),
+ crypto_pwhash_ALG_ARGON2ID13);
+ if (rc != 0) {
+ setError(error, QStringLiteral("Schlüsselableitung fehlgeschlagen"));
+ return {};
+ }
+ return key;
+}
+
+} // namespace
+
+QByteArray encrypt(const QByteArray &plaintext, const QString &passphrase,
+ QString *error) {
+ if (!ensureInit(error)) return {};
+
+ QByteArray salt(SALT_BYTES, Qt::Uninitialized);
+ randombytes_buf(salt.data(), SALT_BYTES);
+ QByteArray nonce(NONCE_BYTES, Qt::Uninitialized);
+ randombytes_buf(nonce.data(), NONCE_BYTES);
+
+ const QByteArray key = deriveKey(passphrase, salt, OPSLIMIT, MEMLIMIT, error);
+ if (key.isEmpty()) return {};
+
+ QByteArray cipher(plaintext.size() + ABYTES, Qt::Uninitialized);
+ unsigned long long cipherLen = 0;
+ const int rc = crypto_aead_xchacha20poly1305_ietf_encrypt(
+ reinterpret_cast(cipher.data()), &cipherLen,
+ reinterpret_cast(plaintext.constData()),
+ static_cast(plaintext.size()),
+ nullptr, 0, // no associated data
+ nullptr, // nsec (unused)
+ reinterpret_cast(nonce.constData()),
+ reinterpret_cast(key.constData()));
+ if (rc != 0) {
+ setError(error, QStringLiteral("Verschlüsselung fehlgeschlagen"));
+ return {};
+ }
+ cipher.resize(static_cast(cipherLen));
+
+ QByteArray out;
+ out.reserve(HEADER_BYTES + cipher.size());
+ out.append(MAGIC, 4);
+ out.append(static_cast(VERSION));
+
+ quint32 opsBE = qToBigEndian(static_cast(OPSLIMIT));
+ quint32 memBE = qToBigEndian(static_cast(MEMLIMIT));
+ out.append(reinterpret_cast(&opsBE), 4);
+ out.append(reinterpret_cast(&memBE), 4);
+ out.append(salt);
+ out.append(nonce);
+ out.append(cipher);
+ return out;
+}
+
+QByteArray decrypt(const QByteArray &envelope, const QString &passphrase,
+ QString *error) {
+ if (!ensureInit(error)) return {};
+ if (envelope.size() < HEADER_BYTES) {
+ setError(error, QStringLiteral("Daten unvollständig"));
+ return {};
+ }
+
+ const char *p = envelope.constData();
+ if (qstrncmp(p, MAGIC, 4) != 0) {
+ setError(error, QStringLiteral("Unbekanntes Format"));
+ return {};
+ }
+ int off = 4;
+ const quint8 version = static_cast(p[off]); off += 1;
+ if (version != VERSION) {
+ setError(error, QStringLiteral("Nicht unterstützte Version: %1").arg(version));
+ return {};
+ }
+ quint32 opsBE, memBE;
+ memcpy(&opsBE, p + off, 4); off += 4;
+ memcpy(&memBE, p + off, 4); off += 4;
+ const quint64 ops = qFromBigEndian(opsBE);
+ const quint64 mem = qFromBigEndian(memBE);
+
+ const QByteArray salt = envelope.mid(off, SALT_BYTES); off += SALT_BYTES;
+ const QByteArray nonce = envelope.mid(off, NONCE_BYTES); off += NONCE_BYTES;
+ const QByteArray cipher = envelope.mid(off);
+ if (cipher.size() < ABYTES) {
+ setError(error, QStringLiteral("Daten unvollständig"));
+ return {};
+ }
+
+ const QByteArray key = deriveKey(passphrase, salt, ops, mem, error);
+ if (key.isEmpty()) return {};
+
+ QByteArray plain(cipher.size() - ABYTES, Qt::Uninitialized);
+ unsigned long long plainLen = 0;
+ const int rc = crypto_aead_xchacha20poly1305_ietf_decrypt(
+ reinterpret_cast(plain.data()), &plainLen,
+ nullptr, // nsec (unused)
+ reinterpret_cast(cipher.constData()),
+ static_cast(cipher.size()),
+ nullptr, 0, // no associated data
+ reinterpret_cast(nonce.constData()),
+ reinterpret_cast(key.constData()));
+ if (rc != 0) {
+ setError(error, QStringLiteral("Entschlüsselung fehlgeschlagen (falsche Passphrase?)"));
+ return {};
+ }
+ plain.resize(static_cast(plainLen));
+ return plain;
+}
+
+} // namespace CryptoEnvelope
+} // namespace umt
diff --git a/src/sync/CryptoEnvelope.h b/src/sync/CryptoEnvelope.h
new file mode 100644
index 0000000..df1b17d
--- /dev/null
+++ b/src/sync/CryptoEnvelope.h
@@ -0,0 +1,35 @@
+#pragma once
+
+#include
+#include
+
+namespace umt {
+
+// Versioned, zero-knowledge encryption envelope shared byte-for-byte with the
+// Android app. A library snapshot is encrypted on the device with a passphrase;
+// the server only ever stores the resulting opaque ciphertext.
+//
+// Byte layout (big-endian integers):
+//
+// magic "UMTS" (4) | version u8 = 1 | opslimit u32 | memlimit u32 |
+// salt[16] | nonce[24] | ciphertext (XChaCha20-Poly1305 AEAD, incl. 16-byte tag)
+//
+// KDF: Argon2id (libsodium crypto_pwhash, ALG 1.3) -> 32-byte key.
+// AEAD: XChaCha20-Poly1305-IETF, no associated data.
+//
+// Both platforms must agree on this exact layout and the Argon2id algorithm so
+// either can decrypt the other's data.
+namespace CryptoEnvelope {
+
+// Returns the full envelope, or an empty QByteArray on failure (sets *error).
+QByteArray encrypt(const QByteArray &plaintext, const QString &passphrase,
+ QString *error = nullptr);
+
+// Decrypts an envelope produced by encrypt() (on either platform). Returns the
+// plaintext, or an empty QByteArray on failure (sets *error).
+QByteArray decrypt(const QByteArray &envelope, const QString &passphrase,
+ QString *error = nullptr);
+
+} // namespace CryptoEnvelope
+
+} // namespace umt
diff --git a/src/sync/LibrarySerializer.cpp b/src/sync/LibrarySerializer.cpp
new file mode 100644
index 0000000..6f8c096
--- /dev/null
+++ b/src/sync/LibrarySerializer.cpp
@@ -0,0 +1,171 @@
+#include "sync/LibrarySerializer.h"
+
+#include
+#include
+#include
+
+namespace umt {
+namespace LibrarySerializer {
+
+namespace {
+
+QString dtToStr(const QDateTime &dt) {
+ return dt.isValid() ? dt.toString(Qt::ISODate) : QString();
+}
+QDateTime strToDt(const QString &s) {
+ return s.isEmpty() ? QDateTime() : QDateTime::fromString(s, Qt::ISODate);
+}
+
+QJsonObject unitToJson(const Unit &u) {
+ QJsonObject o;
+ o[QStringLiteral("number")] = u.number;
+ o[QStringLiteral("title")] = u.title;
+ o[QStringLiteral("watched")] = u.watched;
+ o[QStringLiteral("watchedDate")] = dtToStr(u.watchedDate);
+ return o;
+}
+
+QJsonObject segmentToJson(const Segment &s) {
+ QJsonObject o;
+ o[QStringLiteral("number")] = s.number;
+ o[QStringLiteral("title")] = s.title;
+ QJsonArray units;
+ for (const Unit &u : s.units) units.append(unitToJson(u));
+ o[QStringLiteral("units")] = units;
+ return o;
+}
+
+QJsonObject itemToJson(const MediaItem &m) {
+ QJsonObject o;
+ o[QStringLiteral("type")] = mediaTypeToString(m.type);
+ o[QStringLiteral("title")] = m.title;
+ o[QStringLiteral("originalTitle")] = m.originalTitle;
+
+ QJsonObject loc;
+ for (auto it = m.localizedTitles.constBegin(); it != m.localizedTitles.constEnd(); ++it)
+ loc[it.key()] = it.value();
+ o[QStringLiteral("localizedTitles")] = loc;
+
+ o[QStringLiteral("coverUrl")] = m.coverUrl;
+
+ QJsonArray genres;
+ for (const QString &g : m.genres) genres.append(g);
+ o[QStringLiteral("genres")] = genres;
+
+ QJsonArray tags;
+ for (const QString &t : m.tags) tags.append(t);
+ o[QStringLiteral("tags")] = tags;
+
+ o[QStringLiteral("franchise")] = m.franchise;
+ o[QStringLiteral("status")] = statusToString(m.status);
+ o[QStringLiteral("rating")] = m.rating;
+ o[QStringLiteral("favorite")] = m.favorite;
+ o[QStringLiteral("year")] = m.year;
+ o[QStringLiteral("overview")] = m.overview;
+ o[QStringLiteral("notes")] = m.notes;
+
+ QJsonArray segments;
+ for (const Segment &s : m.segments) segments.append(segmentToJson(s));
+ o[QStringLiteral("segments")] = segments;
+
+ QJsonObject custom;
+ for (auto it = m.customFields.constBegin(); it != m.customFields.constEnd(); ++it)
+ custom[it.key()] = it.value();
+ o[QStringLiteral("customFields")] = custom;
+
+ o[QStringLiteral("dateAdded")] = dtToStr(m.dateAdded);
+ o[QStringLiteral("dateCompleted")] = dtToStr(m.dateCompleted);
+ o[QStringLiteral("externalId")] = m.externalId;
+ o[QStringLiteral("externalSource")] = m.externalSource;
+ return o;
+}
+
+Unit unitFromJson(const QJsonObject &o) {
+ Unit u;
+ u.number = o.value(QStringLiteral("number")).toInt();
+ u.title = o.value(QStringLiteral("title")).toString();
+ u.watched = o.value(QStringLiteral("watched")).toBool();
+ u.watchedDate = strToDt(o.value(QStringLiteral("watchedDate")).toString());
+ return u;
+}
+
+Segment segmentFromJson(const QJsonObject &o) {
+ Segment s;
+ s.number = o.value(QStringLiteral("number")).toInt();
+ s.title = o.value(QStringLiteral("title")).toString();
+ const QJsonArray units = o.value(QStringLiteral("units")).toArray();
+ for (const QJsonValue &v : units) s.units.append(unitFromJson(v.toObject()));
+ return s;
+}
+
+MediaItem itemFromJson(const QJsonObject &o) {
+ MediaItem m;
+ m.type = mediaTypeFromString(o.value(QStringLiteral("type")).toString());
+ m.title = o.value(QStringLiteral("title")).toString();
+ m.originalTitle = o.value(QStringLiteral("originalTitle")).toString();
+
+ const QJsonObject loc = o.value(QStringLiteral("localizedTitles")).toObject();
+ for (auto it = loc.constBegin(); it != loc.constEnd(); ++it)
+ m.localizedTitles.insert(it.key(), it.value().toString());
+
+ m.coverUrl = o.value(QStringLiteral("coverUrl")).toString();
+
+ const QJsonArray genres = o.value(QStringLiteral("genres")).toArray();
+ for (const QJsonValue &v : genres) m.genres << v.toString();
+ const QJsonArray tags = o.value(QStringLiteral("tags")).toArray();
+ for (const QJsonValue &v : tags) m.tags << v.toString();
+
+ m.franchise = o.value(QStringLiteral("franchise")).toString();
+ m.status = statusFromString(o.value(QStringLiteral("status")).toString());
+ m.rating = o.value(QStringLiteral("rating")).toInt();
+ m.favorite = o.value(QStringLiteral("favorite")).toBool();
+ m.year = o.value(QStringLiteral("year")).toInt();
+ m.overview = o.value(QStringLiteral("overview")).toString();
+ m.notes = o.value(QStringLiteral("notes")).toString();
+
+ const QJsonArray segments = o.value(QStringLiteral("segments")).toArray();
+ for (const QJsonValue &v : segments) m.segments.append(segmentFromJson(v.toObject()));
+
+ const QJsonObject custom = o.value(QStringLiteral("customFields")).toObject();
+ for (auto it = custom.constBegin(); it != custom.constEnd(); ++it)
+ m.customFields.insert(it.key(), it.value().toString());
+
+ m.dateAdded = strToDt(o.value(QStringLiteral("dateAdded")).toString());
+ m.dateCompleted = strToDt(o.value(QStringLiteral("dateCompleted")).toString());
+ m.externalId = o.value(QStringLiteral("externalId")).toString();
+ m.externalSource = o.value(QStringLiteral("externalSource")).toString();
+ return m;
+}
+
+} // namespace
+
+QByteArray toJson(const QVector &items) {
+ QJsonObject root;
+ root[QStringLiteral("version")] = 1;
+ root[QStringLiteral("exportedAt")] =
+ QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
+
+ QJsonArray arr;
+ for (const MediaItem &m : items) arr.append(itemToJson(m));
+ root[QStringLiteral("items")] = arr;
+
+ return QJsonDocument(root).toJson(QJsonDocument::Compact);
+}
+
+bool fromJson(const QByteArray &json, QVector *out, QString *error) {
+ QJsonParseError perr;
+ const QJsonDocument doc = QJsonDocument::fromJson(json, &perr);
+ if (perr.error != QJsonParseError::NoError || !doc.isObject()) {
+ if (error) *error = QStringLiteral("Ungültiges JSON: %1").arg(perr.errorString());
+ return false;
+ }
+ const QJsonArray arr = doc.object().value(QStringLiteral("items")).toArray();
+ QVector items;
+ items.reserve(arr.size());
+ for (const QJsonValue &v : arr) items.append(itemFromJson(v.toObject()));
+ *out = items;
+ return true;
+}
+
+} // namespace LibrarySerializer
+} // namespace umt
diff --git a/src/sync/LibrarySerializer.h b/src/sync/LibrarySerializer.h
new file mode 100644
index 0000000..4d34f79
--- /dev/null
+++ b/src/sync/LibrarySerializer.h
@@ -0,0 +1,26 @@
+#pragma once
+
+#include "core/MediaItem.h"
+
+#include
+#include
+#include
+
+namespace umt {
+
+// Serializes the library to/from the canonical JSON snapshot shared with the
+// Android app (the `LibraryExport` schema: { version, exportedAt, items:[...] }).
+// Field names and string values mirror the desktop MediaItem so a snapshot can
+// round-trip between platforms.
+namespace LibrarySerializer {
+
+// Produces a UTF-8 JSON snapshot of the given items.
+QByteArray toJson(const QVector &items);
+
+// Parses a JSON snapshot into items. Returns true on success; on a parse error
+// returns false and leaves *out untouched (all-or-nothing).
+bool fromJson(const QByteArray &json, QVector *out, QString *error = nullptr);
+
+} // namespace LibrarySerializer
+
+} // namespace umt
diff --git a/src/sync/SyncClient.cpp b/src/sync/SyncClient.cpp
new file mode 100644
index 0000000..fbab5e1
--- /dev/null
+++ b/src/sync/SyncClient.cpp
@@ -0,0 +1,199 @@
+#include "sync/SyncClient.h"
+
+#include "core/Database.h"
+#include "core/Settings.h"
+#include "sync/CryptoEnvelope.h"
+#include "sync/LibrarySerializer.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace umt {
+
+SyncClient::SyncClient(Database *db, AppSettings *settings, QObject *parent)
+ : QObject(parent)
+ , m_db(db)
+ , m_settings(settings)
+ , m_nam(new QNetworkAccessManager(this))
+{
+}
+
+bool SyncClient::isConfigured() const {
+ return m_settings->storageMode() == QLatin1String("cloud")
+ && !baseUrl().isEmpty()
+ && !m_settings->proxyToken().isEmpty()
+ && !m_settings->syncPassphrase().isEmpty();
+}
+
+QString SyncClient::baseUrl() const {
+ QString u = m_settings->proxyUrl().trimmed();
+ while (u.endsWith(QLatin1Char('/'))) u.chop(1);
+ return u;
+}
+
+SyncClient::HttpResponse SyncClient::request(const QString &method,
+ const QString &path,
+ const QByteArray &body) {
+ HttpResponse out;
+ QNetworkRequest req(QUrl(baseUrl() + path));
+ req.setHeader(QNetworkRequest::UserAgentHeader,
+ QStringLiteral("UltimateMediaTracker/1.0"));
+ req.setRawHeader("Authorization",
+ QByteArrayLiteral("Bearer ") + m_settings->proxyToken().toUtf8());
+ if (!body.isEmpty())
+ req.setHeader(QNetworkRequest::ContentTypeHeader,
+ QStringLiteral("application/json"));
+
+ QNetworkReply *r = nullptr;
+ if (method == QLatin1String("GET"))
+ r = m_nam->get(req);
+ else if (method == QLatin1String("PUT"))
+ r = m_nam->put(req, body);
+ else
+ r = m_nam->sendCustomRequest(req, method.toUtf8(), body);
+
+ QEventLoop loop;
+ connect(r, &QNetworkReply::finished, &loop, &QEventLoop::quit);
+ loop.exec();
+
+ out.statusCode = r->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
+ out.body = r->readAll();
+ if (r->error() != QNetworkReply::NoError && out.statusCode == 0)
+ out.networkError = r->errorString();
+ r->deleteLater();
+ return out;
+}
+
+SyncClient::Result SyncClient::pull() {
+ Result res;
+ const HttpResponse resp = request(QStringLiteral("GET"),
+ QStringLiteral("/sync/library"), {});
+ if (!resp.networkError.isEmpty()) {
+ res.status = Status::Error;
+ res.message = QStringLiteral("Netzwerkfehler: %1").arg(resp.networkError);
+ return res;
+ }
+ if (resp.statusCode == 404) {
+ // No snapshot on the server yet — nothing to download.
+ res.status = Status::Success;
+ return res;
+ }
+ if (resp.statusCode != 200) {
+ res.status = Status::Error;
+ res.message = QStringLiteral("Server antwortete mit Status %1").arg(resp.statusCode);
+ return res;
+ }
+
+ const QJsonObject obj = QJsonDocument::fromJson(resp.body).object();
+ const qlonglong revision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
+ const QByteArray envelope =
+ QByteArray::fromBase64(obj.value(QStringLiteral("payload")).toString().toUtf8());
+
+ QString cryptoErr;
+ const QByteArray plain = CryptoEnvelope::decrypt(
+ envelope, m_settings->syncPassphrase(), &cryptoErr);
+ if (plain.isNull()) {
+ res.status = Status::Error;
+ res.message = cryptoErr;
+ return res;
+ }
+
+ QString replaceErr;
+ if (!replaceLibrary(plain, &replaceErr)) {
+ res.status = Status::Error;
+ res.message = replaceErr;
+ return res;
+ }
+
+ m_settings->setSyncRevision(revision);
+ res.status = Status::Success;
+ return res;
+}
+
+SyncClient::Result SyncClient::push(bool force) {
+ Result res;
+
+ qlonglong baseRevision = m_settings->syncRevision();
+ if (force) {
+ // Re-read the current server revision so the overwrite is accepted.
+ const HttpResponse cur = request(QStringLiteral("GET"),
+ QStringLiteral("/sync/library"), {});
+ if (!cur.networkError.isEmpty()) {
+ res.status = Status::Error;
+ res.message = QStringLiteral("Netzwerkfehler: %1").arg(cur.networkError);
+ return res;
+ }
+ if (cur.statusCode == 200) {
+ const QJsonObject obj = QJsonDocument::fromJson(cur.body).object();
+ baseRevision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
+ } else if (cur.statusCode == 404) {
+ baseRevision = 0;
+ }
+ }
+
+ const QVector items = m_db->queryItems(FilterCriteria{});
+ const QByteArray json = LibrarySerializer::toJson(items);
+
+ QString cryptoErr;
+ const QByteArray envelope = CryptoEnvelope::encrypt(
+ json, m_settings->syncPassphrase(), &cryptoErr);
+ if (envelope.isNull()) {
+ res.status = Status::Error;
+ res.message = cryptoErr;
+ return res;
+ }
+
+ QJsonObject reqObj;
+ reqObj[QStringLiteral("baseRevision")] = baseRevision;
+ reqObj[QStringLiteral("payload")] =
+ QString::fromUtf8(envelope.toBase64());
+ const QByteArray reqBody = QJsonDocument(reqObj).toJson(QJsonDocument::Compact);
+
+ const HttpResponse resp = request(QStringLiteral("PUT"),
+ QStringLiteral("/sync/library"), reqBody);
+ if (!resp.networkError.isEmpty()) {
+ res.status = Status::Error;
+ res.message = QStringLiteral("Netzwerkfehler: %1").arg(resp.networkError);
+ return res;
+ }
+ if (resp.statusCode == 409) {
+ const QJsonObject obj = QJsonDocument::fromJson(resp.body).object();
+ res.status = Status::Conflict;
+ res.serverRevision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
+ return res;
+ }
+ if (resp.statusCode != 200) {
+ res.status = Status::Error;
+ res.message = QStringLiteral("Server antwortete mit Status %1").arg(resp.statusCode);
+ return res;
+ }
+
+ const QJsonObject obj = QJsonDocument::fromJson(resp.body).object();
+ const qlonglong newRevision = obj.value(QStringLiteral("revision")).toVariant().toLongLong();
+ m_settings->setSyncRevision(newRevision);
+ res.status = Status::Success;
+ return res;
+}
+
+bool SyncClient::replaceLibrary(const QByteArray &snapshotJson, QString *error) {
+ QVector incoming;
+ if (!LibrarySerializer::fromJson(snapshotJson, &incoming, error))
+ return false;
+
+ // All-or-nothing replace: wipe the current library, then insert the snapshot.
+ const QVector existing = m_db->queryItems(FilterCriteria{});
+ for (const MediaItem &m : existing)
+ m_db->deleteItem(m.id);
+ for (MediaItem m : incoming) {
+ m.id = -1;
+ m_db->saveItem(m);
+ }
+ return true;
+}
+
+} // namespace umt
diff --git a/src/sync/SyncClient.h b/src/sync/SyncClient.h
new file mode 100644
index 0000000..c40166e
--- /dev/null
+++ b/src/sync/SyncClient.h
@@ -0,0 +1,66 @@
+#pragma once
+
+#include
+#include
+
+class QNetworkAccessManager;
+
+namespace umt {
+
+class Database;
+class AppSettings;
+
+// Drives the whole-library snapshot sync against the combo server's
+// /sync/library endpoint. The library is encrypted client-side (CryptoEnvelope)
+// so the server only ever stores opaque ciphertext (zero-knowledge).
+//
+// Concurrency is optimistic: each push carries the base revision it was built
+// from; the server rejects a stale base with 409, surfaced here as Conflict so
+// the caller can offer "pull server state" or "overwrite".
+//
+// The network calls run synchronously (driven by a local event loop) since they
+// are triggered by an explicit, user-initiated "Sync now" action.
+class SyncClient : public QObject {
+ Q_OBJECT
+public:
+ enum class Status { Success, Conflict, Error };
+
+ struct Result {
+ Status status = Status::Error;
+ QString message; // German, user-facing (on Error)
+ qlonglong serverRevision = 0; // valid on Conflict
+ };
+
+ SyncClient(Database *db, AppSettings *settings, QObject *parent = nullptr);
+
+ // True if cloud mode is selected and server URL / token / passphrase are set.
+ bool isConfigured() const;
+
+ // Downloads the server snapshot, decrypts it and replaces the local library.
+ // A 404 (no snapshot yet) is treated as Success (nothing to do).
+ Result pull();
+
+ // Encrypts the whole local library and uploads it. With force=false a stale
+ // base revision yields Conflict; with force=true it re-reads the server
+ // revision and overwrites unconditionally.
+ Result push(bool force = false);
+
+private:
+ struct HttpResponse {
+ int statusCode = 0;
+ QByteArray body;
+ QString networkError; // non-empty on transport failure
+ };
+ HttpResponse request(const QString &method, const QString &path,
+ const QByteArray &body);
+
+ QString baseUrl() const;
+ // Replaces the entire local library with the given snapshot (all-or-nothing).
+ bool replaceLibrary(const QByteArray &snapshotJson, QString *error);
+
+ Database *m_db;
+ AppSettings *m_settings;
+ QNetworkAccessManager *m_nam;
+};
+
+} // namespace umt
diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp
index 322212d..f212ed3 100644
--- a/src/ui/MainWindow.cpp
+++ b/src/ui/MainWindow.cpp
@@ -7,9 +7,11 @@
#include "ui/EditDialog.h"
#include "ui/SettingsDialog.h"
#include "core/Settings.h"
+#include "sync/SyncClient.h"
#include "providers/ProviderManager.h"
#include "providers/ImageCache.h"
+#include
#include
#include
#include
@@ -141,12 +143,21 @@ void MainWindow::buildUi()
connect(themeBtn, &QPushButton::clicked, this, &MainWindow::toggleTheme);
tb->addWidget(themeBtn);
+ m_syncBtn = new QPushButton(QStringLiteral("☁"), topBar);
+ m_syncBtn->setObjectName(QStringLiteral("IconButton"));
+ m_syncBtn->setFixedSize(40, rowH);
+ m_syncBtn->setToolTip(QStringLiteral("Bibliothek synchronisieren"));
+ connect(m_syncBtn, &QPushButton::clicked, this, &MainWindow::syncNow);
+ tb->addWidget(m_syncBtn);
+
auto *settingsBtn = new QPushButton(QStringLiteral("⚙"), topBar);
settingsBtn->setObjectName(QStringLiteral("IconButton"));
settingsBtn->setFixedSize(40, rowH);
connect(settingsBtn, &QPushButton::clicked, this, &MainWindow::openSettings);
tb->addWidget(settingsBtn);
+ m_syncBtn->setVisible(m_settings->storageMode() == QLatin1String("cloud"));
+
libLayout->addWidget(topBar);
// grid scroll area
@@ -364,11 +375,83 @@ void MainWindow::openSettings()
connect(&dlg, &SettingsDialog::providersConfigChanged, this, [this]{
m_providers->refreshConfig();
m_theme->apply();
+ m_syncBtn->setVisible(m_settings->storageMode() == QLatin1String("cloud"));
refresh();
});
dlg.exec();
}
+void MainWindow::syncNow()
+{
+ SyncClient client(m_db, m_settings, this);
+ if (!client.isConfigured()) {
+ QMessageBox::information(this, QStringLiteral("Synchronisieren"),
+ QStringLiteral("Cloud-Sync ist nicht vollständig konfiguriert. "
+ "Bitte in den Einstellungen den Speicherort auf „Cloud-Sync“ "
+ "stellen und Proxy-URL, Token sowie Sync-Passphrase eintragen."));
+ return;
+ }
+
+ m_syncBtn->setEnabled(false);
+ QApplication::setOverrideCursor(Qt::WaitCursor);
+
+ // Upload local changes first; resolve a conflict by asking the user.
+ SyncClient::Result up = client.push(false);
+ if (up.status == SyncClient::Status::Conflict) {
+ QApplication::restoreOverrideCursor();
+ QMessageBox box(this);
+ box.setIcon(QMessageBox::Question);
+ box.setWindowTitle(QStringLiteral("Sync-Konflikt"));
+ box.setText(QStringLiteral(
+ "Auf dem Server liegt eine neuere Version der Bibliothek "
+ "(Revision %1).\n\n"
+ "Möchtest du den Serverstand herunterladen (deine lokalen "
+ "Änderungen gehen verloren) oder lokal überschreiben "
+ "(der Serverstand wird ersetzt)?").arg(up.serverRevision));
+ QPushButton *loadBtn = box.addButton(
+ QStringLiteral("Server laden"), QMessageBox::AcceptRole);
+ box.addButton(QStringLiteral("Lokal überschreiben"), QMessageBox::DestructiveRole);
+ box.exec();
+ const bool loadServer = (box.clickedButton() == loadBtn);
+ QApplication::setOverrideCursor(Qt::WaitCursor);
+ if (loadServer) {
+ const SyncClient::Result pulled = client.pull();
+ QApplication::restoreOverrideCursor();
+ m_syncBtn->setEnabled(true);
+ if (pulled.status == SyncClient::Status::Success) {
+ rebuildFilterLists();
+ refresh();
+ QMessageBox::information(this, QStringLiteral("Synchronisieren"),
+ QStringLiteral("Serverstand geladen."));
+ } else {
+ QMessageBox::warning(this, QStringLiteral("Synchronisieren"), pulled.message);
+ }
+ return;
+ }
+ up = client.push(true); // overwrite
+ }
+
+ if (up.status != SyncClient::Status::Success) {
+ QApplication::restoreOverrideCursor();
+ m_syncBtn->setEnabled(true);
+ QMessageBox::warning(this, QStringLiteral("Synchronisieren"), up.message);
+ return;
+ }
+
+ // Then pull the canonical server state back down so this device matches it.
+ const SyncClient::Result down = client.pull();
+ QApplication::restoreOverrideCursor();
+ m_syncBtn->setEnabled(true);
+ if (down.status != SyncClient::Status::Success) {
+ QMessageBox::warning(this, QStringLiteral("Synchronisieren"), down.message);
+ return;
+ }
+ rebuildFilterLists();
+ refresh();
+ QMessageBox::information(this, QStringLiteral("Synchronisieren"),
+ QStringLiteral("Bibliothek synchronisiert."));
+}
+
void MainWindow::toggleTheme()
{
m_settings->setDarkMode(!m_settings->darkMode());
diff --git a/src/ui/MainWindow.h b/src/ui/MainWindow.h
index 5a2b88c..7352f9b 100644
--- a/src/ui/MainWindow.h
+++ b/src/ui/MainWindow.h
@@ -41,6 +41,7 @@ private slots:
void openEditExisting(int id);
void deleteItem(int id);
void openSettings();
+ void syncNow();
void toggleTheme();
private:
@@ -66,6 +67,7 @@ private:
QLineEdit *m_search;
QComboBox *m_sort;
QPushButton *m_sortDir;
+ QPushButton *m_syncBtn;
FilterPanel *m_filters;
QStackedWidget*m_stack;
QWidget *m_libraryPage;
diff --git a/src/ui/SettingsDialog.cpp b/src/ui/SettingsDialog.cpp
index 30bdc40..6668a89 100644
--- a/src/ui/SettingsDialog.cpp
+++ b/src/ui/SettingsDialog.cpp
@@ -142,6 +142,44 @@ SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent)
"Ohne Key funktionieren Filme, Serien, Mangas und Bücher weiterhin – nur "
"die Spiele-Suche braucht ihn.")));
+ m_storageMode = new QComboBox(this);
+ m_storageMode->addItem(QStringLiteral("Nur lokal (SQLite)"), QStringLiteral("local"));
+ m_storageMode->addItem(QStringLiteral("Cloud-Sync (verschlüsselt)"), QStringLiteral("cloud"));
+ m_storageMode->setCurrentIndex(
+ qMax(0, m_storageMode->findData(m_settings->storageMode())));
+ form->addRow(QStringLiteral("Speicherort"), withHelp(m_storageMode,
+ QStringLiteral("Speicherort der Bibliothek"),
+ QStringLiteral(
+ "Wo liegt deine Bibliothek?"
+ ""
+ "- Nur lokal: Die Bibliothek bleibt ausschließlich auf diesem "
+ "Gerät (SQLite). Kein Netzwerk-Zugriff.
"
+ "- Cloud-Sync: Die komplette Bibliothek wird "
+ "Ende-zu-Ende verschlüsselt (Argon2id + XChaCha20-Poly1305) und "
+ "über den Server abgeglichen. Der Server speichert nur Chiffretext – "
+ "Passphrase und Klartext verlassen das Gerät nie.
"
+ "
"
+ "Server-URL und Token werden aus der Proxy-Anmeldung oben "
+ "wiederverwendet.")));
+
+ m_syncPassphrase = new QLineEdit(this);
+ m_syncPassphrase->setText(m_settings->syncPassphrase());
+ m_syncPassphrase->setEchoMode(QLineEdit::Password);
+ m_syncPassphrase->setPlaceholderText(QStringLiteral("Frei wählbare Sync-Passphrase"));
+ form->addRow(QStringLiteral("Sync-Passphrase"), withHelp(m_syncPassphrase,
+ QStringLiteral("Sync-Passphrase"),
+ QStringLiteral(
+ "Die Sync-Passphrase ver- und entschlüsselt deine Bibliothek."
+ ""
+ "- Wähle eine starke, frei gewählte Passphrase.
"
+ "- Dieselbe Passphrase muss auf allen Geräten (Desktop und "
+ "Handy) gesetzt sein, damit sie sich gegenseitig entschlüsseln "
+ "können.
"
+ "- Sie verlässt dein Gerät nie und wird nicht auf dem Server "
+ "gespeichert. Geht sie verloren, sind die Cloud-Daten nicht mehr "
+ "lesbar.
"
+ "
")));
+
m_autoCovers = new QCheckBox(QStringLiteral("Cover automatisch laden"), this);
m_autoCovers->setChecked(m_settings->autoFetchCovers());
form->addRow(QString(), m_autoCovers);
@@ -180,6 +218,8 @@ SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent)
m_settings->setProxyUrl(m_proxyUrl->text().trimmed());
m_settings->setProxyToken(m_proxyToken->text().trimmed());
m_settings->setRawgApiKey(m_rawgKey->text().trimmed());
+ m_settings->setStorageMode(m_storageMode->currentData().toString());
+ m_settings->setSyncPassphrase(m_syncPassphrase->text());
m_settings->setAutoFetchCovers(m_autoCovers->isChecked());
m_settings->setCardWidth(m_cardSize->value());
emit providersConfigChanged();
diff --git a/src/ui/SettingsDialog.h b/src/ui/SettingsDialog.h
index 955dffb..d7a0a58 100644
--- a/src/ui/SettingsDialog.h
+++ b/src/ui/SettingsDialog.h
@@ -40,6 +40,8 @@ private:
QPushButton *m_proxyLogin;
QLineEdit *m_proxyToken;
QLineEdit *m_rawgKey;
+ QComboBox *m_storageMode;
+ QLineEdit *m_syncPassphrase;
QCheckBox *m_autoCovers;
QSlider *m_cardSize;
};