summaryrefslogtreecommitdiffhomepage
path: root/android/lib/shared
diff options
context:
space:
mode:
authorDavid Göransson <david.goransson@mullvad.net>2024-05-29 17:18:29 +0200
committerDavid Göransson <david.goransson@mullvad.net>2024-05-29 17:18:29 +0200
commitad90145a5d86d8c1e6e70f2238f11edf5e50f8d8 (patch)
tree9d085bc81caed9409e3a4360490c06c2da4fbba8 /android/lib/shared
parent8e14a8d4287af66a57a98db79d3ac320c2dad4a1 (diff)
parent767b97eda756f4ec4e67fb5fa2ae664277291e8f (diff)
downloadmullvadvpn-ad90145a5d86d8c1e6e70f2238f11edf5e50f8d8.tar.xz
mullvadvpn-ad90145a5d86d8c1e6e70f2238f11edf5e50f8d8.zip
Merge branch 'android-grpc'
Diffstat (limited to 'android/lib/shared')
-rw-r--r--android/lib/shared/build.gradle.kts47
-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.kt84
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt26
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/DeviceRepository.kt36
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VoucherRepository.kt13
-rw-r--r--android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VpnPermissionRepository.kt11
-rw-r--r--android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxyTest.kt54
8 files changed, 272 insertions, 0 deletions
diff --git a/android/lib/shared/build.gradle.kts b/android/lib/shared/build.gradle.kts
new file mode 100644
index 0000000000..88b5cfb3c9
--- /dev/null
+++ b/android/lib/shared/build.gradle.kts
@@ -0,0 +1,47 @@
+plugins {
+ id(Dependencies.Plugin.androidLibraryId)
+ id(Dependencies.Plugin.kotlinAndroidId)
+ id(Dependencies.Plugin.kotlinParcelizeId)
+ id(Dependencies.Plugin.junit5) version Versions.Plugin.junit5
+}
+
+android {
+ namespace = "net.mullvad.mullvadvpn.lib.shared"
+ compileSdk = Versions.Android.compileSdkVersion
+
+ defaultConfig { minSdk = Versions.Android.minSdkVersion }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ kotlinOptions { jvmTarget = Versions.jvmTarget }
+
+ lint {
+ lintConfig = file("${rootProject.projectDir}/config/lint.xml")
+ abortOnError = true
+ warningsAsErrors = true
+ }
+ buildFeatures { buildConfig = true }
+}
+
+dependencies {
+ implementation(project(Dependencies.Mullvad.commonLib))
+ implementation(project(Dependencies.Mullvad.daemonGrpc))
+ implementation(project(Dependencies.Mullvad.modelLib))
+
+ implementation(Dependencies.Arrow.core)
+ implementation(Dependencies.Kotlin.stdlib)
+ implementation(Dependencies.KotlinX.coroutinesAndroid)
+ implementation(Dependencies.jodaTime)
+
+ testImplementation(Dependencies.Kotlin.test)
+ testImplementation(Dependencies.KotlinX.coroutinesTest)
+ testImplementation(Dependencies.MockK.core)
+ testImplementation(Dependencies.junitApi)
+ testImplementation(Dependencies.junitParams)
+ testImplementation(Dependencies.turbine)
+ testImplementation(project(Dependencies.Mullvad.commonTestLib))
+ testRuntimeOnly(Dependencies.junitEngine)
+}
diff --git a/android/lib/shared/src/main/AndroidManifest.xml b/android/lib/shared/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..cc947c5679
--- /dev/null
+++ b/android/lib/shared/src/main/AndroidManifest.xml
@@ -0,0 +1 @@
+<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
new file mode 100644
index 0000000000..432d113fba
--- /dev/null
+++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/AccountRepository.kt
@@ -0,0 +1,84 @@
+package net.mullvad.mullvadvpn.lib.shared
+
+import arrow.core.Either
+import arrow.core.raise.nullable
+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.filterNotNull
+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.AccountToken
+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
+import org.joda.time.DateTime
+
+class AccountRepository(
+ private val managementService: ManagementService,
+ private val deviceRepository: DeviceRepository,
+ val scope: CoroutineScope
+) {
+
+ private val _mutableAccountDataCache: MutableSharedFlow<AccountData> = MutableSharedFlow()
+
+ private val _isNewAccount: MutableStateFlow<Boolean> = MutableStateFlow(false)
+ val isNewAccount: StateFlow<Boolean> = _isNewAccount
+ val accountData: StateFlow<AccountData?> =
+ merge(
+ managementService.deviceState.filterNotNull().map { deviceState ->
+ when (deviceState) {
+ is DeviceState.LoggedIn -> {
+ managementService.getAccountData(deviceState.accountToken).getOrNull()
+ }
+ DeviceState.LoggedOut,
+ DeviceState.Revoked -> null
+ }
+ },
+ _mutableAccountDataCache
+ )
+ .distinctUntilChanged()
+ .stateIn(scope = scope, SharingStarted.Eagerly, null)
+
+ suspend fun createAccount(): Either<CreateAccountError, AccountToken> =
+ managementService.createAccount().onRight { _isNewAccount.update { true } }
+
+ suspend fun login(accountToken: AccountToken): Either<LoginAccountError, Unit> =
+ managementService.loginAccount(accountToken)
+
+ suspend fun logout() {
+ managementService.logoutAccount()
+ _isNewAccount.update { false }
+ }
+
+ suspend fun fetchAccountHistory(): AccountToken? =
+ managementService.getAccountHistory().getOrNull()
+
+ suspend fun clearAccountHistory() = managementService.clearAccountHistory()
+
+ suspend fun getAccountData(): AccountData? = nullable {
+ val deviceState = ensureNotNull(deviceRepository.deviceState.value as? DeviceState.LoggedIn)
+
+ val accountData =
+ managementService.getAccountData(deviceState.accountToken).getOrNull().bind()
+
+ // Update stateflow cache
+ _mutableAccountDataCache.emit(accountData)
+ accountData
+ }
+
+ suspend fun getWebsiteAuthToken(): WebsiteAuthToken? =
+ managementService.getWebsiteAuthToken().getOrNull()
+
+ internal suspend fun onVoucherRedeemed(newExpiry: DateTime) {
+ accountData.value?.copy(expiryDate = newExpiry)?.let { _mutableAccountDataCache.emit(it) }
+ }
+}
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
new file mode 100644
index 0000000000..6ea373e426
--- /dev/null
+++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxy.kt
@@ -0,0 +1,26 @@
+package net.mullvad.mullvadvpn.lib.shared
+
+import arrow.core.Either
+import arrow.core.raise.either
+import arrow.core.raise.ensure
+import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
+import net.mullvad.mullvadvpn.lib.model.ConnectError
+
+class ConnectionProxy(
+ private val managementService: ManagementService,
+ private val vpnPermissionRepository: VpnPermissionRepository
+) {
+ val tunnelState = managementService.tunnelState
+
+ suspend fun connect(): Either<ConnectError, Boolean> = either {
+ ensure(vpnPermissionRepository.hasVpnPermission()) { ConnectError.NoVpnPermission }
+ 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
new file mode 100644
index 0000000000..b1b8f4fa41
--- /dev/null
+++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/DeviceRepository.kt
@@ -0,0 +1,36 @@
+package net.mullvad.mullvadvpn.lib.shared
+
+import arrow.core.Either
+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.AccountToken
+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(
+ accountToken: AccountToken,
+ deviceId: DeviceId
+ ): Either<DeleteDeviceError, Unit> = managementService.removeDevice(accountToken, deviceId)
+
+ suspend fun deviceList(accountToken: AccountToken): Either<GetDeviceListError, List<Device>> =
+ managementService.getDeviceList(accountToken)
+}
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
new file mode 100644
index 0000000000..a5783a832e
--- /dev/null
+++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VoucherRepository.kt
@@ -0,0 +1,13 @@
+package net.mullvad.mullvadvpn.lib.shared
+
+import net.mullvad.mullvadvpn.lib.daemon.grpc.ManagementService
+
+class VoucherRepository(
+ private val managementService: ManagementService,
+ private val accountRepository: AccountRepository
+) {
+ suspend fun submitVoucher(voucher: String) =
+ managementService.submitVoucher(voucher).onRight {
+ accountRepository.onVoucherRedeemed(it.newExpiryDate)
+ }
+}
diff --git a/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VpnPermissionRepository.kt b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VpnPermissionRepository.kt
new file mode 100644
index 0000000000..b97c60316c
--- /dev/null
+++ b/android/lib/shared/src/main/kotlin/net/mullvad/mullvadvpn/lib/shared/VpnPermissionRepository.kt
@@ -0,0 +1,11 @@
+package net.mullvad.mullvadvpn.lib.shared
+
+import android.content.Context
+import android.net.VpnService
+import net.mullvad.mullvadvpn.lib.common.util.getAlwaysOnVpnAppName
+
+class VpnPermissionRepository(private val applicationContext: Context) {
+ fun hasVpnPermission(): Boolean = VpnService.prepare(applicationContext) == null
+
+ fun getAlwaysOnVpnAppName() = applicationContext.getAlwaysOnVpnAppName()
+}
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
new file mode 100644
index 0000000000..74ab4f6b64
--- /dev/null
+++ b/android/lib/shared/src/test/kotlin/net/mullvad/mullvadvpn/lib/shared/ConnectionProxyTest.kt
@@ -0,0 +1,54 @@
+package net.mullvad.mullvadvpn.lib.shared
+
+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 org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Test
+
+class ConnectionProxyTest {
+
+ private val mockManagementService: ManagementService = mockk(relaxed = true)
+ private val mockVpnPermissionRepository: VpnPermissionRepository = mockk()
+
+ private val connectionProxy: ConnectionProxy =
+ ConnectionProxy(
+ managementService = mockManagementService,
+ vpnPermissionRepository = mockVpnPermissionRepository
+ )
+
+ @Test
+ fun `connect with vpn permission allowed should call managementService connect`() = runTest {
+ every { mockVpnPermissionRepository.hasVpnPermission() } returns true
+ connectionProxy.connect()
+ coVerify(exactly = 1) { mockManagementService.connect() }
+ }
+
+ @Test
+ fun `connect with vpn permission not allowed should not call managementService connect`() =
+ runTest {
+ every { mockVpnPermissionRepository.hasVpnPermission() } returns false
+ 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()
+ }
+}