diff options
| author | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-10-23 16:15:02 +0200 |
|---|---|---|
| committer | Kalle Lindström <karl.lindstrom@mullvad.net> | 2025-10-28 15:55:52 +0100 |
| commit | 376372c84c6b881e0097e5dab5348adab3e5f100 (patch) | |
| tree | 94b43c656977a037bea9f2da45345e66ff737bf6 /android/lib/shared/src | |
| parent | 721496daaab896dd29980fd4b7a234fc7dfd5607 (diff) | |
| download | mullvadvpn-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')
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() - } -} |
