515 lines
16 KiB
Kotlin
515 lines
16 KiB
Kotlin
package org.amnezia.vpn
|
|
|
|
import android.app.AlertDialog
|
|
import android.content.ComponentName
|
|
import android.content.Intent
|
|
import android.content.Intent.EXTRA_MIME_TYPES
|
|
import android.content.Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY
|
|
import android.content.ServiceConnection
|
|
import android.content.pm.PackageManager
|
|
import android.graphics.Bitmap
|
|
import android.net.Uri
|
|
import android.net.VpnService
|
|
import android.os.Bundle
|
|
import android.os.Handler
|
|
import android.os.IBinder
|
|
import android.os.Looper
|
|
import android.os.Message
|
|
import android.os.Messenger
|
|
import android.provider.Settings
|
|
import android.view.WindowManager.LayoutParams
|
|
import android.webkit.MimeTypeMap
|
|
import android.widget.Toast
|
|
import androidx.annotation.MainThread
|
|
import androidx.core.content.ContextCompat
|
|
import java.io.IOException
|
|
import kotlin.LazyThreadSafetyMode.NONE
|
|
import kotlin.text.RegexOption.IGNORE_CASE
|
|
import AppListProvider
|
|
import kotlinx.coroutines.CompletableDeferred
|
|
import kotlinx.coroutines.CoroutineScope
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.SupervisorJob
|
|
import kotlinx.coroutines.cancel
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.runBlocking
|
|
import kotlinx.coroutines.withContext
|
|
import org.amnezia.vpn.protocol.getStatistics
|
|
import org.amnezia.vpn.protocol.getStatus
|
|
import org.amnezia.vpn.qt.QtAndroidController
|
|
import org.amnezia.vpn.util.Log
|
|
import org.qtproject.qt.android.bindings.QtActivity
|
|
|
|
private const val TAG = "AmneziaActivity"
|
|
const val ACTIVITY_MESSENGER_NAME = "Activity"
|
|
|
|
private const val CHECK_VPN_PERMISSION_ACTION_CODE = 1
|
|
private const val CREATE_FILE_ACTION_CODE = 2
|
|
private const val OPEN_FILE_ACTION_CODE = 3
|
|
|
|
class AmneziaActivity : QtActivity() {
|
|
|
|
private lateinit var mainScope: CoroutineScope
|
|
private val qtInitialized = CompletableDeferred<Unit>()
|
|
private var isWaitingStatus = true
|
|
private var isServiceConnected = false
|
|
private var isInBoundState = false
|
|
private lateinit var vpnServiceMessenger: IpcMessenger
|
|
private var tmpFileContentToSave: String = ""
|
|
|
|
private val vpnServiceEventHandler: Handler by lazy(NONE) {
|
|
object : Handler(Looper.getMainLooper()) {
|
|
override fun handleMessage(msg: Message) {
|
|
val event = msg.extractIpcMessage<ServiceEvent>()
|
|
Log.d(TAG, "Handle event: $event")
|
|
when (event) {
|
|
ServiceEvent.STATUS_CHANGED -> {
|
|
msg.data?.getStatus()?.let { (state) ->
|
|
Log.d(TAG, "Handle protocol state: $state")
|
|
QtAndroidController.onVpnStateChanged(state.ordinal)
|
|
}
|
|
}
|
|
|
|
ServiceEvent.STATUS -> {
|
|
if (isWaitingStatus) {
|
|
isWaitingStatus = false
|
|
msg.data?.getStatus()?.let { QtAndroidController.onStatus(it) }
|
|
}
|
|
}
|
|
|
|
ServiceEvent.STATISTICS_UPDATE -> {
|
|
msg.data?.getStatistics()?.let { (rxBytes, txBytes) ->
|
|
QtAndroidController.onStatisticsUpdate(rxBytes, txBytes)
|
|
}
|
|
}
|
|
|
|
ServiceEvent.ERROR -> {
|
|
msg.data?.getString(MSG_ERROR)?.let { error ->
|
|
Log.e(TAG, "From VpnService: $error")
|
|
}
|
|
// todo: add error reporting to Qt
|
|
QtAndroidController.onServiceError()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private val activityMessenger: Messenger by lazy(NONE) {
|
|
Messenger(vpnServiceEventHandler)
|
|
}
|
|
|
|
private val serviceConnection: ServiceConnection by lazy(NONE) {
|
|
object : ServiceConnection {
|
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
|
Log.d(TAG, "Service ${name?.flattenToString()} was connected")
|
|
// get a messenger from the service to send actions to the service
|
|
vpnServiceMessenger.set(Messenger(service))
|
|
// send a messenger to the service to process service events
|
|
vpnServiceMessenger.send(
|
|
Action.REGISTER_CLIENT.packToMessage {
|
|
putString(MSG_CLIENT_NAME, ACTIVITY_MESSENGER_NAME)
|
|
},
|
|
replyTo = activityMessenger
|
|
)
|
|
isServiceConnected = true
|
|
if (isWaitingStatus) {
|
|
vpnServiceMessenger.send(Action.REQUEST_STATUS, replyTo = activityMessenger)
|
|
}
|
|
}
|
|
|
|
override fun onServiceDisconnected(name: ComponentName?) {
|
|
Log.w(TAG, "Service ${name?.flattenToString()} was unexpectedly disconnected")
|
|
isServiceConnected = false
|
|
vpnServiceMessenger.reset()
|
|
isWaitingStatus = true
|
|
QtAndroidController.onServiceDisconnected()
|
|
doBindService()
|
|
}
|
|
|
|
override fun onBindingDied(name: ComponentName?) {
|
|
Log.w(TAG, "Binding to the ${name?.flattenToString()} unexpectedly died")
|
|
doUnbindService()
|
|
doBindService()
|
|
}
|
|
}
|
|
}
|
|
|
|
private data class CheckVpnPermissionCallbacks(val onSuccess: () -> Unit, val onFail: () -> Unit)
|
|
|
|
private var checkVpnPermissionCallbacks: CheckVpnPermissionCallbacks? = null
|
|
|
|
/**
|
|
* Activity overloaded methods
|
|
*/
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
Log.d(TAG, "Create Amnezia activity: $intent")
|
|
mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
|
vpnServiceMessenger = IpcMessenger(
|
|
"VpnService",
|
|
onDeadObjectException = {
|
|
doUnbindService()
|
|
doBindService()
|
|
}
|
|
)
|
|
intent?.let(::processIntent)
|
|
}
|
|
|
|
override fun onNewIntent(intent: Intent?) {
|
|
super.onNewIntent(intent)
|
|
Log.d(TAG, "onNewIntent: $intent")
|
|
intent?.let(::processIntent)
|
|
}
|
|
|
|
private fun processIntent(intent: Intent) {
|
|
// disable config import when starting activity from history
|
|
if (intent.flags and FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY == 0) {
|
|
if (intent.action == ACTION_IMPORT_CONFIG) {
|
|
intent.getStringExtra(EXTRA_CONFIG)?.let {
|
|
mainScope.launch {
|
|
qtInitialized.await()
|
|
QtAndroidController.onConfigImported(it)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override fun onStart() {
|
|
super.onStart()
|
|
Log.d(TAG, "Start Amnezia activity")
|
|
mainScope.launch {
|
|
qtInitialized.await()
|
|
doBindService()
|
|
}
|
|
}
|
|
|
|
override fun onStop() {
|
|
Log.d(TAG, "Stop Amnezia activity")
|
|
doUnbindService()
|
|
super.onStop()
|
|
}
|
|
|
|
override fun onDestroy() {
|
|
Log.d(TAG, "Destroy Amnezia activity")
|
|
mainScope.cancel()
|
|
super.onDestroy()
|
|
}
|
|
|
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
|
when (requestCode) {
|
|
CREATE_FILE_ACTION_CODE -> {
|
|
when (resultCode) {
|
|
RESULT_OK -> {
|
|
data?.data?.let { uri ->
|
|
alterDocument(uri)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
OPEN_FILE_ACTION_CODE -> {
|
|
when (resultCode) {
|
|
RESULT_OK -> data?.data?.toString() ?: ""
|
|
else -> ""
|
|
}.let { uri ->
|
|
QtAndroidController.onFileOpened(uri)
|
|
}
|
|
}
|
|
|
|
CHECK_VPN_PERMISSION_ACTION_CODE -> {
|
|
when (resultCode) {
|
|
RESULT_OK -> {
|
|
Log.d(TAG, "Vpn permission granted")
|
|
Toast.makeText(this, resources.getText(R.string.vpnGranted), Toast.LENGTH_LONG).show()
|
|
checkVpnPermissionCallbacks?.run { onSuccess() }
|
|
}
|
|
|
|
else -> {
|
|
Log.w(TAG, "Vpn permission denied, resultCode: $resultCode")
|
|
showOnVpnPermissionRejectDialog()
|
|
checkVpnPermissionCallbacks?.run { onFail() }
|
|
}
|
|
}
|
|
checkVpnPermissionCallbacks = null
|
|
}
|
|
|
|
else -> super.onActivityResult(requestCode, resultCode, data)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Methods for service binding
|
|
*/
|
|
@MainThread
|
|
private fun doBindService() {
|
|
Log.d(TAG, "Bind service")
|
|
Intent(this, AmneziaVpnService::class.java).also {
|
|
bindService(it, serviceConnection, BIND_ABOVE_CLIENT and BIND_AUTO_CREATE)
|
|
}
|
|
isInBoundState = true
|
|
}
|
|
|
|
@MainThread
|
|
private fun doUnbindService() {
|
|
if (isInBoundState) {
|
|
Log.d(TAG, "Unbind service")
|
|
isWaitingStatus = true
|
|
QtAndroidController.onServiceDisconnected()
|
|
isServiceConnected = false
|
|
vpnServiceMessenger.send(Action.UNREGISTER_CLIENT, activityMessenger)
|
|
vpnServiceMessenger.reset()
|
|
isInBoundState = false
|
|
unbindService(serviceConnection)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Methods of starting and stopping VpnService
|
|
*/
|
|
private fun checkVpnPermissionAndStart(vpnConfig: String) {
|
|
checkVpnPermission(
|
|
onSuccess = { startVpn(vpnConfig) },
|
|
onFail = QtAndroidController::onVpnPermissionRejected
|
|
)
|
|
}
|
|
|
|
@MainThread
|
|
private fun checkVpnPermission(onSuccess: () -> Unit, onFail: () -> Unit) {
|
|
Log.d(TAG, "Check VPN permission")
|
|
VpnService.prepare(applicationContext)?.let {
|
|
checkVpnPermissionCallbacks = CheckVpnPermissionCallbacks(onSuccess, onFail)
|
|
startActivityForResult(it, CHECK_VPN_PERMISSION_ACTION_CODE)
|
|
return
|
|
}
|
|
onSuccess()
|
|
}
|
|
|
|
private fun showOnVpnPermissionRejectDialog() {
|
|
AlertDialog.Builder(this)
|
|
.setTitle(R.string.vpnSetupFailed)
|
|
.setMessage(R.string.vpnSetupFailedMessage)
|
|
.setNegativeButton(R.string.ok) { _, _ -> }
|
|
.setPositiveButton(R.string.openVpnSettings) { _, _ ->
|
|
startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
|
}
|
|
.show()
|
|
}
|
|
|
|
@MainThread
|
|
private fun startVpn(vpnConfig: String) {
|
|
if (isServiceConnected) {
|
|
connectToVpn(vpnConfig)
|
|
} else {
|
|
isWaitingStatus = false
|
|
startVpnService(vpnConfig)
|
|
doBindService()
|
|
}
|
|
}
|
|
|
|
private fun connectToVpn(vpnConfig: String) {
|
|
Log.d(TAG, "Connect to VPN")
|
|
vpnServiceMessenger.send {
|
|
Action.CONNECT.packToMessage {
|
|
putString(MSG_VPN_CONFIG, vpnConfig)
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun startVpnService(vpnConfig: String) {
|
|
Log.d(TAG, "Start VPN service")
|
|
Intent(this, AmneziaVpnService::class.java).apply {
|
|
putExtra(MSG_VPN_CONFIG, vpnConfig)
|
|
}.also {
|
|
ContextCompat.startForegroundService(this, it)
|
|
}
|
|
}
|
|
|
|
private fun disconnectFromVpn() {
|
|
Log.d(TAG, "Disconnect from VPN")
|
|
vpnServiceMessenger.send(Action.DISCONNECT)
|
|
}
|
|
|
|
// saving file
|
|
private fun alterDocument(uri: Uri) {
|
|
try {
|
|
contentResolver.openOutputStream(uri)?.use { os ->
|
|
os.bufferedWriter().use { it.write(tmpFileContentToSave) }
|
|
}
|
|
} catch (e: IOException) {
|
|
e.printStackTrace()
|
|
}
|
|
|
|
tmpFileContentToSave = ""
|
|
}
|
|
|
|
/**
|
|
* Methods called by Qt
|
|
*/
|
|
@Suppress("unused")
|
|
fun qtAndroidControllerInitialized() {
|
|
Log.v(TAG, "Qt Android controller initialized")
|
|
qtInitialized.complete(Unit)
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun start(vpnConfig: String) {
|
|
Log.v(TAG, "Start VPN")
|
|
mainScope.launch {
|
|
checkVpnPermissionAndStart(vpnConfig)
|
|
}
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun stop() {
|
|
Log.v(TAG, "Stop VPN")
|
|
mainScope.launch {
|
|
disconnectFromVpn()
|
|
}
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun resetLastServer(index: Int) {
|
|
Log.v(TAG, "Reset server: $index")
|
|
mainScope.launch {
|
|
VpnStateStore.store {
|
|
if (index == -1 || it.serverIndex == index) {
|
|
VpnState.defaultState
|
|
} else if (it.serverIndex > index) {
|
|
it.copy(serverIndex = it.serverIndex - 1)
|
|
} else {
|
|
it
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun saveFile(fileName: String, data: String) {
|
|
Log.d(TAG, "Save file $fileName")
|
|
mainScope.launch {
|
|
tmpFileContentToSave = data
|
|
|
|
Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
|
|
addCategory(Intent.CATEGORY_OPENABLE)
|
|
type = "text/*"
|
|
putExtra(Intent.EXTRA_TITLE, fileName)
|
|
}.also {
|
|
startActivityForResult(it, CREATE_FILE_ACTION_CODE)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun openFile(filter: String?) {
|
|
Log.v(TAG, "Open file with filter: $filter")
|
|
|
|
val mimeTypes = if (!filter.isNullOrEmpty()) {
|
|
val extensionRegex = "\\*\\.([a-z0-9]+)".toRegex(IGNORE_CASE)
|
|
val mime = MimeTypeMap.getSingleton()
|
|
extensionRegex.findAll(filter).map {
|
|
it.groups[1]?.value?.let { mime.getMimeTypeFromExtension(it) } ?: "*/*"
|
|
}.toSet()
|
|
} else emptySet()
|
|
|
|
Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
|
addCategory(Intent.CATEGORY_OPENABLE)
|
|
Log.v(TAG, "File mimyType filter: $mimeTypes")
|
|
if ("*/*" in mimeTypes) {
|
|
type = "*/*"
|
|
} else {
|
|
when (mimeTypes.size) {
|
|
1 -> type = mimeTypes.first()
|
|
|
|
in 2..Int.MAX_VALUE -> {
|
|
type = "*/*"
|
|
putExtra(EXTRA_MIME_TYPES, mimeTypes.toTypedArray())
|
|
}
|
|
|
|
else -> type = "*/*"
|
|
}
|
|
}
|
|
}.also {
|
|
startActivityForResult(it, OPEN_FILE_ACTION_CODE)
|
|
}
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun setNotificationText(title: String, message: String, timerSec: Int) {
|
|
Log.v(TAG, "Set notification text")
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun isCameraPresent(): Boolean = applicationContext.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA)
|
|
|
|
@Suppress("unused")
|
|
fun startQrCodeReader() {
|
|
Log.v(TAG, "Start camera")
|
|
Intent(this, CameraActivity::class.java).also {
|
|
startActivity(it)
|
|
}
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun setSaveLogs(enabled: Boolean) {
|
|
Log.d(TAG, "Set save logs: $enabled")
|
|
mainScope.launch {
|
|
Log.saveLogs = enabled
|
|
vpnServiceMessenger.send {
|
|
Action.SET_SAVE_LOGS.packToMessage {
|
|
putBoolean(MSG_SAVE_LOGS, enabled)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun exportLogsFile(fileName: String) {
|
|
Log.v(TAG, "Export logs file")
|
|
saveFile(fileName, Log.getLogs())
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun clearLogs() {
|
|
Log.v(TAG, "Clear logs")
|
|
Log.clearLogs()
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun setScreenshotsEnabled(enabled: Boolean) {
|
|
Log.v(TAG, "Set screenshots enabled: $enabled")
|
|
mainScope.launch {
|
|
val flag = if (enabled) 0 else LayoutParams.FLAG_SECURE
|
|
window.setFlags(flag, LayoutParams.FLAG_SECURE)
|
|
}
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun minimizeApp() {
|
|
Log.v(TAG, "Minimize application")
|
|
mainScope.launch {
|
|
moveTaskToBack(false)
|
|
}
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun getAppList(): String {
|
|
Log.v(TAG, "Get app list")
|
|
var appList = ""
|
|
runBlocking {
|
|
mainScope.launch {
|
|
withContext(Dispatchers.IO) {
|
|
appList = AppListProvider.getAppList(packageManager, packageName)
|
|
}
|
|
}.join()
|
|
}
|
|
return appList
|
|
}
|
|
|
|
@Suppress("unused")
|
|
fun getAppIcon(packageName: String, width: Int, height: Int): Bitmap {
|
|
Log.v(TAG, "Get app icon: $packageName")
|
|
return AppListProvider.getAppIcon(packageManager, packageName, width, height)
|
|
}
|
|
}
|