Initial commit
Some checks failed
CI / build (push) Has been cancelled

This commit is contained in:
2026-06-11 11:03:01 +02:00
commit d1ff0e30ba
138 changed files with 5658 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Networking lives in this module, so the permission is declared here and merges into :app. -->
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -0,0 +1,15 @@
package com.example.architecture.core.data.di
import com.example.architecture.core.data.network.HttpClientFactory
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import org.koin.dsl.module
/**
* Core data DI: the single shared [HttpClient]. This is the one sanctioned lambda-DSL binding -
* HttpClient is assembled by a factory plus the OkHttp engine (not a plain constructor), so the
* constructor DSL (`singleOf`) cannot express it. Feature data modules append their own bindings.
*/
val coreDataModule = module {
single<HttpClient> { HttpClientFactory.create(OkHttp.create()) }
}

View File

@@ -0,0 +1,128 @@
package com.example.architecture.core.data.network
import com.example.architecture.core.data.BuildConfig
import com.example.architecture.core.domain.DataError
import com.example.architecture.core.domain.Result
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.request.delete
import io.ktor.client.request.get
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.request.url
import io.ktor.client.statement.HttpResponse
import kotlinx.serialization.SerializationException
import timber.log.Timber
import java.net.UnknownHostException
import java.nio.channels.UnresolvedAddressException
import kotlin.coroutines.cancellation.CancellationException
suspend inline fun <reified Response : Any> HttpClient.get(
route: String,
queryParameters: Map<String, Any?> = emptyMap(),
): Result<Response, DataError.Network> {
return safeCall {
get {
url(constructRoute(route))
queryParameters.forEach { (key, value) -> parameter(key, value) }
}
}
}
suspend inline fun <reified Request, reified Response : Any> HttpClient.post(
route: String,
body: Request,
): Result<Response, DataError.Network> {
return safeCall {
post {
url(constructRoute(route))
setBody(body)
}
}
}
suspend inline fun <reified Response : Any> HttpClient.delete(
route: String,
queryParameters: Map<String, Any?> = emptyMap(),
): Result<Response, DataError.Network> {
return safeCall {
delete {
url(constructRoute(route))
queryParameters.forEach { (key, value) -> parameter(key, value) }
}
}
}
/**
* Wraps a Ktor call AND its response deserialization, turning transport/parse exceptions into typed
* [DataError.Network] results. `responseToResult` runs inside the try so a malformed 2xx body maps
* to SERIALIZATION instead of escaping uncaught.
*/
suspend inline fun <reified T> safeCall(
execute: () -> HttpResponse,
): Result<T, DataError.Network> {
return try {
responseToResult(execute())
} catch (e: UnresolvedAddressException) {
logNetworkError(e, "No internet (unresolved address)")
Result.Error(DataError.Network.NO_INTERNET)
} catch (e: UnknownHostException) {
logNetworkError(e, "No internet (unknown host)")
Result.Error(DataError.Network.NO_INTERNET)
} catch (e: SerializationException) {
logNetworkError(e, "Serialization failure")
Result.Error(DataError.Network.SERIALIZATION)
} catch (e: Exception) {
if (e is CancellationException) throw e
// Ktor's ContentNegotiation wraps a kotlinx SerializationException (malformed/garbage body)
// in its own ContentConvertException, so the catch above misses it. Scan the cause chain so a
// bad payload still maps to SERIALIZATION instead of the generic UNKNOWN.
if (generateSequence(e as Throwable) { it.cause }.any { it is SerializationException }) {
logNetworkError(e, "Serialization failure (wrapped)")
Result.Error(DataError.Network.SERIALIZATION)
} else {
logNetworkError(e, "Unknown network failure")
Result.Error(DataError.Network.UNKNOWN)
}
}
}
/**
* Logs a caught network error. `@PublishedApi internal` so the public inline [safeCall] can call it
* across modules WITHOUT leaking Timber: the Timber dependency stays inside `:core:data` because
* this function's body is not inlined into the caller.
*/
@PublishedApi
internal fun logNetworkError(throwable: Throwable, message: String) {
Timber.tag("HttpClient").e(throwable, message)
}
/** Maps HTTP status codes to typed [DataError.Network] (covering 400/403/404 as well). */
suspend inline fun <reified T> responseToResult(
response: HttpResponse,
): Result<T, DataError.Network> {
return when (response.status.value) {
in 200..299 -> Result.Success(response.body<T>())
400 -> Result.Error(DataError.Network.BAD_REQUEST)
401 -> Result.Error(DataError.Network.UNAUTHORIZED)
403 -> Result.Error(DataError.Network.FORBIDDEN)
404 -> Result.Error(DataError.Network.NOT_FOUND)
408 -> Result.Error(DataError.Network.REQUEST_TIMEOUT)
409 -> Result.Error(DataError.Network.CONFLICT)
413 -> Result.Error(DataError.Network.PAYLOAD_TOO_LARGE)
429 -> Result.Error(DataError.Network.TOO_MANY_REQUESTS)
503 -> Result.Error(DataError.Network.SERVICE_UNAVAILABLE)
in 500..599 -> Result.Error(DataError.Network.SERVER_ERROR)
else -> Result.Error(DataError.Network.UNKNOWN)
}
}
/** Prepends [BuildConfig.BASE_URL] unless [route] is already absolute. */
fun constructRoute(route: String): String {
return when {
route.contains(BuildConfig.BASE_URL) -> route
route.startsWith("/") -> BuildConfig.BASE_URL + route
else -> BuildConfig.BASE_URL + "/$route"
}
}

View File

@@ -0,0 +1,44 @@
package com.example.architecture.core.data.network
import io.ktor.client.HttpClient
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import timber.log.Timber
import io.ktor.client.plugins.logging.Logger as KtorLogger
/**
* Builds the app's single [HttpClient]. The [engine] is injected so tests can pass a Ktor
* `MockEngine` while production passes OkHttp (see `coreDataModule`). Ktor logging is bridged to
* Timber so all logs flow through one tree (planted in the Application).
*/
object HttpClientFactory {
fun create(engine: HttpClientEngine): HttpClient {
return HttpClient(engine) {
install(ContentNegotiation) {
json(
Json {
ignoreUnknownKeys = true
},
)
}
install(Logging) {
logger = object : KtorLogger {
override fun log(message: String) {
Timber.tag("HttpClient").d(message)
}
}
level = LogLevel.ALL
}
defaultRequest {
contentType(ContentType.Application.Json)
}
}
}
}