Compare commits

..

7 Commits

Author SHA1 Message Date
Zane Schepke b3cb7a7988 docs: update README support screenshot 2023-07-23 00:39:30 -04:00
Zane Schepke 6415f49377 chore: add feature graphic and banner to assets 2023-07-23 00:34:17 -04:00
Zane Schepke baed8ff2e7 feat: support for Always-On VPN and Android TV
Added support for Android TV

Added support for Always-On VPN in settings

Fixes bug where handshake notification is not dismissed after successful handshake

Closes #2,  Closes #5
2023-07-23 00:16:16 -04:00
Zane Schepke f6612abe28 fix: service requirements for Android 14
Fixes requirement for foregroundServiceType which is causing crashes on Android 14
2023-07-20 14:23:43 -04:00
Zane Schepke 7ca5de1836 fix: screens not scrollable in landscape
Fixes not being able to scroll when in landscape mode if content is off screen.

Fixes FAB being in the way of controlling tunnels by making it disappear on scroll down.

Bump targetSdk to 34
2023-07-19 09:02:31 -04:00
Zane Schepke 509d22a98c build: disable crashlytics on debug builds 2023-07-18 17:07:36 -04:00
Zane Schepke 68b0902398 feat: add tunnel details screen and handshake monitoring
Adds details screen which display details of tunnel configuration as well as last handshake and rx/tx of peer.

Adds last handshake monitoring with statuses and thresholds.

Adds handshake/connection notifications based on last successful handshake.

Adds status LED next to tunnel on main screen.

Fixes bug where first click on QR code could result in nothing happening if QR code module is being downloaded. Now shows message to user.

Fixes bug where changes made after editing tunnel were not propagated to settings if that tunnel was configured as the default tunnel.

Fixes bug causing crash if wrong config file selected

Update README

Closes #7, Closes #6
2023-07-18 11:53:03 -04:00
24 changed files with 346 additions and 142 deletions
+3 -2
View File
@@ -29,8 +29,8 @@ 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" />
<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>
<span align="left">
@@ -44,6 +44,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
* Always-on VPN for Android support
* Configurable Trusted Network list
* Optional auto connect on mobile data
* Automatic service restart after reboot
+5 -5
View File
@@ -5,25 +5,25 @@ plugins {
id("org.jetbrains.kotlin.android")
kotlin("kapt")
id("com.google.dagger.hilt.android")
id("io.objectbox")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
id("org.jetbrains.kotlin.plugin.serialization")
id("io.objectbox")
}
android {
namespace = "com.zaneschepke.wireguardautotunnel"
compileSdk = 33
compileSdk = 34
val versionMajor = 2
val versionMinor = 1
val versionPatch = 1
val versionMinor = 2
val versionPatch = 0
val versionBuild = 0
defaultConfig {
applicationId = "com.zaneschepke.wireguardautotunnel"
minSdk = 29
targetSdk = 33
targetSdk = 34
versionCode = versionMajor * 10000 + versionMinor * 1000 + versionPatch * 100 + versionBuild
versionName = "${versionMajor}.${versionMinor}.${versionPatch}"
+6 -1
View File
@@ -31,7 +31,7 @@
},
{
"id": "2:8887605597748372702",
"lastPropertyId": "8:4981008812459251156",
"lastPropertyId": "9:4468844863383145378",
"name": "Settings",
"properties": [
{
@@ -59,6 +59,11 @@
"id": "6:3370284381040192129",
"name": "defaultTunnel",
"type": 9
},
{
"id": "9:4468844863383145378",
"name": "isAlwaysOnVpnEnabled",
"type": 1
}
],
"relations": []
+2 -6
View File
@@ -59,11 +59,6 @@
"id": "6:3370284381040192129",
"name": "defaultTunnel",
"type": 9
},
{
"id": "8:4981008812459251156",
"name": "showProminentDisclosure",
"type": 1
}
],
"relations": []
@@ -91,7 +86,8 @@
7555225587864607050,
969146862000617878,
5057486545428188436,
2814640993034665120
2814640993034665120,
4981008812459251156
],
"retiredRelationUids": [],
"version": 1
+27 -3
View File
@@ -1,23 +1,33 @@
<?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" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"
android:maxSdkVersion="30" />
android:maxSdkVersion="30"
tools:ignore="LeanbackUsesWifi" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/>
<!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!--android tv support-->
<uses-feature android:name="android.software.leanback"
android:required="false" />
<uses-feature android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
@@ -29,6 +39,7 @@
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:banner="@mipmap/ic_launcher_foreground"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
@@ -40,24 +51,34 @@
android:theme="@style/Theme.WireguardAutoTunnel">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.foreground.ForegroundService"
android:enabled="true"
android:foregroundServiceType="remoteMessaging"
android:exported="false">
</service>
<service
android:name=".service.foreground.WireGuardTunnelService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:enabled="true"
android:foregroundServiceType="remoteMessaging"
android:exported="false">
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
<meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="true"/>
</service>
<service
android:name=".service.foreground.WireGuardConnectivityWatcherService"
android:enabled="true"
android:stopWithTask="false"
android:foregroundServiceType="location"
android:permission=""
android:exported="false">
</service>
<receiver android:enabled="true" android:name=".receiver.BootReceiver"
@@ -70,5 +91,8 @@
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui"/>
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="true" />
</application>
</manifest>
@@ -1,6 +1,7 @@
package com.zaneschepke.wireguardautotunnel
import android.app.Application
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import dagger.hilt.android.HiltAndroidApp
@@ -15,7 +16,10 @@ class WireGuardAutoTunnel : Application() {
override fun onCreate() {
super.onCreate()
if(BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
if(BuildConfig.DEBUG) {
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(false);
Timber.plant(Timber.DebugTree())
}
settingsRepo.init()
}
}
@@ -7,7 +7,6 @@ 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
@@ -4,7 +4,10 @@ import android.app.Service
import android.content.Intent
import android.os.Bundle
import android.os.IBinder
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import timber.log.Timber
import javax.inject.Inject
open class ForegroundService : Service() {
@@ -24,6 +27,10 @@ open class ForegroundService : Service() {
when (action) {
Action.START.name -> startService(intent.extras)
Action.STOP.name -> stopService(intent.extras)
"android.net.VpnService" -> {
Timber.d("Always-on VPN starting service")
startService(intent.extras)
}
else -> Timber.d("This should never happen. No action in the received intent")
}
} else {
@@ -5,9 +5,11 @@ import android.content.Intent
import android.os.Bundle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
@@ -26,6 +28,9 @@ class WireGuardTunnelService : ForegroundService() {
@Inject
lateinit var vpnService : VpnService
@Inject
lateinit var settingsRepo: Repository<Settings>
@Inject
lateinit var notificationService : NotificationService
@@ -48,7 +53,16 @@ class WireGuardTunnelService : ForegroundService() {
stopService(extras)
}
} else {
Timber.e("Tunnel config null")
Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll();
if(!settings.isNullOrEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
}
}
}
}
CoroutineScope(job).launch {
@@ -62,6 +76,7 @@ class WireGuardTunnelService : ForegroundService() {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
HandshakeStatus.HEALTHY -> {
@@ -74,6 +89,7 @@ class WireGuardTunnelService : ForegroundService() {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
}
@@ -10,5 +10,6 @@ data class Settings(
var isAutoTunnelEnabled : Boolean = false,
var isTunnelOnMobileDataEnabled : Boolean = false,
var trustedNetworkSSIDs : MutableList<String> = mutableListOf(),
var defaultTunnel : String? = null
var defaultTunnel : String? = null,
var isAlwaysOnVpnEnabled : Boolean = false
)
@@ -3,10 +3,8 @@ 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
@@ -72,127 +70,162 @@ fun ConfigScreen(
}
if(tunnel != null) {
Column(
LazyColumn(
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),
item {
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)
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(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) {
}
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 {
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(
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))
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))
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))
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)
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)
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)
}
}, Modifier.padding(25.dp)) {
Text(stringResource(id = R.string.save_changes))
}
}, Modifier.padding(25.dp)) {
Text(stringResource(id = R.string.save_changes))
}
}
}
@@ -9,6 +9,8 @@ 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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -43,6 +45,7 @@ fun DetailScreen(
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle()
val lastHandshake by viewModel.lastHandshake.collectAsStateWithLifecycle(emptyMap())
LaunchedEffect(Unit) {
viewModel.getTunnelById(id)
}
@@ -51,12 +54,14 @@ fun DetailScreen(
val interfaceKey = tunnel?.`interface`?.keyPair?.publicKey?.toBase64().toString()
val addresses = tunnel?.`interface`?.addresses!!.joinToString()
val dnsServers = tunnel?.`interface`?.dnsServers!!.joinToString()
val mtu = tunnel?.`interface`?.mtu?.get().toString()
val optionalMtu = tunnel?.`interface`?.mtu
val mtu = if(optionalMtu?.isPresent == true) optionalMtu.get().toString() else "None"
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(padding)
) {
Row(
@@ -92,7 +97,7 @@ fun DetailScreen(
tunnel?.peers?.forEach{
val peerKey = it.publicKey.toBase64().toString()
val allowedIps = it.allowedIps.joinToString()
val endpoint = it.endpoint.get().toString()
val endpoint = if(it.endpoint.isPresent) it.endpoint.get().toString() else "None"
Text(stringResource(R.string.peer), fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(stringResource(R.string.public_key), fontStyle = FontStyle.Italic)
Text(text = peerKey, modifier = Modifier.clickable {
@@ -3,6 +3,9 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.annotation.SuppressLint
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
@@ -43,11 +46,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
@@ -65,7 +73,6 @@ import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed
import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.ui.theme.pinkRed
import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@@ -78,6 +85,7 @@ fun MainScreen(
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
@@ -89,6 +97,23 @@ fun MainScreen(
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
// Nested scroll for control FAB
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Hide FAB
if (available.y < -1) {
isVisible.value = false
}
// Show FAB
if (available.y > 1) {
isVisible.value = true
}
return Offset.Zero
}
}
}
LaunchedEffect(viewState.value) {
if (viewState.value.showSnackbarMessage) {
val result = snackbarHostState.showSnackbar(
@@ -118,20 +143,26 @@ fun MainScreen(
})
},
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
FloatingActionButton(
modifier = Modifier.padding(bottom = 90.dp),
onClick = {
showBottomSheet = true
},
containerColor = MaterialTheme.colorScheme.secondary,
shape = RoundedCornerShape(16.dp),
floatingActionButton = {
AnimatedVisibility(
visible = isVisible.value,
enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 }),
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray,
)
FloatingActionButton(
modifier = Modifier.padding(bottom = 90.dp),
onClick = {
showBottomSheet = true
},
containerColor = MaterialTheme.colorScheme.secondary,
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray,
)
}
}
}
) {
@@ -204,7 +235,8 @@ fun MainScreen(
.padding(padding)
) {
LazyColumn(modifier = Modifier.fillMaxSize()) {
LazyColumn(modifier = Modifier.fillMaxSize()
.nestedScroll(nestedScrollConnection),) {
items(tunnels.toList()) { tunnel ->
RowListItem(leadingIcon = Icons.Rounded.Circle,
leadingIconColor = when (handshakeStatus) {
@@ -86,6 +86,7 @@ class MainViewModel @Inject constructor(private val application : Application,
val setting = settings[0]
setting.defaultTunnel = null
setting.isAutoTunnelEnabled = false
setting.isAlwaysOnVpnEnabled = false
settingsRepo.save(setting)
}
}
@@ -16,8 +16,10 @@ 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.rememberScrollState
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.filled.Close
import androidx.compose.material.icons.outlined.Add
@@ -91,6 +93,8 @@ fun SettingsScreen(
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
var currentText by remember { mutableStateOf("") }
val scrollState = rememberScrollState()
var isLocationServicesEnabled by remember { mutableStateOf(viewModel.checkLocationServicesEnabled())}
LaunchedEffect(viewState) {
if (viewState.showSnackbarMessage) {
@@ -120,6 +124,7 @@ fun SettingsScreen(
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)
@@ -172,12 +177,41 @@ fun SettingsScreen(
}
return
}
if(!isLocationServicesEnabled) {
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(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
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.clickable(indication = null, interactionSource = interactionSource) {
focusManager.clearFocus()
}
@@ -192,6 +226,7 @@ fun SettingsScreen(
) {
Text(stringResource(R.string.enable_auto_tunnel))
Switch(
enabled = !settings.isAlwaysOnVpnEnabled,
checked = settings.isAutoTunnelEnabled,
onCheckedChange = {
scope.launch {
@@ -208,12 +243,12 @@ fun SettingsScreen(
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = {
if(!settings.isAutoTunnelEnabled) {
if(!(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)) {
expanded = !expanded }},
modifier = Modifier.padding(start = 15.dp, top = 5.dp, bottom = 10.dp),
) {
TextField(
enabled = !settings.isAutoTunnelEnabled,
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = settings.defaultTunnel?.let {
TunnelConfig.from(it).name }
?: "",
@@ -261,11 +296,11 @@ fun SettingsScreen(
scope.launch {
viewModel.onDeleteTrustedSSID(ssid)
}
}, text = ssid, icon = Icons.Filled.Close, enabled = !settings.isAutoTunnelEnabled)
}, text = ssid, icon = Icons.Filled.Close, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled))
}
}
OutlinedTextField(
enabled = !settings.isAutoTunnelEnabled,
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
@@ -301,7 +336,7 @@ fun SettingsScreen(
) {
Text(stringResource(R.string.tunnel_mobile_data))
Switch(
enabled = !settings.isAutoTunnelEnabled,
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnMobileDataEnabled,
onCheckedChange = {
scope.launch {
@@ -310,6 +345,24 @@ fun SettingsScreen(
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
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()
}
}
)
}
}
}
@@ -1,6 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.app.Application
import android.content.Context
import android.location.LocationManager
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
@@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(private val application : Application,
private val tunnelRepo : Repository<TunnelConfig>, private val settingsRepo : Repository<Settings>
@@ -31,6 +34,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
val viewState get() = _viewState.asStateFlow()
init {
checkLocationServicesEnabled()
viewModelScope.launch {
settingsRepo.itemFlow.collect {
val settings = it.first()
@@ -69,7 +73,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
suspend fun toggleAutoTunnel() {
if(_settings.value.defaultTunnel.isNullOrEmpty() && !_settings.value.isAutoTunnelEnabled) {
showSnackBarMessage("Please select a tunnel first")
showSnackBarMessage(application.getString(R.string.select_tunnel_message))
return
}
if(_settings.value.isAutoTunnelEnabled) {
@@ -99,8 +103,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
}
}
}
private suspend fun showSnackBarMessage(message : String) {
suspend fun showSnackBarMessage(message : String) {
_viewState.emit(_viewState.value.copy(
showSnackbarMessage = true,
snackbarMessage = message,
@@ -118,4 +121,20 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
showSnackbarMessage = false
))
}
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))
}
}
fun checkLocationServicesEnabled() : Boolean {
val locationManager =
application.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
}
@@ -11,6 +11,8 @@ 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.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
@@ -43,6 +45,7 @@ fun SupportScreen(padding : PaddingValues) {
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(padding)) {
Text(stringResource(R.string.support_text), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 15.sp)
Row(
+5
View File
@@ -77,4 +77,9 @@
<string name="failed_connection_to">Failed connection to -</string>
<string name="initial_connection_failure_message">Attempting to connect to server after 30 seconds of no response.</string>
<string name="lost_connection_failure_message">Attempting to reconnect to server after more than one minute of no response.</string>
<string name="always_on_vpn_support">Enable Always-On VPN support</string>
<string name="select_tunnel_message">Please select a tunnel first</string>
<string name="location_services_not_detected">Unable to detect Location Services which are required for this feature. Please enable Location Services.</string>
<string name="check_again">Check again</string>
<string name="detecting_location_services_disabled">Detecting Location Services disabled</string>
</resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB