diff --git a/app/src/main/java/com/geeksville/mesh/repository/api/DeviceHardwareRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/api/DeviceHardwareRepository.kt index 273db1288..9dda196b8 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/api/DeviceHardwareRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/api/DeviceHardwareRepository.kt @@ -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() diff --git a/app/src/main/java/com/geeksville/mesh/repository/api/FirmwareReleaseRepository.kt b/app/src/main/java/com/geeksville/mesh/repository/api/FirmwareReleaseRepository.kt index 5915af6eb..42316026f 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/api/FirmwareReleaseRepository.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/api/FirmwareReleaseRepository.kt @@ -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) diff --git a/build.gradle.kts b/build.gradle.kts index 7eae6be01..1ebd08026 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0060855ef..962f0f396 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/network/build.gradle.kts b/network/build.gradle.kts index 9b99d2a66..011a1d415 100644 --- a/network/build.gradle.kts +++ b/network/build.gradle.kts @@ -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) diff --git a/network/src/fdroid/java/com/geeksville/mesh/network/di/FDroidNetworkModule.kt b/network/src/fdroid/java/com/geeksville/mesh/network/di/FDroidNetworkModule.kt new file mode 100644 index 000000000..e422dd6cb --- /dev/null +++ b/network/src/fdroid/java/com/geeksville/mesh/network/di/FDroidNetworkModule.kt @@ -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 . + */ + +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 = + 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.") + } +} diff --git a/network/src/fdroid/java/com/geeksville/mesh/network/retrofit/NoOpApiService.kt b/network/src/fdroid/java/com/geeksville/mesh/network/retrofit/NoOpApiService.kt deleted file mode 100644 index 0e597524c..000000000 --- a/network/src/fdroid/java/com/geeksville/mesh/network/retrofit/NoOpApiService.kt +++ /dev/null @@ -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 . - */ - -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> { - return Response.error(ERROR_NO_OP, "Not Found".toResponseBody(null)) - } - - override suspend fun getFirmwareReleases(): Response { - return Response.error(ERROR_NO_OP, "Not Found".toResponseBody(null)) - } -} diff --git a/network/src/google/java/com/geeksville/mesh/network/di/ApiModule.kt b/network/src/google/java/com/geeksville/mesh/network/di/ApiModule.kt deleted file mode 100644 index 7661d355a..000000000 --- a/network/src/google/java/com/geeksville/mesh/network/di/ApiModule.kt +++ /dev/null @@ -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 . - */ - -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() - } -} diff --git a/network/src/google/java/com/geeksville/mesh/network/di/GoogleNetworkModule.kt b/network/src/google/java/com/geeksville/mesh/network/di/GoogleNetworkModule.kt new file mode 100644 index 000000000..5110be3dc --- /dev/null +++ b/network/src/google/java/com/geeksville/mesh/network/di/GoogleNetworkModule.kt @@ -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 . + */ + +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() + } +} diff --git a/network/src/main/java/com/geeksville/mesh/network/DeviceHardwareRemoteDataSource.kt b/network/src/main/java/com/geeksville/mesh/network/DeviceHardwareRemoteDataSource.kt index 932abe038..b233b258a 100644 --- a/network/src/main/java/com/geeksville/mesh/network/DeviceHardwareRemoteDataSource.kt +++ b/network/src/main/java/com/geeksville/mesh/network/DeviceHardwareRemoteDataSource.kt @@ -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? = withContext(Dispatchers.IO) { - apiService.getDeviceHardware().body() - } +class DeviceHardwareRemoteDataSource @Inject constructor(private val apiService: ApiService) { + suspend fun getAllDeviceHardware(): List = + withContext(Dispatchers.IO) { apiService.getDeviceHardware() } } diff --git a/network/src/main/java/com/geeksville/mesh/network/FirmwareReleaseRemoteDataSource.kt b/network/src/main/java/com/geeksville/mesh/network/FirmwareReleaseRemoteDataSource.kt index 7d3f2ec08..0dda14797 100644 --- a/network/src/main/java/com/geeksville/mesh/network/FirmwareReleaseRemoteDataSource.kt +++ b/network/src/main/java/com/geeksville/mesh/network/FirmwareReleaseRemoteDataSource.kt @@ -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() } } diff --git a/network/src/fdroid/java/com/geeksville/mesh/network/di/ApiModule.kt b/network/src/main/java/com/geeksville/mesh/network/di/NetworkModule.kt similarity index 62% rename from network/src/fdroid/java/com/geeksville/mesh/network/di/ApiModule.kt rename to network/src/main/java/com/geeksville/mesh/network/di/NetworkModule.kt index 34b8b6be2..15453554e 100644 --- a/network/src/fdroid/java/com/geeksville/mesh/network/di/ApiModule.kt +++ b/network/src/main/java/com/geeksville/mesh/network/di/NetworkModule.kt @@ -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() } } diff --git a/network/src/main/java/com/geeksville/mesh/network/retrofit/ApiService.kt b/network/src/main/java/com/geeksville/mesh/network/service/ApiService.kt similarity index 76% rename from network/src/main/java/com/geeksville/mesh/network/retrofit/ApiService.kt rename to network/src/main/java/com/geeksville/mesh/network/service/ApiService.kt index daf513411..f1011e433 100644 --- a/network/src/main/java/com/geeksville/mesh/network/retrofit/ApiService.kt +++ b/network/src/main/java/com/geeksville/mesh/network/service/ApiService.kt @@ -15,17 +15,16 @@ * along with this program. If not, see . */ -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> + suspend fun getDeviceHardware(): List - @GET("/github/firmware/list") - suspend fun getFirmwareReleases(): Response + @GET("github/firmware/list") + suspend fun getFirmwareReleases(): NetworkFirmwareReleases }