From 8e97bac880bb7f771bc65303b5940af5e9a0dd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane?= Date: Fri, 8 May 2026 20:14:42 +0200 Subject: [PATCH] feat(network): send Accept-Language header to addons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `Accept-Language` header on every addon HTTP call so Stremio- compatible addons that maintain localized payloads (catalog names, descriptions, …) can serve them in the user's language. Resolution rules (mirrors NuvioTV PR #1766 with stricter region handling): - User-picked in-app language (always region-less, e.g. `fr`): legacy behaviour `fr, en;q=0.7` — accept any French variant the addon serves. - Otherwise device locale tag from `Locale.getDefault().toLanguageTag()` / `NSLocale.preferredLanguages.first`. - Regional locale (`fr-FR`, `pt-BR`, `es-MX`): strict chain `fr-FR, fr;q=0.9, en;q=0.5` — exact region first, then any variant of the same language, then English. Cross-region variants (e.g. `fr-CA`) are not preferred. - English (with or without region): tag itself, no q-suffix. - Final fallback: `en`. Header is added as a default — if the caller already specified `Accept-Language` (case-insensitive), it is preserved untouched. Affects every place in the app that displays a catalog name pulled from an addon manifest: home rows, "see all" header, search → discover catalog selector, addon reorder home catalogs screen, collection editor catalog picker. Adding a request header is fully backward-compatible — addons that ignore it return identical payloads. --- .../features/addons/AcceptLanguage.android.kt | 6 +++ .../features/addons/AddonPlatform.android.kt | 11 +++- .../app/features/addons/AcceptLanguage.kt | 50 +++++++++++++++++++ .../app/features/addons/AcceptLanguage.ios.kt | 9 ++++ .../app/features/addons/AddonPlatform.ios.kt | 18 +++++-- 5 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.android.kt new file mode 100644 index 00000000..641baa40 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.android.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.addons + +import java.util.Locale + +internal actual fun deviceLanguageTag(): String? = + Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index e7fbe541..ca6e6d66 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -71,6 +71,13 @@ private fun Map.withoutAcceptEncoding(): Map = private fun Map.getHeaderIgnoreCase(name: String): String? = entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value +private fun Map.withDefaultAcceptLanguage(): Map = + if (getHeaderIgnoreCase("Accept-Language") != null) { + this + } else { + this + ("Accept-Language" to buildAcceptLanguageHeader()) + } + private data class LimitedReadResult( val bytes: ByteArray, val truncated: Boolean, @@ -134,7 +141,7 @@ private suspend fun executeTextRequest( body: String = "", ): String = withContext(Dispatchers.IO) { val normalizedMethod = method.uppercase() - val sanitizedHeaders = headers.withoutAcceptEncoding() + val sanitizedHeaders = headers.withoutAcceptEncoding().withDefaultAcceptLanguage() val builder = Request.Builder().url(url) sanitizedHeaders.forEach { (key, value) -> builder.header(key, value) @@ -214,7 +221,7 @@ actual suspend fun httpRequestRaw( ): RawHttpResponse = withContext(Dispatchers.IO) { val normalizedMethod = method.uppercase() - val sanitizedHeaders = headers.withoutAcceptEncoding() + val sanitizedHeaders = headers.withoutAcceptEncoding().withDefaultAcceptLanguage() val builder = Request.Builder().url(url) sanitizedHeaders.forEach { (key, value) -> builder.header(key, value) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.kt new file mode 100644 index 00000000..b6f4b6c2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.kt @@ -0,0 +1,50 @@ +package com.nuvio.app.features.addons + +import com.nuvio.app.features.settings.ThemeSettingsStorage + +/** + * Returns the device's primary BCP-47 language tag (e.g. "fr-FR", "en", "es-MX"), + * or `null` if the platform can't determine one. + */ +internal expect fun deviceLanguageTag(): String? + +/** + * Builds the value of the HTTP `Accept-Language` header that the addon HTTP + * client should send. + * + * Resolution rules: + * - If the user has explicitly picked an in-app language, that code wins + * (the in-app language is always region-less, e.g. `fr`). + * - Otherwise the device locale tag is used. + * + * Header format: + * - Regional locale (`fr-FR`, `pt-BR`, `es-MX`): strict chain + * `fr-FR, fr;q=0.9, en;q=0.5` — accept the exact region first, then any + * variant of the same language, then English. Cross-region variants + * (e.g. `fr-CA`) are not preferred. + * - Language only (`fr`): legacy behaviour `fr, en;q=0.7` — any French variant + * the addon serves is acceptable. + * - English (with or without region): just the tag itself, no q-suffix. + */ +internal fun buildAcceptLanguageHeader(): String { + val rawTag = ThemeSettingsStorage.loadSelectedAppLanguage()?.takeIf { it.isNotBlank() } + ?: deviceLanguageTag()?.takeIf { + it.isNotBlank() && !it.equals("und", ignoreCase = true) + } + ?: return "en" + + val normalized = rawTag.replace('_', '-').trim() + val parts = normalized.split('-', limit = 2) + val language = parts[0].lowercase().takeIf { it.isNotBlank() } ?: return "en" + val region = parts.getOrNull(1)?.takeIf { it.isNotBlank() }?.uppercase() + + if (language == "en") { + return if (region != null) "en-$region, en" else "en" + } + + return if (region != null) { + "$language-$region, $language;q=0.9, en;q=0.5" + } else { + "$language, en;q=0.7" + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.ios.kt new file mode 100644 index 00000000..144cac09 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AcceptLanguage.ios.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.features.addons + +import platform.Foundation.NSLocale +import platform.Foundation.preferredLanguages + +internal actual fun deviceLanguageTag(): String? { + val preferred = NSLocale.preferredLanguages.firstOrNull() as? String + return preferred?.takeIf { it.isNotBlank() } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt index 84943920..78c316d7 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt @@ -46,10 +46,21 @@ private val addonHttpClient = HttpClient(Darwin) { expectSuccess = false } +private fun Map.hasHeaderIgnoreCase(name: String): Boolean = + keys.any { it.equals(name, ignoreCase = true) } + +private fun Map.withDefaultAcceptLanguage(): Map = + if (hasHeaderIgnoreCase("Accept-Language")) { + this + } else { + this + ("Accept-Language" to buildAcceptLanguageHeader()) + } + actual suspend fun httpGetText(url: String): String = addonHttpClient .get(url) { accept(ContentType.Application.Json) + header(HttpHeaders.AcceptLanguage, buildAcceptLanguageHeader()) } .let { response -> val payload = response.bodyAsText() @@ -67,6 +78,7 @@ actual suspend fun httpPostJson(url: String, body: String): String = .post(url) { accept(ContentType.Application.Json) header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + header(HttpHeaders.AcceptLanguage, buildAcceptLanguageHeader()) setBody(body) } .let { response -> @@ -87,7 +99,7 @@ actual suspend fun httpGetTextWithHeaders( addonHttpClient .get(url) { accept(ContentType.Application.Json) - headers.forEach { (key, value) -> + headers.withDefaultAcceptLanguage().forEach { (key, value) -> header(key, value) } } @@ -111,7 +123,7 @@ actual suspend fun httpPostJsonWithHeaders( .post(url) { accept(ContentType.Application.Json) header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - headers.forEach { (key, value) -> + headers.withDefaultAcceptLanguage().forEach { (key, value) -> header(key, value) } setBody(body) @@ -138,7 +150,7 @@ actual suspend fun httpRequestRaw( .request { url(url) this.method = HttpMethod.parse(method.uppercase()) - headers.forEach { (key, value) -> + headers.withDefaultAcceptLanguage().forEach { (key, value) -> header(key, value) } if (this.method == HttpMethod.Post || this.method == HttpMethod.Put || this.method == HttpMethod.Patch) {