Compare commits

..

6 Commits

Author SHA1 Message Date
Zane Schepke 37bae82700 feat: zip file import export
Added support for importing zip files containing multiple config files.
Closes #33

Added support for exporting all config files to downloads folder as a zip with biometric or security pin approval.

Added support for editing or viewing private key with biometric or security pin approval.

Fixed a bug where VPN status indicator functionality was unintentionally disabled.

Other various enhancements and refactors.
2023-10-16 22:43:28 -04:00
Zane Schepke 77cd328a71 fix: config screen edit bug
Fixes a bug where config edit will break configs that have commas because a comma and space are required in tunnel configs

 #42
2023-10-14 01:32:46 -04:00
Zane Schepke 5a1430706b fix: config edit whitespace bug
Closes #42
2023-10-13 23:42:39 -04:00
Zane Schepke 321730536d fix: file import handling
Fixes bug causing crashes when importing a file that is not a .conf file.

Fixes file import on AndroidTV and FireTV
Closes #39

Fixes usability issue on AndroidTV where textboxes immediately open the keyboard on hover.
Allows users two keypress access to turning on tunnels from app load.
Closes #36

Improves service efficiencies by making coroutines lifecycle aware.
2023-10-12 20:20:53 -04:00
Zane Schepke 2912238f27 Merge pull request #41 from licaon-kter/patch-1
Gradle 8.3 was released
2023-10-11 09:31:02 -04:00
Licaon_Kter bc7daacd13 Gradle 8.3 was released 2023-10-11 11:42:58 +00:00
30 changed files with 593 additions and 288 deletions
+11 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.zaneschepke.wireguardautotunnel" applicationId = "com.zaneschepke.wireguardautotunnel"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 31000 versionCode = 31400
versionName = "3.1.0" versionName = "3.1.4"
multiDexEnabled = true multiDexEnabled = true
@@ -81,6 +81,8 @@ val generalImplementation by configurations
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
// optional - helpers for implementing LifecycleOwner in a Service
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
@@ -126,6 +128,9 @@ dependencies {
//lifecycle //lifecycle
implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process)
//icons //icons
implementation(libs.material.icons.extended) implementation(libs.material.icons.extended)
@@ -140,4 +145,8 @@ dependencies {
//barcode scanning //barcode scanning
implementation(libs.zxing.android.embedded) implementation(libs.zxing.android.embedded)
implementation(libs.zxing.core) implementation(libs.zxing.core)
//bio
implementation(libs.androidx.biometric.ktx)
} }
+4
View File
@@ -4,6 +4,10 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"
android:maxSdkVersion="30" android:maxSdkVersion="30"
tools:ignore="LeanbackUsesWifi" /> tools:ignore="LeanbackUsesWifi" />
@@ -8,9 +8,11 @@ object Constants {
const val FADE_IN_ANIMATION_DURATION = 1000 const val FADE_IN_ANIMATION_DURATION = 1000
const val SLIDE_IN_ANIMATION_DURATION = 500 const val SLIDE_IN_ANIMATION_DURATION = 500
const val SLIDE_IN_TRANSITION_OFFSET = 1000 const val SLIDE_IN_TRANSITION_OFFSET = 1000
const val VALID_FILE_EXTENSION = ".conf" const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content" const val URI_CONTENT_SCHEME = "content"
const val URI_PACKAGE_SCHEME = "package" const val URI_PACKAGE_SCHEME = "package"
const val ALLOWED_FILE_TYPES = "*/*" const val ALLOWED_FILE_TYPES = "*/*"
const val ANDROID_TV_STUBS = "com.google.android.tv.frameworkpackagestubs" const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
} }
@@ -0,0 +1,24 @@
package com.zaneschepke.wireguardautotunnel
import android.content.BroadcastReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
fun BroadcastReceiver.goAsync(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
) {
val pendingResult = goAsync()
@OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback.
GlobalScope.launch(context) {
try {
block()
} finally {
pendingResult.finish()
}
}
}
@@ -3,6 +3,8 @@ package com.zaneschepke.wireguardautotunnel
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
@@ -27,13 +29,17 @@ class WireGuardAutoTunnel : Application() {
} }
private fun initSettings() { private fun initSettings() {
CoroutineScope(Dispatchers.IO).launch { with(ProcessLifecycleOwner.get()) {
if(settingsRepo.getAll().isEmpty()) { lifecycleScope.launch {
settingsRepo.save(Settings()) if(settingsRepo.getAll().isEmpty()) {
settingsRepo.save(Settings())
}
} }
} }
} }
companion object { companion object {
fun isRunningOnAndroidTv(context : Context) : Boolean { fun isRunningOnAndroidTv(context : Context) : Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
@@ -3,13 +3,11 @@ package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -18,20 +16,18 @@ class BootReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo : SettingsDoa lateinit var settingsRepo : SettingsDoa
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) = goAsync {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) { if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
CoroutineScope(Dispatchers.IO).launch { try {
try { val settings = settingsRepo.getAll()
val settings = settingsRepo.getAll() if (settings.isNotEmpty()) {
if (settings.isNotEmpty()) { val setting = settings.first()
val setting = settings.first() if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) { ServiceManager.startWatcherService(context, setting.defaultTunnel!!)
ServiceManager.startWatcherService(context, setting.defaultTunnel!!)
}
} }
} finally {
cancel()
} }
} finally {
cancel()
} }
} }
} }
@@ -4,14 +4,12 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
@@ -19,21 +17,19 @@ class NotificationActionReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo : SettingsDoa lateinit var settingsRepo : SettingsDoa
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) = goAsync {
CoroutineScope(Dispatchers.IO).launch { try {
try { val settings = settingsRepo.getAll()
val settings = settingsRepo.getAll() if (settings.isNotEmpty()) {
if (settings.isNotEmpty()) { val setting = settings.first()
val setting = settings.first() if (setting.defaultTunnel != null) {
if (setting.defaultTunnel != null) { ServiceManager.stopVpnService(context)
ServiceManager.stopVpnService(context) delay(Constants.TOGGLE_TUNNEL_DELAY)
delay(Constants.TOGGLE_TUNNEL_DELAY) ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
}
} }
} finally {
cancel()
} }
} finally {
cancel()
} }
} }
} }
@@ -1,22 +1,24 @@
package com.zaneschepke.wireguardautotunnel.service.foreground package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import androidx.lifecycle.LifecycleService
import timber.log.Timber import timber.log.Timber
open class ForegroundService : Service() { open class ForegroundService : LifecycleService() {
private var isServiceStarted = false private var isServiceStarted = false
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
// We don't provide binding, so return null // We don't provide binding, so return null
return null return null
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId") Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) { if (intent != null) {
val action = intent.action val action = intent.action
@@ -7,6 +7,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import androidx.lifecycle.lifecycleScope
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@@ -66,7 +67,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
CoroutineScope(Dispatchers.Main).launch { lifecycleScope.launch(Dispatchers.Main) {
launchWatcherNotification() launchWatcherNotification()
} }
} }
@@ -122,6 +123,9 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
wakeLock = wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run { (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
//TODO decide what to do here with the wakelock
//this is draining battery. Perhaps users only care for VPN to connect when their screen is on
//and they are actively using apps
acquire() acquire()
} }
} }
@@ -134,7 +138,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
} }
private fun startWatcherJob() { private fun startWatcherJob() {
watcherJob = CoroutineScope(Dispatchers.IO).launch { watcherJob = lifecycleScope.launch(Dispatchers.IO) {
val settings = settingsRepo.getAll(); val settings = settingsRepo.getAll();
if(settings.isNotEmpty()) { if(settings.isNotEmpty()) {
setting = settings[0] setting = settings[0]
@@ -3,6 +3,8 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
@@ -38,7 +40,7 @@ class WireGuardTunnelService : ForegroundService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
CoroutineScope(Dispatchers.Main).launch { lifecycleScope.launch(Dispatchers.Main) {
launchVpnStartingNotification() launchVpnStartingNotification()
} }
} }
@@ -48,54 +50,56 @@ class WireGuardTunnelService : ForegroundService() {
launchVpnStartingNotification() launchVpnStartingNotification()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
cancelJob() cancelJob()
job = CoroutineScope(Dispatchers.IO).launch { job = lifecycleScope.launch(Dispatchers.IO) {
if(tunnelConfigString != null) { launch {
try { if(tunnelConfigString != null) {
val tunnelConfig = TunnelConfig.from(tunnelConfigString) try {
tunnelName = tunnelConfig.name val tunnelConfig = TunnelConfig.from(tunnelConfigString)
vpnService.startTunnel(tunnelConfig)
} catch (e : Exception) {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
}
} else {
Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll();
if(settings.isNotEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
tunnelName = tunnelConfig.name tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig) vpnService.startTunnel(tunnelConfig)
} catch (e : Exception) {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
}
} else {
Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll();
if(settings.isNotEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
}
} }
} }
} }
} launch {
CoroutineScope(job).launch { var didShowConnected = false
var didShowConnected = false var didShowFailedHandshakeNotification = false
var didShowFailedHandshakeNotification = false vpnService.handshakeStatus.collect {
vpnService.handshakeStatus.collect { when(it) {
when(it) { HandshakeStatus.NOT_STARTED -> {
HandshakeStatus.NOT_STARTED -> {
}
HandshakeStatus.NEVER_CONNECTED -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
} }
} HandshakeStatus.NEVER_CONNECTED -> {
HandshakeStatus.HEALTHY -> { if(!didShowFailedHandshakeNotification) {
if(!didShowConnected) { launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message))
launchVpnConnectedNotification() didShowFailedHandshakeNotification = true
didShowConnected = true didShowConnected = false
}
} }
} HandshakeStatus.HEALTHY -> {
HandshakeStatus.UNHEALTHY -> { if(!didShowConnected) {
if(!didShowFailedHandshakeNotification) { launchVpnConnectedNotification()
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message)) didShowConnected = true
didShowFailedHandshakeNotification = true }
didShowConnected = false }
HandshakeStatus.UNHEALTHY -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
}
} }
} }
} }
@@ -105,7 +109,7 @@ class WireGuardTunnelService : ForegroundService() {
override fun stopService(extras : Bundle?) { override fun stopService(extras : Bundle?) {
super.stopService(extras) super.stopService(extras)
CoroutineScope(Dispatchers.IO).launch { lifecycleScope.launch(Dispatchers.IO) {
vpnService.stopTunnel() vpnService.stopTunnel()
} }
cancelJob() cancelJob()
@@ -140,7 +144,8 @@ class WireGuardTunnelService : ForegroundService() {
val notification = notificationService.createNotification( val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
action = PendingIntent.getBroadcast(this,0,Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE), action = PendingIntent.getBroadcast(this,0,
Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE),
actionText = getString(R.string.restart), actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed), title = getString(R.string.vpn_connection_failed),
onGoing = false, onGoing = false,
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.service.shortcut
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
@@ -13,6 +14,7 @@ import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -26,10 +28,9 @@ class ShortcutsActivity : ComponentActivity() {
@Inject @Inject
lateinit var tunnelConfigRepo : TunnelConfigDao lateinit var tunnelConfigRepo : TunnelConfigDao
private val scope = CoroutineScope(Dispatchers.Main);
private fun attemptWatcherServiceToggle(tunnelConfig : String) { private fun attemptWatcherServiceToggle(tunnelConfig : String) {
scope.launch { lifecycleScope.launch(Dispatchers.Main) {
val settings = getSettings() val settings = getSettings()
if(settings.isAutoTunnelEnabled) { if(settings.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig) ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig)
@@ -41,7 +42,7 @@ class ShortcutsActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if(intent.getStringExtra(CLASS_NAME_EXTRA_KEY) if(intent.getStringExtra(CLASS_NAME_EXTRA_KEY)
.equals(WireGuardTunnelService::class.java.simpleName)) { .equals(WireGuardTunnelService::class.java.simpleName)) {
scope.launch { lifecycleScope.launch(Dispatchers.Main) {
try { try {
val settings = getSettings() val settings = getSettings()
val tunnelConfig = if(settings.defaultTunnel == null) { val tunnelConfig = if(settings.defaultTunnel == null) {
@@ -55,6 +55,11 @@ class TunnelControlTile : TileService() {
cancelJob() cancelJob()
} }
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
override fun onClick() { override fun onClick() {
super.onClick() super.onClick()
unlockAndRun { unlockAndRun {
@@ -11,6 +11,7 @@ import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@@ -46,6 +47,8 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
override val handshakeStatus: SharedFlow<HandshakeStatus> override val handshakeStatus: SharedFlow<HandshakeStatus>
get() = _handshakeStatus.asSharedFlow() get() = _handshakeStatus.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.IO);
private lateinit var statsJob : Job private lateinit var statsJob : Job
@@ -70,7 +73,7 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
return _tunnelName.value return _tunnelName.value
} }
override suspend fun stopTunnel() { override suspend fun stopTunnel() {
try { try {
if(getState() == Tunnel.State.UP) { if(getState() == Tunnel.State.UP) {
val state = backend.setState(this, Tunnel.State.DOWN, null) val state = backend.setState(this, Tunnel.State.DOWN, null)
@@ -89,7 +92,7 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
val tunnel = this; val tunnel = this;
_state.tryEmit(state) _state.tryEmit(state)
if(state == Tunnel.State.UP) { if(state == Tunnel.State.UP) {
statsJob = CoroutineScope(Dispatchers.IO).launch { statsJob = scope.launch {
val handshakeMap = HashMap<Key, Long>() val handshakeMap = HashMap<Key, Long>()
var neverHadHandshakeCounter = 0 var neverHadHandshakeCounter = 0
while (true) { while (true) {
@@ -128,4 +131,6 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
_lastHandshake.tryEmit(emptyMap()) _lastHandshake.tryEmit(emptyMap())
} }
} }
} }
@@ -11,6 +11,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.google.accompanist.navigation.animation.AnimatedNavHost import com.google.accompanist.navigation.animation.AnimatedNavHost
import com.google.accompanist.navigation.animation.composable import com.google.accompanist.navigation.animation.composable
import com.google.accompanist.navigation.animation.rememberAnimatedNavController import com.google.accompanist.navigation.animation.rememberAnimatedNavController
@@ -41,9 +43,9 @@ import com.google.accompanist.permissions.rememberPermissionState
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.detail.DetailScreen import com.zaneschepke.wireguardautotunnel.ui.screens.detail.DetailScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
@@ -52,7 +54,6 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -99,10 +100,10 @@ class MainActivity : AppCompatActivity() {
} }
fun showSnackBarMessage(message : String) { fun showSnackBarMessage(message : String) {
CoroutineScope(Dispatchers.Main).launch { lifecycleScope.launch(Dispatchers.Main) {
val result = snackbarHostState.showSnackbar( val result = snackbarHostState.showSnackbar(
message = message, message = message,
actionLabel = "Okay", actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short, duration = SnackbarDuration.Short,
) )
when (result) { when (result) {
@@ -184,7 +185,10 @@ class MainActivity : AppCompatActivity() {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
} }
} }
}) { }, exitTransition = {
ExitTransition.None
}
) {
MainScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, navController = navController) MainScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, navController = navController)
} }
composable(Routes.Settings.name, enterTransition = { composable(Routes.Settings.name, enterTransition = {
@@ -1,9 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui
data class ViewState(
val showSnackbarMessage : Boolean = false,
val snackbarMessage : String = "",
val snackbarActionText : String = "",
val onSnackbarActionClick : () -> Unit = {},
val isLoading : Boolean = false
)
@@ -12,7 +12,7 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
@Composable @Composable
fun fun
ConfigurationTextBox(value : String, hint : String, onValueChange : (String) -> Unit, label : String, onDone : () -> Unit, modifier: Modifier) { ConfigurationTextBox(value : String, hint : String, onValueChange : (String) -> Unit, keyboardActions : KeyboardActions, label : String, modifier: Modifier) {
OutlinedTextField( OutlinedTextField(
modifier = modifier, modifier = modifier,
value = value, value = value,
@@ -29,10 +29,6 @@ fun
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done imeAction = ImeAction.Done
), ),
keyboardActions = KeyboardActions( keyboardActions = keyboardActions,
onDone = {
onDone()
}
),
) )
} }
@@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.common.config package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -14,7 +13,7 @@ import androidx.compose.ui.unit.Dp
@Composable @Composable
fun ConfigurationToggle(label : String, enabled : Boolean, checked : Boolean, padding : Dp, fun ConfigurationToggle(label : String, enabled : Boolean, checked : Boolean, padding : Dp,
onCheckChanged : () -> Unit) { onCheckChanged : () -> Unit, modifier : Modifier = Modifier) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -24,6 +23,7 @@ fun ConfigurationToggle(label : String, enabled : Boolean, checked : Boolean, pa
) { ) {
Text(label) Text(label)
Switch( Switch(
modifier = modifier,
enabled = enabled, enabled = enabled,
checked = checked, checked = checked,
onCheckedChange = { onCheckedChange = {
@@ -0,0 +1,79 @@
package com.zaneschepke.wireguardautotunnel.ui.common.prompt
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
@Composable
fun AuthorizationPrompt(onSuccess : () -> Unit, onFailure : () -> Unit, onError : (String) -> Unit) {
val context = LocalContext.current
val biometricManager = BiometricManager.from(context)
val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
val isBiometricAvailable = remember {
when(bio){
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
onError("Biometrics not available")
false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
}
}
if(isBiometricAvailable) {
val executor = remember { ContextCompat.getMainExecutor(context) }
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.setTitle("Biometric Authentication")
.setSubtitle("Log in using your biometric credential")
.build()
val biometricPrompt = BiometricPrompt(
context as FragmentActivity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString)
onFailure()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
onFailure()
}
}
)
biometricPrompt.authenticate(promptInfo)
}
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common package com.zaneschepke.wireguardautotunnel.ui.common.prompt
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -14,12 +14,12 @@ data class InterfaceProxy(
companion object { companion object {
fun from(i : Interface) : InterfaceProxy { fun from(i : Interface) : InterfaceProxy {
return InterfaceProxy( return InterfaceProxy(
publicKey = i.keyPair.publicKey.toBase64(), publicKey = i.keyPair.publicKey.toBase64().trim(),
privateKey = i.keyPair.privateKey.toBase64(), privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(","), addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(",").replace("/", ""), dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = if(i.listenPort.isPresent) i.listenPort.get().toString() else "", listenPort = if(i.listenPort.isPresent) i.listenPort.get().toString().trim() else "",
mtu = if(i.mtu.isPresent) i.mtu.get().toString() else "" mtu = if(i.mtu.isPresent) i.mtu.get().toString().trim() else ""
) )
} }
} }
@@ -7,16 +7,16 @@ data class PeerProxy(
var preSharedKey : String = "", var preSharedKey : String = "",
var persistentKeepalive : String = "", var persistentKeepalive : String = "",
var endpoint : String = "", var endpoint : String = "",
var allowedIps: String = IPV4_WILDCARD.joinToString(",") var allowedIps: String = IPV4_WILDCARD.joinToString(", ").trim()
){ ){
companion object { companion object {
fun from(peer : Peer) : PeerProxy { fun from(peer : Peer) : PeerProxy {
return PeerProxy( return PeerProxy(
publicKey = peer.publicKey.toBase64(), publicKey = peer.publicKey.toBase64(),
preSharedKey = if(peer.preSharedKey.isPresent) peer.preSharedKey.get().toString() else "", preSharedKey = if(peer.preSharedKey.isPresent) peer.preSharedKey.get().toBase64().trim() else "",
persistentKeepalive = if(peer.persistentKeepalive.isPresent) peer.persistentKeepalive.get().toString() else "", persistentKeepalive = if(peer.persistentKeepalive.isPresent) peer.persistentKeepalive.get().toString().trim() else "",
endpoint = if(peer.endpoint.isPresent) peer.endpoint.get().toString() else "", endpoint = if(peer.endpoint.isPresent) peer.endpoint.get().toString().trim() else "",
allowedIps = peer.allowedIps.joinToString(",") allowedIps = peer.allowedIps.joinToString(", ").trim()
) )
} }
val IPV4_PUBLIC_NETWORKS = setOf( val IPV4_PUBLIC_NETWORKS = setOf(
@@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -60,13 +61,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -78,6 +79,7 @@ import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.Routes import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -96,7 +98,6 @@ fun ConfigScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
@@ -111,10 +112,16 @@ fun ConfigScreen(
val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle() val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle()
val proxyInterface by viewModel.interfaceProxy.collectAsStateWithLifecycle() val proxyInterface by viewModel.interfaceProxy.collectAsStateWithLifecycle()
var showApplicationsDialog by remember { mutableStateOf(false) } var showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) }
val baseTextBoxModifier = Modifier.onFocusChanged {
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide()
}
}
val keyboardActions = KeyboardActions( val keyboardActions = KeyboardActions(
onDone = { onDone = {
focusManager.clearFocus()
keyboardController?.hide() keyboardController?.hide()
} }
) )
@@ -143,6 +150,21 @@ fun ConfigScreen(
else "${checkedPackages.size} " + (if (include) "included" else "excluded") else "${checkedPackages.size} " + (if (include) "included" else "excluded")
} }
if(showAuthPrompt) {
AuthorizationPrompt(onSuccess = {
showAuthPrompt = false
isAuthenticated = true },
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage("Authentication failed")
})
}
if (showApplicationsDialog) { if (showApplicationsDialog) {
val sortedPackages = remember(packages) { val sortedPackages = remember(packages) {
packages.sortedBy { viewModel.getPackageLabel(it) } packages.sortedBy { viewModel.getPackageLabel(it) }
@@ -380,19 +402,18 @@ fun ConfigScreen(
onValueChange = { value -> onValueChange = { value ->
viewModel.onTunnelNameChange(value) viewModel.onTunnelNameChange(value)
}, },
onDone = { keyboardActions = keyboardActions,
focusManager.clearFocus()
keyboardController?.hide()
},
label = stringResource(R.string.name), label = stringResource(R.string.name),
hint = stringResource(R.string.tunnel_name).lowercase(), hint = stringResource(R.string.tunnel_name).lowercase(),
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester) modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(focusRequester)
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = baseTextBoxModifier.fillMaxWidth().clickable {
showAuthPrompt = true
},
value = proxyInterface.privateKey, value = proxyInterface.privateKey,
visualTransformation = PasswordVisualTransformation(), visualTransformation = if((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(),
enabled = id == Constants.MANUAL_TUNNEL_CONFIG_ID, enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
onValueChange = { value -> onValueChange = { value ->
viewModel.onPrivateKeyChange(value) viewModel.onPrivateKeyChange(value)
}, },
@@ -416,7 +437,7 @@ fun ConfigScreen(
keyboardActions = keyboardActions keyboardActions = keyboardActions
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth().focusRequester(FocusRequester.Default), modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(FocusRequester.Default),
value = proxyInterface.publicKey, value = proxyInterface.publicKey,
enabled = false, enabled = false,
onValueChange = {}, onValueChange = {},
@@ -445,52 +466,40 @@ fun ConfigScreen(
onValueChange = { value -> onValueChange = { value ->
viewModel.onAddressesChanged(value) viewModel.onAddressesChanged(value)
}, },
onDone = { keyboardActions = keyboardActions,
focusManager.clearFocus()
keyboardController?.hide()
},
label = stringResource(R.string.addresses), label = stringResource(R.string.addresses),
hint = stringResource(R.string.comma_separated_list), hint = stringResource(R.string.comma_separated_list),
modifier = Modifier modifier = baseTextBoxModifier
.fillMaxWidth(3 / 5f) .fillMaxWidth(3 / 5f)
.padding(end = 5.dp) .padding(end = 5.dp)
) )
ConfigurationTextBox( ConfigurationTextBox(
value = proxyInterface.listenPort, value = proxyInterface.listenPort,
onValueChange = { value -> viewModel.onListenPortChanged(value) }, onValueChange = { value -> viewModel.onListenPortChanged(value) },
onDone = { keyboardActions = keyboardActions,
focusManager.clearFocus()
keyboardController?.hide()
},
label = stringResource(R.string.listen_port), label = stringResource(R.string.listen_port),
hint = stringResource(R.string.random), hint = stringResource(R.string.random),
modifier = Modifier.width(IntrinsicSize.Min) modifier = baseTextBoxModifier.width(IntrinsicSize.Min)
) )
} }
Row(modifier = Modifier.fillMaxWidth()) { Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox( ConfigurationTextBox(
value = proxyInterface.dnsServers, value = proxyInterface.dnsServers,
onValueChange = { value -> viewModel.onDnsServersChanged(value) }, onValueChange = { value -> viewModel.onDnsServersChanged(value) },
onDone = { keyboardActions = keyboardActions,
focusManager.clearFocus()
keyboardController?.hide()
},
label = stringResource(R.string.dns_servers), label = stringResource(R.string.dns_servers),
hint = stringResource(R.string.comma_separated_list), hint = stringResource(R.string.comma_separated_list),
modifier = Modifier modifier = baseTextBoxModifier
.fillMaxWidth(3 / 5f) .fillMaxWidth(3 / 5f)
.padding(end = 5.dp) .padding(end = 5.dp)
) )
ConfigurationTextBox( ConfigurationTextBox(
value = proxyInterface.mtu, value = proxyInterface.mtu,
onValueChange = { value -> viewModel.onMtuChanged(value) }, onValueChange = { value -> viewModel.onMtuChanged(value) },
onDone = { keyboardActions = keyboardActions,
focusManager.clearFocus()
keyboardController?.hide()
},
label = stringResource(R.string.mtu), label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto), hint = stringResource(R.string.auto),
modifier = Modifier.width(IntrinsicSize.Min) modifier = baseTextBoxModifier.width(IntrinsicSize.Min)
) )
} }
Row( Row(
@@ -556,13 +565,10 @@ fun ConfigScreen(
value value
) )
}, },
onDone = { keyboardActions = keyboardActions,
focusManager.clearFocus()
keyboardController?.hide()
},
label = stringResource(R.string.public_key), label = stringResource(R.string.public_key),
hint = stringResource(R.string.base64_key), hint = stringResource(R.string.base64_key),
modifier = Modifier.fillMaxWidth() modifier = baseTextBoxModifier.fillMaxWidth()
) )
ConfigurationTextBox( ConfigurationTextBox(
value = peer.preSharedKey, value = peer.preSharedKey,
@@ -572,16 +578,13 @@ fun ConfigScreen(
value value
) )
}, },
onDone = { keyboardActions = keyboardActions,
focusManager.clearFocus()
keyboardController?.hide()
},
label = stringResource(R.string.preshared_key), label = stringResource(R.string.preshared_key),
hint = stringResource(R.string.optional), hint = stringResource(R.string.optional),
modifier = Modifier.fillMaxWidth() modifier = baseTextBoxModifier.fillMaxWidth()
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = baseTextBoxModifier.fillMaxWidth(),
value = peer.persistentKeepalive, value = peer.persistentKeepalive,
enabled = true, enabled = true,
onValueChange = { value -> onValueChange = { value ->
@@ -602,16 +605,13 @@ fun ConfigScreen(
value value
) )
}, },
onDone = { keyboardActions = keyboardActions,
focusManager.clearFocus()
keyboardController?.hide()
},
label = stringResource(R.string.endpoint), label = stringResource(R.string.endpoint),
hint = stringResource(R.string.endpoint).lowercase(), hint = stringResource(R.string.endpoint).lowercase(),
modifier = Modifier.fillMaxWidth() modifier = baseTextBoxModifier.fillMaxWidth()
) )
OutlinedTextField( OutlinedTextField(
modifier = Modifier.fillMaxWidth(), modifier = baseTextBoxModifier.fillMaxWidth(),
value = peer.allowedIps, value = peer.allowedIps,
enabled = true, enabled = true,
onValueChange = { value -> onValueChange = { value ->
@@ -271,22 +271,22 @@ class ConfigViewModel @Inject constructor(private val application : Application,
fun buildPeerListFromProxyPeers() : List<Peer> { fun buildPeerListFromProxyPeers() : List<Peer> {
return _proxyPeers.value.map { return _proxyPeers.value.map {
val builder = Peer.Builder() val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps) if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey) if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey) if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint) if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) builder.parsePersistentKeepalive(it.persistentKeepalive) if (it.persistentKeepalive.isNotEmpty()) builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
builder.build() builder.build()
} }
} }
fun buildInterfaceListFromProxyInterface() : Interface { fun buildInterfaceListFromProxyInterface() : Interface {
val builder = Interface.Builder() val builder = Interface.Builder()
builder.parsePrivateKey(_interface.value.privateKey) builder.parsePrivateKey(_interface.value.privateKey.trim())
builder.parseAddresses(_interface.value.addresses) builder.parseAddresses(_interface.value.addresses.trim())
builder.parseDnsServers(_interface.value.dnsServers) builder.parseDnsServers(_interface.value.dnsServers.trim())
if(_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu) if(_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim())
if(_interface.value.listenPort.isNotEmpty()) builder.parseListenPort(_interface.value.listenPort) if(_interface.value.listenPort.isNotEmpty()) builder.parseListenPort(_interface.value.listenPort.trim())
if(isAllApplicationsEnabled()) _checkedPackages.value.clear() if(isAllApplicationsEnabled()) _checkedPackages.value.clear()
if(_include.value) builder.includeApplications(_checkedPackages.value) if(_include.value) builder.includeApplications(_checkedPackages.value)
if(!_include.value) builder.excludeApplications(_checkedPackages.value) if(!_include.value) builder.excludeApplications(_checkedPackages.value)
@@ -1,6 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -85,6 +89,8 @@ import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed
import com.zaneschepke.wireguardautotunnel.ui.theme.mint import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@@ -129,13 +135,33 @@ fun MainScreen(
} }
} }
val pickFileLauncher = rememberLauncherForActivityResult( val tunnelFileImportResultLauncher = rememberLauncherForActivityResult(object : ActivityResultContracts.GetContent() {
ActivityResultContracts.GetContent() override fun createIntent(context: Context, input: String): Intent {
) { result -> if (result != null) val intent = super.createIntent(context, input)
try {
viewModel.onTunnelFileSelected(result) /* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
} catch (e : Exception) { * what we can do, so detect this and throw an exception that we can catch later. */
showSnackbarMessage(e.message ?: "Unknown error occurred") val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()))
} else {
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
}
if (activitiesToResolveIntent.all {
val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
}) {
throw WgTunnelException("No file explorer installed")
}
return intent
}
}) { data ->
if (data == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
try {
viewModel.onTunnelFileSelected(data)
} catch (e : Exception) {
showSnackbarMessage(e.message ?: "Unknown error occurred")
}
} }
} }
@@ -163,16 +189,16 @@ fun MainScreen(
selectedTunnel = null selectedTunnel = null
} }
}) })
{ Text(text = "Okay") } { Text(text = stringResource(R.string.okay)) }
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { TextButton(onClick = {
showPrimaryChangeAlertDialog = false showPrimaryChangeAlertDialog = false
}) })
{ Text(text = "Cancel") } { Text(text = stringResource(R.string.cancel)) }
}, },
title = { Text(text = "Primary tunnel change") }, title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
text = { Text(text = "Would you like to make this your primary tunnel?") } text = { Text(text = stringResource(R.string.primary_tunnnel_change_question)) }
) )
} }
@@ -246,9 +272,9 @@ fun MainScreen(
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
try { try {
pickFileLauncher.launch(Constants.ALLOWED_FILE_TYPES) tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
} catch (_: Exception) { } catch (e : Exception) {
showSnackbarMessage("No file explorer") showSnackbarMessage(e.message!!)
} }
} }
.padding(10.dp) .padding(10.dp)
@@ -259,36 +285,38 @@ fun MainScreen(
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) )
Text( Text(
stringResource(id = R.string.add_from_file), stringResource(id = R.string.add_tunnels_text),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) )
} }
Divider() if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Row(modifier = Modifier Divider()
.fillMaxWidth() Row(modifier = Modifier
.clickable { .fillMaxWidth()
scope.launch { .clickable {
showBottomSheet = false scope.launch {
val scanOptions = ScanOptions() showBottomSheet = false
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE) val scanOptions = ScanOptions()
scanOptions.setOrientationLocked(true) scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setPrompt(context.getString(R.string.scanning_qr)) scanOptions.setOrientationLocked(true)
scanOptions.setBeepEnabled(false) scanOptions.setPrompt(context.getString(R.string.scanning_qr))
scanOptions.captureActivity = CaptureActivityPortrait::class.java scanOptions.setBeepEnabled(false)
scanLauncher.launch(scanOptions) scanOptions.captureActivity = CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
}
} }
.padding(10.dp)
) {
Icon(
Icons.Filled.QrCode,
contentDescription = stringResource(id = R.string.qr_scan),
modifier = Modifier.padding(10.dp)
)
Text(
stringResource(id = R.string.add_from_qr),
modifier = Modifier.padding(10.dp)
)
} }
.padding(10.dp)
) {
Icon(
Icons.Filled.QrCode,
contentDescription = stringResource(id = R.string.qr_scan),
modifier = Modifier.padding(10.dp)
)
Text(
stringResource(id = R.string.add_from_qr),
modifier = Modifier.padding(10.dp)
)
} }
Divider() Divider()
Row( Row(
@@ -440,6 +468,7 @@ fun MainScreen(
) )
} }
Switch( Switch(
modifier = Modifier.focusRequester(focusRequester),
checked = (state == Tunnel.State.UP && tunnel.name == tunnelName), checked = (state == Tunnel.State.UP && tunnel.name == tunnelName),
onCheckedChange = { checked -> onCheckedChange = { checked ->
onTunnelToggle(checked, tunnel) onTunnelToggle(checked, tunnel)
@@ -7,8 +7,6 @@ import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.wireguard.config.BadConfigException
import com.wireguard.config.Config import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@@ -31,14 +29,16 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipInputStream
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor(private val application : Application, class MainViewModel @Inject constructor(
private val tunnelRepo : TunnelConfigDao, private val application: Application,
private val settingsRepo : SettingsDoa, private val tunnelRepo: TunnelConfigDao,
private val vpnService: VpnService private val settingsRepo: SettingsDoa,
private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val tunnels get() = tunnelRepo.getAllFlow() val tunnels get() = tunnelRepo.getAllFlow()
@@ -60,19 +60,25 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
private fun validateWatcherServiceState(settings: Settings) { private fun validateWatcherServiceState(settings: Settings) {
val watcherState = ServiceManager.getServiceState(application.applicationContext, WireGuardConnectivityWatcherService::class.java) val watcherState = ServiceManager.getServiceState(
if(settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) { application.applicationContext,
ServiceManager.startWatcherService(application.applicationContext, settings.defaultTunnel!!) WireGuardConnectivityWatcherService::class.java
)
if (settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) {
ServiceManager.startWatcherService(
application.applicationContext,
settings.defaultTunnel!!
)
} }
} }
fun onDelete(tunnel : TunnelConfig) { fun onDelete(tunnel: TunnelConfig) {
viewModelScope.launch { viewModelScope.launch {
if(tunnelRepo.count() == 1L) { if (tunnelRepo.count() == 1L) {
ServiceManager.stopWatcherService(application.applicationContext) ServiceManager.stopWatcherService(application.applicationContext)
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) { if (settings.isNotEmpty()) {
val setting = settings[0] val setting = settings[0]
setting.defaultTunnel = null setting.defaultTunnel = null
setting.isAutoTunnelEnabled = false setting.isAutoTunnelEnabled = false
@@ -84,7 +90,7 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
} }
fun onTunnelStart(tunnelConfig : TunnelConfig) { fun onTunnelStart(tunnelConfig: TunnelConfig) {
viewModelScope.launch { viewModelScope.launch {
stopActiveTunnel() stopActiveTunnel()
startTunnel(tunnelConfig) startTunnel(tunnelConfig)
@@ -96,8 +102,11 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
private suspend fun stopActiveTunnel() { private suspend fun stopActiveTunnel() {
if(ServiceManager.getServiceState(application.applicationContext, if (ServiceManager.getServiceState(
WireGuardTunnelService::class.java, ) == ServiceState.STARTED) { application.applicationContext,
WireGuardTunnelService::class.java,
) == ServiceState.STARTED
) {
onTunnelStop() onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY) delay(Constants.TOGGLE_TUNNEL_DELAY)
} }
@@ -107,32 +116,28 @@ class MainViewModel @Inject constructor(private val application : Application,
ServiceManager.stopVpnService(application.applicationContext) ServiceManager.stopVpnService(application.applicationContext)
} }
private fun validateConfigString(config : String) { private fun validateConfigString(config: String) {
if(!config.contains(application.getString(R.string.config_validation))) { if (!config.contains(application.getString(R.string.config_validation))) {
throw WgTunnelException(application.getString(R.string.config_validation)) throw WgTunnelException(application.getString(R.string.config_validation))
} }
} }
fun onTunnelQrResult(result : String) { fun onTunnelQrResult(result: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
validateConfigString(result) validateConfigString(result)
val tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig) addTunnel(tunnelConfig)
} catch (e : WgTunnelException) { } catch (e: WgTunnelException) {
throw WgTunnelException(e.message ?: application.getString(R.string.unknown_error_message)) throw WgTunnelException(
e.message ?: application.getString(R.string.unknown_error_message)
)
} }
} }
} }
private fun validateFileExtension(fileName : String) { private fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
val extension = getFileExtensionFromFileName(fileName)
if(extension != Constants.VALID_FILE_EXTENSION) {
throw WgTunnelException(application.getString(R.string.file_extension_message))
}
}
private fun saveTunnelConfigFromStream(stream : InputStream, fileName : String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8) val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader) val config = Config.parse(bufferReader)
@@ -147,30 +152,52 @@ class MainViewModel @Inject constructor(private val application : Application,
?: throw WgTunnelException(application.getString(R.string.stream_failed)) ?: throw WgTunnelException(application.getString(R.string.stream_failed))
} }
fun onTunnelFileSelected(uri : Uri) { suspend fun onTunnelFileSelected(uri: Uri) {
try { try {
viewModelScope.launch(Dispatchers.IO) { val fileName = getFileName(application.applicationContext, uri)
val fileName = getFileName(application.applicationContext, uri) val fileExtension = getFileExtensionFromFileName(fileName)
validateFileExtension(fileName) when(fileExtension){
val stream = getInputStreamFromUri(uri) Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri)
saveTunnelConfigFromStream(stream, fileName) Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> throw WgTunnelException(application.getString(R.string.file_extension_message))
} }
} catch (e : Exception) {
} catch (e: Exception) {
throw WgTunnelException(e.message ?: "Error importing file") throw WgTunnelException(e.message ?: "Error importing file")
} }
} }
private suspend fun addTunnel(tunnelConfig: TunnelConfig) { private suspend fun saveTunnelsFromZipUri(uri: Uri) {
ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot { it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION }
.forEach {
val name = getNameFromFileName(it.name)
val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
}
}
}
}
private fun saveTunnelFromConfUri(name : String, uri: Uri) {
val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, name)
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig) saveTunnel(tunnelConfig)
} }
private suspend fun saveTunnel(tunnelConfig : TunnelConfig) { private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig) tunnelRepo.save(tunnelConfig)
} }
private fun getFileNameByCursor(context: Context, uri: Uri) : String { private fun getFileNameByCursor(context: Context, uri: Uri): String {
val cursor = context.contentResolver.query(uri, null, null, null, null) val cursor = context.contentResolver.query(uri, null, null, null, null)
if(cursor != null) { if (cursor != null) {
cursor.use { cursor.use {
return getDisplayNameByCursor(it) return getDisplayNameByCursor(it)
} }
@@ -179,16 +206,16 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
} }
private fun getDisplayNameColumnIndex(cursor: Cursor) : Int { private fun getDisplayNameColumnIndex(cursor: Cursor): Int {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if(columnIndex == -1) { if (columnIndex == -1) {
throw WgTunnelException("Cursor out of bounds") throw WgTunnelException("Cursor out of bounds")
} }
return columnIndex return columnIndex
} }
private fun getDisplayNameByCursor(cursor: Cursor) : String { private fun getDisplayNameByCursor(cursor: Cursor): String {
if(cursor.moveToFirst()) { if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor) val index = getDisplayNameColumnIndex(cursor)
return cursor.getString(index) return cursor.getString(index)
} else { } else {
@@ -196,7 +223,7 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
} }
private fun validateUriContentScheme(uri : Uri) { private fun validateUriContentScheme(uri: Uri) {
if (uri.scheme != Constants.URI_CONTENT_SCHEME) { if (uri.scheme != Constants.URI_CONTENT_SCHEME) {
throw WgTunnelException(application.getString(R.string.file_extension_message)) throw WgTunnelException(application.getString(R.string.file_extension_message))
} }
@@ -212,23 +239,25 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
} }
private fun getNameFromFileName(fileName : String) : String { private fun getNameFromFileName(fileName: String): String {
return fileName.substring(0 , fileName.lastIndexOf('.') ) return fileName.substring(0, fileName.lastIndexOf('.'))
} }
private fun getFileExtensionFromFileName(fileName : String) : String { private fun getFileExtensionFromFileName(fileName: String): String {
return try { return try {
fileName.substring(fileName.lastIndexOf('.')) fileName.substring(fileName.lastIndexOf('.'))
} catch (e : Exception) { } catch (e: Exception) {
"" ""
} }
} }
suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) { suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) {
if(selectedTunnel != null) { if (selectedTunnel != null) {
_settings.emit(_settings.value.copy( _settings.emit(
defaultTunnel = selectedTunnel.toString() _settings.value.copy(
)) defaultTunnel = selectedTunnel.toString()
)
)
settingsRepo.save(_settings.value) settingsRepo.save(_settings.value)
} }
} }
@@ -44,12 +44,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -66,12 +69,16 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.StorageUtil
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
import kotlin.math.exp
@OptIn( @OptIn(
ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
ExperimentalLayoutApi::class ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class
) )
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@@ -84,6 +91,7 @@ fun SettingsScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val settings by viewModel.settings.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle()
@@ -94,11 +102,28 @@ fun SettingsScreen(
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var didShowLocationDisclaimer by remember { mutableStateOf(false) } var didShowLocationDisclaimer by remember { mutableStateOf(false) }
var isBackgroundLocationGranted by remember { mutableStateOf(true) } var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showAuthPrompt by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
val screenPadding = 5.dp val screenPadding = 5.dp
val fillMaxHeight = .85f
val fillMaxWidth = .85f val fillMaxWidth = .85f
fun exportAllConfigs() {
try {
val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") }
files.forEachIndexed() { index, file ->
file.outputStream().use {
it.write(tunnels[index].wgQuick.toByteArray())
}
}
StorageUtil.saveFilesToZip(context, files)
didExportFiles = true
showSnackbarMessage("Exported configs to downloads")
} catch (e : Exception) {
showSnackbarMessage(e.message!!)
}
}
fun saveTrustedSSID() { fun saveTrustedSSID() {
if (currentText.isNotEmpty()) { if (currentText.isNotEmpty()) {
@@ -188,6 +213,20 @@ fun SettingsScreen(
} }
} }
if(showAuthPrompt) {
AuthorizationPrompt(onSuccess = {
showAuthPrompt = false
exportAllConfigs() },
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage("Authentication failed")
})
}
if (tunnels.isEmpty()) { if (tunnels.isEmpty()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -264,7 +303,11 @@ fun SettingsScreen(
value = currentText, value = currentText,
onValueChange = { currentText = it }, onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) }, label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier = Modifier.padding(start = screenPadding, top = 5.dp).focusRequester(focusRequester), modifier = Modifier.padding(start = screenPadding, top = 5.dp).focusRequester(focusRequester).onFocusChanged {
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide()
}
},
maxLines = 1, maxLines = 1,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
@@ -355,6 +398,21 @@ fun SettingsScreen(
} }
} }
) )
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
enabled = !didExportFiles,
onClick = {
showAuthPrompt = true
}) {
Text("Export configs")
}
}
} }
} }
} }
@@ -0,0 +1,53 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns
import com.zaneschepke.wireguardautotunnel.Constants
import java.io.File
import java.io.OutputStream
import java.time.Instant
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
object StorageUtil {
private const val ZIP_FILE_MIME_TYPE = "application/zip"
private fun createDownloadsFileOutputStream(context: Context, fileName: String, mimeType : String = Constants.ALLOWED_FILE_TYPES) : OutputStream? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.MIME_TYPE, mimeType)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
return resolver.openOutputStream(uri)
}
} else {
val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName
)
return target.outputStream()
}
return null
}
fun saveFilesToZip(context: Context, files : List<File>) {
val zipOutputStream = createDownloadsFileOutputStream(context, "wg-export_${Instant.now().epochSecond}.zip", ZIP_FILE_MIME_TYPE)
ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file ->
val entry = ZipEntry( file.name)
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use { fis -> fis.copyTo(zos) }
}
}
}
}
}
+6 -3
View File
@@ -8,7 +8,7 @@
<string name="foreground_file">FOREGROUND_FILE</string> <string name="foreground_file">FOREGROUND_FILE</string>
<string name="github_url">https://github.com/zaneschepke/wgtunnel</string> <string name="github_url">https://github.com/zaneschepke/wgtunnel</string>
<string name="privacy_policy_url">https://zaneschepke.github.io/wgtunnel/</string> <string name="privacy_policy_url">https://zaneschepke.github.io/wgtunnel/</string>
<string name="file_extension_message">File is not a .conf file</string> <string name="file_extension_message">File is not a .conf or .zip</string>
<string name="turn_off_tunnel">Turn off tunnel before editing</string> <string name="turn_off_tunnel">Turn off tunnel before editing</string>
<string name="no_tunnels">No tunnels added yet!</string> <string name="no_tunnels">No tunnels added yet!</string>
<string name="tunnel_exists">Tunnel name already exists</string> <string name="tunnel_exists">Tunnel name already exists</string>
@@ -38,9 +38,9 @@
<string name="trusted_ssid_empty_description">Enter SSID</string> <string name="trusted_ssid_empty_description">Enter SSID</string>
<string name="trusted_ssid_value_description">Submit SSID</string> <string name="trusted_ssid_value_description">Submit SSID</string>
<string name="config_validation">[Interface]</string> <string name="config_validation">[Interface]</string>
<string name="add_from_file">Add tunnel from files</string> <string name="add_tunnels_text">Add from file or zip</string>
<string name="open_file">File Open</string> <string name="open_file">File Open</string>
<string name="add_from_qr">Add tunnel from QR code</string> <string name="add_from_qr">Add from QR code</string>
<string name="qr_scan">QR Scan</string> <string name="qr_scan">QR Scan</string>
<string name="tunnel_edit">Tunnel Edit</string> <string name="tunnel_edit">Tunnel Edit</string>
<string name="tunnel_name">Tunnel Name</string> <string name="tunnel_name">Tunnel Name</string>
@@ -124,4 +124,7 @@
<string name="preshared_key">Pre-shared key</string> <string name="preshared_key">Pre-shared key</string>
<string name="seconds">seconds</string> <string name="seconds">seconds</string>
<string name="persistent_keepalive">Persistent keepalive</string> <string name="persistent_keepalive">Persistent keepalive</string>
<string name="cancel">Cancel</string>
<string name="primary_tunnel_change">Primary tunnel change</string>
<string name="primary_tunnnel_change_question">Would you like to make this your primary tunnel?</string>
</resources> </resources>
+9 -5
View File
@@ -1,8 +1,9 @@
[versions] [versions]
accompanist = "0.31.2-alpha" accompanist = "0.31.2-alpha"
activityCompose = "1.7.2" activityCompose = "1.8.0"
androidx-junit = "1.1.5" androidx-junit = "1.1.5"
appcompat = "1.6.1" appcompat = "1.6.1"
biometricKtx = "1.2.0-alpha05"
coreKtx = "1.12.0" coreKtx = "1.12.0"
espressoCore = "3.5.1" espressoCore = "3.5.1"
firebase-crashlytics-gradle = "2.9.9" firebase-crashlytics-gradle = "2.9.9"
@@ -12,18 +13,18 @@ hiltNavigationCompose = "1.0.0"
junit = "4.13.2" junit = "4.13.2"
kotlinx-serialization-json = "1.5.1" kotlinx-serialization-json = "1.5.1"
lifecycle-runtime-compose = "2.6.2" lifecycle-runtime-compose = "2.6.2"
material-icons-extended = "1.5.2" material-icons-extended = "1.5.3"
material3 = "1.1.2" material3 = "1.1.2"
navigationCompose = "2.7.3" navigationCompose = "2.7.4"
roomVersion = "2.6.0-rc01" roomVersion = "2.6.0-rc01"
timber = "5.0.1" timber = "5.0.1"
tunnel = "1.0.20230706" tunnel = "1.0.20230706"
androidGradlePlugin = "8.3.0-alpha06" androidGradlePlugin = "8.3.0-alpha06"
kotlin="1.9.10" kotlin="1.9.10"
ksp="1.9.10-1.0.13" ksp="1.9.10-1.0.13"
composeBom="2023.09.02" composeBom="2023.10.00"
firebaseBom="32.3.1" firebaseBom="32.3.1"
compose="1.5.2" compose="1.5.3"
crashlytics="18.4.3" crashlytics="18.4.3"
analytics="21.3.0" analytics="21.3.0"
composeCompiler="1.5.3" composeCompiler="1.5.3"
@@ -40,6 +41,9 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss
accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" }
#room #room
androidx-biometric-ktx = { module = "androidx.biometric:biometric-ktx", version.ref = "biometricKtx" }
androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycle-runtime-compose" }
androidx-lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle-runtime-compose" }
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" } androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" }
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" }
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomVersion" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "roomVersion" }
+2 -2
View File
@@ -1,6 +1,6 @@
#Mon Apr 24 22:46:45 EDT 2023 #Wed Oct 11 22:39:21 EDT 2023
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-rc-2-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists