diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d06fb277..c63ec921 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 = 31000 - versionName = "3.1.0" + versionCode = 31100 + versionName = "3.1.1" 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) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt index c3dd5f5e..509b25cb 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt @@ -12,5 +12,6 @@ object Constants { 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" } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt index cbaf9af1..8fc90b90 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt @@ -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 diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt index e6dcf4e6..00eb43d8 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt @@ -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] 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 62d9c9e8..918bc5e8 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.lifecycleScope import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa @@ -38,7 +39,7 @@ class WireGuardTunnelService : ForegroundService() { override fun onCreate() { super.onCreate() - CoroutineScope(Dispatchers.Main).launch { + lifecycleScope.launch(Dispatchers.Main) { launchVpnStartingNotification() } } @@ -48,7 +49,7 @@ class WireGuardTunnelService : ForegroundService() { launchVpnStartingNotification() val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) cancelJob() - job = CoroutineScope(Dispatchers.IO).launch { + job = lifecycleScope.launch(Dispatchers.IO) { if(tunnelConfigString != null) { try { val tunnelConfig = TunnelConfig.from(tunnelConfigString) @@ -70,32 +71,32 @@ class WireGuardTunnelService : ForegroundService() { } } } - } - 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 +106,7 @@ class WireGuardTunnelService : ForegroundService() { override fun stopService(extras : Bundle?) { super.stopService(extras) - CoroutineScope(Dispatchers.IO).launch { + lifecycleScope.launch(Dispatchers.IO) { vpnService.stopTunnel() } cancelJob() 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 11bf45d6..adc687ec 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 @@ -13,6 +13,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 @@ -62,6 +63,11 @@ 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/tile/TunnelControlTile.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/TunnelControlTile.kt index a3f3c0bf..0866f09e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/TunnelControlTile.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/TunnelControlTile.kt @@ -55,6 +55,11 @@ class TunnelControlTile : TileService() { cancelJob() } + override fun onDestroy() { + super.onDestroy() + scope.cancel() + } + override fun onClick() { super.onClick() unlockAndRun { 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 87116c7b..e7e70853 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 @@ -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 get() = _handshakeStatus.asSharedFlow() + private val scope = CoroutineScope(Dispatchers.IO); + private lateinit var statsJob : Job @@ -70,11 +73,12 @@ 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) _state.emit(state) + scope.cancel() } } catch (e : BackendException) { Timber.e("Failed to stop tunnel with error: ${e.message}") @@ -89,7 +93,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() var neverHadHandshakeCounter = 0 while (true) { @@ -128,4 +132,6 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend, _lastHandshake.tryEmit(emptyMap()) } } + + } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ViewState.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ViewState.kt deleted file mode 100644 index 0acdf53b..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ViewState.kt +++ /dev/null @@ -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 -) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt index ab03e6b9..c4e30eaf 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt @@ -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, ) } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationToggle.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationToggle.kt index eadebd10..99c26122 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationToggle.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationToggle.kt @@ -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 = { 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 64b583ac..de4c463b 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 @@ -60,7 +60,6 @@ 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 @@ -96,7 +95,6 @@ fun ConfigScreen( ) { val context = LocalContext.current - val focusManager = LocalFocusManager.current val scope = rememberCoroutineScope() val clipboardManager: ClipboardManager = LocalClipboardManager.current val keyboardController = LocalSoftwareKeyboardController.current @@ -111,11 +109,24 @@ fun ConfigScreen( val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle() val proxyInterface by viewModel.interfaceProxy.collectAsStateWithLifecycle() var showApplicationsDialog by remember { mutableStateOf(false) } + val baseTextBoxModifier = Modifier.onFocusChanged { + keyboardController?.hide() + } val keyboardActions = KeyboardActions( onDone = { - focusManager.clearFocus() + //focusManager.clearFocus() + keyboardController?.hide() + }, + onNext = { keyboardController?.hide() + }, + onPrevious = { + keyboardController?.hide() + }, + onGo = { + keyboardController?.hide( + ) } ) @@ -380,16 +391,13 @@ 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(), value = proxyInterface.privateKey, visualTransformation = PasswordVisualTransformation(), enabled = id == Constants.MANUAL_TUNNEL_CONFIG_ID, @@ -416,7 +424,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 +453,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 +552,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 +565,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 +592,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 -> 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 cfe9441a..a41d9a5f 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 @@ -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) @@ -263,32 +289,34 @@ fun MainScreen( 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) 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 30227b9b..d718d07b 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 @@ -35,10 +35,11 @@ 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 +61,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 +91,7 @@ class MainViewModel @Inject constructor(private val application : Application, } } - fun onTunnelStart(tunnelConfig : TunnelConfig) { + fun onTunnelStart(tunnelConfig: TunnelConfig) { viewModelScope.launch { stopActiveTunnel() startTunnel(tunnelConfig) @@ -96,8 +103,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 +117,35 @@ 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) { + private fun validateFileExtension(fileName: String) { val extension = getFileExtensionFromFileName(fileName) - if(extension != Constants.VALID_FILE_EXTENSION) { + 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 +160,28 @@ class MainViewModel @Inject constructor(private val application : Application, ?: throw WgTunnelException(application.getString(R.string.stream_failed)) } - fun onTunnelFileSelected(uri : Uri) { + fun onTunnelFileSelected(uri: Uri) { try { - viewModelScope.launch(Dispatchers.IO) { - val fileName = getFileName(application.applicationContext, uri) - validateFileExtension(fileName) - val stream = getInputStreamFromUri(uri) - saveTunnelConfigFromStream(stream, fileName) - } - } catch (e : Exception) { + val fileName = getFileName(application.applicationContext, uri) + validateFileExtension(fileName) + val stream = getInputStreamFromUri(uri) + saveTunnelConfigFromStream(stream, fileName) + } catch (e: Exception) { throw WgTunnelException(e.message ?: "Error importing file") } } - private suspend fun addTunnel(tunnelConfig: TunnelConfig) { + 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 +190,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 +207,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 +223,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) } } 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 c1e83d6d..2274bae7 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 @@ -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 @@ -71,7 +74,7 @@ import kotlinx.coroutines.launch @OptIn( ExperimentalPermissionsApi::class, - ExperimentalLayoutApi::class + ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class ) @Composable fun SettingsScreen( @@ -84,6 +87,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() @@ -264,7 +268,9 @@ 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 { + keyboardController?.hide() + }, maxLines = 1, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 633bc2c4..90299338 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -124,4 +124,7 @@ Pre-shared key seconds Persistent keepalive + Cancel + Primary tunnel change + Would you like to make this your primary tunnel? \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab14a366..3a040878 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] accompanist = "0.31.2-alpha" -activityCompose = "1.7.2" +activityCompose = "1.8.0" androidx-junit = "1.1.5" appcompat = "1.6.1" coreKtx = "1.12.0" @@ -12,18 +12,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 +40,7 @@ accompanist-permissions = { module = "com.google.accompanist:accompanist-permiss accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } #room +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" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9f665cf5..056788da 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,4 +1,4 @@ -#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-bin.zip