From 0ba5d754d5593e8631c10a49498dcf866b52f9e6 Mon Sep 17 00:00:00 2001 From: albexk Date: Thu, 23 Nov 2023 20:30:03 +0300 Subject: [PATCH] Android Activity refactoring --- .../src/org/amnezia/vpn/AmneziaActivity.kt | 297 +++++++++++++++++- .../src/org/amnezia/vpn/AmneziaVpnService.kt | 1 + .../src/org/amnezia/vpn/IPCContract.kt | 5 - .../android/src/org/amnezia/vpn/IpcMessage.kt | 45 +++ .../platforms/android/android_controller.cpp | 7 +- client/platforms/android/android_controller.h | 2 +- 6 files changed, 339 insertions(+), 18 deletions(-) delete mode 100644 client/android/src/org/amnezia/vpn/IPCContract.kt create mode 100644 client/android/src/org/amnezia/vpn/IpcMessage.kt diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index f484b44b..f031a9ad 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -1,29 +1,304 @@ package org.amnezia.vpn +import android.content.ComponentName import android.content.Intent +import android.content.ServiceConnection import android.net.Uri +import android.net.VpnService +import android.os.Bundle +import android.os.DeadObjectException +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.Message +import android.os.Messenger +import android.os.RemoteException +import android.widget.Toast +import androidx.annotation.MainThread +import androidx.core.content.ContextCompat import java.io.FileNotFoundException import java.io.FileOutputStream import java.io.IOException +import kotlin.LazyThreadSafetyMode.NONE +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import org.amnezia.vpn.protocol.getStatistics +import org.amnezia.vpn.protocol.getStatus +import org.amnezia.vpn.qt.QtAndroidController import org.qtproject.qt.android.bindings.QtActivity private const val TAG = "AmneziaActivity" -private const val CREATE_FILE_ACTION_CODE = 102 +private const val CHECK_VPN_PERMISSION_ACTION_CODE = 1 +private const val CREATE_FILE_ACTION_CODE = 2 class AmneziaActivity : QtActivity() { + private lateinit var mainScope: CoroutineScope + private val qtInitialized = CompletableDeferred() + private var isWaitingStatus = true + private var isServiceConnected = false + private var isInBoundState = false + private var vpnServiceMessenger: Messenger? = null private var tmpFileContentToSave: String = "" - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - if (requestCode == CREATE_FILE_ACTION_CODE && resultCode == RESULT_OK) { - data?.data?.also { uri -> - alterDocument(uri) + private val vpnServiceEventHandler: Handler by lazy(NONE) { + object : Handler(Looper.getMainLooper()) { + override fun handleMessage(msg: Message) { + val event = msg.extractIpcMessage() + Log.d(TAG, "Handle event: $event") + when (event) { + ServiceEvent.CONNECTED -> { + QtAndroidController.onVpnConnected() + } + + ServiceEvent.DISCONNECTED -> { + QtAndroidController.onVpnDisconnected() + } + + ServiceEvent.STATUS -> { + if (isWaitingStatus) { + isWaitingStatus = false + msg.data?.getStatus()?.let { (isConnected) -> + QtAndroidController.onStatus(isConnected) + } + } + } + + ServiceEvent.STATISTICS_UPDATE -> { + msg.data?.getStatistics()?.let { (rxBytes, txBytes) -> + QtAndroidController.onStatisticsUpdate(rxBytes, txBytes) + } + } + + ServiceEvent.ERROR -> { + // todo: add error reporting to Qt + QtAndroidController.onServiceError() + } + } } } - super.onActivityResult(requestCode, resultCode, data) } + private val activityMessenger: Messenger by lazy(NONE) { + Messenger(vpnServiceEventHandler) + } + + private val serviceConnection: ServiceConnection by lazy(NONE) { + object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + Log.d(TAG, "Service ${name?.flattenToString()} was connected") + // get a messenger from the service to send actions to the service + vpnServiceMessenger = Messenger(service) + // send a messenger to the service to process service events + sendToService { + Action.REGISTER_CLIENT.packToMessage().apply { + replyTo = activityMessenger + } + } + isServiceConnected = true + if (isWaitingStatus) { + sendToService(Action.REQUEST_STATUS) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + Log.w(TAG, "Service ${name?.flattenToString()} was unexpectedly disconnected") + isServiceConnected = false + vpnServiceMessenger = null + isWaitingStatus = true + QtAndroidController.onServiceDisconnected() + } + + override fun onBindingDied(name: ComponentName?) { + Log.w(TAG, "Binding to the ${name?.flattenToString()} unexpectedly died") + doUnbindService() + doBindService() + } + } + } + + private data class CheckVpnPermissionCallbacks(val onSuccess: () -> Unit, val onFail: () -> Unit) + + private var checkVpnPermissionCallbacks: CheckVpnPermissionCallbacks? = null + + /** + * Activity overloaded methods + */ + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.v(TAG, "Create Amnezia activity") + mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + } + + override fun onStart() { + super.onStart() + Log.v(TAG, "Start Amnezia activity") + mainScope.launch { + qtInitialized.await() + doBindService() + } + } + + override fun onStop() { + Log.v(TAG, "Stop Amnezia activity") + doUnbindService() + super.onStop() + } + + override fun onDestroy() { + Log.v(TAG, "Destroy Amnezia activity") + mainScope.cancel() + super.onDestroy() + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + CREATE_FILE_ACTION_CODE -> { + when (resultCode) { + RESULT_OK -> { + data?.data?.let { uri -> + alterDocument(uri) + } + } + } + } + + CHECK_VPN_PERMISSION_ACTION_CODE -> { + when (resultCode) { + RESULT_OK -> { + Log.v(TAG, "Vpn permission granted") + Toast.makeText(this, "Vpn permission granted", Toast.LENGTH_LONG).show() + checkVpnPermissionCallbacks?.run { onSuccess() } + } + + else -> { + Log.w(TAG, "Vpn permission denied, resultCode: $resultCode") + Toast.makeText(this, "Vpn permission denied", Toast.LENGTH_LONG).show() + checkVpnPermissionCallbacks?.run { onFail() } + } + } + checkVpnPermissionCallbacks = null + } + + else -> super.onActivityResult(requestCode, resultCode, data) + } + } + + /** + * Methods of communication with the service + */ + private fun sendToService(messenger: Messenger, msg: Message) { + try { + messenger.send(msg) + } catch (e: DeadObjectException) { + Log.w(TAG, "Service messenger is dead") + doUnbindService() + } catch (e: RemoteException) { + Log.w(TAG, "Sending a message to the service failed: ${e.message}") + doUnbindService() + } + } + + @MainThread + private fun sendToService(msg: () -> Message) { + vpnServiceMessenger?.let { + sendToService(it, msg()) + } + } + + @MainThread + private fun sendToService(msg: Action) { + vpnServiceMessenger?.let { + sendToService(it, msg.packToMessage()) + } + } + + /** + * Methods for service binding + */ + @MainThread + private fun doBindService() { + Log.v(TAG, "Bind service") + Intent(this, AmneziaVpnService::class.java).also { + bindService(it, serviceConnection, BIND_ABOVE_CLIENT) + } + isInBoundState = true + } + + @MainThread + private fun doUnbindService() { + if (isInBoundState) { + Log.v(TAG, "Unbind service") + isWaitingStatus = true + QtAndroidController.onServiceDisconnected() + vpnServiceMessenger = null + isServiceConnected = false + isInBoundState = false + unbindService(serviceConnection) + } + } + + /** + * Methods of starting and stopping VpnService + */ + private fun checkVpnPermissionAndStart(vpnConfig: String) { + checkVpnPermission( + onSuccess = { startVpn(vpnConfig) }, + onFail = QtAndroidController::onVpnPermissionRejected + ) + } + + @MainThread + private fun checkVpnPermission(onSuccess: () -> Unit, onFail: () -> Unit) { + Log.v(TAG, "Check VPN permission") + VpnService.prepare(applicationContext)?.let { + checkVpnPermissionCallbacks = CheckVpnPermissionCallbacks(onSuccess, onFail) + startActivityForResult(it, CHECK_VPN_PERMISSION_ACTION_CODE) + return + } + onSuccess() + } + + @MainThread + private fun startVpn(vpnConfig: String) { + if (isServiceConnected) { + connectToVpn(vpnConfig) + } else { + isWaitingStatus = false + startVpnService(vpnConfig) + doBindService() + } + } + + private fun connectToVpn(vpnConfig: String) { + Log.v(TAG, "Connect to VPN") + sendToService { + Action.CONNECT.packToMessage { + putString(VPN_CONFIG, vpnConfig) + } + } + } + + private fun startVpnService(vpnConfig: String) { + Log.v(TAG, "Start VPN service") + Intent(this, AmneziaVpnService::class.java).apply { + putExtra(VPN_CONFIG, vpnConfig) + }.also { + ContextCompat.startForegroundService(this, it) + } + } + + private fun disconnectFromVpn() { + Log.v(TAG, "Disconnect from VPN") + sendToService(Action.DISCONNECT) + } + + // saving file private fun alterDocument(uri: Uri) { try { applicationContext.contentResolver.openFileDescriptor(uri, "w")?.use { fd -> @@ -46,19 +321,23 @@ class AmneziaActivity : QtActivity() { @Suppress("unused") fun qtAndroidControllerInitialized() { Log.v(TAG, "Qt Android controller initialized") - Log.w(TAG, "Not yet implemented") + qtInitialized.complete(Unit) } @Suppress("unused") fun start(vpnConfig: String) { Log.v(TAG, "Start VPN") - Log.w(TAG, "Not yet implemented") + mainScope.launch { + checkVpnPermissionAndStart(vpnConfig) + } } @Suppress("unused") fun stop() { Log.v(TAG, "Stop VPN") - Log.w(TAG, "Not yet implemented") + mainScope.launch { + disconnectFromVpn() + } } @Suppress("unused") diff --git a/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt b/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt index 8fe824d4..8c781547 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt @@ -45,6 +45,7 @@ import java.io.IOException import java.lang.Exception import android.net.VpnService as BaseVpnService +const val VPN_CONFIG = "VPN_CONFIG" class AmneziaVpnService : BaseVpnService()/* , LocalDnsService.Interface */ { diff --git a/client/android/src/org/amnezia/vpn/IPCContract.kt b/client/android/src/org/amnezia/vpn/IPCContract.kt deleted file mode 100644 index 4ae3596d..00000000 --- a/client/android/src/org/amnezia/vpn/IPCContract.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.amnezia.vpn - -const val IMPORT_COMMAND_CODE = 1 -const val IMPORT_ACTION_CODE = "import_action" -const val IMPORT_CONFIG_KEY = "CONFIG_DATA_KEY" diff --git a/client/android/src/org/amnezia/vpn/IpcMessage.kt b/client/android/src/org/amnezia/vpn/IpcMessage.kt new file mode 100644 index 00000000..206b47ad --- /dev/null +++ b/client/android/src/org/amnezia/vpn/IpcMessage.kt @@ -0,0 +1,45 @@ +package org.amnezia.vpn + +import android.os.Bundle +import android.os.Message +import kotlin.enums.enumEntries + +sealed interface IpcMessage { + companion object { + @OptIn(ExperimentalStdlibApi::class) + inline fun extractFromMessage(msg: Message): T + where T : Enum, + T : IpcMessage { + val values = enumEntries() + if (msg.what !in values.indices) { + throw IllegalArgumentException("IPC action or event not found for the message: $msg") + } + return values[msg.what] + } + } +} + +enum class ServiceEvent : IpcMessage { + CONNECTED, + DISCONNECTED, + STATUS, + STATISTICS_UPDATE, + ERROR +} + +enum class Action : IpcMessage { + REGISTER_CLIENT, + CONNECT, + DISCONNECT, + REQUEST_STATUS, + REQUEST_STATISTICS +} + +fun T.packToMessage(): Message + where T : Enum, T : IpcMessage = Message.obtain().also { it.what = ordinal } + +fun T.packToMessage(block: Bundle.() -> Unit): Message + where T : Enum, T : IpcMessage = packToMessage().also { it.data = Bundle().apply(block) } + +inline fun Message.extractIpcMessage(): T + where T : Enum, T : IpcMessage = IpcMessage.extractFromMessage(this) diff --git a/client/platforms/android/android_controller.cpp b/client/platforms/android/android_controller.cpp index c9888b4c..041d1869 100644 --- a/client/platforms/android/android_controller.cpp +++ b/client/platforms/android/android_controller.cpp @@ -17,9 +17,9 @@ AndroidController::AndroidController() : QObject() connect(this, &AndroidController::status, this, [this](bool isVpnConnected) { qDebug() << "Android event: status; connected:" << isVpnConnected; - if (isWaitingInitStatus) { + if (isWaitingStatus) { qDebug() << "Android VPN service is alive, initialization by service status"; - isWaitingInitStatus = false; + isWaitingStatus = false; emit serviceIsAlive(isVpnConnected); } }, @@ -29,6 +29,7 @@ AndroidController::AndroidController() : QObject() this, &AndroidController::serviceDisconnected, this, [this]() { qDebug() << "Android event: service disconnected"; + isWaitingStatus = true; emit connectionStateChanged(Vpn::ConnectionState::Unknown); }, Qt::QueuedConnection); @@ -140,7 +141,7 @@ void AndroidController::callActivityMethod(const char *methodName, const char *s ErrorCode AndroidController::start(const QJsonObject &vpnConfig) { - isWaitingInitStatus = false; + isWaitingStatus = false; auto config = QJsonDocument(vpnConfig).toJson(); callActivityMethod("start", "(Ljava/lang/String;)V", QJniObject::fromString(config).object()); diff --git a/client/platforms/android/android_controller.h b/client/platforms/android/android_controller.h index e30dcc68..eb4109be 100644 --- a/client/platforms/android/android_controller.h +++ b/client/platforms/android/android_controller.h @@ -37,7 +37,7 @@ signals: void serviceIsAlive(bool connected); private: - bool isWaitingInitStatus = true; + bool isWaitingStatus = true; void qtAndroidControllerInitialized();