diff --git a/app/src/main/java/de/krisenvorrat/app/data/repository/UpdateRepositoryImpl.kt b/app/src/main/java/de/krisenvorrat/app/data/repository/UpdateRepositoryImpl.kt new file mode 100644 index 0000000..d5443d4 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/data/repository/UpdateRepositoryImpl.kt @@ -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 = + withContext(Dispatchers.IO) { + try { + val url = "${serverUrl.trimEnd('/')}/api/version" + val response = httpClient.get(url) + when (response.status) { + HttpStatusCode.OK -> Result.success(response.body()) + 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 = 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 + } +} diff --git a/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt b/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt index 33c3c61..ed8aede 100644 --- a/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt +++ b/app/src/main/java/de/krisenvorrat/app/di/RepositoryModule.kt @@ -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 } diff --git a/app/src/main/java/de/krisenvorrat/app/domain/model/UpdateCheckResult.kt b/app/src/main/java/de/krisenvorrat/app/domain/model/UpdateCheckResult.kt new file mode 100644 index 0000000..bcb384f --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/domain/model/UpdateCheckResult.kt @@ -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 +} diff --git a/app/src/main/java/de/krisenvorrat/app/domain/model/VersionInfo.kt b/app/src/main/java/de/krisenvorrat/app/domain/model/VersionInfo.kt new file mode 100644 index 0000000..a22d41e --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/domain/model/VersionInfo.kt @@ -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 +) diff --git a/app/src/main/java/de/krisenvorrat/app/domain/repository/UpdateRepository.kt b/app/src/main/java/de/krisenvorrat/app/domain/repository/UpdateRepository.kt new file mode 100644 index 0000000..9927c2f --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/domain/repository/UpdateRepository.kt @@ -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 + suspend fun downloadApk(apkUrl: String, targetFile: File, onProgress: (Float) -> Unit): Result +} diff --git a/app/src/main/java/de/krisenvorrat/app/domain/usecase/CheckForUpdateUseCase.kt b/app/src/main/java/de/krisenvorrat/app/domain/usecase/CheckForUpdateUseCase.kt new file mode 100644 index 0000000..464b484 --- /dev/null +++ b/app/src/main/java/de/krisenvorrat/app/domain/usecase/CheckForUpdateUseCase.kt @@ -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) + } + ) + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/data/repository/UpdateRepositoryImplTest.kt b/app/src/test/java/de/krisenvorrat/app/data/repository/UpdateRepositoryImplTest.kt new file mode 100644 index 0000000..753c575 --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/data/repository/UpdateRepositoryImplTest.kt @@ -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() + + // 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() + } + } +} diff --git a/app/src/test/java/de/krisenvorrat/app/domain/usecase/CheckForUpdateUseCaseTest.kt b/app/src/test/java/de/krisenvorrat/app/domain/usecase/CheckForUpdateUseCaseTest.kt new file mode 100644 index 0000000..44b1267 --- /dev/null +++ b/app/src/test/java/de/krisenvorrat/app/domain/usecase/CheckForUpdateUseCaseTest.kt @@ -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) + } +}