diff --git a/README.md b/README.md index 99a831b3..e1a667ce 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ This is an alternative Android Application for [WireGuard](https://www.wireguard

- +

diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3a14efdf..d06fb277 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 = 30003 - versionName = "3.0.3" + versionCode = 31000 + versionName = "3.1.0" multiDexEnabled = true diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index be059cb5..03404ee2 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -58,6 +58,8 @@ + + android:value="true" /> , wgQuick: String) : String { - if(packages.isEmpty()) { - return wgQuick - } - val clearedWgQuick = clearAllApplicationsFromConfig(wgQuick) - val excludeConfig = buildExcludedApplicationsString(packages) - return addApplicationsToConfig(excludeConfig, clearedWgQuick) - } - - fun setIncludedApplicationsOnQuick(packages : List, wgQuick: String) : String { - if(packages.isEmpty()) { - return wgQuick - } - val clearedWgQuick = clearAllApplicationsFromConfig(wgQuick) - val includeConfig = buildIncludedApplicationsString(packages) - return addApplicationsToConfig(includeConfig, clearedWgQuick) - } - - private fun buildExcludedApplicationsString(packages : List) : String { - return EXCLUDED_APPLICATIONS + packages.joinToString(APP_CONFIG_SEPARATOR) - } - - private fun buildIncludedApplicationsString(packages : List) : String { - return INCLUDED_APPLICATIONS + packages.joinToString(APP_CONFIG_SEPARATOR) - } fun from(string : String) : TunnelConfig { return Json.decodeFromString(string) } 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 bb5cc307..11bf45d6 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 @@ -1,52 +1,76 @@ package com.zaneschepke.wireguardautotunnel.service.shortcut import android.os.Bundle -import androidx.appcompat.app.AppCompatActivity -import com.zaneschepke.wireguardautotunnel.R +import androidx.activity.ComponentActivity import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa +import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao +import com.zaneschepke.wireguardautotunnel.repository.model.Settings +import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.foreground.Action import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService +import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import timber.log.Timber import javax.inject.Inject @AndroidEntryPoint -class ShortcutsActivity : AppCompatActivity() { +class ShortcutsActivity : ComponentActivity() { @Inject lateinit var settingsRepo : SettingsDoa + @Inject + lateinit var tunnelConfigRepo : TunnelConfigDao + private val scope = CoroutineScope(Dispatchers.Main); private fun attemptWatcherServiceToggle(tunnelConfig : String) { scope.launch { - val settings = settingsRepo.getAll() - if (settings.isNotEmpty()) { - val setting = settings.first() - if(setting.isAutoTunnelEnabled) { - ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig) - } + val settings = getSettings() + if(settings.isAutoTunnelEnabled) { + ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig) } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if(intent.getStringExtra(ShortcutsManager.CLASS_NAME_EXTRA_KEY) - .equals(WireGuardTunnelService::class.java.name)) { - - intent.getStringExtra(getString(R.string.tunnel_extras_key))?.let { - attemptWatcherServiceToggle(it) - } - when(intent.action){ - Action.STOP.name -> ServiceManager.stopVpnService(this) - Action.START.name -> intent.getStringExtra(getString(R.string.tunnel_extras_key)) - ?.let { ServiceManager.startVpnService(this, it) } + if(intent.getStringExtra(CLASS_NAME_EXTRA_KEY) + .equals(WireGuardTunnelService::class.java.simpleName)) { + scope.launch { + try { + val settings = getSettings() + val tunnelConfig = if(settings.defaultTunnel == null) { + tunnelConfigRepo.getAll().first() + } else { + TunnelConfig.from(settings.defaultTunnel!!) + } + attemptWatcherServiceToggle(tunnelConfig.toString()) + when(intent.action){ + Action.STOP.name -> ServiceManager.stopVpnService(this@ShortcutsActivity) + Action.START.name -> ServiceManager.startVpnService(this@ShortcutsActivity, tunnelConfig.toString()) + } + } catch (e : Exception) { + Timber.e(e.message) + } } } finish() } + + private suspend fun getSettings() : Settings { + val settings = settingsRepo.getAll() + return if (settings.isNotEmpty()) { + settings.first() + } else { + throw WgTunnelException("Settings empty") + } + } + companion object { + const val CLASS_NAME_EXTRA_KEY = "className" + } } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsManager.kt deleted file mode 100644 index 3b02d192..00000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsManager.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.service.shortcut - -import android.content.Context -import android.content.Intent -import androidx.core.content.pm.ShortcutInfoCompat -import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.graphics.drawable.IconCompat -import com.zaneschepke.wireguardautotunnel.R -import com.zaneschepke.wireguardautotunnel.service.foreground.Action -import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig - -object ShortcutsManager { - - private const val SHORT_LABEL_MAX_SIZE = 10; - private const val LONG_LABEL_MAX_SIZE = 25; - private const val APPEND_ON = " On"; - private const val APPEND_OFF = " Off" - const val CLASS_NAME_EXTRA_KEY = "className" - - private fun createAndPushShortcut(context : Context, intent : Intent, id : String, shortLabel : String, - longLabel : String, drawable : Int ) { - val shortcut = ShortcutInfoCompat.Builder(context, id) - .setShortLabel(shortLabel) - .setLongLabel(longLabel) - .setIcon(IconCompat.createWithResource(context, drawable)) - .setIntent(intent) - .build() - ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) - } - - fun createTunnelShortcuts(context : Context, tunnelConfig : TunnelConfig) { - createAndPushShortcut(context, - createTunnelOnIntent(context, mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig.toString())), - tunnelConfig.id.toString() + APPEND_ON, - tunnelConfig.name.take((SHORT_LABEL_MAX_SIZE - APPEND_ON.length)) + APPEND_ON, - tunnelConfig.name.take((LONG_LABEL_MAX_SIZE - APPEND_ON.length)) + APPEND_ON, - R.drawable.vpn_on - ) - createAndPushShortcut(context, - createTunnelOffIntent(context, mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig.toString())), - tunnelConfig.id.toString() + APPEND_OFF, - tunnelConfig.name.take((SHORT_LABEL_MAX_SIZE - APPEND_OFF.length)) + APPEND_OFF, - tunnelConfig.name.take((LONG_LABEL_MAX_SIZE - APPEND_OFF.length)) + APPEND_OFF, - R.drawable.vpn_off - ) - } - - fun removeTunnelShortcuts(context : Context, tunnelConfig : TunnelConfig?) { - if(tunnelConfig != null) { - ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(tunnelConfig.id.toString() + APPEND_ON, - tunnelConfig.id.toString() + APPEND_OFF )) - } - } - - private fun createTunnelOnIntent(context: Context, extras : Map) : Intent { - return Intent(context, ShortcutsActivity::class.java).also { - it.action = Action.START.name - it.putExtra(CLASS_NAME_EXTRA_KEY, WireGuardTunnelService::class.java.name) - extras.forEach {(k, v) -> - it.putExtra(k, v) - } - } - } - - private fun createTunnelOffIntent(context : Context, extras : Map) : Intent { - return Intent(context, ShortcutsActivity::class.java).also { - it.action = Action.STOP.name - it.putExtra(CLASS_NAME_EXTRA_KEY, WireGuardTunnelService::class.java.name) - extras.forEach {(k, v) -> - it.putExtra(k, v) - } - } - } -} \ No newline at end of file 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 548355ae..ff9d8f82 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt @@ -15,9 +15,14 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInHorizontally +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarData +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -26,6 +31,7 @@ import androidx.compose.runtime.setValue 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 com.google.accompanist.navigation.animation.AnimatedNavHost import com.google.accompanist.navigation.animation.composable import com.google.accompanist.navigation.animation.rememberAnimatedNavController @@ -35,6 +41,7 @@ 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.screens.config.ConfigScreen @@ -44,10 +51,11 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme -import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import timber.log.Timber -import java.lang.IllegalStateException @AndroidEntryPoint class MainActivity : AppCompatActivity() { @@ -90,7 +98,29 @@ class MainActivity : AppCompatActivity() { } else requestNotificationPermission() } - Scaffold(snackbarHost = { SnackbarHost(snackbarHostState)}, + fun showSnackBarMessage(message : String) { + CoroutineScope(Dispatchers.Main).launch { + val result = snackbarHostState.showSnackbar( + message = message, + actionLabel = "Okay", + duration = SnackbarDuration.Short, + ) + when (result) { + SnackbarResult.ActionPerformed -> { snackbarHostState.currentSnackbarData?.dismiss() } + SnackbarResult.Dismissed -> { snackbarHostState.currentSnackbarData?.dismiss() } + } + } + } + + Scaffold(snackbarHost = { + SnackbarHost(snackbarHostState) { snackbarData: SnackbarData -> + CustomSnackBar( + snackbarData.visuals.message, + isRtl = false, + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) + ) + } + }, modifier = Modifier.onKeyEvent { if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { when (it.nativeKeyEvent.keyCode) { @@ -140,6 +170,7 @@ class MainActivity : AppCompatActivity() { ) return@Scaffold } + AnimatedNavHost(navController, startDestination = Routes.Main.name) { composable(Routes.Main.name, enterTransition = { when (initialState.destination.route) { @@ -154,7 +185,7 @@ class MainActivity : AppCompatActivity() { } } }) { - MainScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController) + MainScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, navController = navController) } composable(Routes.Settings.name, enterTransition = { when (initialState.destination.route) { @@ -175,7 +206,7 @@ class MainActivity : AppCompatActivity() { fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) } } - }) { SettingsScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController, focusRequester = focusRequester) } + }) { SettingsScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester) } composable(Routes.Support.name, enterTransition = { when (initialState.destination.route) { Routes.Settings.name, Routes.Main.name -> @@ -191,17 +222,17 @@ class MainActivity : AppCompatActivity() { }) { SupportScreen(padding = padding, focusRequester) } composable("${Routes.Config.name}/{id}", enterTransition = { fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) - }) { + }) { it -> val id = it.arguments?.getString("id") if(!id.isNullOrBlank()) { - ConfigScreen(padding = padding, navController = navController, id = id, focusRequester = focusRequester)} + ConfigScreen(navController = navController, id = id, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester)} } composable("${Routes.Detail.name}/{id}", enterTransition = { fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) }) { val id = it.arguments?.getString("id") if(!id.isNullOrBlank()) { - DetailScreen(padding = padding, id = id) + DetailScreen(padding = padding, focusRequester = focusRequester, id = id) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt index 6644796e..3378aef5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt @@ -3,24 +3,26 @@ package com.zaneschepke.wireguardautotunnel.ui.common import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import com.zaneschepke.wireguardautotunnel.R @Composable fun ClickableIconButton(onIconClick : () -> Unit, text : String, icon : ImageVector, enabled : Boolean) { - Button(onClick = {}, + TextButton(onClick = {}, enabled = enabled ) { Text(text) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) Icon( imageVector = icon, - contentDescription = "Delete", + contentDescription = stringResource(R.string.delete), modifier = Modifier.size(ButtonDefaults.IconSize).clickable { if(enabled) { onIconClick() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/CustomSnackbar.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/CustomSnackbar.kt new file mode 100644 index 00000000..24cdd839 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/CustomSnackbar.kt @@ -0,0 +1,58 @@ +package com.zaneschepke.wireguardautotunnel.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel + +@Composable +fun CustomSnackBar( + message: String, + isRtl: Boolean = true, + containerColor: Color = MaterialTheme.colorScheme.surface +) { + val context = LocalContext.current + Snackbar(containerColor = containerColor, + modifier = Modifier.fillMaxWidth( + if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1/3f else 2/3f).padding(bottom = 100.dp), + shape = RoundedCornerShape(16.dp) + ) { + CompositionLocalProvider( + LocalLayoutDirection provides + if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr + ) { + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Icon( + Icons.Rounded.Info, + contentDescription = stringResource(R.string.info), + tint = Color.White + ) + Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt index 2a9ce58d..f254a36f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt @@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp @OptIn(ExperimentalFoundationApi::class) @Composable -fun RowListItem(leadingIcon : ImageVector? = null, leadingIconColor : Color = Color.Gray, text : String, onHold : () -> Unit, onClick: () -> Unit, rowButton : @Composable() () -> Unit ) { +fun RowListItem(icon : @Composable() () -> Unit, text : String, onHold : () -> Unit, onClick: () -> Unit, rowButton : @Composable() () -> Unit ) { Box( modifier = Modifier .combinedClickable( @@ -39,13 +39,7 @@ fun RowListItem(leadingIcon : ImageVector? = null, leadingIconColor : Color = Co horizontalArrangement = Arrangement.SpaceBetween ) { Row(verticalAlignment = Alignment.CenterVertically,) { - if(leadingIcon != null) { - Icon( - leadingIcon, "status", - tint = leadingIconColor, - modifier = Modifier.padding(end = 10.dp).size(15.dp) - ) - } + icon() Text(text) } 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 new file mode 100644 index 00000000..ab03e6b9 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationTextBox.kt @@ -0,0 +1,38 @@ +package com.zaneschepke.wireguardautotunnel.ui.common.config + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization + +@Composable +fun + ConfigurationTextBox(value : String, hint : String, onValueChange : (String) -> Unit, label : String, onDone : () -> Unit, modifier: Modifier) { + OutlinedTextField( + modifier = modifier, + value = value, + singleLine = true, + onValueChange = { + onValueChange(it) + }, + label = { Text(label) }, + maxLines = 1, + placeholder = { + Text(hint) + }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + onDone() + } + ), + ) +} \ 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 new file mode 100644 index 00000000..eadebd10 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/config/ConfigurationToggle.kt @@ -0,0 +1,34 @@ +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 +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp + +@Composable +fun ConfigurationToggle(label : String, enabled : Boolean, checked : Boolean, padding : Dp, + onCheckChanged : () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(padding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(label) + Switch( + enabled = enabled, + checked = checked, + onCheckedChange = { + onCheckChanged() + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/text/SectionTitle.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/text/SectionTitle.kt new file mode 100644 index 00000000..721cf773 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/text/SectionTitle.kt @@ -0,0 +1,22 @@ +package com.zaneschepke.wireguardautotunnel.ui.common.text + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun SectionTitle(title : String, padding : Dp) { + Text( + title, + textAlign = TextAlign.Center, + style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.ExtraBold), + modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/models/InterfaceProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/models/InterfaceProxy.kt new file mode 100644 index 00000000..47eab5b8 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/models/InterfaceProxy.kt @@ -0,0 +1,26 @@ +package com.zaneschepke.wireguardautotunnel.ui.models + +import com.wireguard.config.Interface +import com.wireguard.config.Peer + +data class InterfaceProxy( + var privateKey : String = "", + var publicKey : String = "", + var addresses : String = "", + var dnsServers : String = "", + var listenPort : String = "", + var mtu : String = "", +){ + companion object { + fun from(i : Interface) : InterfaceProxy { + return InterfaceProxy( + publicKey = i.keyPair.publicKey.toBase64(), + privateKey = i.keyPair.privateKey.toBase64(), + addresses = i.addresses.joinToString(","), + dnsServers = i.dnsServers.joinToString(",").replace("/", ""), + listenPort = if(i.listenPort.isPresent) i.listenPort.get().toString() else "", + mtu = if(i.mtu.isPresent) i.mtu.get().toString() else "" + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/models/PeerProxy.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/models/PeerProxy.kt new file mode 100644 index 00000000..2bb6abb3 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/models/PeerProxy.kt @@ -0,0 +1,32 @@ +package com.zaneschepke.wireguardautotunnel.ui.models + +import com.wireguard.config.Peer + +data class PeerProxy( + var publicKey : String = "", + var preSharedKey : String = "", + var persistentKeepalive : String = "", + var endpoint : String = "", + var allowedIps: String = IPV4_WILDCARD.joinToString(",") +){ + companion object { + fun from(peer : Peer) : PeerProxy { + return PeerProxy( + publicKey = peer.publicKey.toBase64(), + preSharedKey = if(peer.preSharedKey.isPresent) peer.preSharedKey.get().toString() else "", + persistentKeepalive = if(peer.persistentKeepalive.isPresent) peer.persistentKeepalive.get().toString() else "", + endpoint = if(peer.endpoint.isPresent) peer.endpoint.get().toString() else "", + allowedIps = peer.allowedIps.joinToString(",") + ) + } + val IPV4_PUBLIC_NETWORKS = setOf( + "0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3", + "64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12", + "172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7", + "176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16", + "192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10", + "193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4" + ) + val IPV4_WILDCARD = setOf("0.0.0.0/0") + } +} \ No newline at end of file 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 ccb75b6d..64b583ac 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 @@ -1,142 +1,190 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.config -import android.widget.Toast +import android.annotation.SuppressLint +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Android -import androidx.compose.material3.Button +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.Save +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf 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.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import com.google.accompanist.drawablepainter.DrawablePainter +import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.R +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.text.SectionTitle import kotlinx.coroutines.launch +import timber.log.Timber -@OptIn(ExperimentalComposeUiApi::class) +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class, + ExperimentalFoundationApi::class +) @Composable fun ConfigScreen( viewModel: ConfigViewModel = hiltViewModel(), - padding: PaddingValues, focusRequester: FocusRequester, navController: NavController, - id : String + showSnackbarMessage: (String) -> Unit, + id: String ) { val context = LocalContext.current val focusManager = LocalFocusManager.current - - val keyboardController = LocalSoftwareKeyboardController.current val scope = rememberCoroutineScope() + val clipboardManager: ClipboardManager = LocalClipboardManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null) val tunnelName = viewModel.tunnelName.collectAsStateWithLifecycle() val packages by viewModel.packages.collectAsStateWithLifecycle() val checkedPackages by viewModel.checkedPackages.collectAsStateWithLifecycle() val include by viewModel.include.collectAsStateWithLifecycle() - val allApplications by viewModel.allApplications.collectAsStateWithLifecycle() - val sortedPackages = remember(packages) { - packages.sortedBy { viewModel.getPackageLabel(it) } - } + val isAllApplicationsEnabled by viewModel.isAllApplicationsEnabled.collectAsStateWithLifecycle() + val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle() + val proxyInterface by viewModel.interfaceProxy.collectAsStateWithLifecycle() + var showApplicationsDialog by remember { mutableStateOf(false) } + + val keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + } + ) + + val keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done + ) + + val fillMaxHeight = .85f + val fillMaxWidth = .85f + val screenPadding = 5.dp LaunchedEffect(Unit) { - viewModel.emitScreenData(id) + try { + viewModel.onScreenLoad(id) + } catch (e : Exception) { + showSnackbarMessage(e.message!!) + navController.navigate(Routes.Main.name) + } } - if(tunnel != null) { - LazyColumn( - horizontalAlignment = Alignment.Start, - verticalArrangement = Arrangement.Top, - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + val applicationButtonText = { + "Tunneling apps: " + + if (isAllApplicationsEnabled) "all" + else "${checkedPackages.size} " + (if (include) "included" else "excluded") + + } + if (showApplicationsDialog) { + val sortedPackages = remember(packages) { + packages.sortedBy { viewModel.getPackageLabel(it) } + } + AlertDialog(onDismissRequest = { + showApplicationsDialog = false + }) { + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(if (isAllApplicationsEnabled) 1 / 5f else 4 / 5f) + ) { + Column( + modifier = Modifier.fillMaxWidth() ) { - OutlinedTextField( - modifier = Modifier.focusRequester(focusRequester), - value = tunnelName.value, - onValueChange = { - viewModel.onTunnelNameChange(it) - }, - label = { Text(stringResource(id = R.string.tunnel_name)) }, - maxLines = 1, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - focusManager.clearFocus() - keyboardController?.hide() - viewModel.onTunnelNameChange(tunnelName.value) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource(id = R.string.tunnel_all)) + Switch( + checked = isAllApplicationsEnabled, + onCheckedChange = { + viewModel.onAllApplicationsChange(it) } - ), - ) - } - } - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 7.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(stringResource(id = R.string.tunnel_all)) - Switch( - checked = allApplications, - onCheckedChange = { - viewModel.onAllApplicationsChange(!allApplications) - } - ) - } - } - if (!allApplications) { - item { + ) + } + if (!isAllApplicationsEnabled) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 7.dp), + .padding( + horizontal = 20.dp, + vertical = 7.dp + ), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -165,77 +213,446 @@ fun ConfigScreen( ) } } - } - item { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 7.dp), + .padding( + horizontal = 20.dp, + vertical = 7.dp + ), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween) { + horizontalArrangement = Arrangement.SpaceBetween + ) { SearchBar(viewModel::emitQueriedPackages); } + Spacer(Modifier.padding(5.dp)) + LazyColumn( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier + .fillMaxHeight(4 / 5f) + ) { + items( + sortedPackages, + key = { it.packageName }) { pack -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxSize() + .padding(5.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth( + fillMaxWidth + ) + ) { + val drawable = + pack.applicationInfo?.loadIcon( + context.packageManager + ) + if (drawable != null) { + Image( + painter = DrawablePainter( + drawable + ), + stringResource(id = R.string.icon), + modifier = Modifier.size( + 50.dp, + 50.dp + ) + ) + } else { + Icon( + Icons.Rounded.Android, + stringResource(id = R.string.edit), + modifier = Modifier.size( + 50.dp, + 50.dp + ) + ) + } + Text( + viewModel.getPackageLabel(pack), + modifier = Modifier.padding(5.dp) + ) + } + Checkbox( + modifier = Modifier.fillMaxSize(), + checked = (checkedPackages.contains(pack.packageName)), + onCheckedChange = { + if (it) viewModel.onAddCheckedPackage( + pack.packageName + ) else viewModel.onRemoveCheckedPackage( + pack.packageName + ) + } + ) + } + } + } } - items(sortedPackages, key = { it.packageName }) { pack -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .padding(top = 5.dp), + horizontalArrangement = Arrangement.Center + ) { + TextButton( + onClick = { + showApplicationsDialog = false + }) { + Text(stringResource(R.string.done)) + } + } + } + } + } + } + + + if (tunnel != null) { + Scaffold( + floatingActionButtonPosition = FabPosition.End, + floatingActionButton = { + val secondaryColor = MaterialTheme.colorScheme.secondary + val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) + var fobColor by remember { mutableStateOf(secondaryColor) } + FloatingActionButton( + modifier = Modifier.padding(bottom = 90.dp).onFocusChanged { + if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { + fobColor = if (it.isFocused) hoverColor else secondaryColor } + }, + onClick = { + scope.launch { + try { + viewModel.onSaveAllChanges() + navController.navigate(Routes.Main.name) + showSnackbarMessage(context.resources.getString(R.string.config_changes_saved)) + } catch (e : Exception) { + Timber.e(e.message) + showSnackbarMessage(e.message!!) + } + } + }, + containerColor = fobColor, + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Rounded.Save, + contentDescription = stringResource(id = R.string.save_changes), + tint = Color.DarkGray, + ) + } + }) { + Column { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + modifier = Modifier + .verticalScroll(rememberScrollState()) + .weight(1f, true) + .fillMaxSize() + ) { + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) + Modifier + .fillMaxHeight(fillMaxHeight) + .fillMaxWidth(fillMaxWidth) + else Modifier.fillMaxWidth(fillMaxWidth)).padding( + top = 50.dp, + bottom = 10.dp + ) + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.padding(15.dp).focusGroup() ) { + SectionTitle(stringResource(R.string.interface_), padding = screenPadding) + ConfigurationTextBox( + value = tunnelName.value, + onValueChange = { value -> + viewModel.onTunnelNameChange(value) + }, + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + }, + label = stringResource(R.string.name), + hint = stringResource(R.string.tunnel_name).lowercase(), + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester) + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = proxyInterface.privateKey, + visualTransformation = PasswordVisualTransformation(), + enabled = id == Constants.MANUAL_TUNNEL_CONFIG_ID, + onValueChange = { value -> + viewModel.onPrivateKeyChange(value) + }, + trailingIcon = { + IconButton( + modifier = Modifier.focusRequester(FocusRequester.Default), + onClick = { + viewModel.generateKeyPair() + }) { + Icon( + Icons.Rounded.Refresh, + stringResource(R.string.rotate_keys), + tint = Color.White + ) + } + }, + label = { Text(stringResource(R.string.private_key)) }, + singleLine = true, + placeholder = { Text(stringResource(R.string.base64_key)) }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth().focusRequester(FocusRequester.Default), + value = proxyInterface.publicKey, + enabled = false, + onValueChange = {}, + trailingIcon = { + IconButton( + modifier = Modifier.focusRequester(FocusRequester.Default), + onClick = { + clipboardManager.setText(AnnotatedString(proxyInterface.publicKey)) + }) { + Icon( + Icons.Rounded.ContentCopy, + stringResource(R.string.copy_public_key), + tint = Color.White + ) + } + }, + label = { Text(stringResource(R.string.public_key)) }, + singleLine = true, + placeholder = { Text(stringResource(R.string.base64_key)) }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions + ) + Row(modifier = Modifier.fillMaxWidth()) { + ConfigurationTextBox( + value = proxyInterface.addresses, + onValueChange = { value -> + viewModel.onAddressesChanged(value) + }, + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + }, + label = stringResource(R.string.addresses), + hint = stringResource(R.string.comma_separated_list), + modifier = Modifier + .fillMaxWidth(3 / 5f) + .padding(end = 5.dp) + ) + ConfigurationTextBox( + value = proxyInterface.listenPort, + onValueChange = { value -> viewModel.onListenPortChanged(value) }, + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + }, + label = stringResource(R.string.listen_port), + hint = stringResource(R.string.random), + modifier = Modifier.width(IntrinsicSize.Min) + ) + } + Row(modifier = Modifier.fillMaxWidth()) { + ConfigurationTextBox( + value = proxyInterface.dnsServers, + onValueChange = { value -> viewModel.onDnsServersChanged(value) }, + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + }, + label = stringResource(R.string.dns_servers), + hint = stringResource(R.string.comma_separated_list), + modifier = Modifier + .fillMaxWidth(3 / 5f) + .padding(end = 5.dp) + ) + ConfigurationTextBox( + value = proxyInterface.mtu, + onValueChange = { value -> viewModel.onMtuChanged(value) }, + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + }, + label = stringResource(R.string.mtu), + hint = stringResource(R.string.auto), + modifier = Modifier.width(IntrinsicSize.Min) + ) + } Row( - horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(5.dp) + modifier = Modifier + .fillMaxSize() + .padding(top = 5.dp), + horizontalArrangement = Arrangement.Center ) { - val drawable = - pack.applicationInfo?.loadIcon(context.packageManager) - if (drawable != null) { - Image( - painter = DrawablePainter(drawable), - stringResource(id = R.string.icon), - modifier = Modifier.size(50.dp, 50.dp) - ) - } else { - Icon( - Icons.Rounded.Android, - stringResource(id = R.string.edit), - modifier = Modifier.size(50.dp, 50.dp) - ) + TextButton( + onClick = { + showApplicationsDialog = true + }) { + Text(applicationButtonText()) } - Text( - viewModel.getPackageLabel(pack), modifier = Modifier.padding(5.dp) - ) } - Checkbox( - checked = (checkedPackages.contains(pack.packageName)), - onCheckedChange = { - if (it) viewModel.onAddCheckedPackage(pack.packageName) else viewModel.onRemoveCheckedPackage( - pack.packageName - ) - } + } + } + proxyPeers.forEachIndexed { index, peer -> + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) + Modifier + .fillMaxHeight(fillMaxHeight) + .fillMaxWidth(fillMaxWidth) + else Modifier.fillMaxWidth(fillMaxWidth)).padding( + top = 10.dp, + bottom = 10.dp ) + ) { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier + .padding(horizontal = 15.dp) + .padding(bottom = 10.dp) + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 5.dp) + ) { + SectionTitle(stringResource(R.string.peer), padding = screenPadding) + IconButton( + onClick = { + viewModel.onDeletePeer(index) + } + ) { + Icon(Icons.Rounded.Delete, stringResource(R.string.delete)) + } + } + + ConfigurationTextBox( + value = peer.publicKey, + onValueChange = { value -> + viewModel.onPeerPublicKeyChange( + index, + value + ) + }, + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + }, + label = stringResource(R.string.public_key), + hint = stringResource(R.string.base64_key), + modifier = Modifier.fillMaxWidth() + ) + ConfigurationTextBox( + value = peer.preSharedKey, + onValueChange = { value -> + viewModel.onPreSharedKeyChange( + index, + value + ) + }, + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + }, + label = stringResource(R.string.preshared_key), + hint = stringResource(R.string.optional), + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = peer.persistentKeepalive, + enabled = true, + onValueChange = { value -> + viewModel.onPersistentKeepaliveChanged(index, value) + }, + trailingIcon = { Text(stringResource(R.string.seconds), modifier = Modifier.padding(end = 10.dp)) }, + label = { Text(stringResource(R.string.persistent_keepalive)) }, + singleLine = true, + placeholder = { Text(stringResource(R.string.optional_no_recommend)) }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions + ) + ConfigurationTextBox( + value = peer.endpoint, + onValueChange = { value -> + viewModel.onEndpointChange( + index, + value + ) + }, + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + }, + label = stringResource(R.string.endpoint), + hint = stringResource(R.string.endpoint).lowercase(), + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = peer.allowedIps, + enabled = true, + onValueChange = { value -> + viewModel.onAllowedIpsChange( + index, + value + ) + }, + label = { Text(stringResource(R.string.allowed_ips)) }, + singleLine = true, + placeholder = { Text(stringResource(R.string.comma_separated_list)) }, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions + ) + } } } - } - item { - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth() - ) { - Button(onClick = { - scope.launch { - viewModel.onSaveAllChanges() - Toast.makeText( - context, - context.resources.getString(R.string.config_changes_saved), - Toast.LENGTH_LONG - ).show() - navController.navigate(Routes.Main.name) + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .padding(bottom = 140.dp) + ) { + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + TextButton( + onClick = { + viewModel.addEmptyPeer() + }) { + Text(stringResource(R.string.add_peer)) + } } - }, Modifier.padding(25.dp)) { - Text(stringResource(id = R.string.save_changes)) } } + if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { + Spacer(modifier = Modifier.weight(.17f)) + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt index 770b47e7..5d68c942 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt @@ -10,28 +10,43 @@ import androidx.compose.runtime.toMutableStateList import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.wireguard.config.Config +import com.wireguard.config.Interface +import com.wireguard.config.Peer +import com.wireguard.crypto.Key +import com.wireguard.crypto.KeyPair +import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao -import com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsManager import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig +import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy +import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy +import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -import timber.log.Timber +import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel class ConfigViewModel @Inject constructor(private val application : Application, private val tunnelRepo : TunnelConfigDao, - private val settingsRepo : SettingsDoa) : ViewModel() { + private val settingsRepo : SettingsDoa +) : ViewModel() { private val _tunnel = MutableStateFlow(null) private val _tunnelName = MutableStateFlow("") val tunnelName get() = _tunnelName.asStateFlow() val tunnel get() = _tunnel.asStateFlow() + + private var _proxyPeers = MutableStateFlow(mutableStateListOf()) + val proxyPeers get() = _proxyPeers.asStateFlow() + + private var _interface = MutableStateFlow(InterfaceProxy()) + val interfaceProxy = _interface.asStateFlow() + private val _packages = MutableStateFlow(emptyList()) val packages get() = _packages.asStateFlow() private val packageManager = application.packageManager @@ -41,38 +56,91 @@ class ConfigViewModel @Inject constructor(private val application : Application, private val _include = MutableStateFlow(true) val include get() = _include.asStateFlow() - private val _allApplications = MutableStateFlow(true) - val allApplications get() = _allApplications.asStateFlow() + private val _isAllApplicationsEnabled = MutableStateFlow(false) + val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow() + private val _isDefaultTunnel = MutableStateFlow(false) + val isDefaultTunnel = _isDefaultTunnel.asStateFlow() - fun emitScreenData(id : String) { - viewModelScope.launch(Dispatchers.IO) { - val tunnelConfig = getTunnelConfigById(id); - emitTunnelConfig(tunnelConfig); - emitTunnelConfigName(tunnelConfig?.name) + private lateinit var tunnelConfig: TunnelConfig + + fun onScreenLoad(id : String) { + if(id != Constants.MANUAL_TUNNEL_CONFIG_ID) { + viewModelScope.launch(Dispatchers.IO) { + tunnelConfig = withContext(this.coroutineContext) { + getTunnelConfigById(id) ?: throw WgTunnelException("Config not found") + } + emitScreenData() + } + } else { + emitEmptyScreenData() + } + } + + private fun emitEmptyScreenData() { + tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = "") + viewModelScope.launch { + emitTunnelConfig() + emitPeerProxy(PeerProxy()) + emitInterfaceProxy(InterfaceProxy()) + emitTunnelConfigName() + emitDefaultTunnelStatus() emitQueriedPackages("") - emitCurrentPackageConfigurations(id) + emitTunnelAllApplicationsEnabled() + } + } + + + private suspend fun emitScreenData() { + emitTunnelConfig() + emitPeersFromConfig() + emitInterfaceFromConfig() + emitTunnelConfigName() + emitDefaultTunnelStatus() + emitQueriedPackages("") + emitCurrentPackageConfigurations() + } + + private suspend fun emitDefaultTunnelStatus() { + val settings = settingsRepo.getAll() + if(settings.isNotEmpty()) { + _isDefaultTunnel.value = settings.first().isTunnelConfigDefault(tunnelConfig) } } + private fun emitInterfaceFromConfig() { + val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) + _interface.value = InterfaceProxy.from(config.`interface`) + } + + private fun emitPeersFromConfig() { + val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) + config.peers.forEach{ + _proxyPeers.value.add(PeerProxy.from(it)) + } + } + + private fun emitPeerProxy(peerProxy: PeerProxy) { + _proxyPeers.value.add(peerProxy) + } + + private fun emitInterfaceProxy(interfaceProxy: InterfaceProxy) { + _interface.value = interfaceProxy + } + private suspend fun getTunnelConfigById(id : String) : TunnelConfig? { return try { tunnelRepo.getById(id.toLong()) - } catch (e : Exception) { - Timber.e(e.message) + } catch (_ : Exception) { null } } - private suspend fun emitTunnelConfig(tunnelConfig: TunnelConfig?) { - if(tunnelConfig != null) { - _tunnel.emit(tunnelConfig) - } + private suspend fun emitTunnelConfig() { + _tunnel.emit(tunnelConfig) } - private suspend fun emitTunnelConfigName(name : String?) { - if(name != null) { - _tunnelName.emit(name) - } + private suspend fun emitTunnelConfigName() { + _tunnelName.emit(tunnelConfig.name) } fun onTunnelNameChange(name : String) { @@ -86,8 +154,8 @@ class ConfigViewModel @Inject constructor(private val application : Application, _checkedPackages.value.add(packageName) } - fun onAllApplicationsChange(allApplications : Boolean) { - _allApplications.value = allApplications + fun onAllApplicationsChange(isAllApplicationsEnabled : Boolean) { + _isAllApplicationsEnabled.value = isAllApplicationsEnabled } fun onRemoveCheckedPackage(packageName : String) { @@ -128,20 +196,17 @@ class ConfigViewModel @Inject constructor(private val application : Application, } private suspend fun emitTunnelAllApplicationsEnabled() { - _allApplications.emit(true) + _isAllApplicationsEnabled.emit(true) } private suspend fun emitTunnelAllApplicationsDisabled() { - _allApplications.emit(false) + _isAllApplicationsEnabled.emit(false) } - private fun emitCurrentPackageConfigurations(id : String) { + private fun emitCurrentPackageConfigurations() { viewModelScope.launch(Dispatchers.IO) { - val tunnelConfig = getTunnelConfigById(id) - if (tunnelConfig != null) { - val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) - emitSplitTunnelConfiguration(config) - } + val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) + emitSplitTunnelConfiguration(config) } } @@ -171,44 +236,20 @@ class ConfigViewModel @Inject constructor(private val application : Application, } } - private fun removeTunnelShortcuts(tunnelConfig: TunnelConfig?) { - if(tunnelConfig != null) { - ShortcutsManager.removeTunnelShortcuts(application, tunnelConfig) - } - - } - private fun isAllApplicationsEnabled() : Boolean { - return _allApplications.value + return _isAllApplicationsEnabled.value } private fun isIncludeApplicationsEnabled() : Boolean { return _include.value } - private fun updateQuickStringWithSelectedPackages() : String { - var wgQuick = _tunnel.value?.wgQuick - if(wgQuick != null) { - wgQuick = if(isAllApplicationsEnabled()) { - TunnelConfig.clearAllApplicationsFromConfig(wgQuick) - } else if(isIncludeApplicationsEnabled()) { - TunnelConfig.setIncludedApplicationsOnQuick(_checkedPackages.value, wgQuick) - } else { - TunnelConfig.setExcludedApplicationsOnQuick(_checkedPackages.value, wgQuick) - } - } else { - throw WgTunnelException("Wg quick string is null") - } - return wgQuick; - } - private suspend fun saveConfig(tunnelConfig: TunnelConfig) { tunnelRepo.save(tunnelConfig) } private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) { if(tunnelConfig != null) { saveConfig(tunnelConfig) - addTunnelShortcuts(tunnelConfig) updateSettingsDefaultTunnel(tunnelConfig) } } @@ -227,21 +268,133 @@ class ConfigViewModel @Inject constructor(private val application : Application, } } - private fun addTunnelShortcuts(tunnelConfig: TunnelConfig) { - ShortcutsManager.createTunnelShortcuts(application, tunnelConfig) + fun buildPeerListFromProxyPeers() : List { + return _proxyPeers.value.map { + val builder = Peer.Builder() + if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps) + if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey) + if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey) + if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint) + if (it.persistentKeepalive.isNotEmpty()) builder.parsePersistentKeepalive(it.persistentKeepalive) + builder.build() + } + } + + fun buildInterfaceListFromProxyInterface() : Interface { + val builder = Interface.Builder() + builder.parsePrivateKey(_interface.value.privateKey) + builder.parseAddresses(_interface.value.addresses) + builder.parseDnsServers(_interface.value.dnsServers) + if(_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu) + if(_interface.value.listenPort.isNotEmpty()) builder.parseListenPort(_interface.value.listenPort) + if(isAllApplicationsEnabled()) _checkedPackages.value.clear() + if(_include.value) builder.includeApplications(_checkedPackages.value) + if(!_include.value) builder.excludeApplications(_checkedPackages.value) + return builder.build() } + + suspend fun onSaveAllChanges() { try { - removeTunnelShortcuts(_tunnel.value) - val wgQuick = updateQuickStringWithSelectedPackages() + val peerList = buildPeerListFromProxyPeers() + val wgInterface = buildInterfaceListFromProxyInterface() + val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build() val tunnelConfig = _tunnel.value?.copy( name = _tunnelName.value, - wgQuick = wgQuick + wgQuick = config.toWgQuickString() ) updateTunnelConfig(tunnelConfig) } catch (e : Exception) { - Timber.e(e.message) + throw WgTunnelException("Error: ${e.cause?.message?.lowercase() ?: "unknown error occurred"}") + } + } + + fun onPeerPublicKeyChange(index: Int, publicKey: String) { + _proxyPeers.value[index] = _proxyPeers.value[index].copy( + publicKey = publicKey + ) + } + + fun onPreSharedKeyChange(index: Int, value: String) { + _proxyPeers.value[index] = _proxyPeers.value[index].copy( + preSharedKey = value + ) + } + + fun onEndpointChange(index: Int, value: String) { + _proxyPeers.value[index] = _proxyPeers.value[index].copy( + endpoint = value + ) + } + + fun onAllowedIpsChange(index: Int, value: String) { + _proxyPeers.value[index] = _proxyPeers.value[index].copy( + allowedIps = value + ) + } + + fun onPersistentKeepaliveChanged(index : Int, value : String) { + _proxyPeers.value[index] = _proxyPeers.value[index].copy( + persistentKeepalive = value + ) + } + + fun onDeletePeer(index: Int) { + proxyPeers.value.removeAt(index) + } + + fun addEmptyPeer() { + _proxyPeers.value.add(PeerProxy()) + } + + fun generateKeyPair() { + val keyPair = KeyPair() + _interface.value = _interface.value.copy( + privateKey = keyPair.privateKey.toBase64(), + publicKey = keyPair.publicKey.toBase64() + ) + } + + fun onAddressesChanged(value: String) { + _interface.value = _interface.value.copy( + addresses = value + ) + } + + fun onListenPortChanged(value: String) { + _interface.value = _interface.value.copy( + listenPort = value + ) + } + + fun onDnsServersChanged(value: String) { + _interface.value = _interface.value.copy( + dnsServers = value + ) + } + + fun onMtuChanged(value: String) { + _interface.value = _interface.value.copy( + mtu = value + ) + } + + private fun onInterfacePublicKeyChange(value : String) { + _interface.value = _interface.value.copy( + publicKey = value + ) + } + + fun onPrivateKeyChange(value: String) { + _interface.value = _interface.value.copy( + privateKey = value + ) + if(NumberUtils.isValidKey(value)) { + val pair = KeyPair(Key.fromBase64(value)) + onInterfacePublicKeyChange(pair.publicKey.toBase64()) + } else { + onInterfacePublicKeyChange("") } } } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt index bf19e361..b951d5fc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt @@ -1,11 +1,15 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.detail +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusGroup import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -17,8 +21,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.ClipboardManager import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontStyle @@ -28,17 +35,21 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.zaneschepke.wireguardautotunnel.R +import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.util.NumberUtils import java.time.Duration import java.time.Instant +@OptIn(ExperimentalFoundationApi::class) @Composable fun DetailScreen( viewModel: DetailViewModel = hiltViewModel(), + focusRequester: FocusRequester, padding: PaddingValues, id : String ) { + val context = LocalContext.current val clipboardManager: ClipboardManager = LocalClipboardManager.current val tunnelStats by viewModel.tunnelStats.collectAsStateWithLifecycle(null) val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null) @@ -62,18 +73,20 @@ fun DetailScreen( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.Top, modifier = Modifier - .fillMaxSize() + .fillMaxWidth() + .fillMaxHeight(if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 4/5f else 1f) .verticalScroll(rememberScrollState()) + .focusRequester(focusRequester) .padding(padding) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 7.dp), + .padding(horizontal = 20.dp, vertical = 7.dp).focusGroup(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { - Column { + Column(modifier = Modifier.weight(1f, true)) { Text(stringResource(R.string.config_interface), fontWeight = FontWeight.Bold, fontSize = 20.sp) Text(stringResource(R.string.name), fontStyle = FontStyle.Italic) Text(text = tunnelName, modifier = Modifier.clickable { @@ -122,15 +135,22 @@ fun DetailScreen( val rxKB = NumberUtils.bytesToKB(tunnelStats!!.totalRx()) val txKB = NumberUtils.bytesToKB(tunnelStats!!.totalTx()) Text(stringResource(R.string.transfer), fontStyle = FontStyle.Italic) - Text("rx: ${NumberUtils.formatDecimalTwoPlaces(rxKB)} KB tx: ${NumberUtils.formatDecimalTwoPlaces(txKB)} KB") + val transfer = "rx: ${NumberUtils.formatDecimalTwoPlaces(rxKB)} KB tx: ${NumberUtils.formatDecimalTwoPlaces(txKB)} KB" + Text(transfer, modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(transfer))}) Text(stringResource(R.string.last_handshake), fontStyle = FontStyle.Italic) val handshakeEpoch = lastHandshake[it.publicKey] if(handshakeEpoch != null) { if(handshakeEpoch == 0L) { - Text(stringResource(id = R.string.never)) + Text(stringResource(id = R.string.never), modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(context.getString(R.string.never))) + }) } else { val time = Instant.ofEpochMilli(handshakeEpoch) - Text("${Duration.between(time, Instant.now()).seconds} seconds ago") + val duration = "${Duration.between(time, Instant.now()).seconds} seconds ago" + Text(duration, modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(duration)) + }) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt index ec48f745..06498e83 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt @@ -38,6 +38,7 @@ class DetailViewModel @Inject constructor(private val tunnelRepo : TunnelConfigD viewModelScope.launch(Dispatchers.IO) { val tunnelConfig = getTunnelConfigById(id) if(tunnelConfig != null) { + _tunnelName.emit(tunnelConfig.name) _tunnel.emit(TunnelConfig.configFromQuick(tunnelConfig.wgQuick)) } } 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 012df70c..cfe9441a 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,7 +1,6 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main import android.annotation.SuppressLint -import android.content.Intent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility @@ -17,10 +16,12 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.rounded.Add @@ -28,6 +29,8 @@ import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Star +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FabPosition @@ -37,14 +40,12 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -55,6 +56,7 @@ import androidx.compose.ui.Alignment 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.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType @@ -89,8 +91,10 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( - viewModel: MainViewModel = hiltViewModel(), padding: PaddingValues, - snackbarHostState: SnackbarHostState, navController: NavController + viewModel: MainViewModel = hiltViewModel(), + padding: PaddingValues, + showSnackbarMessage: (String) -> Unit, + navController: NavController ) { val haptic = LocalHapticFeedback.current @@ -100,13 +104,13 @@ fun MainScreen( val sheetState = rememberModalBottomSheetState() var showBottomSheet by remember { mutableStateOf(false) } + var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) } val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(HandshakeStatus.NOT_STARTED) - val viewState = viewModel.viewState.collectAsStateWithLifecycle() var selectedTunnel by remember { mutableStateOf(null) } val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN) val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("") - + val settings by viewModel.settings.collectAsStateWithLifecycle() // Nested scroll for control FAB val nestedScrollConnection = remember { @@ -125,24 +129,14 @@ fun MainScreen( } } - LaunchedEffect(viewState.value) { - if (viewState.value.showSnackbarMessage) { - val result = snackbarHostState.showSnackbar( - message = viewState.value.snackbarMessage, - actionLabel = viewState.value.snackbarActionText, - duration = SnackbarDuration.Long, - ) - when (result) { - SnackbarResult.ActionPerformed -> viewState.value.onSnackbarActionClick - SnackbarResult.Dismissed -> viewState.value.onSnackbarActionClick - } - } - } - val pickFileLauncher = rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - result.data?.data?.let { viewModel.onTunnelFileSelected(it) } + ActivityResultContracts.GetContent() + ) { result -> if (result != null) + try { + viewModel.onTunnelFileSelected(result) + } catch (e : Exception) { + showSnackbarMessage(e.message ?: "Unknown error occurred") + } } val scanLauncher = rememberLauncherForActivityResult( @@ -150,12 +144,46 @@ fun MainScreen( onResult = { try { viewModel.onTunnelQrResult(it.contents) - } catch (e : Exception) { - viewModel.showSnackBarMessage(context.getString(R.string.qr_result_failed)) + } catch (e: Exception) { + showSnackbarMessage(context.getString(R.string.qr_result_failed)) } } ) + if(showPrimaryChangeAlertDialog) { + AlertDialog( + onDismissRequest = { + showPrimaryChangeAlertDialog = false + }, + confirmButton = { + TextButton(onClick = { + scope.launch { + viewModel.onDefaultTunnelChange(selectedTunnel) + showPrimaryChangeAlertDialog = false + selectedTunnel = null + } + }) + { Text(text = "Okay") } + }, + dismissButton = { + TextButton(onClick = { + showPrimaryChangeAlertDialog = false + }) + { Text(text = "Cancel") } + }, + title = { Text(text = "Primary tunnel change") }, + text = { Text(text = "Would you like to make this your primary tunnel?") } + ) + } + + fun onTunnelToggle(checked : Boolean , tunnel : TunnelConfig) { + try { + if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop() + } catch (e : Exception) { + showSnackbarMessage(e.message!!) + } + } + Scaffold( modifier = Modifier.pointerInput(Unit) { detectTapGestures(onTap = { @@ -169,12 +197,19 @@ fun MainScreen( enter = slideInVertically(initialOffsetY = { it * 2 }), exit = slideOutVertically(targetOffsetY = { it * 2 }), ) { + val secondaryColor = MaterialTheme.colorScheme.secondary + val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) + var fobColor by remember { mutableStateOf(secondaryColor) } FloatingActionButton( - modifier = Modifier.padding(bottom = 90.dp), + modifier = Modifier.padding(bottom = 90.dp).onFocusChanged { + if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { + fobColor = if (it.isFocused) hoverColor else secondaryColor } + } + , onClick = { showBottomSheet = true }, - containerColor = MaterialTheme.colorScheme.secondary, + containerColor = fobColor, shape = RoundedCornerShape(16.dp), ) { Icon( @@ -210,20 +245,11 @@ fun MainScreen( .fillMaxWidth() .clickable { showBottomSheet = false - val fileSelectionIntent = Intent(Intent.ACTION_GET_CONTENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - putExtra(Constants.FILES_SHOW_ADVANCED, true) - type = Constants.ALLOWED_FILE_TYPES + try { + pickFileLauncher.launch(Constants.ALLOWED_FILE_TYPES) + } catch (_: Exception) { + showSnackbarMessage("No file explorer") } - if(!viewModel.isIntentAvailable(fileSelectionIntent)) { - fileSelectionIntent.action = Intent.ACTION_OPEN_DOCUMENT - fileSelectionIntent.setPackage(null) - if (!viewModel.isIntentAvailable(fileSelectionIntent)) { - viewModel.showSnackBarMessage(context.getString(R.string.no_file_app)) - return@clickable - } - } - pickFileLauncher.launch(fileSelectionIntent) } .padding(10.dp) ) { @@ -264,6 +290,26 @@ fun MainScreen( modifier = Modifier.padding(10.dp) ) } + Divider() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + showBottomSheet = false + navController.navigate("${Routes.Config.name}/${Constants.MANUAL_TUNNEL_CONFIG_ID}") + } + .padding(10.dp) + ) { + Icon( + Icons.Filled.Create, + contentDescription = stringResource(id = R.string.create_import), + modifier = Modifier.padding(10.dp) + ) + Text( + stringResource(id = R.string.create_import), + modifier = Modifier.padding(10.dp) + ) + } } } Column( @@ -273,27 +319,36 @@ fun MainScreen( .fillMaxSize() .padding(padding) ) { - LazyColumn( modifier = Modifier .fillMaxSize() .nestedScroll(nestedScrollConnection), ) { - items(tunnels, key = { tunnel -> tunnel.id }) {tunnel -> + items(tunnels, key = { tunnel -> tunnel.id }) { tunnel -> + val leadingIconColor = (if (tunnelName == tunnel.name) when (handshakeStatus) { + HandshakeStatus.HEALTHY -> mint + HandshakeStatus.UNHEALTHY -> brickRed + HandshakeStatus.NOT_STARTED -> Color.Gray + HandshakeStatus.NEVER_CONNECTED -> brickRed + } else {Color.Gray}) val focusRequester = remember { FocusRequester() } - RowListItem(leadingIcon = Icons.Rounded.Circle, - leadingIconColor = if (tunnelName == tunnel.name) when (handshakeStatus) { - HandshakeStatus.HEALTHY -> mint - HandshakeStatus.UNHEALTHY -> brickRed - HandshakeStatus.NOT_STARTED -> Color.Gray - HandshakeStatus.NEVER_CONNECTED -> brickRed - } else Color.Gray, + RowListItem(icon = { + if (settings.isTunnelConfigDefault(tunnel)) + Icon( + Icons.Rounded.Star, "status", + tint = leadingIconColor, + modifier = Modifier.padding(end = 10.dp).size(20.dp) + ) + else Icon( + Icons.Rounded.Circle, "status", + tint = leadingIconColor, + modifier = Modifier.padding(end = 15.dp).size(15.dp) + ) + }, text = tunnel.name, onHold = { if (state == Tunnel.State.UP && tunnel.name == tunnelName) { - scope.launch { - viewModel.showSnackBarMessage(context.resources.getString(R.string.turn_off_tunnel)) - } + showSnackbarMessage(context.resources.getString(R.string.turn_off_tunnel)) return@RowListItem } haptic.performHapticFeedback(HapticFeedbackType.LongPress) @@ -303,12 +358,22 @@ fun MainScreen( if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { navController.navigate("${Routes.Detail.name}/${tunnel.id}") } else { + selectedTunnel = tunnel focusRequester.requestFocus() } }, rowButton = { - if (tunnel.id == selectedTunnel?.id) { + if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { Row { + if(!settings.isTunnelConfigDefault(tunnel)) { + IconButton(onClick = { + if(settings.isAutoTunnelEnabled) { + showSnackbarMessage(context.resources.getString(R.string.turn_off_auto)) + } else showPrimaryChangeAlertDialog = true + }) { + Icon(Icons.Rounded.Star, stringResource(id = R.string.set_primary)) + } + } IconButton(onClick = { navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}") }) { @@ -326,6 +391,15 @@ fun MainScreen( } else { if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { Row { + if(!settings.isTunnelConfigDefault(tunnel)) { + IconButton(onClick = { + if(settings.isAutoTunnelEnabled) { + showSnackbarMessage(context.resources.getString(R.string.turn_off_auto)) + } else showPrimaryChangeAlertDialog = true + }) { + Icon(Icons.Rounded.Star, stringResource(id = R.string.set_primary)) + } + } IconButton( modifier = Modifier.focusRequester(focusRequester), onClick = { @@ -335,13 +409,12 @@ fun MainScreen( } IconButton(onClick = { if (state == Tunnel.State.UP && tunnel.name == tunnelName) - scope.launch { - viewModel.showSnackBarMessage( - context.resources.getString( - R.string.turn_off_tunnel - ) + showSnackbarMessage( + context.resources.getString( + R.string.turn_off_tunnel ) - } else { + ) + else { navController.navigate("${Routes.Config.name}/${tunnel.id}") } }) { @@ -352,13 +425,12 @@ fun MainScreen( } IconButton(onClick = { if (state == Tunnel.State.UP && tunnel.name == tunnelName) - scope.launch { - viewModel.showSnackBarMessage( - context.resources.getString( - R.string.turn_off_tunnel - ) + showSnackbarMessage( + context.resources.getString( + R.string.turn_off_tunnel ) - } else { + ) + else { viewModel.onDelete(tunnel) } }) { @@ -370,7 +442,7 @@ fun MainScreen( Switch( checked = (state == Tunnel.State.UP && tunnel.name == tunnelName), onCheckedChange = { checked -> - if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop() + onTunnelToggle(checked, tunnel) } ) } @@ -378,7 +450,7 @@ fun MainScreen( Switch( checked = (state == Tunnel.State.UP && tunnel.name == tunnelName), onCheckedChange = { checked -> - if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop() + 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 99624c35..30227b9b 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 @@ -2,13 +2,12 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main import android.app.Application import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager import android.database.Cursor 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 @@ -21,13 +20,10 @@ import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService -import com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsManager import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService -import com.zaneschepke.wireguardautotunnel.ui.ViewState import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -45,8 +41,6 @@ class MainViewModel @Inject constructor(private val application : Application, private val vpnService: VpnService ) : ViewModel() { - private val _viewState = MutableStateFlow(ViewState()) - val viewState get() = _viewState.asStateFlow() val tunnels get() = tunnelRepo.getAllFlow() val state get() = vpnService.state @@ -87,7 +81,6 @@ class MainViewModel @Inject constructor(private val application : Application, } } tunnelRepo.delete(tunnel) - ShortcutsManager.removeTunnelShortcuts(application.applicationContext, tunnel) } } @@ -127,7 +120,7 @@ class MainViewModel @Inject constructor(private val application : Application, val tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) addTunnel(tunnelConfig) } catch (e : WgTunnelException) { - showSnackBarMessage(e.message ?: application.getString(R.string.unknown_error_message)) + throw WgTunnelException(e.message ?: application.getString(R.string.unknown_error_message)) } } } @@ -156,40 +149,25 @@ class MainViewModel @Inject constructor(private val application : Application, fun onTunnelFileSelected(uri : Uri) { try { - val fileName = getFileName(application.applicationContext, uri) - validateFileExtension(fileName) - val stream = getInputStreamFromUri(uri) - saveTunnelConfigFromStream(stream, fileName) - } catch (e : Exception) { - showExceptionMessage(e) - } - } - - private fun showExceptionMessage(e : Exception) { - when(e) { - is BadConfigException -> { - showSnackBarMessage(application.getString(R.string.bad_config)) - } - is WgTunnelException -> { - showSnackBarMessage(e.message ?: application.getString(R.string.unknown_error_message)) + viewModelScope.launch(Dispatchers.IO) { + val fileName = getFileName(application.applicationContext, uri) + validateFileExtension(fileName) + val stream = getInputStreamFromUri(uri) + saveTunnelConfigFromStream(stream, fileName) } - else -> showSnackBarMessage(application.getString(R.string.unknown_error_message)) + } catch (e : Exception) { + throw WgTunnelException(e.message ?: "Error importing file") } } private suspend fun addTunnel(tunnelConfig: TunnelConfig) { saveTunnel(tunnelConfig) - createTunnelAppShortcuts(tunnelConfig) } private suspend fun saveTunnel(tunnelConfig : TunnelConfig) { tunnelRepo.save(tunnelConfig) } - private fun createTunnelAppShortcuts(tunnelConfig: TunnelConfig) { - ShortcutsManager.createTunnelShortcuts(application.applicationContext, tunnelConfig) - } - private fun getFileNameByCursor(context: Context, uri: Uri) : String { val cursor = context.contentResolver.query(uri, null, null, null, null) if(cursor != null) { @@ -209,23 +187,6 @@ class MainViewModel @Inject constructor(private val application : Application, return columnIndex } - fun isIntentAvailable(i: Intent?): Boolean { - val packageManager = application.packageManager - val list = packageManager.queryIntentActivities( - i!!, - PackageManager.MATCH_DEFAULT_ONLY - ) - // Ignore the Android TV framework app in the list - var size = list.size - for (ri in list) { - // Ignore stub apps - if (Constants.ANDROID_TV_STUBS == ri.activityInfo.packageName) { - size-- - } - } - return size > 0 - } - private fun getDisplayNameByCursor(cursor: Cursor) : String { if(cursor.moveToFirst()) { val index = getDisplayNameColumnIndex(cursor) @@ -251,29 +212,6 @@ class MainViewModel @Inject constructor(private val application : Application, } } - fun showSnackBarMessage(message : String) { - CoroutineScope(Dispatchers.IO).launch { - _viewState.emit(_viewState.value.copy( - showSnackbarMessage = true, - snackbarMessage = message, - snackbarActionText = application.getString(R.string.okay), - onSnackbarActionClick = { - viewModelScope.launch { - dismissSnackBar() - } - } - )) - delay(Constants.SNACKBAR_DELAY) - dismissSnackBar() - } - } - - private suspend fun dismissSnackBar() { - _viewState.emit(_viewState.value.copy( - showSnackbarMessage = false - )) - } - private fun getNameFromFileName(fileName : String) : String { return fileName.substring(0 , fileName.lastIndexOf('.') ) } @@ -285,4 +223,13 @@ class MainViewModel @Inject constructor(private val application : Application, "" } } + + suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) { + if(selectedTunnel != null) { + _settings.emit(_settings.value.copy( + defaultTunnel = selectedTunnel.toString() + )) + settingsRepo.save(_settings.value) + } + } } \ No newline at end of file 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 d1874799..c1e83d6d 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 @@ -11,14 +11,18 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll @@ -26,22 +30,14 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.rounded.LocationOff -import androidx.compose.material3.Button -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.SnackbarDuration -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.SnackbarResult -import androidx.compose.material3.Switch +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextField +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -63,27 +59,26 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavController import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel -import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig -import com.zaneschepke.wireguardautotunnel.ui.Routes import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton +import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle +import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class, +@OptIn( + ExperimentalPermissionsApi::class, ExperimentalLayoutApi::class ) @Composable fun SettingsScreen( viewModel: SettingsViewModel = hiltViewModel(), padding: PaddingValues, - navController: NavController, + showSnackbarMessage: (String) -> Unit, focusRequester: FocusRequester, - snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } ) { val scope = rememberCoroutineScope() @@ -91,39 +86,37 @@ fun SettingsScreen( val focusManager = LocalFocusManager.current val interactionSource = remember { MutableInteractionSource() } - var expanded by remember { mutableStateOf(false) } - val viewState by viewModel.viewState.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle() val trustedSSIDs by viewModel.trustedSSIDs.collectAsStateWithLifecycle() val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) var currentText by remember { mutableStateOf("") } val scrollState = rememberScrollState() - var isLocationServicesEnabled by remember { mutableStateOf(viewModel.checkLocationServicesEnabled())} + var didShowLocationDisclaimer by remember { mutableStateOf(false) } + var isBackgroundLocationGranted by remember { mutableStateOf(true) } + + val screenPadding = 5.dp + val fillMaxHeight = .85f + val fillMaxWidth = .85f - LaunchedEffect(viewState) { - if (viewState.showSnackbarMessage) { - val result = snackbarHostState.showSnackbar( - message = viewState.snackbarMessage, - actionLabel = viewState.snackbarActionText, - duration = SnackbarDuration.Long, - ) - when (result) { - SnackbarResult.ActionPerformed -> viewState.onSnackbarActionClick - SnackbarResult.Dismissed -> viewState.onSnackbarActionClick - } - } - } fun saveTrustedSSID() { if (currentText.isNotEmpty()) { scope.launch { - viewModel.onSaveTrustedSSID(currentText) - currentText = "" + try { + viewModel.onSaveTrustedSSID(currentText) + currentText = "" + } catch (e : Exception) { + showSnackbarMessage(e.message ?: "Unknown error") + } } } } + fun isAllAutoTunnelPermissionsEnabled() : Boolean { + return(isBackgroundLocationGranted && fineLocationState.status.isGranted && !viewModel.isLocationServicesNeeded()) + } + fun openSettings() { scope.launch { val intentSettings = @@ -133,68 +126,66 @@ fun SettingsScreen( context.startActivity(intentSettings) } } + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val backgroundLocationState = rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) if(!backgroundLocationState.status.isGranted) { - Column(horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(padding)) { - Icon(Icons.Rounded.LocationOff, contentDescription = stringResource(id = R.string.map), modifier = Modifier - .padding(30.dp) - .size(128.dp)) - Text(stringResource(R.string.prominent_background_location_title), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 20.sp) - Text(stringResource(R.string.prominent_background_location_message), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 15.sp) - Row( - modifier = if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier - .fillMaxWidth() - .padding(10.dp) else Modifier - .fillMaxWidth() - .padding(30.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceEvenly + isBackgroundLocationGranted = false + if(!didShowLocationDisclaimer) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(padding) ) { - Button(onClick = { - navController.navigate(Routes.Main.name) - }) { - Text(stringResource(id = R.string.no_thanks)) - } - Button(modifier = Modifier.focusRequester(focusRequester), onClick = { - openSettings() - }) { - Text(stringResource(id = R.string.turn_on)) + Icon( + Icons.Rounded.LocationOff, + contentDescription = stringResource(id = R.string.map), + modifier = Modifier + .padding(30.dp) + .size(128.dp) + ) + Text( + stringResource(R.string.prominent_background_location_title), + textAlign = TextAlign.Center, + modifier = Modifier.padding(30.dp), + fontSize = 20.sp + ) + Text( + stringResource(R.string.prominent_background_location_message), + textAlign = TextAlign.Center, + modifier = Modifier.padding(30.dp), + fontSize = 15.sp + ) + Row( + modifier = if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier + .fillMaxWidth() + .padding(10.dp) else Modifier + .fillMaxWidth() + .padding(30.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + TextButton(onClick = { + didShowLocationDisclaimer = true + }) { + Text(stringResource(id = R.string.no_thanks)) + } + TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = { + openSettings() + }) { + Text(stringResource(id = R.string.turn_on)) + } } } + return } - return - } - } - - if(!fineLocationState.status.isGranted) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - Text( - stringResource(id = R.string.precise_location_message), - textAlign = TextAlign.Center, - modifier = Modifier.padding(15.dp), - fontStyle = FontStyle.Italic - ) - Button(modifier = Modifier.focusRequester(focusRequester),onClick = { - fineLocationState.launchPermissionRequest() - }) { - Text(stringResource(id = R.string.request)) - } - + } else { + isBackgroundLocationGranted = true } - return } if (tunnels.isEmpty()) { @@ -214,219 +205,161 @@ fun SettingsScreen( } return } - if(!isLocationServicesEnabled && Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - modifier = Modifier - .fillMaxSize() - .padding(padding) - ) { - Text( - stringResource(id = R.string.location_services_not_detected), - textAlign = TextAlign.Center, - modifier = Modifier.padding(15.dp), - fontStyle = FontStyle.Italic - ) - Button(modifier = Modifier.focusRequester(focusRequester), onClick = { - val locationServicesEnabled = viewModel.checkLocationServicesEnabled() - isLocationServicesEnabled = locationServicesEnabled - if(!locationServicesEnabled) { - scope.launch { - viewModel.showSnackBarMessage(context.getString(R.string.detecting_location_services_disabled)) - } - } - }) { - Text(stringResource(id = R.string.check_again)) - } - } - return - } - val screenPadding = if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 5.dp else 15.dp + Column( - horizontalAlignment = Alignment.Start, + horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Top, - modifier = if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier - .fillMaxHeight(.85f) - .fillMaxWidth() - .verticalScroll(scrollState) - .clickable(indication = null, interactionSource = interactionSource) { - focusManager.clearFocus() - } - .padding(padding) else Modifier + modifier = Modifier .fillMaxSize() .verticalScroll(scrollState) .clickable(indication = null, interactionSource = interactionSource) { focusManager.clearFocus() } - .padding(padding) ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(screenPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) + Modifier + .height(IntrinsicSize.Min) + .fillMaxWidth(fillMaxWidth) + else Modifier.fillMaxWidth(fillMaxWidth)).padding(top = 60.dp, bottom = 25.dp) ) { - Text(stringResource(R.string.enable_auto_tunnel)) - Switch( - modifier = Modifier.focusRequester(focusRequester), - enabled = !settings.isAlwaysOnVpnEnabled, - checked = settings.isAutoTunnelEnabled, - onCheckedChange = { - scope.launch { - viewModel.toggleAutoTunnel() + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.padding(15.dp) + ) { + SectionTitle(title = stringResource(id = R.string.auto_tunneling), padding = screenPadding) + Text( + stringResource(R.string.trusted_ssid), + textAlign = TextAlign.Center, + modifier = Modifier.padding(screenPadding, bottom = 5.dp, top = 5.dp) + ) + FlowRow( + modifier = Modifier.padding(screenPadding), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.SpaceEvenly + ) { + trustedSSIDs.forEach { ssid -> + ClickableIconButton( + onIconClick = { + scope.launch { + viewModel.onDeleteTrustedSSID(ssid) + } + }, + text = ssid, + icon = Icons.Filled.Close, + enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled) + ) + } + if(trustedSSIDs.isEmpty()) { + Text(stringResource(R.string.none), fontStyle = FontStyle.Italic, color = Color.Gray) } } - ) + OutlinedTextField( + enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), + value = currentText, + onValueChange = { currentText = it }, + label = { Text(stringResource(R.string.add_trusted_ssid)) }, + modifier = Modifier.padding(start = screenPadding, top = 5.dp).focusRequester(focusRequester), + maxLines = 1, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + saveTrustedSSID() + } + ), + trailingIcon = { + IconButton(onClick = { saveTrustedSSID() }) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource( + id = R.string.trusted_ssid_value_description + ), + tint = if (currentText == "") Color.Transparent else MaterialTheme.colorScheme.primary + ) + } + }, + ) + ConfigurationToggle(stringResource(R.string.tunnel_mobile_data), + enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), + checked = settings.isTunnelOnMobileDataEnabled, + padding = screenPadding, + onCheckChanged = { + scope.launch { + viewModel.onToggleTunnelOnMobileData() + } + } + ) + ConfigurationToggle(stringResource(id = R.string.tunnel_on_ethernet), + enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), + checked = settings.isTunnelOnEthernetEnabled, + padding = screenPadding, + onCheckChanged = { + scope.launch { + viewModel.onToggleTunnelOnEthernet() + } + } + ) + ConfigurationToggle(stringResource(R.string.enable_auto_tunnel), + enabled = !settings.isAlwaysOnVpnEnabled, + checked = settings.isAutoTunnelEnabled, + padding = screenPadding, + onCheckChanged = { + if(!isAllAutoTunnelPermissionsEnabled()) { + val message = if(viewModel.isLocationServicesNeeded()){ + "Location services required" + } else if(!isBackgroundLocationGranted){ + "Background location required" + } else { + "Precise location required" + } + showSnackbarMessage(message) + } else scope.launch { + viewModel.toggleAutoTunnel() + } + } + ) + } + } - Text( - stringResource(id = R.string.select_tunnel), - textAlign = TextAlign.Center, - modifier = Modifier.padding(screenPadding, bottom = 5.dp, top = 5.dp) - ) - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { - if(!(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)) { - expanded = !expanded }}, - modifier = Modifier.padding(start = 15.dp, top = 5.dp, bottom = 10.dp).clickable { - expanded = !expanded - }, - ) { - TextField( - enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), - value = settings.defaultTunnel?.let { - TunnelConfig.from(it).name } - ?: "", - readOnly = true, - modifier = Modifier.menuAnchor(), - label = { Text(stringResource(R.string.tunnels)) }, - onValueChange = { }, - trailingIcon = { - ExposedDropdownMenuDefaults.TrailingIcon( - expanded = expanded - ) - } - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { - expanded = false - } + if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { + Surface( + tonalElevation = 2.dp, + shadowElevation = 2.dp, + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.fillMaxWidth(fillMaxWidth) + .height(IntrinsicSize.Min) + .padding(bottom = 180.dp) ) { - tunnels.forEach() { tunnel -> - DropdownMenuItem( - onClick = { + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier.padding(15.dp) + ) { + SectionTitle(title = stringResource(id = R.string.other), padding = screenPadding) + ConfigurationToggle(stringResource(R.string.always_on_vpn_support), + enabled = !settings.isAutoTunnelEnabled, + checked = settings.isAlwaysOnVpnEnabled, + padding = screenPadding, + onCheckChanged = { scope.launch { - viewModel.onDefaultTunnelSelected(tunnel) + viewModel.onToggleAlwaysOnVPN() } - expanded = false - }, - text = { Text(text = tunnel.name) } + } ) } } } - Text( - stringResource(R.string.trusted_ssid), - textAlign = TextAlign.Center, - modifier = Modifier.padding(screenPadding, bottom = 5.dp, top = 5.dp) - ) - FlowRow( - modifier = Modifier.padding(screenPadding), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.SpaceEvenly - ) { - trustedSSIDs.forEach { ssid -> - ClickableIconButton(onIconClick = { - scope.launch { - viewModel.onDeleteTrustedSSID(ssid) - } - }, text = ssid, icon = Icons.Filled.Close, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)) - } - } - OutlinedTextField( - enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), - value = currentText, - onValueChange = { currentText = it }, - label = { Text(stringResource(R.string.add_trusted_ssid)) }, - modifier = Modifier.padding(start = screenPadding, top = 5.dp), - maxLines = 1, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - saveTrustedSSID() - } - ), - trailingIcon = { - IconButton(onClick = { saveTrustedSSID() }) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource( - id = R.string.trusted_ssid_value_description - ), - tint = if(currentText == "") Color.Transparent else Color.Green - ) - } - }, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(screenPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(stringResource(R.string.tunnel_mobile_data)) - Switch( - enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), - checked = settings.isTunnelOnMobileDataEnabled, - onCheckedChange = { - scope.launch { - viewModel.onToggleTunnelOnMobileData() - } - } - ) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(screenPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text("Tunnel on Ethernet") - Switch( - enabled = !settings.isAutoTunnelEnabled, - checked = settings.isTunnelOnEthernetEnabled, - onCheckedChange = { - scope.launch { - viewModel.onToggleTunnelOnEthernet() - } - } - ) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(screenPadding), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text(stringResource(R.string.always_on_vpn_support)) - Switch( - enabled = !settings.isAutoTunnelEnabled, - checked = settings.isAlwaysOnVpnEnabled, - onCheckedChange = { - scope.launch { - viewModel.onToggleAlwaysOnVPN() - } - } - ) + if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { + Spacer(modifier = Modifier.weight(.17f)) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt index 4d9bd35b..67c7a6b5 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt @@ -3,22 +3,20 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings import android.app.Application import android.content.Context import android.location.LocationManager +import android.os.Build import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager -import com.zaneschepke.wireguardautotunnel.ui.ViewState +import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import javax.inject.Inject @@ -34,11 +32,8 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio private val _settings = MutableStateFlow(Settings()) val settings get() = _settings.asStateFlow() val tunnels get() = tunnelRepo.getAllFlow() - private val _viewState = MutableStateFlow(ViewState()) - val viewState get() = _viewState.asStateFlow() - init { - checkLocationServicesEnabled() + isLocationServicesEnabled() viewModelScope.launch(Dispatchers.IO) { settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect { val settings = it.first() @@ -54,16 +49,10 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio _settings.value.trustedNetworkSSIDs.add(trimmed) settingsRepo.save(_settings.value) } else { - showSnackBarMessage("SSID already exists.") + throw WgTunnelException("SSID already exists.") } } - suspend fun onDefaultTunnelSelected(tunnelConfig: TunnelConfig) { - settingsRepo.save(_settings.value.copy( - defaultTunnel = tunnelConfig.toString() - )) - } - suspend fun onToggleTunnelOnMobileData() { settingsRepo.save(_settings.value.copy( isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled @@ -75,68 +64,65 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio settingsRepo.save(_settings.value) } + private fun emitFirstTunnelAsDefault() = viewModelScope.async { + _settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString())) + } + suspend fun toggleAutoTunnel() { - if(_settings.value.defaultTunnel.isNullOrEmpty() && !_settings.value.isAutoTunnelEnabled) { - showSnackBarMessage(application.getString(R.string.select_tunnel_message)) - return - } if(_settings.value.isAutoTunnelEnabled) { ServiceManager.stopWatcherService(application) } else { - if(_settings.value.defaultTunnel != null) { - val defaultTunnel = _settings.value.defaultTunnel - ServiceManager.startWatcherService(application, defaultTunnel!!) + if(_settings.value.defaultTunnel == null) { + emitFirstTunnelAsDefault().await() } + val defaultTunnel = _settings.value.defaultTunnel + ServiceManager.startWatcherService(application, defaultTunnel!!) } settingsRepo.save(_settings.value.copy( isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled )) } - suspend fun showSnackBarMessage(message : String) { - _viewState.emit(_viewState.value.copy( - showSnackbarMessage = true, - snackbarMessage = message, - snackbarActionText = "Okay", - onSnackbarActionClick = { - viewModelScope.launch { - dismissSnackBar() - } - } - )) - } - - private suspend fun dismissSnackBar() { - _viewState.emit(_viewState.value.copy( - showSnackbarMessage = false - )) + private suspend fun getFirstTunnelConfig() : TunnelConfig { + return tunnelRepo.getAll().first(); } suspend fun onToggleAlwaysOnVPN() { - if(_settings.value.defaultTunnel != null) { - _settings.emit( - _settings.value.copy(isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled) - ) - settingsRepo.save(_settings.value) - } else { - showSnackBarMessage(application.getString(R.string.select_tunnel_message)) + if(_settings.value.defaultTunnel == null) { + emitFirstTunnelAsDefault().await() } + val updatedSettings = _settings.value.copy(isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled) + emitSettings(updatedSettings) + saveSettings(updatedSettings) + } + + private suspend fun emitSettings(settings: Settings) { + _settings.emit( + settings + ) + } + + private suspend fun saveSettings(settings: Settings) { + settingsRepo.save(settings) } suspend fun onToggleTunnelOnEthernet() { - if(_settings.value.defaultTunnel != null) { - _settings.emit( - _settings.value.copy(isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled) - ) - settingsRepo.save(_settings.value) - } else { - showSnackBarMessage(application.getString(R.string.select_tunnel_message)) + if(_settings.value.defaultTunnel == null) { + emitFirstTunnelAsDefault().await() } + _settings.emit( + _settings.value.copy(isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled) + ) + settingsRepo.save(_settings.value) } - fun checkLocationServicesEnabled() : Boolean { + private fun isLocationServicesEnabled() : Boolean { val locationManager = application.getSystemService(Context.LOCATION_SERVICE) as LocationManager return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) } + + fun isLocationServicesNeeded() : Boolean { + return(!isLocationServicesEnabled() && Build.VERSION.SDK_INT > Build.VERSION_CODES.P) + } } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt index c219eee2..59d53c49 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt @@ -1,7 +1,6 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support import android.content.Intent -import android.content.pm.PackageManager import android.net.Uri import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable @@ -19,8 +18,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/NumberUtils.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/NumberUtils.kt index dd2a5b8f..257a2d68 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/NumberUtils.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/NumberUtils.kt @@ -8,11 +8,16 @@ import java.time.Instant object NumberUtils { private const val BYTES_IN_KB = 1024L + private val keyValidationRegex = """^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=${'$'}""".toRegex() fun bytesToKB(bytes : Long) : BigDecimal { return bytes.toBigDecimal().divide(BYTES_IN_KB.toBigDecimal()) } + fun isValidKey(key : String) : Boolean { + return key.matches(keyValidationRegex) + } + fun generateRandomTunnelName() : String { return "tunnel${(Math.random() * 100000).toInt()}" } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cc42aeb1..633bc2c4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,10 +21,9 @@ Notifications permission is required for the app to work properly. Open Settings Add Trusted SSID - Trusted SSID + Trusted SSIDs Tunnels - Select Tunnel - Enable auto tunneling + Enable auto-tunneling Tunnel on mobile data \"Allow all the time\" location permission is required for retrieving Wi-Fi SSID in the background. Permission is needed for this feature. Location permission is required for this feature to work properly. @@ -32,6 +31,7 @@ "Retry" View Privacy Policy Okay + Tunnel on ethernet This feature requires background location permission to enable Wi-Fi SSID monitoring even while the application is closed. For more details, please see the Privacy Policy linked on the Support screen. Background Location Disclosure Thank you for using WG Tunnel! If you are experiencing issues with the app, please reach out on Discord or create an issue on Github. I will try to address the issue as quickly as possible. Thank you! @@ -51,7 +51,7 @@ Include Tunnel all applications Configuration changes saved. - Save changes + Save Icon No thanks Turn on @@ -75,7 +75,7 @@ Failed connection to - Attempting to connect to server after 30 seconds of no response. Attempting to reconnect to server after more than one minute of no response. - Enable Always-On VPN support + Allow Always-On VPN Please select a tunnel first Unable to detect Location Services which are required for this feature. Please enable Location Services. Check again @@ -97,4 +97,31 @@ Failed to open file stream. An unknown error occurred. No file app installed. + Other + Auto-tunneling + Select tunnel to use + VPN on + VPN off + Primary VPN on + Primary VPN off + Create from scratch + Set primary + Action requires auto-tunnel disabled + Add peer + Info + Done + Interface + Rotate keys + Private key + Copy public key + base64 key + comma separated list + Listen port + (random) + (auto) + (optional) + (optional, not recommended) + Pre-shared key + seconds + Persistent keepalive \ No newline at end of file diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 00000000..0fbe1c3d --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + diff --git a/asset/android_feature_graphic.png b/asset/android_feature_graphic.png index 5e009c05..e69de29b 100644 Binary files a/asset/android_feature_graphic.png and b/asset/android_feature_graphic.png differ diff --git a/asset/android_tv_banner.jpg b/asset/android_tv_banner.jpg index 8c15b527..e69de29b 100644 Binary files a/asset/android_tv_banner.jpg and b/asset/android_tv_banner.jpg differ diff --git a/asset/config_screen.png b/asset/config_screen.png index 97d497b3..86080eac 100644 Binary files a/asset/config_screen.png and b/asset/config_screen.png differ diff --git a/asset/config_screen_old.png b/asset/config_screen_old.png new file mode 100644 index 00000000..97d497b3 Binary files /dev/null and b/asset/config_screen_old.png differ diff --git a/asset/main_screen.png b/asset/main_screen.png index 2b3c6262..12a68531 100644 Binary files a/asset/main_screen.png and b/asset/main_screen.png differ diff --git a/asset/main_screen_old.png b/asset/main_screen_old.png index 09b9a2e1..2b3c6262 100644 Binary files a/asset/main_screen_old.png and b/asset/main_screen_old.png differ diff --git a/asset/main_screen_tv.png b/asset/main_screen_tv.png index 6f18bdee..e69de29b 100644 Binary files a/asset/main_screen_tv.png and b/asset/main_screen_tv.png differ diff --git a/asset/main_screen_tv2.png b/asset/main_screen_tv2.png index 6c37ef64..e69de29b 100644 Binary files a/asset/main_screen_tv2.png and b/asset/main_screen_tv2.png differ diff --git a/asset/settings_screen.png b/asset/settings_screen.png index 38d6600e..ecec78d4 100644 Binary files a/asset/settings_screen.png and b/asset/settings_screen.png differ diff --git a/asset/settings_screen_old.png b/asset/settings_screen_old.png index 3bbe5da0..38d6600e 100644 Binary files a/asset/settings_screen_old.png and b/asset/settings_screen_old.png differ diff --git a/asset/settings_screen_tv.png b/asset/settings_screen_tv.png index f1c4a945..e69de29b 100644 Binary files a/asset/settings_screen_tv.png and b/asset/settings_screen_tv.png differ diff --git a/asset/support_screen.png b/asset/support_screen.png index 6405c5d2..d2569cfe 100644 Binary files a/asset/support_screen.png and b/asset/support_screen.png differ diff --git a/asset/support_screen_old.png b/asset/support_screen_old.png index 1d91904d..6405c5d2 100644 Binary files a/asset/support_screen_old.png and b/asset/support_screen_old.png differ diff --git a/asset/support_screen_tv.png b/asset/support_screen_tv.png index 8917f107..e69de29b 100644 Binary files a/asset/support_screen_tv.png and b/asset/support_screen_tv.png differ diff --git a/asset/tv_banner.png b/asset/tv_banner.png index b0e7c637..e69de29b 100644 Binary files a/asset/tv_banner.png and b/asset/tv_banner.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/config_screen.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/config_screen.png index 97d497b3..86080eac 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/config_screen.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/config_screen.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/main_screen.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/main_screen.png index 2b3c6262..12a68531 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/main_screen.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/main_screen.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/settings_screen.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/settings_screen.png index 38d6600e..ecec78d4 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/settings_screen.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/settings_screen.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/support_screen.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/support_screen.png index 6405c5d2..d2569cfe 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/support_screen.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/support_screen.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3d485f83..ab14a366 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,25 +6,25 @@ appcompat = "1.6.1" coreKtx = "1.12.0" espressoCore = "3.5.1" firebase-crashlytics-gradle = "2.9.9" -google-services = "4.3.15" +google-services = "4.4.0" hiltAndroid = "2.48" 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.1" -material3 = "1.1.1" -navigationCompose = "2.7.2" -roomVersion = "2.6.0-beta01" +material-icons-extended = "1.5.2" +material3 = "1.1.2" +navigationCompose = "2.7.3" +roomVersion = "2.6.0-rc01" timber = "5.0.1" tunnel = "1.0.20230706" -androidGradlePlugin = "8.2.0-beta03" +androidGradlePlugin = "8.3.0-alpha06" kotlin="1.9.10" ksp="1.9.10-1.0.13" -composeBom="2023.09.00" -firebaseBom="32.2.3" -compose="1.5.1" -crashlytics="18.4.1" +composeBom="2023.09.02" +firebaseBom="32.3.1" +compose="1.5.2" +crashlytics="18.4.3" analytics="21.3.0" composeCompiler="1.5.3" zxingAndroidEmbedded = "4.3.0" @@ -38,6 +38,7 @@ accompanist-flowlayout = { module = "com.google.accompanist:accompanist-flowlayo accompanist-navigation-animation = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanist" } accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } + #room androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "roomVersion" } androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "roomVersion" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 96f2e7a9..42d1885d 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Apr 24 22:46:45 EDT 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-rc-2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists