feat(update): Update-Dialog, Installation & App-Start-Integration (#85)

- AndroidManifest: REQUEST_INSTALL_PACKAGES Permission hinzugefuegt
- file_paths.xml: cache/update/ Pfad fuer FileProvider ergaenzt
- UpdateUiState: Sealed UI-State (Hidden, Checking, Available, Downloading,
  ReadyToInstall, Error)
- UpdateViewModel: State-Management fuer Update-Check beim App-Start,
  APK-Download mit Fortschrittsanzeige, Installation via FileProvider
- UpdateBanner: Nicht-blockierender animierter Banner mit Versionsanzeige,
  Download-Button, LinearProgressIndicator, Dismiss-Moeglichkeit
- MainScreen: UpdateBanner ueber dem NavGraph integriert
- ApkInstaller Interface + Impl: Testbare Abstraktion fuer APK-Installation
- RepositoryModule: ApkInstaller DI-Binding registriert
- 7 Unit Tests fuer UpdateViewModel (Update verfuegbar, kein Update,
  nicht konfiguriert, Fehler, Download-Fortschritt, Download-Fehler, Dismiss)
- Alle 441 Tests gruen

Closes #85
This commit is contained in:
Jens Reinemann 2026-05-17 05:13:11 +02:00
parent 3ce8ec28e9
commit dfa4b37eda
10 changed files with 628 additions and 3 deletions

View file

@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".KrisenvorratApp"

View file

@ -0,0 +1,28 @@
package de.krisenvorrat.app.data.repository
import android.content.Context
import android.content.Intent
import androidx.core.content.FileProvider
import dagger.hilt.android.qualifiers.ApplicationContext
import de.krisenvorrat.app.domain.usecase.ApkInstaller
import java.io.File
import javax.inject.Inject
internal class ApkInstallerImpl @Inject constructor(
@ApplicationContext private val context: Context
) : ApkInstaller {
override fun install(file: File) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
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

@ -5,6 +5,7 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.krisenvorrat.app.data.export.ImportExportRepositoryImpl
import de.krisenvorrat.app.data.repository.ApkInstallerImpl
import de.krisenvorrat.app.data.repository.CategoryRepositoryImpl
import de.krisenvorrat.app.data.repository.ItemRepositoryImpl
import de.krisenvorrat.app.data.repository.LocationRepositoryImpl
@ -18,6 +19,7 @@ import de.krisenvorrat.app.domain.repository.LocationRepository
import de.krisenvorrat.app.domain.repository.MessageRepository
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.UpdateRepository
import de.krisenvorrat.app.domain.usecase.ApkInstaller
import javax.inject.Singleton
@Module
@ -51,4 +53,8 @@ internal abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUpdateRepository(impl: UpdateRepositoryImpl): UpdateRepository
@Binds
@Singleton
abstract fun bindApkInstaller(impl: ApkInstallerImpl): ApkInstaller
}

View file

@ -0,0 +1,7 @@
package de.krisenvorrat.app.domain.usecase
import java.io.File
internal interface ApkInstaller {
fun install(file: File)
}

View file

@ -1,5 +1,6 @@
package de.krisenvorrat.app.ui
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
@ -28,6 +29,8 @@ import de.krisenvorrat.app.ui.inventory.InventoryPickerSheet
import de.krisenvorrat.app.ui.inventory.InventoryPickerViewModel
import de.krisenvorrat.app.ui.navigation.KrisenvorratNavGraph
import de.krisenvorrat.app.ui.navigation.TopLevelDestination
import de.krisenvorrat.app.ui.update.UpdateBanner
import de.krisenvorrat.app.ui.update.UpdateViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -46,6 +49,9 @@ internal fun MainScreen() {
val showTopBar = showBottomBar && inventoryState.activeInventoryName.isNotBlank()
val updateViewModel: UpdateViewModel = hiltViewModel()
val updateState by updateViewModel.uiState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
if (showTopBar) {
@ -98,12 +104,21 @@ internal fun MainScreen() {
}
}
) { innerPadding ->
KrisenvorratNavGraph(
navController = navController,
Column(
modifier = Modifier
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
)
) {
UpdateBanner(
status = updateState.status,
onDownloadClick = updateViewModel::startDownload,
onDismiss = updateViewModel::dismiss
)
KrisenvorratNavGraph(
navController = navController,
modifier = Modifier.weight(1f)
)
}
}
if (isInventoryPickerVisible) {

View file

@ -0,0 +1,196 @@
package de.krisenvorrat.app.ui.update
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
internal fun UpdateBanner(
status: UpdateStatus,
onDownloadClick: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
val isVisible = status !is UpdateStatus.Hidden && status !is UpdateStatus.Checking
AnimatedVisibility(
visible = isVisible,
enter = expandVertically(),
exit = shrinkVertically(),
modifier = modifier
) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.fillMaxWidth()
) {
when (status) {
is UpdateStatus.Available -> AvailableBanner(
versionName = status.versionName,
onDownloadClick = onDownloadClick,
onDismiss = onDismiss
)
is UpdateStatus.Downloading -> DownloadingBanner(
progress = status.progress
)
is UpdateStatus.ReadyToInstall -> ReadyBanner(
versionName = status.versionName,
onDismiss = onDismiss
)
is UpdateStatus.Error -> ErrorBanner(
message = status.message,
onDismiss = onDismiss
)
else -> {}
}
}
}
}
@Composable
private fun AvailableBanner(
versionName: String,
onDownloadClick: () -> Unit,
onDismiss: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.SystemUpdate,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Update verfügbar: v$versionName",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
Button(
onClick = onDownloadClick,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Text("Jetzt aktualisieren")
}
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Schließen",
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
@Composable
private fun DownloadingBanner(progress: Float) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = "Update wird heruntergeladen…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
LinearProgressIndicator(
progress = { progress },
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.2f)
)
}
}
@Composable
private fun ReadyBanner(
versionName: String,
onDismiss: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Default.SystemUpdate,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "v$versionName wurde heruntergeladen. Installer wird gestartet…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Schließen",
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
@Composable
private fun ErrorBanner(
message: String,
onDismiss: () -> Unit
) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
text = "Update-Fehler: $message",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.weight(1f)
)
TextButton(onClick = onDismiss) {
Text("OK")
}
}
}
}

