Use Ktor/Ktorfit for API calls (#3122)

This commit is contained in:
Phil Oliver
2025-09-16 14:45:59 -04:00
committed by GitHub
parent d600d182b5
commit bec5dac9d4
13 changed files with 173 additions and 217 deletions

View File

@@ -25,7 +25,6 @@ import com.geeksville.mesh.model.DeviceHardware
import com.geeksville.mesh.network.DeviceHardwareRemoteDataSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@@ -69,8 +68,7 @@ constructor(
// 2. Fetch from remote API
runCatching {
debug("Fetching device hardware from remote API.")
val remoteHardware =
remoteDataSource.getAllDeviceHardware() ?: throw IOException("Empty response from server")
val remoteHardware = remoteDataSource.getAllDeviceHardware()
localDataSource.insertAllDeviceHardware(remoteHardware)
localDataSource.getByHwModel(hwModel)?.asExternalModel()

View File

@@ -26,7 +26,6 @@ import com.geeksville.mesh.database.entity.asExternalModel
import com.geeksville.mesh.network.FirmwareReleaseRemoteDataSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import java.io.IOException
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@@ -99,8 +98,7 @@ constructor(
val remoteFetchSuccess =
runCatching {
debug("Fetching fresh firmware releases from remote API.")
val networkReleases =
remoteDataSource.getFirmwareReleases() ?: throw IOException("Empty response from server")
val networkReleases = remoteDataSource.getFirmwareReleases()
// The API fetches all release types, so we cache them all at once.
localDataSource.insertFirmwareReleases(networkReleases.releases.stable, FirmwareReleaseType.STABLE)

View File

@@ -32,6 +32,7 @@ plugins {
alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.kotlin.parcelize) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ktorfit) apply false
alias(libs.plugins.protobuf) apply false
alias(libs.plugins.secrets) apply false
alias(libs.plugins.dependency.analysis)

View File

@@ -22,8 +22,8 @@ hilt = "2.57.1"
maps-compose = "6.10.0"
# Networking
okhttp = "5.1.0"
retrofit = "3.0.0"
ktor = "3.3.0"
ktorfit = "2.6.4"
# Other
coil = "3.3.0"
@@ -115,10 +115,11 @@ kotlinx-coroutines-guava = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.9.0" }
# Networking
okhttp3 = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
retrofit2 = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit2-kotlin-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
ktorfit = { module = "de.jensklingenberg.ktorfit:ktorfit-lib", version.ref = "ktorfit" }
okhttp3-logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version = "5.1.0" }
# Testing
espresso-core = { module = "androidx.test.espresso:espresso-core", version = "3.7.0" }
@@ -189,7 +190,7 @@ firebase = ["firebase-analytics", "firebase-crashlytics", "firebase-performance"
maps-compose = ["location-services", "maps-compose", "maps-compose-utils", "maps-compose-widgets"]
# Networking
retrofit = ["retrofit2", "retrofit2-kotlin-serialization", "okhttp3", "okhttp3-logging-interceptor"]
ktor = ["ktor-client-content-negotiation", "ktor-client-okhttp", "ktor-serialization-kotlinx-json", "ktorfit", "okhttp3-logging-interceptor"]
# Other
coil = ["coil", "coil-network-core", "coil-network-okhttp", "coil-svg"]
@@ -224,6 +225,7 @@ kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kover = { id = "org.jetbrains.kotlinx.kover", version = "0.9.1" }
ktorfit = { id = "de.jensklingenberg.ktorfit", version.ref = "ktorfit" }
# Google
devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "devtools-ksp" }

View File

@@ -22,6 +22,7 @@ plugins {
alias(libs.plugins.dokka)
alias(libs.plugins.kover)
alias(libs.plugins.protobuf)
alias(libs.plugins.ktorfit)
}
android {
@@ -30,7 +31,7 @@ android {
}
dependencies {
implementation(libs.bundles.retrofit)
implementation(libs.bundles.ktor)
implementation(libs.bundles.coil)
"googleImplementation"(libs.bundles.datadog)
implementation(libs.kotlinx.serialization.json)

View File

@@ -0,0 +1,58 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.network.di
import com.geeksville.mesh.network.BuildConfig
import com.geeksville.mesh.network.model.NetworkDeviceHardware
import com.geeksville.mesh.network.model.NetworkFirmwareReleases
import com.geeksville.mesh.network.service.ApiService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class FDroidNetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(
interceptor =
HttpLoggingInterceptor().apply {
if (BuildConfig.DEBUG) {
setLevel(HttpLoggingInterceptor.Level.BODY)
}
},
)
.build()
@Provides
@Singleton
fun provideApiService(): ApiService = object : ApiService {
override suspend fun getDeviceHardware(): List<NetworkDeviceHardware> =
throw NotImplementedError("API calls to getDeviceHardware are not supported on Fdroid builds.")
override suspend fun getFirmwareReleases(): NetworkFirmwareReleases =
throw NotImplementedError("API calls to getFirmwareReleases are not supported on Fdroid builds.")
}
}

View File

@@ -1,37 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.network.retrofit
import com.geeksville.mesh.network.model.NetworkDeviceHardware
import com.geeksville.mesh.network.model.NetworkFirmwareReleases
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
private const val ERROR_NO_OP = 420
@Singleton
class NoOpApiService@Inject constructor() : ApiService {
override suspend fun getDeviceHardware(): Response<List<NetworkDeviceHardware>> {
return Response.error(ERROR_NO_OP, "Not Found".toResponseBody(null))
}
override suspend fun getFirmwareReleases(): Response<NetworkFirmwareReleases> {
return Response.error(ERROR_NO_OP, "Not Found".toResponseBody(null))
}
}

View File

@@ -1,118 +0,0 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.network.di
import android.content.Context
import coil3.ImageLoader
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import coil3.util.Logger
import com.datadog.android.okhttp.DatadogEventListener
import com.datadog.android.okhttp.DatadogInterceptor
import com.geeksville.mesh.network.BuildConfig
import com.geeksville.mesh.network.retrofit.ApiService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Singleton
private const val DISK_CACHE_PERCENT = 0.02
private const val MEMORY_CACHE_PERCENT = 0.25
@InstallIn(SingletonComponent::class)
@Module
class ApiModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor().apply {
if (BuildConfig.DEBUG) {
setLevel(HttpLoggingInterceptor.Level.BODY)
}
}
val tracedHosts = listOf("meshtastic.org")
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(DatadogInterceptor.Builder(tracedHosts).build())
.eventListenerFactory(DatadogEventListener.Factory())
.build()
}
@Provides
@Singleton
fun provideRetrofit(
okHttpClient: OkHttpClient
): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.meshtastic.org/") // Replace with your base URL
.addConverterFactory(
Json.asConverterFactory(
"application/json; charset=UTF8".toMediaType()
)
)
.client(okHttpClient)
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
@Provides
@Singleton
fun imageLoader(
httpClient: OkHttpClient,
@ApplicationContext application: Context,
): ImageLoader {
val sharedOkHttp = httpClient.newBuilder().build()
return ImageLoader.Builder(application)
.components {
add(
OkHttpNetworkFetcherFactory({ sharedOkHttp })
)
add(SvgDecoder.Factory())
}
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(application, MEMORY_CACHE_PERCENT)
.build()
}
.diskCache {
DiskCache.Builder()
.maxSizePercent(DISK_CACHE_PERCENT)
.build()
}
.logger(if (BuildConfig.DEBUG) DebugLogger(Logger.Level.Verbose) else null)
.crossfade(true)
.build()
}
}

View File

@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Meshtastic LLC
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.network.di
import com.datadog.android.okhttp.DatadogEventListener
import com.datadog.android.okhttp.DatadogInterceptor
import com.geeksville.mesh.network.BuildConfig
import com.geeksville.mesh.network.service.ApiService
import com.geeksville.mesh.network.service.createApiService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import de.jensklingenberg.ktorfit.Ktorfit
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 okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
@Module
class GoogleNetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(
interceptor =
HttpLoggingInterceptor().apply {
if (BuildConfig.DEBUG) {
setLevel(HttpLoggingInterceptor.Level.BODY)
}
},
)
.addInterceptor(interceptor = DatadogInterceptor.Builder(tracedHosts = listOf("meshtastic.org")).build())
.eventListenerFactory(eventListenerFactory = DatadogEventListener.Factory())
.build()
@Provides
@Singleton
fun provideHttpClient(okHttpClient: OkHttpClient): HttpClient = HttpClient(engineFactory = OkHttp) {
engine { preconfigured = okHttpClient }
install(plugin = ContentNegotiation) {
json(
Json {
isLenient = true
ignoreUnknownKeys = true
},
)
}
}
@Provides
@Singleton
fun provideApiService(httpClient: HttpClient): ApiService {
val ktorfit = Ktorfit.Builder().baseUrl(url = "https://api.meshtastic.org/").httpClient(httpClient).build()
return ktorfit.createApiService()
}
}

View File

@@ -18,15 +18,12 @@
package com.geeksville.mesh.network
import com.geeksville.mesh.network.model.NetworkDeviceHardware
import com.geeksville.mesh.network.retrofit.ApiService
import com.geeksville.mesh.network.service.ApiService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class DeviceHardwareRemoteDataSource @Inject constructor(
private val apiService: ApiService,
) {
suspend fun getAllDeviceHardware(): List<NetworkDeviceHardware>? = withContext(Dispatchers.IO) {
apiService.getDeviceHardware().body()
}
class DeviceHardwareRemoteDataSource @Inject constructor(private val apiService: ApiService) {
suspend fun getAllDeviceHardware(): List<NetworkDeviceHardware> =
withContext(Dispatchers.IO) { apiService.getDeviceHardware() }
}

View File

@@ -18,15 +18,12 @@
package com.geeksville.mesh.network
import com.geeksville.mesh.network.model.NetworkFirmwareReleases
import com.geeksville.mesh.network.retrofit.ApiService
import com.geeksville.mesh.network.service.ApiService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
class FirmwareReleaseRemoteDataSource @Inject constructor(
private val apiService: ApiService,
) {
suspend fun getFirmwareReleases(): NetworkFirmwareReleases? = withContext(Dispatchers.IO) {
apiService.getFirmwareReleases().body()
}
class FirmwareReleaseRemoteDataSource @Inject constructor(private val apiService: ApiService) {
suspend fun getFirmwareReleases(): NetworkFirmwareReleases =
withContext(Dispatchers.IO) { apiService.getFirmwareReleases() }
}

View File

@@ -27,8 +27,6 @@ import coil3.svg.SvgDecoder
import coil3.util.DebugLogger
import coil3.util.Logger
import com.geeksville.mesh.network.BuildConfig
import com.geeksville.mesh.network.retrofit.ApiService
import com.geeksville.mesh.network.retrofit.NoOpApiService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -42,40 +40,23 @@ private const val MEMORY_CACHE_PERCENT = 0.25
@InstallIn(SingletonComponent::class)
@Module
class ApiModule {
class NetworkModule {
@Provides
@Singleton
fun provideApiService(): ApiService {
return NoOpApiService()
}
@Provides
@Singleton
fun imageLoader(
httpClient: OkHttpClient,
@ApplicationContext application: Context,
): ImageLoader {
val sharedOkHttp = httpClient.newBuilder().build()
return ImageLoader.Builder(application)
fun provideImageLoader(okHttpClient: OkHttpClient, @ApplicationContext application: Context): ImageLoader {
val sharedOkHttp = okHttpClient.newBuilder().build()
return ImageLoader.Builder(context = application)
.components {
add(
OkHttpNetworkFetcherFactory({ sharedOkHttp })
)
add(OkHttpNetworkFetcherFactory(callFactory = { sharedOkHttp }))
add(SvgDecoder.Factory())
}
.memoryCache {
MemoryCache.Builder()
.maxSizePercent(application, MEMORY_CACHE_PERCENT)
.build()
MemoryCache.Builder().maxSizePercent(context = application, percent = MEMORY_CACHE_PERCENT).build()
}
.diskCache {
DiskCache.Builder()
.maxSizePercent(DISK_CACHE_PERCENT)
.build()
}
.logger(if (BuildConfig.DEBUG) DebugLogger(Logger.Level.Verbose) else null)
.crossfade(true)
.diskCache { DiskCache.Builder().maxSizePercent(percent = DISK_CACHE_PERCENT).build() }
.logger(logger = if (BuildConfig.DEBUG) DebugLogger(minLevel = Logger.Level.Verbose) else null)
.crossfade(enable = true)
.build()
}
}

View File

@@ -15,17 +15,16 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.geeksville.mesh.network.retrofit
package com.geeksville.mesh.network.service
import com.geeksville.mesh.network.model.NetworkDeviceHardware
import com.geeksville.mesh.network.model.NetworkFirmwareReleases
import retrofit2.Response
import retrofit2.http.GET
import de.jensklingenberg.ktorfit.http.GET
interface ApiService {
@GET("resource/deviceHardware")
suspend fun getDeviceHardware(): Response<List<NetworkDeviceHardware>>
suspend fun getDeviceHardware(): List<NetworkDeviceHardware>
@GET("/github/firmware/list")
suspend fun getFirmwareReleases(): Response<NetworkFirmwareReleases>
@GET("github/firmware/list")
suspend fun getFirmwareReleases(): NetworkFirmwareReleases
}