diff options
| author | Albin <albin@mullvad.net> | 2023-07-26 11:49:22 +0200 |
|---|---|---|
| committer | Albin <albin@mullvad.net> | 2023-07-27 10:41:29 +0200 |
| commit | d4e7a6f0b63027d752e19a55d6df9dcf4a7095ff (patch) | |
| tree | 797dc237e0b5dc51f8b7135cc95e7472d16ab085 /android/tile | |
| parent | e320571c388aacc74e7c9e73a54374ec189c4792 (diff) | |
| download | mullvadvpn-d4e7a6f0b63027d752e19a55d6df9dcf4a7095ff.tar.xz mullvadvpn-d4e7a6f0b63027d752e19a55d6df9dcf4a7095ff.zip | |
Move tile classes to tile module
Diffstat (limited to 'android/tile')
3 files changed, 279 insertions, 0 deletions
diff --git a/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/MullvadTileService.kt b/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/MullvadTileService.kt new file mode 100644 index 0000000000..b29b7edfe0 --- /dev/null +++ b/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/MullvadTileService.kt @@ -0,0 +1,142 @@ +package net.mullvad.mullvadvpn.tile + +import android.content.Intent +import android.graphics.drawable.Icon +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import net.mullvad.mullvadvpn.lib.common.util.SdkUtils.setSubtitleIfSupported +import net.mullvad.mullvadvpn.model.ServiceResult +import net.mullvad.mullvadvpn.model.TunnelState +import net.mullvad.talpid.tunnel.ActionAfterDisconnect + +class MullvadTileService : TileService() { + private var scope: CoroutineScope? = null + + private lateinit var securedIcon: Icon + private lateinit var unsecuredIcon: Icon + + override fun onCreate() { + securedIcon = Icon.createWithResource(this, R.drawable.small_logo_white) + unsecuredIcon = Icon.createWithResource(this, R.drawable.small_logo_black) + } + + override fun onClick() { + // Workaround for the reported bug: https://issuetracker.google.com/issues/236862865 + suspend fun isUnlockStatusPropagatedWithinTimeout( + unlockTimeoutMillis: Long, + unlockCheckDelayMillis: Long + ): Boolean { + return withTimeoutOrNull(unlockTimeoutMillis) { + while (isLocked) { + delay(unlockCheckDelayMillis) + } + return@withTimeoutOrNull true + } + ?: false + } + + unlockAndRun { + runBlocking { + val isUnlockStatusPropagated = + isUnlockStatusPropagatedWithinTimeout( + unlockTimeoutMillis = 1000L, + unlockCheckDelayMillis = 100L + ) + + if (isUnlockStatusPropagated) { + toggleTunnel() + } else { + Log.e("mullvad", "Unable to toggle tunnel state") + } + } + } + } + + override fun onStartListening() { + scope = MainScope().apply { launchListenToTunnelState() } + } + + override fun onStopListening() { + scope?.cancel() + } + + private fun toggleTunnel() { + val intent = + Intent().apply { + setClassName(VPN_SERVICE_PACKAGE, VPN_SERVICE_CLASS) + action = + if (qsTile.state == Tile.STATE_INACTIVE) { + KEY_CONNECT_ACTION + } else { + KEY_DISCONNECT_ACTION + } + } + + // Always start as foreground in case tile is out-of-sync. + startForegroundService(intent) + } + + @OptIn(FlowPreview::class) + private fun CoroutineScope.launchListenToTunnelState() = launch { + ServiceConnection(this@MullvadTileService, this) + .tunnelState + .debounce(300L) + .map { (tunnelState, connectionState) -> mapToTileState(tunnelState, connectionState) } + .collect { updateTileState(it) } + } + + private fun mapToTileState( + tunnelState: TunnelState, + connectionState: ServiceResult.ConnectionState + ): Int { + return if (connectionState == ServiceResult.ConnectionState.CONNECTED) { + when (tunnelState) { + is TunnelState.Disconnected -> Tile.STATE_INACTIVE + is TunnelState.Connecting -> Tile.STATE_ACTIVE + is TunnelState.Connected -> Tile.STATE_ACTIVE + is TunnelState.Disconnecting -> { + if (tunnelState.actionAfterDisconnect == ActionAfterDisconnect.Reconnect) { + Tile.STATE_ACTIVE + } else { + Tile.STATE_INACTIVE + } + } + is TunnelState.Error -> { + if (tunnelState.errorState.isBlocking) { + Tile.STATE_ACTIVE + } else { + Tile.STATE_INACTIVE + } + } + } + } else { + Tile.STATE_INACTIVE + } + } + + private fun updateTileState(newState: Int) { + qsTile?.apply { + if (newState == Tile.STATE_ACTIVE) { + state = Tile.STATE_ACTIVE + icon = securedIcon + setSubtitleIfSupported(resources.getText(R.string.secured)) + } else { + state = Tile.STATE_INACTIVE + icon = unsecuredIcon + setSubtitleIfSupported(resources.getText(R.string.unsecured)) + } + updateTile() + } + } +} diff --git a/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/ServiceConnection.kt b/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/ServiceConnection.kt new file mode 100644 index 0000000000..c04820d177 --- /dev/null +++ b/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/ServiceConnection.kt @@ -0,0 +1,131 @@ +package net.mullvad.mullvadvpn.tile + +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.os.Looper +import android.os.Messenger +import kotlin.reflect.KClass +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import net.mullvad.mullvadvpn.lib.common.util.DispatchingFlow +import net.mullvad.mullvadvpn.lib.common.util.bindServiceFlow +import net.mullvad.mullvadvpn.lib.common.util.dispatchTo +import net.mullvad.mullvadvpn.lib.ipc.Event +import net.mullvad.mullvadvpn.lib.ipc.HandlerFlow +import net.mullvad.mullvadvpn.lib.ipc.Request +import net.mullvad.mullvadvpn.model.ServiceResult +import net.mullvad.mullvadvpn.model.TunnelState + +@FlowPreview +class ServiceConnection(context: Context, scope: CoroutineScope) { + private val activeListeners = MutableStateFlow<Pair<Messenger, Int>?>(null) + private val handler = HandlerFlow(Looper.getMainLooper(), Event.Companion::fromMessage) + private val listener = Messenger(handler) + private val listenerId = MutableStateFlow<Int?>(null) + + private lateinit var listenerRegistrations: StateFlow<Pair<Messenger, Int>?> + + lateinit var tunnelState: Flow<Pair<TunnelState, ServiceResult.ConnectionState>> + private set + + private val serviceConnectionStateChannel = + Channel<ServiceResult.ConnectionState>(Channel.RENDEZVOUS) + + init { + val dispatcher = + handler.filterNotNull().dispatchTo { + listenerRegistrations = + subscribeToState(Event.ListenerReady::class, scope) { + Pair(connection, listenerId) + } + + val tunnelStateEvents = + subscribeToState( + Event.TunnelStateChange::class, + scope, + TunnelState.Disconnected + ) { + tunnelState + } + + tunnelState = + tunnelStateEvents.combine(serviceConnectionStateChannel.consumeAsFlow()) { + tunnelState, + serviceConnectionState -> + tunnelState to serviceConnectionState + } + } + + scope.launch { connect(context) } + scope.launch { dispatcher.collect() } + scope.launch { unregisterOldListeners() } + scope.launch { listenerRegistrations.collect { activeListeners.value = it } } + } + + private suspend fun connect(context: Context) { + val intent = Intent().apply { setClassName(VPN_SERVICE_PACKAGE, VPN_SERVICE_CLASS) } + + context + .bindServiceFlow(intent) + .onStart { emit(ServiceResult.NOT_CONNECTED) } + .onEach { result -> serviceConnectionStateChannel.send(result.connectionState) } + .collect { result -> + activeListeners.value = null + result.binder?.let(::registerListener) + } + } + + private fun registerListener(binder: IBinder) { + val request = Request.RegisterListener(listener) + val messenger = Messenger(binder) + + messenger.send(request.message) + } + + private suspend fun unregisterOldListeners() { + var oldListener: Pair<Messenger, Int>? = null + + activeListeners + .onCompletion { oldListener?.let(::unregisterListener) } + .collect { newListener -> + oldListener?.let(::unregisterListener) + oldListener = newListener + } + } + + private fun unregisterListener(registration: Pair<Messenger, Int>) { + val (messenger, listenerId) = registration + val request = Request.UnregisterListener(listenerId) + + messenger.send(request.message) + } + + private fun <V : Any, D> DispatchingFlow<in V>.subscribeToState( + event: KClass<V>, + scope: CoroutineScope, + dataExtractor: suspend V.() -> D + ) = subscribe(event).map(dataExtractor).stateIn(scope, SharingStarted.Lazily, null) + + private fun <V : Any, D> DispatchingFlow<in V>.subscribeToState( + event: KClass<V>, + scope: CoroutineScope, + initialValue: D, + dataExtractor: suspend V.() -> D + ) = subscribe(event).map(dataExtractor).stateIn(scope, SharingStarted.Lazily, initialValue) +} diff --git a/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/TileConstants.kt b/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/TileConstants.kt new file mode 100644 index 0000000000..6229c1478b --- /dev/null +++ b/android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/TileConstants.kt @@ -0,0 +1,6 @@ +package net.mullvad.mullvadvpn.tile + +internal const val VPN_SERVICE_PACKAGE = "net.mullvad.mullvadvpn" +internal const val VPN_SERVICE_CLASS = "$VPN_SERVICE_PACKAGE.service.MullvadVpnService" +internal const val KEY_CONNECT_ACTION = "$VPN_SERVICE_PACKAGE.connect_action" +internal const val KEY_DISCONNECT_ACTION = "$VPN_SERVICE_PACKAGE.disconnect_action" |
