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