Android notification and routing (#797)

Android notification and routing
This commit is contained in:
albexk 2024-05-12 18:04:14 +03:00 committed by GitHub
parent ff348a348c
commit abb3c918e3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1108 additions and 543 deletions

View file

@ -127,7 +127,6 @@ set(HEADERS ${HEADERS}
${CMAKE_CURRENT_LIST_DIR}/core/controllers/vpnConfigurationController.h ${CMAKE_CURRENT_LIST_DIR}/core/controllers/vpnConfigurationController.h
${CMAKE_CURRENT_LIST_DIR}/protocols/protocols_defs.h ${CMAKE_CURRENT_LIST_DIR}/protocols/protocols_defs.h
${CMAKE_CURRENT_LIST_DIR}/protocols/qml_register_protocols.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/pages.h
${CMAKE_CURRENT_LIST_DIR}/ui/property_helper.h ${CMAKE_CURRENT_LIST_DIR}/ui/property_helper.h
${CMAKE_CURRENT_LIST_DIR}/ui/qautostart.h ${CMAKE_CURRENT_LIST_DIR}/ui/qautostart.h
@ -157,6 +156,12 @@ if(NOT IOS)
) )
endif() endif()
if(NOT ANDROID)
set(HEADERS ${HEADERS}
${CMAKE_CURRENT_LIST_DIR}/ui/notificationhandler.h
)
endif()
set(SOURCES ${SOURCES} set(SOURCES ${SOURCES}
${CMAKE_CURRENT_LIST_DIR}/migrations.cpp ${CMAKE_CURRENT_LIST_DIR}/migrations.cpp
${CMAKE_CURRENT_LIST_DIR}/amnezia_application.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/serverController.cpp
${CMAKE_CURRENT_LIST_DIR}/core/controllers/vpnConfigurationController.cpp ${CMAKE_CURRENT_LIST_DIR}/core/controllers/vpnConfigurationController.cpp
${CMAKE_CURRENT_LIST_DIR}/protocols/protocols_defs.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}/ui/qautostart.cpp
${CMAKE_CURRENT_LIST_DIR}/protocols/vpnprotocol.cpp ${CMAKE_CURRENT_LIST_DIR}/protocols/vpnprotocol.cpp
${CMAKE_CURRENT_LIST_DIR}/core/sshclient.cpp ${CMAKE_CURRENT_LIST_DIR}/core/sshclient.cpp
@ -194,6 +198,12 @@ if(NOT IOS)
) )
endif() 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_H CONFIGURE_DEPENDS ${CMAKE_CURRENT_LIST_DIR}/*.h)
file(GLOB COMMON_FILES_CPP CONFIGURE_DEPENDS ${CMAKE_CURRENT_LIST_DIR}/*.cpp) file(GLOB COMMON_FILES_CPP CONFIGURE_DEPENDS ${CMAKE_CURRENT_LIST_DIR}/*.cpp)

View file

@ -142,6 +142,7 @@ void AmneziaApplication::init()
connect(m_settings.get(), &Settings::screenshotsEnabledChanged, [](bool enabled) { AmneziaVPN::toggleScreenshots(enabled); }); connect(m_settings.get(), &Settings::screenshotsEnabledChanged, [](bool enabled) { AmneziaVPN::toggleScreenshots(enabled); });
#endif #endif
#ifndef Q_OS_ANDROID
m_notificationHandler.reset(NotificationHandler::create(nullptr)); m_notificationHandler.reset(NotificationHandler::create(nullptr));
connect(m_vpnConnection.get(), &VpnConnection::connectionStateChanged, m_notificationHandler.get(), 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(), connect(m_notificationHandler.get(), &NotificationHandler::disconnectRequested, m_connectionController.get(),
&ConnectionController::closeConnection); &ConnectionController::closeConnection);
connect(this, &AmneziaApplication::translationsUpdated, m_notificationHandler.get(), &NotificationHandler::onTranslationsUpdated); connect(this, &AmneziaApplication::translationsUpdated, m_notificationHandler.get(), &NotificationHandler::onTranslationsUpdated);
#endif
m_engine->load(url); m_engine->load(url);
m_systemController->setQmlRoot(m_engine->rootObjects().value(0)); m_systemController->setQmlRoot(m_engine->rootObjects().value(0));

View file

@ -26,7 +26,9 @@
#include "ui/models/containers_model.h" #include "ui/models/containers_model.h"
#include "ui/models/languageModel.h" #include "ui/models/languageModel.h"
#include "ui/models/protocols/cloakConfigModel.h" #include "ui/models/protocols/cloakConfigModel.h"
#include "ui/notificationhandler.h" #ifndef Q_OS_ANDROID
#include "ui/notificationhandler.h"
#endif
#ifdef Q_OS_WINDOWS #ifdef Q_OS_WINDOWS
#include "ui/models/protocols/ikev2ConfigModel.h" #include "ui/models/protocols/ikev2ConfigModel.h"
#endif #endif
@ -113,7 +115,9 @@ private:
QSharedPointer<VpnConnection> m_vpnConnection; QSharedPointer<VpnConnection> m_vpnConnection;
QThread m_vpnConnectionThread; QThread m_vpnConnectionThread;
#ifndef Q_OS_ANDROID
QScopedPointer<NotificationHandler> m_notificationHandler; QScopedPointer<NotificationHandler> m_notificationHandler;
#endif
QScopedPointer<ConnectionController> m_connectionController; QScopedPointer<ConnectionController> m_connectionController;
QScopedPointer<PageController> m_pageController; QScopedPointer<PageController> m_pageController;

View file

@ -3,9 +3,6 @@ package org.amnezia.vpn.protocol.cloak
import android.util.Base64 import android.util.Base64
import net.openvpn.ovpn3.ClientAPI_Config import net.openvpn.ovpn3.ClientAPI_Config
import org.amnezia.vpn.protocol.openvpn.OpenVpn 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 import org.json.JSONObject
/** /**
@ -54,13 +51,6 @@ class Cloak : OpenVpn() {
return openVpnConfig 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 { private fun checkCloakJson(cloakConfigJson: JSONObject): JSONObject {
cloakConfigJson.put("NumConn", 1) cloakConfigJson.put("NumConn", 1)
cloakConfigJson.put("ProxyMethod", "openvpn") cloakConfigJson.put("ProxyMethod", "openvpn")

View file

@ -13,7 +13,9 @@ import org.amnezia.vpn.protocol.ProtocolState
import org.amnezia.vpn.protocol.ProtocolState.DISCONNECTED import org.amnezia.vpn.protocol.ProtocolState.DISCONNECTED
import org.amnezia.vpn.protocol.Statistics import org.amnezia.vpn.protocol.Statistics
import org.amnezia.vpn.protocol.VpnStartException 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.getLocalNetworks
import org.amnezia.vpn.util.net.parseInetAddress
import org.json.JSONObject import org.json.JSONObject
/** /**
@ -77,6 +79,12 @@ open class OpenVpn : Protocol() {
if (evalConfig.error) { if (evalConfig.error) {
throw BadConfigException("OpenVPN config parse error: ${evalConfig.message}") 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) configPluggableTransport(configBuilder, config)
configBuilder.configSplitTunneling(config) configBuilder.configSplitTunneling(config)
configBuilder.configAppSplitTunneling(config) configBuilder.configAppSplitTunneling(config)

View file

@ -105,15 +105,17 @@ abstract class Protocol {
vpnBuilder.addSearchDomain(it) vpnBuilder.addSearchDomain(it)
} }
for (addr in config.routes) { for ((inetNetwork, include) in config.routes) {
Log.d(TAG, "addRoute: $addr") if (include) {
vpnBuilder.addRoute(addr) Log.d(TAG, "addRoute: $inetNetwork")
} vpnBuilder.addRoute(inetNetwork)
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
for (addr in config.excludedRoutes) { Log.d(TAG, "excludeRoute: $inetNetwork")
Log.d(TAG, "excludeRoute: $addr") vpnBuilder.excludeRoute(inetNetwork)
vpnBuilder.excludeRoute(addr) } else {
Log.e(TAG, "Trying to exclude route $inetNetwork on old Android")
}
} }
} }

View file

@ -12,8 +12,7 @@ open class ProtocolConfig protected constructor(
val addresses: Set<InetNetwork>, val addresses: Set<InetNetwork>,
val dnsServers: Set<InetAddress>, val dnsServers: Set<InetAddress>,
val searchDomain: String?, val searchDomain: String?,
val routes: Set<InetNetwork>, val routes: Set<Route>,
val excludedRoutes: Set<InetNetwork>,
val includedAddresses: Set<InetNetwork>, val includedAddresses: Set<InetNetwork>,
val excludedAddresses: Set<InetNetwork>, val excludedAddresses: Set<InetNetwork>,
val includedApplications: Set<String>, val includedApplications: Set<String>,
@ -29,7 +28,6 @@ open class ProtocolConfig protected constructor(
builder.dnsServers, builder.dnsServers,
builder.searchDomain, builder.searchDomain,
builder.routes, builder.routes,
builder.excludedRoutes,
builder.includedAddresses, builder.includedAddresses,
builder.excludedAddresses, builder.excludedAddresses,
builder.includedApplications, builder.includedApplications,
@ -43,8 +41,7 @@ open class ProtocolConfig protected constructor(
open class Builder(blockingMode: Boolean) { open class Builder(blockingMode: Boolean) {
internal val addresses: MutableSet<InetNetwork> = hashSetOf() internal val addresses: MutableSet<InetNetwork> = hashSetOf()
internal val dnsServers: MutableSet<InetAddress> = hashSetOf() internal val dnsServers: MutableSet<InetAddress> = hashSetOf()
internal val routes: MutableSet<InetNetwork> = hashSetOf() internal val routes: MutableSet<Route> = mutableSetOf()
internal val excludedRoutes: MutableSet<InetNetwork> = hashSetOf()
internal val includedAddresses: MutableSet<InetNetwork> = hashSetOf() internal val includedAddresses: MutableSet<InetNetwork> = hashSetOf()
internal val excludedAddresses: MutableSet<InetNetwork> = hashSetOf() internal val excludedAddresses: MutableSet<InetNetwork> = hashSetOf()
internal val includedApplications: MutableSet<String> = hashSetOf() internal val includedApplications: MutableSet<String> = hashSetOf()
@ -77,13 +74,21 @@ open class ProtocolConfig protected constructor(
fun setSearchDomain(domain: String) = apply { this.searchDomain = domain } fun setSearchDomain(domain: String) = apply { this.searchDomain = domain }
fun addRoute(route: InetNetwork) = apply { this.routes += route } fun addRoute(route: InetNetwork) = apply { this.routes += Route(route, true) }
fun addRoutes(routes: Collection<InetNetwork>) = apply { this.routes += routes } fun addRoutes(routes: Collection<InetNetwork>) = apply { this.routes += routes.map { Route(it, true) } }
fun removeRoute(route: InetNetwork) = apply { this.routes.remove(route) }
fun excludeRoute(route: InetNetwork) = apply { this.routes += Route(route, false) }
fun excludeRoutes(routes: Collection<InetNetwork>) = 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 clearRoutes() = apply { this.routes.clear() }
fun excludeRoute(route: InetNetwork) = apply { this.excludedRoutes += route } fun prependRoutes(block: Builder.() -> Unit) = apply {
fun excludeRoutes(routes: Collection<InetNetwork>) = apply { this.excludedRoutes += routes } val savedRoutes = mutableListOf<Route>().apply { addAll(routes) }
routes.clear()
block()
routes.addAll(savedRoutes)
}
fun includeAddress(addr: InetNetwork) = apply { this.includedAddresses += addr } fun includeAddress(addr: InetNetwork) = apply { this.includedAddresses += addr }
fun includeAddresses(addresses: Collection<InetNetwork>) = apply { this.includedAddresses += addresses } fun includeAddresses(addresses: Collection<InetNetwork>) = apply { this.includedAddresses += addresses }
@ -117,37 +122,46 @@ open class ProtocolConfig protected constructor(
// remove default routes, if any // remove default routes, if any
removeRoute(InetNetwork("0.0.0.0", 0)) removeRoute(InetNetwork("0.0.0.0", 0))
removeRoute(InetNetwork("::", 0)) removeRoute(InetNetwork("::", 0))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { removeRoute(InetNetwork("2000::", 3))
// for older versions of Android, add the default route to the excluded routes prependRoutes {
// to correctly build the excluded subnets list later addRoutes(includedAddresses)
excludeRoute(InetNetwork("0.0.0.0", 0))
} }
addRoutes(includedAddresses)
} else if (excludedAddresses.isNotEmpty()) { } else if (excludedAddresses.isNotEmpty()) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { prependRoutes {
// default routes are required for split tunneling in newer versions of Android
addRoute(InetNetwork("0.0.0.0", 0)) addRoute(InetNetwork("0.0.0.0", 0))
addRoute(InetNetwork("::", 0)) addRoute(InetNetwork("2000::", 3))
excludeRoutes(excludedAddresses)
} }
excludeRoutes(excludedAddresses)
} }
} }
private fun processExcludedRoutes() { private fun processRoutes() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU && excludedRoutes.isNotEmpty()) { // replace ::/0 as it may cause LAN connection issues
// todo: rewrite, taking into account the current routes val ipv6DefaultRoute = InetNetwork("::", 0)
// for older versions of Android, build a list of subnets without excluded routes if (routes.removeIf { it.include && it.inetNetwork == ipv6DefaultRoute }) {
// and add them to routes prependRoutes {
val ipRangeSet = IpRangeSet() addRoute(InetNetwork("2000::", 3))
ipRangeSet.remove(IpRange("127.0.0.0", 8))
excludedRoutes.forEach {
ipRangeSet.remove(IpRange(it))
} }
// remove default routes, if any }
removeRoute(InetNetwork("0.0.0.0", 0)) // for older versions of Android, build a list of subnets without excluded routes
removeRoute(InetNetwork("::", 0)) // 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) 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() { protected fun configBuild() {
processSplitTunneling() processSplitTunneling()
processExcludedRoutes() processRoutes()
validate() validate()
} }
@ -177,3 +191,5 @@ open class ProtocolConfig protected constructor(
Builder(blockingMode).apply(block).build() Builder(blockingMode).apply(block).build()
} }
} }
data class Route(val inetNetwork: InetNetwork, val include: Boolean)

View file

@ -1,12 +1,26 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<resources> <resources>
<string name="connecting">Подключение</string> <string name="disconnected">Не подключено</string>
<string name="disconnecting">Отключение</string> <string name="connected">Подключено</string>
<string name="cancel">Отмена</string> <string name="connecting">Подключение…</string>
<string name="disconnecting">Отключение…</string>
<string name="reconnecting">Переподключение…</string>
<string name="connect">Подключиться</string>
<string name="disconnect">Отключиться</string>
<string name="ok">ОК</string> <string name="ok">ОК</string>
<string name="cancel">Отмена</string>
<string name="yes">Да</string>
<string name="no">Нет</string>
<string name="vpnGranted">VPN-подключение разрешено</string> <string name="vpnGranted">VPN-подключение разрешено</string>
<string name="vpnDenied">VPN-подключение запрещено</string>
<string name="vpnSetupFailed">Ошибка настройки VPN</string> <string name="vpnSetupFailed">Ошибка настройки VPN</string>
<string name="vpnSetupFailedMessage">Чтобы подключиться к AmneziaVPN необходимо:\n\n- Разрешить приложению подключаться к сети VPN\n- Отключить функцию \"Постоянная VPN\" для всех остальных VPN-приложений в системных настройках VPN</string> <string name="vpnSetupFailedMessage">Чтобы подключиться к AmneziaVPN необходимо:\n\n- Разрешить приложению подключаться к сети VPN\n- Отключить функцию \"Постоянная VPN\" для всех остальных VPN-приложений в системных настройках VPN</string>
<string name="openVpnSettings">Открыть настройки VPN</string> <string name="openVpnSettings">Открыть настройки VPN</string>
<string name="notificationChannelDescription">Уведомления сервиса AmneziaVPN</string>
<string name="notificationDialogTitle">Сервис AmneziaVPN</string>
<string name="notificationDialogMessage">Показывать статус VPN в строке состояния?</string>
<string name="notificationSettingsDialogTitle">Настройки уведомлений</string>
<string name="notificationSettingsDialogMessage">Для показа уведомлений необходимо включить уведомления в системных настройках</string>
<string name="openNotificationSettings">Открыть настройки уведомлений</string>
</resources> </resources>

View file

@ -1,12 +1,26 @@
<?xml version='1.0' encoding='utf-8'?> <?xml version='1.0' encoding='utf-8'?>
<resources> <resources>
<string name="connecting">Connecting</string> <string name="disconnected">Not connected</string>
<string name="disconnecting">Disconnecting</string> <string name="connected">Connected</string>
<string name="cancel">Cancel</string> <string name="connecting">Connecting…</string>
<string name="disconnecting">Disconnecting…</string>
<string name="reconnecting">Reconnecting…</string>
<string name="connect">Connect</string>
<string name="disconnect">Disconnect</string>
<string name="ok">OK</string> <string name="ok">OK</string>
<string name="cancel">Cancel</string>
<string name="yes">Yes</string>
<string name="no">No</string>
<string name="vpnGranted">VPN permission granted</string> <string name="vpnGranted">VPN permission granted</string>
<string name="vpnDenied">VPN permission denied</string>
<string name="vpnSetupFailed">VPN setup error</string> <string name="vpnSetupFailed">VPN setup error</string>
<string name="vpnSetupFailedMessage">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</string> <string name="vpnSetupFailedMessage">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</string>
<string name="openVpnSettings">Open VPN settings</string> <string name="openVpnSettings">Open VPN settings</string>
<string name="notificationChannelDescription">AmneziaVPN service notification</string>
<string name="notificationDialogTitle">AmneziaVPN service</string>
<string name="notificationDialogMessage">Show the VPN state in the status bar?</string>
<string name="notificationSettingsDialogTitle">Notification settings</string>
<string name="notificationSettingsDialogMessage">To show notifications, you must enable notifications in the system settings</string>
<string name="openNotificationSettings">Open notification settings</string>
</resources> </resources>

View file

@ -1,6 +1,9 @@
package org.amnezia.vpn package org.amnezia.vpn
import android.Manifest
import android.app.AlertDialog import android.app.AlertDialog
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.content.Intent.EXTRA_MIME_TYPES 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.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri
import android.net.VpnService import android.net.VpnService
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
@ -21,6 +24,7 @@ import android.view.WindowManager.LayoutParams
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import android.widget.Toast import android.widget.Toast
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import java.io.IOException import java.io.IOException
import kotlin.LazyThreadSafetyMode.NONE import kotlin.LazyThreadSafetyMode.NONE
@ -38,6 +42,7 @@ import org.amnezia.vpn.protocol.getStatistics
import org.amnezia.vpn.protocol.getStatus import org.amnezia.vpn.protocol.getStatus
import org.amnezia.vpn.qt.QtAndroidController import org.amnezia.vpn.qt.QtAndroidController
import org.amnezia.vpn.util.Log import org.amnezia.vpn.util.Log
import org.amnezia.vpn.util.Prefs
import org.qtproject.qt.android.bindings.QtActivity import org.qtproject.qt.android.bindings.QtActivity
private const val TAG = "AmneziaActivity" 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 CHECK_VPN_PERMISSION_ACTION_CODE = 1
private const val CREATE_FILE_ACTION_CODE = 2 private const val CREATE_FILE_ACTION_CODE = 2
private const val OPEN_FILE_ACTION_CODE = 3 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() { class AmneziaActivity : QtActivity() {
@ -54,8 +62,11 @@ class AmneziaActivity : QtActivity() {
private var isWaitingStatus = true private var isWaitingStatus = true
private var isServiceConnected = false private var isServiceConnected = false
private var isInBoundState = false private var isInBoundState = false
private var notificationStateReceiver: BroadcastReceiver? = null
private lateinit var vpnServiceMessenger: IpcMessenger private lateinit var vpnServiceMessenger: IpcMessenger
private var tmpFileContentToSave: String = ""
private val actionResultHandlers = mutableMapOf<Int, ActivityResultHandler>()
private val permissionRequestHandlers = mutableMapOf<Int, PermissionRequestHandler>()
private val vpnServiceEventHandler: Handler by lazy(NONE) { private val vpnServiceEventHandler: Handler by lazy(NONE) {
object : Handler(Looper.getMainLooper()) { 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 * Activity overloaded methods
*/ */
@ -153,9 +160,30 @@ class AmneziaActivity : QtActivity() {
doBindService() doBindService()
} }
) )
registerBroadcastReceivers()
intent?.let(::processIntent) 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?) { override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent) super.onNewIntent(intent)
Log.d(TAG, "onNewIntent: $intent") Log.d(TAG, "onNewIntent: $intent")
@ -193,50 +221,46 @@ class AmneziaActivity : QtActivity() {
override fun onDestroy() { override fun onDestroy() {
Log.d(TAG, "Destroy Amnezia activity") Log.d(TAG, "Destroy Amnezia activity")
unregisterBroadcastReceiver(notificationStateReceiver)
notificationStateReceiver = null
mainScope.cancel() mainScope.cancel()
super.onDestroy() super.onDestroy()
} }
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
when (requestCode) { Log.d(TAG, "Process activity result, code: ${actionCodeToString(requestCode)}, " +
CREATE_FILE_ACTION_CODE -> { "resultCode: $resultCode, data: $data")
when (resultCode) { actionResultHandlers[requestCode]?.let { handler ->
RESULT_OK -> { when (resultCode) {
data?.data?.let { uri -> RESULT_OK -> handler.onSuccess(data)
alterDocument(uri) else -> handler.onFail(data)
}
}
}
} }
handler.onAny(data)
actionResultHandlers.remove(requestCode)
} ?: super.onActivityResult(requestCode, resultCode, data)
}
OPEN_FILE_ACTION_CODE -> { private fun startActivityForResult(intent: Intent, requestCode: Int, handler: ActivityResultHandler) {
when (resultCode) { actionResultHandlers[requestCode] = handler
RESULT_OK -> data?.data?.toString() ?: "" startActivityForResult(intent, requestCode)
else -> "" }
}.let { uri ->
QtAndroidController.onFileOpened(uri) override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, 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 -> { private fun requestPermission(permission: String, requestCode: Int, handler: PermissionRequestHandler) {
when (resultCode) { permissionRequestHandlers[requestCode] = handler
RESULT_OK -> { requestPermissions(arrayOf(permission), requestCode)
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)
}
} }
/** /**
@ -268,22 +292,26 @@ class AmneziaActivity : QtActivity() {
/** /**
* Methods of starting and stopping VpnService * Methods of starting and stopping VpnService
*/ */
private fun checkVpnPermissionAndStart(vpnConfig: String) {
checkVpnPermission(
onSuccess = { startVpn(vpnConfig) },
onFail = QtAndroidController::onVpnPermissionRejected
)
}
@MainThread @MainThread
private fun checkVpnPermission(onSuccess: () -> Unit, onFail: () -> Unit) { private fun checkVpnPermission(onPermissionGranted: () -> Unit) {
Log.d(TAG, "Check VPN permission") Log.d(TAG, "Check VPN permission")
VpnService.prepare(applicationContext)?.let { VpnService.prepare(applicationContext)?.let { intent ->
checkVpnPermissionCallbacks = CheckVpnPermissionCallbacks(onSuccess, onFail) startActivityForResult(intent, CHECK_VPN_PERMISSION_ACTION_CODE, ActivityResultHandler(
startActivityForResult(it, CHECK_VPN_PERMISSION_ACTION_CODE) onSuccess = {
return Log.d(TAG, "Vpn permission granted")
} Toast.makeText(this@AmneziaActivity, resources.getText(R.string.vpnGranted), Toast.LENGTH_LONG).show()
onSuccess() onPermissionGranted()
},
onFail = {
Log.w(TAG, "Vpn permission denied")
showOnVpnPermissionRejectDialog()
mainScope.launch {
qtInitialized.await()
QtAndroidController.onVpnPermissionRejected()
}
}
))
} ?: onPermissionGranted()
} }
private fun showOnVpnPermissionRejectDialog() { private fun showOnVpnPermissionRejectDialog() {
@ -297,6 +325,44 @@ class AmneziaActivity : QtActivity() {
.show() .show()
} }
private fun checkNotificationPermission(onChecked: () -> Unit) {
Log.d(TAG, "Check notification permission")
if (
!isNotificationPermissionGranted() &&
!Prefs.load<Boolean>(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 @MainThread
private fun startVpn(vpnConfig: String) { private fun startVpn(vpnConfig: String) {
if (isServiceConnected) { if (isServiceConnected) {
@ -322,28 +388,21 @@ class AmneziaActivity : QtActivity() {
Intent(this, AmneziaVpnService::class.java).apply { Intent(this, AmneziaVpnService::class.java).apply {
putExtra(MSG_VPN_CONFIG, vpnConfig) putExtra(MSG_VPN_CONFIG, vpnConfig)
}.also { }.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() { private fun disconnectFromVpn() {
Log.d(TAG, "Disconnect from VPN") Log.d(TAG, "Disconnect from VPN")
vpnServiceMessenger.send(Action.DISCONNECT) 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 * Methods called by Qt
*/ */
@ -357,7 +416,11 @@ class AmneziaActivity : QtActivity() {
fun start(vpnConfig: String) { fun start(vpnConfig: String) {
Log.v(TAG, "Start VPN") Log.v(TAG, "Start VPN")
mainScope.launch { mainScope.launch {
checkVpnPermissionAndStart(vpnConfig) checkVpnPermission {
checkNotificationPermission {
startVpn(vpnConfig)
}
}
} }
} }
@ -389,14 +452,26 @@ class AmneziaActivity : QtActivity() {
fun saveFile(fileName: String, data: String) { fun saveFile(fileName: String, data: String) {
Log.d(TAG, "Save file $fileName") Log.d(TAG, "Save file $fileName")
mainScope.launch { mainScope.launch {
tmpFileContentToSave = data
Intent(Intent.ACTION_CREATE_DOCUMENT).apply { Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE) addCategory(Intent.CATEGORY_OPENABLE)
type = "text/*" type = "text/*"
putExtra(Intent.EXTRA_TITLE, fileName) putExtra(Intent.EXTRA_TITLE, fileName)
}.also { }.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") @Suppress("unused")
fun openFile(filter: String?) { fun openFile(filter: String?) {
Log.v(TAG, "Open file with filter: $filter") 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()) { Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
val extensionRegex = "\\*\\.([a-z0-9]+)".toRegex(IGNORE_CASE) addCategory(Intent.CATEGORY_OPENABLE)
val mime = MimeTypeMap.getSingleton() Log.v(TAG, "File mimyType filter: $mimeTypes")
extensionRegex.findAll(filter).map { if ("*/*" in mimeTypes) {
it.groups[1]?.value?.let { mime.getMimeTypeFromExtension(it) } ?: "*/*" type = "*/*"
}.toSet() } else {
} else emptySet() when (mimeTypes.size) {
1 -> type = mimeTypes.first()
Intent(Intent.ACTION_OPEN_DOCUMENT).apply { in 2..Int.MAX_VALUE -> {
addCategory(Intent.CATEGORY_OPENABLE) type = "*/*"
Log.v(TAG, "File mimyType filter: $mimeTypes") putExtra(EXTRA_MIME_TYPES, mimeTypes.toTypedArray())
if ("*/*" in mimeTypes) { }
type = "*/*"
} else {
when (mimeTypes.size) {
1 -> type = mimeTypes.first()
in 2..Int.MAX_VALUE -> { else -> type = "*/*"
type = "*/*"
putExtra(EXTRA_MIME_TYPES, mimeTypes.toTypedArray())
} }
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") @Suppress("unused")
fun isCameraPresent(): Boolean = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA) fun isCameraPresent(): Boolean = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)
@ -514,4 +594,76 @@ class AmneziaActivity : QtActivity() {
Log.v(TAG, "Get app icon") Log.v(TAG, "Get app icon")
return AppListProvider.getAppIcon(packageManager, packageName, width, height) 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<Boolean>(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 = {}
)

View file

@ -3,14 +3,11 @@ package org.amnezia.vpn
import androidx.camera.camera2.Camera2Config import androidx.camera.camera2.Camera2Config
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.CameraXConfig 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.Log
import org.amnezia.vpn.util.Prefs import org.amnezia.vpn.util.Prefs
import org.qtproject.qt.android.bindings.QtApplication import org.qtproject.qt.android.bindings.QtApplication
private const val TAG = "AmneziaApplication" private const val TAG = "AmneziaApplication"
const val NOTIFICATION_CHANNEL_ID: String = "org.amnezia.vpn.notification"
class AmneziaApplication : QtApplication(), CameraXConfig.Provider { class AmneziaApplication : QtApplication(), CameraXConfig.Provider {
@ -20,7 +17,7 @@ class AmneziaApplication : QtApplication(), CameraXConfig.Provider {
Log.init(this) Log.init(this)
VpnStateStore.init(this) VpnStateStore.init(this)
Log.d(TAG, "Create Amnezia application") Log.d(TAG, "Create Amnezia application")
createNotificationChannel() ServiceNotification.createNotificationChannel(this)
} }
override fun getCameraXConfig(): CameraXConfig = CameraXConfig.Builder override fun getCameraXConfig(): CameraXConfig = CameraXConfig.Builder
@ -28,14 +25,4 @@ class AmneziaApplication : QtApplication(), CameraXConfig.Provider {
.setMinimumLoggingLevel(android.util.Log.ERROR) .setMinimumLoggingLevel(android.util.Log.ERROR)
.setAvailableCamerasLimiter(CameraSelector.DEFAULT_BACK_CAMERA) .setAvailableCamerasLimiter(CameraSelector.DEFAULT_BACK_CAMERA)
.build() .build()
private fun createNotificationChannel() {
NotificationManagerCompat.from(this).createNotificationChannel(
Builder(NOTIFICATION_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW)
.setName("AmneziaVPN")
.setDescription("AmneziaVPN service notification")
.setShowBadge(false)
.build()
)
}
} }

View file

@ -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<String>,
@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) }
}

View file

@ -188,11 +188,16 @@ class AmneziaTileService : TileService() {
true true
} }
private fun startVpnService() = private fun startVpnService() {
ContextCompat.startForegroundService( try {
applicationContext, ContextCompat.startForegroundService(
Intent(this, AmneziaVpnService::class.java) applicationContext,
) Intent(this, AmneziaVpnService::class.java)
)
} catch (e: SecurityException) {
Log.e(TAG, "Failed to start AmneziaVpnService: $e")
}
}
private fun connectToVpn() = vpnServiceMessenger.send(Action.CONNECT) private fun connectToVpn() = vpnServiceMessenger.send(Action.CONNECT)
@ -230,7 +235,7 @@ class AmneziaTileService : TileService() {
val tile = qsTile ?: return val tile = qsTile ?: return
tile.apply { tile.apply {
label = vpnState.serverName ?: DEFAULT_TILE_LABEL label = vpnState.serverName ?: DEFAULT_TILE_LABEL
when (vpnState.protocolState) { when (val protocolState = vpnState.protocolState) {
CONNECTED -> { CONNECTED -> {
state = Tile.STATE_ACTIVE state = Tile.STATE_ACTIVE
subtitleCompat = null subtitleCompat = null
@ -241,14 +246,9 @@ class AmneziaTileService : TileService() {
subtitleCompat = null subtitleCompat = null
} }
CONNECTING, RECONNECTING -> { CONNECTING, DISCONNECTING, RECONNECTING -> {
state = Tile.STATE_UNAVAILABLE state = Tile.STATE_UNAVAILABLE
subtitleCompat = resources.getString(R.string.connecting) subtitleCompat = getString(protocolState)
}
DISCONNECTING -> {
state = Tile.STATE_UNAVAILABLE
subtitleCompat = resources.getString(R.string.disconnecting)
} }
} }
updateTile() updateTile()

View file

@ -2,8 +2,8 @@ package org.amnezia.vpn
import android.app.ActivityManager import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE
import android.app.Notification import android.app.NotificationManager
import android.app.PendingIntent import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST
@ -15,10 +15,12 @@ import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.os.Message import android.os.Message
import android.os.Messenger import android.os.Messenger
import android.os.PowerManager
import android.os.Process import android.os.Process
import androidx.annotation.MainThread import androidx.annotation.MainThread
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlin.LazyThreadSafetyMode.NONE import kotlin.LazyThreadSafetyMode.NONE
import kotlinx.coroutines.CoroutineExceptionHandler 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.Log
import org.amnezia.vpn.util.Prefs import org.amnezia.vpn.util.Prefs
import org.amnezia.vpn.util.net.NetworkState import org.amnezia.vpn.util.net.NetworkState
import org.amnezia.vpn.util.net.TrafficStats
import org.json.JSONException import org.json.JSONException
import org.json.JSONObject import org.json.JSONObject
private const val TAG = "AmneziaVpnService" private const val TAG = "AmneziaVpnService"
const val ACTION_DISCONNECT = "org.amnezia.vpn.action.disconnect"
const val MSG_VPN_CONFIG = "VPN_CONFIG" const val MSG_VPN_CONFIG = "VPN_CONFIG"
const val MSG_ERROR = "ERROR" const val MSG_ERROR = "ERROR"
const val MSG_SAVE_LOGS = "SAVE_LOGS" 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_NAME = "LAST_SERVER_NAME"
private const val PREFS_SERVER_INDEX = "LAST_SERVER_INDEX" private const val PREFS_SERVER_INDEX = "LAST_SERVER_INDEX"
private const val PROCESS_NAME = "org.amnezia.vpn:amneziaVpnService" 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 DISCONNECT_TIMEOUT = 5000L
private const val STOP_SERVICE_TIMEOUT = 5000L private const val STOP_SERVICE_TIMEOUT = 5000L
@ -96,8 +101,14 @@ class AmneziaVpnService : VpnService() {
private var connectionJob: Job? = null private var connectionJob: Job? = null
private var disconnectionJob: 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 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<Messenger, IpcMessenger>() private val clientMessengers = ConcurrentHashMap<Messenger, IpcMessenger>()
private val isActivityConnected private val isActivityConnected
@ -131,13 +142,13 @@ class AmneziaVpnService : VpnService() {
val messenger = IpcMessenger(msg.replyTo, clientName) val messenger = IpcMessenger(msg.replyTo, clientName)
clientMessengers[msg.replyTo] = messenger clientMessengers[msg.replyTo] = messenger
Log.d(TAG, "Messenger client '$clientName' was registered") 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 -> { Action.UNREGISTER_CLIENT -> {
clientMessengers.remove(msg.replyTo)?.let { clientMessengers.remove(msg.replyTo)?.let {
Log.d(TAG, "Messenger client '${it.name}' was unregistered") 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 -> { Action.SET_SAVE_LOGS -> {
Log.saveLogs = msg.data.getBoolean(MSG_SAVE_LOGS) Log.saveLogs = msg.data.getBoolean(MSG_SAVE_LOGS)
} }
@ -181,25 +196,7 @@ class AmneziaVpnService : VpnService() {
else -> 0 else -> 0
} }
private val notification: Notification by lazy(NONE) { private val serviceNotification: ServiceNotification by lazy(NONE) { ServiceNotification(this) }
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()
}
/** /**
* Service overloaded methods * Service overloaded methods
@ -212,6 +209,8 @@ class AmneziaVpnService : VpnService() {
loadServerData() loadServerData()
launchProtocolStateHandler() launchProtocolStateHandler()
networkState = NetworkState(this, ::reconnect) networkState = NetworkState(this, ::reconnect)
trafficStats = TrafficStats()
registerBroadcastReceivers()
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -227,7 +226,10 @@ class AmneziaVpnService : VpnService() {
Log.d(TAG, "Start service") Log.d(TAG, "Start service")
connect(intent?.getStringExtra(MSG_VPN_CONFIG)) 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 return START_REDELIVER_INTENT
} }
@ -267,6 +269,7 @@ class AmneziaVpnService : VpnService() {
override fun onDestroy() { override fun onDestroy() {
Log.d(TAG, "Destroy service") Log.d(TAG, "Destroy service")
unregisterBroadcastReceivers()
runBlocking { runBlocking {
disconnect() disconnect()
disconnectionJob?.join() disconnectionJob?.join()
@ -287,6 +290,63 @@ class AmneziaVpnService : VpnService() {
stopSelf() 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 * Methods responsible for processing VPN connection
*/ */
@ -295,29 +355,8 @@ class AmneziaVpnService : VpnService() {
// drop first default UNKNOWN state // drop first default UNKNOWN state
protocolState.drop(1).collect { protocolState -> protocolState.drop(1).collect { protocolState ->
Log.d(TAG, "Protocol state changed: $protocolState") Log.d(TAG, "Protocol state changed: $protocolState")
when (protocolState) {
CONNECTED -> {
networkState.bindNetworkListener()
if (isActivityConnected) launchSendingStatistics()
}
DISCONNECTED -> { serviceNotification.updateNotification(serverName, protocolState)
networkState.unbindNetworkListener()
stopSendingStatistics()
if (!isServiceBound) stopService()
}
DISCONNECTING -> {
networkState.unbindNetworkListener()
stopSendingStatistics()
}
RECONNECTING -> {
stopSendingStatistics()
}
CONNECTING, UNKNOWN -> {}
}
clientMessengers.send { clientMessengers.send {
ServiceEvent.STATUS_CHANGED.packToMessage { ServiceEvent.STATUS_CHANGED.packToMessage {
@ -326,13 +365,41 @@ class AmneziaVpnService : VpnService() {
} }
VpnStateStore.store { VpnState(protocolState, serverName, serverIndex) } 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() { private fun launchSendingStatistics() {
/* if (isServiceBound && isConnected) { if (isServiceBound && isConnected) {
statisticsSendingJob = mainScope.launch { statisticsSendingJob = mainScope.launch {
while (true) { while (true) {
clientMessenger.send { clientMessenger.send {
@ -343,12 +410,62 @@ class AmneziaVpnService : VpnService() {
delay(STATISTICS_SENDING_TIMEOUT) delay(STATISTICS_SENDING_TIMEOUT)
} }
} }
} */ }
} }
@MainThread @MainThread
private fun stopSendingStatistics() { private fun stopSendingStatistics() {
statisticsSendingJob?.cancel() 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<PowerManager>()?.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 @MainThread
@ -471,6 +588,7 @@ class AmneziaVpnService : VpnService() {
private fun saveServerData(config: JSONObject?) { private fun saveServerData(config: JSONObject?) {
serverName = config?.opt("description") as String? serverName = config?.opt("description") as String?
serverIndex = config?.opt("serverIndex") as Int? ?: -1 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_NAME, serverName)
Prefs.save(PREFS_SERVER_INDEX, serverIndex) Prefs.save(PREFS_SERVER_INDEX, serverIndex)
} }
@ -478,6 +596,7 @@ class AmneziaVpnService : VpnService() {
private fun loadServerData() { private fun loadServerData() {
serverName = Prefs.load<String>(PREFS_SERVER_NAME).ifBlank { null } serverName = Prefs.load<String>(PREFS_SERVER_NAME).ifBlank { null }
if (serverName != null) serverIndex = Prefs.load(PREFS_SERVER_INDEX) if (serverName != null) serverIndex = Prefs.load(PREFS_SERVER_INDEX)
Log.d(TAG, "Load server data: ($serverIndex, $serverName)")
} }
private fun checkPermission(): Boolean = private fun checkPermission(): Boolean =
@ -494,9 +613,8 @@ class AmneziaVpnService : VpnService() {
companion object { companion object {
fun isRunning(context: Context): Boolean = fun isRunning(context: Context): Boolean =
(context.getSystemService(ACTIVITY_SERVICE) as ActivityManager) context.getSystemService<ActivityManager>()!!.runningAppProcesses.any {
.runningAppProcesses.any { it.processName == PROCESS_NAME && it.importance <= IMPORTANCE_FOREGROUND_SERVICE
it.processName == PROCESS_NAME && it.importance <= IMPORTANCE_FOREGROUND_SERVICE }
}
} }
} }

View file

@ -32,6 +32,7 @@ enum class Action : IpcMessage {
CONNECT, CONNECT,
DISCONNECT, DISCONNECT,
REQUEST_STATUS, REQUEST_STATUS,
NOTIFICATION_PERMISSION_GRANTED,
SET_SAVE_LOGS SET_SAVE_LOGS
} }

View file

@ -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<String> 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<String> browsers = getBrowserIDs(pm);
List<PackageInfo> 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<String> 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<ResolveInfo> resolveInfos = pm.queryIntentActivities(intent, PackageManager.MATCH_ALL);
List<String> browsers = new ArrayList<String>();
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);
}
}

View file

@ -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

View file

@ -3,9 +3,7 @@ package org.amnezia.vpn
import android.app.AlertDialog import android.app.AlertDialog
import android.app.KeyguardManager import android.app.KeyguardManager
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent 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_MASK
import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.content.res.Configuration.UI_MODE_NIGHT_YES
import android.net.VpnService import android.net.VpnService
@ -33,11 +31,9 @@ class VpnRequestActivity : ComponentActivity() {
val requestIntent = VpnService.prepare(applicationContext) val requestIntent = VpnService.prepare(applicationContext)
if (requestIntent != null) { if (requestIntent != null) {
if (getSystemService<KeyguardManager>()!!.isKeyguardLocked) { if (getSystemService<KeyguardManager>()!!.isKeyguardLocked) {
userPresentReceiver = object : BroadcastReceiver() { userPresentReceiver = registerBroadcastReceiver(Intent.ACTION_USER_PRESENT) {
override fun onReceive(context: Context?, intent: Intent?) = requestLauncher.launch(requestIntent)
requestLauncher.launch(requestIntent)
} }
registerReceiver(userPresentReceiver, IntentFilter(Intent.ACTION_USER_PRESENT))
} else { } else {
requestLauncher.launch(requestIntent) requestLauncher.launch(requestIntent)
} }
@ -49,9 +45,8 @@ class VpnRequestActivity : ComponentActivity() {
} }
override fun onDestroy() { override fun onDestroy() {
userPresentReceiver?.let { unregisterBroadcastReceiver(userPresentReceiver)
unregisterReceiver(it) userPresentReceiver = null
}
super.onDestroy() super.onDestroy()
} }

View file

@ -4,6 +4,8 @@ import android.app.Application
import androidx.datastore.core.MultiProcessDataStoreFactory import androidx.datastore.core.MultiProcessDataStoreFactory
import androidx.datastore.core.Serializer import androidx.datastore.core.Serializer
import androidx.datastore.dataStoreFile import androidx.datastore.dataStoreFile
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import java.io.ObjectInputStream import java.io.ObjectInputStream
import java.io.ObjectOutputStream import java.io.ObjectOutputStream
@ -59,7 +61,8 @@ private class VpnStateSerializer : Serializer<VpnState> {
override suspend fun readFrom(input: InputStream): VpnState { override suspend fun readFrom(input: InputStream): VpnState {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
ObjectInputStream(input).use { val bios = ByteArrayInputStream(input.readBytes())
ObjectInputStream(bios).use {
it.readObject() as VpnState it.readObject() as VpnState
} }
} }
@ -67,9 +70,11 @@ private class VpnStateSerializer : Serializer<VpnState> {
override suspend fun writeTo(t: VpnState, output: OutputStream) { override suspend fun writeTo(t: VpnState, output: OutputStream) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
ObjectOutputStream(output).use { val baos = ByteArrayOutputStream()
ObjectOutputStream(baos).use {
it.writeObject(t) it.writeObject(t)
} }
output.write(baos.toByteArray())
} }
} }
} }

View file

@ -17,6 +17,7 @@ object QtAndroidController {
external fun onServiceError() external fun onServiceError()
external fun onVpnPermissionRejected() external fun onVpnPermissionRejected()
external fun onNotificationStateChanged()
external fun onVpnStateChanged(stateCode: Int) external fun onVpnStateChanged(stateCode: Int)
external fun onStatisticsUpdate(rxBytes: Long, txBytes: Long) external fun onStatisticsUpdate(rxBytes: Long, txBytes: Long)

View file

@ -17,5 +17,7 @@ android {
} }
dependencies { dependencies {
implementation(libs.androidx.core)
implementation(libs.kotlinx.coroutines)
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
} }

View file

@ -1,16 +1,21 @@
package org.amnezia.vpn.util.net package org.amnezia.vpn.util.net
import java.net.Inet4Address
import java.net.InetAddress import java.net.InetAddress
data class InetEndpoint(val address: InetAddress, val port: Int) { 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 { companion object {
fun parse(data: String): InetEndpoint { fun parse(data: String): InetEndpoint {
val split = data.split(":") val i = data.lastIndexOf(':')
val address = parseInetAddress(split.first()) val address = parseInetAddress(data.substring(0, i))
val port = split.last().toInt() val port = data.substring(i + 1).toInt()
return InetEndpoint(address, port) return InetEndpoint(address, port)
} }
} }

View file

@ -9,7 +9,11 @@ data class InetNetwork(val address: InetAddress, val mask: Int) {
constructor(address: InetAddress) : this(address, address.maxPrefixLength) 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 { companion object {
fun parse(data: String): InetNetwork { fun parse(data: String): InetNetwork {

View file

@ -3,12 +3,17 @@ package org.amnezia.vpn.util.net
import java.net.InetAddress import java.net.InetAddress
@OptIn(ExperimentalUnsignedTypes::class) @OptIn(ExperimentalUnsignedTypes::class)
class IpAddress private constructor(private val address: UByteArray) : Comparable<IpAddress> { internal class IpAddress private constructor(private val address: UByteArray) : Comparable<IpAddress> {
val size: Int = address.size val size: Int = address.size
val lastIndex: Int = address.lastIndex val lastIndex: Int = address.lastIndex
val maxMask: Int = size * 8 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(inetAddress: InetAddress) : this(inetAddress.address.asUByteArray())
constructor(ipAddress: String) : this(parseInetAddress(ipAddress)) constructor(ipAddress: String) : this(parseInetAddress(ipAddress))
@ -43,6 +48,8 @@ class IpAddress private constructor(private val address: UByteArray) : Comparabl
return copy return copy
} }
fun isMinIp(): Boolean = address.all { it == 0x00u.toUByte() }
fun isMaxIp(): Boolean = address.all { it == 0xffu.toUByte() } fun isMaxIp(): Boolean = address.all { it == 0xffu.toUByte() }
override fun compareTo(other: IpAddress): Int { override fun compareTo(other: IpAddress): Int {
@ -74,12 +81,14 @@ class IpAddress private constructor(private val address: UByteArray) : Comparabl
private fun toIpv6String(): String { private fun toIpv6String(): String {
val sb = StringBuilder() val sb = StringBuilder()
var i = 0 var i = 0
var block: Int
while (i < size) { while (i < size) {
sb.append(address[i++].toHexString()) block = address[i++].toInt() shl 8
sb.append(address[i++].toHexString()) block += address[i++].toInt()
sb.append(block.toHexString(hexFormat))
sb.append(':') sb.append(':')
} }
sb.deleteAt(sb.lastIndex) sb.deleteAt(sb.lastIndex)
return sb.toString() return convertIpv6ToCanonicalForm(sb.toString())
} }
} }

View file

@ -2,14 +2,24 @@ package org.amnezia.vpn.util.net
import java.net.InetAddress import java.net.InetAddress
class IpRange(private val start: IpAddress, private val end: IpAddress) : Comparable<IpRange> { class IpRange internal constructor(
internal val start: IpAddress,
internal val end: IpAddress
) : Comparable<IpRange> {
init { 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<IpAddress, IpAddress>) : this(addresses.first, addresses.second) private constructor(addresses: Pair<IpAddress, IpAddress>) : this(addresses.first, addresses.second)
internal constructor(ipAddress: IpAddress) : this(ipAddress, ipAddress)
constructor(inetAddress: InetAddress, mask: Int) : this(from(inetAddress, mask)) constructor(inetAddress: InetAddress, mask: Int) : this(from(inetAddress, mask))
constructor(address: String, mask: Int) : this(parseInetAddress(address), 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 = private fun isIntersect(other: IpRange): Boolean =
(start <= other.end) && (end >= other.start) (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<IpRange>? { operator fun minus(other: IpRange): List<IpRange>? {
if (this in other) return emptyList() if (this in other) return emptyList()
if (!isIntersect(other)) return null if (!isIntersect(other)) return null
@ -94,9 +111,7 @@ class IpRange(private val start: IpAddress, private val end: IpAddress) : Compar
return result return result
} }
override fun toString(): String { override fun toString(): String = if (start == end) "<$start>" else "<$start - $end>"
return "$start - $end"
}
companion object { companion object {
private fun from(inetAddress: InetAddress, mask: Int): Pair<IpAddress, IpAddress> { private fun from(inetAddress: InetAddress, mask: Int): Pair<IpAddress, IpAddress> {

View file

@ -1,15 +1,35 @@
package org.amnezia.vpn.util.net 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<IpRange>()
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) { fun remove(ipRange: IpRange) {
val iterator = ranges.iterator() val iterator = ranges.iterator()
val splitRanges = mutableListOf<IpRange>() val splitRanges = mutableListOf<IpRange>()
while (iterator.hasNext()) { while (iterator.hasNext()) {
val range = iterator.next() val curRange = iterator.next()
(range - ipRange)?.let { resultRanges -> if (ipRange.end < curRange.start) break
(curRange - ipRange)?.let { resultRanges ->
iterator.remove() iterator.remove()
splitRanges += resultRanges splitRanges += resultRanges
} }
@ -17,10 +37,7 @@ class IpRangeSet(ipRange: IpRange = IpRange("0.0.0.0", 0)) {
ranges += splitRanges ranges += splitRanges
} }
fun subnets(): List<InetNetwork> = fun subnets(): List<InetNetwork> = ranges.map(IpRange::subnets).flatten()
ranges.map(IpRange::subnets).flatten()
override fun toString(): String { override fun toString(): String = ranges.toString()
return ranges.toString()
}
} }

View file

@ -10,7 +10,9 @@ import android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
import android.net.NetworkRequest import android.net.NetworkRequest
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import androidx.core.content.getSystemService
import kotlin.LazyThreadSafetyMode.NONE import kotlin.LazyThreadSafetyMode.NONE
import kotlinx.coroutines.delay
import org.amnezia.vpn.util.Log import org.amnezia.vpn.util.Log
private const val TAG = "NetworkState" private const val TAG = "NetworkState"
@ -28,7 +30,7 @@ class NetworkState(
} }
private val connectivityManager: ConnectivityManager by lazy(NONE) { private val connectivityManager: ConnectivityManager by lazy(NONE) {
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager context.getSystemService<ConnectivityManager>()!!
} }
private val networkRequest: NetworkRequest by lazy(NONE) { private val networkRequest: NetworkRequest by lazy(NONE) {
@ -80,13 +82,24 @@ class NetworkState(
} }
} }
fun bindNetworkListener() { suspend fun bindNetworkListener() {
if (isListenerBound) return if (isListenerBound) return
Log.d(TAG, "Bind network listener") Log.d(TAG, "Bind network listener")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
connectivityManager.registerBestMatchingNetworkCallback(networkRequest, networkCallback, handler) connectivityManager.registerBestMatchingNetworkCallback(networkRequest, networkCallback, handler)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { } 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 { } else {
connectivityManager.requestNetwork(networkRequest, networkCallback) connectivityManager.requestNetwork(networkRequest, networkCallback)
} }

View file

@ -5,12 +5,14 @@ import android.net.ConnectivityManager
import android.net.InetAddresses import android.net.InetAddresses
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
import android.os.Build import android.os.Build
import androidx.core.content.getSystemService
import java.lang.reflect.InvocationTargetException
import java.net.Inet4Address import java.net.Inet4Address
import java.net.Inet6Address import java.net.Inet6Address
import java.net.InetAddress import java.net.InetAddress
fun getLocalNetworks(context: Context, ipv6: Boolean): List<InetNetwork> { fun getLocalNetworks(context: Context, ipv6: Boolean): List<InetNetwork> {
val connectivityManager = context.getSystemService(ConnectivityManager::class.java) val connectivityManager = context.getSystemService<ConnectivityManager>()!!
connectivityManager.activeNetwork?.let { network -> connectivityManager.activeNetwork?.let { network ->
val netCapabilities = connectivityManager.getNetworkCapabilities(network) val netCapabilities = connectivityManager.getNetworkCapabilities(network)
val linkProperties = connectivityManager.getLinkProperties(network) val linkProperties = connectivityManager.getLinkProperties(network)
@ -39,8 +41,28 @@ private val parseNumericAddressCompat: (String) -> InetAddress =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
InetAddresses::parseNumericAddress InetAddresses::parseNumericAddress
} else { } else {
val m = InetAddress::class.java.getMethod("parseNumericAddress", String::class.java) try {
fun(address: String): InetAddress { val m = InetAddress::class.java.getMethod("parseNumericAddress", String::class.java)
return m.invoke(null, address) as InetAddress 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!!)
}

View file

@ -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)
}
}
}

View file

@ -37,8 +37,8 @@ open class WireguardConfig protected constructor(
open fun appendPeerLine(sb: StringBuilder) = with(sb) { open fun appendPeerLine(sb: StringBuilder) = with(sb) {
appendLine("public_key=$publicKeyHex") appendLine("public_key=$publicKeyHex")
routes.forEach { route -> routes.filter { it.include }.forEach { route ->
appendLine("allowed_ip=$route") appendLine("allowed_ip=${route.inetNetwork}")
} }
appendLine("endpoint=$endpoint") appendLine("endpoint=$endpoint")
if (persistentKeepalive != 0) if (persistentKeepalive != 0)

View file

@ -26,7 +26,6 @@ link_directories(${CMAKE_CURRENT_SOURCE_DIR}/platforms/android)
set(HEADERS ${HEADERS} set(HEADERS ${HEADERS}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/android_controller.h ${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/android_utils.h
${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/authResultReceiver.h ${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/authResultReceiver.h
${CMAKE_CURRENT_SOURCE_DIR}/protocols/android_vpnprotocol.h ${CMAKE_CURRENT_SOURCE_DIR}/protocols/android_vpnprotocol.h
@ -35,7 +34,6 @@ set(HEADERS ${HEADERS}
set(SOURCES ${SOURCES} set(SOURCES ${SOURCES}
${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/android_controller.cpp ${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/android_utils.cpp
${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/authResultReceiver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/platforms/android/authResultReceiver.cpp
${CMAKE_CURRENT_SOURCE_DIR}/protocols/android_vpnprotocol.cpp ${CMAKE_CURRENT_SOURCE_DIR}/protocols/android_vpnprotocol.cpp

View file

@ -93,6 +93,7 @@ bool AndroidController::initialize()
{"onServiceDisconnected", "()V", reinterpret_cast<void *>(onServiceDisconnected)}, {"onServiceDisconnected", "()V", reinterpret_cast<void *>(onServiceDisconnected)},
{"onServiceError", "()V", reinterpret_cast<void *>(onServiceError)}, {"onServiceError", "()V", reinterpret_cast<void *>(onServiceError)},
{"onVpnPermissionRejected", "()V", reinterpret_cast<void *>(onVpnPermissionRejected)}, {"onVpnPermissionRejected", "()V", reinterpret_cast<void *>(onVpnPermissionRejected)},
{"onNotificationStateChanged", "()V", reinterpret_cast<void *>(onNotificationStateChanged)},
{"onVpnStateChanged", "(I)V", reinterpret_cast<void *>(onVpnStateChanged)}, {"onVpnStateChanged", "(I)V", reinterpret_cast<void *>(onVpnStateChanged)},
{"onStatisticsUpdate", "(JJ)V", reinterpret_cast<void *>(onStatisticsUpdate)}, {"onStatisticsUpdate", "(JJ)V", reinterpret_cast<void *>(onStatisticsUpdate)},
{"onFileOpened", "(Ljava/lang/String;)V", reinterpret_cast<void *>(onFileOpened)}, {"onFileOpened", "(Ljava/lang/String;)V", reinterpret_cast<void *>(onFileOpened)},
@ -173,14 +174,6 @@ QString AndroidController::openFile(const QString &filter)
return fileName; 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<jstring>(),
QJniObject::fromString(message).object<jstring>(),
(jint) timerSec);
}
bool AndroidController::isCameraPresent() bool AndroidController::isCameraPresent()
{ {
return callActivityMethod<jboolean>("isCameraPresent", "()Z"); return callActivityMethod<jboolean>("isCameraPresent", "()Z");
@ -257,6 +250,16 @@ QPixmap AndroidController::getAppIcon(const QString &package, QSize *size, const
return QPixmap::fromImage(image); return QPixmap::fromImage(image);
} }
bool AndroidController::isNotificationPermissionGranted()
{
return callActivityMethod<jboolean>("isNotificationPermissionGranted", "()Z");
}
void AndroidController::requestNotificationPermission()
{
callActivityMethod("requestNotificationPermission", "()V");
}
// Moving log processing to the Android side // Moving log processing to the Android side
jclass AndroidController::log; jclass AndroidController::log;
jmethodID AndroidController::logDebug; jmethodID AndroidController::logDebug;
@ -409,6 +412,15 @@ void AndroidController::onVpnPermissionRejected(JNIEnv *env, jobject thiz)
emit AndroidController::instance()->vpnPermissionRejected(); emit AndroidController::instance()->vpnPermissionRejected();
} }
// static
void AndroidController::onNotificationStateChanged(JNIEnv *env, jobject thiz)
{
Q_UNUSED(env);
Q_UNUSED(thiz);
emit AndroidController::instance()->notificationStateChanged();
}
// static // static
void AndroidController::onVpnStateChanged(JNIEnv *env, jobject thiz, jint stateCode) void AndroidController::onVpnStateChanged(JNIEnv *env, jobject thiz, jint stateCode)
{ {

View file

@ -32,7 +32,6 @@ public:
ErrorCode start(const QJsonObject &vpnConfig); ErrorCode start(const QJsonObject &vpnConfig);
void stop(); void stop();
void resetLastServer(int serverIndex); void resetLastServer(int serverIndex);
void setNotificationText(const QString &title, const QString &message, int timerSec);
void saveFile(const QString &fileName, const QString &data); void saveFile(const QString &fileName, const QString &data);
QString openFile(const QString &filter); QString openFile(const QString &filter);
bool isCameraPresent(); bool isCameraPresent();
@ -44,6 +43,8 @@ public:
void minimizeApp(); void minimizeApp();
QJsonArray getAppList(); QJsonArray getAppList();
QPixmap getAppIcon(const QString &package, QSize *size, const QSize &requestedSize); QPixmap getAppIcon(const QString &package, QSize *size, const QSize &requestedSize);
bool isNotificationPermissionGranted();
void requestNotificationPermission();
static bool initLogging(); static bool initLogging();
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message); static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message);
@ -54,6 +55,7 @@ signals:
void serviceDisconnected(); void serviceDisconnected();
void serviceError(); void serviceError();
void vpnPermissionRejected(); void vpnPermissionRejected();
void notificationStateChanged();
void vpnStateChanged(ConnectionState state); void vpnStateChanged(ConnectionState state);
void statisticsUpdated(quint64 rxBytes, quint64 txBytes); void statisticsUpdated(quint64 rxBytes, quint64 txBytes);
void fileOpened(QString uri); void fileOpened(QString uri);
@ -81,6 +83,7 @@ private:
static void onServiceDisconnected(JNIEnv *env, jobject thiz); static void onServiceDisconnected(JNIEnv *env, jobject thiz);
static void onServiceError(JNIEnv *env, jobject thiz); static void onServiceError(JNIEnv *env, jobject thiz);
static void onVpnPermissionRejected(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 onVpnStateChanged(JNIEnv *env, jobject thiz, jint stateCode);
static void onStatisticsUpdate(JNIEnv *env, jobject thiz, jlong rxBytes, jlong txBytes); static void onStatisticsUpdate(JNIEnv *env, jobject thiz, jlong rxBytes, jlong txBytes);
static void onConfigImported(JNIEnv *env, jobject thiz, jstring data); static void onConfigImported(JNIEnv *env, jobject thiz, jstring data);

View file

@ -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);
}

View file

@ -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 <QObject>
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

View file

@ -1088,7 +1088,17 @@ Already installed containers were found on the server. All installed containers
<translation>Язык</translation> <translation>Язык</translation>
</message> </message>
<message> <message>
<location filename="../ui/qml/Pages2/PageSettingsApplication.qml" line="147"/> <location filename="../ui/qml/Pages2/PageSettingsApplication.qml" line="218"/>
<source>Enable notifications</source>
<translation>Включить уведомления</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApplication.qml" line="219"/>
<source>Enable notifications to show the VPN state in the status bar</source>
<translation>Включить уведомления для отображения статуса VPN в строке состояния</translation>
</message>
<message>
<location filename="../ui/qml/Pages2/PageSettingsApplication.qml" line="238"/>
<source>Logging</source> <source>Logging</source>
<translation>Логирование</translation> <translation>Логирование</translation>
</message> </message>

View file

@ -30,6 +30,9 @@ SettingsController::SettingsController(const QSharedPointer<ServersModel> &serve
{ {
m_appVersion = QString("%1 (%2, %3)").arg(QString(APP_VERSION), __DATE__, GIT_COMMIT_HASH); m_appVersion = QString("%1 (%2, %3)").arg(QString(APP_VERSION), __DATE__, GIT_COMMIT_HASH);
checkIfNeedDisableLogs(); checkIfNeedDisableLogs();
#ifdef Q_OS_ANDROID
connect(AndroidController::instance(), &AndroidController::notificationStateChanged, this, &SettingsController::onNotificationStateChanged);
#endif
} }
void SettingsController::toggleAmneziaDns(bool enable) void SettingsController::toggleAmneziaDns(bool enable)
@ -233,3 +236,19 @@ void SettingsController::toggleKillSwitch(bool enable)
{ {
m_settings->setKillSwitchEnabled(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
}

View file

@ -23,6 +23,7 @@ public:
Q_PROPERTY(QString primaryDns READ getPrimaryDns WRITE setPrimaryDns NOTIFY primaryDnsChanged) Q_PROPERTY(QString primaryDns READ getPrimaryDns WRITE setPrimaryDns NOTIFY primaryDnsChanged)
Q_PROPERTY(QString secondaryDns READ getSecondaryDns WRITE setSecondaryDns NOTIFY secondaryDnsChanged) Q_PROPERTY(QString secondaryDns READ getSecondaryDns WRITE setSecondaryDns NOTIFY secondaryDnsChanged)
Q_PROPERTY(bool isLoggingEnabled READ isLoggingEnabled WRITE toggleLogging NOTIFY loggingStateChanged) Q_PROPERTY(bool isLoggingEnabled READ isLoggingEnabled WRITE toggleLogging NOTIFY loggingStateChanged)
Q_PROPERTY(bool isNotificationPermissionGranted READ isNotificationPermissionGranted NOTIFY onNotificationStateChanged)
public slots: public slots:
void toggleAmneziaDns(bool enable); void toggleAmneziaDns(bool enable);
@ -66,6 +67,9 @@ public slots:
bool isKillSwitchEnabled(); bool isKillSwitchEnabled();
void toggleKillSwitch(bool enable); void toggleKillSwitch(bool enable);
bool isNotificationPermissionGranted();
void requestNotificationPermission();
signals: signals:
void primaryDnsChanged(); void primaryDnsChanged();
void secondaryDnsChanged(); void secondaryDnsChanged();
@ -83,6 +87,8 @@ signals:
void loggingDisableByWatcher(); void loggingDisableByWatcher();
void onNotificationStateChanged();
private: private:
QSharedPointer<ServersModel> m_serversModel; QSharedPointer<ServersModel> m_serversModel;
QSharedPointer<ContainersModel> m_containersModel; QSharedPointer<ContainersModel> m_containersModel;

View file

@ -7,10 +7,7 @@
#if defined(Q_OS_IOS) #if defined(Q_OS_IOS)
# include "platforms/ios/iosnotificationhandler.h" # include "platforms/ios/iosnotificationhandler.h"
#elif defined(Q_OS_ANDROID)
# include "platforms/android/android_notificationhandler.h"
#else #else
# include "systemtray_notificationhandler.h" # include "systemtray_notificationhandler.h"
#endif #endif
@ -18,8 +15,6 @@
NotificationHandler* NotificationHandler::create(QObject* parent) { NotificationHandler* NotificationHandler::create(QObject* parent) {
#if defined(Q_OS_IOS) #if defined(Q_OS_IOS)
return new IOSNotificationHandler(parent); return new IOSNotificationHandler(parent);
#elif defined(Q_OS_ANDROID)
return new AndroidNotificationHandler(parent);
#else #else
# if defined(Q_OS_LINUX) # if defined(Q_OS_LINUX)

View file

@ -74,7 +74,8 @@ PageType {
} }
} }
KeyNavigation.tab: labelWithButtonLanguage.rightButton KeyNavigation.tab: Qt.platform.os === "android" && !SettingsController.isNotificationPermissionGranted ?
labelWithButtonNotification.rightButton : labelWithButtonLanguage.rightButton
parentFlickable: fl parentFlickable: fl
} }
@ -82,6 +83,27 @@ PageType {
visible: GC.isMobile() 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 { SwitcherType {
id: switcherAutoStart id: switcherAutoStart
visible: !GC.isMobile() visible: !GC.isMobile()
@ -173,7 +195,6 @@ PageType {
} }
} }
DividerType {} DividerType {}
LabelWithButtonType { LabelWithButtonType {