diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8229fa5..e4f6166 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) } @@ -25,6 +26,8 @@ android { versionName = ProjectSettings.versionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + buildConfigField("String", "KTOR_VERSION", "\"${libs.versions.ktor.get()}\"") } packaging { @@ -118,6 +121,10 @@ kotlin { } } +composeCompiler { + includeComposeMappingFile.set(false) // enterprise build fails without it +} + dependencies { // Support @@ -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) diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/ApiService.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/ApiService.kt index f273e90..9c831b5 100644 --- a/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/ApiService.kt +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/ApiService.kt @@ -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 diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/ContentNegotiationPlugin.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/ContentNegotiationPlugin.kt new file mode 100644 index 0000000..d899d5f --- /dev/null +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/ContentNegotiationPlugin.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/HttpClientPlugin.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/HttpClientPlugin.kt new file mode 100644 index 0000000..182960e --- /dev/null +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/HttpClientPlugin.kt @@ -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<*>) +} diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/HttpTimeoutPlugin.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/HttpTimeoutPlugin.kt new file mode 100644 index 0000000..d846e09 --- /dev/null +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/HttpTimeoutPlugin.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/LoggingPlugin.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/LoggingPlugin.kt new file mode 100644 index 0000000..c48cd12 --- /dev/null +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/LoggingPlugin.kt @@ -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) + } + } +} diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/UserAgentPlugin.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/UserAgentPlugin.kt new file mode 100644 index 0000000..e157f23 --- /dev/null +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/plugins/UserAgentPlugin.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkError.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkError.kt new file mode 100644 index 0000000..2a23081 --- /dev/null +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkError.kt @@ -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) +} diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkErrorParser.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkErrorParser.kt new file mode 100644 index 0000000..821277b --- /dev/null +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkErrorParser.kt @@ -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") + } +} diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkResult.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkResult.kt new file mode 100644 index 0000000..129bda6 --- /dev/null +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkResult.kt @@ -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 { + + /** + * The success result with [data] as the response of the operation. + */ + data class Success(val data: T) : NetworkResult() + + /** + * The failed result with [error] as the failure cause of the operation. + */ + data class Failure(val error: NetworkError) : NetworkResult() + + companion object { + fun 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 NetworkResult.getOrThrow(): T = when (this) { + is NetworkResult.Success -> data + is NetworkResult.Failure -> throw error +} diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkResultConverterFactory.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkResultConverterFactory.kt new file mode 100644 index 0000000..eaac22b --- /dev/null +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/data/remote/result/NetworkResultConverterFactory.kt @@ -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? { + if (typeData.typeInfo.type != NetworkResult::class) return null + + return object : Converter.SuspendResponseConverter { + + override suspend fun convert(result: KtorfitResult): Any { + val wrappedTypeInfo = typeData.typeArgs.first().typeInfo // NetworkResult + + 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 { + 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)) + } + } +} diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/injection/modules/NetworkModule.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/injection/modules/NetworkModule.kt index 79f7df0..7665c48 100644 --- a/app/src/main/kotlin/app/futured/androidprojecttemplate/injection/modules/NetworkModule.kt +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/injection/modules/NetworkModule.kt @@ -1,22 +1,21 @@ package app.futured.androidprojecttemplate.injection.modules -import app.futured.androidprojecttemplate.BuildConfig import app.futured.androidprojecttemplate.data.remote.ApiService +import app.futured.androidprojecttemplate.data.remote.createApiService +import app.futured.androidprojecttemplate.data.remote.plugins.ContentNegotiationPlugin +import app.futured.androidprojecttemplate.data.remote.plugins.HttpTimeoutPlugin +import app.futured.androidprojecttemplate.data.remote.plugins.LoggingPlugin +import app.futured.androidprojecttemplate.data.remote.plugins.UserAgentPlugin +import app.futured.androidprojecttemplate.data.remote.result.NetworkResultConverterFactory +import app.futured.androidprojecttemplate.injection.qualifiers.ApiUrl import app.futured.androidprojecttemplate.tools.Constants.Api.BASE_PROD_URL -import app.futured.androidprojecttemplate.tools.Constants.Api.TIMEOUT_IN_SECONDS import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import kotlinx.serialization.json.Json -import okhttp3.Interceptor -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.kotlinx.serialization.asConverterFactory -import timber.log.Timber -import java.util.concurrent.TimeUnit +import de.jensklingenberg.ktorfit.Ktorfit +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO import javax.inject.Singleton @Module @@ -25,35 +24,36 @@ class NetworkModule { @Provides @Singleton - fun provideLoggingInterceptor(): Interceptor = - HttpLoggingInterceptor { message -> - Timber.tag("OkHttp").d(message) - }.apply { - level = HttpLoggingInterceptor.Level.BODY - } + @ApiUrl + internal fun apiUrl(): String = BASE_PROD_URL @Provides @Singleton - fun provideOkHttpClient( - loggingInterceptor: Interceptor, - ): OkHttpClient = OkHttpClient - .Builder() - .addInterceptor(loggingInterceptor) - .connectTimeout(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) - .readTimeout(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) - .writeTimeout(TIMEOUT_IN_SECONDS, TimeUnit.SECONDS) - .build() + fun provideHttpClient( + contentNegotiationPlugin: ContentNegotiationPlugin, + httpTimeoutPlugin: HttpTimeoutPlugin, + loggingPlugin: LoggingPlugin, + userAgentPlugin: UserAgentPlugin, + ): HttpClient = HttpClient(CIO) { + contentNegotiationPlugin.install(this) + httpTimeoutPlugin.install(this) + loggingPlugin.install(this) + userAgentPlugin.install(this) + } @Provides @Singleton - fun provideRetrofitService( - okHttpClient: OkHttpClient, - json: Json, - ) = Retrofit.Builder() - .baseUrl(BASE_PROD_URL) - .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) - .client(okHttpClient) - .validateEagerly(BuildConfig.DEBUG) + fun provideKtorfit( + @ApiUrl apiUrl: String, + client: HttpClient, + networkResultConverterFactory: NetworkResultConverterFactory, + ): Ktorfit = Ktorfit.Builder() + .baseUrl(apiUrl) + .httpClient(client) + .converterFactories(networkResultConverterFactory) .build() - .create(ApiService::class.java) + + @Provides + @Singleton + fun provideApiService(ktorfit: Ktorfit): ApiService = ktorfit.createApiService() } diff --git a/app/src/main/kotlin/app/futured/androidprojecttemplate/injection/qualifiers/Qualifiers.kt b/app/src/main/kotlin/app/futured/androidprojecttemplate/injection/qualifiers/Qualifiers.kt new file mode 100644 index 0000000..937eb8d --- /dev/null +++ b/app/src/main/kotlin/app/futured/androidprojecttemplate/injection/qualifiers/Qualifiers.kt @@ -0,0 +1,7 @@ +package app.futured.androidprojecttemplate.injection.qualifiers + +import javax.inject.Qualifier + +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class ApiUrl diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index bf7ba73..cec44c3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,8 +1,8 @@ [versions] -agp = "9.1.0" +agp = "9.2.1" gradleVersionsPlugin = "0.53.0" -kotlin = "2.3.0" -ksp = "2.3.4" +kotlin = "2.4.0" +ksp = "2.3.9" androidxComposeBom = "2026.03.00" hilt = "2.59.2" arkitekt = "6.X.X-SNAPSHOT" @@ -12,8 +12,8 @@ testRunner = "1.6.2" junit = "1.2.1" mockk = "1.14.4" serializationJson = "1.10.0" -okhttp = "5.3.2" -retrofit = "3.0.0" +ktor = "3.3.3" +ktorfit = "2.7.2" navigation = "2.9.0" hiltNavigation = "1.3.0" composeLint = "1.4.2" @@ -78,10 +78,12 @@ test-mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } test-junit = { group = "androidx.test.ext", name = "junit-ktx", version.ref = "junit" } # Networking -okHttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } -logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } -retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } -retrofit-converter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } +ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" } +ktor-client-auth = { group = "io.ktor", name = "ktor-client-auth", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktorfit-lib = { group = "de.jensklingenberg.ktorfit", name = "ktorfit-lib", version.ref = "ktorfit" } coil = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } coil-network = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } @@ -109,6 +111,7 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle" } google-services = { id = "com.google.gms.google-services", version.ref = "google-servicesPlugin" } sheethappens = { id = "app.futured.sheethappens", version.ref = "sheethappens" } +ktorfit = { id = "de.jensklingenberg.ktorfit", version.ref = "ktorfit" } # Precompiled script plugins conventions-lint = { id = "conventions-lint" }