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:
parent
3ce8ec28e9
commit
dfa4b37eda
10 changed files with 628 additions and 3 deletions
|
|
@ -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:name=".KrisenvorratApp"
|
android:name=".KrisenvorratApp"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import dagger.Module
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import de.krisenvorrat.app.data.export.ImportExportRepositoryImpl
|
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.CategoryRepositoryImpl
|
||||||
import de.krisenvorrat.app.data.repository.ItemRepositoryImpl
|
import de.krisenvorrat.app.data.repository.ItemRepositoryImpl
|
||||||
import de.krisenvorrat.app.data.repository.LocationRepositoryImpl
|
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.MessageRepository
|
||||||
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
import de.krisenvorrat.app.domain.repository.SettingsRepository
|
||||||
import de.krisenvorrat.app.domain.repository.UpdateRepository
|
import de.krisenvorrat.app.domain.repository.UpdateRepository
|
||||||
|
import de.krisenvorrat.app.domain.usecase.ApkInstaller
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
|
|
@ -51,4 +53,8 @@ internal abstract class RepositoryModule {
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindUpdateRepository(impl: UpdateRepositoryImpl): UpdateRepository
|
abstract fun bindUpdateRepository(impl: UpdateRepositoryImpl): UpdateRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindApkInstaller(impl: ApkInstallerImpl): ApkInstaller
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package de.krisenvorrat.app.domain.usecase
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
internal interface ApkInstaller {
|
||||||
|
fun install(file: File)
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package de.krisenvorrat.app.ui
|
package de.krisenvorrat.app.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
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.inventory.InventoryPickerViewModel
|
||||||
import de.krisenvorrat.app.ui.navigation.KrisenvorratNavGraph
|
import de.krisenvorrat.app.ui.navigation.KrisenvorratNavGraph
|
||||||
import de.krisenvorrat.app.ui.navigation.TopLevelDestination
|
import de.krisenvorrat.app.ui.navigation.TopLevelDestination
|
||||||
|
import de.krisenvorrat.app.ui.update.UpdateBanner
|
||||||
|
import de.krisenvorrat.app.ui.update.UpdateViewModel
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -46,6 +49,9 @@ internal fun MainScreen() {
|
||||||
|
|
||||||
val showTopBar = showBottomBar && inventoryState.activeInventoryName.isNotBlank()
|
val showTopBar = showBottomBar && inventoryState.activeInventoryName.isNotBlank()
|
||||||
|
|
||||||
|
val updateViewModel: UpdateViewModel = hiltViewModel()
|
||||||
|
val updateState by updateViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
if (showTopBar) {
|
if (showTopBar) {
|
||||||
|
|
@ -98,12 +104,21 @@ internal fun MainScreen() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
KrisenvorratNavGraph(
|
Column(
|
||||||
navController = navController,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(innerPadding)
|
.padding(innerPadding)
|
||||||
.consumeWindowInsets(innerPadding)
|
.consumeWindowInsets(innerPadding)
|
||||||
)
|
) {
|
||||||
|
UpdateBanner(
|
||||||
|
status = updateState.status,
|
||||||
|
onDownloadClick = updateViewModel::startDownload,
|
||||||
|
onDismiss = updateViewModel::dismiss
|
||||||
|
)
|
||||||
|
KrisenvorratNavGraph(
|
||||||
|
navController = navController,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isInventoryPickerVisible) {
|
if (isInventoryPickerVisible) {
|
||||||
|
|
|
||||||
196
app/src/main/java/de/krisenvorrat/app/ui/update/UpdateBanner.kt
Normal file
196
app/src/main/java/de/krisenvorrat/app/ui/update/UpdateBanner.kt
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,4 +6,7 @@
|
||||||
<cache-path
|
<cache-path
|
||||||
name="camera_images"
|
name="camera_images"
|
||||||
path="camera/" />
|
path="camera/" />
|
||||||
|
<cache-path
|
||||||
|
name="update"
|
||||||
|
path="update/" />
|
||||||
</paths>
|
</paths>
|
||||||
|
|
|
||||||
|
|
@ -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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue