Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ plugins {
alias(libs.plugins.firebase.distribution)
// TODO enable after providing google-services.json
// alias(libs.plugins.google.services)
alias(libs.plugins.ktorfit)

id(libs.plugins.conventions.lint.get().pluginId)
}
Expand All @@ -25,6 +26,8 @@ android {
versionName = ProjectSettings.versionName

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

buildConfigField("String", "KTOR_VERSION", "\"${libs.versions.ktor.get()}\"")
}

packaging {
Expand Down Expand Up @@ -118,6 +121,10 @@ kotlin {
}
}

composeCompiler {
includeComposeMappingFile.set(false) // enterprise build fails without it
}

dependencies {

// Support
Expand Down Expand Up @@ -158,10 +165,13 @@ dependencies {
implementation(libs.navigation.hilt)

// Networking
implementation(libs.okHttp)
implementation(libs.logging)
implementation(libs.retrofit)
implementation(libs.retrofit.converter)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.auth)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.ktorfit.lib)
ksp(libs.ktorfit.lib)
implementation(libs.coil)
implementation(libs.coil.network)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package app.futured.androidprojecttemplate.data.remote

import de.jensklingenberg.ktorfit.http.GET
import java.time.ZonedDateTime
import kotlinx.serialization.Contextual
import kotlinx.serialization.Serializable
import retrofit2.http.GET

