feat(app): add Ktor HTTP client and SyncService for inventory sync

domain/model/SyncError.kt:
- Sealed class with ConnectionError, Timeout, AuthError, ServerError,
  NotConfigured, Unknown subtypes for typed error handling

domain/repository/SyncService.kt:
- Interface with downloadInventory() and uploadInventory() returning
  Result<InventoryDto> for clean error propagation

data/sync/SyncServiceImpl.kt:
- Ktor Client implementation using OkHttp engine
- GET /api/inventory and PUT /api/inventory endpoints
- X-API-Key header authentication matching server contract
- Server URL and API key read from SettingsRepository
- withContext(Dispatchers.IO) for network calls
- Catches SocketTimeoutException, ConnectException specifically

di/NetworkModule.kt:
- Hilt module providing singleton HttpClient with OkHttp engine
- ContentNegotiation with kotlinx.serialization JSON
- Configurable connect/read/write timeouts (10s/30s/30s)
- Binds SyncServiceImpl to SyncService interface

Dependencies:
- ktor-client-core, ktor-client-okhttp,
  ktor-client-content-negotiation, ktor-client-mock (test)
- ktor-serialization-kotlinx-json (shared with server)
- INTERNET permission added to AndroidManifest.xml

Tests: 9 tests with Ktor MockEngine covering success, 401, 500,
missing config, trailing slash URL normalization

Closes #44
This commit is contained in:
Jens Reinemann 2026-05-14 21:14:40 +02:00
parent cb9bd2bdf4
commit 215790d68e
8 changed files with 442 additions and 0 deletions

View file

@ -71,12 +71,19 @@ dependencies {
// Serialization
implementation(libs.kotlinx.serialization.json)
// Ktor Client
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
// Shared module
implementation(project(":shared"))
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.ktor.client.mock)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.room.testing)
androidTestImplementation(libs.androidx.espresso.core)

View file

@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".KrisenvorratApp"
android:allowBackup="true"

View file

@ -0,0 +1,86 @@
package de.krisenvorrat.app.data.sync
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.app.domain.repository.SyncService
import de.krisenvorrat.shared.model.InventoryDto
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.put
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.ConnectException
import java.net.SocketTimeoutException
import javax.inject.Inject
internal class SyncServiceImpl @Inject constructor(
private val httpClient: HttpClient,
private val settingsRepository: SettingsRepository
) : SyncService {
override suspend fun downloadInventory(): Result<InventoryDto> =
executeRequest { serverUrl, apiKey ->
val response = httpClient.get("$serverUrl/api/inventory") {
header("X-API-Key", apiKey)
}
handleResponse(response)
}
override suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto> =
executeRequest { serverUrl, apiKey ->
val response = httpClient.put("$serverUrl/api/inventory") {
header("X-API-Key", apiKey)
contentType(ContentType.Application.Json)
setBody(inventory)
}
handleResponse(response)
}
private suspend fun executeRequest(
block: suspend (serverUrl: String, apiKey: String) -> Result<InventoryDto>
): Result<InventoryDto> = withContext(Dispatchers.IO) {
val serverUrl = settingsRepository.getValue(KEY_SERVER_URL)
if (serverUrl.isNullOrBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("Server-URL nicht gesetzt"))
}
val apiKey = settingsRepository.getValue(KEY_API_KEY)
if (apiKey.isNullOrBlank()) {
return@withContext Result.failure(SyncError.NotConfigured("API-Key nicht gesetzt"))
}
try {
block(serverUrl.trimEnd('/'), apiKey)
} catch (e: SocketTimeoutException) {
Result.failure(SyncError.Timeout(e))
} catch (e: ConnectException) {
Result.failure(SyncError.ConnectionError(e))
} catch (e: Exception) {
Result.failure(SyncError.Unknown(e))
}
}
private suspend fun handleResponse(
response: io.ktor.client.statement.HttpResponse
): Result<InventoryDto> = when (response.status) {
HttpStatusCode.OK -> Result.success(response.body())
HttpStatusCode.Unauthorized -> Result.failure(SyncError.AuthError())
else -> Result.failure(
SyncError.ServerError(
statusCode = response.status.value,
message = response.status.description
)
)
}
private companion object {
const val KEY_SERVER_URL = "server_url"
const val KEY_API_KEY = "api_key"
}
}