View file

@ -0,0 +1,14 @@
package de.krisenvorrat.app.ui.update
internal data class UpdateUiState(
val status: UpdateStatus = UpdateStatus.Hidden
)
internal sealed interface UpdateStatus {
data object Hidden : UpdateStatus
data object Checking : UpdateStatus
data class Available(val versionName: String, val apkUrl: String) : UpdateStatus
data class Downloading(val progress: Float) : UpdateStatus
data class ReadyToInstall(val versionName: String) : UpdateStatus
data class Error(val message: String) : UpdateStatus
}

View file

@ -0,0 +1,109 @@
package de.krisenvorrat.app.ui.update
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import de.krisenvorrat.app.BuildConfig
import de.krisenvorrat.app.domain.model.UpdateCheckResult
import de.krisenvorrat.app.domain.repository.UpdateRepository
import de.krisenvorrat.app.domain.usecase.ApkInstaller
import de.krisenvorrat.app.domain.usecase.CheckForUpdateUseCase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.io.File
import javax.inject.Inject
@HiltViewModel
internal class UpdateViewModel @Inject constructor(
private val checkForUpdateUseCase: CheckForUpdateUseCase,
private val updateRepository: UpdateRepository,
private val apkInstaller: ApkInstaller,
@ApplicationContext private val context: Context
) : ViewModel() {
private val _uiState = MutableStateFlow(UpdateUiState())
val uiState: StateFlow<UpdateUiState> = _uiState.asStateFlow()
init {
checkForUpdate()
}
fun checkForUpdate() {
viewModelScope.launch {
_uiState.update { it.copy(status = UpdateStatus.Checking) }
when (val result = checkForUpdateUseCase(BuildConfig.VERSION_CODE)) {
is UpdateCheckResult.UpdateAvailable -> {
_uiState.update {
it.copy(
status = UpdateStatus.Available(
versionName = result.versionInfo.versionName,
apkUrl = result.versionInfo.apkUrl
)
)
}
}
is UpdateCheckResult.UpToDate,
is UpdateCheckResult.NotConfigured -> {
_uiState.update { it.copy(status = UpdateStatus.Hidden) }
}
is UpdateCheckResult.Error -> {
_uiState.update { it.copy(status = UpdateStatus.Hidden) }
}
}
}
}
fun startDownload() {
val status = _uiState.value.status
if (status !is UpdateStatus.Available) return
val apkUrl = status.apkUrl
val versionName = status.versionName
val targetFile = File(context.cacheDir, "update/app-latest.apk")
viewModelScope.launch {
_uiState.update { it.copy(status = UpdateStatus.Downloading(0f)) }
updateRepository.downloadApk(
apkUrl = apkUrl,
targetFile = targetFile,
onProgress = { progress ->
_uiState.update { it.copy(status = UpdateStatus.Downloading(progress)) }
}
).fold(
onSuccess = {
_uiState.update {
it.copy(status = UpdateStatus.ReadyToInstall(versionName))
}
installApk(targetFile)
},
onFailure = { error ->
_uiState.update {
it.copy(status = UpdateStatus.Error(
error.message ?: "Download fehlgeschlagen"
))
}
}
)
}
}
fun dismiss() {
_uiState.update { it.copy(status = UpdateStatus.Hidden) }
}
private fun installApk(file: File) {
try {
apkInstaller.install(file)
} catch (_: Exception) {
_uiState.update {
it.copy(status = UpdateStatus.Error("Installation konnte nicht gestartet werden"))
}
}
}
}

