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
+9
View File
@@ -0,0 +1,9 @@
.gradle/
**/build/
local.properties
*.apk
*.aab
*.iml
.idea/
captures/
.cxx/
+27
View File
@@ -0,0 +1,27 @@
# Android organizer app
Share a photo into **live.f12** (or pick from the gallery). The app resizes
to JPEG, longest edge 2000px, uploads to `POST /api/jobs`, then polls until
the worker is done. Variants and intermediates land in
`Pictures/livef12/{job_id}/`. A local notification fires when the job
finishes — no ntfy, no Play Services.
## Build
JDK 17, Android SDK 35. On this machine:
```bash
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
export ANDROID_HOME=$HOME/Android/Sdk
cd android
./gradlew assembleDebug
adb install -r app/build/outputs/apk/debug/app-debug.apk
```
First launch: Settings → paste the `UPLOAD_TOKEN` from the server `.env`.
Default server URL is `https://live.f12.rocks`.
## Share target
Android share sheet → live.f12. That's the replacement for dropping a
file into a Syncthing folder.
+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>
+6
View File
@@ -0,0 +1,6 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21" apply false
}
+4
View File
@@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=60000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+252
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='-Dfile.encoding=UTF-8 "-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
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=-Dfile.encoding=UTF-8 "-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
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "livef12"
include(":app")