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" + } +}