refactor: modern APIs — Koin 4.2, CMP 1.11, Ktor resilience, Room @Upsert, injected dispatchers (#5119)

This commit is contained in:
James Rich 2026-04-14 06:41:01 -05:00 committed by GitHub
parent 99378c9291
commit 9acdf5309f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 453 additions and 278 deletions

View file

@ -28,7 +28,11 @@ import com.google.android.gms.maps.model.TileProvider
import com.google.android.gms.maps.model.UrlTileProvider
import com.google.maps.android.compose.CameraPositionState
import com.google.maps.android.compose.MapType
import kotlinx.coroutines.Dispatchers
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.isSuccess
import io.ktor.utils.io.jvm.javaio.toInputStream
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -45,6 +49,7 @@ import org.koin.core.annotation.KoinViewModel
import org.meshtastic.app.map.model.CustomTileProviderConfig
import org.meshtastic.app.map.prefs.map.GoogleMapsPrefs
import org.meshtastic.app.map.repository.CustomTileProviderRepository
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.model.RadioController
import org.meshtastic.core.repository.MapPrefs
import org.meshtastic.core.repository.NodeRepository
@ -77,6 +82,8 @@ data class MapCameraPosition(
@KoinViewModel
class MapViewModel(
private val application: Application,
private val dispatchers: CoroutineDispatchers,
private val httpClient: HttpClient,
mapPrefs: MapPrefs,
private val googleMapsPrefs: GoogleMapsPrefs,
nodeRepository: NodeRepository,
@ -404,7 +411,7 @@ class MapViewModel(
}
private fun loadPersistedLayers() {
viewModelScope.launch(Dispatchers.IO) {
viewModelScope.launch(dispatchers.io) {
try {
val layersDir = File(application.filesDir, "map_layers")
if (layersDir.exists() && layersDir.isDirectory) {
@ -412,32 +419,33 @@ class MapViewModel(
if (persistedLayerFiles != null) {
val hiddenLayerUrls = googleMapsPrefs.hiddenLayerUrls.value
val loadedItems = persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
val layerType =
when (file.extension.lowercase()) {
"kml",
"kmz",
-> LayerType.KML
"geojson",
"json",
-> LayerType.GEOJSON
else -> null
}
val loadedItems =
persistedLayerFiles.mapNotNull { file ->
if (file.isFile) {
val layerType =
when (file.extension.lowercase()) {
"kml",
"kmz",
-> LayerType.KML
"geojson",
"json",
-> LayerType.GEOJSON
else -> null
}
layerType?.let {
val uri = Uri.fromFile(file)
MapLayerItem(
name = file.nameWithoutExtension,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = it,
)
layerType?.let {
val uri = Uri.fromFile(file)
MapLayerItem(
name = file.nameWithoutExtension,
uri = uri,
isVisible = !hiddenLayerUrls.contains(uri.toString()),
layerType = it,
)
}
} else {
null
}
} else {
null
}
}
val networkItems =
googleMapsPrefs.networkMapLayers.value.mapNotNull { networkString ->
@ -550,7 +558,7 @@ class MapViewModel(
}
}
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(Dispatchers.IO) {
private suspend fun copyFileToInternalStorage(uri: Uri, fileName: String): Uri? = withContext(dispatchers.io) {
try {
val inputStream = application.contentResolver.openInputStream(uri)
val directory = File(application.filesDir, "map_layers")
@ -621,7 +629,7 @@ class MapViewModel(
}
private suspend fun deleteFileToInternalStorage(uri: Uri) {
withContext(Dispatchers.IO) {
withContext(dispatchers.io) {
try {
val file = uri.toFile()
if (file.exists()) {
@ -636,11 +644,15 @@ class MapViewModel(
@Suppress("Recycle")
suspend fun getInputStreamFromUri(layerItem: MapLayerItem): InputStream? {
val uriToLoad = layerItem.uri ?: return null
return withContext(Dispatchers.IO) {
return withContext(dispatchers.io) {
try {
if (layerItem.isNetwork && (uriToLoad.scheme == "http" || uriToLoad.scheme == "https")) {
val url = java.net.URL(uriToLoad.toString())
java.io.BufferedInputStream(url.openStream())
val response = httpClient.get(uriToLoad.toString())
if (!response.status.isSuccess()) {
Logger.withTag("MapViewModel").e { "HTTP ${response.status} fetching layer: $uriToLoad" }
return@withContext null
}
response.bodyAsChannel().toInputStream()
} else {
application.contentResolver.openInputStream(uriToLoad)
}

View file

@ -23,12 +23,12 @@ import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.koin.core.annotation.ComponentScan
import org.koin.core.annotation.Module
import org.koin.core.annotation.Named
import org.koin.core.annotation.Single
import org.meshtastic.core.di.CoroutineDispatchers
@Module
@ComponentScan("org.meshtastic.app.map")
@ -36,9 +36,10 @@ class GoogleMapsKoinModule {
@Single
@Named("GoogleMapsDataStore")
fun provideGoogleMapsDataStore(context: Context): DataStore<Preferences> = PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
)
fun provideGoogleMapsDataStore(context: Context, dispatchers: CoroutineDispatchers): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(context, "google_maps_prefs")),
scope = CoroutineScope(dispatchers.io + SupervisorJob()),
produceFile = { context.preferencesDataStoreFile("google_maps_ds") },
)
}

View file

@ -48,8 +48,8 @@ import coil3.compose.setSingletonImageLoaderFactory
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get
import org.koin.android.ext.android.inject
import org.koin.androidx.compose.koinViewModel
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import org.meshtastic.app.intro.AnalyticsIntro
import org.meshtastic.app.map.getMapViewProvider

View file

@ -31,6 +31,8 @@ import coil3.util.DebugLogger
import coil3.util.Logger
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.plugins.HttpRequestRetry
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
@ -40,6 +42,7 @@ import okio.Path.Companion.toOkioPath
import org.koin.core.annotation.Module
import org.koin.core.annotation.Single
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.network.HttpClientDefaults
import org.meshtastic.core.network.KermitHttpLogger
private const val DISK_CACHE_PERCENT = 0.02
@ -84,6 +87,15 @@ class NetworkModule {
fun provideHttpClient(json: Json, buildConfigProvider: BuildConfigProvider): HttpClient =
HttpClient(engineFactory = Android) {
install(plugin = ContentNegotiation) { json(json) }
install(plugin = HttpTimeout) {
requestTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
connectTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
socketTimeoutMillis = HttpClientDefaults.TIMEOUT_MS
}
install(plugin = HttpRequestRetry) {
retryOnServerErrors(maxRetries = HttpClientDefaults.MAX_RETRIES)
exponentialDelay()
}
if (buildConfigProvider.isDebug) {
install(plugin = Logging) {
logger = KermitHttpLogger