summaryrefslogtreecommitdiffhomepage
path: root/android/service/src
diff options
context:
space:
mode:
authorAlbin <albin@mullvad.net>2023-07-28 10:03:34 +0200
committerAlbin <albin@mullvad.net>2023-07-28 10:45:57 +0200
commit6e644dfb566b986e7cccbf2a95eac281e4eecf87 (patch)
tree469bc8ce128194512106a91a94bb044853f58907 /android/service/src
parent47bc546a330544b420aab2bdd8c5f91abe7f3161 (diff)
downloadmullvadvpn-6e644dfb566b986e7cccbf2a95eac281e4eecf87.tar.xz
mullvadvpn-6e644dfb566b986e7cccbf2a95eac281e4eecf87.zip
Move vpn service classes to service module
Diffstat (limited to 'android/service/src')
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt85
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt117
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt295
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt278
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt7
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt180
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AppVersionInfoCache.kt56
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AuthTokenCache.kt49
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ConnectionProxy.kt85
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomDns.kt133
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt62
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/LocationInfoCache.kt136
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayListListener.kt108
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt167
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt139
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt59
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VoucherRedeemer.kt40
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VpnPermission.kt47
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt148
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannel.kt97
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotification.kt153
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotificationAction.kt59
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/persistence/SplitTunnelingPersistence.kt37
-rw-r--r--android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/util/ExponentialBackoff.kt52
24 files changed, 2589 insertions, 0 deletions
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt
new file mode 100644
index 0000000000..4e121bc693
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/DaemonInstance.kt
@@ -0,0 +1,85 @@
+package net.mullvad.mullvadvpn.service
+
+import kotlin.properties.Delegates.observable
+import kotlin.reflect.KClass
+import kotlin.reflect.safeCast
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.channels.trySendBlocking
+import net.mullvad.mullvadvpn.lib.common.util.Intermittent
+import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration
+
+class DaemonInstance(private val vpnService: MullvadVpnService) {
+ sealed class Command {
+ data class Start(val apiEndpointConfiguration: ApiEndpointConfiguration) : Command()
+ object Stop : Command()
+ }
+
+ private val commandChannel = spawnActor()
+
+ private var daemon by
+ observable<MullvadDaemon?>(null) { _, oldInstance, _ -> oldInstance?.onDestroy() }
+
+ val intermittentDaemon = Intermittent<MullvadDaemon>()
+
+ fun start(apiEndpointConfiguration: ApiEndpointConfiguration) {
+ commandChannel.trySendBlocking(Command.Start(apiEndpointConfiguration))
+ }
+
+ fun stop() {
+ commandChannel.trySendBlocking(Command.Stop)
+ }
+
+ fun onDestroy() {
+ commandChannel.close()
+ intermittentDaemon.onDestroy()
+ }
+
+ private fun spawnActor() =
+ GlobalScope.actor(Dispatchers.Default, Channel.UNLIMITED) {
+ var isRunning = true
+
+ while (isRunning) {
+ val startCommand = waitForCommand(channel, Command.Start::class) ?: break
+ startDaemon(startCommand.apiEndpointConfiguration)
+ isRunning = waitForCommand(channel, Command.Stop::class) is Command.Stop
+ stopDaemon()
+ }
+ }
+
+ private suspend fun <T : Command> waitForCommand(
+ channel: ReceiveChannel<Command>,
+ command: KClass<T>
+ ): T? {
+ return try {
+ var receivedCommand: T?
+ do {
+ receivedCommand = command.safeCast(channel.receive())
+ } while (receivedCommand == null)
+ receivedCommand
+ } catch (exception: ClosedReceiveChannelException) {
+ null
+ }
+ }
+
+ private suspend fun startDaemon(apiEndpointConfiguration: ApiEndpointConfiguration) {
+ val newDaemon =
+ MullvadDaemon(vpnService, apiEndpointConfiguration).apply {
+ onDaemonStopped = {
+ intermittentDaemon.spawnUpdate(null)
+ daemon = null
+ }
+ }
+
+ daemon = newDaemon
+ intermittentDaemon.update(newDaemon)
+ }
+
+ private fun stopDaemon() {
+ daemon?.shutdown()
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt
new file mode 100644
index 0000000000..36d640c719
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/ForegroundNotificationManager.kt
@@ -0,0 +1,117 @@
+package net.mullvad.mullvadvpn.service
+
+import android.app.Service
+import kotlin.properties.Delegates.observable
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.onStart
+import net.mullvad.mullvadvpn.lib.common.util.Intermittent
+import net.mullvad.mullvadvpn.lib.common.util.JobTracker
+import net.mullvad.mullvadvpn.model.DeviceState
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.service.endpoint.ConnectionProxy
+import net.mullvad.mullvadvpn.service.notifications.TunnelStateNotification
+
+class ForegroundNotificationManager(
+ val service: MullvadVpnService,
+ val connectionProxy: ConnectionProxy,
+ val intermittentDaemon: Intermittent<MullvadDaemon>
+) {
+ private sealed class UpdaterMessage {
+ class UpdateNotification : UpdaterMessage()
+ class UpdateAction : UpdaterMessage()
+ class NewTunnelState(val newState: TunnelState) : UpdaterMessage()
+ }
+
+ private val jobTracker = JobTracker()
+ private val updater = runUpdater()
+
+ private val tunnelStateNotification = TunnelStateNotification(service)
+
+ private var loggedIn by
+ observable(false) { _, _, _ -> updater.trySendBlocking(UpdaterMessage.UpdateAction()) }
+
+ private val tunnelState
+ get() = connectionProxy.onStateChange.latestEvent
+
+ private val shouldBeOnForeground
+ get() = lockedToForeground || !(tunnelState is TunnelState.Disconnected)
+
+ var onForeground = false
+ private set
+
+ var lockedToForeground by
+ observable(false) { _, _, _ ->
+ updater.trySendBlocking(UpdaterMessage.UpdateNotification())
+ }
+
+ init {
+ connectionProxy.onStateChange.subscribe(this) { newState ->
+ updater.trySendBlocking(UpdaterMessage.NewTunnelState(newState))
+ }
+
+ intermittentDaemon.registerListener(this) { daemon ->
+ jobTracker.newBackgroundJob("notificationLoggedInJob") {
+ daemon
+ ?.deviceStateUpdates
+ ?.onStart { emit(daemon.getAndEmitDeviceState()) }
+ ?.collect { deviceState -> loggedIn = deviceState is DeviceState.LoggedIn }
+ }
+ }
+
+ updater.trySendBlocking(UpdaterMessage.UpdateNotification())
+ }
+
+ fun onDestroy() {
+ jobTracker.cancelAllJobs()
+ intermittentDaemon.unregisterListener(this)
+ connectionProxy.onStateChange.unsubscribe(this)
+ updater.close()
+ }
+
+ private fun runUpdater() =
+ GlobalScope.actor<UpdaterMessage>(Dispatchers.Main, Channel.UNLIMITED) {
+ for (message in channel) {
+ when (message) {
+ is UpdaterMessage.UpdateNotification -> updateNotification()
+ is UpdaterMessage.UpdateAction -> updateNotificationAction()
+ is UpdaterMessage.NewTunnelState -> {
+ tunnelStateNotification.tunnelState = message.newState
+ updateNotification()
+ }
+ }
+ }
+ }
+
+ fun showOnForeground() {
+ service.startForeground(
+ TunnelStateNotification.NOTIFICATION_ID,
+ tunnelStateNotification.build()
+ )
+
+ onForeground = true
+ }
+
+ fun updateNotification() {
+ if (shouldBeOnForeground != onForeground) {
+ if (shouldBeOnForeground) {
+ showOnForeground()
+ } else {
+ service.stopForeground(Service.STOP_FOREGROUND_DETACH)
+ onForeground = false
+ }
+ }
+ }
+
+ fun cancelNotification() {
+ tunnelStateNotification.visible = false
+ }
+
+ private fun updateNotificationAction() {
+ tunnelStateNotification.showAction = loggedIn
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt
new file mode 100644
index 0000000000..089e13ef31
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadDaemon.kt
@@ -0,0 +1,295 @@
+package net.mullvad.mullvadvpn.service
+
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpoint
+import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration
+import net.mullvad.mullvadvpn.model.AppVersionInfo
+import net.mullvad.mullvadvpn.model.Device
+import net.mullvad.mullvadvpn.model.DeviceEvent
+import net.mullvad.mullvadvpn.model.DeviceListEvent
+import net.mullvad.mullvadvpn.model.DeviceState
+import net.mullvad.mullvadvpn.model.DnsOptions
+import net.mullvad.mullvadvpn.model.GeoIpLocation
+import net.mullvad.mullvadvpn.model.GetAccountDataResult
+import net.mullvad.mullvadvpn.model.LoginResult
+import net.mullvad.mullvadvpn.model.ObfuscationSettings
+import net.mullvad.mullvadvpn.model.QuantumResistantState
+import net.mullvad.mullvadvpn.model.RelayList
+import net.mullvad.mullvadvpn.model.RelaySettingsUpdate
+import net.mullvad.mullvadvpn.model.RemoveDeviceEvent
+import net.mullvad.mullvadvpn.model.RemoveDeviceResult
+import net.mullvad.mullvadvpn.model.Settings
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.model.VoucherSubmissionResult
+import net.mullvad.talpid.util.EventNotifier
+
+class MullvadDaemon(
+ vpnService: MullvadVpnService,
+ apiEndpointConfiguration: ApiEndpointConfiguration
+) {
+ protected var daemonInterfaceAddress = 0L
+
+ val onSettingsChange = EventNotifier<Settings?>(null)
+ var onTunnelStateChange = EventNotifier<TunnelState>(TunnelState.Disconnected)
+
+ var onAppVersionInfoChange: ((AppVersionInfo) -> Unit)? = null
+ var onRelayListChange: ((RelayList) -> Unit)? = null
+ var onDaemonStopped: (() -> Unit)? = null
+
+ private val _deviceStateUpdates = MutableSharedFlow<DeviceState>(extraBufferCapacity = 1)
+ val deviceStateUpdates = _deviceStateUpdates.asSharedFlow()
+
+ private val _deviceListUpdates = MutableSharedFlow<DeviceListEvent>(extraBufferCapacity = 1)
+ val deviceListUpdates = _deviceListUpdates.asSharedFlow()
+
+ init {
+ System.loadLibrary("mullvad_jni")
+
+ initialize(
+ vpnService = vpnService,
+ cacheDirectory = vpnService.cacheDir.absolutePath,
+ resourceDirectory = vpnService.filesDir.absolutePath,
+ apiEndpoint = apiEndpointConfiguration.apiEndpoint()
+ )
+
+ onSettingsChange.notify(getSettings())
+
+ onTunnelStateChange.notify(getState() ?: TunnelState.Disconnected)
+ }
+
+ fun connect() {
+ connect(daemonInterfaceAddress)
+ }
+
+ fun createNewAccount(): String? {
+ return createNewAccount(daemonInterfaceAddress)
+ }
+
+ fun disconnect() {
+ disconnect(daemonInterfaceAddress)
+ }
+
+ fun getAccountData(accountToken: String): GetAccountDataResult {
+ return getAccountData(daemonInterfaceAddress, accountToken)
+ }
+
+ fun getAccountHistory(): String? {
+ return getAccountHistory(daemonInterfaceAddress)
+ }
+
+ fun getWwwAuthToken(): String {
+ return getWwwAuthToken(daemonInterfaceAddress) ?: ""
+ }
+
+ fun getCurrentLocation(): GeoIpLocation? {
+ return getCurrentLocation(daemonInterfaceAddress)
+ }
+
+ fun getCurrentVersion(): String? {
+ return getCurrentVersion(daemonInterfaceAddress)
+ }
+
+ fun getRelayLocations(): RelayList? {
+ return getRelayLocations(daemonInterfaceAddress)
+ }
+
+ fun getSettings(): Settings? {
+ return getSettings(daemonInterfaceAddress)
+ }
+
+ fun getState(): TunnelState? {
+ return getState(daemonInterfaceAddress)
+ }
+
+ fun getVersionInfo(): AppVersionInfo? {
+ return getVersionInfo(daemonInterfaceAddress)
+ }
+
+ fun reconnect() {
+ reconnect(daemonInterfaceAddress)
+ }
+
+ fun clearAccountHistory() {
+ clearAccountHistory(daemonInterfaceAddress)
+ }
+
+ fun loginAccount(accountToken: String): LoginResult {
+ return loginAccount(daemonInterfaceAddress, accountToken)
+ }
+
+ fun logoutAccount() = logoutAccount(daemonInterfaceAddress)
+
+ fun getAndEmitDeviceList(accountToken: String): List<Device>? {
+ return listDevices(daemonInterfaceAddress, accountToken).also { deviceList ->
+ _deviceListUpdates.tryEmit(
+ if (deviceList == null) {
+ DeviceListEvent.Error
+ } else {
+ DeviceListEvent.Available(accountToken, deviceList)
+ }
+ )
+ }
+ }
+
+ fun getAndEmitDeviceState(): DeviceState {
+ return getDevice(daemonInterfaceAddress).also { deviceState ->
+ _deviceStateUpdates.tryEmit(deviceState)
+ }
+ }
+
+ fun refreshDevice() {
+ updateDevice(daemonInterfaceAddress)
+ getAndEmitDeviceState()
+ }
+
+ fun removeDevice(accountToken: String, deviceId: String): RemoveDeviceResult {
+ return removeDevice(daemonInterfaceAddress, accountToken, deviceId)
+ }
+
+ fun setAllowLan(allowLan: Boolean) {
+ setAllowLan(daemonInterfaceAddress, allowLan)
+ }
+
+ fun setAutoConnect(autoConnect: Boolean) {
+ setAutoConnect(daemonInterfaceAddress, autoConnect)
+ }
+
+ fun setDnsOptions(dnsOptions: DnsOptions) {
+ setDnsOptions(daemonInterfaceAddress, dnsOptions)
+ }
+
+ fun setWireguardMtu(wireguardMtu: Int?) {
+ setWireguardMtu(daemonInterfaceAddress, wireguardMtu)
+ }
+
+ fun shutdown() {
+ shutdown(daemonInterfaceAddress)
+ }
+
+ fun submitVoucher(voucher: String): VoucherSubmissionResult {
+ return submitVoucher(daemonInterfaceAddress, voucher)
+ }
+
+ fun updateRelaySettings(update: RelaySettingsUpdate) {
+ updateRelaySettings(daemonInterfaceAddress, update)
+ }
+
+ fun setObfuscationSettings(settings: ObfuscationSettings?) {
+ setObfuscationSettings(daemonInterfaceAddress, settings)
+ }
+
+ fun setQuantumResistant(quantumResistant: QuantumResistantState) {
+ setQuantumResistantTunnel(daemonInterfaceAddress, quantumResistant)
+ }
+
+ fun onDestroy() {
+ onSettingsChange.unsubscribeAll()
+ onTunnelStateChange.unsubscribeAll()
+
+ onAppVersionInfoChange = null
+ onRelayListChange = null
+ onDaemonStopped = null
+
+ deinitialize()
+ }
+
+ private external fun initialize(
+ vpnService: MullvadVpnService,
+ cacheDirectory: String,
+ resourceDirectory: String,
+ apiEndpoint: ApiEndpoint?
+ )
+
+ private external fun deinitialize()
+
+ private external fun connect(daemonInterfaceAddress: Long)
+ private external fun createNewAccount(daemonInterfaceAddress: Long): String?
+ private external fun disconnect(daemonInterfaceAddress: Long)
+ private external fun getAccountData(
+ daemonInterfaceAddress: Long,
+ accountToken: String
+ ): GetAccountDataResult
+
+ private external fun getAccountHistory(daemonInterfaceAddress: Long): String?
+ private external fun getWwwAuthToken(daemonInterfaceAddress: Long): String?
+ private external fun getCurrentLocation(daemonInterfaceAddress: Long): GeoIpLocation?
+ private external fun getCurrentVersion(daemonInterfaceAddress: Long): String?
+ private external fun getRelayLocations(daemonInterfaceAddress: Long): RelayList?
+ private external fun getSettings(daemonInterfaceAddress: Long): Settings?
+ private external fun getState(daemonInterfaceAddress: Long): TunnelState?
+ private external fun getVersionInfo(daemonInterfaceAddress: Long): AppVersionInfo?
+ private external fun reconnect(daemonInterfaceAddress: Long)
+ private external fun clearAccountHistory(daemonInterfaceAddress: Long)
+ private external fun loginAccount(
+ daemonInterfaceAddress: Long,
+ accountToken: String?
+ ): LoginResult
+
+ private external fun logoutAccount(daemonInterfaceAddress: Long)
+ private external fun listDevices(
+ daemonInterfaceAddress: Long,
+ accountToken: String?
+ ): List<Device>?
+
+ private external fun getDevice(daemonInterfaceAddress: Long): DeviceState
+ private external fun updateDevice(daemonInterfaceAddress: Long)
+ private external fun removeDevice(
+ daemonInterfaceAddress: Long,
+ accountToken: String?,
+ deviceId: String
+ ): RemoveDeviceResult
+
+ private external fun setAllowLan(daemonInterfaceAddress: Long, allowLan: Boolean)
+ private external fun setAutoConnect(daemonInterfaceAddress: Long, alwaysOn: Boolean)
+ private external fun setDnsOptions(daemonInterfaceAddress: Long, dnsOptions: DnsOptions)
+ private external fun setWireguardMtu(daemonInterfaceAddress: Long, wireguardMtu: Int?)
+ private external fun shutdown(daemonInterfaceAddress: Long)
+ private external fun submitVoucher(
+ daemonInterfaceAddress: Long,
+ voucher: String
+ ): VoucherSubmissionResult
+
+ private external fun updateRelaySettings(
+ daemonInterfaceAddress: Long,
+ update: RelaySettingsUpdate
+ )
+
+ private external fun setObfuscationSettings(
+ daemonInterfaceAddress: Long,
+ settings: ObfuscationSettings?
+ )
+
+ private external fun setQuantumResistantTunnel(
+ daemonInterfaceAddress: Long,
+ quantumResistant: QuantumResistantState
+ )
+
+ private fun notifyAppVersionInfoEvent(appVersionInfo: AppVersionInfo) {
+ onAppVersionInfoChange?.invoke(appVersionInfo)
+ }
+
+ private fun notifyRelayListEvent(relayList: RelayList) {
+ onRelayListChange?.invoke(relayList)
+ }
+
+ private fun notifySettingsEvent(settings: Settings) {
+ onSettingsChange.notify(settings)
+ }
+
+ private fun notifyTunnelStateEvent(event: TunnelState) {
+ onTunnelStateChange.notify(event)
+ }
+
+ private fun notifyDaemonStopped() {
+ onDaemonStopped?.invoke()
+ }
+
+ private fun notifyDeviceEvent(event: DeviceEvent) {
+ _deviceStateUpdates.tryEmit(event.newState)
+ }
+
+ private fun notifyRemoveDeviceEvent(event: RemoveDeviceEvent) {
+ _deviceListUpdates.tryEmit(DeviceListEvent.Available(event.accountToken, event.newDevices))
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt
new file mode 100644
index 0000000000..9024eaad18
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/MullvadVpnService.kt
@@ -0,0 +1,278 @@
+package net.mullvad.mullvadvpn.service
+
+import android.annotation.SuppressLint
+import android.app.KeyguardManager
+import android.content.Context
+import android.content.Intent
+import android.net.VpnService
+import android.os.IBinder
+import android.os.Looper
+import android.util.Log
+import kotlin.properties.Delegates.observable
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import net.mullvad.mullvadvpn.lib.common.constant.KEY_CONNECT_ACTION
+import net.mullvad.mullvadvpn.lib.common.constant.KEY_DISCONNECT_ACTION
+import net.mullvad.mullvadvpn.lib.common.constant.KEY_QUIT_ACTION
+import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS
+import net.mullvad.mullvadvpn.lib.common.constant.MULLVAD_PACKAGE_NAME
+import net.mullvad.mullvadvpn.lib.endpoint.ApiEndpointConfiguration
+import net.mullvad.mullvadvpn.lib.endpoint.DefaultApiEndpointConfiguration
+import net.mullvad.mullvadvpn.lib.endpoint.getApiEndpointConfigurationExtras
+import net.mullvad.mullvadvpn.model.Settings
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.service.di.vpnServiceModule
+import net.mullvad.mullvadvpn.service.endpoint.ServiceEndpoint
+import net.mullvad.mullvadvpn.service.notifications.AccountExpiryNotification
+import net.mullvad.talpid.TalpidVpnService
+import org.koin.core.context.loadKoinModules
+
+class MullvadVpnService : TalpidVpnService() {
+ companion object {
+ private val TAG = "mullvad"
+
+ init {
+ System.loadLibrary("mullvad_jni")
+ }
+ }
+
+ private enum class PendingAction {
+ Connect,
+ Disconnect,
+ }
+
+ private enum class State {
+ Running,
+ Stopping,
+ Stopped,
+ }
+
+ private val connectionProxy
+ get() = endpoint.connectionProxy
+
+ private var state = State.Running
+
+ private var setUpDaemonJob: Job? = null
+
+ private lateinit var accountExpiryNotification: AccountExpiryNotification
+ private lateinit var daemonInstance: DaemonInstance
+ private lateinit var endpoint: ServiceEndpoint
+ private lateinit var keyguardManager: KeyguardManager
+ private lateinit var notificationManager: ForegroundNotificationManager
+
+ private var pendingAction by
+ observable<PendingAction?>(null) { _, _, _ ->
+ endpoint.settingsListener.settings?.let { settings -> handlePendingAction(settings) }
+ }
+
+ private var apiEndpointConfiguration: ApiEndpointConfiguration =
+ DefaultApiEndpointConfiguration()
+
+ // Suppressing since the tunnel state pref should be writted immediately.
+ @SuppressLint("ApplySharedPref")
+ override fun onCreate() {
+ super.onCreate()
+ Log.d(TAG, "Initializing service")
+
+ loadKoinModules(vpnServiceModule)
+
+ daemonInstance = DaemonInstance(this)
+ keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+
+ endpoint =
+ ServiceEndpoint(
+ Looper.getMainLooper(),
+ daemonInstance.intermittentDaemon,
+ connectivityListener,
+ this
+ )
+
+ endpoint.splitTunneling.onChange.subscribe(this@MullvadVpnService) { excludedApps ->
+ disallowedApps = excludedApps
+ markTunAsStale()
+ connectionProxy.reconnect()
+ }
+
+ notificationManager =
+ ForegroundNotificationManager(this, connectionProxy, daemonInstance.intermittentDaemon)
+
+ accountExpiryNotification =
+ AccountExpiryNotification(
+ this,
+ daemonInstance.intermittentDaemon,
+ endpoint.accountCache
+ )
+
+ // Remove any leftover tunnel state persistence data
+ getSharedPreferences("tunnel_state", MODE_PRIVATE).edit().clear().commit()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ Log.d(TAG, "Starting service")
+
+ if (BuildConfig.DEBUG) {
+ intent?.getApiEndpointConfigurationExtras()?.let { apiEndpointConfiguration = it }
+ }
+
+ daemonInstance.apply {
+ intermittentDaemon.registerListener(this@MullvadVpnService) { daemon ->
+ handleDaemonInstance(daemon)
+ }
+
+ start(apiEndpointConfiguration)
+ }
+
+ val startResult = super.onStartCommand(intent, flags, startId)
+ var quitCommand = false
+
+ // Always promote to foreground if connect/disconnect actions are provided to mitigate cases
+ // where the service would potentially otherwise be too slow running `startForeground`.
+ if (intent?.action == KEY_CONNECT_ACTION || intent?.action == KEY_DISCONNECT_ACTION) {
+ notificationManager.showOnForeground()
+ }
+
+ notificationManager.updateNotification()
+
+ if (!keyguardManager.isDeviceLocked) {
+ val action = intent?.action
+
+ if (action == VpnService.SERVICE_INTERFACE || action == KEY_CONNECT_ACTION) {
+ pendingAction = PendingAction.Connect
+ } else if (action == KEY_DISCONNECT_ACTION) {
+ pendingAction = PendingAction.Disconnect
+ } else if (action == KEY_QUIT_ACTION && !notificationManager.onForeground) {
+ quitCommand = true
+ stop()
+ }
+ }
+
+ if (state == State.Stopping && !quitCommand) {
+ restart()
+ }
+
+ return startResult
+ }
+
+ override fun onBind(intent: Intent): IBinder {
+ Log.d(TAG, "New connection to service")
+ return super.onBind(intent) ?: endpoint.messenger.binder
+ }
+
+ override fun onRebind(intent: Intent) {
+ Log.d(TAG, "Connection to service restored")
+ if (state == State.Stopping) {
+ restart()
+ }
+ }
+
+ override fun onRevoke() {
+ pendingAction = PendingAction.Disconnect
+ }
+
+ override fun onUnbind(intent: Intent): Boolean {
+ Log.d(TAG, "Closed all connections to service")
+
+ if (state != State.Running) {
+ stop()
+ }
+
+ return true
+ }
+
+ override fun onDestroy() {
+ Log.d(TAG, "Service has stopped")
+ state = State.Stopped
+ accountExpiryNotification.onDestroy()
+ notificationManager.onDestroy()
+ daemonInstance.onDestroy()
+ super.onDestroy()
+ }
+
+ override fun onTaskRemoved(rootIntent: Intent?) {
+ connectionProxy.onStateChange.latestEvent.let { tunnelState ->
+ Log.d(TAG, "Task removed (tunnelState=$tunnelState)")
+ if (tunnelState == TunnelState.Disconnected) {
+ notificationManager.cancelNotification()
+ stop()
+ }
+ }
+ }
+
+ private fun handleDaemonInstance(daemon: MullvadDaemon?) {
+ setUpDaemonJob?.cancel()
+
+ if (daemon != null) {
+ setUpDaemonJob = setUpDaemon(daemon)
+ } else {
+ Log.d(TAG, "Daemon has stopped")
+
+ if (state == State.Running) {
+ restart()
+ }
+ }
+ }
+
+ private fun setUpDaemon(daemon: MullvadDaemon) =
+ GlobalScope.launch(Dispatchers.Main) {
+ if (state != State.Stopped) {
+ val settings = daemon.getSettings()
+
+ if (settings != null) {
+ handlePendingAction(settings)
+ } else {
+ restart()
+ }
+ }
+ }
+
+ private fun stop() {
+ Log.d(TAG, "Stopping service")
+ state = State.Stopping
+ daemonInstance.stop()
+ stopSelf()
+ }
+
+ private fun restart() {
+ if (state != State.Stopped) {
+ Log.d(TAG, "Restarting service")
+
+ state = State.Running
+
+ daemonInstance.apply {
+ stop()
+ start(apiEndpointConfiguration)
+ }
+ } else {
+ Log.d(TAG, "Ignoring restart because onDestroy has executed")
+ }
+ }
+
+ private fun handlePendingAction(settings: Settings) {
+ when (pendingAction) {
+ PendingAction.Connect -> {
+ if (settings != null) {
+ connectionProxy.connect()
+ } else {
+ openUi()
+ }
+ }
+ PendingAction.Disconnect -> connectionProxy.disconnect()
+ null -> return
+ }
+
+ pendingAction = null
+ }
+
+ private fun openUi() {
+ val intent =
+ Intent().apply {
+ setClassName(MULLVAD_PACKAGE_NAME, MAIN_ACTIVITY_CLASS)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ }
+
+ startActivity(intent)
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt
new file mode 100644
index 0000000000..0a7d3dec39
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/di/VpnServiceModule.kt
@@ -0,0 +1,7 @@
+package net.mullvad.mullvadvpn.service.di
+
+import androidx.core.app.NotificationManagerCompat
+import org.koin.android.ext.koin.androidContext
+import org.koin.dsl.module
+
+val vpnServiceModule = module { single { NotificationManagerCompat.from(androidContext()) } }
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt
new file mode 100644
index 0000000000..ad8b96f9a5
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AccountCache.kt
@@ -0,0 +1,180 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.channels.trySendBlocking
+import net.mullvad.mullvadvpn.lib.common.util.JobTracker
+import net.mullvad.mullvadvpn.lib.common.util.parseAsDateTime
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.model.AccountCreationResult
+import net.mullvad.mullvadvpn.model.AccountExpiry
+import net.mullvad.mullvadvpn.model.AccountHistory
+import net.mullvad.mullvadvpn.model.GetAccountDataResult
+import net.mullvad.talpid.util.EventNotifier
+
+class AccountCache(private val endpoint: ServiceEndpoint) {
+ companion object {
+ private sealed class Command {
+ object CreateAccount : Command()
+ data class Login(val account: String) : Command()
+ object Logout : Command()
+ }
+ }
+
+ private val commandChannel = spawnActor()
+
+ private val daemon
+ get() = endpoint.intermittentDaemon
+
+ val onAccountExpiryChange = EventNotifier<AccountExpiry>(AccountExpiry.Missing)
+ val onAccountHistoryChange = EventNotifier<AccountHistory>(AccountHistory.Missing)
+
+ private val jobTracker = JobTracker()
+
+ private var accountExpiry by onAccountExpiryChange.notifiable()
+ private var accountHistory by onAccountHistoryChange.notifiable()
+
+ private var cachedAccountToken: String? = null
+ private var cachedCreatedAccountToken: String? = null
+
+ val isNewAccount: Boolean
+ get() = cachedAccountToken == cachedCreatedAccountToken
+
+ init {
+ jobTracker.newBackgroundJob("autoFetchAccountExpiry") {
+ daemon.await().deviceStateUpdates.collect { deviceState ->
+ accountExpiry =
+ deviceState
+ .token()
+ .also { cachedAccountToken = it }
+ ?.let { fetchAccountExpiry(it) }
+ ?: AccountExpiry.Missing
+ }
+ }
+
+ onAccountHistoryChange.subscribe(this) { history ->
+ endpoint.sendEvent(Event.AccountHistoryEvent(history))
+ }
+
+ onAccountExpiryChange.subscribe(this) { endpoint.sendEvent(Event.AccountExpiryEvent(it)) }
+
+ endpoint.dispatcher.apply {
+ registerHandler(Request.CreateAccount::class) { _ ->
+ commandChannel.trySendBlocking(Command.CreateAccount)
+ }
+
+ registerHandler(Request.Login::class) { request ->
+ request.account?.let { account ->
+ commandChannel.trySendBlocking(Command.Login(account))
+ }
+ }
+
+ registerHandler(Request.Logout::class) { _ ->
+ commandChannel.trySendBlocking(Command.Logout)
+ }
+
+ registerHandler(Request.FetchAccountExpiry::class) { _ ->
+ jobTracker.newBackgroundJob("fetchAccountExpiry") {
+ accountExpiry =
+ cachedAccountToken?.let { fetchAccountExpiry(it) } ?: AccountExpiry.Missing
+ }
+ }
+
+ registerHandler(Request.FetchAccountHistory::class) { _ ->
+ jobTracker.newBackgroundJob("fetchAccountHistory") {
+ accountHistory = fetchAccountHistory()
+ }
+ }
+
+ registerHandler(Request.ClearAccountHistory::class) { _ ->
+ jobTracker.newBackgroundJob("clearAccountHistory") { clearAccountHistory() }
+ }
+ }
+ }
+
+ fun onDestroy() {
+ jobTracker.cancelAllJobs()
+
+ onAccountExpiryChange.unsubscribeAll()
+ onAccountHistoryChange.unsubscribeAll()
+
+ commandChannel.close()
+ }
+
+ private fun spawnActor() =
+ GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) {
+ try {
+ for (command in channel) {
+ when (command) {
+ is Command.CreateAccount -> doCreateAccount()
+ is Command.Login -> doLogin(command.account)
+ is Command.Logout -> doLogout()
+ }
+ }
+ } catch (exception: ClosedReceiveChannelException) {
+ // Command channel was closed, stop the actor
+ }
+ }
+
+ private suspend fun clearAccountHistory() {
+ daemon.await().clearAccountHistory()
+ accountHistory = fetchAccountHistory()
+ }
+
+ private suspend fun doCreateAccount() {
+ daemon
+ .await()
+ .createNewAccount()
+ .also { newAccountToken -> cachedCreatedAccountToken = newAccountToken }
+ .let { newAccountToken ->
+ if (newAccountToken != null) {
+ AccountCreationResult.Success(newAccountToken)
+ } else {
+ AccountCreationResult.Failure
+ }
+ }
+ .also { result -> endpoint.sendEvent(Event.AccountCreationEvent(result)) }
+ }
+
+ private suspend fun doLogin(account: String) {
+ daemon.await().loginAccount(account).also { result ->
+ endpoint.sendEvent(Event.LoginEvent(result))
+ }
+ }
+
+ private suspend fun doLogout() {
+ daemon.await().logoutAccount()
+ accountHistory = fetchAccountHistory()
+ }
+
+ private suspend fun fetchAccountHistory(): AccountHistory {
+ return daemon.await().getAccountHistory().let { history ->
+ if (history != null) {
+ AccountHistory.Available(history)
+ } else {
+ AccountHistory.Missing
+ }
+ }
+ }
+
+ private suspend fun fetchAccountExpiry(accountToken: String): AccountExpiry {
+ return fetchAccountData(accountToken).let { result ->
+ if (result is GetAccountDataResult.Ok) {
+ result.accountData.expiry.parseAsDateTime()?.let { parsedDateTime ->
+ AccountExpiry.Available(parsedDateTime)
+ }
+ ?: AccountExpiry.Missing
+ } else {
+ AccountExpiry.Missing
+ }
+ }
+ }
+
+ private suspend fun fetchAccountData(accountToken: String): GetAccountDataResult {
+ return daemon.await().getAccountData(accountToken)
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AppVersionInfoCache.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AppVersionInfoCache.kt
new file mode 100644
index 0000000000..767ac3e251
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AppVersionInfoCache.kt
@@ -0,0 +1,56 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlin.properties.Delegates.observable
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.model.AppVersionInfo
+import net.mullvad.mullvadvpn.service.MullvadDaemon
+
+class AppVersionInfoCache(endpoint: ServiceEndpoint) {
+ private val daemon = endpoint.intermittentDaemon
+
+ var appVersionInfo by
+ observable<AppVersionInfo?>(null) { _, _, info ->
+ endpoint.sendEvent(Event.AppVersionInfo(info))
+ }
+ private set
+
+ var currentVersion by
+ observable<String?>(null) { _, _, version ->
+ endpoint.sendEvent(Event.CurrentVersion(version))
+ }
+ private set
+
+ init {
+ daemon.registerListener(this) { newDaemon ->
+ newDaemon?.let { daemon ->
+ initializeCurrentVersion(daemon)
+ registerVersionInfoListener(daemon)
+ fetchInitialVersionInfo(daemon)
+ }
+ }
+ }
+
+ fun onDestroy() {
+ daemon.unregisterListener(this)
+ }
+
+ private fun initializeCurrentVersion(daemon: MullvadDaemon) {
+ if (currentVersion == null) {
+ currentVersion = daemon.getCurrentVersion()
+ }
+ }
+
+ private fun registerVersionInfoListener(daemon: MullvadDaemon) {
+ daemon.onAppVersionInfoChange = { newAppVersionInfo ->
+ synchronized(this@AppVersionInfoCache) { appVersionInfo = newAppVersionInfo }
+ }
+ }
+
+ private fun fetchInitialVersionInfo(daemon: MullvadDaemon) {
+ synchronized(this@AppVersionInfoCache) {
+ if (appVersionInfo == null) {
+ appVersionInfo = daemon.getVersionInfo()
+ }
+ }
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AuthTokenCache.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AuthTokenCache.kt
new file mode 100644
index 0000000000..6506c0469d
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/AuthTokenCache.kt
@@ -0,0 +1,49 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlin.properties.Delegates.observable
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.channels.trySendBlocking
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.Request
+
+class AuthTokenCache(endpoint: ServiceEndpoint) {
+ companion object {
+ private enum class Command {
+ Fetch
+ }
+ }
+
+ private val daemon = endpoint.intermittentDaemon
+ private val requestQueue = spawnActor()
+
+ var authToken by
+ observable<String?>(null) { _, _, token -> endpoint.sendEvent(Event.AuthToken(token)) }
+ private set
+
+ init {
+ endpoint.dispatcher.registerHandler(Request.FetchAuthToken::class) { _ ->
+ requestQueue.trySendBlocking(Command.Fetch)
+ }
+ }
+
+ fun onDestroy() {
+ requestQueue.close()
+ }
+
+ private fun spawnActor() =
+ GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) {
+ try {
+ for (command in channel) {
+ when (command) {
+ Command.Fetch -> authToken = daemon.await().getWwwAuthToken()
+ }
+ }
+ } catch (exception: ClosedReceiveChannelException) {
+ // Closed sender, so stop the actor
+ }
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ConnectionProxy.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ConnectionProxy.kt
new file mode 100644
index 0000000000..a2c97a05bd
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ConnectionProxy.kt
@@ -0,0 +1,85 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.channels.trySendBlocking
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.talpid.util.EventNotifier
+
+class ConnectionProxy(val vpnPermission: VpnPermission, endpoint: ServiceEndpoint) {
+ private enum class Command {
+ CONNECT,
+ RECONNECT,
+ DISCONNECT,
+ }
+
+ private val commandChannel = spawnActor()
+ private val daemon = endpoint.intermittentDaemon
+ private val initialState = TunnelState.Disconnected
+
+ var onStateChange = EventNotifier<TunnelState>(initialState)
+
+ var state by onStateChange.notifiable()
+ private set
+
+ init {
+ daemon.registerListener(this) { newDaemon ->
+ newDaemon?.onTunnelStateChange?.subscribe(this@ConnectionProxy) { newState ->
+ state = newState
+ }
+ }
+
+ onStateChange.subscribe(this) { tunnelState ->
+ endpoint.sendEvent(Event.TunnelStateChange(tunnelState))
+ }
+
+ endpoint.dispatcher.apply {
+ registerHandler(Request.Connect::class) { _ -> connect() }
+ registerHandler(Request.Reconnect::class) { _ -> reconnect() }
+ registerHandler(Request.Disconnect::class) { _ -> disconnect() }
+ }
+ }
+
+ fun connect() {
+ commandChannel.trySendBlocking(Command.CONNECT)
+ }
+
+ fun reconnect() {
+ commandChannel.trySendBlocking(Command.RECONNECT)
+ }
+
+ fun disconnect() {
+ commandChannel.trySendBlocking(Command.DISCONNECT)
+ }
+
+ fun onDestroy() {
+ commandChannel.close()
+ onStateChange.unsubscribeAll()
+ daemon.unregisterListener(this)
+ }
+
+ private fun spawnActor() =
+ GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) {
+ try {
+ while (true) {
+ val command = channel.receive()
+
+ when (command) {
+ Command.CONNECT -> {
+ vpnPermission.request()
+ daemon.await().connect()
+ }
+ Command.RECONNECT -> daemon.await().reconnect()
+ Command.DISCONNECT -> daemon.await().disconnect()
+ }
+ }
+ } catch (exception: ClosedReceiveChannelException) {
+ // Closed sender, so stop the actor
+ }
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomDns.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomDns.kt
new file mode 100644
index 0000000000..fe8f55a66d
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/CustomDns.kt
@@ -0,0 +1,133 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import java.net.InetAddress
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.channels.trySendBlocking
+import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.model.CustomDnsOptions
+import net.mullvad.mullvadvpn.model.DefaultDnsOptions
+import net.mullvad.mullvadvpn.model.DnsOptions
+import net.mullvad.mullvadvpn.model.DnsState
+
+class CustomDns(private val endpoint: ServiceEndpoint) {
+ private sealed class Command {
+ @Deprecated("Use SetDnsOptions") class AddDnsServer(val server: InetAddress) : Command()
+ @Deprecated("Use SetDnsOptions") class RemoveDnsServer(val server: InetAddress) : Command()
+ @Deprecated("Use SetDnsOptions")
+ class ReplaceDnsServer(val oldServer: InetAddress, val newServer: InetAddress) : Command()
+ @Deprecated("Use SetDnsOptions") class SetEnabled(val enabled: Boolean) : Command()
+
+ class SetDnsOptions(val dnsOptions: DnsOptions) : Command()
+ }
+
+ private val commandChannel = spawnActor()
+ private val dnsServers = ArrayList<InetAddress>()
+
+ private val daemon
+ get() = endpoint.intermittentDaemon
+
+ private var enabled = false
+
+ init {
+ endpoint.settingsListener.dnsOptionsNotifier.subscribe(this) { maybeDnsOptions ->
+ maybeDnsOptions?.let { dnsOptions ->
+ enabled = dnsOptions.state == DnsState.Custom
+ dnsServers.clear()
+ dnsServers.addAll(dnsOptions.customOptions.addresses)
+ }
+ }
+
+ endpoint.dispatcher.apply {
+ registerHandler(Request.AddCustomDnsServer::class) { request ->
+ commandChannel.trySendBlocking(Command.AddDnsServer(request.address))
+ }
+
+ registerHandler(Request.RemoveCustomDnsServer::class) { request ->
+ commandChannel.trySendBlocking(Command.RemoveDnsServer(request.address))
+ }
+
+ registerHandler(Request.ReplaceCustomDnsServer::class) { request ->
+ commandChannel.trySendBlocking(
+ Command.ReplaceDnsServer(request.oldAddress, request.newAddress)
+ )
+ }
+
+ registerHandler(Request.SetEnableCustomDns::class) { request ->
+ commandChannel.trySendBlocking(Command.SetEnabled(request.enable))
+ }
+
+ registerHandler(Request.SetDnsOptions::class) { request ->
+ commandChannel.trySendBlocking(Command.SetDnsOptions(request.dnsOptions))
+ }
+ }
+ }
+
+ fun onDestroy() {
+ endpoint.settingsListener.dnsOptionsNotifier.unsubscribe(this)
+ commandChannel.close()
+ }
+
+ private fun spawnActor() =
+ GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) {
+ try {
+ while (true) {
+ val command = channel.receive()
+
+ when (command) {
+ is Command.AddDnsServer -> doAddDnsServer(command.server)
+ is Command.RemoveDnsServer -> doRemoveDnsServer(command.server)
+ is Command.ReplaceDnsServer -> {
+ doReplaceDnsServer(command.oldServer, command.newServer)
+ }
+ is Command.SetEnabled -> changeDnsOptions(command.enabled)
+ is Command.SetDnsOptions -> setDnsOptions(command.dnsOptions)
+ }
+ }
+ } catch (exception: ClosedReceiveChannelException) {
+ // Closed sender, so stop the actor
+ }
+ }
+
+ private suspend fun doAddDnsServer(server: InetAddress) {
+ if (!dnsServers.contains(server)) {
+ dnsServers.add(server)
+ changeDnsOptions(enabled)
+ }
+ }
+
+ private suspend fun doReplaceDnsServer(oldServer: InetAddress, newServer: InetAddress) {
+ if (oldServer != newServer && !dnsServers.contains(newServer)) {
+ val index = dnsServers.indexOf(oldServer)
+
+ if (index >= 0) {
+ dnsServers.removeAt(index)
+ dnsServers.add(index, newServer)
+ changeDnsOptions(enabled)
+ }
+ }
+ }
+
+ private suspend fun doRemoveDnsServer(server: InetAddress) {
+ if (dnsServers.remove(server)) {
+ changeDnsOptions(enabled)
+ }
+ }
+
+ private suspend fun changeDnsOptions(enable: Boolean) {
+ val options =
+ DnsOptions(
+ state = if (enable) DnsState.Custom else DnsState.Default,
+ customOptions = CustomDnsOptions(dnsServers),
+ defaultOptions = DefaultDnsOptions()
+ )
+ daemon.await().setDnsOptions(options)
+ }
+
+ private suspend fun setDnsOptions(dnsOptions: DnsOptions) {
+ daemon.await().setDnsOptions(dnsOptions)
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt
new file mode 100644
index 0000000000..db264ed1fe
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/DaemonDeviceDataSource.kt
@@ -0,0 +1,62 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlinx.coroutines.flow.collect
+import net.mullvad.mullvadvpn.lib.common.util.JobTracker
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.service.MullvadDaemon
+
+class DaemonDeviceDataSource(val endpoint: ServiceEndpoint) {
+ private val tracker = JobTracker()
+
+ init {
+ endpoint.intermittentDaemon.registerListener(this) { daemon ->
+ if (daemon != null) {
+ launchDeviceEndpointJobs(daemon)
+ } else {
+ tracker.cancelAllJobs()
+ }
+ }
+ }
+
+ private fun launchDeviceEndpointJobs(daemon: MullvadDaemon) {
+ tracker.newBackgroundJob("propagateDeviceUpdatesJob") {
+ daemon.deviceStateUpdates.collect { newState ->
+ endpoint.sendEvent(Event.DeviceStateEvent(newState))
+ }
+ }
+
+ tracker.newBackgroundJob("propagateDeviceListUpdatesJob") {
+ daemon.deviceListUpdates.collect { newState ->
+ endpoint.sendEvent(Event.DeviceListUpdate(newState))
+ }
+ }
+
+ endpoint.dispatcher.registerHandler(Request.GetDevice::class) {
+ tracker.newBackgroundJob("getDeviceJob") { daemon.getAndEmitDeviceState() }
+ }
+
+ endpoint.dispatcher.registerHandler(Request.RefreshDeviceState::class) {
+ tracker.newBackgroundJob("refreshDeviceJob") { daemon.refreshDevice() }
+ }
+
+ endpoint.dispatcher.registerHandler(Request.RemoveDevice::class) { request ->
+ tracker.newBackgroundJob("removeDeviceJob") {
+ daemon.removeDevice(request.accountToken, request.deviceId).also { result ->
+ endpoint.sendEvent(Event.DeviceRemovalEvent(request.deviceId, result))
+ }
+ }
+ }
+
+ endpoint.dispatcher.registerHandler(Request.GetDeviceList::class) { request ->
+ tracker.newBackgroundJob("getDeviceListJob") {
+ daemon.getAndEmitDeviceList(request.accountToken)
+ }
+ }
+ }
+
+ fun onDestroy() {
+ tracker.cancelAllJobs()
+ endpoint.intermittentDaemon.unregisterListener(this)
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/LocationInfoCache.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/LocationInfoCache.kt
new file mode 100644
index 0000000000..fb3a8637f6
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/LocationInfoCache.kt
@@ -0,0 +1,136 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlin.properties.Delegates.observable
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.channels.trySendBlocking
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import net.mullvad.mullvadvpn.lib.common.util.toGeographicLocationConstraint
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.model.Constraint
+import net.mullvad.mullvadvpn.model.GeoIpLocation
+import net.mullvad.mullvadvpn.model.RelaySettings
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.service.util.ExponentialBackoff
+import net.mullvad.talpid.tunnel.ActionAfterDisconnect
+
+class LocationInfoCache(private val endpoint: ServiceEndpoint) {
+ companion object {
+ private enum class RequestFetch {
+ ForRealLocation,
+ ForRelayLocation,
+ }
+ }
+
+ private val fetchRetryDelays =
+ ExponentialBackoff().apply {
+ scale = 50
+ cap = 30 /* min */ * 60 /* s */ * 1000 /* ms */
+ count = 17 // ceil(log2(cap / scale) + 1)
+ }
+
+ private val fetchRequestChannel = runFetcher()
+
+ private val daemon
+ get() = endpoint.intermittentDaemon
+
+ private var lastKnownRealLocation: GeoIpLocation? = null
+ private var selectedRelayLocation: GeoIpLocation? = null
+
+ var location: GeoIpLocation? by
+ observable(null) { _, _, newLocation -> endpoint.sendEvent(Event.NewLocation(newLocation)) }
+
+ var state by
+ observable<TunnelState>(TunnelState.Disconnected) { _, _, newState ->
+ when (newState) {
+ is TunnelState.Disconnected -> {
+ location = lastKnownRealLocation
+ fetchRequestChannel.trySendBlocking(RequestFetch.ForRealLocation)
+ }
+ is TunnelState.Connecting -> location = newState.location
+ is TunnelState.Connected -> {
+ location = newState.location
+ fetchRequestChannel.trySendBlocking(RequestFetch.ForRelayLocation)
+ }
+ is TunnelState.Disconnecting -> {
+ when (newState.actionAfterDisconnect) {
+ ActionAfterDisconnect.Nothing -> location = lastKnownRealLocation
+ ActionAfterDisconnect.Block -> location = null
+ ActionAfterDisconnect.Reconnect -> location = selectedRelayLocation
+ }
+ }
+ is TunnelState.Error -> location = null
+ }
+ }
+
+ init {
+ endpoint.connectionProxy.onStateChange.subscribe(this) { newState -> state = newState }
+
+ endpoint.connectivityListener.connectivityNotifier.subscribe(this) { isConnected ->
+ if (isConnected && state is TunnelState.Disconnected) {
+ fetchRequestChannel.trySendBlocking(RequestFetch.ForRealLocation)
+ }
+ }
+
+ endpoint.settingsListener.relaySettingsNotifier.subscribe(this, ::updateSelectedLocation)
+ }
+
+ fun onDestroy() {
+ endpoint.connectionProxy.onStateChange.unsubscribe(this)
+ endpoint.connectivityListener.connectivityNotifier.unsubscribe(this)
+ endpoint.settingsListener.relaySettingsNotifier.unsubscribe(this)
+
+ fetchRequestChannel.close()
+ }
+
+ private fun runFetcher() =
+ GlobalScope.actor<RequestFetch>(Dispatchers.Default, Channel.CONFLATED) {
+ try {
+ fetcherLoop(channel)
+ } catch (exception: ClosedReceiveChannelException) {}
+ }
+
+ private suspend fun fetcherLoop(channel: ReceiveChannel<RequestFetch>) {
+ channel
+ .receiveAsFlow()
+ .flatMapLatest(::fetchCurrentLocation)
+ .collect(::handleFetchedLocation)
+ }
+
+ private fun fetchCurrentLocation(fetchType: RequestFetch) = flow {
+ var newLocation = daemon.await().getCurrentLocation()
+
+ fetchRetryDelays.reset()
+
+ while (newLocation == null) {
+ delay(fetchRetryDelays.next())
+ newLocation = daemon.await().getCurrentLocation()
+ }
+
+ emit(Pair(newLocation, fetchType))
+ }
+
+ private suspend fun handleFetchedLocation(pairItem: Pair<GeoIpLocation, RequestFetch>) {
+ val (newLocation, fetchType) = pairItem
+
+ if (fetchType == RequestFetch.ForRealLocation) {
+ lastKnownRealLocation = newLocation
+ }
+
+ location = newLocation
+ }
+
+ private fun updateSelectedLocation(relaySettings: RelaySettings?) {
+ val settings = relaySettings as? RelaySettings.Normal
+ val constraint = settings?.relayConstraints?.location as? Constraint.Only
+
+ selectedRelayLocation = constraint?.value?.toGeographicLocationConstraint()?.location
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayListListener.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayListListener.kt
new file mode 100644
index 0000000000..1abf64907c
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/RelayListListener.kt
@@ -0,0 +1,108 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlin.properties.Delegates.observable
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.channels.trySendBlocking
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.model.Constraint
+import net.mullvad.mullvadvpn.model.GeographicLocationConstraint
+import net.mullvad.mullvadvpn.model.LocationConstraint
+import net.mullvad.mullvadvpn.model.RelayConstraintsUpdate
+import net.mullvad.mullvadvpn.model.RelayList
+import net.mullvad.mullvadvpn.model.RelaySettingsUpdate
+import net.mullvad.mullvadvpn.model.WireguardConstraints
+import net.mullvad.mullvadvpn.service.MullvadDaemon
+
+class RelayListListener(endpoint: ServiceEndpoint) {
+ companion object {
+ private enum class Command {
+ SetRelayLocation,
+ SetWireguardConstraints
+ }
+ }
+
+ private val commandChannel = spawnActor()
+ private val daemon = endpoint.intermittentDaemon
+
+ private var selectedRelayLocation by
+ observable<GeographicLocationConstraint?>(null) { _, _, _ ->
+ commandChannel.trySendBlocking(Command.SetRelayLocation)
+ }
+ private var selectedWireguardConstraints by
+ observable<WireguardConstraints?>(null) { _, _, _ ->
+ commandChannel.trySendBlocking(Command.SetWireguardConstraints)
+ }
+
+ var relayList by
+ observable<RelayList?>(null) { _, _, relays ->
+ endpoint.sendEvent(Event.NewRelayList(relays))
+ }
+ private set
+
+ init {
+ daemon.registerListener(this) { newDaemon ->
+ newDaemon?.let { daemon ->
+ setUpListener(daemon)
+ fetchInitialRelayList(daemon)
+ }
+ }
+
+ endpoint.dispatcher.registerHandler(Request.SetRelayLocation::class) { request ->
+ selectedRelayLocation = request.relayLocation
+ }
+
+ endpoint.dispatcher.registerHandler(Request.SetWireguardConstraints::class) { request ->
+ selectedWireguardConstraints = request.wireguardConstraints
+ }
+ }
+
+ fun onDestroy() {
+ commandChannel.close()
+ daemon.unregisterListener(this)
+ }
+
+ private fun setUpListener(daemon: MullvadDaemon) {
+ daemon.onRelayListChange = { relayLocations -> relayList = relayLocations }
+ }
+
+ private fun fetchInitialRelayList(daemon: MullvadDaemon) {
+ synchronized(this) {
+ if (relayList == null) {
+ relayList = daemon.getRelayLocations()
+ }
+ }
+ }
+
+ private fun spawnActor() =
+ GlobalScope.actor<Command>(Dispatchers.Default, Channel.CONFLATED) {
+ try {
+ for (command in channel) {
+ when (command) {
+ Command.SetRelayLocation,
+ Command.SetWireguardConstraints -> updateRelayConstraints()
+ }
+ }
+ } catch (exception: ClosedReceiveChannelException) {
+ // Closed sender, so stop the actor
+ }
+ }
+
+ private suspend fun updateRelayConstraints() {
+ val location: Constraint<LocationConstraint> =
+ selectedRelayLocation?.let { location ->
+ Constraint.Only(LocationConstraint.Location(location))
+ }
+ ?: Constraint.Any()
+ val wireguardConstraints: WireguardConstraints? = selectedWireguardConstraints
+
+ val update =
+ RelaySettingsUpdate.Normal(RelayConstraintsUpdate(location, wireguardConstraints))
+
+ daemon.await().updateRelaySettings(update)
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt
new file mode 100644
index 0000000000..1d6cb9f9a7
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/ServiceEndpoint.kt
@@ -0,0 +1,167 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import android.content.Context
+import android.os.Looper
+import android.os.Messenger
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.channels.trySendBlocking
+import net.mullvad.mullvadvpn.lib.common.util.Intermittent
+import net.mullvad.mullvadvpn.lib.ipc.DispatchingHandler
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.lib.ipc.extensions.trySendEvent
+import net.mullvad.mullvadvpn.service.MullvadDaemon
+import net.mullvad.mullvadvpn.service.persistence.SplitTunnelingPersistence
+import net.mullvad.talpid.ConnectivityListener
+
+const val SHOULD_LOG_DEAD_OBJECT_EXCEPTION = true
+
+class ServiceEndpoint(
+ looper: Looper,
+ internal val intermittentDaemon: Intermittent<MullvadDaemon>,
+ val connectivityListener: ConnectivityListener,
+ context: Context
+) {
+ companion object {
+ sealed class Command {
+ data class RegisterListener(val listener: Messenger) : Command()
+ data class UnregisterListener(val listenerId: Int) : Command()
+ }
+ }
+
+ private val listeners = mutableMapOf<Int, Messenger>()
+ private val commands: SendChannel<Command> = startRegistrator()
+
+ internal val dispatcher = DispatchingHandler(looper) { message -> Request.fromMessage(message) }
+
+ private var listenerIdCounter = 0
+
+ val messenger = Messenger(dispatcher)
+
+ val vpnPermission = VpnPermission(context, this)
+
+ val connectionProxy = ConnectionProxy(vpnPermission, this)
+ val settingsListener = SettingsListener(this)
+
+ val accountCache = AccountCache(this)
+ val appVersionInfoCache = AppVersionInfoCache(this)
+ val authTokenCache = AuthTokenCache(this)
+ val customDns = CustomDns(this)
+ val locationInfoCache = LocationInfoCache(this)
+ val relayListListener = RelayListListener(this)
+ val splitTunneling = SplitTunneling(SplitTunnelingPersistence(context), this)
+ val voucherRedeemer = VoucherRedeemer(this)
+
+ private val deviceRepositoryBackend = DaemonDeviceDataSource(this)
+
+ init {
+ dispatcher.apply {
+ registerHandler(Request.RegisterListener::class) { request ->
+ commands.trySendBlocking(Command.RegisterListener(request.listener))
+ }
+
+ registerHandler(Request.UnregisterListener::class) { request ->
+ commands.trySendBlocking(Command.UnregisterListener(request.listenerId))
+ }
+ }
+ }
+
+ fun onDestroy() {
+ dispatcher.onDestroy()
+ commands.close()
+
+ accountCache.onDestroy()
+ appVersionInfoCache.onDestroy()
+ authTokenCache.onDestroy()
+ connectionProxy.onDestroy()
+ customDns.onDestroy()
+ deviceRepositoryBackend.onDestroy()
+ locationInfoCache.onDestroy()
+ relayListListener.onDestroy()
+ settingsListener.onDestroy()
+ splitTunneling.onDestroy()
+ voucherRedeemer.onDestroy()
+ }
+
+ internal fun sendEvent(event: Event) {
+ synchronized(this) {
+ val deadListeners = mutableSetOf<Int>()
+
+ for ((id, listener) in listeners) {
+ if (!listener.trySendEvent(event, SHOULD_LOG_DEAD_OBJECT_EXCEPTION)) {
+ deadListeners.add(id)
+ }
+ }
+ deadListeners.forEach { listeners.remove(it) }
+ }
+ }
+
+ private fun startRegistrator() =
+ GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) {
+ try {
+ for (command in channel) {
+ when (command) {
+ is Command.RegisterListener -> {
+ intermittentDaemon.await()
+
+ registerListener(command.listener)
+ }
+ is Command.UnregisterListener -> unregisterListener(command.listenerId)
+ }
+ }
+ } catch (exception: ClosedReceiveChannelException) {
+ // Registration queue closed; stop registrator
+ }
+ }
+
+ private fun registerListener(listener: Messenger) {
+ synchronized(this) {
+ val listenerId = newListenerId()
+
+ listeners.put(listenerId, listener)
+
+ val initialEvents =
+ mutableListOf(
+ Event.TunnelStateChange(connectionProxy.state),
+ Event.AccountHistoryEvent(accountCache.onAccountHistoryChange.latestEvent),
+ Event.SettingsUpdate(settingsListener.settings),
+ Event.NewLocation(locationInfoCache.location),
+ Event.SplitTunnelingUpdate(splitTunneling.onChange.latestEvent),
+ Event.CurrentVersion(appVersionInfoCache.currentVersion),
+ Event.AppVersionInfo(appVersionInfoCache.appVersionInfo),
+ Event.NewRelayList(relayListListener.relayList),
+ Event.AuthToken(authTokenCache.authToken),
+ Event.ListenerReady(messenger, listenerId)
+ )
+
+ if (vpnPermission.waitingForResponse) {
+ initialEvents.add(Event.VpnPermissionRequest)
+ }
+
+ val didSuccessfullySendAllMessages =
+ initialEvents.all { event ->
+ listener.trySendEvent(event, SHOULD_LOG_DEAD_OBJECT_EXCEPTION)
+ }
+ if (didSuccessfullySendAllMessages.not()) {
+ listeners.remove(listenerId)
+ }
+ }
+ }
+
+ private fun unregisterListener(listenerId: Int) {
+ synchronized(this) { listeners.remove(listenerId) }
+ }
+
+ private fun newListenerId(): Int {
+ val listenerId = listenerIdCounter
+
+ listenerIdCounter += 1
+
+ return listenerId
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt
new file mode 100644
index 0000000000..2863594cb9
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SettingsListener.kt
@@ -0,0 +1,139 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.channels.trySendBlocking
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.model.*
+import net.mullvad.mullvadvpn.service.MullvadDaemon
+import net.mullvad.talpid.util.EventNotifier
+
+class SettingsListener(endpoint: ServiceEndpoint) {
+ private sealed class Command {
+ class SetAllowLan(val allow: Boolean) : Command()
+ class SetAutoConnect(val autoConnect: Boolean) : Command()
+ class SetWireGuardMtu(val mtu: Int?) : Command()
+ class SetObfuscationSettings(val settings: ObfuscationSettings?) : Command()
+ class SetQuantumResistant(val quantumResistant: QuantumResistantState) : Command()
+ }
+
+ private val commandChannel = spawnActor()
+ private val daemon = endpoint.intermittentDaemon
+
+ val dnsOptionsNotifier = EventNotifier<DnsOptions?>(null)
+ val relaySettingsNotifier = EventNotifier<RelaySettings?>(null)
+ val obfuscationSettingsNotifier = EventNotifier<ObfuscationSettings?>(null)
+ val settingsNotifier = EventNotifier<Settings?>(null)
+
+ var settings by settingsNotifier.notifiable()
+ private set
+
+ init {
+ daemon.registerListener(this) { newDaemon ->
+ if (newDaemon != null) {
+ registerListener(newDaemon)
+ fetchInitialSettings(newDaemon)
+ }
+ }
+
+ settingsNotifier.subscribe(this) { settings ->
+ endpoint.sendEvent(Event.SettingsUpdate(settings))
+ }
+
+ endpoint.dispatcher.apply {
+ registerHandler(Request.SetAllowLan::class) { request ->
+ commandChannel.trySendBlocking(Command.SetAllowLan(request.allow))
+ }
+
+ registerHandler(Request.SetAutoConnect::class) { request ->
+ commandChannel.trySendBlocking(Command.SetAutoConnect(request.autoConnect))
+ }
+
+ registerHandler(Request.SetWireGuardMtu::class) { request ->
+ commandChannel.trySendBlocking(Command.SetWireGuardMtu(request.mtu))
+ }
+
+ registerHandler(Request.SetObfuscationSettings::class) { request ->
+ commandChannel.trySendBlocking(Command.SetObfuscationSettings(request.settings))
+ }
+
+ registerHandler(Request.SetWireGuardQuantumResistant::class) { request ->
+ commandChannel.trySendBlocking(
+ Command.SetQuantumResistant(request.quantumResistant)
+ )
+ }
+ }
+ }
+
+ fun onDestroy() {
+ commandChannel.close()
+ daemon.unregisterListener(this)
+
+ dnsOptionsNotifier.unsubscribeAll()
+ relaySettingsNotifier.unsubscribeAll()
+ obfuscationSettingsNotifier.unsubscribeAll()
+ settingsNotifier.unsubscribeAll()
+ }
+
+ fun subscribe(id: Any, listener: (Settings) -> Unit) {
+ settingsNotifier.subscribe(id) { maybeSettings ->
+ maybeSettings?.let { settings -> listener(settings) }
+ }
+ }
+
+ fun unsubscribe(id: Any) {
+ settingsNotifier.unsubscribe(id)
+ }
+
+ private fun registerListener(daemon: MullvadDaemon) {
+ daemon.onSettingsChange.subscribe(this, ::handleNewSettings)
+ }
+
+ private fun fetchInitialSettings(daemon: MullvadDaemon) {
+ synchronized(this) { handleNewSettings(daemon.getSettings()) }
+ }
+
+ private fun handleNewSettings(newSettings: Settings?) {
+ if (newSettings != null) {
+ synchronized(this) {
+ if (settings?.tunnelOptions?.dnsOptions != newSettings.tunnelOptions.dnsOptions) {
+ dnsOptionsNotifier.notify(newSettings.tunnelOptions.dnsOptions)
+ }
+
+ if (settings?.relaySettings != newSettings.relaySettings) {
+ relaySettingsNotifier.notify(newSettings.relaySettings)
+ }
+
+ if (settings?.obfuscationSettings != newSettings.obfuscationSettings) {
+ obfuscationSettingsNotifier.notify(newSettings.obfuscationSettings)
+ }
+
+ settings = newSettings
+ }
+ }
+ }
+
+ private fun spawnActor() =
+ GlobalScope.actor<Command>(Dispatchers.Default, Channel.UNLIMITED) {
+ try {
+ for (command in channel) {
+ when (command) {
+ is Command.SetAllowLan -> daemon.await().setAllowLan(command.allow)
+ is Command.SetAutoConnect ->
+ daemon.await().setAutoConnect(command.autoConnect)
+ is Command.SetWireGuardMtu -> daemon.await().setWireguardMtu(command.mtu)
+ is Command.SetObfuscationSettings ->
+ daemon.await().setObfuscationSettings(command.settings)
+ is Command.SetQuantumResistant ->
+ daemon.await().setQuantumResistant(command.quantumResistant)
+ }
+ }
+ } catch (exception: ClosedReceiveChannelException) {
+ // Closed sender, so stop the actor
+ }
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt
new file mode 100644
index 0000000000..a683b1e4bf
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/SplitTunneling.kt
@@ -0,0 +1,59 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlin.properties.Delegates.observable
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.Request
+import net.mullvad.mullvadvpn.service.persistence.SplitTunnelingPersistence
+import net.mullvad.talpid.util.EventNotifier
+
+class SplitTunneling(persistence: SplitTunnelingPersistence, endpoint: ServiceEndpoint) {
+ private val excludedApps = persistence.excludedApps.toMutableSet()
+
+ private var enabled by
+ observable(persistence.enabled) { _, wasEnabled, isEnabled ->
+ if (wasEnabled != isEnabled) {
+ persistence.enabled = isEnabled
+ update()
+ }
+ }
+
+ val onChange = EventNotifier<List<String>?>(excludedApps.toList())
+
+ init {
+ onChange.subscribe(this) { excludedApps ->
+ endpoint.sendEvent(Event.SplitTunnelingUpdate(excludedApps))
+ }
+
+ endpoint.dispatcher.apply {
+ registerHandler(Request.IncludeApp::class) { request ->
+ excludedApps.remove(request.packageName)
+ update()
+ }
+
+ registerHandler(Request.ExcludeApp::class) { request ->
+ excludedApps.add(request.packageName)
+ update()
+ }
+
+ registerHandler(Request.SetEnableSplitTunneling::class) { request ->
+ enabled = request.enable
+ }
+
+ registerHandler(Request.PersistExcludedApps::class) { _ ->
+ persistence.excludedApps = excludedApps
+ }
+ }
+ }
+
+ fun onDestroy() {
+ onChange.unsubscribeAll()
+ }
+
+ private fun update() {
+ if (enabled) {
+ onChange.notify(excludedApps.toList())
+ } else {
+ onChange.notify(null)
+ }
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VoucherRedeemer.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VoucherRedeemer.kt
new file mode 100644
index 0000000000..a7003d6888
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VoucherRedeemer.kt
@@ -0,0 +1,40 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.actor
+import kotlinx.coroutines.channels.trySendBlocking
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.Request
+
+class VoucherRedeemer(private val endpoint: ServiceEndpoint) {
+ private val daemon
+ get() = endpoint.intermittentDaemon
+
+ private val voucherChannel = spawnActor()
+
+ init {
+ endpoint.dispatcher.registerHandler(Request.SubmitVoucher::class) { request ->
+ voucherChannel.trySendBlocking(request.voucher)
+ }
+ }
+
+ fun onDestroy() {
+ voucherChannel.close()
+ }
+
+ private fun spawnActor() =
+ GlobalScope.actor<String>(Dispatchers.Default, Channel.UNLIMITED) {
+ try {
+ for (voucher in channel) {
+ val result = daemon.await().submitVoucher(voucher)
+
+ endpoint.sendEvent(Event.VoucherSubmissionResult(voucher, result))
+ }
+ } catch (exception: ClosedReceiveChannelException) {
+ // Voucher channel was closed, stop the actor
+ }
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VpnPermission.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VpnPermission.kt
new file mode 100644
index 0000000000..c753c13b36
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/endpoint/VpnPermission.kt
@@ -0,0 +1,47 @@
+package net.mullvad.mullvadvpn.service.endpoint
+
+import android.content.Context
+import android.content.Intent
+import android.net.VpnService
+import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS
+import net.mullvad.mullvadvpn.lib.common.constant.MULLVAD_PACKAGE_NAME
+import net.mullvad.mullvadvpn.lib.common.util.Intermittent
+import net.mullvad.mullvadvpn.lib.ipc.Event
+import net.mullvad.mullvadvpn.lib.ipc.Request
+
+class VpnPermission(private val context: Context, private val endpoint: ServiceEndpoint) {
+ private val isGranted = Intermittent<Boolean>()
+
+ var waitingForResponse = false
+ private set
+
+ init {
+ endpoint.dispatcher.registerHandler(Request.VpnPermissionResponse::class) { request ->
+ waitingForResponse = false
+ isGranted.spawnUpdate(request.isGranted)
+ }
+ }
+
+ suspend fun request(): Boolean {
+ val intent = VpnService.prepare(context)
+
+ if (intent == null) {
+ isGranted.update(true)
+ } else {
+ val activityIntent =
+ Intent().apply {
+ setClassName(MULLVAD_PACKAGE_NAME, MAIN_ACTIVITY_CLASS)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ }
+
+ isGranted.update(null)
+ waitingForResponse = true
+
+ context.startActivity(activityIntent)
+ endpoint.sendEvent(Event.VpnPermissionRequest)
+ }
+
+ return isGranted.await()
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt
new file mode 100644
index 0000000000..dcc97e8b11
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/AccountExpiryNotification.kt
@@ -0,0 +1,148 @@
+package net.mullvad.mullvadvpn.service.notifications
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.core.app.NotificationCompat
+import kotlin.properties.Delegates.observable
+import kotlinx.coroutines.delay
+import net.mullvad.mullvadvpn.lib.common.constant.BuildTypes
+import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS
+import net.mullvad.mullvadvpn.lib.common.constant.MULLVAD_PACKAGE_NAME
+import net.mullvad.mullvadvpn.lib.common.util.Intermittent
+import net.mullvad.mullvadvpn.lib.common.util.JobTracker
+import net.mullvad.mullvadvpn.lib.common.util.SdkUtils
+import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.isNotificationPermissionGranted
+import net.mullvad.mullvadvpn.model.AccountExpiry
+import net.mullvad.mullvadvpn.service.BuildConfig
+import net.mullvad.mullvadvpn.service.MullvadDaemon
+import net.mullvad.mullvadvpn.service.R
+import net.mullvad.mullvadvpn.service.endpoint.AccountCache
+import org.joda.time.DateTime
+import org.joda.time.Duration
+
+class AccountExpiryNotification(
+ val context: Context,
+ val daemon: Intermittent<MullvadDaemon>,
+ val accountCache: AccountCache
+) {
+ companion object {
+ val NOTIFICATION_ID: Int = 2
+ val REMAINING_TIME_FOR_REMINDERS = Duration.standardDays(2)
+ val TIME_BETWEEN_CHECKS: Long = 12 /* h */ * 60 /* min */ * 60 /* s */ * 1000 /* ms */
+ }
+
+ private val jobTracker = JobTracker()
+ private val resources = context.resources
+
+ private val buyMoreTimeUrl = resources.getString(R.string.account_url)
+
+ private val channel =
+ NotificationChannel(
+ context,
+ "mullvad_account_time",
+ NotificationCompat.VISIBILITY_PRIVATE,
+ R.string.account_time_notification_channel_name,
+ R.string.account_time_notification_channel_description,
+ NotificationManager.IMPORTANCE_HIGH,
+ true,
+ true
+ )
+
+ var accountExpiry by
+ observable<AccountExpiry>(AccountExpiry.Missing) { _, oldValue, newValue ->
+ if (oldValue != newValue) {
+ jobTracker.newUiJob("update") { update(newValue) }
+ }
+ }
+
+ init {
+ accountCache.onAccountExpiryChange.subscribe(this) { expiry -> accountExpiry = expiry }
+ }
+
+ fun onDestroy() {
+ accountCache.onAccountExpiryChange.unsubscribe(this)
+ }
+
+ // Suppressing since the permission check is done by calling a common util in another module.
+ @SuppressLint("MissingPermission")
+ private suspend fun update(expiry: AccountExpiry) {
+ val expiryDate = expiry.date()
+ val durationUntilExpiry = expiryDate?.remainingTime()
+
+ if (accountCache.isNewAccount.not() && durationUntilExpiry?.isCloseToExpiry() == true) {
+ if (context.isNotificationPermissionGranted()) {
+ val notification = build(expiryDate, durationUntilExpiry)
+ channel.notificationManager.notify(NOTIFICATION_ID, notification)
+ }
+ jobTracker.newUiJob("scheduleUpdate") { scheduleUpdate() }
+ } else {
+ channel.notificationManager.cancel(NOTIFICATION_ID)
+ jobTracker.cancelJob("scheduleUpdate")
+ }
+ }
+
+ private fun DateTime.remainingTime(): Duration {
+ return Duration(DateTime.now(), this)
+ }
+
+ private fun Duration.isCloseToExpiry(): Boolean {
+ return isShorterThan(REMAINING_TIME_FOR_REMINDERS)
+ }
+
+ private suspend fun scheduleUpdate() {
+ delay(TIME_BETWEEN_CHECKS)
+ update(accountExpiry)
+ }
+
+ private suspend fun build(expiry: DateTime, remainingTime: Duration): Notification {
+ val url =
+ jobTracker.runOnBackground {
+ Uri.parse("$buyMoreTimeUrl?token=${daemon.await().getWwwAuthToken()}")
+ }
+ val intent =
+ if (BuildTypes.RELEASE == BuildConfig.BUILD_TYPE) {
+ Intent().apply {
+ setClassName(MULLVAD_PACKAGE_NAME, MAIN_ACTIVITY_CLASS)
+ setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ setAction(Intent.ACTION_MAIN)
+ }
+ } else {
+ Intent(Intent.ACTION_VIEW, url)
+ }
+ val pendingIntent =
+ PendingIntent.getActivity(context, 1, intent, SdkUtils.getSupportedPendingIntentFlags())
+
+ return channel.buildNotification(pendingIntent, format(expiry, remainingTime))
+ }
+
+ private fun format(expiry: DateTime, remainingTime: Duration): String {
+ if (remainingTime.isShorterThan(Duration.ZERO)) {
+ return resources.getString(R.string.account_credit_has_expired)
+ } else {
+ val remainingTimeInfo = remainingTime.toPeriodTo(expiry)
+
+ if (remainingTimeInfo.days >= 1) {
+ return getRemainingText(
+ R.plurals.account_credit_expires_in_days,
+ remainingTime.standardDays.toInt()
+ )
+ } else if (remainingTimeInfo.hours >= 1) {
+ return getRemainingText(
+ R.plurals.account_credit_expires_in_hours,
+ remainingTime.standardHours.toInt()
+ )
+ } else {
+ return resources.getString(R.string.account_credit_expires_in_a_few_minutes)
+ }
+ }
+ }
+
+ private fun getRemainingText(pluralId: Int, quantity: Int): String {
+ return resources.getQuantityString(pluralId, quantity, quantity)
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannel.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannel.kt
new file mode 100644
index 0000000000..d6e904e6ca
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/NotificationChannel.kt
@@ -0,0 +1,97 @@
+package net.mullvad.mullvadvpn.service.notifications
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.content.Context
+import androidx.core.app.NotificationChannelCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import net.mullvad.mullvadvpn.service.R
+
+class NotificationChannel(
+ val context: Context,
+ val id: String,
+ val visibility: Int,
+ name: Int,
+ description: Int,
+ importance: Int,
+ isVibrationEnabled: Boolean,
+ isBadgeEnabled: Boolean
+) {
+ private val badgeColor by lazy { context.getColor(R.color.colorPrimary) }
+
+ val notificationManager = NotificationManagerCompat.from(context)
+
+ init {
+ val channelName = context.getString(name)
+ val channelDescription = context.getString(description)
+
+ val channel =
+ NotificationChannelCompat.Builder(id, importance)
+ .setName(channelName)
+ .setDescription(channelDescription)
+ .setShowBadge(isBadgeEnabled)
+ .setVibrationEnabled(isVibrationEnabled)
+ .build()
+
+ notificationManager.createNotificationChannel(channel)
+ }
+
+ fun buildNotification(
+ intent: PendingIntent,
+ title: String,
+ deleteIntent: PendingIntent? = null,
+ isOngoing: Boolean = false
+ ): Notification {
+ return buildNotification(intent, title, emptyList(), deleteIntent, isOngoing)
+ }
+
+ fun buildNotification(
+ intent: PendingIntent,
+ title: Int,
+ deleteIntent: PendingIntent? = null,
+ isOngoing: Boolean = false
+ ): Notification {
+ return buildNotification(intent, title, emptyList(), deleteIntent, isOngoing)
+ }
+
+ fun buildNotification(
+ pendingIntent: PendingIntent,
+ title: Int,
+ actions: List<NotificationCompat.Action>,
+ deleteIntent: PendingIntent? = null,
+ isOngoing: Boolean = false
+ ): Notification {
+ return buildNotification(
+ pendingIntent,
+ context.getString(title),
+ actions,
+ deleteIntent,
+ isOngoing
+ )
+ }
+
+ private fun buildNotification(
+ pendingIntent: PendingIntent,
+ title: String,
+ actions: List<NotificationCompat.Action>,
+ deleteIntent: PendingIntent? = null,
+ isOngoing: Boolean = false
+ ): Notification {
+ val builder =
+ NotificationCompat.Builder(context, id)
+ .setSmallIcon(R.drawable.small_logo_black)
+ .setColor(badgeColor)
+ .setContentTitle(title)
+ .setContentIntent(pendingIntent)
+ .setVisibility(visibility)
+ .setOngoing(isOngoing)
+ for (action in actions) {
+ builder.addAction(action)
+ }
+
+ deleteIntent?.let { intent -> builder.setDeleteIntent(intent) }
+
+ return builder.build()
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotification.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotification.kt
new file mode 100644
index 0000000000..b9691b6fa9
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotification.kt
@@ -0,0 +1,153 @@
+package net.mullvad.mullvadvpn.service.notifications
+
+import android.annotation.SuppressLint
+import android.app.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import kotlin.properties.Delegates.observable
+import net.mullvad.mullvadvpn.lib.common.constant.MAIN_ACTIVITY_CLASS
+import net.mullvad.mullvadvpn.lib.common.constant.MULLVAD_PACKAGE_NAME
+import net.mullvad.mullvadvpn.lib.common.util.SdkUtils
+import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.isNotificationPermissionGranted
+import net.mullvad.mullvadvpn.lib.common.util.getErrorNotificationResources
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.service.R
+import net.mullvad.talpid.tunnel.ActionAfterDisconnect
+import net.mullvad.talpid.tunnel.ErrorStateCause
+
+class TunnelStateNotification(val context: Context) {
+ companion object {
+ val NOTIFICATION_ID: Int = 1
+ }
+
+ private val channel =
+ NotificationChannel(
+ context,
+ "vpn_tunnel_status",
+ NotificationCompat.VISIBILITY_SECRET,
+ R.string.foreground_notification_channel_name,
+ R.string.foreground_notification_channel_description,
+ NotificationManager.IMPORTANCE_MIN,
+ false,
+ false
+ )
+
+ private val notificationText: Int
+ get() =
+ when (val state = tunnelState) {
+ is TunnelState.Disconnected -> R.string.unsecured
+ is TunnelState.Connecting -> {
+ if (reconnecting) {
+ R.string.reconnecting
+ } else {
+ R.string.connecting
+ }
+ }
+ is TunnelState.Connected -> R.string.secured
+ is TunnelState.Disconnecting -> {
+ when (state.actionAfterDisconnect) {
+ ActionAfterDisconnect.Reconnect -> R.string.reconnecting
+ else -> R.string.disconnecting
+ }
+ }
+ is TunnelState.Error -> {
+ if (state.isDeviceOffline()) {
+ R.string.blocking_internet_device_offline
+ } else {
+ state.errorState.getErrorNotificationResources(context).titleResourceId
+ }
+ }
+ }
+
+ private fun TunnelState.isDeviceOffline(): Boolean {
+ return (this as? TunnelState.Error)?.errorState?.cause is ErrorStateCause.IsOffline
+ }
+
+ private val shouldDisplayOngoingNotification: Boolean
+ get() =
+ when (tunnelState) {
+ is TunnelState.Connected -> true
+ is TunnelState.Disconnected,
+ is TunnelState.Connecting,
+ is TunnelState.Disconnecting,
+ is TunnelState.Error -> false
+ }
+
+ private var reconnecting = false
+ private var showingReconnecting = false
+
+ var showAction by observable(false) { _, _, _ -> update() }
+
+ var tunnelState by
+ observable<TunnelState>(TunnelState.Disconnected) { _, _, newState ->
+ val isReconnecting = newState is TunnelState.Connecting && reconnecting
+ val shouldBeginReconnecting =
+ (newState as? TunnelState.Disconnecting)?.actionAfterDisconnect ==
+ ActionAfterDisconnect.Reconnect
+ reconnecting = isReconnecting || shouldBeginReconnecting
+ update()
+ }
+
+ var visible by
+ observable(true) { _, _, newValue ->
+ if (newValue == true) {
+ update()
+ } else {
+ channel.notificationManager.cancel(NOTIFICATION_ID)
+ }
+ }
+
+ // Suppressing since the permission check is done by calling a common util in another module.
+ @SuppressLint("MissingPermission")
+ private fun update() {
+ if (
+ context.isNotificationPermissionGranted() &&
+ visible &&
+ (!reconnecting || !showingReconnecting)
+ ) {
+ channel.notificationManager.notify(NOTIFICATION_ID, build())
+ }
+ }
+
+ fun build(): Notification {
+ val intent =
+ Intent().apply {
+ setClassName(MULLVAD_PACKAGE_NAME, MAIN_ACTIVITY_CLASS)
+ setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ setAction(Intent.ACTION_MAIN)
+ }
+ val pendingIntent =
+ PendingIntent.getActivity(context, 1, intent, SdkUtils.getSupportedPendingIntentFlags())
+ val actions =
+ if (showAction) {
+ listOf(buildAction())
+ } else {
+ emptyList()
+ }
+
+ return channel.buildNotification(
+ pendingIntent,
+ notificationText,
+ actions,
+ isOngoing = shouldDisplayOngoingNotification
+ )
+ }
+
+ private fun buildAction(): NotificationCompat.Action {
+ val action = TunnelStateNotificationAction.from(tunnelState)
+ val label = context.getString(action.text)
+ val intent = Intent(action.key).setPackage(MULLVAD_PACKAGE_NAME)
+ val pendingIntent =
+ PendingIntent.getForegroundService(
+ context,
+ 1,
+ intent,
+ SdkUtils.getSupportedPendingIntentFlags()
+ )
+
+ return NotificationCompat.Action(action.icon, label, pendingIntent)
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotificationAction.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotificationAction.kt
new file mode 100644
index 0000000000..c415940ea8
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/notifications/TunnelStateNotificationAction.kt
@@ -0,0 +1,59 @@
+package net.mullvad.mullvadvpn.service.notifications
+
+import net.mullvad.mullvadvpn.lib.common.constant.KEY_CONNECT_ACTION
+import net.mullvad.mullvadvpn.lib.common.constant.KEY_DISCONNECT_ACTION
+import net.mullvad.mullvadvpn.model.TunnelState
+import net.mullvad.mullvadvpn.service.R
+import net.mullvad.talpid.tunnel.ActionAfterDisconnect
+
+enum class TunnelStateNotificationAction {
+ Connect,
+ Disconnect,
+ Cancel,
+ Dismiss;
+
+ companion object {
+ fun from(tunnelState: TunnelState) =
+ when (tunnelState) {
+ is TunnelState.Disconnected -> Connect
+ is TunnelState.Connecting -> Cancel
+ is TunnelState.Connected -> Disconnect
+ is TunnelState.Disconnecting -> {
+ when (tunnelState.actionAfterDisconnect) {
+ ActionAfterDisconnect.Reconnect -> Cancel
+ else -> Connect
+ }
+ }
+ is TunnelState.Error -> {
+ if (tunnelState.errorState.isBlocking) {
+ Disconnect
+ } else {
+ Dismiss
+ }
+ }
+ }
+ }
+
+ val text
+ get() =
+ when (this) {
+ Connect -> R.string.connect
+ Disconnect -> R.string.disconnect
+ Cancel -> R.string.cancel
+ Dismiss -> R.string.dismiss
+ }
+
+ val key
+ get() =
+ when (this) {
+ Connect -> KEY_CONNECT_ACTION
+ else -> KEY_DISCONNECT_ACTION
+ }
+
+ val icon
+ get() =
+ when (this) {
+ Connect -> R.drawable.icon_notification_connect
+ else -> R.drawable.icon_notification_disconnect
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/persistence/SplitTunnelingPersistence.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/persistence/SplitTunnelingPersistence.kt
new file mode 100644
index 0000000000..264304ab3f
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/persistence/SplitTunnelingPersistence.kt
@@ -0,0 +1,37 @@
+package net.mullvad.mullvadvpn.service.persistence
+
+import android.content.Context
+import java.io.File
+import kotlin.properties.Delegates.observable
+
+// The spelling of the shared preferences location can't be changed to American English without
+// either having users lose their preferences on update or implementing some migration code.
+private const val SHARED_PREFERENCES = "split_tunnelling"
+private const val KEY_ENABLED = "enabled"
+
+class SplitTunnelingPersistence(context: Context) {
+ // The spelling of the app list file name can't be changed to American English without either
+ // having users lose their preferences on update or implementing some migration code.
+ private val appListFile = File(context.filesDir, "split-tunnelling.txt")
+ private val preferences = context.getSharedPreferences(SHARED_PREFERENCES, Context.MODE_PRIVATE)
+
+ var enabled by
+ observable(preferences.getBoolean(KEY_ENABLED, false)) { _, _, isEnabled ->
+ preferences.edit().apply {
+ putBoolean(KEY_ENABLED, isEnabled)
+ apply()
+ }
+ }
+
+ var excludedApps by
+ observable(loadExcludedApps()) { _, _, excludedAppsSet ->
+ appListFile.writeText(excludedAppsSet.joinToString(separator = "\n"))
+ }
+
+ private fun loadExcludedApps(): Set<String> {
+ return when {
+ appListFile.exists() -> appListFile.readLines().toSet()
+ else -> emptySet()
+ }
+ }
+}
diff --git a/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/util/ExponentialBackoff.kt b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/util/ExponentialBackoff.kt
new file mode 100644
index 0000000000..12e94a9241
--- /dev/null
+++ b/android/service/src/main/kotlin/net/mullvad/mullvadvpn/service/util/ExponentialBackoff.kt
@@ -0,0 +1,52 @@
+package net.mullvad.mullvadvpn.service.util
+
+// Calculates a series of delays that increase exponentially.
+//
+// The delays follow the formula:
+//
+// (base ^ retryAttempt) * scale
+//
+// but it is never larger than the specified cap value.
+class ExponentialBackoff : Iterator<Long> {
+ private var unscaledValue = 1L
+ private var current = 1L
+
+ var iteration = 1
+ private set
+
+ var base = 2L
+ var scale = 1000L
+ var cap = Long.MAX_VALUE
+ var count: Int? = null
+
+ override fun hasNext(): Boolean {
+ val maxIterations = count
+
+ if (maxIterations != null) {
+ return iteration < maxIterations
+ } else {
+ return true
+ }
+ }
+
+ override fun next(): Long {
+ iteration += 1
+
+ if (current >= cap) {
+ return cap
+ } else {
+ val value = current
+
+ unscaledValue *= base
+ current = Math.min(cap, scale * unscaledValue)
+
+ return value
+ }
+ }
+
+ fun reset() {
+ unscaledValue = 1L
+ current = 1L
+ iteration = 1
+ }
+}