diff --git a/client/amnezia_application.cpp b/client/amnezia_application.cpp index 0831b1a2..1ac179fd 100644 --- a/client/amnezia_application.cpp +++ b/client/amnezia_application.cpp @@ -95,7 +95,14 @@ void AmneziaApplication::init() qFatal("Android logging initialization failed"); } AndroidController::instance()->setSaveLogs(m_settings->isSaveLogs()); - connect(m_settings.get(), &Settings::saveLogsChanged, AndroidController::instance(), &AndroidController::setSaveLogs); + connect(m_settings.get(), &Settings::saveLogsChanged, + AndroidController::instance(), &AndroidController::setSaveLogs); + + connect(m_settings.get(), &Settings::serverRemoved, + AndroidController::instance(), &AndroidController::resetLastServer); + + connect(m_settings.get(), &Settings::settingsCleared, + [](){ AndroidController::instance()->resetLastServer(-1); }); connect(AndroidController::instance(), &AndroidController::initConnectionState, this, [this](Vpn::ConnectionState state) { diff --git a/client/android/AndroidManifest.xml b/client/android/AndroidManifest.xml index 22eed003..548c9bb9 100644 --- a/client/android/AndroidManifest.xml +++ b/client/android/AndroidManifest.xml @@ -56,6 +56,10 @@ + + + + @@ -146,6 +150,22 @@ + + + + + + + + + + + Подключение + Отключение + \ No newline at end of file diff --git a/client/android/res/values/strings.xml b/client/android/res/values/strings.xml new file mode 100644 index 00000000..3fdd9844 --- /dev/null +++ b/client/android/res/values/strings.xml @@ -0,0 +1,5 @@ + + + Connecting + Disconnecting + \ No newline at end of file diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index 9a813626..f01e8df6 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -26,9 +26,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import org.amnezia.vpn.protocol.ProtocolState import org.amnezia.vpn.protocol.getStatistics import org.amnezia.vpn.protocol.getStatus import org.amnezia.vpn.qt.QtAndroidController @@ -36,11 +34,11 @@ import org.amnezia.vpn.util.Log import org.qtproject.qt.android.bindings.QtActivity private const val TAG = "AmneziaActivity" +const val ACTIVITY_MESSENGER_NAME = "Activity" private const val CHECK_VPN_PERMISSION_ACTION_CODE = 1 private const val CREATE_FILE_ACTION_CODE = 2 private const val OPEN_FILE_ACTION_CODE = 3 -private const val BIND_SERVICE_TIMEOUT = 1000L class AmneziaActivity : QtActivity() { @@ -58,25 +56,17 @@ class AmneziaActivity : QtActivity() { val event = msg.extractIpcMessage() Log.d(TAG, "Handle event: $event") when (event) { - ServiceEvent.CONNECTED -> { - QtAndroidController.onVpnConnected() - } - - ServiceEvent.DISCONNECTED -> { - QtAndroidController.onVpnDisconnected() - doUnbindService() - } - - ServiceEvent.RECONNECTING -> { - QtAndroidController.onVpnReconnecting() + ServiceEvent.STATUS_CHANGED -> { + msg.data?.getStatus()?.let { (state) -> + Log.d(TAG, "Handle protocol state: $state") + QtAndroidController.onVpnStateChanged(state.ordinal) + } } ServiceEvent.STATUS -> { if (isWaitingStatus) { isWaitingStatus = false - msg.data?.getStatus()?.let { (state) -> - QtAndroidController.onStatus(state.ordinal) - } + msg.data?.getStatus()?.let { QtAndroidController.onStatus(it) } } } @@ -87,7 +77,7 @@ class AmneziaActivity : QtActivity() { } ServiceEvent.ERROR -> { - msg.data?.getString(ERROR_MSG)?.let { error -> + msg.data?.getString(MSG_ERROR)?.let { error -> Log.e(TAG, "From VpnService: $error") } // todo: add error reporting to Qt @@ -109,14 +99,15 @@ class AmneziaActivity : QtActivity() { // get a messenger from the service to send actions to the service vpnServiceMessenger.set(Messenger(service)) // send a messenger to the service to process service events - vpnServiceMessenger.send { - Action.REGISTER_CLIENT.packToMessage().apply { - replyTo = activityMessenger - } - } + vpnServiceMessenger.send( + Action.REGISTER_CLIENT.packToMessage { + putString(MSG_CLIENT_NAME, ACTIVITY_MESSENGER_NAME) + }, + replyTo = activityMessenger + ) isServiceConnected = true if (isWaitingStatus) { - vpnServiceMessenger.send(Action.REQUEST_STATUS) + vpnServiceMessenger.send(Action.REQUEST_STATUS, replyTo = activityMessenger) } } @@ -126,6 +117,7 @@ class AmneziaActivity : QtActivity() { vpnServiceMessenger.reset() isWaitingStatus = true QtAndroidController.onServiceDisconnected() + doBindService() } override fun onBindingDied(name: ComponentName?) { @@ -148,8 +140,11 @@ class AmneziaActivity : QtActivity() { Log.d(TAG, "Create Amnezia activity: $intent") mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) vpnServiceMessenger = IpcMessenger( - onDeadObjectException = ::doUnbindService, - messengerName = "VpnService" + "VpnService", + onDeadObjectException = { + doUnbindService() + doBindService() + } ) intent?.let(::processIntent) } @@ -244,10 +239,9 @@ class AmneziaActivity : QtActivity() { private fun doBindService() { Log.d(TAG, "Bind service") Intent(this, AmneziaVpnService::class.java).also { - bindService(it, serviceConnection, BIND_ABOVE_CLIENT) + bindService(it, serviceConnection, BIND_ABOVE_CLIENT and BIND_AUTO_CREATE) } isInBoundState = true - handleBindTimeout() } @MainThread @@ -256,26 +250,14 @@ class AmneziaActivity : QtActivity() { Log.d(TAG, "Unbind service") isWaitingStatus = true QtAndroidController.onServiceDisconnected() - vpnServiceMessenger.reset() isServiceConnected = false + vpnServiceMessenger.send(Action.UNREGISTER_CLIENT, activityMessenger) + vpnServiceMessenger.reset() isInBoundState = false unbindService(serviceConnection) } } - private fun handleBindTimeout() { - mainScope.launch { - if (isWaitingStatus) { - delay(BIND_SERVICE_TIMEOUT) - if (isWaitingStatus && !isServiceConnected) { - Log.d(TAG, "Bind timeout, reset connection status") - isWaitingStatus = false - QtAndroidController.onStatus(ProtocolState.DISCONNECTED.ordinal) - } - } - } - } - /** * Methods of starting and stopping VpnService */ @@ -312,7 +294,7 @@ class AmneziaActivity : QtActivity() { Log.d(TAG, "Connect to VPN") vpnServiceMessenger.send { Action.CONNECT.packToMessage { - putString(VPN_CONFIG, vpnConfig) + putString(MSG_VPN_CONFIG, vpnConfig) } } } @@ -320,7 +302,7 @@ class AmneziaActivity : QtActivity() { private fun startVpnService(vpnConfig: String) { Log.d(TAG, "Start VPN service") Intent(this, AmneziaVpnService::class.java).apply { - putExtra(VPN_CONFIG, vpnConfig) + putExtra(MSG_VPN_CONFIG, vpnConfig) }.also { ContextCompat.startForegroundService(this, it) } @@ -369,6 +351,22 @@ class AmneziaActivity : QtActivity() { } } + @Suppress("unused") + fun resetLastServer(index: Int) { + Log.v(TAG, "Reset server: $index") + mainScope.launch { + VpnStateStore.store { + if (index == -1 || it.serverIndex == index) { + VpnState.defaultState + } else if (it.serverIndex > index) { + it.copy(serverIndex = it.serverIndex - 1) + } else { + it + } + } + } + } + @Suppress("unused") fun saveFile(fileName: String, data: String) { Log.d(TAG, "Save file $fileName") @@ -438,7 +436,7 @@ class AmneziaActivity : QtActivity() { Log.saveLogs = enabled vpnServiceMessenger.send { Action.SET_SAVE_LOGS.packToMessage { - putBoolean(SAVE_LOGS, enabled) + putBoolean(MSG_SAVE_LOGS, enabled) } } } diff --git a/client/android/src/org/amnezia/vpn/AmneziaApplication.kt b/client/android/src/org/amnezia/vpn/AmneziaApplication.kt index 33182887..d8c87bd6 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaApplication.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaApplication.kt @@ -18,6 +18,7 @@ class AmneziaApplication : QtApplication(), CameraXConfig.Provider { super.onCreate() Prefs.init(this) Log.init(this) + VpnStateStore.init(this) Log.d(TAG, "Create Amnezia application") createNotificationChannel() } diff --git a/client/android/src/org/amnezia/vpn/AmneziaTileService.kt b/client/android/src/org/amnezia/vpn/AmneziaTileService.kt new file mode 100644 index 00000000..5ad872a0 --- /dev/null +++ b/client/android/src/org/amnezia/vpn/AmneziaTileService.kt @@ -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 + } +} diff --git a/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt b/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt index 78f89ab8..094383e8 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt @@ -1,7 +1,10 @@ package org.amnezia.vpn +import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE import android.app.Notification import android.app.PendingIntent +import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED @@ -16,6 +19,7 @@ import android.os.Process import androidx.annotation.MainThread import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat +import java.util.concurrent.ConcurrentHashMap import kotlin.LazyThreadSafetyMode.NONE import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope @@ -26,6 +30,7 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -39,14 +44,11 @@ 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.protocol.Statistics -import org.amnezia.vpn.protocol.Status import org.amnezia.vpn.protocol.VpnException import org.amnezia.vpn.protocol.VpnStartException import org.amnezia.vpn.protocol.awg.Awg import org.amnezia.vpn.protocol.cloak.Cloak import org.amnezia.vpn.protocol.openvpn.OpenVpn -import org.amnezia.vpn.protocol.putStatistics import org.amnezia.vpn.protocol.putStatus import org.amnezia.vpn.protocol.wireguard.Wireguard import org.amnezia.vpn.util.Log @@ -57,12 +59,16 @@ import org.json.JSONObject private const val TAG = "AmneziaVpnService" -const val VPN_CONFIG = "VPN_CONFIG" -const val ERROR_MSG = "ERROR_MSG" -const val SAVE_LOGS = "SAVE_LOGS" +const val MSG_VPN_CONFIG = "VPN_CONFIG" +const val MSG_ERROR = "ERROR" +const val MSG_SAVE_LOGS = "SAVE_LOGS" +const val MSG_CLIENT_NAME = "CLIENT_NAME" const val AFTER_PERMISSION_CHECK = "AFTER_PERMISSION_CHECK" private const val PREFS_CONFIG_KEY = "LAST_CONF" +private const val PREFS_SERVER_NAME = "LAST_SERVER_NAME" +private const val PREFS_SERVER_INDEX = "LAST_SERVER_INDEX" +private const val PROCESS_NAME = "org.amnezia.vpn:amneziaVpnService" private const val NOTIFICATION_ID = 1337 private const val STATISTICS_SENDING_TIMEOUT = 1000L private const val DISCONNECT_TIMEOUT = 5000L @@ -76,6 +82,8 @@ class AmneziaVpnService : VpnService() { private var protocol: Protocol? = null private val protocolCache = mutableMapOf() private var protocolState = MutableStateFlow(UNKNOWN) + private var serverName: String? = null + private var serverIndex: Int = -1 private val isConnected get() = protocolState.value == CONNECTED @@ -89,8 +97,11 @@ class AmneziaVpnService : VpnService() { private var connectionJob: Job? = null private var disconnectionJob: Job? = null private var statisticsSendingJob: Job? = null - private lateinit var clientMessenger: IpcMessenger private lateinit var networkState: NetworkState + private val clientMessengers = ConcurrentHashMap() + + private val isActivityConnected + get() = clientMessengers.any { it.value.name == ACTIVITY_MESSENGER_NAME } private val connectionExceptionHandler = CoroutineExceptionHandler { _, e -> protocolState.value = DISCONNECTED @@ -116,13 +127,22 @@ class AmneziaVpnService : VpnService() { Log.d(TAG, "Handle action: $action") when (action) { Action.REGISTER_CLIENT -> { - clientMessenger.set(msg.replyTo) + val clientName = msg.data.getString(MSG_CLIENT_NAME) + val messenger = IpcMessenger(msg.replyTo, clientName) + clientMessengers[msg.replyTo] = messenger + Log.d(TAG, "Messenger client '$clientName' was registered") + if (clientName == ACTIVITY_MESSENGER_NAME && isConnected) launchSendingStatistics() + } + + Action.UNREGISTER_CLIENT -> { + clientMessengers.remove(msg.replyTo)?.let { + Log.d(TAG, "Messenger client '${it.name}' was unregistered") + if (it.name == ACTIVITY_MESSENGER_NAME) stopSendingStatistics() + } } Action.CONNECT -> { - val vpnConfig = msg.data.getString(VPN_CONFIG) - Prefs.save(PREFS_CONFIG_KEY, vpnConfig) - connect(vpnConfig) + connect(msg.data.getString(MSG_VPN_CONFIG)) } Action.DISCONNECT -> { @@ -130,17 +150,17 @@ class AmneziaVpnService : VpnService() { } Action.REQUEST_STATUS -> { - clientMessenger.send { - ServiceEvent.STATUS.packToMessage { - putStatus(Status.build { - setState(this@AmneziaVpnService.protocolState.value) - }) + clientMessengers[msg.replyTo]?.let { clientMessenger -> + clientMessenger.send { + ServiceEvent.STATUS.packToMessage { + putStatus(this@AmneziaVpnService.protocolState.value) + } } } } Action.SET_SAVE_LOGS -> { - Log.saveLogs = msg.data.getBoolean(SAVE_LOGS) + Log.saveLogs = msg.data.getBoolean(MSG_SAVE_LOGS) } } } @@ -189,7 +209,7 @@ class AmneziaVpnService : VpnService() { Log.d(TAG, "Create Amnezia VPN service") mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) connectionScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + connectionExceptionHandler) - clientMessenger = IpcMessenger(messengerName = "Client") + loadServerData() launchProtocolStateHandler() networkState = NetworkState(this, ::reconnect) } @@ -201,15 +221,13 @@ class AmneziaVpnService : VpnService() { if (isAlwaysOnCompat) { Log.d(TAG, "Start service via Always-on") - connect(Prefs.load(PREFS_CONFIG_KEY)) + connect() } else if (intent?.getBooleanExtra(AFTER_PERMISSION_CHECK, false) == true) { Log.d(TAG, "Start service after permission check") - connect(Prefs.load(PREFS_CONFIG_KEY)) + connect() } else { Log.d(TAG, "Start service") - val vpnConfig = intent?.getStringExtra(VPN_CONFIG) - Prefs.save(PREFS_CONFIG_KEY, vpnConfig) - connect(vpnConfig) + connect(intent?.getStringExtra(MSG_VPN_CONFIG)) } ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, foregroundServiceTypeCompat) return START_REDELIVER_INTENT @@ -219,17 +237,16 @@ class AmneziaVpnService : VpnService() { Log.d(TAG, "onBind by $intent") if (intent?.action == SERVICE_INTERFACE) return super.onBind(intent) isServiceBound = true - if (isConnected) launchSendingStatistics() return vpnServiceMessenger.binder } override fun onUnbind(intent: Intent?): Boolean { Log.d(TAG, "onUnbind by $intent") if (intent?.action != SERVICE_INTERFACE) { - isServiceBound = false - stopSendingStatistics() - clientMessenger.reset() - if (isUnknown || isDisconnected) stopService() + if (clientMessengers.isEmpty()) { + isServiceBound = false + if (isUnknown || isDisconnected) stopService() + } } return true } @@ -238,7 +255,6 @@ class AmneziaVpnService : VpnService() { Log.d(TAG, "onRebind by $intent") if (intent?.action != SERVICE_INTERFACE) { isServiceBound = true - if (isConnected) launchSendingStatistics() } super.onRebind(intent) } @@ -278,17 +294,16 @@ class AmneziaVpnService : VpnService() { */ private fun launchProtocolStateHandler() { mainScope.launch { - protocolState.collect { protocolState -> + // drop first default UNKNOWN state + protocolState.drop(1).collect { protocolState -> Log.d(TAG, "Protocol state changed: $protocolState") when (protocolState) { CONNECTED -> { - clientMessenger.send(ServiceEvent.CONNECTED) networkState.bindNetworkListener() - if (isServiceBound) launchSendingStatistics() + if (isActivityConnected) launchSendingStatistics() } DISCONNECTED -> { - clientMessenger.send(ServiceEvent.DISCONNECTED) networkState.unbindNetworkListener() stopSendingStatistics() if (!isServiceBound) stopService() @@ -300,12 +315,19 @@ class AmneziaVpnService : VpnService() { } RECONNECTING -> { - clientMessenger.send(ServiceEvent.RECONNECTING) stopSendingStatistics() } CONNECTING, UNKNOWN -> {} } + + clientMessengers.send { + ServiceEvent.STATUS_CHANGED.packToMessage { + putStatus(protocolState) + } + } + + VpnStateStore.store { VpnState(protocolState, serverName, serverIndex) } } } } @@ -332,7 +354,17 @@ class AmneziaVpnService : VpnService() { } @MainThread - private fun connect(vpnConfig: String?) { + private fun connect(vpnConfig: String? = null) { + if (vpnConfig == null) { + connectToVpn(Prefs.load(PREFS_CONFIG_KEY)) + } else { + Prefs.save(PREFS_CONFIG_KEY, vpnConfig) + connectToVpn(vpnConfig) + } + } + + @MainThread + private fun connectToVpn(vpnConfig: String) { if (isConnected || protocolState.value == CONNECTING) return Log.d(TAG, "Start VPN connection") @@ -340,6 +372,7 @@ class AmneziaVpnService : VpnService() { protocolState.value = CONNECTING val config = parseConfigToJson(vpnConfig) + saveServerData(config) if (config == null) { onError("Invalid VPN config") protocolState.value = DISCONNECTED @@ -417,24 +450,38 @@ class AmneziaVpnService : VpnService() { private fun onError(msg: String) { Log.e(TAG, msg) mainScope.launch { - clientMessenger.send { + clientMessengers.send { ServiceEvent.ERROR.packToMessage { - putString(ERROR_MSG, msg) + putString(MSG_ERROR, msg) } } } } - private fun parseConfigToJson(vpnConfig: String?): JSONObject? = - try { - vpnConfig?.let { - JSONObject(it) - } - } catch (e: JSONException) { - onError("Invalid VPN config json format: ${e.message}") + private fun parseConfigToJson(vpnConfig: String): JSONObject? = + if (vpnConfig.isBlank()) { null + } else { + try { + JSONObject(vpnConfig) + } catch (e: JSONException) { + onError("Invalid VPN config json format: ${e.message}") + null + } } + private fun saveServerData(config: JSONObject?) { + serverName = config?.opt("description") as String? + serverIndex = config?.opt("serverIndex") as Int? ?: -1 + Prefs.save(PREFS_SERVER_NAME, serverName) + Prefs.save(PREFS_SERVER_INDEX, serverIndex) + } + + private fun loadServerData() { + serverName = Prefs.load(PREFS_SERVER_NAME).ifBlank { null } + if (serverName != null) serverIndex = Prefs.load(PREFS_SERVER_INDEX) + } + private fun checkPermission(): Boolean = if (prepare(applicationContext) != null) { Intent(this, VpnRequestActivity::class.java).apply { @@ -446,4 +493,12 @@ class AmneziaVpnService : VpnService() { } else { true } + + companion object { + fun isRunning(context: Context): Boolean = + (context.getSystemService(ACTIVITY_SERVICE) as ActivityManager) + .runningAppProcesses.any { + it.processName == PROCESS_NAME && it.importance <= IMPORTANCE_FOREGROUND_SERVICE + } + } } diff --git a/client/android/src/org/amnezia/vpn/IpcMessage.kt b/client/android/src/org/amnezia/vpn/IpcMessage.kt index 26c3b9de..2ddff4ef 100644 --- a/client/android/src/org/amnezia/vpn/IpcMessage.kt +++ b/client/android/src/org/amnezia/vpn/IpcMessage.kt @@ -20,9 +20,7 @@ sealed interface IpcMessage { } enum class ServiceEvent : IpcMessage { - CONNECTED, - DISCONNECTED, - RECONNECTING, + STATUS_CHANGED, STATUS, STATISTICS_UPDATE, ERROR @@ -30,6 +28,7 @@ enum class ServiceEvent : IpcMessage { enum class Action : IpcMessage { REGISTER_CLIENT, + UNREGISTER_CLIENT, CONNECT, DISCONNECT, REQUEST_STATUS, diff --git a/client/android/src/org/amnezia/vpn/IpcMessenger.kt b/client/android/src/org/amnezia/vpn/IpcMessenger.kt index 218a165b..58baf31a 100644 --- a/client/android/src/org/amnezia/vpn/IpcMessenger.kt +++ b/client/android/src/org/amnezia/vpn/IpcMessenger.kt @@ -9,11 +9,21 @@ import org.amnezia.vpn.util.Log private const val TAG = "IpcMessenger" class IpcMessenger( + messengerName: String? = null, private val onDeadObjectException: () -> Unit = {}, - private val onRemoteException: () -> Unit = {}, - private val messengerName: String = "Unknown" + private val onRemoteException: () -> Unit = {} ) { private var messenger: Messenger? = null + val name = messengerName ?: "Unknown" + + constructor( + messenger: Messenger, + messengerName: String? = null, + onDeadObjectException: () -> Unit = {}, + onRemoteException: () -> Unit = {} + ) : this(messengerName, onDeadObjectException, onRemoteException) { + this.messenger = messenger + } fun set(messenger: Messenger) { this.messenger = messenger @@ -25,19 +35,29 @@ class IpcMessenger( fun send(msg: () -> Message) = messenger?.sendMsg(msg()) + fun send(msg: Message, replyTo: Messenger) = messenger?.sendMsg(msg.apply { this.replyTo = replyTo }) + fun send(msg: T) where T : Enum, T : IpcMessage = messenger?.sendMsg(msg.packToMessage()) + fun send(msg: T, replyTo: Messenger) + where T : Enum, T : IpcMessage = messenger?.sendMsg(msg.packToMessage().apply { this.replyTo = replyTo }) + private fun Messenger.sendMsg(msg: Message) { try { send(msg) } catch (e: DeadObjectException) { - Log.w(TAG, "$messengerName messenger is dead") + Log.w(TAG, "$name messenger is dead") messenger = null onDeadObjectException() } catch (e: RemoteException) { - Log.w(TAG, "Sending a message to the $messengerName messenger failed: ${e.message}") + Log.w(TAG, "Sending a message to the $name messenger failed: ${e.message}") onRemoteException() } } } + +fun Map.send(msg: () -> Message) = this.values.forEach { it.send(msg) } + +fun Map.send(msg: T) + where T : Enum, T : IpcMessage = this.values.forEach { it.send(msg) } diff --git a/client/android/src/org/amnezia/vpn/VpnState.kt b/client/android/src/org/amnezia/vpn/VpnState.kt new file mode 100644 index 00000000..4d5e9c99 --- /dev/null +++ b/client/android/src/org/amnezia/vpn/VpnState.kt @@ -0,0 +1,75 @@ +package org.amnezia.vpn + +import android.app.Application +import androidx.datastore.core.MultiProcessDataStoreFactory +import androidx.datastore.core.Serializer +import androidx.datastore.dataStoreFile +import java.io.InputStream +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.io.OutputStream +import java.io.Serializable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import org.amnezia.vpn.protocol.ProtocolState +import org.amnezia.vpn.protocol.ProtocolState.DISCONNECTED +import org.amnezia.vpn.util.Log + +private const val TAG = "VpnState" +private const val STORE_FILE_NAME = "vpnState" + +data class VpnState( + val protocolState: ProtocolState, + val serverName: String? = null, + val serverIndex: Int = -1 +) : Serializable { + companion object { + private const val serialVersionUID: Long = -1760654961004181606 + val defaultState: VpnState = VpnState(DISCONNECTED) + } +} + +object VpnStateStore { + private lateinit var app: Application + + private val dataStore = MultiProcessDataStoreFactory.create( + serializer = VpnStateSerializer(), + produceFile = { app.dataStoreFile(STORE_FILE_NAME) } + ) + + fun init(app: Application) { + Log.v(TAG, "Init VpnStateStore") + this.app = app + } + + fun dataFlow(): Flow = dataStore.data + + suspend fun store(f: (vpnState: VpnState) -> VpnState) { + try { + dataStore.updateData(f) + } catch (e : Exception) { + Log.e(TAG, "Failed to store VpnState: $e") + } + } +} + +private class VpnStateSerializer : Serializer { + override val defaultValue: VpnState = VpnState.defaultState + + override suspend fun readFrom(input: InputStream): VpnState { + return withContext(Dispatchers.IO) { + ObjectInputStream(input).use { + it.readObject() as VpnState + } + } + } + + override suspend fun writeTo(t: VpnState, output: OutputStream) { + withContext(Dispatchers.IO) { + ObjectOutputStream(output).use { + it.writeObject(t) + } + } + } +} diff --git a/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt b/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt index cab810a7..537d9925 100644 --- a/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt +++ b/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt @@ -1,18 +1,23 @@ package org.amnezia.vpn.qt +import org.amnezia.vpn.protocol.ProtocolState +import org.amnezia.vpn.protocol.Status + /** * JNI functions of the AndroidController class from android_controller.cpp, * called by events in the Android part of the client */ object QtAndroidController { + + fun onStatus(status: Status) = onStatus(status.state) + fun onStatus(protocolState: ProtocolState) = onStatus(protocolState.ordinal) + external fun onStatus(stateCode: Int) external fun onServiceDisconnected() external fun onServiceError() external fun onVpnPermissionRejected() - external fun onVpnConnected() - external fun onVpnDisconnected() - external fun onVpnReconnecting() + external fun onVpnStateChanged(stateCode: Int) external fun onStatisticsUpdate(rxBytes: Long, txBytes: Long) external fun onFileOpened(uri: String) diff --git a/client/platforms/android/android_controller.cpp b/client/platforms/android/android_controller.cpp index 767004fc..b789f0e0 100644 --- a/client/platforms/android/android_controller.cpp +++ b/client/platforms/android/android_controller.cpp @@ -56,26 +56,10 @@ AndroidController::AndroidController() : QObject() Qt::QueuedConnection); connect( - this, &AndroidController::vpnConnected, this, - [this]() { - qDebug() << "Android event: VPN connected"; - emit connectionStateChanged(Vpn::ConnectionState::Connected); - }, - Qt::QueuedConnection); - - connect( - this, &AndroidController::vpnDisconnected, this, - [this]() { - qDebug() << "Android event: VPN disconnected"; - emit connectionStateChanged(Vpn::ConnectionState::Disconnected); - }, - Qt::QueuedConnection); - - connect( - this, &AndroidController::vpnReconnecting, this, - [this]() { - qDebug() << "Android event: VPN reconnecting"; - emit connectionStateChanged(Vpn::ConnectionState::Reconnecting); + this, &AndroidController::vpnStateChanged, this, + [this](AndroidController::ConnectionState state) { + qDebug() << "Android event: VPN state changed:" << textConnectionState(state); + emit connectionStateChanged(convertState(state)); }, Qt::QueuedConnection); @@ -106,9 +90,7 @@ bool AndroidController::initialize() {"onServiceDisconnected", "()V", reinterpret_cast(onServiceDisconnected)}, {"onServiceError", "()V", reinterpret_cast(onServiceError)}, {"onVpnPermissionRejected", "()V", reinterpret_cast(onVpnPermissionRejected)}, - {"onVpnConnected", "()V", reinterpret_cast(onVpnConnected)}, - {"onVpnDisconnected", "()V", reinterpret_cast(onVpnDisconnected)}, - {"onVpnReconnecting", "()V", reinterpret_cast(onVpnReconnecting)}, + {"onVpnStateChanged", "(I)V", reinterpret_cast(onVpnStateChanged)}, {"onStatisticsUpdate", "(JJ)V", reinterpret_cast(onStatisticsUpdate)}, {"onFileOpened", "(Ljava/lang/String;)V", reinterpret_cast(onFileOpened)}, {"onConfigImported", "(Ljava/lang/String;)V", reinterpret_cast(onConfigImported)}, @@ -158,6 +140,11 @@ void AndroidController::stop() callActivityMethod("stop", "()V"); } +void AndroidController::resetLastServer(int serverIndex) +{ + callActivityMethod("resetLastServer", "(I)V", serverIndex); +} + void AndroidController::saveFile(const QString &fileName, const QString &data) { callActivityMethod("saveFile", "(Ljava/lang/String;Ljava/lang/String;)V", @@ -370,30 +357,14 @@ void AndroidController::onVpnPermissionRejected(JNIEnv *env, jobject thiz) } // static -void AndroidController::onVpnConnected(JNIEnv *env, jobject thiz) +void AndroidController::onVpnStateChanged(JNIEnv *env, jobject thiz, jint stateCode) { Q_UNUSED(env); Q_UNUSED(thiz); - emit AndroidController::instance()->vpnConnected(); -} + auto state = ConnectionState(stateCode); -// static -void AndroidController::onVpnDisconnected(JNIEnv *env, jobject thiz) -{ - Q_UNUSED(env); - Q_UNUSED(thiz); - - emit AndroidController::instance()->vpnDisconnected(); -} - -// static -void AndroidController::onVpnReconnecting(JNIEnv *env, jobject thiz) -{ - Q_UNUSED(env); - Q_UNUSED(thiz); - - emit AndroidController::instance()->vpnReconnecting(); + emit AndroidController::instance()->vpnStateChanged(state); } // static diff --git a/client/platforms/android/android_controller.h b/client/platforms/android/android_controller.h index 86b117f7..3491d837 100644 --- a/client/platforms/android/android_controller.h +++ b/client/platforms/android/android_controller.h @@ -20,9 +20,9 @@ public: // keep synchronized with org.amnezia.vpn.protocol.ProtocolState enum class ConnectionState { + DISCONNECTED, CONNECTED, CONNECTING, - DISCONNECTED, DISCONNECTING, RECONNECTING, UNKNOWN @@ -30,6 +30,7 @@ public: ErrorCode start(const QJsonObject &vpnConfig); void stop(); + void resetLastServer(int serverIndex); void setNotificationText(const QString &title, const QString &message, int timerSec); void saveFile(const QString &fileName, const QString &data); QString openFile(const QString &filter); @@ -48,9 +49,7 @@ signals: void serviceDisconnected(); void serviceError(); void vpnPermissionRejected(); - void vpnConnected(); - void vpnDisconnected(); - void vpnReconnecting(); + void vpnStateChanged(ConnectionState state); void statisticsUpdated(quint64 rxBytes, quint64 txBytes); void fileOpened(QString uri); void configImported(QString config); @@ -77,9 +76,7 @@ private: static void onServiceDisconnected(JNIEnv *env, jobject thiz); static void onServiceError(JNIEnv *env, jobject thiz); static void onVpnPermissionRejected(JNIEnv *env, jobject thiz); - static void onVpnConnected(JNIEnv *env, jobject thiz); - static void onVpnDisconnected(JNIEnv *env, jobject thiz); - static void onVpnReconnecting(JNIEnv *env, jobject thiz); + static void onVpnStateChanged(JNIEnv *env, jobject thiz, jint stateCode); static void onStatisticsUpdate(JNIEnv *env, jobject thiz, jlong rxBytes, jlong txBytes); static void onConfigImported(JNIEnv *env, jobject thiz, jstring data); static void onFileOpened(JNIEnv *env, jobject thiz, jstring uri); diff --git a/client/protocols/protocols_defs.h b/client/protocols/protocols_defs.h index f83a0067..8ab5594f 100644 --- a/client/protocols/protocols_defs.h +++ b/client/protocols/protocols_defs.h @@ -20,6 +20,7 @@ namespace amnezia constexpr char dns1[] = "dns1"; constexpr char dns2[] = "dns2"; + constexpr char serverIndex[] = "serverIndex"; constexpr char description[] = "description"; constexpr char name[] = "name"; constexpr char cert[] = "cert"; diff --git a/client/settings.cpp b/client/settings.cpp index 475c52e7..223719e2 100644 --- a/client/settings.cpp +++ b/client/settings.cpp @@ -68,6 +68,7 @@ void Settings::removeServer(int index) servers.removeAt(index); setServersArray(servers); + emit serverRemoved(index); } bool Settings::editServer(int index, const QJsonObject &server) @@ -338,6 +339,7 @@ QString Settings::secondaryDns() const void Settings::clearSettings() { m_settings.clearSettings(); + emit settingsCleared(); } ServerCredentials Settings::defaultServerCredentials() const diff --git a/client/settings.h b/client/settings.h index ed302653..613d567b 100644 --- a/client/settings.h +++ b/client/settings.h @@ -191,6 +191,8 @@ public: signals: void saveLogsChanged(bool enabled); + void serverRemoved(int serverIndex); + void settingsCleared(); private: QVariant value(const QString &key, const QVariant &defaultValue = QVariant()) const; diff --git a/client/vpnconnection.cpp b/client/vpnconnection.cpp index d267584a..c3719562 100644 --- a/client/vpnconnection.cpp +++ b/client/vpnconnection.cpp @@ -270,6 +270,7 @@ QJsonObject VpnConnection::createVpnConfiguration(int serverIndex, const ServerC ErrorCode *errorCode) { QJsonObject vpnConfiguration; + vpnConfiguration[config_key::serverIndex] = serverIndex; for (ProtocolEnumNS::Proto proto : ContainerProps::protocolsForContainer(container)) { auto s = m_settings->server(serverIndex); @@ -471,10 +472,15 @@ void VpnConnection::disconnectFromVpn() #ifdef Q_OS_ANDROID if (m_vpnProtocol && m_vpnProtocol.data()) { - connect(AndroidController::instance(), &AndroidController::vpnDisconnected, this, - [this]() { - onConnectionStateChanged(Vpn::ConnectionState::Disconnected); - }, Qt::SingleShotConnection); + auto *const connection = new QMetaObject::Connection; + *connection = connect(AndroidController::instance(), &AndroidController::vpnStateChanged, this, + [this, connection](AndroidController::ConnectionState state) { + if (state == AndroidController::ConnectionState::DISCONNECTED) { + onConnectionStateChanged(Vpn::ConnectionState::Disconnected); + disconnect(*connection); + delete connection; + } + }); m_vpnProtocol.data()->stop(); } #endif