Skip to content

Commit

Permalink
Add support location translations
Browse files Browse the repository at this point in the history
  • Loading branch information
Rawa committed Aug 7, 2024
1 parent cc9878d commit a321369
Show file tree
Hide file tree
Showing 11 changed files with 192 additions and 9 deletions.
1 change: 1 addition & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,11 @@
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/provider_paths" />
</provider>
<receiver android:name=".broadcastreceiver.LocaleChangedBroadcastReceiver"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter>
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package net.mullvad.mullvadvpn.broadcastreceiver

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
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<LocaleRepository>()

override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == Intent.ACTION_LOCALE_CHANGED) {
localeRepository.refreshLocale()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()) }
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<List<RelayItem.Location.Country>> =
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<RelayItem.Location.Country>.translateRelay(
translations: Map<String, String>
): List<RelayItem.Location.Country> {
if (translations.isEmpty()) {
return this
}

return Every.list<RelayItem.Location.Country>().modify(this) {
it.copy<RelayItem.Location.Country> {
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<WireguardEndpointData> =
managementService.wireguardEndpointData.stateIn(
Expand Down
Original file line number Diff line number Diff line change
@@ -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?,
Expand All @@ -10,4 +12,6 @@ data class GeoIpLocation(
val latitude: Double,
val longitude: Double,
val hostname: String?,
)
) {
companion object
}
1 change: 1 addition & 0 deletions android/lib/shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>): 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<String, String>): GeoIpLocation =
copy(city = translations[city] ?: city, country = translations[country] ?: country)

suspend fun connect(): Either<ConnectError, Boolean> = either {
ensure(vpnPermissionRepository.hasVpnPermission()) { ConnectError.NoVpnPermission }
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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<Locale?> = _currentLocale

private fun getLocale(): Locale? = resources.configuration.locales.get(0)

fun refreshLocale() {
_currentLocale.value = getLocale().also { Logger.d("New locale: $it") }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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.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<String, String>

class RelayLocationTranslationRepository(
val context: Context,
val localeRepository: LocaleRepository,
externalScope: CoroutineScope = MainScope(),
val dispatcher: CoroutineDispatcher = Dispatchers.IO
) {
val translations: StateFlow<Translations> =
localeRepository.currentLocale
.map { loadTranslations(it) }
.stateIn(externalScope, SharingStarted.Eagerly, emptyMap())

private val defaultTranslation: Map<String, String>

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("Updating translations based $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 }
}
}

private fun loadRelayTranslation(xml: XmlResourceParser): Map<String, String> {
val translation = mutableMapOf<String, String>()
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"
}
}

0 comments on commit a321369

Please sign in to comment.