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 9, 2024
1 parent cc9878d commit 71d57de
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
@@ -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)
6 changes: 6 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -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
@@ -11,6 +11,8 @@ import net.mullvad.mullvadvpn.lib.model.BuildVersion
import net.mullvad.mullvadvpn.lib.shared.AccountRepository
import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy
import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
import net.mullvad.mullvadvpn.lib.shared.LocaleRepository
import net.mullvad.mullvadvpn.lib.shared.RelayLocationTranslationRepository
import net.mullvad.mullvadvpn.lib.shared.VpnPermissionRepository
import org.koin.android.ext.koin.androidContext
import org.koin.core.qualifier.named
@@ -32,5 +34,7 @@ val appModule = module {
single { AccountRepository(get(), get(), MainScope()) }
single { DeviceRepository(get()) }
single { VpnPermissionRepository(androidContext()) }
single { ConnectionProxy(get(), get()) }
single { ConnectionProxy(get(), get(), get()) }
single { LocaleRepository(get()) }
single { RelayLocationTranslationRepository(get(), get(), MainScope()) }
}
Original file line number Diff line number Diff line change
@@ -117,7 +117,7 @@ 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()) }
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
@@ -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(
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?,
@@ -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
@@ -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))
Original file line number Diff line number Diff line change
@@ -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 }
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 71d57de

Please sign in to comment.