feat: add Android app and end-to-end encrypted library sync

Introduce the Kotlin/Compose Android companion app and extend the TMDB
proxy into a combo server that synchronizes the whole library as an
encrypted snapshot, with all three components (Go server, Android,
desktop) sharing one zero-knowledge crypto envelope and JSON schema.

- server: add PostgreSQL-backed /sync/library (GET/PUT) with optimistic
  concurrency (revision + 409 on conflict); sync stays disabled unless
  DATABASE_URL is set. Add docker-compose with Postgres.
- desktop: add libsodium CryptoEnvelope, LibrarySerializer and SyncClient;
  storage-mode and passphrase settings; a "Sync now" toolbar action with
  conflict resolution.
- android: Room-backed library with local/cloud storage modes, encrypted
  sync client, and settings UI. The proxy server (URL + token) can be
  configured as a TMDB proxy even in local-only mode.

Crypto is identical across platforms: Argon2id (libsodium crypto_pwhash,
INTERACTIVE) + XChaCha20-Poly1305 in a versioned "UMTS" envelope, so a
snapshot encrypted on one client decrypts on the other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Tronax 2026-06-21 00:35:54 +02:00
parent 83a26ef0cf
commit de353f0218
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
65 changed files with 4178 additions and 0 deletions

View file

@ -17,6 +17,13 @@ endif()
find_package(Qt6 REQUIRED COMPONENTS Widgets Network Sql Svg Concurrent) 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() qt_standard_project_setup()
set(SOURCES set(SOURCES
@ -68,6 +75,13 @@ set(SOURCES
src/ui/StarRating.h src/ui/StarRating.h
src/ui/StarRating.cpp 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 resources/resources.qrc
) )
@ -87,6 +101,7 @@ target_link_libraries(umt PRIVATE
Qt6::Sql Qt6::Sql
Qt6::Svg Qt6::Svg
Qt6::Concurrent Qt6::Concurrent
PkgConfig::SODIUM
) )
install(TARGETS umt install(TARGETS umt

12
android/.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
/app/build/
/.kotlin/

100
android/README.md Normal file
View file

@ -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.

View file

@ -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)
}

9
android/app/proguard-rules.pro vendored Normal file
View file

@ -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(...);
}

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".UmtApp"
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.UltimateMediaTracker">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.UltimateMediaTracker">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -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"
}
]
}

View file

@ -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)
}
}

View file

@ -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()
}
}
}
}
}

View file

@ -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()
}
}
}

View file

@ -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<List<MediaItem>> =
dao.observeAll().map { rows -> rows.map { it.toDomain() } }
fun observeItem(id: Long): Flow<MediaItem?> =
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) }
}
}
}

View file

@ -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<MediaItemDto> = emptyList(),
)
@Serializable
data class MediaItemDto(
val type: String = "Movie",
val title: String = "",
val originalTitle: String = "",
val localizedTitles: Map<String, String> = emptyMap(),
val coverUrl: String = "",
val genres: List<String> = emptyList(),
val tags: List<String> = 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<SegmentDto> = emptyList(),
val customFields: Map<String, String> = 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<UnitDto> = 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,
)

View file

@ -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
}
}

View file

@ -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<List<MediaEntity>>
@Query("SELECT * FROM media WHERE id = :id")
fun observeById(id: Long): Flow<MediaEntity?>
@Query("SELECT * FROM media ORDER BY title COLLATE NOCASE")
suspend fun getAll(): List<MediaEntity>
@Query("SELECT COUNT(*) FROM media")
suspend fun count(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertAll(items: List<MediaEntity>)
@Update
suspend fun update(item: MediaEntity)
@Query("DELETE FROM media")
suspend fun clear()
}

View file

@ -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,
)

View file

@ -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 }
}
}
}

View file

@ -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
}
}

View file

@ -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")
}
}
}

View file

@ -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"
}
}

View file

