Use Ktor/Ktorfit for API calls (#3122)

pull/3121/head
Phil Oliver 2025-09-16 14:45:59 -04:00 zatwierdzone przez GitHub
rodzic d600d182b5
commit bec5dac9d4
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
13 zmienionych plików z 173 dodań i 217 usunięć

Wyświetl plik

@ -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()

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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" }

Wyświetl plik

@ -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)

Wyświetl plik

@ -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.")
}
}

Wyświetl plik

@ -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))
}
}

Wyświetl plik

@ -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()
}
}

Wyświetl plik

@ -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()
}
}

Wyświetl plik

@ -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() }
}

Wyświetl plik

@ -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() }
}

Wyświetl plik

@ -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()
}
}

Wyświetl plik

@ -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
}