feat: organizer Android app and token-gated upload API
CI / lint-and-test (push) Failing after 1m18s
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:
@@ -0,0 +1,9 @@
|
||||
.gradle/
|
||||
**/build/
|
||||
local.properties
|
||||
*.apk
|
||||
*.aab
|
||||
*.iml
|
||||
.idea/
|
||||
captures/
|
||||
.cxx/
|
||||
@@ -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.
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
# Keep rules unused while minify is off; ready if we ever shrink release.
|
||||
-dontwarn okhttp3.**
|
||||
-dontwarn okio.**
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
Binary file not shown.
@@ -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
|
||||
+252
@@ -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" "$@"
|
||||
Vendored
+94
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user