summaryrefslogtreecommitdiffhomepage
path: root/android/lib/shared/src
diff options
context:
space:
mode:
authorKalle Lindström <karl.lindstrom@mullvad.net>2025-10-23 16:15:02 +0200
committerKalle Lindström <karl.lindstrom@mullvad.net>2025-10-28 15:55:52 +0100
commit376372c84c6b881e0097e5dab5348adab3e5f100 (patch)
tree94b43c656977a037bea9f2da45345e66ff737bf6 /android/lib/shared/src
parent721496daaab896dd29980fd4b7a234fc7dfd5607 (diff)
downloadmullvadvpn-376372c84c6b881e0097e5dab5348adab3e5f100.tar.xz
mullvadvpn-376372c84c6b881e0097e5dab5348adab3e5f100.zip
Add option to show relay location in notification
This PR adds the following: - An option to show the relay location in the connection notification. - A new submenu under Settings called Notifications. - In the new Notifications screen a toggle to enable/disable showing the location in the notification.
Diffstat (limited to 'android/lib/shared/src')
-rw-r--r--android/lib/shared/src/main/AndroidManifest.xml1
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt114
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt47
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/DeviceRepository.kt42
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/LocaleRepository.kt19
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/PrepareVpnUseCase.kt11
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/RelayLocationTranslationRepository.kt61
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VoucherRepository.kt14
-rw-r--r--android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepositoryTest.kt78
-rw-r--r--android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxyTest.kt63
10 files changed, 0 insertions, 450 deletions
diff --git a/android/lib/shared/src/main/AndroidManifest.xml b/android/lib/shared/src/main/AndroidManifest.xml
deleted file mode 100644
index cc947c5679..0000000000
--- a/android/lib/shared/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1 +0,0 @@
-<manifest />
diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt
deleted file mode 100644
index bfb1918875..0000000000
--- a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt
+++ /dev/null
@@ -1,114 +0,0 @@
-package net.mullvad.mullvadvpn.lib.shared
-
-import arrow.core.Either
-import java.time.ZonedDateTime
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.merge
-import kotlinx.coroutines.flow.stateIn
-import kotlinx.coroutines.flow.update
-import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
-import net.mullvad.mullvadvpn.lib.model.AccountData
-import net.mullvad.mullvadvpn.lib.model.AccountNumber
-import net.mullvad.mullvadvpn.lib.model.ClearAccountHistoryError
-import net.mullvad.mullvadvpn.lib.model.CreateAccountError
-import net.mullvad.mullvadvpn.lib.model.DeviceState
-import net.mullvad.mullvadvpn.lib.model.LoginAccountError
-import net.mullvad.mullvadvpn.lib.model.WebsiteAuthToken
-
-class AccountRepository(
- private val managementService: ManagementService,
- private val deviceRepository: DeviceRepository,
- val scope: CoroutineScope,
-) {
- private var lastSuccessfulAccountDataFetch: ZonedDateTime? = null
-
- private val _mutableAccountDataCache: MutableSharedFlow<AccountData> = MutableSharedFlow()
-
- private val _isNewAccount: MutableStateFlow<Boolean> = MutableStateFlow(false)
-
- private val _mutableAccountHistory: MutableStateFlow<AccountNumber?> = MutableStateFlow(null)
-
- val isNewAccount: StateFlow<Boolean> = _isNewAccount
-
- val accountHistory: StateFlow<AccountNumber?> = _mutableAccountHistory
-
- val accountData: StateFlow<AccountData?> =
- merge(
- managementService.deviceState.map { deviceState ->
- when (deviceState) {
- is DeviceState.LoggedIn -> {
- managementService
- .getAccountData(deviceState.accountNumber)
- .getOrNull()
- ?.also { lastSuccessfulAccountDataFetch = ZonedDateTime.now() }
- }
- DeviceState.LoggedOut,
- DeviceState.Revoked -> null
- }
- },
- _mutableAccountDataCache,
- )
- .distinctUntilChanged()
- .stateIn(scope = scope, SharingStarted.Eagerly, null)
-
- suspend fun createAccount(): Either<CreateAccountError, AccountNumber> =
- managementService.createAccount().onRight { _isNewAccount.update { true } }
-
- suspend fun login(accountNumber: AccountNumber): Either<LoginAccountError, Unit> =
- managementService.loginAccount(accountNumber)
-
- suspend fun logout() =
- managementService.logoutAccount().onRight { _isNewAccount.update { false } }
-
- suspend fun fetchAccountHistory(): AccountNumber? =
- managementService
- .getAccountHistory()
- .onRight { _mutableAccountHistory.value = it }
- .getOrNull()
-
- suspend fun clearAccountHistory(): Either<ClearAccountHistoryError, Unit> =
- managementService.clearAccountHistory().onRight { _mutableAccountHistory.value = null }
-
- /*
- * Fetches the account data from the server, and updates the cache.
- * Unless force is true, it will only fetch if no fetch was made in the last minute.
- */
- suspend fun refreshAccountData(ignoreTimeout: Boolean = true) {
- // Only refresh if logged in
- val deviceState = deviceRepository.deviceState.value as? DeviceState.LoggedIn ?: return
-
- if (ignoreTimeout || lastSuccessfulAccountDataFetch.canFetchAccountData()) {
- val accountData =
- managementService.getAccountData(deviceState.accountNumber).getOrNull()
- lastSuccessfulAccountDataFetch = ZonedDateTime.now()
-
- // Update stateflow cache, only update if device state is still logged in and using the
- // same account number
- deviceRepository.deviceState.value?.let {
- if (it is DeviceState.LoggedIn && it.accountNumber == accountData?.accountNumber) {
- _mutableAccountDataCache.emit(accountData)
- }
- }
- }
- }
-
- suspend fun getWebsiteAuthToken(): WebsiteAuthToken? =
- managementService.getWebsiteAuthToken().getOrNull()
-
- internal suspend fun onVoucherRedeemed(newExpiry: ZonedDateTime) {
- accountData.value?.copy(expiryDate = newExpiry)?.let { _mutableAccountDataCache.emit(it) }
- }
-
- fun resetIsNewAccount() {
- _isNewAccount.value = false
- }
-
- private fun ZonedDateTime?.canFetchAccountData(): Boolean =
- this == null || this.isBefore(ZonedDateTime.now().minusMinutes(1))
-}
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
deleted file mode 100644
index baf404d89c..0000000000
--- a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-package net.mullvad.mullvadvpn.lib.shared
-
-import arrow.core.Either
-import arrow.core.raise.either
-import kotlinx.coroutines.flow.combine
-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
-
-class ConnectionProxy(
- private val managementService: ManagementService,
- translationRepository: RelayLocationTranslationRepository,
- private val prepareVpnUseCase: PrepareVpnUseCase,
-) {
- 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 {
- prepareVpnUseCase.invoke().mapLeft(ConnectError::NotPrepared).bind()
- managementService.connect().bind()
- }
-
- suspend fun connectWithoutPermissionCheck(): Either<ConnectError, Boolean> =
- managementService.connect()
-
- suspend fun disconnect() = managementService.disconnect()
-
- suspend fun reconnect() = managementService.reconnect()
-}
diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/DeviceRepository.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/DeviceRepository.kt
deleted file mode 100644
index 258f918788..0000000000
--- a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/DeviceRepository.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-package net.mullvad.mullvadvpn.lib.shared
-
-import arrow.core.Either
-import co.touchlab.kermit.Logger
-import kotlinx.coroutines.CoroutineDispatcher
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.stateIn
-import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
-import net.mullvad.mullvadvpn.lib.model.AccountNumber
-import net.mullvad.mullvadvpn.lib.model.DeleteDeviceError
-import net.mullvad.mullvadvpn.lib.model.Device
-import net.mullvad.mullvadvpn.lib.model.DeviceId
-import net.mullvad.mullvadvpn.lib.model.DeviceState
-import net.mullvad.mullvadvpn.lib.model.GetDeviceListError
-
-class DeviceRepository(
- private val managementService: ManagementService,
- dispatcher: CoroutineDispatcher = Dispatchers.IO,
-) {
- val deviceState: StateFlow<DeviceState?> =
- managementService.deviceState.stateIn(
- CoroutineScope(dispatcher),
- SharingStarted.Eagerly,
- null,
- )
-
- suspend fun removeDevice(
- accountNumber: AccountNumber,
- deviceId: DeviceId,
- ): Either<DeleteDeviceError, Unit> = managementService.removeDevice(accountNumber, deviceId)
-
- suspend fun deviceList(accountNumber: AccountNumber): Either<GetDeviceListError, List<Device>> =
- managementService.getDeviceList(accountNumber)
-
- suspend fun updateDevice() {
- Logger.i("Update device")
- managementService.updateDevice()
- }
-}
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
deleted file mode 100644
index 4e5628d214..0000000000
--- a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/LocaleRepository.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-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") }
- }
-}
diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/PrepareVpnUseCase.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/PrepareVpnUseCase.kt
deleted file mode 100644
index 7f7ec88120..0000000000
--- a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/PrepareVpnUseCase.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package net.mullvad.mullvadvpn.lib.shared
-
-import android.content.Context
-import arrow.core.Either
-import net.mullvad.mullvadvpn.lib.common.util.prepareVpnSafe
-import net.mullvad.mullvadvpn.lib.model.PrepareError
-import net.mullvad.mullvadvpn.lib.model.Prepared
-
-class PrepareVpnUseCase(private val applicationContext: Context) {
- fun invoke(): Either<PrepareError, Prepared> = applicationContext.prepareVpnSafe()
-}
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
deleted file mode 100644
index b2685abaf2..0000000000
--- a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/RelayLocationTranslationRepository.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package net.mullvad.mullvadvpn.lib.shared
-
-import android.content.Context
-import android.content.res.XmlResourceParser
-import co.touchlab.kermit.Logger
-import java.util.Locale
-import kotlin.collections.set
-import kotlin.collections.toMap
-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.filterNotNull
-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
- .filterNotNull()
- .map { loadTranslations(it) }
- .stateIn(externalScope, SharingStarted.Eagerly, emptyMap())
-
- private suspend fun loadTranslations(locale: Locale): Translations =
- withContext(dispatcher) {
- Logger.d("Updating translations based on $locale")
- if (locale.language == DEFAULT_LANGUAGE) emptyMap()
- else {
- // Load current translations
- val xml = context.resources.getXml(R.xml.relay_locations)
- xml.loadRelayTranslation()
- }
- }
-
- private fun XmlResourceParser.loadRelayTranslation(): Map<String, String> {
- val translation = mutableMapOf<String, String>()
- while (this.eventType != XmlResourceParser.END_DOCUMENT) {
- if (this.eventType == XmlResourceParser.START_TAG && this.name == "string") {
- val key = this.getAttributeValue(null, "name")
- val value = this.nextText()
- translation[key] = value
- }
- this.next()
- }
- return translation.toMap()
- }
-
- companion object {
- private const val DEFAULT_LANGUAGE = "en"
- }
-}
diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VoucherRepository.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VoucherRepository.kt
deleted file mode 100644
index 9b08181ee3..0000000000
--- a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VoucherRepository.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package net.mullvad.mullvadvpn.lib.shared
-
-import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
-import net.mullvad.mullvadvpn.lib.model.VoucherCode
-
-class VoucherRepository(
- private val managementService: ManagementService,
- private val accountRepository: AccountRepository,
-) {
- suspend fun submitVoucher(voucher: VoucherCode) =
- managementService.submitVoucher(voucher).onRight {
- accountRepository.onVoucherRedeemed(it.newExpiryDate)
- }
-}
diff --git a/android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepositoryTest.kt b/android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepositoryTest.kt
deleted file mode 100644
index ff2ecb88b2..0000000000
--- a/android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepositoryTest.kt
+++ /dev/null
@@ -1,78 +0,0 @@
-package net.mullvad.mullvadvpn.lib.shared
-
-import arrow.core.right
-import io.mockk.coEvery
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.MainScope
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.test.runTest
-import net.mullvad.mullvadvpn.lib.common.test.TestCoroutineRule
-import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
-import net.mullvad.mullvadvpn.lib.model.AccountData
-import net.mullvad.mullvadvpn.lib.model.AccountNumber
-import net.mullvad.mullvadvpn.lib.model.DeviceState
-import org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.Test
-import org.junit.jupiter.api.extension.ExtendWith
-
-@OptIn(ExperimentalCoroutinesApi::class)
-@ExtendWith(TestCoroutineRule::class)
-class AccountRepositoryTest {
-
- private val mockManagementService: ManagementService = mockk()
- private val mockDeviceRepository: DeviceRepository = mockk()
-
- private val mockDeviceStateFlow = MutableStateFlow<DeviceState>(DeviceState.LoggedOut)
-
- private lateinit var accountRepository: AccountRepository
-
- @BeforeEach
- fun setup() {
- every { mockDeviceRepository.deviceState } returns mockDeviceStateFlow
- every { mockManagementService.deviceState } returns mockDeviceStateFlow
-
- accountRepository =
- AccountRepository(
- managementService = mockManagementService,
- deviceRepository = mockDeviceRepository,
- scope = MainScope(),
- )
- }
-
- @Test
- fun `given force is true should always call managementService getAccountData`() = runTest {
- // Arrange
- val accountData: AccountData = mockk()
- val accountNumber = AccountNumber("1234567890")
- every { accountData.accountNumber } returns accountNumber
- coEvery { mockManagementService.getAccountData(accountNumber) } returns accountData.right()
-
- // Act
- mockDeviceStateFlow.emit(DeviceState.LoggedIn(accountNumber, mockk(relaxed = true)))
- accountRepository.refreshAccountData(ignoreTimeout = true)
-
- // Assert
- coVerify { mockManagementService.getAccountData(accountNumber) }
- }
-
- @Test
- fun `given last latestAccountDataFetch null should always call managementService getAccountData`() =
- runTest {
- // Arrange
- val accountData: AccountData = mockk()
- val accountNumber = AccountNumber("1234567890")
- every { accountData.accountNumber } returns accountNumber
- coEvery { mockManagementService.getAccountData(accountNumber) } returns
- accountData.right()
-
- // Act
- mockDeviceStateFlow.emit(DeviceState.LoggedIn(accountNumber, mockk(relaxed = true)))
- accountRepository.refreshAccountData(ignoreTimeout = false)
-
- // Assert
- coVerify { mockManagementService.getAccountData(accountNumber) }
- }
-}
diff --git a/android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxyTest.kt b/android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxyTest.kt
deleted file mode 100644
index b9d276c34b..0000000000
--- a/android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxyTest.kt
+++ /dev/null
@@ -1,63 +0,0 @@
-package net.mullvad.mullvadvpn.lib.shared
-
-import android.content.Intent
-import arrow.core.left
-import arrow.core.right
-import io.mockk.coVerify
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.unmockkAll
-import kotlinx.coroutines.test.runTest
-import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
-import net.mullvad.mullvadvpn.lib.model.PrepareError
-import net.mullvad.mullvadvpn.lib.model.Prepared
-import org.junit.jupiter.api.AfterEach
-import org.junit.jupiter.api.Test
-
-class ConnectionProxyTest {
-
- private val mockManagementService: ManagementService = mockk(relaxed = true)
- private val mockVpnPermissionRepository: PrepareVpnUseCase = mockk()
- private val mockTranslationRepository: RelayLocationTranslationRepository =
- mockk(relaxed = true)
-
- private val connectionProxy: ConnectionProxy =
- ConnectionProxy(
- managementService = mockManagementService,
- prepareVpnUseCase = mockVpnPermissionRepository,
- translationRepository = mockTranslationRepository,
- )
-
- @Test
- fun `connect with vpn permission allowed should call managementService connect`() = runTest {
- every { mockVpnPermissionRepository.invoke() } returns Prepared.right()
- connectionProxy.connect()
- coVerify(exactly = 1) { mockManagementService.connect() }
- }
-
- @Test
- fun `connect with vpn permission not allowed should not call managementService connect`() =
- runTest {
- every { mockVpnPermissionRepository.invoke() } returns
- PrepareError.NotPrepared(Intent()).left()
- connectionProxy.connect()
- coVerify(exactly = 0) { mockManagementService.connect() }
- }
-
- @Test
- fun `disconnect should call managementService disconnect`() = runTest {
- connectionProxy.disconnect()
- coVerify(exactly = 1) { mockManagementService.disconnect() }
- }
-
- @Test
- fun `reconnect should call managementService reconnect`() = runTest {
- connectionProxy.reconnect()
- coVerify(exactly = 1) { mockManagementService.reconnect() }
- }
-
- @AfterEach
- fun tearDown() {
- unmockkAll()
- }
-}