View file

@ -0,0 +1,50 @@
package de.krisenvorrat.app.di
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.krisenvorrat.app.data.sync.SyncServiceImpl
import de.krisenvorrat.app.domain.repository.SyncService
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
internal abstract class NetworkModule {
@Binds
@Singleton
abstract fun bindSyncService(impl: SyncServiceImpl): SyncService
companion object {
@Provides
@Singleton
fun provideHttpClient(): HttpClient = HttpClient(OkHttp) {
engine {
config {
connectTimeout(CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
writeTimeout(WRITE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
}
}
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
encodeDefaults = true
})
}
}
private const val CONNECT_TIMEOUT_SECONDS = 10L
private const val READ_TIMEOUT_SECONDS = 30L
private const val WRITE_TIMEOUT_SECONDS = 30L
}
}

View file

@ -0,0 +1,21 @@
package de.krisenvorrat.app.domain.model
internal sealed class SyncError(message: String, cause: Throwable? = null) : Exception(message, cause) {
class ConnectionError(cause: Throwable? = null) :
SyncError("Server nicht erreichbar", cause)
class Timeout(cause: Throwable? = null) :
SyncError("Zeitüberschreitung bei der Verbindung zum Server", cause)
class AuthError :
SyncError("Authentifizierung fehlgeschlagen API-Key ungültig")
class ServerError(val statusCode: Int, message: String) :
SyncError("Serverfehler ($statusCode): $message")
class NotConfigured(detail: String) :
SyncError("Sync nicht konfiguriert: $detail")
class Unknown(cause: Throwable) :
SyncError("Unbekannter Fehler: ${cause.message}", cause)
}

View file

@ -0,0 +1,8 @@
package de.krisenvorrat.app.domain.repository
import de.krisenvorrat.shared.model.InventoryDto
internal interface SyncService {
suspend fun downloadInventory(): Result<InventoryDto>
suspend fun uploadInventory(inventory: InventoryDto): Result<InventoryDto>
}

View file

