Skip to content

Commit 7a3fe6c

Browse files
committed
Add support location translations
1 parent a4320c3 commit 7a3fe6c

File tree

12 files changed

+182
-9
lines changed

12 files changed

+182
-9
lines changed

android/app/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ dependencies {
339339
implementation(Dependencies.AndroidX.lifecycleViewmodelKtx)
340340
implementation(Dependencies.AndroidX.lifecycleRuntimeCompose)
341341
implementation(Dependencies.Arrow.core)
342+
implementation(Dependencies.Arrow.optics)
342343
implementation(Dependencies.Arrow.resilience)
343344
implementation(Dependencies.Compose.constrainLayout)
344345
implementation(Dependencies.Compose.foundation)

android/app/src/main/AndroidManifest.xml

+6
Original file line numberDiff line numberDiff line change
@@ -106,5 +106,11 @@
106106
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
107107
android:resource="@xml/provider_paths" />
108108
</provider>
109+
<receiver android:name=".broadcastreceiver.LocaleChangedBroadcastReceiver"
110+
android:exported="false">
111+
<intent-filter>
112+
<action android:name="android.intent.action.LOCALE_CHANGED" />
113+
</intent-filter>
114+
</receiver>
109115
</application>
110116
</manifest>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package net.mullvad.mullvadvpn.broadcastreceiver
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import net.mullvad.mullvadvpn.lib.shared.LocaleRepository
7+
import org.koin.core.component.KoinComponent
8+
import org.koin.core.component.inject
9+
10+
class LocaleChangedBroadcastReceiver : BroadcastReceiver(), KoinComponent {
11+
private val localeRepository by inject<LocaleRepository>()
12+
13+
override fun onReceive(context: Context?, intent: Intent?) {
14+
if (intent?.action == Intent.ACTION_LOCALE_CHANGED) {
15+
localeRepository.refreshLocale()
16+
}
17+
}
18+
}

android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/AppModule.kt

+5-1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import net.mullvad.mullvadvpn.lib.model.BuildVersion
1111
import net.mullvad.mullvadvpn.lib.shared.AccountRepository
1212
import net.mullvad.mullvadvpn.lib.shared.ConnectionProxy
1313
import net.mullvad.mullvadvpn.lib.shared.DeviceRepository
14+
import net.mullvad.mullvadvpn.lib.shared.LocaleRepository
15+
import net.mullvad.mullvadvpn.lib.shared.RelayLocationTranslationRepository
1416
import net.mullvad.mullvadvpn.lib.shared.VpnPermissionRepository
1517
import org.koin.android.ext.koin.androidContext
1618
import org.koin.core.qualifier.named
@@ -32,5 +34,7 @@ val appModule = module {
3234
single { AccountRepository(get(), get(), MainScope()) }
3335
single { DeviceRepository(get()) }
3436
single { VpnPermissionRepository(androidContext()) }
35-
single { ConnectionProxy(get(), get()) }
37+
single { ConnectionProxy(get(), get(), get()) }
38+
single { LocaleRepository(get()) }
39+
single { RelayLocationTranslationRepository(get(), get(), MainScope()) }
3640
}

android/app/src/main/kotlin/net/mullvad/mullvadvpn/di/UiModule.kt

+1-1
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ val uiModule = module {
117117
single { MullvadProblemReport(get()) }
118118
single { RelayOverridesRepository(get()) }
119119
single { CustomListsRepository(get()) }
120-
single { RelayListRepository(get()) }
120+
single { RelayListRepository(get(), get()) }
121121
single { RelayListFilterRepository(get()) }
122122
single { VoucherRepository(get(), get()) }
123123
single { SplitTunnelingRepository(get()) }

android/app/src/main/kotlin/net/mullvad/mullvadvpn/repository/RelayListRepository.kt

+39-5
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package net.mullvad.mullvadvpn.repository
22

3+
import arrow.optics.Every
4+
import arrow.optics.copy
5+
import arrow.optics.dsl.every
36
import kotlinx.coroutines.CoroutineDispatcher
47
import kotlinx.coroutines.CoroutineScope
58
import kotlinx.coroutines.Dispatchers
69
import kotlinx.coroutines.flow.Flow
710
import kotlinx.coroutines.flow.SharingStarted
811
import kotlinx.coroutines.flow.StateFlow
12+
import kotlinx.coroutines.flow.combine
913
import kotlinx.coroutines.flow.distinctUntilChanged
1014
import kotlinx.coroutines.flow.map
1115
import kotlinx.coroutines.flow.stateIn
@@ -17,18 +21,48 @@ import net.mullvad.mullvadvpn.lib.model.RelayItem
1721
import net.mullvad.mullvadvpn.lib.model.RelayItemId
1822
import net.mullvad.mullvadvpn.lib.model.WireguardConstraints
1923
import net.mullvad.mullvadvpn.lib.model.WireguardEndpointData
24+
import net.mullvad.mullvadvpn.lib.model.cities
25+
import net.mullvad.mullvadvpn.lib.model.name
26+
import net.mullvad.mullvadvpn.lib.shared.RelayLocationTranslationRepository
2027
import net.mullvad.mullvadvpn.relaylist.findByGeoLocationId
28+
import net.mullvad.mullvadvpn.util.sortedByName
2129

2230
class RelayListRepository(
2331
private val managementService: ManagementService,
32+
private val translationRepository: RelayLocationTranslationRepository,
2433
dispatcher: CoroutineDispatcher = Dispatchers.IO
2534
) {
2635
val relayList: StateFlow<List<RelayItem.Location.Country>> =
27-
managementService.relayCountries.stateIn(
28-
CoroutineScope(dispatcher),
29-
SharingStarted.WhileSubscribed(),
30-
emptyList()
31-
)
36+
combine(managementService.relayCountries, translationRepository.translations) {
37+
countries,
38+
translations ->
39+
countries.translateRelay(translations)
40+
}
41+
.stateIn(CoroutineScope(dispatcher), SharingStarted.WhileSubscribed(), emptyList())
42+
43+
private fun List<RelayItem.Location.Country>.translateRelay(
44+
translations: Map<String, String>
45+
): List<RelayItem.Location.Country> {
46+
if (translations.isEmpty()) {
47+
return this
48+
}
49+
50+
return Every.list<RelayItem.Location.Country>()
51+
.modify(this) {
52+
it.copy<RelayItem.Location.Country> {
53+
RelayItem.Location.Country.name set translations.getOrDefault(it.name, it.name)
54+
RelayItem.Location.Country.cities.every(Every.list()).name transform
55+
{ cityName ->
56+
translations.getOrDefault(cityName, cityName)
57+
}
58+
RelayItem.Location.Country.cities transform
59+
{ cities ->
60+
cities.sortedByName { it.name }
61+
}
62+
}
63+
}
64+
.sortedByName { it.name }
65+
}
3266

3367
val wireguardEndpointData: StateFlow<WireguardEndpointData> =
3468
managementService.wireguardEndpointData.stateIn(

android/app/src/main/kotlin/net/mullvad/mullvadvpn/util/StringExtensions.kt

+3
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ fun String.removeHtmlTags(): String =
1414
Html.fromHtml(this, HtmlCompat.FROM_HTML_MODE_LEGACY).toString()
1515

1616
fun List<String>.trimAll() = map { it.trim() }
17+
18+
fun <T> List<T>.sortedByName(comparator: (T) -> String) =
19+
this.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER, comparator))
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package net.mullvad.mullvadvpn.lib.model
22

3+
import arrow.optics.optics
34
import java.net.InetAddress
45

6+
@optics
57
data class GeoIpLocation(
68
val ipv4: InetAddress?,
79
val ipv6: InetAddress?,
@@ -10,4 +12,6 @@ data class GeoIpLocation(
1012
val latitude: Double,
1113
val longitude: Double,
1214
val hostname: String?,
13-
)
15+
) {
16+
companion object
17+
}

android/lib/shared/build.gradle.kts

+1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ android {
2727
}
2828

2929
dependencies {
30+
implementation(project(Dependencies.Mullvad.resourceLib))
3031
implementation(project(Dependencies.Mullvad.commonLib))
3132
implementation(project(Dependencies.Mullvad.daemonGrpc))
3233
implementation(project(Dependencies.Mullvad.modelLib))

android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt

+25-1
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,38 @@ package net.mullvad.mullvadvpn.lib.shared
33
import arrow.core.Either
44
import arrow.core.raise.either
55
import arrow.core.raise.ensure
6+
import kotlinx.coroutines.flow.combine
7+
import mullvad_daemon.management_interface.location
68
import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
79
import net.mullvad.mullvadvpn.lib.model.ConnectError
10+
import net.mullvad.mullvadvpn.lib.model.GeoIpLocation
11+
import net.mullvad.mullvadvpn.lib.model.TunnelState
12+
import net.mullvad.mullvadvpn.lib.model.location
813

914
class ConnectionProxy(
1015
private val managementService: ManagementService,
16+
private val translationRepository: RelayLocationTranslationRepository,
1117
private val vpnPermissionRepository: VpnPermissionRepository
1218
) {
13-
val tunnelState = managementService.tunnelState
19+
val tunnelState =
20+
combine(managementService.tunnelState, translationRepository.translations) {
21+
tunnelState,
22+
translations ->
23+
tunnelState.translateLocations(translations)
24+
}
25+
26+
private fun TunnelState.translateLocations(translations: Map<String, String>): TunnelState {
27+
return when (this) {
28+
is TunnelState.Connecting -> copy(location = location?.translate(translations))
29+
is TunnelState.Disconnected -> copy(location = location?.translate(translations))
30+
is TunnelState.Disconnecting -> this
31+
is TunnelState.Error -> this
32+
is TunnelState.Connected -> copy(location = location?.translate(translations))
33+
}
34+
}
35+
36+
private fun GeoIpLocation.translate(translations: Map<String, String>): GeoIpLocation =
37+
copy(city = translations[city] ?: city, country = translations[country] ?: country)
1438

1539
suspend fun connect(): Either<ConnectError, Boolean> = either {
1640
ensure(vpnPermissionRepository.hasVpnPermission()) { ConnectError.NoVpnPermission }
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package net.mullvad.mullvadvpn.lib.shared
2+
3+
import android.content.res.Resources
4+
import co.touchlab.kermit.Logger
5+
import java.util.Locale
6+
import kotlin.also
7+
import kotlinx.coroutines.flow.MutableStateFlow
8+
import kotlinx.coroutines.flow.StateFlow
9+
10+
class LocaleRepository(val resources: Resources) {
11+
private val _currentLocale = MutableStateFlow(getLocale())
12+
val currentLocale: StateFlow<Locale?> = _currentLocale
13+
14+
private fun getLocale(): Locale? = resources.configuration.locales.get(0)
15+
16+
fun refreshLocale() {
17+
_currentLocale.value = getLocale().also { Logger.d("New locale: $it") }
18+
}
19+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package net.mullvad.mullvadvpn.lib.shared
2+
3+
import android.content.Context
4+
import android.content.res.XmlResourceParser
5+
import co.touchlab.kermit.Logger
6+
import java.util.Locale
7+
import kotlin.collections.set
8+
import kotlin.collections.toMap
9+
import kotlinx.coroutines.CoroutineDispatcher
10+
import kotlinx.coroutines.CoroutineScope
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.MainScope
13+
import kotlinx.coroutines.flow.SharingStarted
14+
import kotlinx.coroutines.flow.StateFlow
15+
import kotlinx.coroutines.flow.map
16+
import kotlinx.coroutines.flow.stateIn
17+
import kotlinx.coroutines.withContext
18+
19+
typealias Translations = Map<String, String>
20+
21+
class RelayLocationTranslationRepository(
22+
val context: Context,
23+
val localeRepository: LocaleRepository,
24+
externalScope: CoroutineScope = MainScope(),
25+
val dispatcher: CoroutineDispatcher = Dispatchers.IO
26+
) {
27+
val translations: StateFlow<Translations> =
28+
localeRepository.currentLocale
29+
.map { loadTranslations(it) }
30+
.stateIn(externalScope, SharingStarted.Eagerly, emptyMap())
31+
32+
private suspend fun loadTranslations(locale: Locale?): Translations =
33+
withContext(dispatcher) {
34+
Logger.d("Updating translations based $locale")
35+
if (locale == null || locale.language == DEFAULT_LANGUAGE) emptyMap()
36+
else {
37+
// Load current translations
38+
val xml = context.resources.getXml(R.xml.relay_locations)
39+
loadRelayTranslation(xml)
40+
}
41+
}
42+
43+
private fun loadRelayTranslation(xml: XmlResourceParser): Map<String, String> {
44+
val translation = mutableMapOf<String, String>()
45+
while (xml.eventType != XmlResourceParser.END_DOCUMENT) {
46+
if (xml.eventType == XmlResourceParser.START_TAG && xml.name == "string") {
47+
val key = xml.getAttributeValue(null, "name")
48+
val value = xml.nextText()
49+
translation[key] = value
50+
}
51+
xml.next()
52+
}
53+
return translation.toMap()
54+
}
55+
56+
companion object {
57+
private const val DEFAULT_LANGUAGE = "en"
58+
}
59+
}

0 commit comments

Comments
 (0)