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