@ -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<ProgressUnit> = 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<String, String> = emptyMap(),
val coverUrl: String = "",
val genres: List<String> = emptyList(),
val tags: List<String> = 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<Segment> = emptyList(),
val customFields: Map<String, String> = 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()
}
}

View file

@ -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() })
}
}
}

View file

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

View file

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

View file

@ -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<Int, Boolean>() }
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),
)
}
}
}
}
}
}

View file

@ -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<MediaItem?> =
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 <T : ViewModel> create(modelClass: Class<T>): T =
DetailViewModel(Graph.repository, itemId) as T
}
}
}

View file

@ -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<MediaType?> = 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))
}
}
}
}

View file

@ -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<MediaItem> = 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<MediaType?>(null)
/** One-shot messages (e.g. import result) for a snackbar. */
val messages = MutableStateFlow<String?>(null)
val uiState: StateFlow<LibraryUiState> =
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 <T : ViewModel> create(modelClass: Class<T>): T =
LibraryViewModel(Graph.repository, Graph.syncSettings, Graph.syncClient) as T
}
}
}

View file

@ -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") }
}
}
}
}
}
}

View file

@ -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<SettingsUiState> = _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 <T : ViewModel> create(modelClass: Class<T>): T =
SettingsViewModel(Graph.syncSettings, Graph.syncClient) as T
}
}
}

View file

@ -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)

View file

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

View file

@ -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,
),
)

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#0A84FF"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M44,34 L78,54 L44,74 Z" />
</vector>

View file

@ -0,0 +1,4 @@
<resources>
<!-- Window background before Compose draws; matches the app's dark surface. -->
<color name="umt_window_bg">#161618</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Media Tracker</string>
</resources>

View file

@ -0,0 +1,8 @@
<resources>
<!-- Plain window theme; all real theming is done in Compose (UmtTheme). -->
<style name="Theme.UltimateMediaTracker" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
<item name="android:windowBackground">@color/umt_window_bg</item>
</style>
</resources>

9
android/build.gradle.kts Normal file
View file

@ -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
}

View file

@ -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

View file

@ -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" }

Binary file not shown.

View file

@ -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

252
android/gradlew vendored Executable file
View file

@ -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" "$@"

94
android/gradlew.bat vendored Normal file
View file

@ -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

View file

@ -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")

View file

@ -11,6 +11,13 @@
# dist/ portable build (zip & ship this folder) # dist/ portable build (zip & ship this folder)
# UltimateMediaTracker-Setup.exe NSIS installer (if makensis is installed) # 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 set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"

View file

@ -31,3 +31,13 @@ SESSION_SECRET=change-me-to-a-long-random-string
# How long an issued desktop-app token stays valid (in days). # How long an issued desktop-app token stays valid (in days).
TOKEN_TTL_DAYS=90 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

View file

