feat: organizer Android app and token-gated upload API
CI / lint-and-test (push) Failing after 1m18s

Replace Syncthing as the product upload path with a share-target app
and POST /api/jobs. Web UI live-polls job progress.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Frank Schwenk
2026-08-27 21:38:50 +02:00
parent 5dcc3717c7
commit c03f17488c
58 changed files with 3134 additions and 186 deletions
+65
View File
@@ -0,0 +1,65 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization")
}
android {
namespace = "rocks.f12.live"
compileSdk = 35
defaultConfig {
applicationId = "rocks.f12.live"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
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
}
}
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
implementation(composeBom)
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-extended")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.7")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.work:work-runtime-ktx:2.10.0")
implementation("androidx.exifinterface:exifinterface:1.3.7")
implementation("androidx.core:core-ktx:1.15.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0")
implementation("io.coil-kt:coil-compose:2.7.0")
}
+3
View File
@@ -0,0 +1,3 @@
# Keep rules unused while minify is off; ready if we ever shrink release.
-dontwarn okhttp3.**
-dontwarn okio.**
+44
View File
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:name=".LiveF12App"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.LiveF12"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:theme="@style/Theme.LiveF12"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity>
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>
@@ -0,0 +1,11 @@
package rocks.f12.live
import android.app.Application
import rocks.f12.live.notify.AppNotifications
class LiveF12App : Application() {
override fun onCreate() {
super.onCreate()
AppNotifications.ensureChannels(this)
}
}
@@ -0,0 +1,79 @@
package rocks.f12.live
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import rocks.f12.live.ui.LiveF12Root
class MainActivity : ComponentActivity() {
private var shareUri by mutableStateOf<Uri?>(null)
private var openJobId by mutableStateOf<String?>(null)
private val pickImage =
registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
if (uri != null) shareUri = uri
}
private val askNotifications =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { /* best-effort */ }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
requestNotificationPermission()
consumeIntent(intent)
setContent {
LiveF12Root(
shareUri = shareUri,
openJobId = openJobId,
onPickPhoto = { pickImage.launch("image/*") },
onShareConsumed = { shareUri = null },
onOpenJobConsumed = { openJobId = null },
)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
consumeIntent(intent)
}
private fun consumeIntent(intent: Intent?) {
if (intent == null) return
intent.getStringExtra(EXTRA_JOB_ID)?.let { openJobId = it }
if (intent.action == Intent.ACTION_SEND) {
val uri =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_STREAM)
}
if (uri != null) shareUri = uri
}
}
private fun requestNotificationPermission() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
val granted =
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
}
companion object {
const val EXTRA_JOB_ID = "job_id"
}
}
@@ -0,0 +1,105 @@
package rocks.f12.live.data
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.asRequestBody
import java.io.File
import java.io.IOException
import java.util.concurrent.TimeUnit
class ApiException(val code: Int, override val message: String) : IOException(message)
class LiveF12Api(
private val client: OkHttpClient = defaultClient(),
) {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
fun health(baseUrl: String): HealthResponse {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/api/health").get().build()
return parse(call(request), HealthResponse.serializer())
}
fun listJobs(baseUrl: String): JobListResponse {
val request = Request.Builder().url("${baseUrl.trimEnd('/')}/api/jobs").get().build()
return parse(call(request), JobListResponse.serializer())
}
fun job(baseUrl: String, jobId: String): JobDetail {
val request =
Request.Builder()
.url("${baseUrl.trimEnd('/')}/api/jobs/${encode(jobId)}")
.get()
.build()
return parse(call(request), JobDetail.serializer())
}
fun upload(baseUrl: String, token: String, jpeg: File, filename: String = jpeg.name): JobSummary {
val body =
MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart(
"file",
filename,
jpeg.asRequestBody("image/jpeg".toMediaType()),
)
.build()
val request =
Request.Builder()
.url("${baseUrl.trimEnd('/')}/api/jobs")
.header("Authorization", "Bearer $token")
.post(body)
.build()
return parse(call(request), JobSummary.serializer())
}
fun download(baseUrl: String, relativeOrAbsolute: String): ByteArray {
val url = resolve(baseUrl, relativeOrAbsolute)
val request = Request.Builder().url(url).get().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw ApiException(response.code, "Download fehlgeschlagen (${response.code})")
}
return response.body?.bytes() ?: throw ApiException(response.code, "Leere Antwort")
}
}
private fun <T> parse(raw: String, serializer: kotlinx.serialization.DeserializationStrategy<T>): T =
json.decodeFromString(serializer, raw)
private fun call(request: Request): String {
client.newCall(request).execute().use { response ->
val body = response.body?.string().orEmpty()
if (!response.isSuccessful) {
val detail = runCatching { json.decodeFromString<ErrorBody>(body).detail }.getOrNull()
throw ApiException(response.code, detail ?: "HTTP ${response.code}")
}
return body
}
}
companion object {
fun defaultClient(): OkHttpClient =
OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.writeTimeout(120, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
fun resolve(baseUrl: String, path: String): String {
if (path.startsWith("http://") || path.startsWith("https://")) return path
return baseUrl.trimEnd('/') + if (path.startsWith("/")) path else "/$path"
}
private fun encode(jobId: String): String = java.net.URLEncoder.encode(jobId, Charsets.UTF_8.name())
}
}
@kotlinx.serialization.Serializable
private data class ErrorBody(val detail: String = "")
@@ -0,0 +1,80 @@
package rocks.f12.live.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class JobListResponse(
val jobs: List<JobSummary> = emptyList(),
val active: Boolean = false,
)
@Serializable
data class JobSummary(
@SerialName("job_id") val jobId: String,
val status: String,
@SerialName("status_label") val statusLabel: String = status,
val stage: String? = null,
@SerialName("stage_label") val stageLabel: String? = null,
val progress: JobProgress? = null,
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
@SerialName("variant_count") val variantCount: Int = 0,
val thumbnail: String? = null,
@SerialName("thumbnail_url") val thumbnailUrl: String? = null,
val error: String? = null,
)
@Serializable
data class JobProgress(
val current: Int = 0,
val total: Int = 0,
)
@Serializable
data class FileRef(
val file: String,
val url: String,
)
@Serializable
data class JobDetail(
@SerialName("job_id") val jobId: String,
val status: String,
@SerialName("status_label") val statusLabel: String = status,
val stage: String? = null,
@SerialName("stage_label") val stageLabel: String? = null,
val progress: JobProgress? = null,
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
@SerialName("variant_count") val variantCount: Int = 0,
val thumbnail: String? = null,
@SerialName("thumbnail_url") val thumbnailUrl: String? = null,
val error: String? = null,
val original: FileRef? = null,
val rembg: FileRef? = null,
@SerialName("has_rembg") val hasRembg: Boolean = false,
val variants: List<VariantRef> = emptyList(),
val intermediates: List<FileRef> = emptyList(),
@SerialName("variant_errors") val variantErrors: List<String> = emptyList(),
@SerialName("source_file") val sourceFile: String? = null,
) {
val isActive: Boolean get() = status == "queued" || status == "processing" || status == "pending"
val isTerminal: Boolean get() = status == "done" || status == "error"
}
@Serializable
data class VariantRef(
val id: String? = null,
val file: String,
val url: String? = null,
val source: String? = null,
@SerialName("background_filter") val backgroundFilter: String? = null,
@SerialName("foreground_filter") val foregroundFilter: String? = null,
)
@Serializable
data class HealthResponse(
val ok: Boolean = false,
val upload: Boolean = false,
)
@@ -0,0 +1,38 @@
package rocks.f12.live.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
private val Context.dataStore by preferencesDataStore(name = "settings")
data class AppSettings(
val baseUrl: String,
val token: String,
)
class SettingsRepository(private val context: Context) {
val settings: Flow<AppSettings> =
context.dataStore.data.map { prefs ->
AppSettings(
baseUrl = prefs[KEY_BASE_URL]?.ifBlank { DEFAULT_BASE_URL } ?: DEFAULT_BASE_URL,
token = prefs[KEY_TOKEN].orEmpty(),
)
}
suspend fun update(baseUrl: String, token: String) {
context.dataStore.edit { prefs ->
prefs[KEY_BASE_URL] = baseUrl.trim().trimEnd('/')
prefs[KEY_TOKEN] = token.trim()
}
}
companion object {
const val DEFAULT_BASE_URL = "https://live.f12.rocks"
private val KEY_BASE_URL = stringPreferencesKey("base_url")
private val KEY_TOKEN = stringPreferencesKey("upload_token")
}
}
@@ -0,0 +1,95 @@
package rocks.f12.live.image
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import android.net.Uri
import androidx.exifinterface.media.ExifInterface
import java.io.File
import java.io.FileOutputStream
import kotlin.math.max
import kotlin.math.roundToInt
object ImagePrep {
const val MAX_EDGE = 2000
const val JPEG_QUALITY = 92
fun prepare(context: Context, uri: Uri): File {
val resolver = context.contentResolver
val bounds =
resolver.openInputStream(uri)?.use { input ->
BitmapFactory.Options().also {
it.inJustDecodeBounds = true
BitmapFactory.decodeStream(input, null, it)
}
} ?: throw IllegalArgumentException("Bild nicht lesbar")
val longEdge = max(bounds.outWidth, bounds.outHeight).coerceAtLeast(1)
val sample = sampleSize(longEdge, MAX_EDGE)
val bitmap =
resolver.openInputStream(uri)?.use { input ->
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
BitmapFactory.decodeStream(input, null, opts)
} ?: throw IllegalArgumentException("Bild nicht dekodierbar")
val oriented = applyExif(resolver.openInputStream(uri), bitmap)
val scaled = scaleToMaxEdge(oriented, MAX_EDGE)
if (scaled !== oriented && oriented !== bitmap) {
oriented.recycle()
}
if (oriented !== bitmap && scaled !== bitmap) {
bitmap.recycle()
}
val out = File(context.cacheDir, "upload-${System.currentTimeMillis()}.jpg")
FileOutputStream(out).use { stream ->
if (!scaled.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, stream)) {
throw IllegalStateException("JPEG-Encode fehlgeschlagen")
}
}
if (scaled !== bitmap) scaled.recycle()
return out
}
private fun sampleSize(longEdge: Int, maxEdge: Int): Int {
var sample = 1
var size = longEdge
while (size / 2 >= maxEdge) {
sample *= 2
size /= 2
}
return sample
}
private fun scaleToMaxEdge(src: Bitmap, maxEdge: Int): Bitmap {
val longEdge = max(src.width, src.height)
if (longEdge <= maxEdge) return src
val scale = maxEdge.toFloat() / longEdge
val w = (src.width * scale).roundToInt().coerceAtLeast(1)
val h = (src.height * scale).roundToInt().coerceAtLeast(1)
return Bitmap.createScaledBitmap(src, w, h, true)
}
private fun applyExif(stream: java.io.InputStream?, bitmap: Bitmap): Bitmap {
if (stream == null) return bitmap
val orientation =
stream.use { input ->
ExifInterface(input).getAttributeInt(
ExifInterface.TAG_ORIENTATION,
ExifInterface.ORIENTATION_NORMAL,
)
}
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
else -> return bitmap
}
return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
}
@@ -0,0 +1,65 @@
package rocks.f12.live.media
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import rocks.f12.live.data.FileRef
import rocks.f12.live.data.JobDetail
import rocks.f12.live.data.LiveF12Api
import rocks.f12.live.data.VariantRef
object GalleryStore {
fun saveJob(context: Context, api: LiveF12Api, baseUrl: String, job: JobDetail): Int {
val refs = mutableListOf<FileRef>()
job.original?.let(refs::add)
job.rembg?.let(refs::add)
job.variants.forEach { variant ->
refs.add(FileRef(variant.file, variant.url ?: "/jobs/${job.jobId}/files/${variant.file}"))
}
job.intermediates.forEach(refs::add)
var saved = 0
refs.distinctBy { it.file }.forEach { ref ->
val bytes = api.download(baseUrl, ref.url)
write(context, job.jobId, ref.file, bytes)
saved += 1
}
return saved
}
private fun write(context: Context, jobId: String, filename: String, bytes: ByteArray) {
val mime =
when {
filename.endsWith(".png", ignoreCase = true) -> "image/png"
filename.endsWith(".webp", ignoreCase = true) -> "image/webp"
else -> "image/jpeg"
}
val values =
ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
put(MediaStore.Images.Media.MIME_TYPE, mime)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(
MediaStore.Images.Media.RELATIVE_PATH,
"${Environment.DIRECTORY_PICTURES}/livef12/$jobId",
)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val resolver = context.contentResolver
val uri =
resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
?: throw IllegalStateException("MediaStore insert fehlgeschlagen")
resolver.openOutputStream(uri)?.use { it.write(bytes) }
?: throw IllegalStateException("MediaStore write fehlgeschlagen")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
values.clear()
values.put(MediaStore.Images.Media.IS_PENDING, 0)
resolver.update(uri, values, null, null)
}
}
fun variantUrl(jobId: String, variant: VariantRef): String =
variant.url ?: "/jobs/$jobId/files/${variant.file}"
}
@@ -0,0 +1,77 @@
package rocks.f12.live.notify
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import rocks.f12.live.MainActivity
import rocks.f12.live.R
object AppNotifications {
const val CHANNEL_PROGRESS = "progress"
const val CHANNEL_RESULT = "result"
const val PROGRESS_ID = 42
fun ensureChannels(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_PROGRESS,
"Jobs laufen",
NotificationManager.IMPORTANCE_LOW,
).apply { setShowBadge(false) },
)
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_RESULT,
"Job fertig",
NotificationManager.IMPORTANCE_DEFAULT,
),
)
}
fun progress(context: Context, jobId: String, text: String): android.app.Notification {
return NotificationCompat.Builder(context, CHANNEL_PROGRESS)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle("Job läuft")
.setContentText(text.ifBlank { jobId })
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentIntent(openJob(context, jobId))
.build()
}
fun result(context: Context, jobId: String, title: String, text: String, success: Boolean) {
val notification =
NotificationCompat.Builder(context, CHANNEL_RESULT)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(text)
.setAutoCancel(true)
.setContentIntent(openJob(context, jobId))
.build()
try {
NotificationManagerCompat.from(context).notify(jobId.hashCode(), notification)
} catch (_: SecurityException) {
/* POST_NOTIFICATIONS denied */
}
NotificationManagerCompat.from(context).cancel(PROGRESS_ID)
}
private fun openJob(context: Context, jobId: String): PendingIntent {
val intent =
Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
putExtra(MainActivity.EXTRA_JOB_ID, jobId)
}
return PendingIntent.getActivity(
context,
jobId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
}
@@ -0,0 +1,103 @@
package rocks.f12.live.ui
import android.net.Uri
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import rocks.f12.live.ui.screens.HomeScreen
import rocks.f12.live.ui.screens.HomeViewModel
import rocks.f12.live.ui.screens.JobScreen
import rocks.f12.live.ui.screens.JobViewModel
import rocks.f12.live.ui.screens.SettingsScreen
import rocks.f12.live.ui.screens.SettingsViewModel
import rocks.f12.live.ui.screens.UploadScreen
import rocks.f12.live.ui.screens.UploadViewModel
import rocks.f12.live.ui.theme.Bg
import rocks.f12.live.ui.theme.LiveF12Theme
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
@Composable
fun LiveF12Root(
shareUri: Uri?,
openJobId: String?,
onPickPhoto: () -> Unit,
onShareConsumed: () -> Unit,
onOpenJobConsumed: () -> Unit,
) {
LiveF12Theme {
Surface(Modifier.fillMaxSize(), color = Bg) {
val nav = rememberNavController()
val homeVm: HomeViewModel = viewModel()
val settingsVm: SettingsViewModel = viewModel()
val jobVm: JobViewModel = viewModel()
val uploadVm: UploadViewModel = viewModel()
val homeState by homeVm.state.collectAsStateWithLifecycle()
LaunchedEffect(shareUri) {
val uri = shareUri ?: return@LaunchedEffect
val encoded = URLEncoder.encode(uri.toString(), StandardCharsets.UTF_8.name())
nav.navigate("upload?uri=$encoded")
onShareConsumed()
}
LaunchedEffect(openJobId) {
val id = openJobId ?: return@LaunchedEffect
nav.navigate("job/$id")
onOpenJobConsumed()
}
NavHost(navController = nav, startDestination = "home") {
composable("home") {
HomeScreen(
viewModel = homeVm,
onOpenJob = { nav.navigate("job/$it") },
onSettings = { nav.navigate("settings") },
onPickPhoto = onPickPhoto,
)
}
composable("settings") {
SettingsScreen(viewModel = settingsVm, onBack = { nav.popBackStack() })
}
composable(
"job/{jobId}",
arguments = listOf(navArgument("jobId") { type = NavType.StringType }),
) { entry ->
val jobId = entry.arguments?.getString("jobId").orEmpty()
JobScreen(
jobId = jobId,
viewModel = jobVm,
baseUrl = homeState.settings.baseUrl,
onBack = { nav.popBackStack() },
)
}
composable(
"upload?uri={uri}",
arguments = listOf(navArgument("uri") { type = NavType.StringType }),
) { entry ->
val raw = entry.arguments?.getString("uri").orEmpty()
val uri = Uri.parse(java.net.URLDecoder.decode(raw, StandardCharsets.UTF_8.name()))
UploadScreen(
uri = uri,
viewModel = uploadVm,
onDone = { jobId ->
nav.navigate("job/$jobId") {
popUpTo("home")
}
},
onErrorBack = { nav.popBackStack() },
)
}
}
}
}
}
@@ -0,0 +1,163 @@
package rocks.f12.live.ui.screens
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.PaddingValues
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddPhotoAlternate
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import rocks.f12.live.data.JobSummary
import rocks.f12.live.data.LiveF12Api
import rocks.f12.live.ui.theme.Accent
import rocks.f12.live.ui.theme.AccentOn
import rocks.f12.live.ui.theme.Bg
import rocks.f12.live.ui.theme.BgElevated
import rocks.f12.live.ui.theme.Err
import rocks.f12.live.ui.theme.Ok
import rocks.f12.live.ui.theme.Pending
import rocks.f12.live.ui.theme.Text
import rocks.f12.live.ui.theme.TextDim
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
viewModel: HomeViewModel,
onOpenJob: (String) -> Unit,
onSettings: () -> Unit,
onPickPhoto: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
containerColor = Bg,
topBar = {
TopAppBar(
title = { Text("live.f12.rocks") },
actions = {
IconButton(onClick = onSettings) {
Icon(Icons.Outlined.Settings, contentDescription = "Einstellungen")
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = Bg,
titleContentColor = Text,
actionIconContentColor = Text,
),
)
},
floatingActionButton = {
FloatingActionButton(onClick = onPickPhoto, containerColor = Accent, contentColor = AccentOn) {
Icon(Icons.Outlined.AddPhotoAlternate, contentDescription = "Foto wählen")
}
},
) { padding ->
when {
state.settings.token.isBlank() -> {
Column(Modifier.padding(padding).padding(16.dp)) {
Text("Kein Token. In den Einstellungen den Upload-Token setzen.", color = Pending)
}
}
state.error != null && state.jobs.isEmpty() -> {
Column(Modifier.padding(padding).padding(16.dp)) {
Text(state.error ?: "", color = Err)
Text("Ziehen zum Neu laden passiert beim nächsten Poll.", color = TextDim, modifier = Modifier.padding(top = 8.dp))
}
}
state.jobs.isEmpty() && !state.loading -> {
Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
Text("Noch keine Jobs. Foto teilen oder + tippen.", color = TextDim)
}
}
else -> {
LazyVerticalGrid(
columns = GridCells.Adaptive(160.dp),
contentPadding = PaddingValues(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(padding).fillMaxSize(),
) {
items(state.jobs, key = { it.jobId }) { job ->
JobCard(
job = job,
baseUrl = state.settings.baseUrl,
onClick = { onOpenJob(job.jobId) },
)
}
}
}
}
}
}
@Composable
private fun JobCard(job: JobSummary, baseUrl: String, onClick: () -> Unit) {
val statusColor =
when (job.status) {
"done" -> Ok
"error" -> Err
else -> Pending
}
Column(
modifier =
Modifier
.clip(RoundedCornerShape(8.dp))
.background(BgElevated)
.clickable(onClick = onClick),
) {
val thumb = job.thumbnailUrl?.let { LiveF12Api.resolve(baseUrl, it) }
if (thumb != null) {
AsyncImage(
model = thumb,
contentDescription = job.jobId,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).background(androidx.compose.ui.graphics.Color.Black),
)
} else {
Box(
Modifier.fillMaxWidth().aspectRatio(1f).background(androidx.compose.ui.graphics.Color.Black),
contentAlignment = Alignment.Center,
) {
Text(job.statusLabel, color = statusColor)
}
}
Column(Modifier.padding(10.dp)) {
Text(job.jobId, color = Text, maxLines = 1)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
Text(job.statusLabel, color = statusColor)
Text(
job.stageLabel?.takeIf { job.status != "done" } ?: "${job.variantCount} Varianten",
color = TextDim,
)
}
}
}
}
@@ -0,0 +1,146 @@
package rocks.f12.live.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
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.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import rocks.f12.live.data.FileRef
import rocks.f12.live.data.LiveF12Api
import rocks.f12.live.ui.theme.Accent
import rocks.f12.live.ui.theme.Bg
import rocks.f12.live.ui.theme.BgElevated
import rocks.f12.live.ui.theme.Err
import rocks.f12.live.ui.theme.Ok
import rocks.f12.live.ui.theme.Pending
import rocks.f12.live.ui.theme.Text
import rocks.f12.live.ui.theme.TextDim
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun JobScreen(
jobId: String,
viewModel: JobViewModel,
baseUrl: String,
onBack: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(jobId) { viewModel.start(jobId) }
val job = state.job
val statusColor =
when (job?.status) {
"done" -> Ok
"error" -> Err
else -> Pending
}
Scaffold(
containerColor = Bg,
topBar = {
TopAppBar(
title = { Text(jobId, maxLines = 1) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Zurück")
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = Bg,
titleContentColor = Text,
navigationIconContentColor = Text,
),
)
},
) { padding ->
if (job == null) {
Text(
state.error ?: "Lädt…",
color = if (state.error != null) Err else TextDim,
modifier = Modifier.padding(padding).padding(16.dp),
)
return@Scaffold
}
val files = buildList {
job.original?.let { add("Original" to it) }
job.rembg?.let { add("Rembg" to it) }
job.variants.forEach { v ->
add((v.id ?: "Variante") to FileRef(v.file, v.url ?: "/jobs/${job.jobId}/files/${v.file}"))
}
job.intermediates.forEach { add(it.file to it) }
}
LazyVerticalGrid(
columns = GridCells.Adaptive(140.dp),
modifier = Modifier.padding(padding).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("${job.statusLabel} · ${job.stageLabel ?: ""}", color = statusColor)
if (job.isActive && job.progress != null && job.progress.total > 0) {
LinearProgressIndicator(
progress = { job.progress.current.toFloat() / job.progress.total },
modifier = Modifier.fillMaxWidth(),
color = Accent,
)
}
job.error?.let { Text(it, color = Err) }
Button(
onClick = viewModel::saveToGallery,
enabled = !state.saving && job.variants.isNotEmpty(),
) {
Text(
when {
state.saving -> "Speichert…"
state.savedCount != null -> "${state.savedCount} Dateien gespeichert"
else -> "Aufs Handy speichern"
},
)
}
}
}
items(files, key = { it.second.file }) { (label, ref) ->
Column(
Modifier.clip(RoundedCornerShape(8.dp)).background(BgElevated),
) {
AsyncImage(
model = LiveF12Api.resolve(baseUrl, ref.url),
contentDescription = label,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxWidth().aspectRatio(1f),
)
Text(label, color = TextDim, modifier = Modifier.padding(8.dp), maxLines = 2)
}
}
}
}
}
@@ -0,0 +1,96 @@
package rocks.f12.live.ui.screens
import androidx.compose.foundation.layout.Column
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.outlined.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import rocks.f12.live.ui.theme.Accent
import rocks.f12.live.ui.theme.Bg
import rocks.f12.live.ui.theme.Ok
import rocks.f12.live.ui.theme.Text
import rocks.f12.live.ui.theme.TextDim
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
viewModel: SettingsViewModel,
onBack: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
containerColor = Bg,
topBar = {
TopAppBar(
title = { Text("Einstellungen") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Outlined.ArrowBack, contentDescription = "Zurück")
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = Bg,
titleContentColor = Text,
navigationIconContentColor = Text,
),
)
},
) { padding ->
Column(
Modifier
.padding(padding)
.padding(16.dp)
.verticalScroll(rememberScrollState()),
) {
Text("Server und Token nur auf diesem Handy. Gäste brauchen das nicht.", color = TextDim)
OutlinedTextField(
value = state.baseUrl,
onValueChange = viewModel::setBaseUrl,
label = { Text("Server-URL") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 16.dp),
)
OutlinedTextField(
value = state.token,
onValueChange = viewModel::setToken,
label = { Text("Upload-Token") },
singleLine = true,
modifier = Modifier.fillMaxWidth().padding(top = 12.dp),
)
Button(
onClick = viewModel::save,
modifier = Modifier.fillMaxWidth().padding(top = 20.dp),
) {
Text("Speichern")
}
OutlinedButton(
onClick = viewModel::probe,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
) {
Text("Server prüfen")
}
if (state.saved) {
Text("Gespeichert.", color = Ok, modifier = Modifier.padding(top = 12.dp))
}
state.probe?.let { Text(it, color = Accent, modifier = Modifier.padding(top = 8.dp)) }
}
}
}
@@ -0,0 +1,60 @@
package rocks.f12.live.ui.screens
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import rocks.f12.live.ui.theme.Accent
import rocks.f12.live.ui.theme.Err
import rocks.f12.live.ui.theme.Text
@Composable
fun UploadScreen(
uri: Uri,
viewModel: UploadViewModel,
onDone: (String) -> Unit,
onErrorBack: () -> Unit,
) {
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(uri) { viewModel.start(uri) }
LaunchedEffect(state.jobId) {
state.jobId?.let(onDone)
}
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
when (state.phase) {
"error" ->
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(24.dp)) {
Text(state.error ?: "Fehler", color = Err)
Text(
"Zurück",
color = Accent,
modifier = Modifier.padding(top = 16.dp).clickable(onClick = onErrorBack),
)
}
else -> {
CircularProgressIndicator(color = Accent)
Text(
when (state.phase) {
"prepare" -> "JPEG, längste Seite 2000px…"
"upload" -> "Lädt hoch…"
else -> "Job angelegt…"
},
color = Text,
modifier = Modifier.padding(top = 96.dp),
)
}
}
}
}
@@ -0,0 +1,256 @@
package rocks.f12.live.ui.screens
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rocks.f12.live.data.AppSettings
import rocks.f12.live.data.JobDetail
import rocks.f12.live.data.JobSummary
import rocks.f12.live.data.LiveF12Api
import rocks.f12.live.data.SettingsRepository
import rocks.f12.live.image.ImagePrep
import rocks.f12.live.media.GalleryStore
import rocks.f12.live.work.JobSyncWorker
data class HomeState(
val settings: AppSettings = AppSettings(SettingsRepository.DEFAULT_BASE_URL, ""),
val jobs: List<JobSummary> = emptyList(),
val loading: Boolean = true,
val error: String? = null,
)
class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val settingsRepo = SettingsRepository(application)
private val api = LiveF12Api()
private val _state = MutableStateFlow(HomeState())
val state: StateFlow<HomeState> = _state.asStateFlow()
private var poll: Job? = null
init {
viewModelScope.launch {
settingsRepo.settings.collect { settings ->
_state.value = _state.value.copy(settings = settings)
}
}
refresh()
startPoll()
}
fun refresh() {
viewModelScope.launch {
_state.value = _state.value.copy(loading = true, error = null)
runCatching {
withContext(Dispatchers.IO) { api.listJobs(_state.value.settings.baseUrl) }
}.onSuccess { response ->
_state.value = _state.value.copy(jobs = response.jobs, loading = false, error = null)
}.onFailure { err ->
_state.value =
_state.value.copy(
loading = false,
error = err.message ?: "Jobs konnten nicht geladen werden",
)
}
}
}
private fun startPoll() {
poll?.cancel()
poll =
viewModelScope.launch {
while (isActive) {
delay(4000)
val base = _state.value.settings.baseUrl
runCatching { withContext(Dispatchers.IO) { api.listJobs(base) } }
.onSuccess { response ->
_state.value = _state.value.copy(jobs = response.jobs, error = null)
}
}
}
}
}
data class SettingsState(
val baseUrl: String = SettingsRepository.DEFAULT_BASE_URL,
val token: String = "",
val saved: Boolean = false,
val probe: String? = null,
)
class SettingsViewModel(application: Application) : AndroidViewModel(application) {
private val repo = SettingsRepository(application)
private val api = LiveF12Api()
private val _state = MutableStateFlow(SettingsState())
val state: StateFlow<SettingsState> = _state.asStateFlow()
init {
viewModelScope.launch {
repo.settings.collect { settings ->
_state.value =
_state.value.copy(
baseUrl = settings.baseUrl,
token = settings.token,
)
}
}
}
fun setBaseUrl(value: String) {
_state.value = _state.value.copy(baseUrl = value, saved = false)
}
fun setToken(value: String) {
_state.value = _state.value.copy(token = value, saved = false)
}
fun save() {
viewModelScope.launch {
repo.update(_state.value.baseUrl, _state.value.token)
_state.value = _state.value.copy(saved = true, probe = null)
}
}
fun probe() {
viewModelScope.launch {
val url = _state.value.baseUrl.trim().trimEnd('/')
val result =
runCatching { withContext(Dispatchers.IO) { api.health(url) } }
.fold(
onSuccess = { health ->
if (health.upload) "Server ok, Upload aktiv." else "Server erreichbar, Upload nicht konfiguriert."
},
onFailure = { err -> "Nicht erreichbar: ${err.message}" },
)
_state.value = _state.value.copy(probe = result)
}
}
}
data class JobUiState(
val job: JobDetail? = null,
val loading: Boolean = true,
val error: String? = null,
val saving: Boolean = false,
val savedCount: Int? = null,
)
class JobViewModel(application: Application) : AndroidViewModel(application) {
private val settingsRepo = SettingsRepository(application)
private val api = LiveF12Api()
private val _state = MutableStateFlow(JobUiState())
val state: StateFlow<JobUiState> = _state.asStateFlow()
private var poll: Job? = null
private var jobId: String? = null
fun start(id: String) {
if (jobId == id && poll?.isActive == true) return
jobId = id
poll?.cancel()
_state.value = JobUiState(loading = true)
loadOnce(id)
poll =
viewModelScope.launch {
while (isActive) {
delay(4000)
val current = _state.value.job
if (current?.isTerminal == true) break
loadOnce(id)
}
}
}
private fun loadOnce(id: String) {
viewModelScope.launch {
val base = settingsRepo.settings.first().baseUrl
runCatching { withContext(Dispatchers.IO) { api.job(base, id) } }
.onSuccess { job ->
_state.value = _state.value.copy(job = job, loading = false, error = null)
}
.onFailure { err ->
_state.value =
_state.value.copy(
loading = false,
error = err.message ?: "Job nicht geladen",
)
}
}
}
fun saveToGallery() {
val job = _state.value.job ?: return
viewModelScope.launch {
_state.value = _state.value.copy(saving = true)
val base = settingsRepo.settings.first().baseUrl
runCatching {
withContext(Dispatchers.IO) { GalleryStore.saveJob(getApplication(), api, base, job) }
}.onSuccess { count ->
_state.value = _state.value.copy(saving = false, savedCount = count, error = null)
}.onFailure { err ->
_state.value =
_state.value.copy(
saving = false,
error = err.message ?: "Speichern fehlgeschlagen",
)
}
}
}
}
data class UploadUiState(
val phase: String = "prepare",
val error: String? = null,
val jobId: String? = null,
)
class UploadViewModel(application: Application) : AndroidViewModel(application) {
private val settingsRepo = SettingsRepository(application)
private val api = LiveF12Api()
private val _state = MutableStateFlow(UploadUiState())
val state: StateFlow<UploadUiState> = _state.asStateFlow()
private var startedFor: Uri? = null
fun start(uri: Uri) {
if (startedFor == uri && _state.value.phase != "error") return
startedFor = uri
viewModelScope.launch {
_state.value = UploadUiState(phase = "prepare")
val settings = settingsRepo.settings.first()
if (settings.token.isBlank()) {
_state.value = UploadUiState(phase = "error", error = "Kein Upload-Token. Erst Einstellungen.")
return@launch
}
val file =
runCatching {
withContext(Dispatchers.IO) { ImagePrep.prepare(getApplication(), uri) }
}.onFailure { err ->
_state.value = UploadUiState(phase = "error", error = err.message ?: "Konvertierung fehlgeschlagen")
}.getOrNull() ?: return@launch
_state.value = UploadUiState(phase = "upload")
val uploaded =
runCatching {
withContext(Dispatchers.IO) {
api.upload(settings.baseUrl, settings.token, file, file.name)
}
}.onFailure { err ->
_state.value = UploadUiState(phase = "error", error = err.message ?: "Upload fehlgeschlagen")
}.getOrNull()
file.delete()
if (uploaded == null) return@launch
JobSyncWorker.enqueue(getApplication(), uploaded.jobId, settings.baseUrl)
_state.value = UploadUiState(phase = "done", jobId = uploaded.jobId)
}
}
}
@@ -0,0 +1,38 @@
package rocks.f12.live.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
val Bg = Color(0xFF14161A)
val BgElevated = Color(0xFF1D2025)
val Border = Color(0xFF2C3038)
val Text = Color(0xFFE7E9EC)
val TextDim = Color(0xFF9AA0AA)
val Accent = Color(0xFFFF5A1F)
val AccentOn = Color(0xFF14161A)
val Ok = Color(0xFF4CAF6A)
val Err = Color(0xFFE05A4A)
val Pending = Color(0xFFD9A441)
private val Colors: ColorScheme =
darkColorScheme(
primary = Accent,
onPrimary = AccentOn,
background = Bg,
onBackground = Text,
surface = BgElevated,
onSurface = Text,
surfaceVariant = Border,
onSurfaceVariant = TextDim,
error = Err,
onError = Text,
outline = Border,
)
@Composable
fun LiveF12Theme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = Colors, content = content)
}
@@ -0,0 +1,113 @@
package rocks.f12.live.work
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.delay
import rocks.f12.live.data.LiveF12Api
import rocks.f12.live.media.GalleryStore
import rocks.f12.live.notify.AppNotifications
class JobSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
private val api = LiveF12Api()
override suspend fun doWork(): Result {
val jobId = inputData.getString(KEY_JOB_ID) ?: return Result.failure()
val baseUrl = inputData.getString(KEY_BASE_URL) ?: return Result.failure()
setForeground(foreground("Wartet auf Worker…", jobId))
var lastLabel = ""
repeat(MAX_POLLS) { _ ->
val job =
try {
api.job(baseUrl, jobId)
} catch (_: Exception) {
delay(POLL_MS)
return@repeat
}
val label = job.stageLabel ?: job.statusLabel
if (label != lastLabel) {
setForeground(foreground(label, jobId))
lastLabel = label
}
when {
job.status == "done" -> {
val count = GalleryStore.saveJob(applicationContext, api, baseUrl, job)
AppNotifications.result(
applicationContext,
jobId,
"Job fertig",
"$count Dateien in Pictures/livef12",
success = true,
)
return Result.success()
}
job.status == "error" -> {
AppNotifications.result(
applicationContext,
jobId,
"Job fehlgeschlagen",
job.error ?: "Pipeline-Fehler",
success = false,
)
return Result.failure()
}
else -> delay(POLL_MS)
}
}
AppNotifications.result(
applicationContext,
jobId,
"Job hängt",
"Timeout nach Poll. In der App nachschauen.",
success = false,
)
return Result.retry()
}
private fun foreground(text: String, jobId: String): ForegroundInfo {
val notification = AppNotifications.progress(applicationContext, jobId, text)
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ForegroundInfo(
AppNotifications.PROGRESS_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC,
)
} else {
ForegroundInfo(AppNotifications.PROGRESS_ID, notification)
}
}
companion object {
private const val KEY_JOB_ID = "job_id"
private const val KEY_BASE_URL = "base_url"
private const val POLL_MS = 4000L
private const val MAX_POLLS = 450 // ~30 min
fun enqueue(context: Context, jobId: String, baseUrl: String) {
val request =
OneTimeWorkRequestBuilder<JobSyncWorker>()
.setInputData(workDataOf(KEY_JOB_ID to jobId, KEY_BASE_URL to baseUrl))
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.addTag("job-$jobId")
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"job-$jobId",
ExistingWorkPolicy.KEEP,
request,
)
}
}
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<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="#FF5A1F"
android:pathData="M34,38h40c2.2,0 4,1.8 4,4v28c0,2.2 -1.8,4 -4,4H34c-2.2,0 -4,-1.8 -4,-4V42c0,-2.2 1.8,-4 4,-4z" />
<path
android:fillColor="#14161A"
android:pathData="M54,56m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0" />
<path
android:fillColor="#14161A"
android:pathData="M40,44h8v4h-8z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/bg" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg">#14161A</color>
<color name="accent">#FF5A1F</color>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">live.f12</string>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LiveF12" parent="android:Theme.Material.NoActionBar">
<item name="android:statusBarColor">@color/bg</item>
<item name="android:navigationBarColor">@color/bg</item>
<item name="android:windowBackground">@color/bg</item>
<item name="android:colorPrimary">@color/accent</item>
</style>
</resources>