feat: add split tunneling and tunnel configuration screen

This adds a tunnel configuration screen where user can configure split application tunneling and change tunnel name.

Additional QR code validation added to prevent adding of invalid QR codes to tunnels.

Minor UI quality-of-life changes.

Closes #3
This commit is contained in:
Zane Schepke 2023-07-04 14:21:45 -04:00
parent 49bf7fa8b9
commit 8b81831910
14 changed files with 467 additions and 99 deletions

View File

@ -28,6 +28,7 @@ This is an alternative Android Application for [WireGuard](https://www.wireguard
<p float="center"> <p float="center">
<img label="Main" style="padding-right:25px" src="./asset/main_screen.png" width="200" /> <img label="Main" style="padding-right:25px" src="./asset/main_screen.png" width="200" />
<img label="Config" style="padding-left:25px" src="./asset/config_screen.png" width="200" />
<img label="Settings" style="padding-left:25px" src="./asset/settings_screen.png" width="200" /> <img label="Settings" style="padding-left:25px" src="./asset/settings_screen.png" width="200" />
<img label="Support" style="padding-left:25px" src="./asset/support_screen.png" width="200" /> <img label="Support" style="padding-left:25px" src="./asset/support_screen.png" width="200" />
</p> </p>

View File

@ -15,8 +15,8 @@ android {
namespace = "com.zaneschepke.wireguardautotunnel" namespace = "com.zaneschepke.wireguardautotunnel"
compileSdk = 33 compileSdk = 33
val versionMajor = 1 val versionMajor = 2
val versionMinor = 2 val versionMinor = 0
val versionPatch = 0 val versionPatch = 0
val versionBuild = 0 val versionBuild = 0
@ -101,6 +101,7 @@ dependencies {
implementation("com.google.accompanist:accompanist-permissions:${rExtra.get("accompanistVersion")}") implementation("com.google.accompanist:accompanist-permissions:${rExtra.get("accompanistVersion")}")
implementation("com.google.accompanist:accompanist-flowlayout:${rExtra.get("accompanistVersion")}") implementation("com.google.accompanist:accompanist-flowlayout:${rExtra.get("accompanistVersion")}")
implementation("com.google.accompanist:accompanist-navigation-animation:${rExtra.get("accompanistVersion")}") implementation("com.google.accompanist:accompanist-navigation-animation:${rExtra.get("accompanistVersion")}")
implementation("com.google.accompanist:accompanist-drawablepainter:${rExtra.get("accompanistVersion")}")
//db //db
implementation("io.objectbox:objectbox-kotlin:${rExtra.get("objectBoxVersion")}") implementation("io.objectbox:objectbox-kotlin:${rExtra.get("objectBoxVersion")}")

View File

@ -17,6 +17,11 @@
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!--start service on boot permission--> <!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
</queries>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:name=".WireGuardAutoTunnel" android:name=".WireGuardAutoTunnel"

View File

@ -19,11 +19,64 @@ data class TunnelConfig(
var name : String, var name : String,
var wgQuick : String var wgQuick : String
) { ) {
override fun toString(): String { override fun toString(): String {
return Json.encodeToString(serializer(), this) return Json.encodeToString(serializer(), this)
} }
companion object { companion object {
private const val INCLUDED_APPLICATIONS = "IncludedApplications = "
private const val EXCLUDED_APPLICATIONS = "ExcludedApplications = "
private const val INTERFACE = "[Interface]"
private const val NEWLINE_CHAR = "\n"
private const val APP_CONFIG_SEPARATOR = ", "
private fun addApplicationsToConfig(appConfig : String, wgQuick : String) : String {
val configList = wgQuick.split(NEWLINE_CHAR).toMutableList()
val interfaceIndex = configList.indexOf(INTERFACE)
configList.add(interfaceIndex + 1, appConfig)
return configList.joinToString(NEWLINE_CHAR)
}
fun clearAllApplicationsFromConfig(wgQuick : String) : String {
val configList = wgQuick.split(NEWLINE_CHAR).toMutableList()
val itr = configList.iterator()
while (itr.hasNext()) {
val next = itr.next()
if(next.contains(INCLUDED_APPLICATIONS) || next.contains(EXCLUDED_APPLICATIONS)) {
itr.remove()
}
}
return configList.joinToString(NEWLINE_CHAR)
}
fun setExcludedApplicationsOnQuick(packages : List<String>, wgQuick: String) : String {
if(packages.isEmpty()) {
return wgQuick
}
val clearedWgQuick = clearAllApplicationsFromConfig(wgQuick)
val excludeConfig = buildExcludedApplicationsString(packages)
return addApplicationsToConfig(excludeConfig, clearedWgQuick)
}
fun setIncludedApplicationsOnQuick(packages : List<String>, 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>) : String {
return EXCLUDED_APPLICATIONS + packages.joinToString(APP_CONFIG_SEPARATOR)
}
private fun buildIncludedApplicationsString(packages : List<String>) : String {
return INCLUDED_APPLICATIONS + packages.joinToString(APP_CONFIG_SEPARATOR)
}
fun from(string : String) : TunnelConfig { fun from(string : String) : TunnelConfig {
return Json.decodeFromString<TunnelConfig>(string) return Json.decodeFromString<TunnelConfig>(string)
} }

View File

@ -33,6 +33,7 @@ import com.wireguard.android.backend.GoBackend
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
@ -121,11 +122,11 @@ class MainActivity : AppCompatActivity() {
) )
else -> { else -> {
fadeIn(animationSpec = tween(2000)) fadeIn(animationSpec = tween(1000))
} }
} }
}) { }) {
MainScreen(padding = padding, snackbarHostState = snackbarHostState) MainScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController)
} }
composable(Routes.Settings.name, enterTransition = { composable(Routes.Settings.name, enterTransition = {
when (initialState.destination.route) { when (initialState.destination.route) {
@ -143,7 +144,7 @@ class MainActivity : AppCompatActivity() {
} }
else -> { else -> {
fadeIn(animationSpec = tween(2000)) fadeIn(animationSpec = tween(1000))
} }
} }
}) { SettingsScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController) } }) { SettingsScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController) }
@ -156,10 +157,13 @@ class MainActivity : AppCompatActivity() {
) )
else -> { else -> {
fadeIn(animationSpec = tween(2000)) fadeIn(animationSpec = tween(1000))
} }
} }
}) { SupportScreen(padding = padding) } }) { SupportScreen(padding = padding) }
composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(1000))
}) { ConfigScreen(padding = padding, navController = navController, id = it.arguments?.getString("id"))}
} }
} }
} }

