feat(update): Update-Check & APK-Download Data/Domain-Layer

VersionInfo-Datenmodell, UpdateRepository (checkForUpdate + downloadApk
mit Progress-Callback) und CheckForUpdateUseCase implementiert.

Neue Dateien:
- domain/model/VersionInfo.kt: @Serializable Datenmodell (versionCode,
  versionName, apkUrl)
- domain/model/UpdateCheckResult.kt: Sealed interface (UpdateAvailable,
  UpToDate, Error, NotConfigured)
- domain/repository/UpdateRepository.kt: Interface
- data/repository/UpdateRepositoryImpl.kt: Ktor HttpClient mit
  Streaming-Download und Progress-Reporting
- domain/usecase/CheckForUpdateUseCase.kt: Vergleicht Server-versionCode
  mit BuildConfig.VERSION_CODE, prueft Server-URL via SettingsRepository

Geaenderte Dateien:
- di/RepositoryModule.kt: UpdateRepository Hilt-Binding ergaenzt

Tests (12 neue):
- UpdateRepositoryImplTest: checkForUpdate (Erfolg, 500, trailing slash),
  downloadApk (Erfolg+Progress, 404, Parent-Dir-Erstellung)
- CheckForUpdateUseCaseTest: neuer/gleicher/aelterer versionCode,
  leere/blanke Server-URL, Netzwerkfehler

Closes #84
This commit is contained in:
Jens Reinemann 2026-05-17 04:38:34 +02:00
parent 994d6b1b07
commit 3ce8ec28e9
8 changed files with 465 additions and 0 deletions

View file

@ -0,0 +1,88 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.domain.model.VersionInfo
import de.krisenvorrat.app.domain.repository.UpdateRepository
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.prepareGet
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentLength
import io.ktor.utils.io.readAvailable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.net.ConnectException
import java.net.SocketTimeoutException
import javax.inject.Inject
internal class UpdateRepositoryImpl @Inject constructor(
private val httpClient: HttpClient
) : UpdateRepository {
override suspend fun checkForUpdate(serverUrl: String): Result<VersionInfo> =
withContext(Dispatchers.IO) {
try {
val url = "${serverUrl.trimEnd('/')}/api/version"
val response = httpClient.get(url)
when (response.status) {
HttpStatusCode.OK -> Result.success(response.body<VersionInfo>())
else -> Result.failure(
Exception("Server returned ${response.status.value}: ${response.status.description}")
)
}
} catch (e: SocketTimeoutException) {
Result.failure(e)
} catch (e: ConnectException) {
Result.failure(e)
} catch (e: Exception) {
Result.failure(e)
}
}
override suspend fun downloadApk(
apkUrl: String,
targetFile: File,
onProgress: (Float) -> Unit
): Result<File> = withContext(Dispatchers.IO) {
try {
targetFile.parentFile?.mkdirs()
httpClient.prepareGet(apkUrl).execute { response ->
if (response.status != HttpStatusCode.OK) {
return@execute Result.failure(
Exception("Download failed: ${response.status.value}")
)
}
val totalBytes = response.contentLength() ?: -1L
val channel = response.bodyAsChannel()
val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
var bytesRead = 0L
targetFile.outputStream().buffered().use { output ->
while (!channel.isClosedForRead) {
val read = channel.readAvailable(buffer)
if (read <= 0) break
output.write(buffer, 0, read)
bytesRead += read
if (totalBytes > 0) {
onProgress(bytesRead.toFloat() / totalBytes.toFloat())
}
}
}
onProgress(1f)
Result.success(targetFile)
}
} catch (e: Exception) {
targetFile.delete()
Result.failure(e)
}
}
private companion object {
const val DOWNLOAD_BUFFER_SIZE = 8192
}
}

View file

@ -10,12 +10,14 @@ import de.krisenvorrat.app.data.repository.ItemRepositoryImpl
import de.krisenvorrat.app.data.repository.LocationRepositoryImpl
import de.krisenvorrat.app.data.repository.MessageRepositoryImpl
import de.krisenvorrat.app.data.repository.SettingsRepositoryImpl
import de.krisenvorrat.app.data.repository.UpdateRepositoryImpl
import de.krisenvorrat.app.domain.repository.CategoryRepository
import de.krisenvorrat.app.domain.repository.ImportExportRepository
import de.krisenvorrat.app.domain.repository.ItemRepository
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 javax.inject.Singleton
@Module
@ -45,4 +47,8 @@ internal abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindMessageRepository(impl: MessageRepositoryImpl): MessageRepository
@Binds
@Singleton
abstract fun bindUpdateRepository(impl: UpdateRepositoryImpl): UpdateRepository
}

View file

@ -0,0 +1,8 @@
package de.krisenvorrat.app.domain.model
internal sealed interface UpdateCheckResult {
data class UpdateAvailable(val versionInfo: VersionInfo) : UpdateCheckResult
data object UpToDate : UpdateCheckResult
data class Error(val cause: Throwable) : UpdateCheckResult
data object NotConfigured : UpdateCheckResult
}

