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">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
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.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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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) {
|
||||
|
|
|
|||
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
|
||||
name="camera_images"
|
||||
path="camera/" />
|
||||
<cache-path
|
||||
name="update"
|
||||
path="update/" />
|
||||
</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