From de353f0218361de39157da78999534856768e6f5 Mon Sep 17 00:00:00 2001 From: Tronax Date: Sun, 21 Jun 2026 00:35:54 +0200 Subject: [PATCH] feat: add Android app and end-to-end encrypted library sync Introduce the Kotlin/Compose Android companion app and extend the TMDB proxy into a combo server that synchronizes the whole library as an encrypted snapshot, with all three components (Go server, Android, desktop) sharing one zero-knowledge crypto envelope and JSON schema. - server: add PostgreSQL-backed /sync/library (GET/PUT) with optimistic concurrency (revision + 409 on conflict); sync stays disabled unless DATABASE_URL is set. Add docker-compose with Postgres. - desktop: add libsodium CryptoEnvelope, LibrarySerializer and SyncClient; storage-mode and passphrase settings; a "Sync now" toolbar action with conflict resolution. - android: Room-backed library with local/cloud storage modes, encrypted sync client, and settings UI. The proxy server (URL + token) can be configured as a TMDB proxy even in local-only mode. Crypto is identical across platforms: Argon2id (libsodium crypto_pwhash, INTERACTIVE) + XChaCha20-Poly1305 in a versioned "UMTS" envelope, so a snapshot encrypted on one client decrypts on the other. Co-Authored-By: Claude Opus 4.6 --- CMakeLists.txt | 15 + android/.gitignore | 12 + android/README.md | 100 ++++++ android/app/build.gradle.kts | 80 +++++ android/app/proguard-rules.pro | 9 + android/app/src/main/AndroidManifest.xml | 25 ++ .../app/src/main/assets/sample-library.json | 104 ++++++ .../src/main/java/com/umt/tracker/Graph.kt | 28 ++ .../main/java/com/umt/tracker/MainActivity.kt | 25 ++ .../src/main/java/com/umt/tracker/UmtApp.kt | 18 ++ .../com/umt/tracker/data/LibraryRepository.kt | 81 +++++ .../umt/tracker/data/json/LibraryExport.kt | 120 +++++++ .../com/umt/tracker/data/json/LibraryJson.kt | 12 + .../com/umt/tracker/data/local/MediaDao.kt | 33 ++ .../com/umt/tracker/data/local/MediaEntity.kt | 94 ++++++ .../com/umt/tracker/data/local/UmtDatabase.kt | 25 ++ .../umt/tracker/data/sync/CryptoEnvelope.kt | 116 +++++++ .../com/umt/tracker/data/sync/SyncClient.kt | 139 ++++++++ .../com/umt/tracker/data/sync/SyncSettings.kt | 73 +++++ .../main/java/com/umt/tracker/domain/Media.kt | 160 +++++++++ .../java/com/umt/tracker/ui/UmtNavHost.kt | 42 +++ .../com/umt/tracker/ui/components/Common.kt | 30 ++ .../umt/tracker/ui/components/MediaCard.kt | 142 ++++++++ .../com/umt/tracker/ui/detail/DetailScreen.kt | 303 ++++++++++++++++++ .../umt/tracker/ui/detail/DetailViewModel.kt | 29 ++ .../umt/tracker/ui/library/LibraryScreen.kt | 196 +++++++++++ .../tracker/ui/library/LibraryViewModel.kt | 106 ++++++ .../umt/tracker/ui/settings/SettingsScreen.kt | 185 +++++++++++ .../tracker/ui/settings/SettingsViewModel.kt | 100 ++++++ .../java/com/umt/tracker/ui/theme/Color.kt | 22 ++ .../java/com/umt/tracker/ui/theme/Theme.kt | 68 ++++ .../java/com/umt/tracker/ui/theme/Type.kt | 41 +++ .../app/src/main/res/drawable/ic_launcher.xml | 12 + android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/strings.xml | 3 + android/app/src/main/res/values/themes.xml | 8 + android/build.gradle.kts | 9 + android/gradle.properties | 8 + android/gradle/libs.versions.toml | 47 +++ android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + android/gradlew | 252 +++++++++++++++ android/gradlew.bat | 94 ++++++ android/settings.gradle.kts | 23 ++ build-windows.sh | 7 + server/.env.example | 10 + server/config.go | 2 + server/docker-compose.yml | 44 +++ server/go.mod | 6 + server/go.sum | 18 ++ server/main.go | 14 + server/store.go | 136 ++++++++ server/sync.go | 123 +++++++ src/core/Settings.cpp | 22 ++ src/core/Settings.h | 14 + src/sync/CryptoEnvelope.cpp | 158 +++++++++ src/sync/CryptoEnvelope.h | 35 ++ src/sync/LibrarySerializer.cpp | 171 ++++++++++ src/sync/LibrarySerializer.h | 26 ++ src/sync/SyncClient.cpp | 199 ++++++++++++ src/sync/SyncClient.h | 66 ++++ src/ui/MainWindow.cpp | 83 +++++ src/ui/MainWindow.h | 2 + src/ui/SettingsDialog.cpp | 40 +++ src/ui/SettingsDialog.h | 2 + 65 files changed, 4178 insertions(+) create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/assets/sample-library.json create mode 100644 android/app/src/main/java/com/umt/tracker/Graph.kt create mode 100644 android/app/src/main/java/com/umt/tracker/MainActivity.kt create mode 100644 android/app/src/main/java/com/umt/tracker/UmtApp.kt create mode 100644 android/app/src/main/java/com/umt/tracker/data/LibraryRepository.kt create mode 100644 android/app/src/main/java/com/umt/tracker/data/json/LibraryExport.kt create mode 100644 android/app/src/main/java/com/umt/tracker/data/json/LibraryJson.kt create mode 100644 android/app/src/main/java/com/umt/tracker/data/local/MediaDao.kt create mode 100644 android/app/src/main/java/com/umt/tracker/data/local/MediaEntity.kt create mode 100644 android/app/src/main/java/com/umt/tracker/data/local/UmtDatabase.kt create mode 100644 android/app/src/main/java/com/umt/tracker/data/sync/CryptoEnvelope.kt create mode 100644 android/app/src/main/java/com/umt/tracker/data/sync/SyncClient.kt create mode 100644 android/app/src/main/java/com/umt/tracker/data/sync/SyncSettings.kt create mode 100644 android/app/src/main/java/com/umt/tracker/domain/Media.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/UmtNavHost.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/components/Common.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/components/MediaCard.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/detail/DetailScreen.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/detail/DetailViewModel.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/library/LibraryScreen.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/library/LibraryViewModel.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/settings/SettingsScreen.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/settings/SettingsViewModel.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/theme/Color.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/theme/Theme.kt create mode 100644 android/app/src/main/java/com/umt/tracker/ui/theme/Type.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/libs.versions.toml create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 server/docker-compose.yml create mode 100644 server/store.go create mode 100644 server/sync.go create mode 100644 src/sync/CryptoEnvelope.cpp create mode 100644 src/sync/CryptoEnvelope.h create mode 100644 src/sync/LibrarySerializer.cpp create mode 100644 src/sync/LibrarySerializer.h create mode 100644 src/sync/SyncClient.cpp create mode 100644 src/sync/SyncClient.h 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 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 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; };