Compare commits

..

5 Commits

Author SHA1 Message Date
Zane Schepke cb3a7d024b fix: discord link expired 2023-07-04 22:28:39 -04:00
Zane Schepke 259be21b04 docs: update README 2023-07-04 17:03:30 -04:00
Zane Schepke 8b81831910 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
2023-07-04 14:21:45 -04:00
Zane Schepke 49bf7fa8b9 feat: add scan qr code option for importing tunnel configs
Closes #1
2023-07-02 22:45:36 -04:00
Zane Schepke 2f53a8f3b4 Update VPN connection required message to add always-on VPN disclaimer 2023-07-02 11:59:24 -04:00
19 changed files with 601 additions and 89 deletions
+2
View File
@@ -28,6 +28,7 @@ This is an alternative Android Application for [WireGuard](https://www.wireguard
<p float="center">
<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="Support" style="padding-left:25px" src="./asset/support_screen.png" width="200" />
</p>
@@ -42,6 +43,7 @@ The inspiration for this app came from the inconvenience of constantly having to
* Add tunnels via .conf file
* Auto connect to VPN based on Wi-Fi SSID
* Split tunneling by application
* Configurable Trusted Network list
* Optional auto connect on mobile data
* Automatic service restart after reboot
+11 -5
View File
@@ -15,9 +15,9 @@ android {
namespace = "com.zaneschepke.wireguardautotunnel"
compileSdk = 33
val versionMajor = 1
val versionMinor = 1
val versionPatch = 5
val versionMajor = 2
val versionMinor = 0
val versionPatch = 1
val versionBuild = 0
defaultConfig {
@@ -71,7 +71,7 @@ dependencies {
implementation("androidx.compose.ui:ui")
implementation("androidx.compose.ui:ui-graphics")
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material3:material3:1.1.1")
implementation("androidx.appcompat:appcompat:1.6.1")
testImplementation("junit:junit:4.13.2")
@@ -89,7 +89,7 @@ dependencies {
implementation("com.jakewharton.timber:timber:5.0.1")
// compose navigation
implementation("androidx.navigation:navigation-compose:2.5.3")
implementation("androidx.navigation:navigation-compose:2.6.0")
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
// hilt
@@ -101,6 +101,7 @@ dependencies {
implementation("com.google.accompanist:accompanist-permissions:${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-drawablepainter:${rExtra.get("accompanistVersion")}")
//db
implementation("io.objectbox:objectbox-kotlin:${rExtra.get("objectBoxVersion")}")
@@ -120,6 +121,11 @@ dependencies {
implementation("com.google.firebase:firebase-crashlytics-ktx")
implementation("com.google.firebase:firebase-analytics-ktx")
//barcode scanning
implementation("com.google.android.gms:play-services-code-scanner:16.0.0")
}
kapt {
correctErrorTypes = true
+9
View File
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
@@ -16,6 +17,11 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
</queries>
<application
android:allowBackup="true"
android:name=".WireGuardAutoTunnel"
@@ -59,5 +65,8 @@
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui"/>
</application>
</manifest>
@@ -0,0 +1,41 @@
package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScanner
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner
import com.zaneschepke.wireguardautotunnel.service.barcode.QRScanner
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ViewModelScoped
@Module
@InstallIn(ViewModelComponent::class)
class ScannerModule {
@ViewModelScoped
@Provides
fun provideBarCodeOptions() : GmsBarcodeScannerOptions {
return GmsBarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
}
@ViewModelScoped
@Provides
fun provideBarCodeScanner(@ApplicationContext context: Context, options: GmsBarcodeScannerOptions) : GmsBarcodeScanner {
return GmsBarcodeScanning.getClient(context, options)
}
@ViewModelScoped
@Provides
fun provideQRScanner(gmsBarcodeScanner: GmsBarcodeScanner) : CodeScanner {
return QRScanner(gmsBarcodeScanner)
}
}
@@ -1,5 +1,7 @@
package com.zaneschepke.wireguardautotunnel.module
import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner
import com.zaneschepke.wireguardautotunnel.service.barcode.QRScanner
import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
import com.zaneschepke.wireguardautotunnel.service.network.WifiService
@@ -10,6 +12,7 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ServiceComponent
import dagger.hilt.android.scopes.ServiceScoped
import dagger.hilt.android.scopes.ViewModelScoped
@Module
@InstallIn(ServiceComponent::class)
@@ -0,0 +1,7 @@
package com.zaneschepke.wireguardautotunnel.service.barcode
import kotlinx.coroutines.flow.Flow
interface CodeScanner {
fun scan() : Flow<String?>
}
@@ -0,0 +1,22 @@
package com.zaneschepke.wireguardautotunnel.service.barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScanner
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import timber.log.Timber
import javax.inject.Inject
class QRScanner @Inject constructor(private val gmsBarcodeScanner: GmsBarcodeScanner) : CodeScanner {
override fun scan(): Flow<String?> {
return callbackFlow {
gmsBarcodeScanner.startScan().addOnSuccessListener {
trySend(it.rawValue)
}.addOnFailureListener {
Timber.e(it.message)
}
awaitClose {
}
}
}
}
@@ -24,7 +24,7 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
override val state get() = _state.asSharedFlow()
override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{
try {
return try {
if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) {
stopTunnel()
}
@@ -33,10 +33,10 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
val state = backend.setState(
this, Tunnel.State.UP, config)
_state.emit(state)
return state;
state;
} catch (e : Exception) {
Timber.e("Failed to start tunnel with error: ${e.message}")
return Tunnel.State.DOWN
Tunnel.State.DOWN
}
}
@@ -19,11 +19,64 @@ data class TunnelConfig(
var name : String,
var wgQuick : String
) {
override fun toString(): String {
return Json.encodeToString(serializer(), this)
}
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 {
return Json.decodeFromString<TunnelConfig>(string)
}
@@ -33,6 +33,7 @@ import com.wireguard.android.backend.GoBackend
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
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.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
@@ -121,11 +122,11 @@ class MainActivity : AppCompatActivity() {
)
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 = {
when (initialState.destination.route) {
@@ -143,7 +144,7 @@ class MainActivity : AppCompatActivity() {
}
else -> {
fadeIn(animationSpec = tween(2000))
fadeIn(animationSpec = tween(1000))
}
}
}) { SettingsScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController) }
@@ -156,10 +157,13 @@ class MainActivity : AppCompatActivity() {
)
else -> {
fadeIn(animationSpec = tween(2000))
fadeIn(animationSpec = tween(1000))
}
}
}) { SupportScreen(padding = padding) }
composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(1000))
}) { ConfigScreen(padding = padding, navController = navController, id = it.arguments?.getString("id"))}
}
}
}
@@ -9,7 +9,8 @@ import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
enum class Routes {
Main,
Settings,
Support;
Support,
Config;
companion object {
@@ -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))
}
}
}
}
}
@@ -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)
}
}
}
}
@@ -1,39 +1,41 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.annotation.SuppressLint
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.FileOpen
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Delete
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.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.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.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -53,9 +55,11 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import kotlinx.coroutines.launch
@@ -63,17 +67,17 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValues,
snackbarHostState : SnackbarHostState) {
snackbarHostState : SnackbarHostState, navController: NavController) {
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) }
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val viewState = viewModel.viewState.collectAsStateWithLifecycle()
var showAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val settings by viewModel.settings.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
@@ -111,14 +115,14 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
FloatingActionButton(
modifier = Modifier.padding(bottom = 90.dp),
onClick = {
pickFileLauncher.launch("*/*")
showBottomSheet = true
},
containerColor = MaterialTheme.colorScheme.secondary,
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = "Add Tunnel",
contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray,
)
}
@@ -135,6 +139,42 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic)
}
}
if (showBottomSheet) {
ModalBottomSheet(
onDismissRequest = {
showBottomSheet = false
},
sheetState = sheetState
) {
// Sheet content
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
showBottomSheet = false
pickFileLauncher.launch("*/*")
}
.padding(10.dp)
) {
Icon(Icons.Filled.FileOpen, contentDescription = stringResource(id = R.string.open_file), modifier = Modifier.padding(10.dp))
Text(stringResource(id = R.string.add_from_file), modifier = Modifier.padding(10.dp))
}
Divider()
Row(modifier = Modifier
.fillMaxWidth()
.clickable {
scope.launch {
showBottomSheet = false
viewModel.onTunnelQRSelected()
}
}
.padding(10.dp)
) {
Icon(Icons.Filled.QrCode, contentDescription = stringResource(id = R.string.qr_scan), modifier = Modifier.padding(10.dp))
Text(stringResource(id = R.string.add_from_qr), modifier = Modifier.padding(10.dp))
}
}
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
@@ -158,12 +198,12 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
if (tunnel.id == selectedTunnel?.id) {
Row() {
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) }) {
Icon(Icons.Rounded.Delete, "Delete")
Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete))
}
}
} else {
@@ -177,36 +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,
)
})
}
}
}
}
@@ -10,6 +10,7 @@ import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner
import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceTracker
@@ -32,7 +33,8 @@ import javax.inject.Inject
class MainViewModel @Inject constructor(private val application : Application,
private val tunnelRepo : Repository<TunnelConfig>,
private val settingsRepo : Repository<Settings>,
private val vpnService: VpnService
private val vpnService: VpnService,
private val codeScanner: CodeScanner
) : ViewModel() {
private val _viewState = MutableStateFlow(ViewState())
@@ -43,7 +45,9 @@ class MainViewModel @Inject constructor(private val application : Application,
private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow()
private val defaultConfigName = "tunnel${(Math.random() * 1000).toInt()}"
private val defaultConfigName = {
"tunnel${(Math.random() * 100000).toInt()}"
}
init {
@@ -86,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 {
ServiceTracker.actionOnService( Action.START, application, WireGuardTunnelService::class.java,
mapOf(application.resources.getString(R.string.tunnel_extras_key) to tunnelConfig.toString()))
@@ -111,6 +99,17 @@ class MainViewModel @Inject constructor(private val application : Application,
ServiceTracker.actionOnService( Action.STOP, application, WireGuardTunnelService::class.java)
}
suspend fun onTunnelQRSelected() {
codeScanner.scan().collect {
Timber.d(it)
if(!it.isNullOrEmpty() && it.contains(application.resources.getString(R.string.config_validation))) {
tunnelRepo.save(TunnelConfig(name = defaultConfigName(), wgQuick = it))
} else {
showSnackBarMessage("Invalid QR code. Try again.")
}
}
}
fun onTunnelFileSelected(uri : Uri) {
val fileName = getFileName(application.applicationContext, uri)
val extension = getFileExtensionFromFileName(fileName)
@@ -135,14 +134,14 @@ class MainViewModel @Inject constructor(private val application : Application,
private fun getFileName(context: Context, uri: Uri): String {
if (uri.scheme == "content") {
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor ?: return defaultConfigName
cursor ?: return defaultConfigName()
cursor.use {
if(cursor.moveToFirst()) {
return cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
}
}
}
return defaultConfigName
return defaultConfigName()
}
suspend fun showSnackBarMessage(message : String) {
@@ -4,28 +4,24 @@ import android.Manifest
import android.content.Intent
import android.net.Uri
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.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.wrapContentHeight
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
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.AddCircleOutline
import androidx.compose.material.icons.outlined.Done
import androidx.compose.material.icons.rounded.LocationOff
import androidx.compose.material.icons.rounded.Map
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -51,6 +47,7 @@ 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.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
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.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class,
@@ -84,6 +80,9 @@ fun SettingsScreen(
val scope = rememberCoroutineScope()
val context = LocalContext.current
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()
@@ -122,7 +121,7 @@ fun SettingsScreen(
modifier = Modifier
.fillMaxSize()
.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)
.size(128.dp))
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 = {
navController.navigate(Routes.Main.name)
}) {
Text("No thanks")
Text(stringResource(id = R.string.no_thanks))
}
Button(onClick = {
scope.launch {
@@ -149,7 +148,7 @@ fun SettingsScreen(
context.startActivity(intentSettings)
}
}) {
Text("Turn on")
Text(stringResource(id = R.string.turn_on))
}
}
}
@@ -179,6 +178,9 @@ fun SettingsScreen(
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.clickable(indication = null, interactionSource = interactionSource) {
focusManager.clearFocus()
}
.padding(padding)
) {
Row(
+22 -2
View File
@@ -12,12 +12,12 @@
<string name="turn_off_tunnel">Turn off tunnel before editing</string>
<string name="no_tunnels">No tunnels added yet!</string>
<string name="tunnel_exists">Tunnel name already exists</string>
<string name="discord_url">https://discord.gg/Ad5fuEts</string>
<string name="discord_url">https://discord.gg/rbRRNh6H7V</string>
<string name="watcher_notification_title">Watcher Service</string>
<string name="watcher_notification_text">Monitoring network state changes</string>
<string name="tunnel_start_title">VPN Connected</string>
<string name="tunnel_start_text">Connected to tunnel -</string>
<string name="vpn_permission_required">VPN permission is required for the app to work properly.</string>
<string name="vpn_permission_required">VPN permission is required for the app to work properly. If this permission is not launching, please disable \"Always-on VPN\" in your phone settings for the official WireGuard mobile app and try again.</string>
<string name="notification_permission_required">Notifications permission is required for the app to work properly.</string>
<string name="open_settings">Open Settings</string>
<string name="add_trusted_ssid">Add Trusted SSID</string>
@@ -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="trusted_ssid_empty_description">Enter 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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

+1 -1
View File
@@ -8,7 +8,7 @@ buildscript {
dependencies {
classpath("io.objectbox:objectbox-gradle-plugin:$objectBoxVersion")
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")
}
}