interface ApiService {

@GET("/api/user/2")
@GET("api/user/2")
suspend fun user(): SampleApiModel

@Serializable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package app.futured.androidprojecttemplate.data.remote.plugins

import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.DefaultRequest
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.header
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class ContentNegotiationPlugin @Inject constructor(private val json: Json) : HttpClientPlugin {

override fun install(config: HttpClientConfig<*>) {
config.install(ContentNegotiation) {
json(json)
}

config.install(DefaultRequest) {
header(HttpHeaders.ContentType, ContentType.Application.Json)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package app.futured.androidprojecttemplate.data.remote.plugins

import io.ktor.client.HttpClientConfig

/**
* This interface unifies Ktor plugin installation logic. All HTTP client plugins should implement this interface.
*/
internal interface HttpClientPlugin {

/**
* Installs plugin into Ktor HTTP client's [config].
*/
fun install(config: HttpClientConfig<*>)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package app.futured.androidprojecttemplate.data.remote.plugins

import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.HttpTimeout
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds

@Singleton
class HttpTimeoutPlugin @Inject constructor() : HttpClientPlugin {

companion object {
private val CONNECT_TIMEOUT = 10.seconds
private val REQUEST_TIMEOUT = 15.seconds
private val SOCKET_TIMEOUT = 10.seconds
}

override fun install(config: HttpClientConfig<*>) {
config.install(HttpTimeout) {
connectTimeoutMillis = CONNECT_TIMEOUT.inWholeMilliseconds
requestTimeoutMillis = REQUEST_TIMEOUT.inWholeMilliseconds
socketTimeoutMillis = SOCKET_TIMEOUT.inWholeMilliseconds
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package app.futured.androidprojecttemplate.data.remote.plugins

import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logger
import io.ktor.client.plugins.logging.Logging
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class LoggingPlugin @Inject constructor() : HttpClientPlugin {

companion object {
private val LOG_LEVEL = LogLevel.ALL
}

override fun install(config: HttpClientConfig<*>) {
config.install(Logging) {
logger = TimberLogger()
level = LOG_LEVEL
}
}

private class TimberLogger : Logger {
override fun log(message: String) {
Timber.tag("Ktor").d(message)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package app.futured.androidprojecttemplate.data.remote.plugins

import android.content.Context
import android.os.Build
import app.futured.androidprojecttemplate.BuildConfig
import app.futured.androidprojecttemplate.R
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClientConfig
import io.ktor.client.plugins.UserAgent
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class UserAgentPlugin @Inject constructor(@param:ApplicationContext private val context: Context) : HttpClientPlugin {

private val userAgentString = listOf(
"${context.getString(R.string.app_name)}/${BuildConfig.VERSION_NAME}",
"(${BuildConfig.APPLICATION_ID}; build:${BuildConfig.VERSION_CODE}; Android ${Build.VERSION.RELEASE}; Model:${Build.MANUFACTURER} ${Build.MODEL})",
"ktor-client/${BuildConfig.KTOR_VERSION}",
).joinToString(separator = " ")

override fun install(config: HttpClientConfig<*>) {
config.install(UserAgent) {
agent = userAgentString
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package app.futured.androidprojecttemplate.data.remote.result

import io.ktor.http.HttpStatusCode

/**
* Network error wrapper that encapsulate all the errors that occurred during execution of the network module operations.
*/
sealed class NetworkError(message: String?, cause: Throwable?) : RuntimeException(message, cause) {

/**
* Represents an error that occurred on the cloud but the HTTP response code was not successful.
*/
class HttpError(val statusCode: Int, message: String?) : NetworkError(message = message, cause = null) {

internal constructor(statusCode: HttpStatusCode) : this(statusCode = statusCode.value, message = statusCode.description)
}

class SerializationError(cause: Throwable?) : NetworkError(message = cause?.message, cause = cause)

/**
* Represents network error occurred during the network communication.
* For example socket closed, DNS issue, TLS problem etc.
*/
class ConnectionError(cause: Throwable?) : NetworkError(message = cause?.message, cause = cause)

/**
* Error class that should be used only for unknown network module errors.
* Ideally, this error should be never thrown and each error type should use its own [NetworkError] subclass.
*/
class UnknownError(cause: Throwable?) : NetworkError(message = cause?.message, cause)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package app.futured.androidprojecttemplate.data.remote.result

import io.ktor.http.HttpStatusCode
import io.ktor.http.isSuccess
import io.ktor.util.network.UnresolvedAddressException
import kotlinx.io.IOException
import kotlinx.serialization.SerializationException
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.coroutines.cancellation.CancellationException

/**
* Class responsible for converting [Throwable]s into meaningful [NetworkError]s.
*/
@Singleton
class NetworkErrorParser @Inject constructor() {

/**
* Parses provided [throwable] into [NetworkError].
*/
fun parse(throwable: Throwable): NetworkError = when (throwable) {
is CancellationException -> throw throwable // CancellationExceptions are standard way of cancelling coroutine, should be rethrown
is SerializationException -> NetworkError.SerializationError(throwable)
is IOException, is UnresolvedAddressException -> NetworkError.ConnectionError(throwable)
else -> NetworkError.UnknownError(throwable)
}

/**
* Parses provided [code] as [NetworkError.HttpError]
*/
fun parse(code: HttpStatusCode): NetworkError = if (!code.isSuccess()) {
NetworkError.HttpError(statusCode = code)
} else {
error("The provided code: $code is successful and cannot be parsed as error")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package app.futured.androidprojecttemplate.data.remote.result

/**
* Wrapper class with either [Success] or [Failure] state.
* The class is used as a result of network operations.
*/
sealed class NetworkResult<out T> {

/**
* The success result with [data] as the response of the operation.
*/
data class Success<T>(val data: T) : NetworkResult<T>()

/**
* The failed result with [error] as the failure cause of the operation.
*/
data class Failure(val error: NetworkError) : NetworkResult<Nothing>()

companion object {
fun <T> success(data: T) = Success(data)
fun error(error: NetworkError) = Failure(error)
}
}

/**
* Returns the encapsulated value if this instance represents [NetworkResult.Success] or
* throws the encapsulated [Throwable] exception if it is [NetworkResult.Failure].
*/
inline fun <reified T> NetworkResult<T>.getOrThrow(): T = when (this) {
is NetworkResult.Success -> data
is NetworkResult.Failure -> throw error
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package app.futured.androidprojecttemplate.data.remote.result

import de.jensklingenberg.ktorfit.Ktorfit
import de.jensklingenberg.ktorfit.converter.Converter
import de.jensklingenberg.ktorfit.converter.KtorfitResult
import de.jensklingenberg.ktorfit.converter.TypeData
import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse
import io.ktor.http.isSuccess
import io.ktor.util.reflect.TypeInfo
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.reflect.cast

@Singleton
class NetworkResultConverterFactory @Inject constructor(val errorParser: NetworkErrorParser) : Converter.Factory {

override fun suspendResponseConverter(
typeData: TypeData,
ktorfit: Ktorfit,
): Converter.SuspendResponseConverter<HttpResponse, *>? {
if (typeData.typeInfo.type != NetworkResult::class) return null

return object : Converter.SuspendResponseConverter<HttpResponse, Any> {

override suspend fun convert(result: KtorfitResult): Any {
val wrappedTypeInfo = typeData.typeArgs.first().typeInfo // NetworkResult<wrappedTypeInfo>

return when (result) {
is KtorfitResult.Success -> result.response.toNetworkResult(expectedType = wrappedTypeInfo)
is KtorfitResult.Failure -> NetworkResult.error(errorParser.parse(result.throwable))
}
}
}
}

private suspend inline fun HttpResponse.toNetworkResult(expectedType: TypeInfo): NetworkResult<Any> {
if (!status.isSuccess()) {
return NetworkResult.error(errorParser.parse(status))
}

return runCatching {
NetworkResult.success(expectedType.type.cast(body(expectedType)))
}.getOrElse { throwable ->
NetworkResult.error(errorParser.parse(throwable))
}
}
}
Loading
Loading