summaryrefslogtreecommitdiffhomepage
path: root/android/tile
diff options
context:
space:
mode:
Diffstat (limited to 'android/tile')
-rw-r--r--android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/MullvadTileService.kt142
-rw-r--r--android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/ServiceConnection.kt131
-rw-r--r--android/tile/src/main/kotlin/net/mullvad/mullvadvpn/tile/TileConstants.kt6
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"