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"
minSdk = 26
targetSdk = 34
versionCode = 31000
versionName = "3.1.0"
versionCode = 31400
versionName = "3.1.4"
multiDexEnabled = true
@@ -81,6 +81,8 @@ val generalImplementation by configurations
dependencies {
implementation(libs.androidx.core.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(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui)
@@ -126,6 +128,9 @@ dependencies {
//lifecycle
implementation(libs.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process)
//icons
implementation(libs.material.icons.extended)
@@ -140,4 +145,8 @@ dependencies {
//barcode scanning
implementation(libs.zxing.android.embedded)
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.READ_EXTERNAL_STORAGE"
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"
android:maxSdkVersion="30"
tools:ignore="LeanbackUsesWifi" />
@@ -8,9 +8,11 @@ object Constants {
const val FADE_IN_ANIMATION_DURATION = 1000
const val SLIDE_IN_ANIMATION_DURATION = 500
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_PACKAGE_SCHEME = "package"
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.content.Context
import android.content.pm.PackageManager
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import dagger.hilt.android.HiltAndroidApp
@@ -27,13 +29,17 @@ class WireGuardAutoTunnel : Application() {
}
private fun initSettings() {
CoroutineScope(Dispatchers.IO).launch {
if(settingsRepo.getAll().isEmpty()) {
settingsRepo.save(Settings())
with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch {
if(settingsRepo.getAll().isEmpty()) {
settingsRepo.save(Settings())
}
}
}
}
companion object {
fun isRunningOnAndroidTv(context : Context) : Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
@@ -3,13 +3,11 @@ package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@@ -18,20 +16,18 @@ class BootReceiver : BroadcastReceiver() {
@Inject
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) {
CoroutineScope(Dispatchers.IO).launch {
try {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
ServiceManager.startWatcherService(context, setting.defaultTunnel!!)
}
try {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
ServiceManager.startWatcherService(context, setting.defaultTunnel!!)
}
} finally {
cancel()
}
} finally {
cancel()
}
}
}
@@ -4,14 +4,12 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
@@ -19,21 +17,19 @@ class NotificationActionReceiver : BroadcastReceiver() {
@Inject
lateinit var settingsRepo : SettingsDoa
override fun onReceive(context: Context, intent: Intent?) {
CoroutineScope(Dispatchers.IO).launch {
try {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
if (setting.defaultTunnel != null) {
ServiceManager.stopVpnService(context)
delay(Constants.TOGGLE_TUNNEL_DELAY)
ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
}
override fun onReceive(context: Context, intent: Intent?) = goAsync {
try {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
if (setting.defaultTunnel != null) {
ServiceManager.stopVpnService(context)
delay(Constants.TOGGLE_TUNNEL_DELAY)
ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
}
} finally {
cancel()
}
} finally {
cancel()
}
}
}
@@ -1,22 +1,24 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.Service
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import androidx.lifecycle.LifecycleService
import timber.log.Timber
open class ForegroundService : Service() {
open class ForegroundService : LifecycleService() {
private var isServiceStarted = false
override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
// We don't provide binding, so return null
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) {
val action = intent.action
@@ -7,6 +7,7 @@ import android.content.Intent
import android.os.Bundle
import android.os.PowerManager
import android.os.SystemClock
import androidx.lifecycle.lifecycleScope
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R
@@ -66,7 +67,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
override fun onCreate() {
super.onCreate()
CoroutineScope(Dispatchers.Main).launch {
lifecycleScope.launch(Dispatchers.Main) {
launchWatcherNotification()
}
}
@@ -122,6 +123,9 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
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()
}
}
@@ -134,7 +138,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
}
private fun startWatcherJob() {
watcherJob = CoroutineScope(Dispatchers.IO).launch {
watcherJob = lifecycleScope.launch(Dispatchers.IO) {
val settings = settingsRepo.getAll();
if(settings.isNotEmpty()) {
setting = settings[0]
@@ -3,6 +3,8 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
@@ -38,7 +40,7 @@ class WireGuardTunnelService : ForegroundService() {
override fun onCreate() {
super.onCreate()
CoroutineScope(Dispatchers.Main).launch {
lifecycleScope.launch(Dispatchers.Main) {
launchVpnStartingNotification()
}
}
@@ -48,54 +50,56 @@ class WireGuardTunnelService : ForegroundService() {
launchVpnStartingNotification()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
cancelJob()
job = CoroutineScope(Dispatchers.IO).launch {
if(tunnelConfigString != null) {
try {
val tunnelConfig = TunnelConfig.from(tunnelConfigString)
tunnelName = tunnelConfig.name
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!!)
job = lifecycleScope.launch(Dispatchers.IO) {
launch {
if(tunnelConfigString != null) {
try {
val tunnelConfig = TunnelConfig.from(tunnelConfigString)
tunnelName = tunnelConfig.name
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)
}
}
}
}
}
CoroutineScope(job).launch {
var didShowConnected = false
var didShowFailedHandshakeNotification = false
vpnService.handshakeStatus.collect {
when(it) {
HandshakeStatus.NOT_STARTED -> {
}
HandshakeStatus.NEVER_CONNECTED -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
launch {
var didShowConnected = false
var didShowFailedHandshakeNotification = false
vpnService.handshakeStatus.collect {
when(it) {
HandshakeStatus.NOT_STARTED -> {
}
}
HandshakeStatus.HEALTHY -> {
if(!didShowConnected) {
launchVpnConnectedNotification()
didShowConnected = true
HandshakeStatus.NEVER_CONNECTED -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
}
HandshakeStatus.UNHEALTHY -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
HandshakeStatus.HEALTHY -> {
if(!didShowConnected) {
launchVpnConnectedNotification()
didShowConnected = true
}
}
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?) {
super.stopService(extras)
CoroutineScope(Dispatchers.IO).launch {
lifecycleScope.launch(Dispatchers.IO) {
vpnService.stopTunnel()
}
cancelJob()
@@ -140,7 +144,8 @@ class WireGuardTunnelService : ForegroundService() {
val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
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),
title = getString(R.string.vpn_connection_failed),
onGoing = false,
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.service.shortcut
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
@@ -13,6 +14,7 @@ import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@@ -26,10 +28,9 @@ class ShortcutsActivity : ComponentActivity() {
@Inject
lateinit var tunnelConfigRepo : TunnelConfigDao
private val scope = CoroutineScope(Dispatchers.Main);
private fun attemptWatcherServiceToggle(tunnelConfig : String) {
scope.launch {
lifecycleScope.launch(Dispatchers.Main) {
val settings = getSettings()
if(settings.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig)
@@ -41,7 +42,7 @@ class ShortcutsActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
if(intent.getStringExtra(CLASS_NAME_EXTRA_KEY)
.equals(WireGuardTunnelService::class.java.simpleName)) {
scope.launch {
lifecycleScope.launch(Dispatchers.Main) {
try {
val settings = getSettings()
val tunnelConfig = if(settings.defaultTunnel == null) {
@@ -55,6 +55,11 @@ class TunnelControlTile : TileService() {
cancelJob()
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
override fun onClick() {
super.onClick()
unlockAndRun {
@@ -11,6 +11,7 @@ import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
@@ -46,6 +47,8 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
override val handshakeStatus: SharedFlow<HandshakeStatus>
get() = _handshakeStatus.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.IO);
private lateinit var statsJob : Job
@@ -70,7 +73,7 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
return _tunnelName.value
}
override suspend fun stopTunnel() {
override suspend fun stopTunnel() {
try {
if(getState() == Tunnel.State.UP) {
val state = backend.setState(this, Tunnel.State.DOWN, null)
@@ -89,7 +92,7 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
val tunnel = this;
_state.tryEmit(state)
if(state == Tunnel.State.UP) {
statsJob = CoroutineScope(Dispatchers.IO).launch {
statsJob = scope.launch {
val handshakeMap = HashMap<Key, Long>()
var neverHadHandshakeCounter = 0
while (true) {
@@ -128,4 +131,6 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
_lastHandshake.tryEmit(emptyMap())
}
}
}
@@ -11,6 +11,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -32,6 +33,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.google.accompanist.navigation.animation.AnimatedNavHost
import com.google.accompanist.navigation.animation.composable
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.zaneschepke.wireguardautotunnel.Constants
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.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.detail.DetailScreen
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.WireguardAutoTunnelTheme
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -99,10 +100,10 @@ class MainActivity : AppCompatActivity() {
}
fun showSnackBarMessage(message : String) {
CoroutineScope(Dispatchers.Main).launch {
lifecycleScope.launch(Dispatchers.Main) {
val result = snackbarHostState.showSnackbar(
message = message,
actionLabel = "Okay",
actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short,
)
when (result) {
@@ -184,7 +185,10 @@ class MainActivity : AppCompatActivity() {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}
}
}) {
}, exitTransition = {
ExitTransition.None
}
) {
MainScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, navController = navController)
}
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
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(
modifier = modifier,
value = value,
@@ -29,10 +29,6 @@ fun
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
onDone()
}
),
keyboardActions = keyboardActions,
)
}
@@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -14,7 +13,7 @@ import androidx.compose.ui.unit.Dp
@Composable
fun ConfigurationToggle(label : String, enabled : Boolean, checked : Boolean, padding : Dp,
onCheckChanged : () -> Unit) {
onCheckChanged : () -> Unit, modifier : Modifier = Modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -24,6 +23,7 @@ fun ConfigurationToggle(label : String, enabled : Boolean, checked : Boolean, pa
) {
Text(label)
Switch(
modifier = modifier,
enabled = enabled,
checked = checked,
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.Row
@@ -14,12 +14,12 @@ data class InterfaceProxy(
companion object {
fun from(i : Interface) : InterfaceProxy {
return InterfaceProxy(
publicKey = i.keyPair.publicKey.toBase64(),
privateKey = i.keyPair.privateKey.toBase64(),
addresses = i.addresses.joinToString(","),
dnsServers = i.dnsServers.joinToString(",").replace("/", ""),
listenPort = if(i.listenPort.isPresent) i.listenPort.get().toString() else "",
mtu = if(i.mtu.isPresent) i.mtu.get().toString() else ""
publicKey = i.keyPair.publicKey.toBase64().trim(),
privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = if(i.listenPort.isPresent) i.listenPort.get().toString().trim() else "",
mtu = if(i.mtu.isPresent) i.mtu.get().toString().trim() else ""
)
}
}
@@ -7,16 +7,16 @@ data class PeerProxy(
var preSharedKey : String = "",
var persistentKeepalive : String = "",
var endpoint : String = "",
var allowedIps: String = IPV4_WILDCARD.joinToString(",")
var allowedIps: String = IPV4_WILDCARD.joinToString(", ").trim()
){
companion object {
fun from(peer : Peer) : PeerProxy {
return PeerProxy(
publicKey = peer.publicKey.toBase64(),
preSharedKey = if(peer.preSharedKey.isPresent) peer.preSharedKey.get().toString() else "",
persistentKeepalive = if(peer.persistentKeepalive.isPresent) peer.persistentKeepalive.get().toString() else "",
endpoint = if(peer.endpoint.isPresent) peer.endpoint.get().toString() else "",
allowedIps = peer.allowedIps.joinToString(",")
preSharedKey = if(peer.preSharedKey.isPresent) peer.preSharedKey.get().toBase64().trim() else "",
persistentKeepalive = if(peer.persistentKeepalive.isPresent) peer.persistentKeepalive.get().toString().trim() else "",
endpoint = if(peer.endpoint.isPresent) peer.endpoint.get().toString().trim() else "",
allowedIps = peer.allowedIps.joinToString(", ").trim()
)
}
val IPV4_PUBLIC_NETWORKS = setOf(
@@ -3,6 +3,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.annotation.SuppressLint
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
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.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
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.common.SearchBar
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 kotlinx.coroutines.launch
import timber.log.Timber
@@ -96,7 +98,6 @@ fun ConfigScreen(
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val scope = rememberCoroutineScope()
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current
@@ -111,10 +112,16 @@ fun ConfigScreen(
val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle()
val proxyInterface by viewModel.interfaceProxy.collectAsStateWithLifecycle()
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(
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
}
)
@@ -143,6 +150,21 @@ fun ConfigScreen(
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) {
val sortedPackages = remember(packages) {
packages.sortedBy { viewModel.getPackageLabel(it) }
@@ -380,19 +402,18 @@ fun ConfigScreen(
onValueChange = { value ->
viewModel.onTunnelNameChange(value)
},
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
},
keyboardActions = keyboardActions,
label = stringResource(R.string.name),
hint = stringResource(R.string.tunnel_name).lowercase(),
modifier = Modifier.fillMaxWidth().focusRequester(focusRequester)
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(focusRequester)
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
modifier = baseTextBoxModifier.fillMaxWidth().clickable {
showAuthPrompt = true
},
value = proxyInterface.privateKey,
visualTransformation = PasswordVisualTransformation(),
enabled = id == Constants.MANUAL_TUNNEL_CONFIG_ID,
visualTransformation = if((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(),
enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
onValueChange = { value ->
viewModel.onPrivateKeyChange(value)
},
@@ -416,7 +437,7 @@ fun ConfigScreen(
keyboardActions = keyboardActions
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth().focusRequester(FocusRequester.Default),
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(FocusRequester.Default),
value = proxyInterface.publicKey,
enabled = false,
onValueChange = {},
@@ -445,52 +466,40 @@ fun ConfigScreen(
onValueChange = { value ->
viewModel.onAddressesChanged(value)
},
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
},
keyboardActions = keyboardActions,
label = stringResource(R.string.addresses),
hint = stringResource(R.string.comma_separated_list),
modifier = Modifier
modifier = baseTextBoxModifier
.fillMaxWidth(3 / 5f)
.padding(end = 5.dp)
)
ConfigurationTextBox(
value = proxyInterface.listenPort,
onValueChange = { value -> viewModel.onListenPortChanged(value) },
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
},
keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port),
hint = stringResource(R.string.random),
modifier = Modifier.width(IntrinsicSize.Min)
modifier = baseTextBoxModifier.width(IntrinsicSize.Min)
)
}
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = proxyInterface.dnsServers,
onValueChange = { value -> viewModel.onDnsServersChanged(value) },
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
},
keyboardActions = keyboardActions,
label = stringResource(R.string.dns_servers),
hint = stringResource(R.string.comma_separated_list),
modifier = Modifier
modifier = baseTextBoxModifier
.fillMaxWidth(3 / 5f)
.padding(end = 5.dp)
)
ConfigurationTextBox(
value = proxyInterface.mtu,
onValueChange = { value -> viewModel.onMtuChanged(value) },
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
},
keyboardActions = keyboardActions,
label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto),
modifier = Modifier.width(IntrinsicSize.Min)
modifier = baseTextBoxModifier.width(IntrinsicSize.Min)
)
}
Row(
@@ -556,13 +565,10 @@ fun ConfigScreen(
value
)
},
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
},
keyboardActions = keyboardActions,
label = stringResource(R.string.public_key),
hint = stringResource(R.string.base64_key),
modifier = Modifier.fillMaxWidth()
modifier = baseTextBoxModifier.fillMaxWidth()
)
ConfigurationTextBox(
value = peer.preSharedKey,
@@ -572,16 +578,13 @@ fun ConfigScreen(
value
)
},
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
},
keyboardActions = keyboardActions,
label = stringResource(R.string.preshared_key),
hint = stringResource(R.string.optional),
modifier = Modifier.fillMaxWidth()
modifier = baseTextBoxModifier.fillMaxWidth()
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
modifier = baseTextBoxModifier.fillMaxWidth(),
value = peer.persistentKeepalive,
enabled = true,
onValueChange = { value ->
@@ -602,16 +605,13 @@ fun ConfigScreen(
value
)
},
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
},
keyboardActions = keyboardActions,
label = stringResource(R.string.endpoint),
hint = stringResource(R.string.endpoint).lowercase(),
modifier = Modifier.fillMaxWidth()
modifier = baseTextBoxModifier.fillMaxWidth()
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
modifier = baseTextBoxModifier.fillMaxWidth(),
value = peer.allowedIps,
enabled = true,
onValueChange = { value ->
@@ -271,22 +271,22 @@ class ConfigViewModel @Inject constructor(private val application : Application,
fun buildPeerListFromProxyPeers() : List<Peer> {
return _proxyPeers.value.map {
val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps)
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey)
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey)
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint)
if (it.persistentKeepalive.isNotEmpty()) builder.parsePersistentKeepalive(it.persistentKeepalive)
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
builder.build()
}
}
fun buildInterfaceListFromProxyInterface() : Interface {
val builder = Interface.Builder()
builder.parsePrivateKey(_interface.value.privateKey)
builder.parseAddresses(_interface.value.addresses)
builder.parseDnsServers(_interface.value.dnsServers)
if(_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu)
if(_interface.value.listenPort.isNotEmpty()) builder.parseListenPort(_interface.value.listenPort)
builder.parsePrivateKey(_interface.value.privateKey.trim())
builder.parseAddresses(_interface.value.addresses.trim())
builder.parseDnsServers(_interface.value.dnsServers.trim())
if(_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim())
if(_interface.value.listenPort.isNotEmpty()) builder.parseListenPort(_interface.value.listenPort.trim())
if(isAllApplicationsEnabled()) _checkedPackages.value.clear()
if(_include.value) builder.includeApplications(_checkedPackages.value)
if(!_include.value) builder.excludeApplications(_checkedPackages.value)
@@ -1,6 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
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.result.contract.ActivityResultContracts
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.theme.brickRed
import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@@ -129,13 +135,33 @@ fun MainScreen(
}
}
val pickFileLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.GetContent()
) { result -> if (result != null)
try {
viewModel.onTunnelFileSelected(result)
} catch (e : Exception) {
showSnackbarMessage(e.message ?: "Unknown error occurred")
val tunnelFileImportResultLauncher = rememberLauncherForActivityResult(object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */
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
}
})
{ Text(text = "Okay") }
{ Text(text = stringResource(R.string.okay)) }
},
dismissButton = {
TextButton(onClick = {
showPrimaryChangeAlertDialog = false
})
{ Text(text = "Cancel") }
{ Text(text = stringResource(R.string.cancel)) }
},
title = { Text(text = "Primary tunnel change") },
text = { Text(text = "Would you like to make this your primary tunnel?") }
title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
text = { Text(text = stringResource(R.string.primary_tunnnel_change_question)) }
)
}
@@ -246,9 +272,9 @@ fun MainScreen(
.clickable {
showBottomSheet = false
try {
pickFileLauncher.launch(Constants.ALLOWED_FILE_TYPES)
} catch (_: Exception) {
showSnackbarMessage("No file explorer")
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
} catch (e : Exception) {
showSnackbarMessage(e.message!!)
}
}
.padding(10.dp)
@@ -259,36 +285,38 @@ fun MainScreen(
modifier = Modifier.padding(10.dp)
)
Text(
stringResource(id = R.string.add_from_file),
stringResource(id = R.string.add_tunnels_text),
modifier = Modifier.padding(10.dp)
)
}
Divider()
Row(modifier = Modifier
.fillMaxWidth()
.clickable {
scope.launch {
showBottomSheet = false
val scanOptions = ScanOptions()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(context.getString(R.string.scanning_qr))
scanOptions.setBeepEnabled(false)
scanOptions.captureActivity = CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Divider()
Row(modifier = Modifier
.fillMaxWidth()
.clickable {
scope.launch {
showBottomSheet = false
val scanOptions = ScanOptions()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(context.getString(R.string.scanning_qr))
scanOptions.setBeepEnabled(false)
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()
Row(
@@ -440,6 +468,7 @@ fun MainScreen(
)
}
Switch(
modifier = Modifier.focusRequester(focusRequester),
checked = (state == Tunnel.State.UP && tunnel.name == tunnelName),
onCheckedChange = { checked ->
onTunnelToggle(checked, tunnel)
@@ -7,8 +7,6 @@ import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.wireguard.config.BadConfigException
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R
@@ -31,14 +29,16 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import java.io.InputStream
import java.util.zip.ZipInputStream
import javax.inject.Inject
@HiltViewModel
class MainViewModel @Inject constructor(private val application : Application,
private val tunnelRepo : TunnelConfigDao,
private val settingsRepo : SettingsDoa,
private val vpnService: VpnService
class MainViewModel @Inject constructor(
private val application: Application,
private val tunnelRepo: TunnelConfigDao,
private val settingsRepo: SettingsDoa,
private val vpnService: VpnService
) : ViewModel() {
val tunnels get() = tunnelRepo.getAllFlow()
@@ -60,19 +60,25 @@ class MainViewModel @Inject constructor(private val application : Application,
}
private fun validateWatcherServiceState(settings: Settings) {
val watcherState = ServiceManager.getServiceState(application.applicationContext, WireGuardConnectivityWatcherService::class.java)
if(settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) {
ServiceManager.startWatcherService(application.applicationContext, settings.defaultTunnel!!)
val watcherState = ServiceManager.getServiceState(
application.applicationContext,
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 {
if(tunnelRepo.count() == 1L) {
if (tunnelRepo.count() == 1L) {
ServiceManager.stopWatcherService(application.applicationContext)
val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) {
if (settings.isNotEmpty()) {
val setting = settings[0]
setting.defaultTunnel = null
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 {
stopActiveTunnel()
startTunnel(tunnelConfig)
@@ -96,8 +102,11 @@ class MainViewModel @Inject constructor(private val application : Application,
}
private suspend fun stopActiveTunnel() {
if(ServiceManager.getServiceState(application.applicationContext,
WireGuardTunnelService::class.java, ) == ServiceState.STARTED) {
if (ServiceManager.getServiceState(
application.applicationContext,
WireGuardTunnelService::class.java,
) == ServiceState.STARTED
) {
onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY)
}
@@ -107,32 +116,28 @@ class MainViewModel @Inject constructor(private val application : Application,
ServiceManager.stopVpnService(application.applicationContext)
}
private fun validateConfigString(config : String) {
if(!config.contains(application.getString(R.string.config_validation))) {
private fun validateConfigString(config: String) {
if (!config.contains(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) {
try {
validateConfigString(result)
val tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig)
} catch (e : WgTunnelException) {
throw WgTunnelException(e.message ?: application.getString(R.string.unknown_error_message))
} catch (e: WgTunnelException) {
throw WgTunnelException(
e.message ?: application.getString(R.string.unknown_error_message)
)
}
}
}
private fun validateFileExtension(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) {
private fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
viewModelScope.launch(Dispatchers.IO) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
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))
}
fun onTunnelFileSelected(uri : Uri) {
suspend fun onTunnelFileSelected(uri: Uri) {
try {
viewModelScope.launch(Dispatchers.IO) {
val fileName = getFileName(application.applicationContext, uri)
validateFileExtension(fileName)
val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, fileName)
val fileName = getFileName(application.applicationContext, uri)
val fileExtension = getFileExtensionFromFileName(fileName)
when(fileExtension){
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri)
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")
}
}
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)
}
private suspend fun saveTunnel(tunnelConfig : TunnelConfig) {
private suspend fun saveTunnel(tunnelConfig: 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)
if(cursor != null) {
if (cursor != null) {
cursor.use {
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)
if(columnIndex == -1) {
if (columnIndex == -1) {
throw WgTunnelException("Cursor out of bounds")
}
return columnIndex
}
private fun getDisplayNameByCursor(cursor: Cursor) : String {
if(cursor.moveToFirst()) {
private fun getDisplayNameByCursor(cursor: Cursor): String {
if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
return cursor.getString(index)
} 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) {
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 {
return fileName.substring(0 , fileName.lastIndexOf('.') )
private fun getNameFromFileName(fileName: String): String {
return fileName.substring(0, fileName.lastIndexOf('.'))
}
private fun getFileExtensionFromFileName(fileName : String) : String {
private fun getFileExtensionFromFileName(fileName: String): String {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e : Exception) {
} catch (e: Exception) {
""
}
}
suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) {
if(selectedTunnel != null) {
_settings.emit(_settings.value.copy(
defaultTunnel = selectedTunnel.toString()
))
if (selectedTunnel != null) {
_settings.emit(
_settings.value.copy(
defaultTunnel = selectedTunnel.toString()
)
)
settingsRepo.save(_settings.value)
}
}
@@ -44,12 +44,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
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.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
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.ui.common.ClickableIconButton
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.util.StorageUtil
import kotlinx.coroutines.launch
import java.io.File
import kotlin.math.exp
@OptIn(
ExperimentalPermissionsApi::class,
ExperimentalLayoutApi::class
ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class
)
@Composable
fun SettingsScreen(
@@ -84,6 +91,7 @@ fun SettingsScreen(
val scope = rememberCoroutineScope()
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val interactionSource = remember { MutableInteractionSource() }
val settings by viewModel.settings.collectAsStateWithLifecycle()
@@ -94,11 +102,28 @@ fun SettingsScreen(
val scrollState = rememberScrollState()
var didShowLocationDisclaimer by remember { mutableStateOf(false) }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showAuthPrompt by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
val screenPadding = 5.dp
val fillMaxHeight = .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() {
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()) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -264,7 +303,11 @@ fun SettingsScreen(
value = currentText,
onValueChange = { currentText = it },
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,
keyboardOptions = KeyboardOptions(
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="github_url">https://github.com/zaneschepke/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="no_tunnels">No tunnels added yet!</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_value_description">Submit SSID</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="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="tunnel_edit">Tunnel Edit</string>
<string name="tunnel_name">Tunnel Name</string>
@@ -124,4 +124,7 @@
<string name="preshared_key">Pre-shared key</string>
<string name="seconds">seconds</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>
+9 -5
View File
@@ -1,8 +1,9 @@
[versions]
accompanist = "0.31.2-alpha"
activityCompose = "1.7.2"
activityCompose = "1.8.0"
androidx-junit = "1.1.5"
appcompat = "1.6.1"
biometricKtx = "1.2.0-alpha05"
coreKtx = "1.12.0"
espressoCore = "3.5.1"
firebase-crashlytics-gradle = "2.9.9"
@@ -12,18 +13,18 @@ hiltNavigationCompose = "1.0.0"
junit = "4.13.2"
kotlinx-serialization-json = "1.5.1"
lifecycle-runtime-compose = "2.6.2"
material-icons-extended = "1.5.2"
material-icons-extended = "1.5.3"
material3 = "1.1.2"
navigationCompose = "2.7.3"
navigationCompose = "2.7.4"
roomVersion = "2.6.0-rc01"
timber = "5.0.1"
tunnel = "1.0.20230706"
androidGradlePlugin = "8.3.0-alpha06"
kotlin="1.9.10"
ksp="1.9.10-1.0.13"
composeBom="2023.09.02"
composeBom="2023.10.00"
firebaseBom="32.3.1"
compose="1.5.2"
compose="1.5.3"
crashlytics="18.4.3"
analytics="21.3.0"
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" }
#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-ktx = { module = "androidx.room:room-ktx", 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
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
zipStorePath=wrapper/dists