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,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>
|
||||
Reference in New Issue
Block a user