Native android service implemented
This commit is contained in:
parent
d553d7f772
commit
eb497be730
81 changed files with 6442 additions and 34 deletions
115
client/android/src/org/amnezia/vpn/NotificationUtil.kt
Normal file
115
client/android/src/org/amnezia/vpn/NotificationUtil.kt
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/* 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.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Parcel
|
||||
import androidx.core.app.NotificationCompat
|
||||
import org.json.JSONObject
|
||||
|
||||
object NotificationUtil {
|
||||
var sCurrentContext: Context? = null
|
||||
private var sNotificationBuilder: NotificationCompat.Builder? = null
|
||||
|
||||
const val NOTIFICATION_CHANNEL_ID = "com.amnezia.vpnNotification"
|
||||
const val CONNECTED_NOTIFICATION_ID = 1337
|
||||
const val tag = "NotificationUtil"
|
||||
|
||||
/**
|
||||
* Updates the current shown notification from a
|
||||
* Parcel - Gets called from AndroidController.cpp
|
||||
*/
|
||||
fun update(data: Parcel) {
|
||||
// [data] is here a json containing the noification content
|
||||
val buffer = data.createByteArray()
|
||||
val json = buffer?.let { String(it) }
|
||||
val content = JSONObject(json)
|
||||
|
||||
update(content.getString("title"), content.getString("message"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current shown notification
|
||||
*/
|
||||
fun update(heading: String, message: String) {
|
||||
if (sCurrentContext == null) return
|
||||
val notificationManager: NotificationManager =
|
||||
sCurrentContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
sNotificationBuilder?.let {
|
||||
it.setContentTitle(heading)
|
||||
.setContentText(message)
|
||||
notificationManager.notify(CONNECTED_NOTIFICATION_ID, it.build())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the default translated "connected" notification, in case the vpn gets started
|
||||
* without the app.
|
||||
*/
|
||||
fun saveFallBackMessage(data: Parcel, context: Context) {
|
||||
// [data] is here a json containing the notification content
|
||||
val buffer = data.createByteArray()
|
||||
val json = buffer?.let { String(it) }
|
||||
val content = JSONObject(json)
|
||||
|
||||
val prefs = Prefs.get(context)
|
||||
prefs.edit()
|
||||
.putString("fallbackNotificationHeader", content.getString("title"))
|
||||
.putString("fallbackNotificationMessage", content.getString("message"))
|
||||
.apply()
|
||||
Log.v(tag, "Saved new fallback message -> ${content.getString("title")}")
|
||||
}
|
||||
|
||||
/*
|
||||
* Creates a new Notification using the current set of Strings
|
||||
* Shows the notification in the given {context}
|
||||
*/
|
||||
fun show(service: VPNService) {
|
||||
sNotificationBuilder = NotificationCompat.Builder(service, NOTIFICATION_CHANNEL_ID)
|
||||
sCurrentContext = service
|
||||
val notificationManager: NotificationManager =
|
||||
sCurrentContext?.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
// From Oreo on we need to have a "notification channel" to post to.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val name = "vpn"
|
||||
val descriptionText = " "
|
||||
val importance = NotificationManager.IMPORTANCE_LOW
|
||||
val channel = NotificationChannel(NOTIFICATION_CHANNEL_ID, name, importance).apply {
|
||||
description = descriptionText
|
||||
}
|
||||
// Register the channel with the system
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
// In case we do not have gotten a message to show from the Frontend
|
||||
// try to populate the notification with a translated Fallback message
|
||||
val prefs = Prefs.get(service)
|
||||
val message =
|
||||
"" + prefs.getString("fallbackNotificationMessage", "Running in the Background")
|
||||
val header = "" + prefs.getString("fallbackNotificationHeader", "Mozilla VPN")
|
||||
|
||||
// Create the Intent that Should be Fired if the User Clicks the notification
|
||||
val mainActivityName = "org.amnezia.vpn.qt.VPNActivity"
|
||||
val activity = Class.forName(mainActivityName)
|
||||
val intent = Intent(service, activity)
|
||||
val pendingIntent = PendingIntent.getActivity(service, 0, intent, 0)
|
||||
// Build our notification
|
||||
sNotificationBuilder?.let {
|
||||
it.setSmallIcon(org.amnezia.vpn.R.drawable.ic_amnezia_round)
|
||||
.setContentTitle(header)
|
||||
.setContentText(message)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
|
||||
service.startForeground(CONNECTED_NOTIFICATION_ID, it.build())
|
||||
}
|
||||
}
|
||||
}
|
||||
35
client/android/src/org/amnezia/vpn/Prefs.kt
Normal file
35
client/android/src/org/amnezia/vpn/Prefs.kt
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/* 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.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Log
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
|
||||
object Prefs {
|
||||
// Opens and returns an instance of EncryptedSharedPreferences
|
||||
fun get(context: Context): SharedPreferences {
|
||||
try {
|
||||
val mainKey = MasterKey.Builder(context.applicationContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
|
||||
val sharedPrefsFile = "com.amnezia.vpn.secure.prefs"
|
||||
val sharedPreferences: SharedPreferences = EncryptedSharedPreferences.create(
|
||||
context.applicationContext,
|
||||
sharedPrefsFile,
|
||||
mainKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
return sharedPreferences
|
||||
} catch (e: Exception) {
|
||||
Log.e("Android-Prefs", "Getting Encryption Storage failed, plaintext fallback")
|
||||
return context.getSharedPreferences("com.amnezia.vpn.prefrences", Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
}
|
||||
72
client/android/src/org/amnezia/vpn/VPNLogger.kt
Normal file
72
client/android/src/org/amnezia/vpn/VPNLogger.kt
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/* 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.content.Context
|
||||
import java.io.File
|
||||
import java.time.LocalDateTime
|
||||
import android.util.Log as nativeLog
|
||||
|
||||
/*
|
||||
* Drop in replacement for android.util.Log
|
||||
* Also stores a copy of all logs in tmp/mozilla_deamon_logs.txt
|
||||
*/
|
||||
class Log {
|
||||
val LOG_MAX_FILE_SIZE = 204800
|
||||
private var file: File
|
||||
private constructor(context: Context) {
|
||||
val tempDIR = context.cacheDir
|
||||
file = File(tempDIR, "mozilla_deamon_logs.txt")
|
||||
if (file.length() > LOG_MAX_FILE_SIZE) {
|
||||
file.writeText("")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
var instance: Log? = null
|
||||
fun init(ctx: Context) {
|
||||
if (instance == null) {
|
||||
instance = Log(ctx)
|
||||
}
|
||||
}
|
||||
fun i(tag: String, message: String) {
|
||||
instance?.write("[info] - ($tag) - $message")
|
||||
if (!BuildConfig.DEBUG) { return; }
|
||||
nativeLog.i(tag, message)
|
||||
}
|
||||
fun v(tag: String, message: String) {
|
||||
instance?.write("($tag) - $message")
|
||||
if (!BuildConfig.DEBUG) { return; }
|
||||
nativeLog.v(tag, message)
|
||||
}
|
||||
fun e(tag: String, message: String) {
|
||||
instance?.write("[error] - ($tag) - $message")
|
||||
if (!BuildConfig.DEBUG) { return; }
|
||||
nativeLog.e(tag, message)
|
||||
}
|
||||
// Only Prints && Loggs when in debug, noop in release.
|
||||
fun sensitive(tag: String, message: String?) {
|
||||
if (!BuildConfig.DEBUG) { return; }
|
||||
if (message == null) { return; }
|
||||
e(tag, message)
|
||||
}
|
||||
|
||||
fun getContent(): String? {
|
||||
return try {
|
||||
instance?.file?.readText()
|
||||
} catch (e: Exception) {
|
||||
"=== Failed to read Daemon Logs === \n ${e.localizedMessage} "
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFile() {
|
||||
instance?.file?.writeText("")
|
||||
}
|
||||
}
|
||||
private fun write(message: String) {
|
||||
LocalDateTime.now()
|
||||
file.appendText("[${LocalDateTime.now()}] $message \n")
|
||||
}
|
||||
}
|
||||
309
client/android/src/org/amnezia/vpn/VPNService.kt
Normal file
309
client/android/src/org/amnezia/vpn/VPNService.kt
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
/* 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.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.system.OsConstants
|
||||
import com.wireguard.android.util.SharedLibraryLoader
|
||||
import com.wireguard.config.*
|
||||
import com.wireguard.crypto.Key
|
||||
import org.json.JSONObject
|
||||
|
||||
class VPNService : android.net.VpnService() {
|
||||
private val tag = "VPNService"
|
||||
private var mBinder: VPNServiceBinder = VPNServiceBinder(this)
|
||||
private var mConfig: JSONObject? = null
|
||||
private var mConnectionTime: Long = 0
|
||||
private var mAlreadyInitialised = false
|
||||
|
||||
private var currentTunnelHandle = -1
|
||||
|
||||
fun init() {
|
||||
if (mAlreadyInitialised) {
|
||||
return
|
||||
}
|
||||
Log.init(this)
|
||||
SharedLibraryLoader.loadSharedLibrary(this, "wg-go")
|
||||
Log.i(tag, "loaded lib")
|
||||
Log.e(tag, "Wireguard Version ${wgVersion()}")
|
||||
mAlreadyInitialised = true
|
||||
}
|
||||
|
||||
override fun onUnbind(intent: Intent?): Boolean {
|
||||
if (!isUp) {
|
||||
// If the Qt Client got closed while we were not connected
|
||||
// we do not need to stay as a foreground service.
|
||||
stopForeground(true)
|
||||
}
|
||||
return super.onUnbind(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* EntryPoint for the Service, gets Called when AndroidController.cpp
|
||||
* calles bindService. Returns the [VPNServiceBinder] so QT can send Requests to it.
|
||||
*/
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
Log.v(tag, "Got Bind request")
|
||||
init()
|
||||
return mBinder
|
||||
}
|
||||
|
||||
/**
|
||||
* Might be the entryPoint if the Service gets Started via an
|
||||
* Service Intent: Might be from Always-On-Vpn from Settings
|
||||
* or from Booting the device and having "connect on boot" enabled.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
init()
|
||||
intent?.let {
|
||||
if (intent.getBooleanExtra("startOnly", false)) {
|
||||
Log.i(tag, "Start only!")
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
}
|
||||
// This start is from always-on
|
||||
if (this.mConfig == null) {
|
||||
// We don't have tunnel to turn on - Try to create one with last config the service got
|
||||
val prefs = Prefs.get(this)
|
||||
val lastConfString = prefs.getString("lastConf", "")
|
||||
if (lastConfString.isNullOrEmpty()) {
|
||||
// We have nothing to connect to -> Exit
|
||||
Log.e(
|
||||
tag,
|
||||
"VPN service was triggered without defining a Server or having a tunnel"
|
||||
)
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
this.mConfig = JSONObject(lastConfString)
|
||||
}
|
||||
turnOn(this.mConfig!!)
|
||||
return super.onStartCommand(intent, flags, startId)
|
||||
}
|
||||
|
||||
// Invoked when the application is revoked.
|
||||
// At this moment, the VPN interface is already deactivated by the system.
|
||||
override fun onRevoke() {
|
||||
this.turnOff()
|
||||
super.onRevoke()
|
||||
}
|
||||
|
||||
var connectionTime: Long = 0
|
||||
get() {
|
||||
return mConnectionTime
|
||||
}
|
||||
|
||||
var isUp: Boolean
|
||||
get() {
|
||||
return currentTunnelHandle >= 0
|
||||
}
|
||||
private set(value) {
|
||||
if (value) {
|
||||
mBinder.dispatchEvent(VPNServiceBinder.EVENTS.connected, "")
|
||||
mConnectionTime = System.currentTimeMillis()
|
||||
return
|
||||
}
|
||||
mBinder.dispatchEvent(VPNServiceBinder.EVENTS.disconnected, "")
|
||||
mConnectionTime = 0
|
||||
}
|
||||
val status: JSONObject
|
||||
get() {
|
||||
val deviceIpv4: String = ""
|
||||
return JSONObject().apply {
|
||||
putOpt("rx_bytes", getConfigValue("rx_bytes"))
|
||||
putOpt("tx_bytes", getConfigValue("tx_bytes"))
|
||||
putOpt("endpoint", mConfig?.getJSONObject("server")?.getString("ipv4Gateway"))
|
||||
putOpt("deviceIpv4", mConfig?.getJSONObject("device")?.getString("ipv4Address"))
|
||||
}
|
||||
}
|
||||
/*
|
||||
* Checks if the VPN Permission is given.
|
||||
* If the permission is given, returns true
|
||||
* Requests permission and returns false if not.
|
||||
*/
|
||||
fun checkPermissions(): Boolean {
|
||||
// See https://developer.android.com/guide/topics/connectivity/vpn#connect_a_service
|
||||
// Call Prepare, if we get an Intent back, we dont have the VPN Permission
|
||||
// from the user. So we need to pass this to our main Activity and exit here.
|
||||
val intent = prepare(this)
|
||||
if (intent == null) {
|
||||
Log.e(tag, "VPN Permission Already Present")
|
||||
return true
|
||||
}
|
||||
Log.e(tag, "Requesting VPN Permission")
|
||||
return false
|
||||
}
|
||||
|
||||
fun turnOn(json: JSONObject) {
|
||||
Log.sensitive(tag, json.toString())
|
||||
val wireguard_conf = buildWireugardConfig(json)
|
||||
|
||||
if (!checkPermissions()) {
|
||||
Log.e(tag, "turn on was called without no permissions present!")
|
||||
isUp = false
|
||||
return
|
||||
}
|
||||
Log.i(tag, "Permission okay")
|
||||
if (currentTunnelHandle != -1) {
|
||||
Log.e(tag, "Tunnel already up")
|
||||
// Turn the tunnel down because this might be a switch
|
||||
wgTurnOff(currentTunnelHandle)
|
||||
}
|
||||
val wgConfig: String = wireguard_conf!!.toWgUserspaceString()
|
||||
val builder = Builder()
|
||||
setupBuilder(wireguard_conf, builder)
|
||||
builder.setSession("mvpn0")
|
||||
builder.establish().use { tun ->
|
||||
if (tun == null)return
|
||||
Log.i(tag, "Go backend " + wgVersion())
|
||||
currentTunnelHandle = wgTurnOn("mvpn0", tun.detachFd(), wgConfig)
|
||||
}
|
||||
if (currentTunnelHandle < 0) {
|
||||
Log.e(tag, "Activation Error Code -> $currentTunnelHandle")
|
||||
isUp = false
|
||||
return
|
||||
}
|
||||
protect(wgGetSocketV4(currentTunnelHandle))
|
||||
protect(wgGetSocketV6(currentTunnelHandle))
|
||||
mConfig = json
|
||||
isUp = true
|
||||
|
||||
// Store the config in case the service gets
|
||||
// asked boot vpn from the OS
|
||||
val prefs = Prefs.get(this)
|
||||
prefs.edit()
|
||||
.putString("lastConf", json.toString())
|
||||
.apply()
|
||||
|
||||
NotificationUtil.show(this) // Go foreground
|
||||
}
|
||||
|
||||
fun turnOff() {
|
||||
Log.v(tag, "Try to disable tunnel")
|
||||
wgTurnOff(currentTunnelHandle)
|
||||
currentTunnelHandle = -1
|
||||
stopForeground(false)
|
||||
isUp = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures an Android VPN Service Tunnel
|
||||
* with a given Wireguard Config
|
||||
*/
|
||||
private fun setupBuilder(config: Config, builder: Builder) {
|
||||
// Setup Split tunnel
|
||||
for (excludedApplication in config.`interface`.excludedApplications)
|
||||
builder.addDisallowedApplication(excludedApplication)
|
||||
|
||||
// Device IP
|
||||
for (addr in config.`interface`.addresses) builder.addAddress(addr.address, addr.mask)
|
||||
// DNS
|
||||
for (addr in config.`interface`.dnsServers) builder.addDnsServer(addr.hostAddress)
|
||||
// Add All routes the VPN may route tos
|
||||
for (peer in config.peers) {
|
||||
for (addr in peer.allowedIps) {
|
||||
builder.addRoute(addr.address, addr.mask)
|
||||
}
|
||||
}
|
||||
builder.allowFamily(OsConstants.AF_INET)
|
||||
builder.allowFamily(OsConstants.AF_INET6)
|
||||
builder.setMtu(config.`interface`.mtu.orElse(1280))
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) builder.setMetered(false)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) setUnderlyingNetworks(null)
|
||||
|
||||
builder.setBlocking(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets config value for {key} from the Current
|
||||
* running Wireguard tunnel
|
||||
*/
|
||||
private fun getConfigValue(key: String): String? {
|
||||
if (!isUp) {
|
||||
return null
|
||||
}
|
||||
val config = wgGetConfig(currentTunnelHandle) ?: return null
|
||||
val lines = config.split("\n")
|
||||
for (line in lines) {
|
||||
val parts = line.split("=")
|
||||
val k = parts.first()
|
||||
val value = parts.last()
|
||||
if (key == k) {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Wireguard [Config] from a [json] string -
|
||||
* The [json] will be created in AndroidController.cpp
|
||||
*/
|
||||
private fun buildWireugardConfig(obj: JSONObject): Config {
|
||||
val confBuilder = Config.Builder()
|
||||
val jServer = obj.getJSONObject("server")
|
||||
val peerBuilder = Peer.Builder()
|
||||
val ep =
|
||||
InetEndpoint.parse(jServer.getString("ipv4AddrIn") + ":" + jServer.getString("port"))
|
||||
peerBuilder.setEndpoint(ep)
|
||||
peerBuilder.setPublicKey(Key.fromBase64(jServer.getString("publicKey")))
|
||||
|
||||
val jAllowedIPList = obj.getJSONArray("allowedIPs")
|
||||
if (jAllowedIPList.length() == 0) {
|
||||
val internet = InetNetwork.parse("0.0.0.0/0") // aka The whole internet.
|
||||
peerBuilder.addAllowedIp(internet)
|
||||
} else {
|
||||
(0 until jAllowedIPList.length()).toList().forEach {
|
||||
val network = InetNetwork.parse(jAllowedIPList.getString(it))
|
||||
peerBuilder.addAllowedIp(network)
|
||||
}
|
||||
}
|
||||
|
||||
confBuilder.addPeer(peerBuilder.build())
|
||||
|
||||
val privateKey = obj.getJSONObject("keys").getString("privateKey")
|
||||
val jDevice = obj.getJSONObject("device")
|
||||
|
||||
val ifaceBuilder = Interface.Builder()
|
||||
ifaceBuilder.parsePrivateKey(privateKey)
|
||||
ifaceBuilder.addAddress(InetNetwork.parse(jDevice.getString("ipv4Address")))
|
||||
ifaceBuilder.addAddress(InetNetwork.parse(jDevice.getString("ipv6Address")))
|
||||
ifaceBuilder.addDnsServer(InetNetwork.parse(obj.getString("dns")).address)
|
||||
val jExcludedApplication = obj.getJSONArray("excludedApps")
|
||||
(0 until jExcludedApplication.length()).toList().forEach {
|
||||
val appName = jExcludedApplication.get(it).toString()
|
||||
ifaceBuilder.excludeApplication(appName)
|
||||
}
|
||||
confBuilder.setInterface(ifaceBuilder.build())
|
||||
return confBuilder.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun startService(c: Context) {
|
||||
c.applicationContext.startService(
|
||||
Intent(c.applicationContext, VPNService::class.java).apply {
|
||||
putExtra("startOnly", true)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
private external fun wgGetConfig(handle: Int): String?
|
||||
@JvmStatic
|
||||
private external fun wgGetSocketV4(handle: Int): Int
|
||||
@JvmStatic
|
||||
private external fun wgGetSocketV6(handle: Int): Int
|
||||
@JvmStatic
|
||||
private external fun wgTurnOff(handle: Int)
|
||||
@JvmStatic
|
||||
private external fun wgTurnOn(ifName: String, tunFd: Int, settings: String): Int
|
||||
@JvmStatic
|
||||
private external fun wgVersion(): String?
|
||||
}
|
||||
}
|
||||
170
client/android/src/org/amnezia/vpn/VPNServiceBinder.kt
Normal file
170
client/android/src/org/amnezia/vpn/VPNServiceBinder.kt
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/* 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.os.Binder
|
||||
import android.os.DeadObjectException
|
||||
import android.os.IBinder
|
||||
import android.os.Parcel
|
||||
import com.wireguard.config.*
|
||||
import org.json.JSONObject
|
||||
import java.lang.Exception
|
||||
|
||||
class VPNServiceBinder(service: VPNService) : Binder() {
|
||||
|
||||
private val mService = service
|
||||
private val tag = "VPNServiceBinder"
|
||||
private var mListener: IBinder? = null
|
||||
private var mResumeConfig: JSONObject? = null
|
||||
|
||||
/**
|
||||
* The codes this Binder does accept in [onTransact]
|
||||
*/
|
||||
object ACTIONS {
|
||||
const val activate = 1
|
||||
const val deactivate = 2
|
||||
const val registerEventListener = 3
|
||||
const val requestStatistic = 4
|
||||
const val requestGetLog = 5
|
||||
const val requestCleanupLog = 6
|
||||
const val resumeActivate = 7
|
||||
const val setNotificationText = 8
|
||||
const val setFallBackNotification = 9
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets called when the VPNServiceBinder gets a request from a Client.
|
||||
* The [code] determines what action is requested. - see [ACTIONS]
|
||||
* [data] may contain a utf-8 encoded json string with optional args or is null.
|
||||
* [reply] is a pointer to a buffer in the clients memory, to reply results.
|
||||
* we use this to send result data.
|
||||
*
|
||||
* returns true if the [code] was accepted
|
||||
*/
|
||||
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
|
||||
Log.i(tag, "GOT TRANSACTION $code")
|
||||
|
||||
when (code) {
|
||||
ACTIONS.activate -> {
|
||||
try {
|
||||
Log.i(tag, "Activiation Requested, parsing Config")
|
||||
// [data] is here a json containing the wireguard conf
|
||||
val buffer = data.createByteArray()
|
||||
val json = buffer?.let { String(it) }
|
||||
val config = JSONObject(json)
|
||||
Log.v(tag, "Stored new Tunnel config in Service")
|
||||
|
||||
if (!mService.checkPermissions()) {
|
||||
mResumeConfig = config
|
||||
// The Permission prompt was already
|
||||
// send, in case it's accepted we will
|
||||
// receive ACTIONS.resumeActivate
|
||||
return true
|
||||
}
|
||||
this.mService.turnOn(config)
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "An Error occurred while enabling the VPN: ${e.localizedMessage}")
|
||||
dispatchEvent(EVENTS.activationError, e.localizedMessage)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
ACTIONS.resumeActivate -> {
|
||||
// [data] is empty
|
||||
// Activate the current tunnel
|
||||
try {
|
||||
mResumeConfig?.let { this.mService.turnOn(it) }
|
||||
} catch (e: Exception) {
|
||||
Log.e(tag, "An Error occurred while enabling the VPN: ${e.localizedMessage}")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
ACTIONS.deactivate -> {
|
||||
// [data] here is empty
|
||||
this.mService.turnOff()
|
||||
return true
|
||||
}
|
||||
|
||||
ACTIONS.registerEventListener -> {
|
||||
// [data] contains the Binder that we need to dispatch the Events
|
||||
val binder = data.readStrongBinder()
|
||||
mListener = binder
|
||||
val obj = JSONObject()
|
||||
obj.put("connected", mService.isUp)
|
||||
obj.put("time", mService.connectionTime)
|
||||
dispatchEvent(EVENTS.init, obj.toString())
|
||||
return true
|
||||
}
|
||||
|
||||
ACTIONS.requestStatistic -> {
|
||||
dispatchEvent(EVENTS.statisticUpdate, mService.status.toString())
|
||||
return true
|
||||
}
|
||||
|
||||
ACTIONS.requestGetLog -> {
|
||||
// Grabs all the Logs and dispatch new Log Event
|
||||
dispatchEvent(EVENTS.backendLogs, Log.getContent())
|
||||
return true
|
||||
}
|
||||
ACTIONS.requestCleanupLog -> {
|
||||
Log.clearFile()
|
||||
return true
|
||||
}
|
||||
ACTIONS.setNotificationText -> {
|
||||
NotificationUtil.update(data)
|
||||
return true
|
||||
}
|
||||
ACTIONS.setFallBackNotification -> {
|
||||
NotificationUtil.saveFallBackMessage(data, mService)
|
||||
return true
|
||||
}
|
||||
IBinder.LAST_CALL_TRANSACTION -> {
|
||||
Log.e(tag, "The OS Requested to shut down the VPN")
|
||||
this.mService.turnOff()
|
||||
return true
|
||||
}
|
||||
|
||||
else -> {
|
||||
Log.e(tag, "Received invalid bind request \t Code -> $code")
|
||||
// If we're hitting this there is probably something wrong in the client.
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches an Event to all registered Binders
|
||||
* [code] the Event that happened - see [EVENTS]
|
||||
* To register an Eventhandler use [onTransact] with
|
||||
* [ACTIONS.registerEventListener]
|
||||
*/
|
||||
fun dispatchEvent(code: Int, payload: String?) {
|
||||
try {
|
||||
mListener?.let {
|
||||
if (it.isBinderAlive) {
|
||||
val data = Parcel.obtain()
|
||||
data.writeByteArray(payload?.toByteArray(charset("UTF-8")))
|
||||
it.transact(code, data, Parcel.obtain(), 0)
|
||||
}
|
||||
}
|
||||
} catch (e: DeadObjectException) {
|
||||
// If the QT Process is killed (not just inactive)
|
||||
// we cant access isBinderAlive, so nothing to do here.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The codes we Are Using in case of [dispatchEvent]
|
||||
*/
|
||||
object EVENTS {
|
||||
const val init = 0
|
||||
const val connected = 1
|
||||
const val disconnected = 2
|
||||
const val statisticUpdate = 3
|
||||
const val backendLogs = 4
|
||||
const val activationError = 5
|
||||
}
|
||||
}
|
||||
189
client/android/src/org/amnezia/vpn/qt/PackageManagerHelper.java
Normal file
189
client/android/src/org/amnezia/vpn/qt/PackageManagerHelper.java
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
/* 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.qt;
|
||||
|
||||
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.mozilla.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);
|
||||
}
|
||||
}
|
||||
33
client/android/src/org/amnezia/vpn/qt/VPNActivity.java
Normal file
33
client/android/src/org/amnezia/vpn/qt/VPNActivity.java
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
/* 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.qt;
|
||||
|
||||
import android.view.KeyEvent;
|
||||
|
||||
public class VPNActivity extends org.qtproject.qt5.android.bindings.QtActivity {
|
||||
|
||||
@Override
|
||||
public boolean onKeyDown(int keyCode, KeyEvent event) {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK && event.getRepeatCount() == 0) {
|
||||
onBackPressed();
|
||||
return true;
|
||||
}
|
||||
return super.onKeyDown(keyCode, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBackPressed() {
|
||||
try {
|
||||
if (!handleBackButton()) {
|
||||
// Move the activity into paused state if back button was pressed
|
||||
moveTaskToBack(true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true if MVPN has handled the back button
|
||||
native boolean handleBackButton();
|
||||
}
|
||||
18
client/android/src/org/amnezia/vpn/qt/VPNApplication.java
Normal file
18
client/android/src/org/amnezia/vpn/qt/VPNApplication.java
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package org.amnezia.vpn.qt;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.os.Bundle;
|
||||
|
||||
import org.amnezia.vpn.BuildConfig;
|
||||
|
||||
public class VPNApplication extends org.qtproject.qt5.android.bindings.QtApplication {
|
||||
|
||||
private static VPNApplication instance;
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
VPNApplication.instance = this;
|
||||
}
|
||||
|
||||
}
|
||||
37
client/android/src/org/amnezia/vpn/qt/VPNPermissionHelper.kt
Normal file
37
client/android/src/org/amnezia/vpn/qt/VPNPermissionHelper.kt
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/* 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.qt
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class VPNPermissionHelper : android.net.VpnService() {
|
||||
/**
|
||||
* This small service does nothing else then checking if the vpn permission
|
||||
* is present and prompting if not.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val intent = prepare(this.applicationContext)
|
||||
if (intent != null) {
|
||||
startActivityForResult(intent)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun startService(c: Context) {
|
||||
val appC = c.applicationContext
|
||||
appC.startService(Intent(appC, VPNPermissionHelper::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the Global QTAndroidActivity and calls startActivityForResult with the given intent
|
||||
* Is used to request the VPN-Permission, if not given.
|
||||
* Actually Implemented in src/platforms/android/AndroidController.cpp
|
||||
*/
|
||||
external fun startActivityForResult(i: Intent)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue