diff --git a/AGENTS.md b/AGENTS.md index e61c7d52..6fdadf2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,13 @@ ## Dependencies - `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations. -- Kotlin: `2.3.0`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). +- Kotlin: `2.4.21`, JVM target: `11` (compile/target compatibility in both `composeApp/build.gradle.kts` and `plugin_interfaces/build.gradle.kts`). - **compose-webview (desktop) dispatcher hijack:** `dev.nucleusframework:composewebview` transitively pulls `nucleus.decorated-window-tao`, which registers a `TaoMainDispatcherFactory` (`loadPriority = 100`) via `META-INF/services` that hijacks `Dispatchers.Main` away from the Swing EDT. This breaks lifecycle's `enforceMainThreadIfNeeded` (window fails with "Method addObserver must be called on the main thread"). The app overrides it with `SwingMainDispatcherFactory` (`composeApp/src/jvmMain/.../core/coroutines/`, `loadPriority = Int.MAX_VALUE`) which pins `Dispatchers.Main` back to the Swing EDT. Keep that factory + its `META-INF/services/kotlinx.coroutines.internal.MainDispatcherFactory` resource; the long-term fix is in the webview (make the Tao dispatcher opt-in or lower its priority). +## Networking +- Never call `HttpClient(...)` directly. Inject `HttpClientFactory` (`commonMain/.../core/network/HttpClientFactory.kt`, a Koin single) and use `.default` / `.json` (shared, never close them) or `.create { ... }` for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop). +- Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails. + ## GraalVM native image (desktop) - Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`. - Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM. diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b717523d..70c93430 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -199,7 +199,6 @@ kotlin { implementation(libs.ktor.client.logging) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.serialization.kotlinx.json) - implementation(libs.ktor.client.cio) implementation(libs.ktor.client.websockets) implementation(libs.ktor.server.core) implementation(libs.ktor.server.cio) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt index 05ec4253..d7668946 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt @@ -17,6 +17,8 @@ package org.spotube.spotube.core.di +import org.koin.core.module.dsl.onClose +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository @@ -108,6 +110,7 @@ expect val platformModules: Module val sharedModules = module { includes(navigationModule) singleOf(::Database) + single { HttpClientFactory() } withOptions { onClose { it?.close() } } singleOf(::WebViewController) @@ -164,7 +167,7 @@ val sharedModules = module { viewModelOf(::WelcomeViewModel) // Updates - single { AppUpdateService(get()) } withOptions { createdAtStart() } + single { AppUpdateService(get(), get()) } withOptions { createdAtStart() } single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } // Downloads @@ -273,7 +276,7 @@ val sharedModules = module { createdAtStart() } single { RemoteControlHandler(get(), get(), get(), get()) } - single { RemoteControlClient() } + single { RemoteControlClient(get()) } singleOf(::DeviceDiscoveryService) single { RemoteControlService(get(), get(), get()) } withOptions { createdAtStart() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt new file mode 100644 index 00000000..1157bec2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/network/HttpClientFactory.kt @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero 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 Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.network + +import io.ktor.client.HttpClient +import io.ktor.client.HttpClientConfig +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +/** + * The single place the app builds Ktor [HttpClient]s. + * + * It owns one client on the platform's default engine (Darwin on iOS, OkHttp on + * Android/desktop), so every client shares that engine's connection pool and + * no code path can accidentally pick an engine that doesn't work on a platform. + * + * - [default] and [json] are shared: use them directly and never close them. + * - [create] derives a client with extra configuration (timeouts, WebSockets, + * default request, …). It reuses the shared engine; the caller owns the + * returned client and may close it without affecting anyone else. + */ +class HttpClientFactory : AutoCloseable { + private val root = HttpClient() + + /** Plain client with no plugins installed. */ + val default: HttpClient get() = root + + /** Client that (de)serializes JSON bodies via [HttpJson]. */ + val json: HttpClient by lazy { + create { install(ContentNegotiation) { json(HttpJson) } } + } + + fun create(block: HttpClientConfig<*>.() -> Unit): HttpClient = root.config(block) + + override fun close() { + root.close() + } +} + +/** Lenient JSON used for HTTP bodies: remote APIs add fields over time. */ +val HttpJson = Json { + ignoreUnknownKeys = true + isLenient = true +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt index ed7dce2e..f6f474c3 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt @@ -17,8 +17,8 @@ package org.spotube.spotube.core.remote +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger -import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.header import io.ktor.client.request.url @@ -49,7 +49,7 @@ import spotube.composeapp.generated.resources.devices_error_send_failed * WebSocket client for controlling a remote Spotube instance. * Connects to the remote device's `/control` endpoint and sends commands. */ -class RemoteControlClient { +class RemoteControlClient(httpClientFactory: HttpClientFactory) { private val logger = Logger.withTag("RemoteControlClient") private val json = Json { ignoreUnknownKeys = true @@ -59,7 +59,7 @@ class RemoteControlClient { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val httpClient = HttpClient { + private val httpClient = httpClientFactory.create { install(WebSockets) install(HttpTimeout) { connectTimeoutMillis = 10_000 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt index d0a700e9..14e4837c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/server/LocalServer.kt @@ -17,16 +17,15 @@ package org.spotube.spotube.core.server +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.remote.RemoteControlHandler import org.spotube.spotube.modules.settings.SettingsViewModel -import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.http.HttpMethod import io.ktor.server.application.Application import io.ktor.server.application.install -import io.ktor.server.cio.CIO import io.ktor.server.engine.EmbeddedServer import io.ktor.server.engine.embeddedServer import io.ktor.server.response.respondText @@ -60,11 +59,12 @@ class LocalServer( private val streamingUrlRepository: StreamingUrlRepository, private val audioPlayerQueue: AudioPlayerQueue, private val cacheManager: CacheManager, - private val remoteControlHandler: RemoteControlHandler) : KoinComponent { + private val remoteControlHandler: RemoteControlHandler, + httpClientFactory: HttpClientFactory) : KoinComponent { val logger by injectLogger() - private val httpClient = HttpClient { - // A stalled upstream connection must not wedge the CIO dispatcher thread + private val httpClient = httpClientFactory.create { + // A stalled upstream connection must not wedge an engine thread // forever. Only the connect phase is bounded — the proxy streams long // audio bodies, so request/socket timeouts would cut playback short. install(HttpTimeout) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt index 28a0674b..de319170 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/tools/user_agents/UserAgents.kt @@ -17,12 +17,13 @@ package org.spotube.spotube.core.tools.user_agents +import org.koin.core.component.inject +import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.spotube.PlatformType import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.getPlatform -import io.ktor.client.HttpClient -import io.ktor.client.engine.cio.CIO import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText import io.ktor.http.isSuccess @@ -53,7 +54,8 @@ import kotlin.time.Clock * [random] is intentionally synchronous and never blocks on the network: it serves * whatever is cached (even stale) and refreshes in the background when needed. */ -object UserAgents { +object UserAgents : KoinComponent { + private val httpClientFactory: HttpClientFactory by inject() private const val SOURCE_URL = "https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json" private const val CACHE_FILE_NAME = "user_agents_cache.json" @@ -111,14 +113,9 @@ object UserAgents { private fun isValidAgent(ua: String) = ua.startsWith("Mozilla/") private suspend fun fetchRemote(): List { - val client = HttpClient(CIO) - try { - val res = client.get(SOURCE_URL) - if (!res.status.isSuccess()) error("Unexpected status ${res.status}") - return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText()) - } finally { - client.close() - } + val res = httpClientFactory.default.get(SOURCE_URL) + if (!res.status.isSuccess()) error("Unexpected status ${res.status}") + return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText()) } private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt index a396aced..553966b1 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/host_apis/RealHttpClientAPI.kt @@ -17,11 +17,12 @@ package org.spotube.spotube.core.zipline.host_apis +import org.koin.core.component.inject +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.plugin_interfaces.host_apis.HttpClientAPI import org.spotube.plugin_interfaces.host_apis.HttpMethod import org.spotube.plugin_interfaces.host_apis.HttpResponse import org.spotube.spotube.core.di.injectLogger -import io.ktor.client.HttpClient import io.ktor.client.request.request import io.ktor.client.request.setBody import io.ktor.client.statement.bodyAsText @@ -30,7 +31,8 @@ import org.koin.core.component.KoinComponent class RealHttpClientAPI : HttpClientAPI, KoinComponent { val logger by injectLogger() - val httpClient = HttpClient() + private val httpClientFactory: HttpClientFactory by inject() + private val httpClient get() = httpClientFactory.default override suspend fun request( method: HttpMethod, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt index b46bc9e0..59a931b8 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/lrclib/RealLRCLibLyricsAPI.kt @@ -17,11 +17,14 @@ package org.spotube.spotube.core.zipline.plugin_apis.lrclib +import org.koin.core.component.inject +import org.koin.core.component.KoinComponent +import org.spotube.spotube.core.network.HttpJson +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsAPI import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsResponse import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack -import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest @@ -36,7 +39,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json @Serializable data class LRCLibResponse( @@ -47,19 +49,22 @@ data class LRCLibResponse( val plainLyrics: String?, val syncedLyrics: String?) -class RealLRCLibLyricsAPI : LyricsAPI { - private val httpClient = HttpClient { - install(Logging) { - level = LogLevel.ALL - logger = Logger.SIMPLE - } - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - defaultRequest { - url("https://lrclib.net/api/get") - // TODO: Add the actual version of Spotube instead of hardcoding it - userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") +class RealLRCLibLyricsAPI : LyricsAPI, KoinComponent { + private val httpClientFactory: HttpClientFactory by inject() + private val httpClient by lazy { + httpClientFactory.create { + install(Logging) { + level = LogLevel.ALL + logger = Logger.SIMPLE + } + install(ContentNegotiation) { + json(HttpJson) + } + defaultRequest { + url("https://lrclib.net/api/get") + // TODO: Add the actual version of Spotube instead of hardcoding it + userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") + } } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt index a1c80553..52b2eaa7 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt @@ -17,15 +17,15 @@ package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt +import org.koin.core.component.inject +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.tools.user_agents.UserAgents -import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsText import org.spotube.spotube.core.di.injectLogger import org.koin.core.component.KoinComponent -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post @@ -54,15 +54,8 @@ class ISRCProviders : KoinComponent { .lowercase() } - val client = HttpClient() { - install(ContentNegotiation) { - json(Json { - prettyPrint = true - isLenient = true - ignoreUnknownKeys = true - }) - } - } + private val httpClientFactory: HttpClientFactory by inject() + private val client get() = httpClientFactory.json private fun MetadataTrack.describe() = "id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " + diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt index 9b1824a0..2cbd6775 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/downloads/DownloadManager.kt @@ -17,6 +17,7 @@ package org.spotube.spotube.modules.downloads +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.spotube.core.paths.Paths @@ -26,7 +27,6 @@ import org.spotube.spotube.core.server.StreamingUrlRepository import org.spotube.spotube.core.server.urlSummary import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.spotube.modules.settings.SettingsRepository -import io.ktor.client.HttpClient import io.ktor.client.plugins.HttpTimeout import io.ktor.client.request.get import io.ktor.client.request.head @@ -88,7 +88,8 @@ sealed interface DownloadStatus { class DownloadManager( private val settingsRepository: SettingsRepository, private val paths: Paths, - private val streamingUrlRepository: StreamingUrlRepository + private val streamingUrlRepository: StreamingUrlRepository, + private val httpClientFactory: HttpClientFactory ) { companion object { const val MAX_CONCURRENT_DOWNLOADS = 4 @@ -101,7 +102,7 @@ class DownloadManager( private val fileSystem = FileSystem.SYSTEM private val queueMutex = Mutex() - private val httpClient = HttpClient { + private val httpClient = httpClientFactory.create { install(HttpTimeout) { requestTimeoutMillis = 300_000 connectTimeoutMillis = 30_000 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt index 34d1fd92..86a8f30b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/GitHubPluginRepo.kt @@ -17,9 +17,9 @@ package org.spotube.spotube.modules.plugin +import org.spotube.spotube.core.network.HttpClientFactory import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.headers import io.ktor.client.request.parameter @@ -27,7 +27,6 @@ import io.ktor.http.append import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json @Serializable data class GitHubRepoSearchResponse( @@ -64,12 +63,8 @@ data class GitHubAsset( val name: String, @SerialName("browser_download_url") val browserDownloadUrl: String) -class GitHubPluginRepository { - private val httpClient = HttpClient { - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - } +/** [httpClient] must have JSON content negotiation installed (see [HttpClientFactory.json]). */ +class GitHubPluginRepository(private val httpClient: HttpClient) { suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse { return httpClient.get("https://api.github.com/search/repositories") { @@ -113,8 +108,4 @@ class GitHubPluginRepository { emptyList() } } - - fun close() { - httpClient.close() - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 71871b23..6b2ed87a 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -17,6 +17,7 @@ package org.spotube.spotube.modules.plugin +import org.spotube.spotube.core.network.HttpClientFactory import org.spotube.spotube.core.db.Database import org.spotube.spotube.core.db.DatabaseKeys import org.spotube.spotube.core.di.injectLogger @@ -24,7 +25,6 @@ import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.zipline.BuiltInPluginService import org.spotube.spotube.core.zipline.PluginService import org.spotube.spotube.core.zipline.ZiplinePluginService -import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.bodyAsChannel import io.ktor.utils.io.readRemaining @@ -113,7 +113,8 @@ interface ScrobblePluginSource { class PluginManager( val database: Database, - val paths: Paths) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { + val paths: Paths, + private val httpClientFactory: HttpClientFactory) : KoinComponent, PluginProvider, AudioPluginSource, ScrobblePluginSource { private val logger by injectLogger() private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } @@ -121,7 +122,7 @@ class PluginManager( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler) private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath() val pluginsDirPath: Path get() = pluginsDir - private val httpClient = HttpClient() + private val httpClient get() = httpClientFactory.default enum class InstallPromptKind { INSTALL, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt index e3d5ff5c..7d685cfd 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginViewModel.kt @@ -17,6 +17,7 @@ package org.spotube.spotube.modules.plugin +import org.spotube.spotube.core.network.HttpClientFactory import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.spotube.spotube.core.di.injectLogger @@ -141,10 +142,11 @@ private data class PluginScreenState( @OptIn(ExperimentalCoroutinesApi::class) class PluginViewModel( private val pluginManager: PluginManager, - private val webViewController: WebViewController) : ViewModel(), KoinComponent { + private val webViewController: WebViewController, + httpClientFactory: HttpClientFactory) : ViewModel(), KoinComponent { private val logger by injectLogger() - private val gitHubRepo = GitHubPluginRepository() + private val gitHubRepo = GitHubPluginRepository(httpClientFactory.json) private val _screenState = MutableStateFlow(PluginScreenState()) private val _allRepos = mutableListOf() @@ -658,8 +660,4 @@ class PluginViewModel( } } - override fun onCleared() { - gitHubRepo.close() - super.onCleared() - } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt index 234a0338..0bbc8552 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/update/AppUpdateService.kt @@ -17,13 +17,11 @@ package org.spotube.spotube.modules.update +import org.spotube.spotube.core.network.HttpClientFactory import co.touchlab.kermit.Logger import org.spotube.spotube.AppBuildInfo import org.spotube.spotube.modules.settings.SettingsRepository -import io.ktor.client.HttpClient import io.ktor.client.call.body -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.http.HttpHeaders @@ -37,7 +35,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json import net.swiftzer.semver.SemVer import org.koin.core.component.KoinComponent @@ -52,14 +49,11 @@ data class AvailableAppUpdate( * process, independent of any screen's lifecycle. */ class AppUpdateService( - private val settingsRepository: SettingsRepository) : KoinComponent, AutoCloseable { + private val settingsRepository: SettingsRepository, + httpClientFactory: HttpClientFactory) : KoinComponent { private val logger = Logger.withTag("AppUpdateService") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val httpClient = HttpClient(CIO) { - install(ContentNegotiation) { - json(Json { ignoreUnknownKeys = true }) - } - } + private val httpClient = httpClientFactory.json private val _availableUpdate = MutableStateFlow(null) val availableUpdate: StateFlow = _availableUpdate.asStateFlow() @@ -117,10 +111,6 @@ class AppUpdateService( private fun parseVersion(version: String): SemVer? = runCatching { SemVer.parse(version) }.getOrNull() - - override fun close() { - httpClient.close() - } } @Serializable