refactor(network): central HttpClientFactory; drop Ktor CIO client

On iOS the CIO client engine competed with Darwin as the default engine and
has no TLS on Kotlin/Native, so HTTPS requests (e.g. plugin discovery) failed.

Remove ktor-client-cio and build every client through a Koin-provided
HttpClientFactory that owns one client on the platform default engine
(Darwin on iOS, OkHttp on Android/desktop). Consumers use the shared
default/json clients or derive their own via create { }, sharing the engine.
This commit is contained in:
Kingkor Roy Tirtho 2026-10-23 11:54:29 +06:00
parent 09df4b494c
commit ff7a1714ff
15 changed files with 133 additions and 89 deletions

View File

@ -19,9 +19,13 @@
## Dependencies ## Dependencies
- `gradle/libs.versions.toml` is the single source of truth for all version pins and library declarations. - `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). - **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) ## 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`. - 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. - 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.

View File

@ -199,7 +199,6 @@ kotlin {
implementation(libs.ktor.client.logging) implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.serialization.kotlinx.json) implementation(libs.ktor.client.serialization.kotlinx.json)
implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.websockets) implementation(libs.ktor.client.websockets)
implementation(libs.ktor.server.core) implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio) implementation(libs.ktor.server.cio)

View File

@ -17,6 +17,8 @@
package org.spotube.spotube.core.di 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 co.touchlab.kermit.Logger
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository import org.spotube.spotube.core.audioplayer.AudioPlayerQueueRepository
@ -108,6 +110,7 @@ expect val platformModules: Module
val sharedModules = module { val sharedModules = module {
includes(navigationModule) includes(navigationModule)
singleOf(::Database) singleOf(::Database)
single { HttpClientFactory() } withOptions { onClose { it?.close() } }
singleOf(::WebViewController) singleOf(::WebViewController)
@ -164,7 +167,7 @@ val sharedModules = module {
viewModelOf(::WelcomeViewModel) viewModelOf(::WelcomeViewModel)
// Updates // Updates
single { AppUpdateService(get()) } withOptions { createdAtStart() } single { AppUpdateService(get(), get()) } withOptions { createdAtStart() }
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() } single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
// Downloads // Downloads
@ -273,7 +276,7 @@ val sharedModules = module {
createdAtStart() createdAtStart()
} }
single { RemoteControlHandler(get(), get(), get(), get()) } single { RemoteControlHandler(get(), get(), get(), get()) }
single { RemoteControlClient() } single { RemoteControlClient(get()) }
singleOf(::DeviceDiscoveryService) singleOf(::DeviceDiscoveryService)
single { RemoteControlService(get(), get(), get()) } withOptions { single { RemoteControlService(get(), get(), get()) } withOptions {
createdAtStart() createdAtStart()

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}

View File

@ -17,8 +17,8 @@
package org.spotube.spotube.core.remote package org.spotube.spotube.core.remote
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.client.request.url 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. * WebSocket client for controlling a remote Spotube instance.
* Connects to the remote device's `/control` endpoint and sends commands. * 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 logger = Logger.withTag("RemoteControlClient")
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@ -59,7 +59,7 @@ class RemoteControlClient {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val httpClient = HttpClient { private val httpClient = httpClientFactory.create {
install(WebSockets) install(WebSockets)
install(HttpTimeout) { install(HttpTimeout) {
connectTimeoutMillis = 10_000 connectTimeoutMillis = 10_000

View File

@ -17,16 +17,15 @@
package org.spotube.spotube.core.server 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.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.remote.RemoteControlHandler import org.spotube.spotube.core.remote.RemoteControlHandler
import org.spotube.spotube.modules.settings.SettingsViewModel import org.spotube.spotube.modules.settings.SettingsViewModel
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
import io.ktor.http.HttpMethod import io.ktor.http.HttpMethod
import io.ktor.server.application.Application import io.ktor.server.application.Application
import io.ktor.server.application.install 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.engine.embeddedServer import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
@ -60,11 +59,12 @@ class LocalServer(
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager, private val cacheManager: CacheManager,
private val remoteControlHandler: RemoteControlHandler) : KoinComponent { private val remoteControlHandler: RemoteControlHandler,
httpClientFactory: HttpClientFactory) : KoinComponent {
val logger by injectLogger<LocalServer>() val logger by injectLogger<LocalServer>()
private val httpClient = HttpClient { private val httpClient = httpClientFactory.create {
// A stalled upstream connection must not wedge the CIO dispatcher thread // A stalled upstream connection must not wedge an engine thread
// forever. Only the connect phase is bounded — the proxy streams long // forever. Only the connect phase is bounded — the proxy streams long
// audio bodies, so request/socket timeouts would cut playback short. // audio bodies, so request/socket timeouts would cut playback short.
install(HttpTimeout) { install(HttpTimeout) {

View File

@ -17,12 +17,13 @@
package org.spotube.spotube.core.tools.user_agents 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 co.touchlab.kermit.Logger
import org.spotube.spotube.PlatformType import org.spotube.spotube.PlatformType
import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.paths.Paths
import org.spotube.spotube.getPlatform 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.request.get
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.isSuccess 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 * [random] is intentionally synchronous and never blocks on the network: it serves
* whatever is cached (even stale) and refreshes in the background when needed. * 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 = private const val SOURCE_URL =
"https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json" "https://cdn.jsdelivr.net/npm/top-user-agents@latest/src/index.json"
private const val CACHE_FILE_NAME = "user_agents_cache.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 fun isValidAgent(ua: String) = ua.startsWith("Mozilla/")
private suspend fun fetchRemote(): List<String> { private suspend fun fetchRemote(): List<String> {
val client = HttpClient(CIO) val res = httpClientFactory.default.get(SOURCE_URL)
try { if (!res.status.isSuccess()) error("Unexpected status ${res.status}")
val res = client.get(SOURCE_URL) return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText())
if (!res.status.isSuccess()) error("Unexpected status ${res.status}")
return json.decodeFromString(ListSerializer(String.serializer()), res.bodyAsText())
} finally {
client.close()
}
} }
private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath() private fun cachePath() = (Paths().getApplicationCacheDirPath() + "/" + CACHE_FILE_NAME).toPath()

View File

@ -17,11 +17,12 @@
package org.spotube.spotube.core.zipline.host_apis 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.HttpClientAPI
import org.spotube.plugin_interfaces.host_apis.HttpMethod import org.spotube.plugin_interfaces.host_apis.HttpMethod
import org.spotube.plugin_interfaces.host_apis.HttpResponse import org.spotube.plugin_interfaces.host_apis.HttpResponse
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import io.ktor.client.HttpClient
import io.ktor.client.request.request import io.ktor.client.request.request
import io.ktor.client.request.setBody import io.ktor.client.request.setBody
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
@ -30,7 +31,8 @@ import org.koin.core.component.KoinComponent
class RealHttpClientAPI : HttpClientAPI, KoinComponent { class RealHttpClientAPI : HttpClientAPI, KoinComponent {
val logger by injectLogger<RealHttpClientAPI>() val logger by injectLogger<RealHttpClientAPI>()
val httpClient = HttpClient() private val httpClientFactory: HttpClientFactory by inject()
private val httpClient get() = httpClientFactory.default
override suspend fun request( override suspend fun request(
method: HttpMethod, method: HttpMethod,

View File

@ -17,11 +17,14 @@
package org.spotube.spotube.core.zipline.plugin_apis.lrclib 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.LyricsAPI
import org.spotube.plugin_interfaces.plugin_apis.lyrics.LyricsLine 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.lyrics.LyricsResponse
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack 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.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.defaultRequest import io.ktor.client.plugins.defaultRequest
@ -36,7 +39,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable @Serializable
data class LRCLibResponse( data class LRCLibResponse(
@ -47,19 +49,22 @@ data class LRCLibResponse(
val plainLyrics: String?, val plainLyrics: String?,
val syncedLyrics: String?) val syncedLyrics: String?)
class RealLRCLibLyricsAPI : LyricsAPI { class RealLRCLibLyricsAPI : LyricsAPI, KoinComponent {
private val httpClient = HttpClient { private val httpClientFactory: HttpClientFactory by inject()
install(Logging) { private val httpClient by lazy {
level = LogLevel.ALL httpClientFactory.create {
logger = Logger.SIMPLE install(Logging) {
} level = LogLevel.ALL
install(ContentNegotiation) { logger = Logger.SIMPLE
json(Json { ignoreUnknownKeys = true }) }
} install(ContentNegotiation) {
defaultRequest { json(HttpJson)
url("https://lrclib.net/api/get") }
// TODO: Add the actual version of Spotube instead of hardcoding it defaultRequest {
userAgent("Spotube/v6.0.0 LRCLib Lyric Plugin") 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")
}
} }
} }

View File

@ -17,15 +17,15 @@
package org.spotube.spotube.core.zipline.plugin_apis.newpipe_yt 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.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.tools.user_agents.UserAgents import org.spotube.spotube.core.tools.user_agents.UserAgents
import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
import io.ktor.client.request.post import io.ktor.client.request.post
@ -54,15 +54,8 @@ class ISRCProviders : KoinComponent {
.lowercase() .lowercase()
} }
val client = HttpClient() { private val httpClientFactory: HttpClientFactory by inject()
install(ContentNegotiation) { private val client get() = httpClientFactory.json
json(Json {
prettyPrint = true
isLenient = true
ignoreUnknownKeys = true
})
}
}
private fun MetadataTrack.describe() = private fun MetadataTrack.describe() =
"id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " + "id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " +

View File

@ -17,6 +17,7 @@
package org.spotube.spotube.modules.downloads package org.spotube.spotube.modules.downloads
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.paths.Paths 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.spotube.core.server.urlSummary
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.spotube.modules.settings.SettingsRepository import org.spotube.spotube.modules.settings.SettingsRepository
import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.head import io.ktor.client.request.head
@ -88,7 +88,8 @@ sealed interface DownloadStatus {
class DownloadManager( class DownloadManager(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val paths: Paths, private val paths: Paths,
private val streamingUrlRepository: StreamingUrlRepository private val streamingUrlRepository: StreamingUrlRepository,
private val httpClientFactory: HttpClientFactory
) { ) {
companion object { companion object {
const val MAX_CONCURRENT_DOWNLOADS = 4 const val MAX_CONCURRENT_DOWNLOADS = 4
@ -101,7 +102,7 @@ class DownloadManager(
private val fileSystem = FileSystem.SYSTEM private val fileSystem = FileSystem.SYSTEM
private val queueMutex = Mutex() private val queueMutex = Mutex()
private val httpClient = HttpClient { private val httpClient = httpClientFactory.create {
install(HttpTimeout) { install(HttpTimeout) {
requestTimeoutMillis = 300_000 requestTimeoutMillis = 300_000
connectTimeoutMillis = 30_000 connectTimeoutMillis = 30_000

View File

@ -17,9 +17,9 @@
package org.spotube.spotube.modules.plugin package org.spotube.spotube.modules.plugin
import org.spotube.spotube.core.network.HttpClientFactory
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.headers import io.ktor.client.request.headers
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
@ -27,7 +27,6 @@ import io.ktor.http.append
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable @Serializable
data class GitHubRepoSearchResponse( data class GitHubRepoSearchResponse(
@ -64,12 +63,8 @@ data class GitHubAsset(
val name: String, val name: String,
@SerialName("browser_download_url") val browserDownloadUrl: String) @SerialName("browser_download_url") val browserDownloadUrl: String)
class GitHubPluginRepository { /** [httpClient] must have JSON content negotiation installed (see [HttpClientFactory.json]). */
private val httpClient = HttpClient { class GitHubPluginRepository(private val httpClient: HttpClient) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse { suspend fun searchSpotubePlugins(page: Int = 1, perPage: Int = 30): GitHubRepoSearchResponse {
return httpClient.get("https://api.github.com/search/repositories") { return httpClient.get("https://api.github.com/search/repositories") {
@ -113,8 +108,4 @@ class GitHubPluginRepository {
emptyList() emptyList()
} }
} }
fun close() {
httpClient.close()
}
} }

View File

@ -17,6 +17,7 @@
package org.spotube.spotube.modules.plugin 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.Database
import org.spotube.spotube.core.db.DatabaseKeys import org.spotube.spotube.core.db.DatabaseKeys
import org.spotube.spotube.core.di.injectLogger 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.BuiltInPluginService
import org.spotube.spotube.core.zipline.PluginService import org.spotube.spotube.core.zipline.PluginService
import org.spotube.spotube.core.zipline.ZiplinePluginService import org.spotube.spotube.core.zipline.ZiplinePluginService
import io.ktor.client.HttpClient
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsChannel
import io.ktor.utils.io.readRemaining import io.ktor.utils.io.readRemaining
@ -113,7 +113,8 @@ interface ScrobblePluginSource {
class PluginManager( class PluginManager(
val database: Database, 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<PluginManager>() private val logger by injectLogger<PluginManager>()
private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception -> private val pluginExceptionHandler = CoroutineExceptionHandler { _, exception ->
logger.e(exception) { "Plugin runtime threw an unhandled exception. Intercepted safely." } 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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate + pluginExceptionHandler)
private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath() private val pluginsDir = "${paths.getApplicationDataDirPath()}/plugins".toPath()
val pluginsDirPath: Path get() = pluginsDir val pluginsDirPath: Path get() = pluginsDir
private val httpClient = HttpClient() private val httpClient get() = httpClientFactory.default
enum class InstallPromptKind { enum class InstallPromptKind {
INSTALL, INSTALL,

View File

@ -17,6 +17,7 @@
package org.spotube.spotube.modules.plugin package org.spotube.spotube.modules.plugin
import org.spotube.spotube.core.network.HttpClientFactory
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
@ -141,10 +142,11 @@ private data class PluginScreenState(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class PluginViewModel( class PluginViewModel(
private val pluginManager: PluginManager, private val pluginManager: PluginManager,
private val webViewController: WebViewController) : ViewModel(), KoinComponent { private val webViewController: WebViewController,
httpClientFactory: HttpClientFactory) : ViewModel(), KoinComponent {
private val logger by injectLogger<PluginViewModel>() private val logger by injectLogger<PluginViewModel>()
private val gitHubRepo = GitHubPluginRepository() private val gitHubRepo = GitHubPluginRepository(httpClientFactory.json)
private val _screenState = MutableStateFlow(PluginScreenState()) private val _screenState = MutableStateFlow(PluginScreenState())
private val _allRepos = mutableListOf<GitHubRepo>() private val _allRepos = mutableListOf<GitHubRepo>()
@ -658,8 +660,4 @@ class PluginViewModel(
} }
} }
override fun onCleared() {
gitHubRepo.close()
super.onCleared()
}
} }

View File

@ -17,13 +17,11 @@
package org.spotube.spotube.modules.update package org.spotube.spotube.modules.update
import org.spotube.spotube.core.network.HttpClientFactory
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import org.spotube.spotube.AppBuildInfo import org.spotube.spotube.AppBuildInfo
import org.spotube.spotube.modules.settings.SettingsRepository import org.spotube.spotube.modules.settings.SettingsRepository
import io.ktor.client.HttpClient
import io.ktor.client.call.body 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.get
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
@ -37,7 +35,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.swiftzer.semver.SemVer import net.swiftzer.semver.SemVer
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -52,14 +49,11 @@ data class AvailableAppUpdate(
* process, independent of any screen's lifecycle. * process, independent of any screen's lifecycle.
*/ */
class AppUpdateService( class AppUpdateService(
private val settingsRepository: SettingsRepository) : KoinComponent, AutoCloseable { private val settingsRepository: SettingsRepository,
httpClientFactory: HttpClientFactory) : KoinComponent {
private val logger = Logger.withTag("AppUpdateService") private val logger = Logger.withTag("AppUpdateService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val httpClient = HttpClient(CIO) { private val httpClient = httpClientFactory.json
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
private val _availableUpdate = MutableStateFlow<AvailableAppUpdate?>(null) private val _availableUpdate = MutableStateFlow<AvailableAppUpdate?>(null)
val availableUpdate: StateFlow<AvailableAppUpdate?> = _availableUpdate.asStateFlow() val availableUpdate: StateFlow<AvailableAppUpdate?> = _availableUpdate.asStateFlow()
@ -117,10 +111,6 @@ class AppUpdateService(
private fun parseVersion(version: String): SemVer? = runCatching { private fun parseVersion(version: String): SemVer? = runCatching {
SemVer.parse(version) SemVer.parse(version)
}.getOrNull() }.getOrNull()
override fun close() {
httpClient.close()
}
} }
@Serializable @Serializable