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) {