diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 74a44ee..3858032 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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) + } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 03404ee..bd35721 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,10 @@ + diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt index 509b25c..264130a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt @@ -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 = "*/*" diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/Extensions.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/Extensions.kt new file mode 100644 index 0000000..9e7efee --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/Extensions.kt @@ -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() + } + } +} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt index f9e140a..3d40cdd 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt @@ -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) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt index 8d85636..9748e7b 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt @@ -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() } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt index ebe4120..f9bb3e3 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt @@ -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() } } } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt index 918bc5e..44ca2fc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt @@ -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, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsActivity.kt index adc687e..872084d 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsActivity.kt @@ -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()) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt index e7e7085..32bac9c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt @@ -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}") diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt index ff9d8f8..2b9e0c5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt @@ -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 = { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/prompt/AuthorizationPrompt.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/prompt/AuthorizationPrompt.kt new file mode 100644 index 0000000..b90d0aa --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/prompt/AuthorizationPrompt.kt @@ -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) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/CustomSnackbar.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/prompt/CustomSnackbar.kt similarity index 97% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/CustomSnackbar.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/prompt/CustomSnackbar.kt index 24cdd83..ea95962 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/CustomSnackbar.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/prompt/CustomSnackbar.kt @@ -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 diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt index ff25946..713bab6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt @@ -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) }, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt index a41d9a5..593f842 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt @@ -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) ) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt index d718d07..577154c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt @@ -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) } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt index c106cba..bb0f2ec 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt @@ -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") + } + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/StorageUtil.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/StorageUtil.kt new file mode 100644 index 0000000..f01e3cc --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/StorageUtil.kt @@ -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) { + 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) } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9029933..34c4694 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,7 +8,7 @@ FOREGROUND_FILE https://github.com/zaneschepke/wgtunnel https://zaneschepke.github.io/wgtunnel/ - File is not a .conf file + File is not a .conf or .zip Turn off tunnel before editing No tunnels added yet! Tunnel name already exists @@ -38,9 +38,9 @@ Enter SSID Submit SSID [Interface] - Add tunnel from files + Add from file or zip File Open - Add tunnel from QR code + Add from QR code QR Scan Tunnel Edit Tunnel Name diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3a04087..0f9a6ff 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }