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.
This commit is contained in:
parent
77cd328a71
commit
37bae82700
|
@ -14,8 +14,8 @@ android {
|
|||
applicationId = "com.zaneschepke.wireguardautotunnel"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 31300
|
||||
versionName = "3.1.3"
|
||||
versionCode = 31400
|
||||
versionName = "3.1.4"
|
||||
|
||||
multiDexEnabled = true
|
||||
|
||||
|
@ -128,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)
|
||||
|
@ -142,4 +145,8 @@ dependencies {
|
|||
//barcode scanning
|
||||
implementation(libs.zxing.android.embedded)
|
||||
implementation(libs.zxing.core)
|
||||
|
||||
//bio
|
||||
implementation(libs.androidx.biometric.ktx)
|
||||
|
||||
}
|
|
@ -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,7 +8,8 @@ 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 = "*/*"
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,6 +3,7 @@ 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
|
||||
|
@ -50,24 +51,26 @@ class WireGuardTunnelService : ForegroundService() {
|
|||
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
|
||||
cancelJob()
|
||||
job = lifecycleScope.launch(Dispatchers.IO) {
|
||||
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!!)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -141,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
|
||||
|
@ -27,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)
|
||||
|
@ -42,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) {
|
||||
|
@ -63,11 +63,6 @@ class ShortcutsActivity : ComponentActivity() {
|
|||
finish()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private suspend fun getSettings() : Settings {
|
||||
val settings = settingsRepo.getAll()
|
||||
return if (settings.isNotEmpty()) {
|
||||
|
|
|
@ -78,7 +78,6 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
|
|||
if(getState() == Tunnel.State.UP) {
|
||||
val state = backend.setState(this, Tunnel.State.DOWN, null)
|
||||
_state.emit(state)
|
||||
scope.cancel()
|
||||
}
|
||||
} catch (e : BackendException) {
|
||||
Timber.e("Failed to stop tunnel with error: ${e.message}")
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
@ -66,6 +67,7 @@ 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
|
||||
|
@ -77,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
|
||||
|
@ -109,6 +112,8 @@ 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()
|
||||
|
@ -117,18 +122,7 @@ fun ConfigScreen(
|
|||
|
||||
val keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
//focusManager.clearFocus()
|
||||
keyboardController?.hide()
|
||||
},
|
||||
onNext = {
|
||||
keyboardController?.hide()
|
||||
},
|
||||
onPrevious = {
|
||||
keyboardController?.hide()
|
||||
},
|
||||
onGo = {
|
||||
keyboardController?.hide(
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -156,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) }
|
||||
|
@ -399,10 +408,12 @@ fun ConfigScreen(
|
|||
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(focusRequester)
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = baseTextBoxModifier.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)
|
||||
},
|
||||
|
|
|
@ -285,7 +285,7 @@ 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)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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,6 +29,7 @@ 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
|
||||
|
||||
|
||||
|
@ -138,13 +137,6 @@ class MainViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
|
||||
|
@ -160,17 +152,41 @@ class MainViewModel @Inject constructor(
|
|||
?: throw WgTunnelException(application.getString(R.string.stream_failed))
|
||||
}
|
||||
|
||||
fun onTunnelFileSelected(uri: Uri) {
|
||||
suspend fun onTunnelFileSelected(uri: Uri) {
|
||||
try {
|
||||
val fileName = getFileName(application.applicationContext, uri)
|
||||
validateFileExtension(fileName)
|
||||
val stream = getInputStreamFromUri(uri)
|
||||
saveTunnelConfigFromStream(stream, fileName)
|
||||
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) {
|
||||
throw WgTunnelException(e.message ?: "Error importing file")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -69,8 +69,12 @@ 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,
|
||||
|
@ -98,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()) {
|
||||
|
@ -192,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,
|
||||
|
@ -363,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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -3,6 +3,7 @@ accompanist = "0.31.2-alpha"
|
|||
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"
|
||||
|
@ -40,6 +41,8 @@ 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" }
|
||||
|
|
Loading…
Reference in New Issue