View File

@ -9,7 +9,8 @@ import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
enum class Routes { enum class Routes {
Main, Main,
Settings, Settings,
Support; Support,
Config;
companion object { companion object {

View File

@ -0,0 +1,200 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
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.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
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.R
import com.zaneschepke.wireguardautotunnel.ui.Routes
import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ConfigScreen(
viewModel: ConfigViewModel = hiltViewModel(),
padding: PaddingValues,
navController: NavController,
id : String?
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val scope = rememberCoroutineScope()
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()
LaunchedEffect(Unit) {
viewModel.getTunnelById(id)
viewModel.emitAllInternetCapablePackages()
viewModel.emitCurrentPackageConfigurations(id)
}
if(tunnel != null) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
OutlinedTextField(
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 = allApplications,
onCheckedChange = {
viewModel.onAllApplicationsChange(!allApplications)
}
)
}
if(!allApplications) {
Row(modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) {
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween){
Text(stringResource(id = R.string.include))
Checkbox(
checked = include,
onCheckedChange = {
viewModel.onIncludeChange(!include)
}
)
}
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween){
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !include,
onCheckedChange = {
viewModel.onIncludeChange(!include)
}
)
}
}
LazyColumn(modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(.75f)
.padding(horizontal = 14.dp, vertical = 7.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.Start) {
items(packages) { pack ->
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(5.dp)
) {
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(pack.applicationInfo.loadLabel(context.packageManager).toString(), modifier = Modifier.padding(5.dp))
}
Checkbox(
checked = (checkedPackages.contains(pack.packageName)),
onCheckedChange = {
if(it) viewModel.onAddCheckedPackage(pack.packageName) else viewModel.onRemoveCheckedPackage(pack.packageName)
}
)
}
}
}
}
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)
}
}, Modifier.padding(25.dp)) {
Text(stringResource(id = R.string.save_changes))
}
}
}
}
}

View File

@ -0,0 +1,133 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.Manifest
import android.app.Application
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.os.Build
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class ConfigViewModel @Inject constructor(private val application : Application,
private val tunnelRepo : Repository<TunnelConfig>) : ViewModel() {
private val _tunnel = MutableStateFlow<TunnelConfig?>(null)
private val _tunnelName = MutableStateFlow("")
val tunnelName get() = _tunnelName.asStateFlow()
val tunnel get() = _tunnel.asStateFlow()
private val _packages = MutableStateFlow(emptyList<PackageInfo>())
val packages get() = _packages.asStateFlow()
private val packageManager = application.packageManager
private val _checkedPackages = MutableStateFlow(mutableStateListOf<String>())
val checkedPackages get() = _checkedPackages.asStateFlow()
private val _include = MutableStateFlow(true)
val include get() = _include.asStateFlow()
private val _allApplications = MutableStateFlow(true)
val allApplications get() = _allApplications.asStateFlow()
suspend fun getTunnelById(id : String?) : TunnelConfig? {
return try {
if(id != null) {
val config = tunnelRepo.getById(id.toLong())
if (config != null) {
_tunnel.emit(config)
_tunnelName.emit(config.name)
}
return config
}
return null
} catch (e : Exception) {
Timber.e(e.message)
null
}
}
fun onTunnelNameChange(name : String) {
_tunnelName.value = name
}
fun onIncludeChange(include : Boolean) {
_include.value = include
}
fun onAddCheckedPackage(packageName : String) {
_checkedPackages.value.add(packageName)
}
fun onAllApplicationsChange(allApplications : Boolean) {
_allApplications.value = allApplications
}
fun onRemoveCheckedPackage(packageName : String) {
_checkedPackages.value.remove(packageName)
}
suspend fun emitCurrentPackageConfigurations(id : String?) {
val tunnelConfig = getTunnelById(id)
if(tunnelConfig != null) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val excludedApps = config.`interface`.excludedApplications
val includedApps = config.`interface`.includedApplications
if(excludedApps.isNullOrEmpty() && includedApps.isNullOrEmpty()) {
_allApplications.emit(true)
return
}
if(excludedApps.isEmpty()) {
_include.emit(true)
_checkedPackages.emit(includedApps.toMutableStateList())
} else {
_include.emit(false)
_checkedPackages.emit(excludedApps.toMutableStateList())
}
_allApplications.emit(false)
}
}
suspend fun emitAllInternetCapablePackages() {
_packages.emit(getAllInternetCapablePackages())
}
private fun getAllInternetCapablePackages() : List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
}
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(permissions, PackageManager.PackageInfoFlags.of(0L))
} else {
@Suppress("DEPRECATION")
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
suspend fun onSaveAllChanges() {
var wgQuick = _tunnel.value?.wgQuick
if(wgQuick != null) {
wgQuick = if(_include.value) {
TunnelConfig.setIncludedApplicationsOnQuick(_checkedPackages.value, wgQuick)
} else {
TunnelConfig.setExcludedApplicationsOnQuick(_checkedPackages.value, wgQuick)
}
if(_allApplications.value) {
wgQuick = TunnelConfig.clearAllApplicationsFromConfig(wgQuick)
}
_tunnel.value?.copy(
name = _tunnelName.value,
wgQuick = wgQuick
)?.let {
tunnelRepo.save(it)
}
}
}
}

View File

@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@ -22,10 +21,7 @@ import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
@ -33,17 +29,12 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -57,7 +48,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.modifier.modifierLocalConsumer
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -65,9 +55,11 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -75,7 +67,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValues, fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValues,
snackbarHostState : SnackbarHostState) { snackbarHostState : SnackbarHostState, navController: NavController) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val context = LocalContext.current val context = LocalContext.current
@ -85,7 +77,6 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val viewState = viewModel.viewState.collectAsStateWithLifecycle() val viewState = viewModel.viewState.collectAsStateWithLifecycle()
var showAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) } var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN) val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("") val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
@ -131,7 +122,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
contentDescription = "Add Tunnel", contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray, tint = Color.DarkGray,
) )
} }
@ -157,24 +148,30 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
) { ) {
// Sheet content // Sheet content
Row( Row(
modifier = Modifier.fillMaxWidth().clickable { modifier = Modifier
.fillMaxWidth()
.clickable {
showBottomSheet = false showBottomSheet = false
pickFileLauncher.launch("*/*") pickFileLauncher.launch("*/*")
}.padding(10.dp) }
.padding(10.dp)
) { ) {
Icon(Icons.Filled.FileOpen, contentDescription = "File Open", modifier = Modifier.padding(10.dp)) Icon(Icons.Filled.FileOpen, contentDescription = stringResource(id = R.string.open_file), modifier = Modifier.padding(10.dp))
Text("Add tunnel from files", modifier = Modifier.padding(10.dp)) Text(stringResource(id = R.string.add_from_file), modifier = Modifier.padding(10.dp))
} }
Divider() Divider()
Row(modifier = Modifier.fillMaxWidth().clickable { Row(modifier = Modifier
.fillMaxWidth()
.clickable {
scope.launch { scope.launch {
showBottomSheet = false showBottomSheet = false
viewModel.onTunnelQRSelected() viewModel.onTunnelQRSelected()
} }
}.padding(10.dp) }
.padding(10.dp)
) { ) {
Icon(Icons.Filled.QrCode, contentDescription = "QR Scan", modifier = Modifier.padding(10.dp)) Icon(Icons.Filled.QrCode, contentDescription = stringResource(id = R.string.qr_scan), modifier = Modifier.padding(10.dp))
Text("Add tunnel from QR code", modifier = Modifier.padding(10.dp)) Text(stringResource(id = R.string.add_from_qr), modifier = Modifier.padding(10.dp))
} }
} }
} }
@ -201,12 +198,12 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
if (tunnel.id == selectedTunnel?.id) { if (tunnel.id == selectedTunnel?.id) {
Row() { Row() {
IconButton(onClick = { IconButton(onClick = {
showAlertDialog = true navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}")
}) { }) {
Icon(Icons.Rounded.Edit, "Edit") Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
} }
IconButton(onClick = { viewModel.onDelete(tunnel) }) { IconButton(onClick = { viewModel.onDelete(tunnel) }) {
Icon(Icons.Rounded.Delete, "Delete") Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete))
} }
} }
} else { } else {
@ -220,40 +217,6 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
}) })
} }
} }
if (showAlertDialog && selectedTunnel != null) {
AlertDialog(onDismissRequest = {
showAlertDialog = false
}, confirmButton = {
Button(onClick = {
if (tunnels.any { it.name == selectedTunnel?.name }) {
Toast.makeText(
context,
context.resources.getString(R.string.tunnel_exists),
Toast.LENGTH_LONG
)
.show()
return@Button
}
viewModel.onEditTunnel(selectedTunnel!!)
showAlertDialog = false
}) {
Text("Save")
}
},
title = { Text("Tunnel Edit") }, text = {
OutlinedTextField(
value = selectedTunnel!!.name,
onValueChange = {
selectedTunnel = selectedTunnel!!.copy(
name = it
)
},
label = { Text("Tunnel Name") },
modifier = Modifier.padding(start = 15.dp, top = 5.dp),
maxLines = 1,
)
})
}
} }
} }
} }

View File