@ -22,6 +22,7 @@ type Config struct {
SessionKey []byte // HMAC secret for signing access tokens / state cookies SessionKey []byte // HMAC secret for signing access tokens / state cookies
AllowGroups []string // if set, the user must be in one of these Authentik groups AllowGroups []string // if set, the user must be in one of these Authentik groups
TokenTTL time.Duration // lifetime of issued desktop-app tokens 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 { func getenv(key, def string) string {
@ -41,6 +42,7 @@ func loadConfig() (*Config, error) {
ClientID: getenv("OIDC_CLIENT_ID", ""), ClientID: getenv("OIDC_CLIENT_ID", ""),
ClientSecret: getenv("OIDC_CLIENT_SECRET", ""), ClientSecret: getenv("OIDC_CLIENT_SECRET", ""),
RedirectURL: getenv("OIDC_REDIRECT_URL", ""), RedirectURL: getenv("OIDC_REDIRECT_URL", ""),
DatabaseURL: getenv("DATABASE_URL", ""),
} }
if c.RedirectURL == "" { if c.RedirectURL == "" {

44
server/docker-compose.yml Normal file
View file

@ -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:

View file

@ -4,10 +4,16 @@ go 1.22
require ( require (
github.com/coreos/go-oidc/v3 v3.11.0 github.com/coreos/go-oidc/v3 v3.11.0
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/oauth2 v0.21.0 golang.org/x/oauth2 v0.21.0
) )
require ( require (
github.com/go-jose/go-jose/v4 v4.0.2 // indirect 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/crypto v0.25.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/text v0.16.0 // indirect
) )

View file

@ -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 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= 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 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 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 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -27,6 +27,7 @@ type server struct {
verifier *oidc.IDTokenVerifier verifier *oidc.IDTokenVerifier
oauth oauth2.Config oauth oauth2.Config
client *http.Client client *http.Client
store *store // nil when DATABASE_URL is unset (sync disabled)
} }
func main() { func main() {
@ -55,6 +56,18 @@ func main() {
client: &http.Client{Timeout: 15 * time.Second}, 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 := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok")) w.Write([]byte("ok"))
@ -63,6 +76,7 @@ func main() {
mux.HandleFunc("/login", s.handleLogin) mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/callback", s.handleCallback) mux.HandleFunc("/callback", s.handleCallback)
mux.HandleFunc("/3/", s.handleProxy) 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) log.Printf("umt-tmdb-proxy listening on %s (public %s)", cfg.ListenAddr, cfg.PublicURL)
srv := &http.Server{ srv := &http.Server{

136
server/store.go Normal file
View file

@ -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(&current); 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
}

123
server/sync.go Normal file
View file

@ -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)
}

View file

@ -72,6 +72,28 @@ void AppSettings::setRawgApiKey(const QString &key) {
m_s.setValue(QStringLiteral("providers/rawgKey"), 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 { bool AppSettings::autoFetchCovers() const {
return m_s.value(QStringLiteral("providers/autoCovers"), true).toBool(); return m_s.value(QStringLiteral("providers/autoCovers"), true).toBool();
} }

View file

@ -46,6 +46,20 @@ public:
QString rawgApiKey() const; QString rawgApiKey() const;
void setRawgApiKey(const QString &key); 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; bool autoFetchCovers() const;
void setAutoFetchCovers(bool on); void setAutoFetchCovers(bool on);

158
src/sync/CryptoEnvelope.cpp Normal file
View file

@ -0,0 +1,158 @@
#include "sync/CryptoEnvelope.h"
#include <QtEndian>
#include <QtGlobal>
#include <sodium.h>
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<unsigned char *>(key.data()), KEY_BYTES,
pw.constData(), static_cast<unsigned long long>(pw.size()),
reinterpret_cast<const unsigned char *>(salt.constData()),
ops, static_cast<size_t>(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<unsigned char *>(cipher.data()), &cipherLen,
reinterpret_cast<const unsigned char *>(plaintext.constData()),
static_cast<unsigned long long>(plaintext.size()),
nullptr, 0, // no associated data
nullptr, // nsec (unused)
reinterpret_cast<const unsigned char *>(nonce.constData()),
reinterpret_cast<const unsigned char *>(key.constData()));
if (rc != 0) {
setError(error, QStringLiteral("Verschlüsselung fehlgeschlagen"));
return {};
}
cipher.resize(static_cast<int>(cipherLen));
QByteArray out;
out.reserve(HEADER_BYTES + cipher.size());
out.append(MAGIC, 4);
out.append(static_cast<char>(VERSION));
quint32 opsBE = qToBigEndian<quint32>(static_cast<quint32>(OPSLIMIT));
quint32 memBE = qToBigEndian<quint32>(static_cast<quint32>(MEMLIMIT));
out.append(reinterpret_cast<const char *>(&opsBE), 4);
out.append(reinterpret_cast<const char *>(&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<quint8>(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<quint32>(opsBE);
const quint64 mem = qFromBigEndian<quint32>(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<unsigned char *>(plain.data()), &plainLen,
nullptr, // nsec (unused)
reinterpret_cast<const unsigned char *>(cipher.constData()),
static_cast<unsigned long long>(cipher.size()),
nullptr, 0, // no associated data
reinterpret_cast<const unsigned char *>(nonce.constData()),
reinterpret_cast<const unsigned char *>(key.constData()));
if (rc != 0) {
setError(error, QStringLiteral("Entschlüsselung fehlgeschlagen (falsche Passphrase?)"));
return {};
}
plain.resize(static_cast<int>(plainLen));
return plain;
}
} // namespace CryptoEnvelope
} // namespace umt

35
src/sync/CryptoEnvelope.h Normal file
View file

@ -0,0 +1,35 @@
#pragma once
#include <QByteArray>
#include <QString>
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

View file

@ -0,0 +1,171 @@
#include "sync/LibrarySerializer.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
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<MediaItem> &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<MediaItem> *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<MediaItem> items;
items.reserve(arr.size());
for (const QJsonValue &v : arr) items.append(itemFromJson(v.toObject()));
*out = items;
return true;
}
} // namespace LibrarySerializer
} // namespace umt

View file

@ -0,0 +1,26 @@
#pragma once
#include "core/MediaItem.h"
#include <QByteArray>
#include <QString>
#include <QVector>
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<MediaItem> &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<MediaItem> *out, QString *error = nullptr);
} // namespace LibrarySerializer
} // namespace umt

