App update module
This commit is contained in:
@@ -38,6 +38,7 @@ android {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/app/src/main/java/de/haushalt/app/data/AppUpdateApi.kt
Normal file
15
app/app/src/main/java/de/haushalt/app/data/AppUpdateApi.kt
Normal 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,
|
||||||
|
)
|
||||||
36
app/app/src/main/java/de/haushalt/app/data/AppUpdater.kt
Normal file
36
app/app/src/main/java/de/haushalt/app/data/AppUpdater.kt
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
app/app/src/main/res/xml/file_paths.xml
Normal file
4
app/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<cache-path name="apk" path="apk/" />
|
||||||
|
</paths>
|
||||||
4
backend/public/app/version.json
Normal file
4
backend/public/app/version.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"versionCode": 1,
|
||||||
|
"apkFile": "haushalt.apk"
|
||||||
|
}
|
||||||
46
module.md
46
module.md
@@ -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
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user