@ -90,22 +90,6 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
} }
fun onEditTunnel(tunnel: TunnelConfig) {
viewModelScope.launch {
tunnelRepo.save(tunnel)
val settings = settingsRepo.getAll()
if(!settings.isNullOrEmpty() && settings[0].defaultTunnel != null) {
val setting = settings[0]
val defaultTunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
if(defaultTunnelConfig.id == tunnel.id) {
setting.defaultTunnel = tunnel.toString()
settingsRepo.save(setting)
}
}
}
}
fun onTunnelStart(tunnelConfig : TunnelConfig) = viewModelScope.launch { fun onTunnelStart(tunnelConfig : TunnelConfig) = viewModelScope.launch {
ServiceTracker.actionOnService( Action.START, application, WireGuardTunnelService::class.java, ServiceTracker.actionOnService( Action.START, application, WireGuardTunnelService::class.java,
mapOf(application.resources.getString(R.string.tunnel_extras_key) to tunnelConfig.toString())) mapOf(application.resources.getString(R.string.tunnel_extras_key) to tunnelConfig.toString()))
@ -118,8 +102,10 @@ class MainViewModel @Inject constructor(private val application : Application,
suspend fun onTunnelQRSelected() { suspend fun onTunnelQRSelected() {
codeScanner.scan().collect { codeScanner.scan().collect {
Timber.d(it) Timber.d(it)
if(!it.isNullOrEmpty()) { if(!it.isNullOrEmpty() && it.contains(application.resources.getString(R.string.config_validation))) {
tunnelRepo.save(TunnelConfig(name = defaultConfigName(), wgQuick = it)) tunnelRepo.save(TunnelConfig(name = defaultConfigName(), wgQuick = it))
} else {
showSnackBarMessage("Invalid QR code. Try again.")
} }
} }
} }

View File

@ -4,28 +4,24 @@ import android.Manifest
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.provider.Settings import android.provider.Settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Done
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.AddCircleOutline
import androidx.compose.material.icons.outlined.Done
import androidx.compose.material.icons.rounded.LocationOff import androidx.compose.material.icons.rounded.LocationOff
import androidx.compose.material.icons.rounded.Map
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -51,6 +47,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@ -68,7 +65,6 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.Routes import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class, @OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class,
@ -84,6 +80,9 @@ fun SettingsScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current
val interactionSource = remember { MutableInteractionSource() }
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val viewState by viewModel.viewState.collectAsStateWithLifecycle() val viewState by viewModel.viewState.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle()
@ -122,7 +121,7 @@ fun SettingsScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding)) { .padding(padding)) {
Icon(Icons.Rounded.LocationOff, contentDescription = "Map", modifier = Modifier Icon(Icons.Rounded.LocationOff, contentDescription = stringResource(id = R.string.map), modifier = Modifier
.padding(30.dp) .padding(30.dp)
.size(128.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_title), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 20.sp)
@ -138,7 +137,7 @@ fun SettingsScreen(
Button(onClick = { Button(onClick = {
navController.navigate(Routes.Main.name) navController.navigate(Routes.Main.name)
}) { }) {
Text("No thanks") Text(stringResource(id = R.string.no_thanks))
} }
Button(onClick = { Button(onClick = {
scope.launch { scope.launch {
@ -149,7 +148,7 @@ fun SettingsScreen(
context.startActivity(intentSettings) context.startActivity(intentSettings)
} }
}) { }) {
Text("Turn on") Text(stringResource(id = R.string.turn_on))
} }
} }
} }
@ -179,6 +178,9 @@ fun SettingsScreen(
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.clickable(indication = null, interactionSource = interactionSource) {
focusManager.clearFocus()
}
.padding(padding) .padding(padding)
) { ) {
Row( Row(

View File

@ -37,4 +37,24 @@
<string name="support_text">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!</string> <string name="support_text">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!</string>
<string name="trusted_ssid_empty_description">Enter SSID</string> <string name="trusted_ssid_empty_description">Enter SSID</string>
<string name="trusted_ssid_value_description">Submit SSID</string> <string name="trusted_ssid_value_description">Submit SSID</string>
<string name="config_validation">[Interface]</string>
<string name="invalid_qr">Invalid QR code.</string>
<string name="add_from_file">Add tunnel from files</string>
<string name="open_file">File Open</string>
<string name="add_from_qr">Add tunnel from QR code</string>
<string name="qr_scan">QR Scan</string>
<string name="tunnel_edit">Tunnel Edit</string>
<string name="tunnel_name">Tunnel Name</string>
<string name="edit">Edit</string>
<string name="delete">Delete</string>
<string name="add_tunnel">Add Tunnel</string>
<string name="exclude">Exclude</string>
<string name="include">Include</string>
<string name="tunnel_all">Tunnel all applications</string>
<string name="config_changes_saved">Configuration changes saved.</string>
<string name="save_changes">Save changes</string>
<string name="icon">Icon</string>
<string name="no_thanks">No thanks</string>
<string name="turn_on">Turn on</string>
<string name="map">Map</string>
</resources> </resources>

BIN
asset/config_screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

View File

@ -4,12 +4,11 @@ buildscript {
val objectBoxVersion by extra("3.5.1") val objectBoxVersion by extra("3.5.1")
val hiltVersion by extra("2.44") val hiltVersion by extra("2.44")
val accompanistVersion by extra("0.31.2-alpha") val accompanistVersion by extra("0.31.2-alpha")
val cameraVersion by extra("1.3.0-beta01")
dependencies { dependencies {
classpath("io.objectbox:objectbox-gradle-plugin:$objectBoxVersion") classpath("io.objectbox:objectbox-gradle-plugin:$objectBoxVersion")
classpath("com.google.gms:google-services:4.3.15") classpath("com.google.gms:google-services:4.3.15")
classpath("com.google.firebase:firebase-crashlytics-gradle:2.9.5") classpath("com.google.firebase:firebase-crashlytics-gradle:2.9.6")
} }
} }