diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index dc9228f8..19351aef 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -127,7 +127,6 @@ set(HEADERS ${HEADERS} ${CMAKE_CURRENT_LIST_DIR}/core/controllers/vpnConfigurationController.h ${CMAKE_CURRENT_LIST_DIR}/protocols/protocols_defs.h ${CMAKE_CURRENT_LIST_DIR}/protocols/qml_register_protocols.h - ${CMAKE_CURRENT_LIST_DIR}/ui/notificationhandler.h ${CMAKE_CURRENT_LIST_DIR}/ui/pages.h ${CMAKE_CURRENT_LIST_DIR}/ui/property_helper.h ${CMAKE_CURRENT_LIST_DIR}/ui/qautostart.h @@ -157,6 +156,12 @@ if(NOT IOS) ) endif() +if(NOT ANDROID) + set(HEADERS ${HEADERS} + ${CMAKE_CURRENT_LIST_DIR}/ui/notificationhandler.h + ) +endif() + set(SOURCES ${SOURCES} ${CMAKE_CURRENT_LIST_DIR}/migrations.cpp ${CMAKE_CURRENT_LIST_DIR}/amnezia_application.cpp @@ -168,7 +173,6 @@ set(SOURCES ${SOURCES} ${CMAKE_CURRENT_LIST_DIR}/core/controllers/serverController.cpp ${CMAKE_CURRENT_LIST_DIR}/core/controllers/vpnConfigurationController.cpp ${CMAKE_CURRENT_LIST_DIR}/protocols/protocols_defs.cpp - ${CMAKE_CURRENT_LIST_DIR}/ui/notificationhandler.cpp ${CMAKE_CURRENT_LIST_DIR}/ui/qautostart.cpp ${CMAKE_CURRENT_LIST_DIR}/protocols/vpnprotocol.cpp ${CMAKE_CURRENT_LIST_DIR}/core/sshclient.cpp @@ -194,6 +198,12 @@ if(NOT IOS) ) endif() +if(NOT ANDROID) + set(SOURCES ${SOURCES} + ${CMAKE_CURRENT_LIST_DIR}/ui/notificationhandler.cpp + ) +endif() + file(GLOB COMMON_FILES_H CONFIGURE_DEPENDS ${CMAKE_CURRENT_LIST_DIR}/*.h) file(GLOB COMMON_FILES_CPP CONFIGURE_DEPENDS ${CMAKE_CURRENT_LIST_DIR}/*.cpp) diff --git a/client/amnezia_application.cpp b/client/amnezia_application.cpp index 9653c595..910ae214 100644 --- a/client/amnezia_application.cpp +++ b/client/amnezia_application.cpp @@ -142,6 +142,7 @@ void AmneziaApplication::init() connect(m_settings.get(), &Settings::screenshotsEnabledChanged, [](bool enabled) { AmneziaVPN::toggleScreenshots(enabled); }); #endif +#ifndef Q_OS_ANDROID m_notificationHandler.reset(NotificationHandler::create(nullptr)); connect(m_vpnConnection.get(), &VpnConnection::connectionStateChanged, m_notificationHandler.get(), @@ -153,6 +154,7 @@ void AmneziaApplication::init() connect(m_notificationHandler.get(), &NotificationHandler::disconnectRequested, m_connectionController.get(), &ConnectionController::closeConnection); connect(this, &AmneziaApplication::translationsUpdated, m_notificationHandler.get(), &NotificationHandler::onTranslationsUpdated); +#endif m_engine->load(url); m_systemController->setQmlRoot(m_engine->rootObjects().value(0)); diff --git a/client/amnezia_application.h b/client/amnezia_application.h index aa14701b..d260cd47 100644 --- a/client/amnezia_application.h +++ b/client/amnezia_application.h @@ -26,7 +26,9 @@ #include "ui/models/containers_model.h" #include "ui/models/languageModel.h" #include "ui/models/protocols/cloakConfigModel.h" -#include "ui/notificationhandler.h" +#ifndef Q_OS_ANDROID + #include "ui/notificationhandler.h" +#endif #ifdef Q_OS_WINDOWS #include "ui/models/protocols/ikev2ConfigModel.h" #endif @@ -113,7 +115,9 @@ private: QSharedPointer m_vpnConnection; QThread m_vpnConnectionThread; +#ifndef Q_OS_ANDROID QScopedPointer m_notificationHandler; +#endif QScopedPointer m_connectionController; QScopedPointer m_pageController; diff --git a/client/android/cloak/src/main/kotlin/Cloak.kt b/client/android/cloak/src/main/kotlin/Cloak.kt index 651e353b..5a549130 100644 --- a/client/android/cloak/src/main/kotlin/Cloak.kt +++ b/client/android/cloak/src/main/kotlin/Cloak.kt @@ -3,9 +3,6 @@ package org.amnezia.vpn.protocol.cloak import android.util.Base64 import net.openvpn.ovpn3.ClientAPI_Config import org.amnezia.vpn.protocol.openvpn.OpenVpn -import org.amnezia.vpn.protocol.openvpn.OpenVpnConfig -import org.amnezia.vpn.util.net.InetNetwork -import org.amnezia.vpn.util.net.parseInetAddress import org.json.JSONObject /** @@ -54,13 +51,6 @@ class Cloak : OpenVpn() { return openVpnConfig } - override fun configPluggableTransport(configBuilder: OpenVpnConfig.Builder, config: JSONObject) { - // exclude remote server ip from vpn routes - val remoteServer = config.getString("hostName") - val remoteServerAddress = InetNetwork(parseInetAddress(remoteServer)) - configBuilder.excludeRoute(remoteServerAddress) - } - private fun checkCloakJson(cloakConfigJson: JSONObject): JSONObject { cloakConfigJson.put("NumConn", 1) cloakConfigJson.put("ProxyMethod", "openvpn") diff --git a/client/android/openvpn/src/main/kotlin/org/amnezia/vpn/protocol/openvpn/OpenVpn.kt b/client/android/openvpn/src/main/kotlin/org/amnezia/vpn/protocol/openvpn/OpenVpn.kt index 1cf763c8..e36fdefc 100644 --- a/client/android/openvpn/src/main/kotlin/org/amnezia/vpn/protocol/openvpn/OpenVpn.kt +++ b/client/android/openvpn/src/main/kotlin/org/amnezia/vpn/protocol/openvpn/OpenVpn.kt @@ -13,7 +13,9 @@ import org.amnezia.vpn.protocol.ProtocolState import org.amnezia.vpn.protocol.ProtocolState.DISCONNECTED import org.amnezia.vpn.protocol.Statistics import org.amnezia.vpn.protocol.VpnStartException +import org.amnezia.vpn.util.net.InetNetwork import org.amnezia.vpn.util.net.getLocalNetworks +import org.amnezia.vpn.util.net.parseInetAddress import org.json.JSONObject /** @@ -77,6 +79,12 @@ open class OpenVpn : Protocol() { if (evalConfig.error) { throw BadConfigException("OpenVPN config parse error: ${evalConfig.message}") } + + // exclude remote server ip from vpn routes + val remoteServer = config.getString("hostName") + val remoteServerAddress = InetNetwork(parseInetAddress(remoteServer)) + configBuilder.excludeRoute(remoteServerAddress) + configPluggableTransport(configBuilder, config) configBuilder.configSplitTunneling(config) configBuilder.configAppSplitTunneling(config) diff --git a/client/android/protocolApi/src/main/kotlin/Protocol.kt b/client/android/protocolApi/src/main/kotlin/Protocol.kt index ce4a1ed6..e51d0fc1 100644 --- a/client/android/protocolApi/src/main/kotlin/Protocol.kt +++ b/client/android/protocolApi/src/main/kotlin/Protocol.kt @@ -105,15 +105,17 @@ abstract class Protocol { vpnBuilder.addSearchDomain(it) } - for (addr in config.routes) { - Log.d(TAG, "addRoute: $addr") - vpnBuilder.addRoute(addr) - } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - for (addr in config.excludedRoutes) { - Log.d(TAG, "excludeRoute: $addr") - vpnBuilder.excludeRoute(addr) + for ((inetNetwork, include) in config.routes) { + if (include) { + Log.d(TAG, "addRoute: $inetNetwork") + vpnBuilder.addRoute(inetNetwork) + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + Log.d(TAG, "excludeRoute: $inetNetwork") + vpnBuilder.excludeRoute(inetNetwork) + } else { + Log.e(TAG, "Trying to exclude route $inetNetwork on old Android") + } } } diff --git a/client/android/protocolApi/src/main/kotlin/ProtocolConfig.kt b/client/android/protocolApi/src/main/kotlin/ProtocolConfig.kt index 5731de6c..bebcea4c 100644 --- a/client/android/protocolApi/src/main/kotlin/ProtocolConfig.kt +++ b/client/android/protocolApi/src/main/kotlin/ProtocolConfig.kt @@ -12,8 +12,7 @@ open class ProtocolConfig protected constructor( val addresses: Set, val dnsServers: Set, val searchDomain: String?, - val routes: Set, - val excludedRoutes: Set, + val routes: Set, val includedAddresses: Set, val excludedAddresses: Set, val includedApplications: Set, @@ -29,7 +28,6 @@ open class ProtocolConfig protected constructor( builder.dnsServers, builder.searchDomain, builder.routes, - builder.excludedRoutes, builder.includedAddresses, builder.excludedAddresses, builder.includedApplications, @@ -43,8 +41,7 @@ open class ProtocolConfig protected constructor( open class Builder(blockingMode: Boolean) { internal val addresses: MutableSet = hashSetOf() internal val dnsServers: MutableSet = hashSetOf() - internal val routes: MutableSet = hashSetOf() - internal val excludedRoutes: MutableSet = hashSetOf() + internal val routes: MutableSet = mutableSetOf() internal val includedAddresses: MutableSet = hashSetOf() internal val excludedAddresses: MutableSet = hashSetOf() internal val includedApplications: MutableSet = hashSetOf() @@ -77,13 +74,21 @@ open class ProtocolConfig protected constructor( fun setSearchDomain(domain: String) = apply { this.searchDomain = domain } - fun addRoute(route: InetNetwork) = apply { this.routes += route } - fun addRoutes(routes: Collection) = apply { this.routes += routes } - fun removeRoute(route: InetNetwork) = apply { this.routes.remove(route) } + fun addRoute(route: InetNetwork) = apply { this.routes += Route(route, true) } + fun addRoutes(routes: Collection) = apply { this.routes += routes.map { Route(it, true) } } + + fun excludeRoute(route: InetNetwork) = apply { this.routes += Route(route, false) } + fun excludeRoutes(routes: Collection) = apply { this.routes += routes.map { Route(it, false) } } + + fun removeRoute(route: InetNetwork) = apply { this.routes.removeIf { it.inetNetwork == route } } fun clearRoutes() = apply { this.routes.clear() } - fun excludeRoute(route: InetNetwork) = apply { this.excludedRoutes += route } - fun excludeRoutes(routes: Collection) = apply { this.excludedRoutes += routes } + fun prependRoutes(block: Builder.() -> Unit) = apply { + val savedRoutes = mutableListOf().apply { addAll(routes) } + routes.clear() + block() + routes.addAll(savedRoutes) + } fun includeAddress(addr: InetNetwork) = apply { this.includedAddresses += addr } fun includeAddresses(addresses: Collection) = apply { this.includedAddresses += addresses } @@ -117,37 +122,46 @@ open class ProtocolConfig protected constructor( // remove default routes, if any removeRoute(InetNetwork("0.0.0.0", 0)) removeRoute(InetNetwork("::", 0)) - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { - // for older versions of Android, add the default route to the excluded routes - // to correctly build the excluded subnets list later - excludeRoute(InetNetwork("0.0.0.0", 0)) + removeRoute(InetNetwork("2000::", 3)) + prependRoutes { + addRoutes(includedAddresses) } - addRoutes(includedAddresses) } else if (excludedAddresses.isNotEmpty()) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // default routes are required for split tunneling in newer versions of Android + prependRoutes { addRoute(InetNetwork("0.0.0.0", 0)) - addRoute(InetNetwork("::", 0)) + addRoute(InetNetwork("2000::", 3)) + excludeRoutes(excludedAddresses) } - excludeRoutes(excludedAddresses) } } - private fun processExcludedRoutes() { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && excludedRoutes.isNotEmpty()) { - // todo: rewrite, taking into account the current routes - // for older versions of Android, build a list of subnets without excluded routes - // and add them to routes - val ipRangeSet = IpRangeSet() - ipRangeSet.remove(IpRange("127.0.0.0", 8)) - excludedRoutes.forEach { - ipRangeSet.remove(IpRange(it)) + private fun processRoutes() { + // replace ::/0 as it may cause LAN connection issues + val ipv6DefaultRoute = InetNetwork("::", 0) + if (routes.removeIf { it.include && it.inetNetwork == ipv6DefaultRoute }) { + prependRoutes { + addRoute(InetNetwork("2000::", 3)) } - // remove default routes, if any - removeRoute(InetNetwork("0.0.0.0", 0)) - removeRoute(InetNetwork("::", 0)) + } + // for older versions of Android, build a list of subnets without excluded routes + // and add them to routes + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && routes.any { !it.include }) { + val ipRangeSet = IpRangeSet() + routes.forEach { + if (it.include) ipRangeSet.add(IpRange(it.inetNetwork)) + else ipRangeSet.remove(IpRange(it.inetNetwork)) + } + ipRangeSet.remove(IpRange("127.0.0.0", 8)) + ipRangeSet.remove(IpRange("::1", 128)) + routes.clear() ipRangeSet.subnets().forEach(::addRoute) - addRoute(InetNetwork("2000::", 3)) + } + // filter ipv4 and ipv6 loopback addresses + val ipv6Loopback = InetNetwork("::1", 128) + routes.removeIf { + it.include && + if (it.inetNetwork.isIpv4) it.inetNetwork.address.address[0] == 127.toByte() + else it.inetNetwork == ipv6Loopback } } @@ -165,7 +179,7 @@ open class ProtocolConfig protected constructor( protected fun configBuild() { processSplitTunneling() - processExcludedRoutes() + processRoutes() validate() } @@ -177,3 +191,5 @@ open class ProtocolConfig protected constructor( Builder(blockingMode).apply(block).build() } } + +data class Route(val inetNetwork: InetNetwork, val include: Boolean) diff --git a/client/android/res/values-ru/strings.xml b/client/android/res/values-ru/strings.xml index 53cb9c45..8bdabfc0 100644 --- a/client/android/res/values-ru/strings.xml +++ b/client/android/res/values-ru/strings.xml @@ -1,12 +1,26 @@ - Подключение - Отключение - Отмена + Не подключено + Подключено + Подключение… + Отключение… + Переподключение… + Подключиться + Отключиться ОК + Отмена + Да + Нет + VPN-подключение разрешено - VPN-подключение запрещено Ошибка настройки VPN Чтобы подключиться к AmneziaVPN необходимо:\n\n- Разрешить приложению подключаться к сети VPN\n- Отключить функцию \"Постоянная VPN\" для всех остальных VPN-приложений в системных настройках VPN Открыть настройки VPN + + Уведомления сервиса AmneziaVPN + Сервис AmneziaVPN + Показывать статус VPN в строке состояния? + Настройки уведомлений + Для показа уведомлений необходимо включить уведомления в системных настройках + Открыть настройки уведомлений \ No newline at end of file diff --git a/client/android/res/values/strings.xml b/client/android/res/values/strings.xml index 9172d14b..5251403b 100644 --- a/client/android/res/values/strings.xml +++ b/client/android/res/values/strings.xml @@ -1,12 +1,26 @@ - Connecting - Disconnecting - Cancel + Not connected + Connected + Connecting… + Disconnecting… + Reconnecting… + Connect + Disconnect OK + Cancel + Yes + No + VPN permission granted - VPN permission denied VPN setup error To connect to AmneziaVPN, please do the following:\n\n- Allow the app to set up a VPN connection\n- Disable Always-on VPN for any other VPN app in the VPN system settings Open VPN settings + + AmneziaVPN service notification + AmneziaVPN service + Show the VPN state in the status bar? + Notification settings + To show notifications, you must enable notifications in the system settings + Open notification settings \ 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 8e71f136..c9063f22 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -1,6 +1,9 @@ package org.amnezia.vpn +import android.Manifest import android.app.AlertDialog +import android.app.NotificationManager +import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Intent import android.content.Intent.EXTRA_MIME_TYPES @@ -8,8 +11,8 @@ import android.content.Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY import android.content.ServiceConnection import android.content.pm.PackageManager import android.graphics.Bitmap -import android.net.Uri import android.net.VpnService +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.IBinder @@ -21,6 +24,7 @@ import android.view.WindowManager.LayoutParams import android.webkit.MimeTypeMap import android.widget.Toast import androidx.annotation.MainThread +import androidx.annotation.RequiresApi import androidx.core.content.ContextCompat import java.io.IOException import kotlin.LazyThreadSafetyMode.NONE @@ -38,6 +42,7 @@ import org.amnezia.vpn.protocol.getStatistics import org.amnezia.vpn.protocol.getStatus import org.amnezia.vpn.qt.QtAndroidController import org.amnezia.vpn.util.Log +import org.amnezia.vpn.util.Prefs import org.qtproject.qt.android.bindings.QtActivity private const val TAG = "AmneziaActivity" @@ -46,6 +51,9 @@ 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 CHECK_NOTIFICATION_PERMISSION_ACTION_CODE = 4 + +private const val PREFS_NOTIFICATION_PERMISSION_ASKED = "NOTIFICATION_PERMISSION_ASKED" class AmneziaActivity : QtActivity() { @@ -54,8 +62,11 @@ class AmneziaActivity : QtActivity() { private var isWaitingStatus = true private var isServiceConnected = false private var isInBoundState = false + private var notificationStateReceiver: BroadcastReceiver? = null private lateinit var vpnServiceMessenger: IpcMessenger - private var tmpFileContentToSave: String = "" + + private val actionResultHandlers = mutableMapOf() + private val permissionRequestHandlers = mutableMapOf() private val vpnServiceEventHandler: Handler by lazy(NONE) { object : Handler(Looper.getMainLooper()) { @@ -135,10 +146,6 @@ class AmneziaActivity : QtActivity() { } } - private data class CheckVpnPermissionCallbacks(val onSuccess: () -> Unit, val onFail: () -> Unit) - - private var checkVpnPermissionCallbacks: CheckVpnPermissionCallbacks? = null - /** * Activity overloaded methods */ @@ -153,9 +160,30 @@ class AmneziaActivity : QtActivity() { doBindService() } ) + registerBroadcastReceivers() intent?.let(::processIntent) } + private fun registerBroadcastReceivers() { + notificationStateReceiver = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + registerBroadcastReceiver( + arrayOf( + NotificationManager.ACTION_NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED, + NotificationManager.ACTION_APP_BLOCK_STATE_CHANGED + ) + ) { + Log.d( + TAG, "Notification state changed: ${it?.action}, blocked = " + + "${it?.getBooleanExtra(NotificationManager.EXTRA_BLOCKED_STATE, false)}" + ) + mainScope.launch { + qtInitialized.await() + QtAndroidController.onNotificationStateChanged() + } + } + } else null + } + override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) Log.d(TAG, "onNewIntent: $intent") @@ -193,50 +221,46 @@ class AmneziaActivity : QtActivity() { override fun onDestroy() { Log.d(TAG, "Destroy Amnezia activity") + unregisterBroadcastReceiver(notificationStateReceiver) + notificationStateReceiver = null 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) - } - } - } + Log.d(TAG, "Process activity result, code: ${actionCodeToString(requestCode)}, " + + "resultCode: $resultCode, data: $data") + actionResultHandlers[requestCode]?.let { handler -> + when (resultCode) { + RESULT_OK -> handler.onSuccess(data) + else -> handler.onFail(data) } + handler.onAny(data) + actionResultHandlers.remove(requestCode) + } ?: super.onActivityResult(requestCode, resultCode, data) + } - OPEN_FILE_ACTION_CODE -> { - when (resultCode) { - RESULT_OK -> data?.data?.toString() ?: "" - else -> "" - }.let { uri -> - QtAndroidController.onFileOpened(uri) - } + private fun startActivityForResult(intent: Intent, requestCode: Int, handler: ActivityResultHandler) { + actionResultHandlers[requestCode] = handler + startActivityForResult(intent, requestCode) + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + Log.d(TAG, "Process permission result, code: ${actionCodeToString(requestCode)}, " + + "permissions: ${permissions.contentToString()}, results: ${grantResults.contentToString()}") + permissionRequestHandlers[requestCode]?.let { handler -> + if (grantResults.isNotEmpty()) { + if (grantResults[0] == PackageManager.PERMISSION_GRANTED) handler.onSuccess() + else handler.onFail() } + handler.onAny() + permissionRequestHandlers.remove(requestCode) + } ?: super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } - CHECK_VPN_PERMISSION_ACTION_CODE -> { - when (resultCode) { - RESULT_OK -> { - Log.d(TAG, "Vpn permission granted") - Toast.makeText(this, resources.getText(R.string.vpnGranted), Toast.LENGTH_LONG).show() - checkVpnPermissionCallbacks?.run { onSuccess() } - } - - else -> { - Log.w(TAG, "Vpn permission denied, resultCode: $resultCode") - showOnVpnPermissionRejectDialog() - checkVpnPermissionCallbacks?.run { onFail() } - } - } - checkVpnPermissionCallbacks = null - } - - else -> super.onActivityResult(requestCode, resultCode, data) - } + private fun requestPermission(permission: String, requestCode: Int, handler: PermissionRequestHandler) { + permissionRequestHandlers[requestCode] = handler + requestPermissions(arrayOf(permission), requestCode) } /** @@ -268,22 +292,26 @@ class AmneziaActivity : QtActivity() { /** * 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) { + private fun checkVpnPermission(onPermissionGranted: () -> Unit) { Log.d(TAG, "Check VPN permission") - VpnService.prepare(applicationContext)?.let { - checkVpnPermissionCallbacks = CheckVpnPermissionCallbacks(onSuccess, onFail) - startActivityForResult(it, CHECK_VPN_PERMISSION_ACTION_CODE) - return - } - onSuccess() + VpnService.prepare(applicationContext)?.let { intent -> + startActivityForResult(intent, CHECK_VPN_PERMISSION_ACTION_CODE, ActivityResultHandler( + onSuccess = { + Log.d(TAG, "Vpn permission granted") + Toast.makeText(this@AmneziaActivity, resources.getText(R.string.vpnGranted), Toast.LENGTH_LONG).show() + onPermissionGranted() + }, + onFail = { + Log.w(TAG, "Vpn permission denied") + showOnVpnPermissionRejectDialog() + mainScope.launch { + qtInitialized.await() + QtAndroidController.onVpnPermissionRejected() + } + } + )) + } ?: onPermissionGranted() } private fun showOnVpnPermissionRejectDialog() { @@ -297,6 +325,44 @@ class AmneziaActivity : QtActivity() { .show() } + private fun checkNotificationPermission(onChecked: () -> Unit) { + Log.d(TAG, "Check notification permission") + if ( + !isNotificationPermissionGranted() && + !Prefs.load(PREFS_NOTIFICATION_PERMISSION_ASKED) + ) { + showNotificationPermissionDialog(onChecked) + } else { + onChecked() + } + } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + private fun showNotificationPermissionDialog(onChecked: () -> Unit) { + AlertDialog.Builder(this) + .setTitle(R.string.notificationDialogTitle) + .setMessage(R.string.notificationDialogMessage) + .setNegativeButton(R.string.no) { _, _ -> + Prefs.save(PREFS_NOTIFICATION_PERMISSION_ASKED, true) + onChecked() + } + .setPositiveButton(R.string.yes) { _, _ -> + val saveAsked: () -> Unit = { + Prefs.save(PREFS_NOTIFICATION_PERMISSION_ASKED, true) + } + requestPermission( + Manifest.permission.POST_NOTIFICATIONS, + CHECK_NOTIFICATION_PERMISSION_ACTION_CODE, + PermissionRequestHandler( + onSuccess = saveAsked, + onFail = saveAsked, + onAny = onChecked + ) + ) + } + .show() + } + @MainThread private fun startVpn(vpnConfig: String) { if (isServiceConnected) { @@ -322,28 +388,21 @@ class AmneziaActivity : QtActivity() { Intent(this, AmneziaVpnService::class.java).apply { putExtra(MSG_VPN_CONFIG, vpnConfig) }.also { - ContextCompat.startForegroundService(this, it) + try { + ContextCompat.startForegroundService(this, it) + } catch (e: SecurityException) { + Log.e(TAG, "Failed to start AmneziaVpnService: $e") + QtAndroidController.onServiceError() + } } } + @MainThread private fun disconnectFromVpn() { Log.d(TAG, "Disconnect from VPN") vpnServiceMessenger.send(Action.DISCONNECT) } - // saving file - private fun alterDocument(uri: Uri) { - try { - contentResolver.openOutputStream(uri)?.use { os -> - os.bufferedWriter().use { it.write(tmpFileContentToSave) } - } - } catch (e: IOException) { - e.printStackTrace() - } - - tmpFileContentToSave = "" - } - /** * Methods called by Qt */ @@ -357,7 +416,11 @@ class AmneziaActivity : QtActivity() { fun start(vpnConfig: String) { Log.v(TAG, "Start VPN") mainScope.launch { - checkVpnPermissionAndStart(vpnConfig) + checkVpnPermission { + checkNotificationPermission { + startVpn(vpnConfig) + } + } } } @@ -389,14 +452,26 @@ class AmneziaActivity : QtActivity() { fun saveFile(fileName: String, data: String) { Log.d(TAG, "Save file $fileName") mainScope.launch { - tmpFileContentToSave = data - Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE) type = "text/*" putExtra(Intent.EXTRA_TITLE, fileName) }.also { - startActivityForResult(it, CREATE_FILE_ACTION_CODE) + startActivityForResult(it, CREATE_FILE_ACTION_CODE, ActivityResultHandler( + onSuccess = { + it?.data?.let { uri -> + Log.d(TAG, "Save file to $uri") + try { + contentResolver.openOutputStream(uri)?.use { os -> + os.bufferedWriter().use { it.write(data) } + } + } catch (e: IOException) { + Log.e(TAG, "Failed to save file $uri: $e") + // todo: send error to Qt + } + } + } + )) } } } @@ -404,42 +479,47 @@ class AmneziaActivity : QtActivity() { @Suppress("unused") fun openFile(filter: String?) { Log.v(TAG, "Open file with filter: $filter") + mainScope.launch { + val mimeTypes = if (!filter.isNullOrEmpty()) { + val extensionRegex = "\\*\\.([a-z0-9]+)".toRegex(IGNORE_CASE) + val mime = MimeTypeMap.getSingleton() + extensionRegex.findAll(filter).map { + it.groups[1]?.value?.let { mime.getMimeTypeFromExtension(it) } ?: "*/*" + }.toSet() + } else emptySet() - val mimeTypes = if (!filter.isNullOrEmpty()) { - val extensionRegex = "\\*\\.([a-z0-9]+)".toRegex(IGNORE_CASE) - val mime = MimeTypeMap.getSingleton() - extensionRegex.findAll(filter).map { - it.groups[1]?.value?.let { mime.getMimeTypeFromExtension(it) } ?: "*/*" - }.toSet() - } else emptySet() + Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + Log.v(TAG, "File mimyType filter: $mimeTypes") + if ("*/*" in mimeTypes) { + type = "*/*" + } else { + when (mimeTypes.size) { + 1 -> type = mimeTypes.first() - Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - Log.v(TAG, "File mimyType filter: $mimeTypes") - if ("*/*" in mimeTypes) { - type = "*/*" - } else { - when (mimeTypes.size) { - 1 -> type = mimeTypes.first() + in 2..Int.MAX_VALUE -> { + type = "*/*" + putExtra(EXTRA_MIME_TYPES, mimeTypes.toTypedArray()) + } - in 2..Int.MAX_VALUE -> { - type = "*/*" - putExtra(EXTRA_MIME_TYPES, mimeTypes.toTypedArray()) + else -> type = "*/*" } - - else -> type = "*/*" } + }.also { + startActivityForResult(it, OPEN_FILE_ACTION_CODE, ActivityResultHandler( + onSuccess = { + val uri = it?.data?.toString() ?: "" + Log.d(TAG, "Open file: $uri") + mainScope.launch { + qtInitialized.await() + QtAndroidController.onFileOpened(uri) + } + } + )) } - }.also { - startActivityForResult(it, OPEN_FILE_ACTION_CODE) } } - @Suppress("unused") - fun setNotificationText(title: String, message: String, timerSec: Int) { - Log.v(TAG, "Set notification text") - } - @Suppress("unused") fun isCameraPresent(): Boolean = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA) @@ -514,4 +594,76 @@ class AmneziaActivity : QtActivity() { Log.v(TAG, "Get app icon") return AppListProvider.getAppIcon(packageManager, packageName, width, height) } + + @Suppress("unused") + fun isNotificationPermissionGranted(): Boolean = applicationContext.isNotificationPermissionGranted() + + @Suppress("unused") + fun requestNotificationPermission() { + val shouldShowPreRequest = shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) + requestPermission( + Manifest.permission.POST_NOTIFICATIONS, + CHECK_NOTIFICATION_PERMISSION_ACTION_CODE, + PermissionRequestHandler( + onSuccess = { + mainScope.launch { + Prefs.save(PREFS_NOTIFICATION_PERMISSION_ASKED, true) + vpnServiceMessenger.send(Action.NOTIFICATION_PERMISSION_GRANTED) + qtInitialized.await() + QtAndroidController.onNotificationStateChanged() + } + }, + onFail = { + if (!Prefs.load(PREFS_NOTIFICATION_PERMISSION_ASKED)) { + Prefs.save(PREFS_NOTIFICATION_PERMISSION_ASKED, true) + } else { + val shouldShowPostRequest = + shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) + if (!shouldShowPreRequest && !shouldShowPostRequest) { + showNotificationSettingsDialog() + } + } + } + ) + ) + } + + private fun showNotificationSettingsDialog() { + AlertDialog.Builder(this) + .setTitle(R.string.notificationSettingsDialogTitle) + .setMessage(R.string.notificationSettingsDialogMessage) + .setNegativeButton(R.string.cancel) { _, _ -> } + .setPositiveButton(R.string.openNotificationSettings) { _, _ -> + startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { + putExtra(Settings.EXTRA_APP_PACKAGE, packageName) + }) + } + .show() + } + + /** + * Utils methods + */ + companion object { + private fun actionCodeToString(actionCode: Int): String = + when (actionCode) { + CHECK_VPN_PERMISSION_ACTION_CODE -> "CHECK_VPN_PERMISSION" + CREATE_FILE_ACTION_CODE -> "CREATE_FILE" + OPEN_FILE_ACTION_CODE -> "OPEN_FILE" + CHECK_NOTIFICATION_PERMISSION_ACTION_CODE -> "CHECK_NOTIFICATION_PERMISSION" + else -> actionCode.toString() + } + } } + +private class ActivityResultHandler( + val onSuccess: (data: Intent?) -> Unit = {}, + val onFail: (data: Intent?) -> Unit = {}, + val onAny: (data: Intent?) -> Unit = {} +) + +private class PermissionRequestHandler( + val onSuccess: () -> Unit = {}, + val onFail: () -> Unit = {}, + val onAny: () -> Unit = {} +) diff --git a/client/android/src/org/amnezia/vpn/AmneziaApplication.kt b/client/android/src/org/amnezia/vpn/AmneziaApplication.kt index d8c87bd6..8b066056 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaApplication.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaApplication.kt @@ -3,14 +3,11 @@ package org.amnezia.vpn import androidx.camera.camera2.Camera2Config import androidx.camera.core.CameraSelector import androidx.camera.core.CameraXConfig -import androidx.core.app.NotificationChannelCompat.Builder -import androidx.core.app.NotificationManagerCompat import org.amnezia.vpn.util.Log import org.amnezia.vpn.util.Prefs import org.qtproject.qt.android.bindings.QtApplication private const val TAG = "AmneziaApplication" -const val NOTIFICATION_CHANNEL_ID: String = "org.amnezia.vpn.notification" class AmneziaApplication : QtApplication(), CameraXConfig.Provider { @@ -20,7 +17,7 @@ class AmneziaApplication : QtApplication(), CameraXConfig.Provider { Log.init(this) VpnStateStore.init(this) Log.d(TAG, "Create Amnezia application") - createNotificationChannel() + ServiceNotification.createNotificationChannel(this) } override fun getCameraXConfig(): CameraXConfig = CameraXConfig.Builder @@ -28,14 +25,4 @@ class AmneziaApplication : QtApplication(), CameraXConfig.Provider { .setMinimumLoggingLevel(android.util.Log.ERROR) .setAvailableCamerasLimiter(CameraSelector.DEFAULT_BACK_CAMERA) .build() - - private fun createNotificationChannel() { - NotificationManagerCompat.from(this).createNotificationChannel( - Builder(NOTIFICATION_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) - .setName("AmneziaVPN") - .setDescription("AmneziaVPN service notification") - .setShowBadge(false) - .build() - ) - } } diff --git a/client/android/src/org/amnezia/vpn/AmneziaContext.kt b/client/android/src/org/amnezia/vpn/AmneziaContext.kt new file mode 100644 index 00000000..3f2104b5 --- /dev/null +++ b/client/android/src/org/amnezia/vpn/AmneziaContext.kt @@ -0,0 +1,56 @@ +package org.amnezia.vpn + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import androidx.core.content.ContextCompat.RegisterReceiverFlags +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 + +fun Context.getString(state: ProtocolState): String = + getString( + when (state) { + DISCONNECTED, UNKNOWN -> R.string.disconnected + CONNECTED -> R.string.connected + CONNECTING -> R.string.connecting + DISCONNECTING -> R.string.disconnecting + RECONNECTING -> R.string.reconnecting + } + ) + +fun Context.registerBroadcastReceiver( + action: String, + @RegisterReceiverFlags flags: Int = ContextCompat.RECEIVER_EXPORTED, + onReceive: (Intent?) -> Unit +): BroadcastReceiver = registerBroadcastReceiver(arrayOf(action), flags, onReceive) + +fun Context.registerBroadcastReceiver( + actions: Array, + @RegisterReceiverFlags flags: Int = ContextCompat.RECEIVER_EXPORTED, + onReceive: (Intent?) -> Unit +): BroadcastReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + onReceive(intent) + } + }.also { + ContextCompat.registerReceiver( + this, + it, + IntentFilter().apply { + actions.forEach(::addAction) + }, + flags + ) + } + +fun Context.unregisterBroadcastReceiver(receiver: BroadcastReceiver?) { + receiver?.let { this.unregisterReceiver(it) } +} diff --git a/client/android/src/org/amnezia/vpn/AmneziaTileService.kt b/client/android/src/org/amnezia/vpn/AmneziaTileService.kt index 5ad872a0..1d13feac 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaTileService.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaTileService.kt @@ -188,11 +188,16 @@ class AmneziaTileService : TileService() { true } - private fun startVpnService() = - ContextCompat.startForegroundService( - applicationContext, - Intent(this, AmneziaVpnService::class.java) - ) + private fun startVpnService() { + try { + ContextCompat.startForegroundService( + applicationContext, + Intent(this, AmneziaVpnService::class.java) + ) + } catch (e: SecurityException) { + Log.e(TAG, "Failed to start AmneziaVpnService: $e") + } + } private fun connectToVpn() = vpnServiceMessenger.send(Action.CONNECT) @@ -230,7 +235,7 @@ class AmneziaTileService : TileService() { val tile = qsTile ?: return tile.apply { label = vpnState.serverName ?: DEFAULT_TILE_LABEL - when (vpnState.protocolState) { + when (val protocolState = vpnState.protocolState) { CONNECTED -> { state = Tile.STATE_ACTIVE subtitleCompat = null @@ -241,14 +246,9 @@ class AmneziaTileService : TileService() { subtitleCompat = null } - CONNECTING, RECONNECTING -> { + CONNECTING, DISCONNECTING, RECONNECTING -> { state = Tile.STATE_UNAVAILABLE - subtitleCompat = resources.getString(R.string.connecting) - } - - DISCONNECTING -> { - state = Tile.STATE_UNAVAILABLE - subtitleCompat = resources.getString(R.string.disconnecting) + subtitleCompat = getString(protocolState) } } updateTile() diff --git a/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt b/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt index d0a1e8e8..89c53481 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt @@ -2,8 +2,8 @@ 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.app.NotificationManager +import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST @@ -15,10 +15,12 @@ import android.os.IBinder import android.os.Looper import android.os.Message import android.os.Messenger +import android.os.PowerManager import android.os.Process import androidx.annotation.MainThread -import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import java.util.concurrent.ConcurrentHashMap import kotlin.LazyThreadSafetyMode.NONE import kotlinx.coroutines.CoroutineExceptionHandler @@ -54,11 +56,14 @@ import org.amnezia.vpn.protocol.wireguard.Wireguard import org.amnezia.vpn.util.Log import org.amnezia.vpn.util.Prefs import org.amnezia.vpn.util.net.NetworkState +import org.amnezia.vpn.util.net.TrafficStats import org.json.JSONException import org.json.JSONObject private const val TAG = "AmneziaVpnService" +const val ACTION_DISCONNECT = "org.amnezia.vpn.action.disconnect" + const val MSG_VPN_CONFIG = "VPN_CONFIG" const val MSG_ERROR = "ERROR" const val MSG_SAVE_LOGS = "SAVE_LOGS" @@ -69,8 +74,8 @@ 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 STATISTICS_SENDING_TIMEOUT = 1000L +private const val TRAFFIC_STATS_UPDATE_TIMEOUT = 1000L private const val DISCONNECT_TIMEOUT = 5000L private const val STOP_SERVICE_TIMEOUT = 5000L @@ -96,8 +101,14 @@ class AmneziaVpnService : VpnService() { private var connectionJob: Job? = null private var disconnectionJob: Job? = null - private var statisticsSendingJob: Job? = null + private var trafficStatsUpdateJob: Job? = null + // private var statisticsSendingJob: Job? = null private lateinit var networkState: NetworkState + private lateinit var trafficStats: TrafficStats + private var disconnectReceiver: BroadcastReceiver? = null + private var notificationStateReceiver: BroadcastReceiver? = null + private var screenOnReceiver: BroadcastReceiver? = null + private var screenOffReceiver: BroadcastReceiver? = null private val clientMessengers = ConcurrentHashMap() private val isActivityConnected @@ -131,13 +142,13 @@ class AmneziaVpnService : VpnService() { 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() + // 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() + // if (it.name == ACTIVITY_MESSENGER_NAME) stopSendingStatistics() } } @@ -159,6 +170,10 @@ class AmneziaVpnService : VpnService() { } } + Action.NOTIFICATION_PERMISSION_GRANTED -> { + enableNotification() + } + Action.SET_SAVE_LOGS -> { Log.saveLogs = msg.data.getBoolean(MSG_SAVE_LOGS) } @@ -181,25 +196,7 @@ class AmneziaVpnService : VpnService() { else -> 0 } - private val notification: Notification by lazy(NONE) { - NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) - .setSmallIcon(R.drawable.ic_amnezia_round) - .setShowWhen(false) - .setContentIntent( - PendingIntent.getActivity( - this, - 0, - Intent(this, AmneziaActivity::class.java), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - ) - .setOngoing(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setCategory(NotificationCompat.CATEGORY_SERVICE) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) - .build() - } + private val serviceNotification: ServiceNotification by lazy(NONE) { ServiceNotification(this) } /** * Service overloaded methods @@ -212,6 +209,8 @@ class AmneziaVpnService : VpnService() { loadServerData() launchProtocolStateHandler() networkState = NetworkState(this, ::reconnect) + trafficStats = TrafficStats() + registerBroadcastReceivers() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -227,7 +226,10 @@ class AmneziaVpnService : VpnService() { Log.d(TAG, "Start service") connect(intent?.getStringExtra(MSG_VPN_CONFIG)) } - ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, foregroundServiceTypeCompat) + ServiceCompat.startForeground( + this, NOTIFICATION_ID, serviceNotification.buildNotification(serverName, protocolState.value), + foregroundServiceTypeCompat + ) return START_REDELIVER_INTENT } @@ -267,6 +269,7 @@ class AmneziaVpnService : VpnService() { override fun onDestroy() { Log.d(TAG, "Destroy service") + unregisterBroadcastReceivers() runBlocking { disconnect() disconnectionJob?.join() @@ -287,6 +290,63 @@ class AmneziaVpnService : VpnService() { stopSelf() } + private fun registerBroadcastReceivers() { + Log.d(TAG, "Register broadcast receivers") + disconnectReceiver = registerBroadcastReceiver(ACTION_DISCONNECT, ContextCompat.RECEIVER_NOT_EXPORTED) { + Log.d(TAG, "Broadcast request received: $ACTION_DISCONNECT") + disconnect() + } + + notificationStateReceiver = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + registerBroadcastReceiver( + arrayOf( + NotificationManager.ACTION_NOTIFICATION_CHANNEL_BLOCK_STATE_CHANGED, + NotificationManager.ACTION_APP_BLOCK_STATE_CHANGED + ) + ) { + val state = it?.getBooleanExtra(NotificationManager.EXTRA_BLOCKED_STATE, false) + Log.d(TAG, "Notification state changed: ${it?.action}, blocked = $state") + if (state == false) { + enableNotification() + } else { + disableNotification() + } + } + } else null + + registerScreenStateBroadcastReceivers() + } + + private fun registerScreenStateBroadcastReceivers() { + if (serviceNotification.isNotificationEnabled()) { + Log.d(TAG, "Register screen state broadcast receivers") + screenOnReceiver = registerBroadcastReceiver(Intent.ACTION_SCREEN_ON) { + if (isConnected && serviceNotification.isNotificationEnabled()) startTrafficStatsUpdateJob() + } + + screenOffReceiver = registerBroadcastReceiver(Intent.ACTION_SCREEN_OFF) { + stopTrafficStatsUpdateJob() + } + } + } + + private fun unregisterScreenStateBroadcastReceivers() { + Log.d(TAG, "Unregister screen state broadcast receivers") + unregisterBroadcastReceiver(screenOnReceiver) + unregisterBroadcastReceiver(screenOffReceiver) + screenOnReceiver = null + screenOffReceiver = null + } + + private fun unregisterBroadcastReceivers() { + Log.d(TAG, "Unregister broadcast receivers") + unregisterBroadcastReceiver(disconnectReceiver) + unregisterBroadcastReceiver(notificationStateReceiver) + unregisterScreenStateBroadcastReceivers() + disconnectReceiver = null + notificationStateReceiver = null + } + /** * Methods responsible for processing VPN connection */ @@ -295,29 +355,8 @@ class AmneziaVpnService : VpnService() { // drop first default UNKNOWN state protocolState.drop(1).collect { protocolState -> Log.d(TAG, "Protocol state changed: $protocolState") - when (protocolState) { - CONNECTED -> { - networkState.bindNetworkListener() - if (isActivityConnected) launchSendingStatistics() - } - DISCONNECTED -> { - networkState.unbindNetworkListener() - stopSendingStatistics() - if (!isServiceBound) stopService() - } - - DISCONNECTING -> { - networkState.unbindNetworkListener() - stopSendingStatistics() - } - - RECONNECTING -> { - stopSendingStatistics() - } - - CONNECTING, UNKNOWN -> {} - } + serviceNotification.updateNotification(serverName, protocolState) clientMessengers.send { ServiceEvent.STATUS_CHANGED.packToMessage { @@ -326,13 +365,41 @@ class AmneziaVpnService : VpnService() { } VpnStateStore.store { VpnState(protocolState, serverName, serverIndex) } + + when (protocolState) { + CONNECTED -> { + networkState.bindNetworkListener() + // if (isActivityConnected) launchSendingStatistics() + launchTrafficStatsUpdate() + } + + DISCONNECTED -> { + networkState.unbindNetworkListener() + stopTrafficStatsUpdateJob() + // stopSendingStatistics() + if (!isServiceBound) stopService() + } + + DISCONNECTING -> { + networkState.unbindNetworkListener() + stopTrafficStatsUpdateJob() + // stopSendingStatistics() + } + + RECONNECTING -> { + stopTrafficStatsUpdateJob() + // stopSendingStatistics() + } + + CONNECTING, UNKNOWN -> {} + } } } } - @MainThread +/* @MainThread private fun launchSendingStatistics() { - /* if (isServiceBound && isConnected) { + if (isServiceBound && isConnected) { statisticsSendingJob = mainScope.launch { while (true) { clientMessenger.send { @@ -343,12 +410,62 @@ class AmneziaVpnService : VpnService() { delay(STATISTICS_SENDING_TIMEOUT) } } - } */ + } } @MainThread private fun stopSendingStatistics() { statisticsSendingJob?.cancel() + } */ + + @MainThread + private fun enableNotification() { + registerScreenStateBroadcastReceivers() + serviceNotification.updateNotification(serverName, protocolState.value) + launchTrafficStatsUpdate() + } + + @MainThread + private fun disableNotification() { + unregisterScreenStateBroadcastReceivers() + stopTrafficStatsUpdateJob() + } + + @MainThread + private fun launchTrafficStatsUpdate() { + stopTrafficStatsUpdateJob() + if (isConnected && + serviceNotification.isNotificationEnabled() && + getSystemService()?.isInteractive != false + ) { + Log.d(TAG, "Launch traffic stats update") + trafficStats.reset() + startTrafficStatsUpdateJob() + } + } + + @MainThread + private fun startTrafficStatsUpdateJob() { + if (trafficStatsUpdateJob == null && trafficStats.isSupported()) { + Log.d(TAG, "Start traffic stats update") + trafficStatsUpdateJob = mainScope.launch { + while (true) { + trafficStats.getSpeed().let { speed -> + if (isConnected) { + serviceNotification.updateSpeed(speed) + } + } + delay(TRAFFIC_STATS_UPDATE_TIMEOUT) + } + } + } + } + + @MainThread + private fun stopTrafficStatsUpdateJob() { + Log.d(TAG, "Stop traffic stats update") + trafficStatsUpdateJob?.cancel() + trafficStatsUpdateJob = null } @MainThread @@ -471,6 +588,7 @@ class AmneziaVpnService : VpnService() { private fun saveServerData(config: JSONObject?) { serverName = config?.opt("description") as String? serverIndex = config?.opt("serverIndex") as Int? ?: -1 + Log.d(TAG, "Save server data: ($serverIndex, $serverName)") Prefs.save(PREFS_SERVER_NAME, serverName) Prefs.save(PREFS_SERVER_INDEX, serverIndex) } @@ -478,6 +596,7 @@ class AmneziaVpnService : VpnService() { private fun loadServerData() { serverName = Prefs.load(PREFS_SERVER_NAME).ifBlank { null } if (serverName != null) serverIndex = Prefs.load(PREFS_SERVER_INDEX) + Log.d(TAG, "Load server data: ($serverIndex, $serverName)") } private fun checkPermission(): Boolean = @@ -494,9 +613,8 @@ class AmneziaVpnService : VpnService() { companion object { fun isRunning(context: Context): Boolean = - (context.getSystemService(ACTIVITY_SERVICE) as ActivityManager) - .runningAppProcesses.any { - it.processName == PROCESS_NAME && it.importance <= IMPORTANCE_FOREGROUND_SERVICE - } + context.getSystemService()!!.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 2ddff4ef..bd206004 100644 --- a/client/android/src/org/amnezia/vpn/IpcMessage.kt +++ b/client/android/src/org/amnezia/vpn/IpcMessage.kt @@ -32,6 +32,7 @@ enum class Action : IpcMessage { CONNECT, DISCONNECT, REQUEST_STATUS, + NOTIFICATION_PERMISSION_GRANTED, SET_SAVE_LOGS } diff --git a/client/android/src/org/amnezia/vpn/PackageManagerHelper.java b/client/android/src/org/amnezia/vpn/PackageManagerHelper.java deleted file mode 100644 index 55bfbf93..00000000 --- a/client/android/src/org/amnezia/vpn/PackageManagerHelper.java +++ /dev/null @@ -1,189 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -package org.amnezia.vpn; - -import android.Manifest; -import android.content.Context; -import android.content.Intent; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.graphics.Color; -import android.graphics.drawable.ColorDrawable; -import android.graphics.drawable.Drawable; -import android.Manifest.permission; -import android.net.Uri; -import android.os.Build; -import android.util.Log; -import android.webkit.WebView; - -import org.json.JSONException; -import org.json.JSONObject; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.regex.Pattern; - -// Gets used by /platforms/android/androidAppListProvider.cpp -public class PackageManagerHelper { - final static String TAG = "PackageManagerHelper"; - final static int MIN_CHROME_VERSION = 65; - - final static List CHROME_BROWSERS = Arrays.asList( - new String[] {"com.google.android.webview", "com.android.webview", "com.google.chrome"}); - - private static String getAllAppNames(Context ctx) { - JSONObject output = new JSONObject(); - PackageManager pm = ctx.getPackageManager(); - List browsers = getBrowserIDs(pm); - List packs = pm.getInstalledPackages(PackageManager.GET_PERMISSIONS); - for (int i = 0; i < packs.size(); i++) { - PackageInfo p = packs.get(i); - // Do not add ourselves and System Apps to the list, unless it might be a browser - if ((!isSystemPackage(p,pm) || browsers.contains(p.packageName)) - && !isSelf(p)) { - String appid = p.packageName; - String appName = p.applicationInfo.loadLabel(pm).toString(); - try { - output.put(appid, appName); - } catch (JSONException e) { - e.printStackTrace(); - } - } - } - return output.toString(); - } - - private static Drawable getAppIcon(Context ctx, String id) { - try { - return ctx.getPackageManager().getApplicationIcon(id); - } catch (PackageManager.NameNotFoundException e) { - e.printStackTrace(); - } - return new ColorDrawable(Color.TRANSPARENT); - } - - private static boolean isSystemPackage(PackageInfo pkgInfo, PackageManager pm) { - if( (pkgInfo.applicationInfo.flags & ApplicationInfo.FLAG_SYSTEM) == 0){ - // no system app - return false; - } - // For Systems Packages there are Cases where we want to add it anyway: - // Has the use Internet permission (otherwise makes no sense) - // Had at least 1 update (this means it's probably on any AppStore) - // Has a a launch activity (has a ui and is not just a system service) - - if(!usesInternet(pkgInfo)){ - return true; - } - if(!hadUpdate(pkgInfo)){ - return true; - } - if(pm.getLaunchIntentForPackage(pkgInfo.packageName) == null){ - // If there is no way to launch this from a homescreen, def a sys package - return true; - } - return false; - } - private static boolean isSelf(PackageInfo pkgInfo) { - return pkgInfo.packageName.equals("org.amnezia.vpn") - || pkgInfo.packageName.equals("org.amnezia.vpn.debug"); - } - private static boolean usesInternet(PackageInfo pkgInfo){ - if(pkgInfo.requestedPermissions == null){ - return false; - } - for(int i=0; i < pkgInfo.requestedPermissions.length; i++) { - String permission = pkgInfo.requestedPermissions[i]; - if(Manifest.permission.INTERNET.equals(permission)){ - return true; - } - } - return false; - } - private static boolean hadUpdate(PackageInfo pkgInfo){ - return pkgInfo.lastUpdateTime > pkgInfo.firstInstallTime; - } - - // Returns List of all Packages that can classify themselves as browsers - private static List getBrowserIDs(PackageManager pm) { - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse("https://www.amnezia.org/")); - intent.addCategory(Intent.CATEGORY_BROWSABLE); - // We've tried using PackageManager.MATCH_DEFAULT_ONLY flag and found that browsers that - // are not set as the default browser won't be matched even if they had CATEGORY_DEFAULT set - // in the intent filter - - List resolveInfos = pm.queryIntentActivities(intent, PackageManager.MATCH_ALL); - List browsers = new ArrayList(); - for (int i = 0; i < resolveInfos.size(); i++) { - ResolveInfo info = resolveInfos.get(i); - String browserID = info.activityInfo.packageName; - browsers.add(browserID); - } - return browsers; - } - - // Gets called in AndroidAuthenticationListener; - public static boolean isWebViewSupported(Context ctx) { - Log.v(TAG, "Checking if installed Webview is compatible with FxA"); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - // The default Webview is able do to FXA - return true; - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - PackageInfo pi = WebView.getCurrentWebViewPackage(); - if (CHROME_BROWSERS.contains(pi.packageName)) { - return isSupportedChromeBrowser(pi); - } - return isNotAncientBrowser(pi); - } - - // Before O the webview is hardcoded, but we dont know which package it is. - // Check if com.google.android.webview is installed - PackageManager pm = ctx.getPackageManager(); - try { - PackageInfo pi = pm.getPackageInfo("com.google.android.webview", 0); - return isSupportedChromeBrowser(pi); - } catch (PackageManager.NameNotFoundException e) { - } - // Otherwise check com.android.webview - try { - PackageInfo pi = pm.getPackageInfo("com.android.webview", 0); - return isSupportedChromeBrowser(pi); - } catch (PackageManager.NameNotFoundException e) { - } - Log.e(TAG, "Android System WebView is not found"); - // Giving up :( - return false; - } - - private static boolean isSupportedChromeBrowser(PackageInfo pi) { - Log.d(TAG, "Checking Chrome Based Browser: " + pi.packageName); - Log.d(TAG, "version name: " + pi.versionName); - Log.d(TAG, "version code: " + pi.versionCode); - try { - String versionCode = pi.versionName.split(Pattern.quote(" "))[0]; - String majorVersion = versionCode.split(Pattern.quote("."))[0]; - int version = Integer.parseInt(majorVersion); - return version >= MIN_CHROME_VERSION; - } catch (Exception e) { - Log.e(TAG, "Failed to check Chrome Version Code " + pi.versionName); - return false; - } - } - - private static boolean isNotAncientBrowser(PackageInfo pi) { - // Not a google chrome - So the version name is worthless - // Lets just make sure the WebView - // used is not ancient ==> Was updated in at least the last 365 days - Log.d(TAG, "Checking Chrome Based Browser: " + pi.packageName); - Log.d(TAG, "version name: " + pi.versionName); - Log.d(TAG, "version code: " + pi.versionCode); - double oneYearInMillis = 31536000000L; - return pi.lastUpdateTime > (System.currentTimeMillis() - oneYearInMillis); - } -} diff --git a/client/android/src/org/amnezia/vpn/ServiceNotification.kt b/client/android/src/org/amnezia/vpn/ServiceNotification.kt new file mode 100644 index 00000000..efdd04d3 --- /dev/null +++ b/client/android/src/org/amnezia/vpn/ServiceNotification.kt @@ -0,0 +1,180 @@ +package org.amnezia.vpn + +import android.Manifest.permission +import android.annotation.SuppressLint +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.core.app.NotificationChannelCompat.Builder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.Action +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import org.amnezia.vpn.protocol.ProtocolState +import org.amnezia.vpn.protocol.ProtocolState.CONNECTED +import org.amnezia.vpn.protocol.ProtocolState.DISCONNECTED +import org.amnezia.vpn.util.Log +import org.amnezia.vpn.util.net.TrafficStats.TrafficData + +private const val TAG = "ServiceNotification" + +private const val OLD_NOTIFICATION_CHANNEL_ID: String = "org.amnezia.vpn.notification" +private const val NOTIFICATION_CHANNEL_ID: String = "org.amnezia.vpn.notifications" +const val NOTIFICATION_ID = 1337 + +private const val GET_ACTIVITY_REQUEST_CODE = 0 +private const val CONNECT_REQUEST_CODE = 1 +private const val DISCONNECT_REQUEST_CODE = 2 + +class ServiceNotification(private val context: Context) { + + private val upDownSymbols = when (Build.BRAND) { + "Infinix" -> '˅' to '˄' + else -> '↓' to '↑' + } + + private val notificationManager = NotificationManagerCompat.from(context) + + private val notificationBuilder = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setShowWhen(false) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setContentIntent( + PendingIntent.getActivity( + context, + GET_ACTIVITY_REQUEST_CODE, + Intent(context, AmneziaActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + + private val zeroSpeed: String = with(TrafficData.ZERO) { + formatSpeedString(rxString, txString) + } + + fun buildNotification(serverName: String?, state: ProtocolState): Notification { + val speedString = if (state == CONNECTED) zeroSpeed else null + + Log.d(TAG, "Build notification: $serverName, $state") + + return notificationBuilder + .setSmallIcon(R.drawable.ic_amnezia_round) + .setContentTitle(serverName ?: "AmneziaVPN") + .setContentText(context.getString(state)) + .setSubText(speedString) + .setWhen(System.currentTimeMillis()) + .clearActions() + .apply { + getAction(state)?.let { + addAction(it) + } + } + .build() + } + + private fun buildNotification(speed: TrafficData): Notification = + notificationBuilder + .setWhen(System.currentTimeMillis()) + .setSubText(getSpeedString(speed)) + .build() + + fun isNotificationEnabled(): Boolean { + if (!context.isNotificationPermissionGranted()) return false + if (!notificationManager.areNotificationsEnabled()) return false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) + ?.let { it.importance != NotificationManager.IMPORTANCE_NONE } ?: true + } + return true + } + + @SuppressLint("MissingPermission") + fun updateNotification(serverName: String?, state: ProtocolState) { + if (context.isNotificationPermissionGranted()) { + Log.d(TAG, "Update notification: $serverName, $state") + notificationManager.notify(NOTIFICATION_ID, buildNotification(serverName, state)) + } + } + + @SuppressLint("MissingPermission") + fun updateSpeed(speed: TrafficData) { + if (context.isNotificationPermissionGranted()) { + notificationManager.notify(NOTIFICATION_ID, buildNotification(speed)) + } + } + + private fun getSpeedString(traffic: TrafficData) = + if (traffic == TrafficData.ZERO) zeroSpeed + else formatSpeedString(traffic.rxString, traffic.txString) + + private fun formatSpeedString(rx: String, tx: String) = with(upDownSymbols) { "$first $rx $second $tx" } + + private fun getAction(state: ProtocolState): Action? { + return when (state) { + CONNECTED -> { + Action( + 0, context.getString(R.string.disconnect), + PendingIntent.getBroadcast( + context, + DISCONNECT_REQUEST_CODE, + Intent(ACTION_DISCONNECT).apply { + setPackage("org.amnezia.vpn") + }, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + } + + DISCONNECTED -> { + Action( + 0, context.getString(R.string.connect), + createServicePendingIntent( + context, + CONNECT_REQUEST_CODE, + Intent(context, AmneziaVpnService::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + } + + else -> null + } + } + + private val createServicePendingIntent: (Context, Int, Intent, Int) -> PendingIntent = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PendingIntent::getForegroundService + } else { + PendingIntent::getService + } + + companion object { + fun createNotificationChannel(context: Context) { + with(NotificationManagerCompat.from(context)) { + deleteNotificationChannel(OLD_NOTIFICATION_CHANNEL_ID) + createNotificationChannel( + Builder(NOTIFICATION_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setShowBadge(false) + .setSound(null, null) + .setVibrationEnabled(false) + .setLightsEnabled(false) + .setName("AmneziaVPN") + .setDescription(context.resources.getString(R.string.notificationChannelDescription)) + .build() + ) + } + } + } +} + +fun Context.isNotificationPermissionGranted(): Boolean = + Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || + ContextCompat.checkSelfPermission(this, permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED diff --git a/client/android/src/org/amnezia/vpn/VpnRequestActivity.kt b/client/android/src/org/amnezia/vpn/VpnRequestActivity.kt index 74aeb578..12d3fb3d 100644 --- a/client/android/src/org/amnezia/vpn/VpnRequestActivity.kt +++ b/client/android/src/org/amnezia/vpn/VpnRequestActivity.kt @@ -3,9 +3,7 @@ package org.amnezia.vpn import android.app.AlertDialog import android.app.KeyguardManager import android.content.BroadcastReceiver -import android.content.Context import android.content.Intent -import android.content.IntentFilter import android.content.res.Configuration.UI_MODE_NIGHT_MASK import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.net.VpnService @@ -33,11 +31,9 @@ class VpnRequestActivity : ComponentActivity() { val requestIntent = VpnService.prepare(applicationContext) if (requestIntent != null) { if (getSystemService()!!.isKeyguardLocked) { - userPresentReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) = - requestLauncher.launch(requestIntent) + userPresentReceiver = registerBroadcastReceiver(Intent.ACTION_USER_PRESENT) { + requestLauncher.launch(requestIntent) } - registerReceiver(userPresentReceiver, IntentFilter(Intent.ACTION_USER_PRESENT)) } else { requestLauncher.launch(requestIntent) } @@ -49,9 +45,8 @@ class VpnRequestActivity : ComponentActivity() { } override fun onDestroy() { - userPresentReceiver?.let { - unregisterReceiver(it) - } + unregisterBroadcastReceiver(userPresentReceiver) + userPresentReceiver = null super.onDestroy() } diff --git a/client/android/src/org/amnezia/vpn/VpnState.kt b/client/android/src/org/amnezia/vpn/VpnState.kt index 4d5e9c99..fbc4ef59 100644 --- a/client/android/src/org/amnezia/vpn/VpnState.kt +++ b/client/android/src/org/amnezia/vpn/VpnState.kt @@ -4,6 +4,8 @@ import android.app.Application import androidx.datastore.core.MultiProcessDataStoreFactory import androidx.datastore.core.Serializer import androidx.datastore.dataStoreFile +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream import java.io.InputStream import java.io.ObjectInputStream import java.io.ObjectOutputStream @@ -59,7 +61,8 @@ private class VpnStateSerializer : Serializer { override suspend fun readFrom(input: InputStream): VpnState { return withContext(Dispatchers.IO) { - ObjectInputStream(input).use { + val bios = ByteArrayInputStream(input.readBytes()) + ObjectInputStream(bios).use { it.readObject() as VpnState } } @@ -67,9 +70,11 @@ private class VpnStateSerializer : Serializer { override suspend fun writeTo(t: VpnState, output: OutputStream) { withContext(Dispatchers.IO) { - ObjectOutputStream(output).use { + val baos = ByteArrayOutputStream() + ObjectOutputStream(baos).use { it.writeObject(t) } + output.write(baos.toByteArray()) } } } diff --git a/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt b/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt index 537d9925..e382b080 100644 --- a/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt +++ b/client/android/src/org/amnezia/vpn/qt/QtAndroidController.kt @@ -17,6 +17,7 @@ object QtAndroidController { external fun onServiceError() external fun onVpnPermissionRejected() + external fun onNotificationStateChanged() external fun onVpnStateChanged(stateCode: Int) external fun onStatisticsUpdate(rxBytes: Long, txBytes: Long) diff --git a/client/android/utils/build.gradle.kts b/client/android/utils/build.gradle.kts index 2ad03d61..6f44624c 100644 --- a/client/android/utils/build.gradle.kts +++ b/client/android/utils/build.gradle.kts @@ -17,5 +17,7 @@ android { } dependencies { + implementation(libs.androidx.core) + implementation(libs.kotlinx.coroutines) implementation(libs.androidx.security.crypto) } diff --git a/client/android/utils/src/main/kotlin/net/InetEndpoint.kt b/client/android/utils/src/main/kotlin/net/InetEndpoint.kt index f131182c..1bf63bf7 100644 --- a/client/android/utils/src/main/kotlin/net/InetEndpoint.kt +++ b/client/android/utils/src/main/kotlin/net/InetEndpoint.kt @@ -1,16 +1,21 @@ package org.amnezia.vpn.util.net +import java.net.Inet4Address import java.net.InetAddress data class InetEndpoint(val address: InetAddress, val port: Int) { - override fun toString(): String = "${address.hostAddress}:$port" + override fun toString(): String = if (address is Inet4Address) { + "${address.ip}:$port" + } else { + "[${address.ip}]:$port" + } companion object { fun parse(data: String): InetEndpoint { - val split = data.split(":") - val address = parseInetAddress(split.first()) - val port = split.last().toInt() + val i = data.lastIndexOf(':') + val address = parseInetAddress(data.substring(0, i)) + val port = data.substring(i + 1).toInt() return InetEndpoint(address, port) } } diff --git a/client/android/utils/src/main/kotlin/net/InetNetwork.kt b/client/android/utils/src/main/kotlin/net/InetNetwork.kt index 2285454b..a21528b0 100644 --- a/client/android/utils/src/main/kotlin/net/InetNetwork.kt +++ b/client/android/utils/src/main/kotlin/net/InetNetwork.kt @@ -9,7 +9,11 @@ data class InetNetwork(val address: InetAddress, val mask: Int) { constructor(address: InetAddress) : this(address, address.maxPrefixLength) - override fun toString(): String = "${address.hostAddress}/$mask" + val isIpv4: Boolean = address is Inet4Address + val isIpv6: Boolean + get() = !isIpv4 + + override fun toString(): String = "${address.ip}/$mask" companion object { fun parse(data: String): InetNetwork { diff --git a/client/android/utils/src/main/kotlin/net/IpAddress.kt b/client/android/utils/src/main/kotlin/net/IpAddress.kt index 83880b91..2f046b8b 100644 --- a/client/android/utils/src/main/kotlin/net/IpAddress.kt +++ b/client/android/utils/src/main/kotlin/net/IpAddress.kt @@ -3,12 +3,17 @@ package org.amnezia.vpn.util.net import java.net.InetAddress @OptIn(ExperimentalUnsignedTypes::class) -class IpAddress private constructor(private val address: UByteArray) : Comparable { +internal class IpAddress private constructor(private val address: UByteArray) : Comparable { val size: Int = address.size val lastIndex: Int = address.lastIndex val maxMask: Int = size * 8 + @OptIn(ExperimentalStdlibApi::class) + val hexFormat: HexFormat by lazy { + HexFormat { number.removeLeadingZeros = true } + } + constructor(inetAddress: InetAddress) : this(inetAddress.address.asUByteArray()) constructor(ipAddress: String) : this(parseInetAddress(ipAddress)) @@ -43,6 +48,8 @@ class IpAddress private constructor(private val address: UByteArray) : Comparabl return copy } + fun isMinIp(): Boolean = address.all { it == 0x00u.toUByte() } + fun isMaxIp(): Boolean = address.all { it == 0xffu.toUByte() } override fun compareTo(other: IpAddress): Int { @@ -74,12 +81,14 @@ class IpAddress private constructor(private val address: UByteArray) : Comparabl private fun toIpv6String(): String { val sb = StringBuilder() var i = 0 + var block: Int while (i < size) { - sb.append(address[i++].toHexString()) - sb.append(address[i++].toHexString()) + block = address[i++].toInt() shl 8 + block += address[i++].toInt() + sb.append(block.toHexString(hexFormat)) sb.append(':') } sb.deleteAt(sb.lastIndex) - return sb.toString() + return convertIpv6ToCanonicalForm(sb.toString()) } } diff --git a/client/android/utils/src/main/kotlin/net/IpRange.kt b/client/android/utils/src/main/kotlin/net/IpRange.kt index 834c762c..cf169791 100644 --- a/client/android/utils/src/main/kotlin/net/IpRange.kt +++ b/client/android/utils/src/main/kotlin/net/IpRange.kt @@ -2,14 +2,24 @@ package org.amnezia.vpn.util.net import java.net.InetAddress -class IpRange(private val start: IpAddress, private val end: IpAddress) : Comparable { +class IpRange internal constructor( + internal val start: IpAddress, + internal val end: IpAddress +) : Comparable { init { - if (start > end) throw IllegalArgumentException("Start IP: $start is greater then end IP: $end") + if (start.size != end.size) { + throw IllegalArgumentException( + "Unable to create a range between IPv4 and IPv6 addresses (start IP: [$start], end IP: [$end])" + ) + } + if (start > end) throw IllegalArgumentException("Start IP: [$start] is greater then end IP: [$end]") } private constructor(addresses: Pair) : this(addresses.first, addresses.second) + internal constructor(ipAddress: IpAddress) : this(ipAddress, ipAddress) + constructor(inetAddress: InetAddress, mask: Int) : this(from(inetAddress, mask)) constructor(address: String, mask: Int) : this(parseInetAddress(address), mask) @@ -22,6 +32,13 @@ class IpRange(private val start: IpAddress, private val end: IpAddress) : Compar private fun isIntersect(other: IpRange): Boolean = (start <= other.end) && (end >= other.start) + operator fun plus(other: IpRange): IpRange? { + if (start > other.end && !start.isMinIp() && start.dec() == other.end) return IpRange(other.start, end) + if (end < other.start && !end.isMaxIp() && end.inc() == other.start) return IpRange(start, other.end) + if (!isIntersect(other)) return null + return IpRange(minOf(start, other.start), maxOf(end, other.end)) + } + operator fun minus(other: IpRange): List? { if (this in other) return emptyList() if (!isIntersect(other)) return null @@ -94,9 +111,7 @@ class IpRange(private val start: IpAddress, private val end: IpAddress) : Compar return result } - override fun toString(): String { - return "$start - $end" - } + override fun toString(): String = if (start == end) "<$start>" else "<$start - $end>" companion object { private fun from(inetAddress: InetAddress, mask: Int): Pair { diff --git a/client/android/utils/src/main/kotlin/net/IpRangeSet.kt b/client/android/utils/src/main/kotlin/net/IpRangeSet.kt index 45bae854..1053a0c6 100644 --- a/client/android/utils/src/main/kotlin/net/IpRangeSet.kt +++ b/client/android/utils/src/main/kotlin/net/IpRangeSet.kt @@ -1,15 +1,35 @@ package org.amnezia.vpn.util.net -class IpRangeSet(ipRange: IpRange = IpRange("0.0.0.0", 0)) { +class IpRangeSet { - private val ranges = sortedSetOf(ipRange) + private val ranges = sortedSetOf() + + fun add(ipRange: IpRange) { + val iterator = ranges.iterator() + var rangeToAdd = ipRange + run { + while (iterator.hasNext()) { + val curRange = iterator.next() + if (rangeToAdd.end < curRange.start && + !rangeToAdd.end.isMaxIp() && + rangeToAdd.end.inc() != curRange.start) break + (curRange + rangeToAdd)?.let { resultRange -> + if (resultRange == curRange) return@run + iterator.remove() + rangeToAdd = resultRange + } + } + ranges += rangeToAdd + } + } fun remove(ipRange: IpRange) { val iterator = ranges.iterator() val splitRanges = mutableListOf() while (iterator.hasNext()) { - val range = iterator.next() - (range - ipRange)?.let { resultRanges -> + val curRange = iterator.next() + if (ipRange.end < curRange.start) break + (curRange - ipRange)?.let { resultRanges -> iterator.remove() splitRanges += resultRanges } @@ -17,10 +37,7 @@ class IpRangeSet(ipRange: IpRange = IpRange("0.0.0.0", 0)) { ranges += splitRanges } - fun subnets(): List = - ranges.map(IpRange::subnets).flatten() + fun subnets(): List = ranges.map(IpRange::subnets).flatten() - override fun toString(): String { - return ranges.toString() - } + override fun toString(): String = ranges.toString() } diff --git a/client/android/utils/src/main/kotlin/net/NetworkState.kt b/client/android/utils/src/main/kotlin/net/NetworkState.kt index 957fc3cb..3cff8c04 100644 --- a/client/android/utils/src/main/kotlin/net/NetworkState.kt +++ b/client/android/utils/src/main/kotlin/net/NetworkState.kt @@ -10,7 +10,9 @@ import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED import android.net.NetworkRequest import android.os.Build import android.os.Handler +import androidx.core.content.getSystemService import kotlin.LazyThreadSafetyMode.NONE +import kotlinx.coroutines.delay import org.amnezia.vpn.util.Log private const val TAG = "NetworkState" @@ -28,7 +30,7 @@ class NetworkState( } private val connectivityManager: ConnectivityManager by lazy(NONE) { - context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + context.getSystemService()!! } private val networkRequest: NetworkRequest by lazy(NONE) { @@ -80,13 +82,24 @@ class NetworkState( } } - fun bindNetworkListener() { + suspend fun bindNetworkListener() { if (isListenerBound) return Log.d(TAG, "Bind network listener") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { connectivityManager.registerBestMatchingNetworkCallback(networkRequest, networkCallback, handler) } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - connectivityManager.requestNetwork(networkRequest, networkCallback, handler) + try { + connectivityManager.requestNetwork(networkRequest, networkCallback, handler) + } catch (e: SecurityException) { + Log.e(TAG, "Failed to bind network listener: $e") + // Android 11 bug: https://issuetracker.google.com/issues/175055271 + if (e.message?.startsWith("Package android does not belong to") == true) { + delay(1000) + connectivityManager.requestNetwork(networkRequest, networkCallback, handler) + } else { + throw e + } + } } else { connectivityManager.requestNetwork(networkRequest, networkCallback) } diff --git a/client/android/utils/src/main/kotlin/net/NetworkUtils.kt b/client/android/utils/src/main/kotlin/net/NetworkUtils.kt index 83160e70..b75748be 100644 --- a/client/android/utils/src/main/kotlin/net/NetworkUtils.kt +++ b/client/android/utils/src/main/kotlin/net/NetworkUtils.kt @@ -5,12 +5,14 @@ import android.net.ConnectivityManager import android.net.InetAddresses import android.net.NetworkCapabilities import android.os.Build +import androidx.core.content.getSystemService +import java.lang.reflect.InvocationTargetException import java.net.Inet4Address import java.net.Inet6Address import java.net.InetAddress fun getLocalNetworks(context: Context, ipv6: Boolean): List { - val connectivityManager = context.getSystemService(ConnectivityManager::class.java) + val connectivityManager = context.getSystemService()!! connectivityManager.activeNetwork?.let { network -> val netCapabilities = connectivityManager.getNetworkCapabilities(network) val linkProperties = connectivityManager.getLinkProperties(network) @@ -39,8 +41,28 @@ private val parseNumericAddressCompat: (String) -> InetAddress = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { InetAddresses::parseNumericAddress } else { - val m = InetAddress::class.java.getMethod("parseNumericAddress", String::class.java) - fun(address: String): InetAddress { - return m.invoke(null, address) as InetAddress + try { + val m = InetAddress::class.java.getMethod("parseNumericAddress", String::class.java) + fun(address: String): InetAddress { + try { + return m.invoke(null, address) as InetAddress + } catch (e: InvocationTargetException) { + throw e.cause ?: e + } + } + } catch (_: NoSuchMethodException) { + fun(address: String): InetAddress { + return InetAddress.getByName(address) + } } } + +internal fun convertIpv6ToCanonicalForm(ipv6: String): String = ipv6 + .replace("((?:(?:^|:)0+\\b){2,}):?(?!\\S*\\b\\1:0+\\b)(\\S*)".toRegex(), "::$2") + +internal val InetAddress.ip: String + get() = if (this is Inet4Address) { + hostAddress!! + } else { + convertIpv6ToCanonicalForm(hostAddress!!) + } diff --git a/client/android/utils/src/main/kotlin/net/TrafficStats.kt b/client/android/utils/src/main/kotlin/net/TrafficStats.kt new file mode 100644 index 00000000..170d164e --- /dev/null +++ b/client/android/utils/src/main/kotlin/net/TrafficStats.kt @@ -0,0 +1,93 @@ +package org.amnezia.vpn.util.net + +import android.net.TrafficStats +import android.os.Build +import android.os.Process +import android.os.SystemClock +import kotlin.math.roundToLong + +private const val BYTE = 1L +private const val KiB = BYTE shl 10 +private const val MiB = KiB shl 10 +private const val GiB = MiB shl 10 +private const val TiB = GiB shl 10 + +class TrafficStats { + + private var lastTrafficData = TrafficData.ZERO + private var lastTimestamp = 0L + + private val getTrafficDataCompat: () -> TrafficData = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val iface = "tun0" + fun(): TrafficData { + return TrafficData(TrafficStats.getRxBytes(iface), TrafficStats.getTxBytes(iface)) + } + } else { + val uid = Process.myUid() + fun(): TrafficData { + return TrafficData(TrafficStats.getUidRxBytes(uid), TrafficStats.getUidTxBytes(uid)) + } + } + + fun reset() { + lastTrafficData = getTrafficDataCompat() + lastTimestamp = SystemClock.elapsedRealtime() + } + + fun isSupported(): Boolean = + lastTrafficData.rx != TrafficStats.UNSUPPORTED.toLong() && lastTrafficData.tx != TrafficStats.UNSUPPORTED.toLong() + + fun getSpeed(): TrafficData { + val timestamp = SystemClock.elapsedRealtime() + val elapsedSeconds = (timestamp - lastTimestamp) / 1000.0 + val trafficData = getTrafficDataCompat() + val speed = trafficData.diff(lastTrafficData, elapsedSeconds) + lastTrafficData = trafficData + lastTimestamp = timestamp + return speed + } + + class TrafficData(val rx: Long, val tx: Long) { + + private var _rxString: String? = null + val rxString: String + get() { + if (_rxString == null) _rxString = rx.speedToString() + return _rxString ?: throw AssertionError("Set to null by another thread") + } + + private var _txString: String? = null + val txString: String + get() { + if (_txString == null) _txString = tx.speedToString() + return _txString ?: throw AssertionError("Set to null by another thread") + } + + fun diff(other: TrafficData, elapsedSeconds: Double): TrafficData { + val rx = ((this.rx - other.rx) / elapsedSeconds).round() + val tx = ((this.tx - other.tx) / elapsedSeconds).round() + return if (rx == 0L && tx == 0L) ZERO else TrafficData(rx, tx) + } + + private fun Double.round() = if (isNaN()) 0L else roundToLong() + + private fun Long.speedToString() = + when { + this < KiB -> formatSize(this, BYTE, "B/s") + this < MiB -> formatSize(this, KiB, "KiB/s") + this < GiB -> formatSize(this, MiB, "MiB/s") + this < TiB -> formatSize(this, GiB, "GiB/s") + else -> formatSize(this, TiB, "TiB/s") + } + + private fun formatSize(bytes: Long, divider: Long, unit: String): String { + val s = (bytes.toDouble() / divider * 100).roundToLong() / 100.0 + return "${s.toString().removeSuffix(".0")} $unit" + } + + companion object { + val ZERO: TrafficData = TrafficData(0L, 0L) + } + } +} diff --git a/client/android/wireguard/src/main/kotlin/org/amnezia/vpn/protocol/wireguard/WireguardConfig.kt b/client/android/wireguard/src/main/kotlin/org/amnezia/vpn/protocol/wireguard/WireguardConfig.kt index 0e303f0e..09269f54 100644 --- a/client/android/wireguard/src/main/kotlin/org/amnezia/vpn/protocol/wireguard/WireguardConfig.kt +++ b/client/android/wireguard/src/main/kotlin/org/amnezia/vpn/protocol/wireguard/WireguardConfig.kt @@ -37,8 +37,8 @@ open class WireguardConfig protected constructor( open fun appendPeerLine(sb: StringBuilder) = with(sb) { appendLine("public_key=$publicKeyHex") - routes.forEach { route -> - appendLine("allowed_ip=$route") + routes.filter { it.include }.forEach { route -> + appendLine("allowed_ip=${route.inetNetwork}") } appendLine("endpoint=$endpoint") if (persistentKeepalive != 0) diff --git a/client/cmake/android.cmake b/client/cmake/android.cmake index d3ba196d..c39642ff 100644 --- a/client/cmake/android.cmake +++ b/client/cmake/android.cmake @@ -26,7 +26,6 @@ link_directories(${CMAKE_CURRENT_SOURCE_DIR}/platforms/android) set(HEADERS ${HEADERS} ${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/android_controller.h - ${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/android_notificationhandler.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/android_utils.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/authResultReceiver.h ${CMAKE_CURRENT_SOURCE_DIR}/protocols/android_vpnprotocol.h @@ -35,7 +34,6 @@ set(HEADERS ${HEADERS} set(SOURCES ${SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/android_controller.cpp - ${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/android_notificationhandler.cpp ${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/android_utils.cpp ${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/authResultReceiver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/protocols/android_vpnprotocol.cpp diff --git a/client/platforms/android/android_controller.cpp b/client/platforms/android/android_controller.cpp index ce2aeb4c..0311fd0a 100644 --- a/client/platforms/android/android_controller.cpp +++ b/client/platforms/android/android_controller.cpp @@ -93,6 +93,7 @@ bool AndroidController::initialize() {"onServiceDisconnected", "()V", reinterpret_cast(onServiceDisconnected)}, {"onServiceError", "()V", reinterpret_cast(onServiceError)}, {"onVpnPermissionRejected", "()V", reinterpret_cast(onVpnPermissionRejected)}, + {"onNotificationStateChanged", "()V", reinterpret_cast(onNotificationStateChanged)}, {"onVpnStateChanged", "(I)V", reinterpret_cast(onVpnStateChanged)}, {"onStatisticsUpdate", "(JJ)V", reinterpret_cast(onStatisticsUpdate)}, {"onFileOpened", "(Ljava/lang/String;)V", reinterpret_cast(onFileOpened)}, @@ -173,14 +174,6 @@ QString AndroidController::openFile(const QString &filter) return fileName; } -void AndroidController::setNotificationText(const QString &title, const QString &message, int timerSec) -{ - callActivityMethod("setNotificationText", "(Ljava/lang/String;Ljava/lang/String;I)V", - QJniObject::fromString(title).object(), - QJniObject::fromString(message).object(), - (jint) timerSec); -} - bool AndroidController::isCameraPresent() { return callActivityMethod("isCameraPresent", "()Z"); @@ -257,6 +250,16 @@ QPixmap AndroidController::getAppIcon(const QString &package, QSize *size, const return QPixmap::fromImage(image); } +bool AndroidController::isNotificationPermissionGranted() +{ + return callActivityMethod("isNotificationPermissionGranted", "()Z"); +} + +void AndroidController::requestNotificationPermission() +{ + callActivityMethod("requestNotificationPermission", "()V"); +} + // Moving log processing to the Android side jclass AndroidController::log; jmethodID AndroidController::logDebug; @@ -409,6 +412,15 @@ void AndroidController::onVpnPermissionRejected(JNIEnv *env, jobject thiz) emit AndroidController::instance()->vpnPermissionRejected(); } +// static +void AndroidController::onNotificationStateChanged(JNIEnv *env, jobject thiz) +{ + Q_UNUSED(env); + Q_UNUSED(thiz); + + emit AndroidController::instance()->notificationStateChanged(); +} + // static void AndroidController::onVpnStateChanged(JNIEnv *env, jobject thiz, jint stateCode) { diff --git a/client/platforms/android/android_controller.h b/client/platforms/android/android_controller.h index 15de0ccc..d015dbe3 100644 --- a/client/platforms/android/android_controller.h +++ b/client/platforms/android/android_controller.h @@ -32,7 +32,6 @@ 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); bool isCameraPresent(); @@ -44,6 +43,8 @@ public: void minimizeApp(); QJsonArray getAppList(); QPixmap getAppIcon(const QString &package, QSize *size, const QSize &requestedSize); + bool isNotificationPermissionGranted(); + void requestNotificationPermission(); static bool initLogging(); static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message); @@ -54,6 +55,7 @@ signals: void serviceDisconnected(); void serviceError(); void vpnPermissionRejected(); + void notificationStateChanged(); void vpnStateChanged(ConnectionState state); void statisticsUpdated(quint64 rxBytes, quint64 txBytes); void fileOpened(QString uri); @@ -81,6 +83,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 onNotificationStateChanged(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); diff --git a/client/platforms/android/android_notificationhandler.cpp b/client/platforms/android/android_notificationhandler.cpp deleted file mode 100644 index 2bd26b7f..00000000 --- a/client/platforms/android/android_notificationhandler.cpp +++ /dev/null @@ -1,21 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -#include "android_notificationhandler.h" -#include "platforms/android/android_controller.h" - -AndroidNotificationHandler::AndroidNotificationHandler(QObject* parent) - : NotificationHandler(parent) { -} -AndroidNotificationHandler::~AndroidNotificationHandler() { -} - -void AndroidNotificationHandler::notify(NotificationHandler::Message type, - const QString& title, - const QString& message, int timerMsec) { - Q_UNUSED(type); - qDebug() << "Send notification - " << message; - AndroidController::instance()->setNotificationText(title, message, - timerMsec / 1000); -} diff --git a/client/platforms/android/android_notificationhandler.h b/client/platforms/android/android_notificationhandler.h deleted file mode 100644 index e3e7325e..00000000 --- a/client/platforms/android/android_notificationhandler.h +++ /dev/null @@ -1,24 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -#ifndef ANDROIDNOTIFICATIONHANDLER_H -#define ANDROIDNOTIFICATIONHANDLER_H - -#include "ui/notificationhandler.h" - -#include - -class AndroidNotificationHandler final : public NotificationHandler { - Q_DISABLE_COPY_MOVE(AndroidNotificationHandler) - - public: - AndroidNotificationHandler(QObject* parent); - ~AndroidNotificationHandler(); - - protected: - void notify(Message type, const QString& title, const QString& message, - int timerMsec) override; -}; - -#endif // ANDROIDNOTIFICATIONHANDLER_H diff --git a/client/translations/amneziavpn_ru_RU.ts b/client/translations/amneziavpn_ru_RU.ts index 35fec6b8..f1351b2e 100644 --- a/client/translations/amneziavpn_ru_RU.ts +++ b/client/translations/amneziavpn_ru_RU.ts @@ -1088,7 +1088,17 @@ Already installed containers were found on the server. All installed containers Язык - + + Enable notifications + Включить уведомления + + + + Enable notifications to show the VPN state in the status bar + Включить уведомления для отображения статуса VPN в строке состояния + + + Logging Логирование diff --git a/client/ui/controllers/settingsController.cpp b/client/ui/controllers/settingsController.cpp index 31ca2607..aceac551 100644 --- a/client/ui/controllers/settingsController.cpp +++ b/client/ui/controllers/settingsController.cpp @@ -30,6 +30,9 @@ SettingsController::SettingsController(const QSharedPointer &serve { m_appVersion = QString("%1 (%2, %3)").arg(QString(APP_VERSION), __DATE__, GIT_COMMIT_HASH); checkIfNeedDisableLogs(); +#ifdef Q_OS_ANDROID + connect(AndroidController::instance(), &AndroidController::notificationStateChanged, this, &SettingsController::onNotificationStateChanged); +#endif } void SettingsController::toggleAmneziaDns(bool enable) @@ -233,3 +236,19 @@ void SettingsController::toggleKillSwitch(bool enable) { m_settings->setKillSwitchEnabled(enable); } + +bool SettingsController::isNotificationPermissionGranted() +{ +#ifdef Q_OS_ANDROID + return AndroidController::instance()->isNotificationPermissionGranted(); +#else + return true; +#endif +} + +void SettingsController::requestNotificationPermission() +{ +#ifdef Q_OS_ANDROID + AndroidController::instance()->requestNotificationPermission(); +#endif +} diff --git a/client/ui/controllers/settingsController.h b/client/ui/controllers/settingsController.h index ac84856d..43ad10e8 100644 --- a/client/ui/controllers/settingsController.h +++ b/client/ui/controllers/settingsController.h @@ -23,6 +23,7 @@ public: Q_PROPERTY(QString primaryDns READ getPrimaryDns WRITE setPrimaryDns NOTIFY primaryDnsChanged) Q_PROPERTY(QString secondaryDns READ getSecondaryDns WRITE setSecondaryDns NOTIFY secondaryDnsChanged) Q_PROPERTY(bool isLoggingEnabled READ isLoggingEnabled WRITE toggleLogging NOTIFY loggingStateChanged) + Q_PROPERTY(bool isNotificationPermissionGranted READ isNotificationPermissionGranted NOTIFY onNotificationStateChanged) public slots: void toggleAmneziaDns(bool enable); @@ -66,6 +67,9 @@ public slots: bool isKillSwitchEnabled(); void toggleKillSwitch(bool enable); + bool isNotificationPermissionGranted(); + void requestNotificationPermission(); + signals: void primaryDnsChanged(); void secondaryDnsChanged(); @@ -83,6 +87,8 @@ signals: void loggingDisableByWatcher(); + void onNotificationStateChanged(); + private: QSharedPointer m_serversModel; QSharedPointer m_containersModel; diff --git a/client/ui/notificationhandler.cpp b/client/ui/notificationhandler.cpp index 1f81c2c2..5efb45c4 100644 --- a/client/ui/notificationhandler.cpp +++ b/client/ui/notificationhandler.cpp @@ -7,10 +7,7 @@ #if defined(Q_OS_IOS) # include "platforms/ios/iosnotificationhandler.h" -#elif defined(Q_OS_ANDROID) -# include "platforms/android/android_notificationhandler.h" #else - # include "systemtray_notificationhandler.h" #endif @@ -18,8 +15,6 @@ NotificationHandler* NotificationHandler::create(QObject* parent) { #if defined(Q_OS_IOS) return new IOSNotificationHandler(parent); -#elif defined(Q_OS_ANDROID) - return new AndroidNotificationHandler(parent); #else # if defined(Q_OS_LINUX) diff --git a/client/ui/qml/Pages2/PageSettingsApplication.qml b/client/ui/qml/Pages2/PageSettingsApplication.qml index 7ed733e0..2243915f 100644 --- a/client/ui/qml/Pages2/PageSettingsApplication.qml +++ b/client/ui/qml/Pages2/PageSettingsApplication.qml @@ -74,7 +74,8 @@ PageType { } } - KeyNavigation.tab: labelWithButtonLanguage.rightButton + KeyNavigation.tab: Qt.platform.os === "android" && !SettingsController.isNotificationPermissionGranted ? + labelWithButtonNotification.rightButton : labelWithButtonLanguage.rightButton parentFlickable: fl } @@ -82,6 +83,27 @@ PageType { visible: GC.isMobile() } + LabelWithButtonType { + id: labelWithButtonNotification + visible: Qt.platform.os === "android" && !SettingsController.isNotificationPermissionGranted + Layout.fillWidth: true + + text: qsTr("Enable notifications") + descriptionText: qsTr("Enable notifications to show the VPN state in the status bar") + rightImageSource: "qrc:/images/controls/chevron-right.svg" + + KeyNavigation.tab: labelWithButtonLanguage.rightButton + parentFlickable: fl + + clickedFunction: function() { + SettingsController.requestNotificationPermission() + } + } + + DividerType { + visible: Qt.platform.os === "android" && !SettingsController.isNotificationPermissionGranted + } + SwitcherType { id: switcherAutoStart visible: !GC.isMobile() @@ -173,7 +195,6 @@ PageType { } } - DividerType {} LabelWithButtonType {