From 4e81cea8317097bcd2f8ce85decd6b78e5156e8a Mon Sep 17 00:00:00 2001 From: Marek Lenczewski Date: Sun, 12 Apr 2026 10:46:14 +0200 Subject: [PATCH] App update module --- app/app/build.gradle.kts | 1 + app/app/src/main/AndroidManifest.xml | 11 ++ .../main/java/de/haushalt/app/StartScreen.kt | 119 +++++++++++++++--- .../java/de/haushalt/app/data/ApiClient.kt | 17 ++- .../java/de/haushalt/app/data/AppUpdateApi.kt | 15 +++ .../java/de/haushalt/app/data/AppUpdater.kt | 36 ++++++ app/app/src/main/res/xml/file_paths.xml | 4 + backend/public/app/version.json | 4 + module.md | 46 +++++-- 9 files changed, 216 insertions(+), 37 deletions(-) create mode 100644 app/app/src/main/java/de/haushalt/app/data/AppUpdateApi.kt create mode 100644 app/app/src/main/java/de/haushalt/app/data/AppUpdater.kt create mode 100644 app/app/src/main/res/xml/file_paths.xml create mode 100644 backend/public/app/version.json diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 0d01f81..ed12121 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -38,6 +38,7 @@ android { buildFeatures { compose = true + buildConfig = true } } diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml index 03d7e28..39680d7 100644 --- a/app/app/src/main/AndroidManifest.xml +++ b/app/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + + + + diff --git a/app/app/src/main/java/de/haushalt/app/StartScreen.kt b/app/app/src/main/java/de/haushalt/app/StartScreen.kt index d4039f9..db69233 100644 --- a/app/app/src/main/java/de/haushalt/app/StartScreen.kt +++ b/app/app/src/main/java/de/haushalt/app/StartScreen.kt @@ -1,42 +1,121 @@ package de.haushalt.app import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer 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.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import de.haushalt.app.data.AppUpdater +import de.haushalt.app.data.AppVersion +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private sealed interface UpdateState { + data object Idle : UpdateState + data object Checking : UpdateState + data object NoUpdate : UpdateState + data class Available(val version: AppVersion) : UpdateState + data object Downloading : UpdateState + data class Error(val message: String) : UpdateState +} @Composable fun StartScreen(onOpenTasks: () -> Unit = {}) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = 160.dp), - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - ) { - item { - Card( - onClick = onOpenTasks, - modifier = Modifier - .padding(8.dp) - .aspectRatio(1f), - ) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, + val context = LocalContext.current + val scope = rememberCoroutineScope() + var updateState by remember { mutableStateOf(UpdateState.Idle) } + + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = 160.dp), + modifier = Modifier.weight(1f), + ) { + item { + Card( + onClick = onOpenTasks, + modifier = Modifier + .padding(8.dp) + .aspectRatio(1f), ) { - Text( - text = "Tasks", - style = MaterialTheme.typography.titleLarge, - ) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Tasks", + style = MaterialTheme.typography.titleLarge, + ) + } + } + } + } + + Spacer(Modifier.padding(8.dp)) + + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + when (val state = updateState) { + UpdateState.Idle -> TextButton(onClick = { + scope.launch { + updateState = UpdateState.Checking + try { + val version = withContext(Dispatchers.IO) { AppUpdater.checkUpdate() } + updateState = if (version != null) UpdateState.Available(version) + else UpdateState.NoUpdate + } catch (e: Exception) { + updateState = UpdateState.Error(e.message ?: "Fehler") + } + } + }) { Text("Update prüfen") } + + UpdateState.Checking -> Text("Prüfe…", style = MaterialTheme.typography.bodySmall) + + UpdateState.NoUpdate -> Text( + "App ist aktuell", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + is UpdateState.Available -> Button(onClick = { + scope.launch { + updateState = UpdateState.Downloading + try { + withContext(Dispatchers.IO) { + AppUpdater.downloadAndInstall(context, state.version) + } + updateState = UpdateState.Idle + } catch (e: Exception) { + updateState = UpdateState.Error(e.message ?: "Fehler") + } + } + }) { Text("Update installieren (v${state.version.versionCode})") } + + UpdateState.Downloading -> Text( + "Lade herunter…", + style = MaterialTheme.typography.bodySmall, + ) + + is UpdateState.Error -> TextButton(onClick = { updateState = UpdateState.Idle }) { + Text(state.message, color = MaterialTheme.colorScheme.error) } } } diff --git a/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt b/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt index 52943a8..8890602 100644 --- a/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt +++ b/app/app/src/main/java/de/haushalt/app/data/ApiClient.kt @@ -8,23 +8,32 @@ import retrofit2.Retrofit import retrofit2.converter.kotlinx.serialization.asConverterFactory object ApiClient { - private const val BASE_URL = "http://192.168.178.34:8080/api/" + const val BASE_URL = "http://192.168.178.34:8080/" + private const val API_URL = "${BASE_URL}api/" private val json = Json { ignoreUnknownKeys = true explicitNulls = false } - private val okHttp: OkHttpClient = OkHttpClient.Builder() + val okHttp: OkHttpClient = OkHttpClient.Builder() .addInterceptor( HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } ) .build() - val taskApi: TaskApi = Retrofit.Builder() + private val retrofit: Retrofit = Retrofit.Builder() + .baseUrl(API_URL) + .client(okHttp) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + val taskApi: TaskApi = retrofit.create(TaskApi::class.java) + + val appUpdateApi: AppUpdateApi = Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttp) .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .build() - .create(TaskApi::class.java) + .create(AppUpdateApi::class.java) } diff --git a/app/app/src/main/java/de/haushalt/app/data/AppUpdateApi.kt b/app/app/src/main/java/de/haushalt/app/data/AppUpdateApi.kt new file mode 100644 index 0000000..7fe928f --- /dev/null +++ b/app/app/src/main/java/de/haushalt/app/data/AppUpdateApi.kt @@ -0,0 +1,15 @@ +package de.haushalt.app.data + +import kotlinx.serialization.Serializable +import retrofit2.http.GET + +interface AppUpdateApi { + @GET("app/version.json") + suspend fun getVersion(): AppVersion +} + +@Serializable +data class AppVersion( + val versionCode: Int, + val apkFile: String, +) diff --git a/app/app/src/main/java/de/haushalt/app/data/AppUpdater.kt b/app/app/src/main/java/de/haushalt/app/data/AppUpdater.kt new file mode 100644 index 0000000..98b1558 --- /dev/null +++ b/app/app/src/main/java/de/haushalt/app/data/AppUpdater.kt @@ -0,0 +1,36 @@ +package de.haushalt.app.data + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import de.haushalt.app.BuildConfig +import okhttp3.Request +import java.io.File + +object AppUpdater { + suspend fun checkUpdate(): AppVersion? { + val remote = ApiClient.appUpdateApi.getVersion() + return if (remote.versionCode > BuildConfig.VERSION_CODE) remote else null + } + + fun downloadAndInstall(context: Context, version: AppVersion) { + val apkUrl = "${ApiClient.BASE_URL}app/${version.apkFile}" + val apkFile = File(context.cacheDir, "apk/haushalt.apk") + apkFile.parentFile?.mkdirs() + + val request = Request.Builder().url(apkUrl).build() + ApiClient.okHttp.newCall(request).execute().use { response -> + apkFile.outputStream().use { out -> + response.body!!.byteStream().copyTo(out) + } + } + + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } +} diff --git a/app/app/src/main/res/xml/file_paths.xml b/app/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..18d7a35 --- /dev/null +++ b/app/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/backend/public/app/version.json b/backend/public/app/version.json new file mode 100644 index 0000000..0644ac1 --- /dev/null +++ b/backend/public/app/version.json @@ -0,0 +1,4 @@ +{ + "versionCode": 1, + "apkFile": "haushalt.apk" +} diff --git a/module.md b/module.md index 0f2a065..2db9963 100644 --- a/module.md +++ b/module.md @@ -74,6 +74,39 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird - TaskCreate page: create task - TaskEdit page: update task +# App update module +## Backend +- public/app/version.json - version info (versionCode, apkFile) +- public/app/haushalt.apk - current APK (manually deployed) +## App +- AppUpdateApi.kt - version check endpoint +- AppUpdater.kt - check version, download APK, trigger install +- StartScreen.kt - "Update prüfen" button with state feedback +- AndroidManifest.xml - REQUEST_INSTALL_PACKAGES, FileProvider +- file_paths.xml - cache path for downloaded APK +## Features +- Manual update check from start screen +- Download and install new APK from server +- No Play Store required + +# TaskSchema module +## Backend +- TaskSchema - TaskSchema entity + - id, name, status, date, type, repeat, start, end +- TaskType - Enum for schema type + - single (create one task on date or null, delete schema after date or now if null) + - repeat (create tasks depending on repeat in start-end range, delete after enddate) +- TaskRepeat - Enum for schema repeat + - daily, weekly (array with weekdays), monthly (array with monthdays) +- TaskSchemaController - TaskSchema routes + - index, show, create, update, delete +- TaskSchemaManager - TaskSchema CRUD + - create, update, delete +- TaskSchemaRepository - TaskSchema queries +- TaskGenerator - Create tasks from schema + - generate + + # Category module ## Backend - Category - Category entity @@ -86,17 +119,4 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird - TaskSchemaManager - TaskSchema CRUD - TaskSchemaRepository - TaskSchema queries -# TaskSchema module -- TaskSchema - TaskSchema entity - - id, name, status, category, start, end, date - - type(single, repeat, custom), - - repeat(daily, weekly, monthly, yearly) - - custom(days) -- TaskSchemaController - TaskSchema routes - - index, show, create, update, delete -- TaskSchemaManager - TaskSchema CRUD - - create, update, delete -- TaskSchemaRepository - TaskSchema queries -- TaskGenerator - Create tasks from schema - - generate