199
src/sync/SyncClient.cpp Normal file
View file

@ -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 <QEventLoop>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QUrl>
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<MediaItem> 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<MediaItem> incoming;
if (!LibrarySerializer::fromJson(snapshotJson, &incoming, error))
return false;
// All-or-nothing replace: wipe the current library, then insert the snapshot.
const QVector<MediaItem> 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

66
src/sync/SyncClient.h Normal file
View file

@ -0,0 +1,66 @@
#pragma once
#include <QObject>
#include <QString>
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

View file

@ -7,9 +7,11 @@
#include "ui/EditDialog.h" #include "ui/EditDialog.h"
#include "ui/SettingsDialog.h" #include "ui/SettingsDialog.h"
#include "core/Settings.h" #include "core/Settings.h"
#include "sync/SyncClient.h"
#include "providers/ProviderManager.h" #include "providers/ProviderManager.h"
#include "providers/ImageCache.h" #include "providers/ImageCache.h"
#include <QApplication>
#include <QWidget> #include <QWidget>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QVBoxLayout> #include <QVBoxLayout>
@ -141,12 +143,21 @@ void MainWindow::buildUi()
connect(themeBtn, &QPushButton::clicked, this, &MainWindow::toggleTheme); connect(themeBtn, &QPushButton::clicked, this, &MainWindow::toggleTheme);
tb->addWidget(themeBtn); 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); auto *settingsBtn = new QPushButton(QStringLiteral(""), topBar);
settingsBtn->setObjectName(QStringLiteral("IconButton")); settingsBtn->setObjectName(QStringLiteral("IconButton"));
settingsBtn->setFixedSize(40, rowH); settingsBtn->setFixedSize(40, rowH);
connect(settingsBtn, &QPushButton::clicked, this, &MainWindow::openSettings); connect(settingsBtn, &QPushButton::clicked, this, &MainWindow::openSettings);
tb->addWidget(settingsBtn); tb->addWidget(settingsBtn);
m_syncBtn->setVisible(m_settings->storageMode() == QLatin1String("cloud"));
libLayout->addWidget(topBar); libLayout->addWidget(topBar);
// grid scroll area // grid scroll area
@ -364,11 +375,83 @@ void MainWindow::openSettings()
connect(&dlg, &SettingsDialog::providersConfigChanged, this, [this]{ connect(&dlg, &SettingsDialog::providersConfigChanged, this, [this]{
m_providers->refreshConfig(); m_providers->refreshConfig();
m_theme->apply(); m_theme->apply();
m_syncBtn->setVisible(m_settings->storageMode() == QLatin1String("cloud"));
refresh(); refresh();
}); });
dlg.exec(); 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() void MainWindow::toggleTheme()
{ {
m_settings->setDarkMode(!m_settings->darkMode()); m_settings->setDarkMode(!m_settings->darkMode());

View file

@ -41,6 +41,7 @@ private slots:
void openEditExisting(int id); void openEditExisting(int id);
void deleteItem(int id); void deleteItem(int id);
void openSettings(); void openSettings();
void syncNow();
void toggleTheme(); void toggleTheme();
private: private:
@ -66,6 +67,7 @@ private:
QLineEdit *m_search; QLineEdit *m_search;
QComboBox *m_sort; QComboBox *m_sort;
QPushButton *m_sortDir; QPushButton *m_sortDir;
QPushButton *m_syncBtn;
FilterPanel *m_filters; FilterPanel *m_filters;
QStackedWidget*m_stack; QStackedWidget*m_stack;
QWidget *m_libraryPage; QWidget *m_libraryPage;

View file

@ -142,6 +142,44 @@ SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent)
"Ohne Key funktionieren Filme, Serien, Mangas und Bücher weiterhin nur " "Ohne Key funktionieren Filme, Serien, Mangas und Bücher weiterhin nur "
"die Spiele-Suche braucht ihn."))); "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(
"<b>Wo liegt deine Bibliothek?</b>"
"<ul>"
"<li><b>Nur lokal:</b> Die Bibliothek bleibt ausschließlich auf diesem "
"Gerät (SQLite). Kein Netzwerk-Zugriff.</li>"
"<li><b>Cloud-Sync:</b> Die komplette Bibliothek wird "
"<b>Ende-zu-Ende verschlüsselt</b> (Argon2id + XChaCha20-Poly1305) und "
"über den Server abgeglichen. Der Server speichert nur Chiffretext "
"Passphrase und Klartext verlassen das Gerät nie.</li>"
"</ul>"
"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(
"<b>Die Sync-Passphrase ver- und entschlüsselt deine Bibliothek.</b>"
"<ul>"
"<li>Wähle eine starke, frei gewählte Passphrase.</li>"
"<li><b>Dieselbe</b> Passphrase muss auf allen Geräten (Desktop und "
"Handy) gesetzt sein, damit sie sich gegenseitig entschlüsseln "
"können.</li>"
"<li>Sie verlässt dein Gerät nie und wird nicht auf dem Server "
"gespeichert. Geht sie verloren, sind die Cloud-Daten nicht mehr "
"lesbar.</li>"
"</ul>")));
m_autoCovers = new QCheckBox(QStringLiteral("Cover automatisch laden"), this); m_autoCovers = new QCheckBox(QStringLiteral("Cover automatisch laden"), this);
m_autoCovers->setChecked(m_settings->autoFetchCovers()); m_autoCovers->setChecked(m_settings->autoFetchCovers());
form->addRow(QString(), m_autoCovers); form->addRow(QString(), m_autoCovers);
@ -180,6 +218,8 @@ SettingsDialog::SettingsDialog(AppSettings *settings, QWidget *parent)
m_settings->setProxyUrl(m_proxyUrl->text().trimmed()); m_settings->setProxyUrl(m_proxyUrl->text().trimmed());
m_settings->setProxyToken(m_proxyToken->text().trimmed()); m_settings->setProxyToken(m_proxyToken->text().trimmed());
m_settings->setRawgApiKey(m_rawgKey->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->setAutoFetchCovers(m_autoCovers->isChecked());
m_settings->setCardWidth(m_cardSize->value()); m_settings->setCardWidth(m_cardSize->value());
emit providersConfigChanged(); emit providersConfigChanged();

View file

@ -40,6 +40,8 @@ private:
QPushButton *m_proxyLogin; QPushButton *m_proxyLogin;
QLineEdit *m_proxyToken; QLineEdit *m_proxyToken;
QLineEdit *m_rawgKey; QLineEdit *m_rawgKey;
QComboBox *m_storageMode;
QLineEdit *m_syncPassphrase;
QCheckBox *m_autoCovers; QCheckBox *m_autoCovers;
QSlider *m_cardSize; QSlider *m_cardSize;
}; };