App update module

This commit is contained in:
Marek Lenczewski
2026-04-12 10:46:14 +02:00
parent 27b34eb90f
commit 4e81cea831
9 changed files with 216 additions and 37 deletions

View File

@@ -38,6 +38,7 @@ android {
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
} }

View File

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:label="@string/app_name" android:label="@string/app_name"
@@ -16,6 +17,16 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View File

@@ -1,26 +1,54 @@
package de.haushalt.app package de.haushalt.app
import androidx.compose.foundation.layout.Box 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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp 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 @Composable
fun StartScreen(onOpenTasks: () -> Unit = {}) { fun StartScreen(onOpenTasks: () -> Unit = {}) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var updateState by remember { mutableStateOf<UpdateState>(UpdateState.Idle) }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp), columns = GridCells.Adaptive(minSize = 160.dp),
modifier = Modifier modifier = Modifier.weight(1f),
.fillMaxSize()
.padding(16.dp),
) { ) {
item { item {
Card( Card(
@@ -41,4 +69,55 @@ fun StartScreen(onOpenTasks: () -> Unit = {}) {
} }
} }
} }
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)
}
}
}
}
} }

View File

@@ -8,23 +8,32 @@ import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.converter.kotlinx.serialization.asConverterFactory
object ApiClient { 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 { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
explicitNulls = false explicitNulls = false
} }
private val okHttp: OkHttpClient = OkHttpClient.Builder() val okHttp: OkHttpClient = OkHttpClient.Builder()
.addInterceptor( .addInterceptor(
HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC } HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
) )
.build() .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) .baseUrl(BASE_URL)
.client(okHttp) .client(okHttp)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType())) .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build() .build()
.create(TaskApi::class.java) .create(AppUpdateApi::class.java)
} }

View File

@@ -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,
)

View File

@@ -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)
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path name="apk" path="apk/" />
</paths>

View File

@@ -0,0 +1,4 @@
{
"versionCode": 1,
"apkFile": "haushalt.apk"
}

View File

@@ -74,6 +74,39 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird
- TaskCreate page: create task - TaskCreate page: create task
- TaskEdit page: update 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 # Category module
## Backend ## Backend
- Category - Category entity - Category - Category entity
@@ -86,17 +119,4 @@ Implementierungs-Schritte als Feature-Module - WIE es gebaut wird
- TaskSchemaManager - TaskSchema CRUD - TaskSchemaManager - TaskSchema CRUD
- TaskSchemaRepository - TaskSchema queries - 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