diff options
| author | Albin <albin@mullvad.net> | 2023-07-28 10:03:34 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2023-07-28 10:45:57 +0200 |
| commit | 6e644dfb566b986e7cccbf2a95eac281e4eecf87 (patch) | |
| tree | 469bc8ce128194512106a91a94bb044853f58907 /android/service/src | |
| parent | 47bc546a330544b420aab2bdd8c5f91abe7f3161 (diff) | |
| download | mullvadvpn-6e644dfb566b986e7cccbf2a95eac281e4eecf87.tar.xz mullvadvpn-6e644dfb566b986e7cccbf2a95eac281e4eecf87.zip | |
Move vpn service classes to service module
Diffstat (limited to 'android/service/src')
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 + } +} |