View file

@ -0,0 +1,10 @@
package de.krisenvorrat.app.domain.model
import kotlinx.serialization.Serializable
@Serializable
internal data class VersionInfo(
val versionCode: Int,
val versionName: String,
val apkUrl: String
)

View file

@ -0,0 +1,9 @@
package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.app.domain.model.VersionInfo
import java.io.File
internal interface UpdateRepository {
suspend fun checkForUpdate(serverUrl: String): Result<VersionInfo>
suspend fun downloadApk(apkUrl: String, targetFile: File, onProgress: (Float) -> Unit): Result<File>
}

View file

@ -0,0 +1,33 @@
package de.krisenvorrat.app.domain.usecase
import de.krisenvorrat.app.domain.model.SettingsKey.StringKey
import de.krisenvorrat.app.domain.model.UpdateCheckResult
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.UpdateRepository
import javax.inject.Inject
internal class CheckForUpdateUseCase @Inject constructor(
private val updateRepository: UpdateRepository,
private val settingsRepository: SettingsRepository
) {
suspend operator fun invoke(currentVersionCode: Int): UpdateCheckResult {
val serverUrl = settingsRepository.getString(StringKey.ServerUrl)
if (serverUrl.isBlank()) {
return UpdateCheckResult.NotConfigured
}
return updateRepository.checkForUpdate(serverUrl).fold(
onSuccess = { versionInfo ->
if (versionInfo.versionCode > currentVersionCode) {
UpdateCheckResult.UpdateAvailable(versionInfo)
} else {
UpdateCheckResult.UpToDate
}
},
onFailure = { error ->
UpdateCheckResult.Error(error)
}
)
}
}

View file

@ -0,0 +1,202 @@
package de.krisenvorrat.app.data.repository
import de.krisenvorrat.app.domain.model.VersionInfo
import io.ktor.client.HttpClient
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.respondError
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpMethod
import io.ktor.http.HttpStatusCode
import io.ktor.http.headersOf
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class UpdateRepositoryImplTest {
private val jsonSerializer = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private lateinit var httpClient: HttpClient
private lateinit var repository: UpdateRepositoryImpl
@After
fun tearDown() {
if (::httpClient.isInitialized) {
httpClient.close()
}
}
private fun createClient(engine: MockEngine): HttpClient = HttpClient(engine) {
install(ContentNegotiation) {
json(jsonSerializer)
}
}
// --- checkForUpdate ---
@Test
fun test_checkForUpdate_serverReturnsVersionInfo_returnsSuccess() = runTest {
// Given
val versionInfo = VersionInfo(versionCode = 5, versionName = "2.0", apkUrl = "https://example.com/app.apk")
val engine = MockEngine { request ->
assertEquals("/api/version", request.url.encodedPath)
assertEquals(HttpMethod.Get, request.method)
respond(
content = jsonSerializer.encodeToString(VersionInfo.serializer(), versionInfo),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
httpClient = createClient(engine)
repository = UpdateRepositoryImpl(httpClient)
// When
val result = repository.checkForUpdate("https://example.com")
// Then
assertTrue(result.isSuccess)
assertEquals(versionInfo, result.getOrNull())
}
@Test
fun test_checkForUpdate_serverReturns500_returnsFailure() = runTest {
// Given
val engine = MockEngine {
respondError(HttpStatusCode.InternalServerError)
}
httpClient = createClient(engine)
repository = UpdateRepositoryImpl(httpClient)
// When
val result = repository.checkForUpdate("https://example.com")
// Then
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull()?.message?.contains("500") == true)
}
@Test
fun test_checkForUpdate_serverUrlWithTrailingSlash_stripsSlash() = runTest {
// Given
val versionInfo = VersionInfo(versionCode = 5, versionName = "2.0", apkUrl = "https://example.com/app.apk")
val engine = MockEngine { request ->
assertEquals("/api/version", request.url.encodedPath)
respond(
content = jsonSerializer.encodeToString(VersionInfo.serializer(), versionInfo),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
httpClient = createClient(engine)
repository = UpdateRepositoryImpl(httpClient)
// When
val result = repository.checkForUpdate("https://example.com/")
// Then
assertTrue(result.isSuccess)
}
// --- downloadApk ---
@Test
fun test_downloadApk_serverReturnsData_writesFileAndReportsProgress() = runTest {
// Given
val apkData = ByteArray(1024) { it.toByte() }
val engine = MockEngine {
respond(
content = apkData,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentLength, apkData.size.toString())
)
}
httpClient = createClient(engine)
repository = UpdateRepositoryImpl(httpClient)
val targetFile = File.createTempFile("test-apk", ".apk")
targetFile.deleteOnExit()
val progressValues = mutableListOf<Float>()
// When
val result = repository.downloadApk(
apkUrl = "https://example.com/app.apk",
targetFile = targetFile,
onProgress = { progressValues.add(it) }
)
// Then
assertTrue(result.isSuccess)
assertEquals(targetFile, result.getOrNull())
assertTrue(targetFile.exists())
assertEquals(apkData.size.toLong(), targetFile.length())
assertTrue(progressValues.isNotEmpty())
assertEquals(1f, progressValues.last(), 0.001f)
}
@Test
fun test_downloadApk_serverReturns404_returnsFailure() = runTest {
// Given
val engine = MockEngine {
respondError(HttpStatusCode.NotFound)
}
httpClient = createClient(engine)
repository = UpdateRepositoryImpl(httpClient)
val targetFile = File.createTempFile("test-apk", ".apk")
targetFile.deleteOnExit()
// When
val result = repository.downloadApk(
apkUrl = "https://example.com/app.apk",
targetFile = targetFile,
onProgress = {}
)
// Then
assertTrue(result.isFailure)
}
@Test
fun test_downloadApk_createsParentDirectories() = runTest {
// Given
val apkData = ByteArray(64) { it.toByte() }
val engine = MockEngine {
respond(
content = apkData,
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentLength, apkData.size.toString())
)
}
httpClient = createClient(engine)
repository = UpdateRepositoryImpl(httpClient)
val tempDir = File(System.getProperty("java.io.tmpdir"), "test-update-${System.nanoTime()}")
val targetFile = File(tempDir, "subdir/app-latest.apk")
try {
// When
val result = repository.downloadApk(
apkUrl = "https://example.com/app.apk",
targetFile = targetFile,
onProgress = {}
)
// Then
assertTrue(result.isSuccess)
assertTrue(targetFile.exists())
} finally {
tempDir.deleteRecursively()
}
}
}

View file

@ -0,0 +1,109 @@
package de.krisenvorrat.app.domain.usecase
import de.krisenvorrat.app.domain.model.SettingsKey.StringKey
import de.krisenvorrat.app.domain.model.UpdateCheckResult
import de.krisenvorrat.app.domain.model.VersionInfo
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.UpdateRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.net.ConnectException
class CheckForUpdateUseCaseTest {
private val updateRepository: UpdateRepository = mockk()
private val settingsRepository: SettingsRepository = mockk()
private val useCase = CheckForUpdateUseCase(updateRepository, settingsRepository)
private fun setupServerUrl(url: String) {
coEvery { settingsRepository.getString(StringKey.ServerUrl) } returns url
}
@Test
fun test_invoke_serverHasNewerVersion_returnsUpdateAvailable() = runTest {
// Given
val versionInfo = VersionInfo(versionCode = 5, versionName = "2.0", apkUrl = "https://example.com/app.apk")
setupServerUrl("https://example.com")
coEvery { updateRepository.checkForUpdate("https://example.com") } returns Result.success(versionInfo)
// When
val result = useCase(currentVersionCode = 3)
// Then
assertTrue(result is UpdateCheckResult.UpdateAvailable)
assertEquals(versionInfo, (result as UpdateCheckResult.UpdateAvailable).versionInfo)
}
@Test
fun test_invoke_serverHasSameVersion_returnsUpToDate() = runTest {
// Given
val versionInfo = VersionInfo(versionCode = 3, versionName = "1.2", apkUrl = "https://example.com/app.apk")
setupServerUrl("https://example.com")
coEvery { updateRepository.checkForUpdate("https://example.com") } returns Result.success(versionInfo)
// When
val result = useCase(currentVersionCode = 3)
// Then
assertTrue(result is UpdateCheckResult.UpToDate)
}
@Test
fun test_invoke_serverHasOlderVersion_returnsUpToDate() = runTest {
// Given
val versionInfo = VersionInfo(versionCode = 2, versionName = "1.0", apkUrl = "https://example.com/app.apk")
setupServerUrl("https://example.com")
coEvery { updateRepository.checkForUpdate("https://example.com") } returns Result.success(versionInfo)
// When
val result = useCase(currentVersionCode = 3)
// Then
assertTrue(result is UpdateCheckResult.UpToDate)
}
@Test
fun test_invoke_serverUrlIsEmpty_returnsNotConfigured() = runTest {
// Given
setupServerUrl("")
// When
val result = useCase(currentVersionCode = 3)
// Then
assertTrue(result is UpdateCheckResult.NotConfigured)
coVerify(exactly = 0) { updateRepository.checkForUpdate(any()) }
}
@Test
fun test_invoke_serverUrlIsBlank_returnsNotConfigured() = runTest {
// Given
setupServerUrl(" ")
// When
val result = useCase(currentVersionCode = 3)
// Then
assertTrue(result is UpdateCheckResult.NotConfigured)
}
@Test
fun test_invoke_networkError_returnsError() = runTest {
// Given
setupServerUrl("https://example.com")
val networkError = ConnectException("Connection refused")
coEvery { updateRepository.checkForUpdate("https://example.com") } returns Result.failure(networkError)
// When
val result = useCase(currentVersionCode = 3)
// Then
assertTrue(result is UpdateCheckResult.Error)
assertEquals(networkError, (result as UpdateCheckResult.Error).cause)
}
}