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 @Volatile private var vpnProto: VpnProto? = null 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() scope.launch { Log.d(TAG, "Start listening") vpnProto = VpnStateStore.getVpnState().vpnProto vpnProto.also { proto -> if (proto != null && AmneziaVpnService.isRunning(applicationContext, proto.processName)) { 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 && vpnProto != null) { 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") vpnProto?.let { proto -> Intent(this, proto.serviceClass).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) putExtra(EXTRA_PROTOCOL, vpnProto) }.also { startActivityAndCollapseCompat(it) } false } else { true } private fun startVpnService() { vpnProto?.let { proto -> try { ContextCompat.startForegroundService( applicationContext, Intent(this, proto.serviceClass) ) } catch (e: SecurityException) { Log.e(TAG, "Failed to start ${proto.serviceClass.simpleName}: $e") } } ?: Log.e(TAG, "Failed to start vpn service: vpnProto is null") } 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 vpnProto = vpnState.vpnProto val tile = qsTile ?: return tile.apply { label = (vpnState.serverName ?: DEFAULT_TILE_LABEL) + (vpnProto?.let { " ${it.label}" } ?: "") when (val protocolState = vpnState.protocolState) { CONNECTED -> { state = Tile.STATE_ACTIVE subtitleCompat = null } DISCONNECTED, UNKNOWN -> { state = Tile.STATE_INACTIVE subtitleCompat = null } CONNECTING, DISCONNECTING, RECONNECTING -> { state = Tile.STATE_UNAVAILABLE subtitleCompat = getString(protocolState) } } 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 } }