View file

@ -6,4 +6,7 @@
<cache-path
name="camera_images"
path="camera/" />
<cache-path
name="update"
path="update/" />
</paths>

View file

@ -0,0 +1,246 @@
package de.krisenvorrat.app.ui.update
import de.krisenvorrat.app.domain.model.UpdateCheckResult
import de.krisenvorrat.app.domain.model.VersionInfo
import de.krisenvorrat.app.domain.repository.UpdateRepository
import de.krisenvorrat.app.domain.usecase.ApkInstaller
import de.krisenvorrat.app.domain.usecase.CheckForUpdateUseCase
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
class UpdateViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val checkForUpdateUseCase: CheckForUpdateUseCase = mockk()
private val updateRepository: UpdateRepository = mockk()
private val apkInstaller: ApkInstaller = mockk(relaxed = true)
private val context: android.content.Context = mockk(relaxed = true)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
val cacheDir = File(System.getProperty("java.io.tmpdir"), "test-cache-${System.nanoTime()}")
cacheDir.mkdirs()
every { context.cacheDir } returns cacheDir
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel(): UpdateViewModel {
return UpdateViewModel(
checkForUpdateUseCase = checkForUpdateUseCase,
updateRepository = updateRepository,
apkInstaller = apkInstaller,
context = context
)
}
@Test
fun test_init_updateAvailable_showsAvailableStatus() = runTest(testDispatcher) {
// Given
val versionInfo = VersionInfo(versionCode = 10, versionName = "2.0", apkUrl = "https://example.com/app.apk")
coEvery { checkForUpdateUseCase(any()) } returns UpdateCheckResult.UpdateAvailable(versionInfo)
// When
val viewModel = createViewModel()
advanceUntilIdle()
// Then
val status = viewModel.uiState.value.status
assertTrue(status is UpdateStatus.Available)
assertEquals("2.0", (status as UpdateStatus.Available).versionName)
assertEquals("https://example.com/app.apk", status.apkUrl)
}
@Test
fun test_init_upToDate_showsHiddenStatus() = runTest(testDispatcher) {
// Given
coEvery { checkForUpdateUseCase(any()) } returns UpdateCheckResult.UpToDate
// When
val viewModel = createViewModel()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.status is UpdateStatus.Hidden)
}
@Test
fun test_init_notConfigured_showsHiddenStatus() = runTest(testDispatcher) {
// Given
coEvery { checkForUpdateUseCase(any()) } returns UpdateCheckResult.NotConfigured
// When
val viewModel = createViewModel()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.status is UpdateStatus.Hidden)
}
@Test
fun test_init_checkError_showsHiddenStatus() = runTest(testDispatcher) {
// Given
coEvery { checkForUpdateUseCase(any()) } returns UpdateCheckResult.Error(Exception("Network error"))
// When
val viewModel = createViewModel()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.status is UpdateStatus.Hidden)
}
@Test
fun test_startDownload_downloadSucceeds_showsReadyToInstallAndCallsInstaller() = runTest(testDispatcher) {
// Given
val versionInfo = VersionInfo(versionCode = 10, versionName = "2.0", apkUrl = "https://example.com/app.apk")
coEvery { checkForUpdateUseCase(any()) } returns UpdateCheckResult.UpdateAvailable(versionInfo)
val targetFileSlot = slot<File>()
coEvery {
updateRepository.downloadApk(
apkUrl = eq("https://example.com/app.apk"),
targetFile = capture(targetFileSlot),
onProgress = any()
)
} coAnswers {
val onProgress = thirdArg<(Float) -> Unit>()
onProgress(0.5f)
onProgress(1.0f)
targetFileSlot.captured.parentFile?.mkdirs()
targetFileSlot.captured.createNewFile()
Result.success(targetFileSlot.captured)
}
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.startDownload()
advanceUntilIdle()
// Then
coVerify { updateRepository.downloadApk(any(), any(), any()) }
verify { apkInstaller.install(any()) }
val status = viewModel.uiState.value.status
assertTrue(status is UpdateStatus.ReadyToInstall)
assertEquals("2.0", (status as UpdateStatus.ReadyToInstall).versionName)
}
@Test
fun test_startDownload_downloadFails_showsError() = runTest(testDispatcher) {
// Given
val versionInfo = VersionInfo(versionCode = 10, versionName = "2.0", apkUrl = "https://example.com/app.apk")
coEvery { checkForUpdateUseCase(any()) } returns UpdateCheckResult.UpdateAvailable(versionInfo)
coEvery {
updateRepository.downloadApk(any(), any(), any())
} returns Result.failure(Exception("Download failed"))
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.startDownload()
advanceUntilIdle()
// Then
val status = viewModel.uiState.value.status
assertTrue(status is UpdateStatus.Error)
assertEquals("Download failed", (status as UpdateStatus.Error).message)
}
@Test
fun test_dismiss_hidesStatus() = runTest(testDispatcher) {
// Given
val versionInfo = VersionInfo(versionCode = 10, versionName = "2.0", apkUrl = "https://example.com/app.apk")
coEvery { checkForUpdateUseCase(any()) } returns UpdateCheckResult.UpdateAvailable(versionInfo)
val viewModel = createViewModel()
advanceUntilIdle()
assertTrue(viewModel.uiState.value.status is UpdateStatus.Available)
// When
viewModel.dismiss()
// Then
assertTrue(viewModel.uiState.value.status is UpdateStatus.Hidden)
}
@Test
fun test_startDownload_reportsProgress() = runTest(testDispatcher) {
// Given
val versionInfo = VersionInfo(versionCode = 10, versionName = "2.0", apkUrl = "https://example.com/app.apk")
coEvery { checkForUpdateUseCase(any()) } returns UpdateCheckResult.UpdateAvailable(versionInfo)
val progressValues = mutableListOf<Float>()
val targetFileSlot = slot<File>()
coEvery {
updateRepository.downloadApk(any(), capture(targetFileSlot), any())
} coAnswers {
val onProgress = thirdArg<(Float) -> Unit>()
onProgress(0.25f)
progressValues.add(0.25f)
onProgress(0.75f)
progressValues.add(0.75f)
onProgress(1.0f)
progressValues.add(1.0f)
targetFileSlot.captured.parentFile?.mkdirs()
targetFileSlot.captured.createNewFile()
Result.success(targetFileSlot.captured)
}
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.startDownload()
advanceUntilIdle()
// Then
assertEquals(3, progressValues.size)
assertEquals(0.25f, progressValues[0], 0.001f)
assertEquals(0.75f, progressValues[1], 0.001f)
assertEquals(1.0f, progressValues[2], 0.001f)
}
@Test
fun test_startDownload_whenNotAvailable_doesNothing() = runTest(testDispatcher) {
// Given
coEvery { checkForUpdateUseCase(any()) } returns UpdateCheckResult.UpToDate
val viewModel = createViewModel()
advanceUntilIdle()
// When
viewModel.startDownload()
advanceUntilIdle()
// Then
assertTrue(viewModel.uiState.value.status is UpdateStatus.Hidden)
coVerify(exactly = 0) { updateRepository.downloadApk(any(), any(), any()) }
}
}