diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
index f0c576664768..794d6f2b6d9e 100644
--- a/android/app/build.gradle.kts
+++ b/android/app/build.gradle.kts
@@ -339,6 +339,7 @@ dependencies {
implementation(Dependencies.AndroidX.lifecycleViewmodelKtx)
implementation(Dependencies.AndroidX.lifecycleRuntimeCompose)
implementation(Dependencies.Arrow.core)
+ implementation(Dependencies.Arrow.optics)
implementation(Dependencies.Arrow.resilience)
implementation(Dependencies.Compose.constrainLayout)
implementation(Dependencies.Compose.foundation)
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 92471124665d..2144f1f94542 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -106,5 +106,11 @@
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/broadcastreceiver/LocaleChangedBroadcastReceiver.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/broadcastreceiver/LocaleChangedBroadcastReceiver.kt
new file mode 100644
index 000000000000..59228b36e68c
--- /dev/null
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/broadcastreceiver/LocaleChangedBroadcastReceiver.kt
@@ -0,0 +1,20 @@
+package net.mullvad.mullvadvpn.broadcastreceiver
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import co.touchlab.kermit.Logger
+import net.mullvad.mullvadvpn.lib.shared.LocaleRepository
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+
+class LocaleChangedBroadcastReceiver : BroadcastReceiver(), KoinComponent {
+ private val localeRepository by inject()
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == Intent.ACTION_LOCALE_CHANGED) {
+ Logger.d("AppLang: New App Language")
+ localeRepository.refreshLocale()
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt
index 6af9ff57cb02..7fdaac7d0f79 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt
@@ -32,5 +32,5 @@ val appModule = module {
single { AccountRepository(get(), get(), MainScope()) }
single { DeviceRepository(get()) }
single { VpnPermissionRepository(androidContext()) }
- single { ConnectionProxy(get(), get()) }
+ single { ConnectionProxy(get(), get(), get()) }
}
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
index 6494cbb16758..95530177a0a3 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt
@@ -10,6 +10,8 @@ import net.mullvad.mullvadvpn.applist.ApplicationsProvider
import net.mullvad.mullvadvpn.constant.IS_PLAY_BUILD
import net.mullvad.mullvadvpn.dataproxy.MullvadProblemReport
import net.mullvad.mullvadvpn.lib.payment.PaymentProvider
+import net.mullvad.mullvadvpn.lib.shared.LocaleRepository
+import net.mullvad.mullvadvpn.lib.shared.RelayLocationTranslationRepository
import net.mullvad.mullvadvpn.lib.shared.VoucherRepository
import net.mullvad.mullvadvpn.repository.ApiAccessRepository
import net.mullvad.mullvadvpn.repository.ChangelogRepository
@@ -117,13 +119,15 @@ val uiModule = module {
single { MullvadProblemReport(get()) }
single { RelayOverridesRepository(get()) }
single { CustomListsRepository(get()) }
- single { RelayListRepository(get()) }
+ single { RelayListRepository(get(), get()) }
single { RelayListFilterRepository(get()) }
single { VoucherRepository(get(), get()) }
single { SplitTunnelingRepository(get()) }
single { ApiAccessRepository(get()) }
single { NewDeviceRepository() }
single { SplashCompleteRepository() }
+ single { LocaleRepository(get()) }
+ single { RelayLocationTranslationRepository(get(), get(), MainScope()) }
single { AccountExpiryNotificationUseCase(get()) }
single { TunnelStateNotificationUseCase(get()) }
diff --git a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt
index ce41b57c4ca4..2198863ed1aa 100644
--- a/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt
+++ b/android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt
@@ -1,11 +1,15 @@
package net.mullvad.mullvadvpn.repository
+import arrow.optics.Every
+import arrow.optics.copy
+import arrow.optics.dsl.every
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@@ -17,18 +21,41 @@ import net.mullvad.mullvadvpn.lib.model.RelayItem
import net.mullvad.mullvadvpn.lib.model.RelayItemId
import net.mullvad.mullvadvpn.lib.model.WireguardConstraints
import net.mullvad.mullvadvpn.lib.model.WireguardEndpointData
+import net.mullvad.mullvadvpn.lib.model.cities
+import net.mullvad.mullvadvpn.lib.model.name
+import net.mullvad.mullvadvpn.lib.shared.RelayLocationTranslationRepository
import net.mullvad.mullvadvpn.relaylist.findByGeoLocationId
class RelayListRepository(
private val managementService: ManagementService,
+ private val translationRepository: RelayLocationTranslationRepository,
dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
val relayList: StateFlow> =
- managementService.relayCountries.stateIn(
- CoroutineScope(dispatcher),
- SharingStarted.WhileSubscribed(),
- emptyList()
- )
+ combine(managementService.relayCountries, translationRepository.translations) {
+ countries,
+ translations ->
+ countries.translateRelay(translations)
+ }
+ .stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), emptyList())
+
+ private fun List.translateRelay(
+ translations: Map
+ ): List {
+ if (translations.isEmpty()) {
+ return this
+ }
+
+ return Every.list().modify(this) {
+ it.copy {
+ RelayItem.Location.Country.name set translations.getOrDefault(it.name, it.name)
+ RelayItem.Location.Country.cities.every(Every.list()).name transform
+ { cityName ->
+ translations.getOrDefault(cityName, cityName)
+ }
+ }
+ }
+ }
val wireguardEndpointData: StateFlow =
managementService.wireguardEndpointData.stateIn(
diff --git a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GeoIpLocation.kt b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GeoIpLocation.kt
index 3334b458d7a7..d312382a0209 100644
--- a/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GeoIpLocation.kt
+++ b/android/lib/model/src/main/kotlin/net/mullvad/mullvadvpn/lib/model/GeoIpLocation.kt
@@ -1,7 +1,9 @@
package net.mullvad.mullvadvpn.lib.model
+import arrow.optics.optics
import java.net.InetAddress
+@optics
data class GeoIpLocation(
val ipv4: InetAddress?,
val ipv6: InetAddress?,
@@ -10,4 +12,6 @@ data class GeoIpLocation(
val latitude: Double,
val longitude: Double,
val hostname: String?,
-)
+) {
+ companion object
+}
diff --git a/android/lib/shared/build.gradle.kts b/android/lib/shared/build.gradle.kts
index a1bf4a9c7504..7e3c2ac3acb5 100644
--- a/android/lib/shared/build.gradle.kts
+++ b/android/lib/shared/build.gradle.kts
@@ -27,6 +27,7 @@ android {
}
dependencies {
+ implementation(project(Dependencies.Mullvad.resourceLib))
implementation(project(Dependencies.Mullvad.commonLib))
implementation(project(Dependencies.Mullvad.daemonGrpc))
implementation(project(Dependencies.Mullvad.modelLib))
diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt
index 6ea373e4260c..67258659054d 100644
--- a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt
+++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt
@@ -3,14 +3,38 @@ package net.mullvad.mullvadvpn.lib.shared
import arrow.core.Either
import arrow.core.raise.either
import arrow.core.raise.ensure
+import kotlinx.coroutines.flow.combine
+import mullvad_daemon.management_interface.location
import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
import net.mullvad.mullvadvpn.lib.model.ConnectError
+import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
+import net.mullvad.mullvadvpn.lib.model.TunnelState
+import net.mullvad.mullvadvpn.lib.model.location
class ConnectionProxy(
private val managementService: ManagementService,
+ private val translationRepository: RelayLocationTranslationRepository,
private val vpnPermissionRepository: VpnPermissionRepository
) {
- val tunnelState = managementService.tunnelState
+ val tunnelState =
+ combine(managementService.tunnelState, translationRepository.translations) {
+ tunnelState,
+ translations ->
+ tunnelState.translateLocations(translations)
+ }
+
+ private fun TunnelState.translateLocations(translations: Map): TunnelState {
+ return when (this) {
+ is TunnelState.Connecting -> copy(location = location?.translate(translations))
+ is TunnelState.Disconnected -> copy(location = location?.translate(translations))
+ is TunnelState.Disconnecting -> this
+ is TunnelState.Error -> this
+ is TunnelState.Connected -> copy(location = location?.translate(translations))
+ }
+ }
+
+ private fun GeoIpLocation.translate(translations: Map): GeoIpLocation =
+ copy(city = translations[city] ?: city, country = translations[country] ?: country)
suspend fun connect(): Either = either {
ensure(vpnPermissionRepository.hasVpnPermission()) { ConnectError.NoVpnPermission }
diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/LocaleRepository.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/LocaleRepository.kt
new file mode 100644
index 000000000000..8d952ec112b9
--- /dev/null
+++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/LocaleRepository.kt
@@ -0,0 +1,20 @@
+package net.mullvad.mullvadvpn.lib.shared
+
+import android.content.res.Resources
+import co.touchlab.kermit.Logger
+import java.util.Locale
+import kotlin.also
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+class LocaleRepository(val resources: Resources) {
+ private val _currentLocale = MutableStateFlow(getLocale())
+ val currentLocale: StateFlow = _currentLocale
+
+ private fun getLocale(): Locale? = resources.configuration.locales.get(0)
+
+ fun refreshLocale() {
+ Logger.d("AppLang: Refreshing locale")
+ _currentLocale.value = getLocale().also { Logger.d("AppLang: New locale: $it") }
+ }
+}
diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/RelayLocationTranslationRepository.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/RelayLocationTranslationRepository.kt
new file mode 100644
index 000000000000..795edac9f4d9
--- /dev/null
+++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/RelayLocationTranslationRepository.kt
@@ -0,0 +1,84 @@
+package net.mullvad.mullvadvpn.lib.shared
+
+import android.content.Context
+import android.content.res.Configuration
+import android.content.res.XmlResourceParser
+import co.touchlab.kermit.Logger
+import java.util.Locale
+import kotlin.also
+import kotlin.collections.associate
+import kotlin.collections.set
+import kotlin.collections.toMap
+import kotlin.to
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.MainScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.withContext
+
+typealias Translations = Map
+
+class RelayLocationTranslationRepository(
+ val context: Context,
+ val localeRepository: LocaleRepository,
+ externalScope: CoroutineScope = MainScope(),
+ val dispatcher: CoroutineDispatcher = Dispatchers.IO
+) {
+ val translations: StateFlow =
+ localeRepository.currentLocale
+ .map { loadTranslations(it) }
+ .stateIn(externalScope, SharingStarted.Eagerly, emptyMap())
+
+ private val defaultTranslation: Map
+
+ init {
+ val defaultConfiguration = defaultConfiguration()
+ val confContext = context.createConfigurationContext(defaultConfiguration)
+ val defaultTranslationXml = confContext.resources.getXml(R.xml.relay_locations)
+ defaultTranslation = loadRelayTranslation(defaultTranslationXml)
+ }
+
+ private suspend fun loadTranslations(locale: Locale?): Translations =
+ withContext(dispatcher) {
+ Logger.d(
+ "AppLang ${this@RelayLocationTranslationRepository}: Updating based on current locale to $locale"
+ )
+ if (locale == null || locale.language == DEFAULT_LANGUAGE) emptyMap()
+ else {
+ // Load current translations
+ val xml = context.resources.getXml(R.xml.relay_locations)
+ val translation = loadRelayTranslation(xml)
+
+ translation.entries
+ .associate { (id, name) -> defaultTranslation[id]!! to name }
+ .also { Logger.d("AppLang: New translationTable: $it") }
+ }
+ }
+
+ private fun loadRelayTranslation(xml: XmlResourceParser): Map {
+ val translation = mutableMapOf()
+ while (xml.eventType != XmlResourceParser.END_DOCUMENT) {
+ if (xml.eventType == XmlResourceParser.START_TAG && xml.name == "string") {
+ val key = xml.getAttributeValue(null, "name")
+ val value = xml.nextText()
+ translation[key] = value
+ }
+ xml.next()
+ }
+ return translation.toMap()
+ }
+
+ private fun defaultConfiguration(): Configuration {
+ val configuration = context.resources.configuration
+ configuration.setLocale(Locale(DEFAULT_LANGUAGE))
+ return configuration
+ }
+
+ companion object {
+ private const val DEFAULT_LANGUAGE = "en"
+ }
+}