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

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