Add Quick Settings tile (#660)

* Add Quick Settings tile

- Add multi-client support to AmneziaVpnService
- Make AmneziaActivity permanently connected to AmneziaVpnService while it is running
- Refactor processing of connection state changes on qt side
- Add VpnState DataStore
- Add check if AmneziaVpnService is running

* Add tile reset when the server is removed from the application
This commit is contained in:
albexk 2024-03-04 18:08:55 +03:00 committed by GitHub
parent ca633ae882
commit 080e1d98c6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 602 additions and 154 deletions

View file

@ -0,0 +1,272 @@
package org.amnezia.vpn
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.ComponentName
import android.content.Intent
import android.content.ServiceConnection
import android.net.VpnService
import android.os.Build
import android.os.IBinder
import android.os.Messenger
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.core.content.ContextCompat
import kotlin.LazyThreadSafetyMode.NONE
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.amnezia.vpn.protocol.ProtocolState
import org.amnezia.vpn.protocol.ProtocolState.CONNECTED
import org.amnezia.vpn.protocol.ProtocolState.CONNECTING
import org.amnezia.vpn.protocol.ProtocolState.DISCONNECTED
import org.amnezia.vpn.protocol.ProtocolState.DISCONNECTING
import org.amnezia.vpn.protocol.ProtocolState.RECONNECTING
import org.amnezia.vpn.protocol.ProtocolState.UNKNOWN
import org.amnezia.vpn.util.Log
private const val TAG = "AmneziaTileService"
private const val DEFAULT_TILE_LABEL = "AmneziaVPN"
class AmneziaTileService : TileService() {
private lateinit var scope: CoroutineScope
private var vpnStateListeningJob: Job? = null
private lateinit var vpnServiceMessenger: IpcMessenger
@Volatile
private var isServiceConnected = false
private var isInBoundState = false
@Volatile
private var isVpnConfigExists = false
private val serviceConnection: ServiceConnection by lazy(NONE) {
object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
Log.d(TAG, "Service ${name?.flattenToString()} was connected")
vpnServiceMessenger.set(Messenger(service))
isServiceConnected = true
}
override fun onServiceDisconnected(name: ComponentName?) {
Log.w(TAG, "Service ${name?.flattenToString()} was unexpectedly disconnected")
isServiceConnected = false
vpnServiceMessenger.reset()
updateVpnState(DISCONNECTED)
}
override fun onBindingDied(name: ComponentName?) {
Log.w(TAG, "Binding to the ${name?.flattenToString()} unexpectedly died")
doUnbindService()
doBindService()
}
}
}
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Create Amnezia Tile Service")
scope = CoroutineScope(SupervisorJob())
vpnServiceMessenger = IpcMessenger(
"VpnService",
onDeadObjectException = ::doUnbindService
)
}
override fun onDestroy() {
Log.d(TAG, "Destroy Amnezia Tile Service")
doUnbindService()
scope.cancel()
super.onDestroy()
}
// Workaround for some bugs
override fun onBind(intent: Intent?): IBinder? =
try {
super.onBind(intent)
} catch (e: Throwable) {
Log.e(TAG, "Failed to bind AmneziaTileService: $e")
null
}
override fun onStartListening() {
super.onStartListening()
Log.d(TAG, "Start listening")
if (AmneziaVpnService.isRunning(applicationContext)) {
Log.d(TAG, "Vpn service is running")
doBindService()
} else {
Log.d(TAG, "Vpn service is not running")
isServiceConnected = false
updateVpnState(DISCONNECTED)
}
vpnStateListeningJob = launchVpnStateListening()
}
override fun onStopListening() {
Log.d(TAG, "Stop listening")
vpnStateListeningJob?.cancel()
vpnStateListeningJob = null
doUnbindService()
super.onStopListening()
}
override fun onClick() {
Log.d(TAG, "onClick")
if (isLocked) {
unlockAndRun { onClickInternal() }
} else {
onClickInternal()
}
}
private fun onClickInternal() {
if (isVpnConfigExists) {
Log.d(TAG, "Change VPN state")
if (qsTile.state == Tile.STATE_INACTIVE) {
Log.d(TAG, "Start VPN")
updateVpnState(CONNECTING)
startVpn()
} else if (qsTile.state == Tile.STATE_ACTIVE) {
Log.d(TAG, "Stop vpn")
updateVpnState(DISCONNECTING)
stopVpn()
}
} else {
Log.d(TAG, "Start Activity")
Intent(this, AmneziaActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}.also {
startActivityAndCollapseCompat(it)
}
}
}
private fun doBindService() {
Log.d(TAG, "Bind service")
Intent(this, AmneziaVpnService::class.java).also {
bindService(it, serviceConnection, BIND_ABOVE_CLIENT)
}
isInBoundState = true
}
private fun doUnbindService() {
if (isInBoundState) {
Log.d(TAG, "Unbind service")
isServiceConnected = false
vpnServiceMessenger.reset()
isInBoundState = false
unbindService(serviceConnection)
}
}
private fun startVpn() {
if (isServiceConnected) {
connectToVpn()
} else {
if (checkPermission()) {
startVpnService()
doBindService()
} else {
updateVpnState(DISCONNECTED)
}
}
}
private fun checkPermission() =
if (VpnService.prepare(applicationContext) != null) {
Intent(this, VpnRequestActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}.also {
startActivityAndCollapseCompat(it)
}
false
} else {
true
}
private fun startVpnService() =
ContextCompat.startForegroundService(
applicationContext,
Intent(this, AmneziaVpnService::class.java)
)
private fun connectToVpn() = vpnServiceMessenger.send(Action.CONNECT)
private fun stopVpn() = vpnServiceMessenger.send(Action.DISCONNECT)
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun startActivityAndCollapseCompat(intent: Intent) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(
PendingIntent.getActivity(
applicationContext,
0,
intent,
PendingIntent.FLAG_IMMUTABLE
)
)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}
private fun updateVpnState(state: ProtocolState) {
scope.launch {
VpnStateStore.store { it.copy(protocolState = state) }
}
}
private fun launchVpnStateListening() =
scope.launch { VpnStateStore.dataFlow().collectLatest(::updateTile) }
private fun updateTile(vpnState: VpnState) {
Log.d(TAG, "Update tile: $vpnState")
isVpnConfigExists = vpnState.serverName != null
val tile = qsTile ?: return
tile.apply {
label = vpnState.serverName ?: DEFAULT_TILE_LABEL
when (vpnState.protocolState) {
CONNECTED -> {
state = Tile.STATE_ACTIVE
subtitleCompat = null
}
DISCONNECTED, UNKNOWN -> {
state = Tile.STATE_INACTIVE
subtitleCompat = null
}
CONNECTING, RECONNECTING -> {
state = Tile.STATE_UNAVAILABLE
subtitleCompat = resources.getString(R.string.connecting)
}
DISCONNECTING -> {
state = Tile.STATE_UNAVAILABLE
subtitleCompat = resources.getString(R.string.disconnecting)
}
}
updateTile()
}
// double update to fix weird visual glitches
tile.updateTile()
}
private var Tile.subtitleCompat: CharSequence?
set(value) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
this.subtitle = value
}
}
get() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return this.subtitle
}
return null
}
}