@ -0,0 +1,264 @@
package de.krisenvorrat.app.data.sync
import de.krisenvorrat.app.domain.model.SyncError
import de.krisenvorrat.app.domain.repository.SettingsRepository
import de.krisenvorrat.shared.model.CategoryDto
import de.krisenvorrat.shared.model.InventoryDto
import de.krisenvorrat.shared.model.ItemDto
import de.krisenvorrat.shared.model.LocationDto
import de.krisenvorrat.shared.model.SettingDto
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 io.mockk.coEvery
import io.mockk.mockk
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.Before
import org.junit.Test
class SyncServiceImplTest {
private val settingsRepository: SettingsRepository = mockk()
private val jsonSerializer = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val testInventory = InventoryDto(
categories = listOf(CategoryDto(id = 1, name = "Konserven")),
locations = listOf(LocationDto(id = 1, name = "Keller")),
items = listOf(
ItemDto(
id = "item-1",
name = "Bohnen",
categoryId = 1,
quantity = 5.0,
unit = "Dose",
unitPrice = 1.29,
kcalPer100g = 100,
expiryDate = "2027-06-01",
locationId = 1,
minStock = 2.0,
notes = "",
lastUpdated = System.currentTimeMillis()
)
),
settings = listOf(SettingDto(key = "household_size", value = "2"))
)
private lateinit var httpClient: HttpClient
private lateinit var syncService: SyncServiceImpl
@After
fun tearDown() {
if (::httpClient.isInitialized) {
httpClient.close()
}
}
private fun createClient(engine: MockEngine): HttpClient = HttpClient(engine) {
install(ContentNegotiation) {
json(jsonSerializer)
}
}
private fun setupSettings(serverUrl: String? = "http://localhost:8080", apiKey: String? = "test-key") {
coEvery { settingsRepository.getValue("server_url") } returns serverUrl
coEvery { settingsRepository.getValue("api_key") } returns apiKey
}
// --- downloadInventory ---
@Test
fun test_downloadInventory_serverReturnsInventory_returnsSuccess() = runTest {
// Given
setupSettings()
val engine = MockEngine { request ->
assertEquals("/api/inventory", request.url.encodedPath)
assertEquals(HttpMethod.Get, request.method)
assertEquals("test-key", request.headers["X-API-Key"])
respond(
content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
// When
val result = syncService.downloadInventory()
// Then
assertTrue(result.isSuccess)
assertEquals(testInventory, result.getOrNull())
}
@Test
fun test_downloadInventory_serverReturns401_returnsAuthError() = runTest {
// Given
setupSettings()
val engine = MockEngine {
respondError(HttpStatusCode.Unauthorized)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
// When
val result = syncService.downloadInventory()
// Then
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is SyncError.AuthError)
}
@Test
fun test_downloadInventory_serverReturns500_returnsServerError() = runTest {
// Given
setupSettings()
val engine = MockEngine {
respondError(HttpStatusCode.InternalServerError)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
// When
val result = syncService.downloadInventory()
// Then
assertTrue(result.isFailure)
val error = result.exceptionOrNull() as SyncError.ServerError
assertEquals(500, error.statusCode)
}
@Test
fun test_downloadInventory_noServerUrl_returnsNotConfigured() = runTest {
// Given
setupSettings(serverUrl = null)
val engine = MockEngine { respondError(HttpStatusCode.OK) }
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
// When
val result = syncService.downloadInventory()
// Then
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is SyncError.NotConfigured)
}
@Test
fun test_downloadInventory_emptyServerUrl_returnsNotConfigured() = runTest {
// Given
setupSettings(serverUrl = " ")
val engine = MockEngine { respondError(HttpStatusCode.OK) }
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
// When
val result = syncService.downloadInventory()
// Then
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is SyncError.NotConfigured)
}
@Test
fun test_downloadInventory_noApiKey_returnsNotConfigured() = runTest {
// Given
setupSettings(apiKey = null)
val engine = MockEngine { respondError(HttpStatusCode.OK) }
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
// When
val result = syncService.downloadInventory()
// Then
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is SyncError.NotConfigured)
}
// --- uploadInventory ---
@Test
fun test_uploadInventory_serverAccepts_returnsSuccess() = runTest {
// Given
setupSettings()
val engine = MockEngine { request ->
assertEquals("/api/inventory", request.url.encodedPath)
assertEquals(HttpMethod.Put, request.method)
assertEquals("test-key", request.headers["X-API-Key"])
assertEquals(
ContentType.Application.Json.toString(),
request.body.contentType.toString()
)
respond(
content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
// When
val result = syncService.uploadInventory(testInventory)
// Then
assertTrue(result.isSuccess)
assertEquals(testInventory, result.getOrNull())
}
@Test
fun test_uploadInventory_serverReturns401_returnsAuthError() = runTest {
// Given
setupSettings()
val engine = MockEngine {
respondError(HttpStatusCode.Unauthorized)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
// When
val result = syncService.uploadInventory(testInventory)
// Then
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is SyncError.AuthError)
}
@Test
fun test_uploadInventory_serverUrlHasTrailingSlash_urlIsCorrect() = runTest {
// Given
setupSettings(serverUrl = "http://localhost:8080/")
val engine = MockEngine { request ->
assertEquals("http://localhost:8080/api/inventory", request.url.toString())
respond(
content = jsonSerializer.encodeToString(InventoryDto.serializer(), testInventory),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
)
}
httpClient = createClient(engine)
syncService = SyncServiceImpl(httpClient, settingsRepository)
// When
val result = syncService.uploadInventory(testInventory)
// Then
assertTrue(result.isSuccess)
}
}

View file

@ -58,6 +58,10 @@ ktor-server-status-pages = { group = "io.ktor", name = "ktor-server-status-pages
ktor-server-auth = { group = "io.ktor", name = "ktor-server-auth", version.ref = "ktor" }
ktor-server-call-logging = { group = "io.ktor", name = "ktor-server-call-logging", version.ref = "ktor" }
ktor-server-test-host = { group = "io.ktor", name = "ktor-server-test-host", version.ref = "ktor" }
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-mock = { group = "io.ktor", name = "ktor-client-mock", version.ref = "ktor" }
logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" }
exposed-core = { group = "org.jetbrains.exposed", name = "exposed-core", version.ref = "exposed" }
exposed-jdbc = { group = "org.jetbrains.exposed", name = "exposed-jdbc", version.ref = "exposed" }