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
This commit is contained in:
Zane Schepke 2023-07-17 16:32:16 -04:00
parent 0c45558293
commit 68b0902398
28 changed files with 612 additions and 107 deletions

View File

@ -27,7 +27,7 @@ This is an alternative Android Application for [WireGuard](https://www.wireguard
## Screenshots
<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="Support" style="padding-left:25px" src="./asset/support_screen.png" width="200" />

View File

@ -16,8 +16,8 @@ android {
compileSdk = 33
val versionMajor = 2
val versionMinor = 0
val versionPatch = 3
val versionMinor = 1
val versionPatch = 2
val versionBuild = 0
defaultConfig {
@ -54,7 +54,7 @@ android {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.7"
kotlinCompilerExtensionVersion = "1.4.8"
}
packaging {
resources {
@ -83,7 +83,7 @@ dependencies {
debugImplementation("androidx.compose.ui:ui-test-manifest")
//wireguard tunnel
implementation("com.wireguard.android:tunnel:1.0.20230405")
implementation("com.wireguard.android:tunnel:1.0.20230427")
//logging
implementation("com.jakewharton.timber:timber:5.0.1")
@ -127,6 +127,7 @@ dependencies {
}
kapt {
correctErrorTypes = true
}

View File

@ -11,6 +11,7 @@
<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"/>
<!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
@ -59,12 +60,13 @@
android:stopWithTask="false"
android:exported="false">
</service>
<receiver android:enabled="true" android:name=".BootReceiver"
<receiver android:enabled="true" android:name=".receiver.BootReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter>
</receiver>
<receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui"/>

View File

@ -1,8 +1,9 @@
package com.zaneschepke.wireguardautotunnel
package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceTracker
@ -11,7 +12,6 @@ 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
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
@ -23,21 +23,19 @@ class BootReceiver : BroadcastReceiver() {
@Inject
lateinit var settingsRepo : Repository<Settings>
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
CoroutineScope(SupervisorJob()).launch {
try {
val settings = settingsRepo.getAll()
if (!settings.isNullOrEmpty()) {
val setting = settings[0]
val setting = settings.first()
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
val defaultTunnel = TunnelConfig.from(setting.defaultTunnel!!)
ServiceTracker.actionOnService(
Action.START, context,
WireGuardConnectivityWatcherService::class.java,
mapOf(context.resources.getString(R.string.tunnel_extras_key) to
defaultTunnel.toString())
setting.defaultTunnel!!)
)
}
}

View File

@ -0,0 +1,56 @@
package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceTracker
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() {
@Inject
lateinit var settingsRepo : Repository<Settings>
override fun onReceive(context: Context, intent: Intent?) {
CoroutineScope(SupervisorJob()).launch {
try {
val settings = settingsRepo.getAll()
if (!settings.isNullOrEmpty()) {
val setting = settings.first()
if (setting.defaultTunnel != null) {
ServiceTracker.actionOnService(
Action.STOP, context,
WireGuardTunnelService::class.java,
mapOf(
context.resources.getString(R.string.tunnel_extras_key) to
setting.defaultTunnel!!
)
)
delay(1000)
ServiceTracker.actionOnService(
Action.START, context,
WireGuardTunnelService::class.java,
mapOf(
context.resources.getString(R.string.tunnel_extras_key) to
setting.defaultTunnel!!
)
)
}
}
} finally {
cancel()
}
}
}
}

View File

@ -13,6 +13,7 @@ class QRScanner @Inject constructor(private val gmsBarcodeScanner: GmsBarcodeSca
gmsBarcodeScanner.startScan().addOnSuccessListener {
trySend(it.rawValue)
}.addOnFailureListener {
trySend(it.message)
Timber.e(it.message)
}
awaitClose {

View File

@ -20,7 +20,6 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
@ -123,7 +122,6 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
}
}
@OptIn(DelicateCoroutinesApi::class)
private fun startWatcherJob() {
watcherJob = CoroutineScope(SupervisorJob()).launch {
val settings = settingsRepo.getAll();
@ -151,13 +149,17 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
is NetworkStatus.CapabilitiesChanged -> {
isMobileDataConnected = true
Timber.d("Mobile data capabilities changed")
if(!isWifiConnected && setting.isTunnelOnMobileDataEnabled
&& vpnService.getState() == Tunnel.State.DOWN)
startVPN()
if(!disconnecting && !connecting) {
if(!isWifiConnected && setting.isTunnelOnMobileDataEnabled
&& vpnService.getState() == Tunnel.State.DOWN)
startVPN()
}
}
is NetworkStatus.Unavailable -> {
isMobileDataConnected = false
if(!isWifiConnected && vpnService.getState() == Tunnel.State.UP) stopVPN()
if(!disconnecting && !connecting) {
if(!isWifiConnected && vpnService.getState() == Tunnel.State.UP) stopVPN()
}
Timber.d("Lost mobile data connection")
}
}
@ -178,7 +180,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
Timber.d("Not connect and not disconnecting")
val ssid = wifiService.getNetworkName(it.networkCapabilities);
Timber.d("SSID: $ssid")
if ((setting.trustedNetworkSSIDs?.contains(ssid) == false) && vpnService.getState() == Tunnel.State.DOWN) {
if (!setting.trustedNetworkSSIDs.contains(ssid) && vpnService.getState() == Tunnel.State.DOWN) {
Timber.d("Starting VPN Tunnel for untrusted network: $ssid")
startVPN()
} else if (!disconnecting && vpnService.getState() == Tunnel.State.UP && setting.trustedNetworkSSIDs.contains(

View File

@ -1,9 +1,12 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
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.TunnelConfig
import dagger.hilt.android.AndroidEntryPoint
@ -28,6 +31,8 @@ class WireGuardTunnelService : ForegroundService() {
private lateinit var job : Job
private var tunnelName : String = ""
override fun startService(extras : Bundle?) {
super.startService(extras)
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
@ -36,10 +41,8 @@ class WireGuardTunnelService : ForegroundService() {
if(tunnelConfigString != null) {
try {
val tunnelConfig = TunnelConfig.from(tunnelConfigString)
val state = vpnService.startTunnel(tunnelConfig)
if (state == Tunnel.State.UP) {
launchVpnConnectedNotification(tunnelConfig.name)
}
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
} catch (e : Exception) {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
@ -48,6 +51,34 @@ class WireGuardTunnelService : ForegroundService() {
Timber.e("Tunnel config null")
}
}
CoroutineScope(job).launch {
var didShowConnected = false
var didShowFailedHandshakeNotification = false
vpnService.handshakeStatus.collect {
when(it) {
HandshakeStatus.NOT_STARTED -> {
}
HandshakeStatus.NEVER_CONNECTED -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message))
didShowFailedHandshakeNotification = true
}
}
HandshakeStatus.HEALTHY -> {
if(!didShowConnected) {
launchVpnConnectedNotification()
didShowConnected = true
}
}
HandshakeStatus.UNHEALTHY -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message))
didShowFailedHandshakeNotification = true
}
}
}
}
}
}
override fun stopService(extras : Bundle?) {
@ -59,7 +90,7 @@ class WireGuardTunnelService : ForegroundService() {
stopSelf()
}
private fun launchVpnConnectedNotification(tunnelName : String) {
private fun launchVpnConnectedNotification() {
val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
@ -70,6 +101,22 @@ class WireGuardTunnelService : ForegroundService() {
)
super.startForeground(foregroundId, notification)
}
private fun launchVpnConnectionFailedNotification(message : String) {
val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
action = PendingIntent.getBroadcast(this,0,Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE),
actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed),
onGoing = false,
showTimestamp = true,
description = message
)
super.startForeground(foregroundId, notification)
}
private fun cancelJob() {
if(this::job.isInitialized) {
job.cancel()

View File

@ -1,6 +1,5 @@
package com.zaneschepke.wireguardautotunnel.service.network
import android.app.Service
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
@ -10,12 +9,10 @@ import android.net.wifi.SupplicantState
import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager
import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Context, networkCapability : Int) : NetworkService<T> {

View File

@ -2,12 +2,15 @@ package com.zaneschepke.wireguardautotunnel.service.notification
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
interface NotificationService {
fun createNotification(
channelId: String,
channelName: String,
title: String = "",
action: PendingIntent? = null,
actionText: String? = null,
description: String,
showTimestamp : Boolean = false,
importance: Int = NotificationManager.IMPORTANCE_HIGH,

View File

@ -20,13 +20,15 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
channelId: String,
channelName: String,
title: String,
action: PendingIntent?,
actionText: String?,
description: String,
showTimestamp: Boolean,
importance: Int,
vibration: Boolean,
onGoing: Boolean,
lights: Boolean
) : Notification {
): Notification {
val channel = NotificationChannel(
channelId,
channelName,
@ -42,7 +44,12 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
notificationManager.createNotificationChannel(channel)
val pendingIntent: PendingIntent =
Intent(context, MainActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
PendingIntent.getActivity(
context,
0,
notificationIntent,
PendingIntent.FLAG_IMMUTABLE
)
}
val builder: Notification.Builder =
@ -50,14 +57,21 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
context,
channelId
)
return builder
.setContentTitle(title)
.setContentText(description)
.setContentIntent(pendingIntent)
.setOngoing(onGoing)
.setShowWhen(showTimestamp)
.setSmallIcon(R.mipmap.ic_launcher_foreground)
.build()
return builder.let {
if(action != null && actionText != null) {
//TODO find a not deprecated way to do this
it.addAction(
Notification.Action.Builder(0, actionText, action)
.build())
it.setAutoCancel(true)
}
it.setContentTitle(title)
.setContentText(description)
.setContentIntent(pendingIntent)
.setOngoing(onGoing)
.setShowWhen(showTimestamp)
.setSmallIcon(R.mipmap.ic_launcher_foreground)
.build()
}
}
}

View File

@ -0,0 +1,14 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel
enum class HandshakeStatus {
HEALTHY,
UNHEALTHY,
NEVER_CONNECTED,
NOT_STARTED;
companion object {
private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 120
const val UNHEALTHY_TIME_LIMIT_SEC = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + 60
const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30
}
}

View File

@ -1,6 +1,8 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel
import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import kotlinx.coroutines.flow.SharedFlow
@ -9,5 +11,8 @@ interface VpnService : Tunnel {
suspend fun stopTunnel()
val state : SharedFlow<Tunnel.State>
val tunnelName : SharedFlow<String>
val statistics : SharedFlow<Statistics>
val lastHandshake : SharedFlow<Map<Key,Long>>
val handshakeStatus : SharedFlow<HandshakeStatus>
fun getState() : Tunnel.State
}

View File

@ -2,27 +2,51 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel
import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnService {
class WireGuardTunnel @Inject constructor(private val backend : Backend,
) : VpnService {
private val _tunnelName = MutableStateFlow("")
override val tunnelName get() = _tunnelName.asStateFlow()
private val _state = MutableSharedFlow<Tunnel.State>(
replay = 1,
onBufferOverflow = BufferOverflow.SUSPEND,
extraBufferCapacity = 1)
replay = 1)
private val _handshakeStatus = MutableSharedFlow<HandshakeStatus>(replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val state get() = _state.asSharedFlow()
private val _statistics = MutableSharedFlow<Statistics>(replay = 1)
override val statistics get() = _statistics.asSharedFlow()
private val _lastHandshake = MutableSharedFlow<Map<Key, Long>>(replay = 1)
override val lastHandshake get() = _lastHandshake.asSharedFlow()
override val handshakeStatus: SharedFlow<HandshakeStatus>
get() = _handshakeStatus.asSharedFlow()
private lateinit var statsJob : Job
override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{
return try {
if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) {
@ -60,6 +84,46 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
}
override fun onStateChange(state : Tunnel.State) {
val tunnel = this;
_state.tryEmit(state)
if(state == Tunnel.State.UP) {
statsJob = CoroutineScope(Dispatchers.IO).launch {
val handshakeMap = HashMap<Key, Long>()
var neverHadHandshakeCounter = 0
while (true) {
val statistics = backend.getStatistics(tunnel)
_statistics.emit(statistics)
statistics.peers().forEach {
val handshakeEpoch = statistics.peer(it)?.latestHandshakeEpochMillis ?: 0L
handshakeMap[it] = handshakeEpoch
if(handshakeEpoch == 0L) {
if(neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED)
} else {
_handshakeStatus.emit(HandshakeStatus.NOT_STARTED)
}
if(neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
neverHadHandshakeCounter++
}
return@forEach
}
if(NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) >= HandshakeStatus.UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.UNHEALTHY)
} else {
_handshakeStatus.emit(HandshakeStatus.HEALTHY)
}
}
_lastHandshake.emit(handshakeMap)
delay(1000)
}
}
}
if(state == Tunnel.State.DOWN) {
if(this::statsJob.isInitialized) {
statsJob.cancel()
}
_handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED)
_lastHandshake.tryEmit(emptyMap())
}
}
}

View File

@ -14,7 +14,6 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
@ -34,6 +33,7 @@ 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.detail.DetailScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
@ -44,7 +44,7 @@ import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class,
@OptIn(ExperimentalAnimationApi::class,
ExperimentalPermissionsApi::class
)
override fun onCreate(savedInstanceState: Bundle?) {
@ -164,6 +164,9 @@ class MainActivity : AppCompatActivity() {
composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(1000))
}) { ConfigScreen(padding = padding, navController = navController, id = it.arguments?.getString("id"))}
composable("${Routes.Detail.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(1000))
}) { DetailScreen(padding = padding, id = it.arguments?.getString("id")) }
}
}
}

View File

@ -10,7 +10,8 @@ enum class Routes {
Main,
Settings,
Support,
Config;
Config,
Detail;
companion object {

View File

@ -7,20 +7,24 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun RowListItem(text : String, onHold : () -> Unit, rowButton : @Composable() () -> Unit ) {
fun RowListItem(leadingIcon : ImageVector? = null, leadingIconColor : Color = Color.Gray, text : String, onHold : () -> Unit, onClick: () -> Unit, rowButton : @Composable() () -> Unit ) {
Box(
modifier = Modifier
.combinedClickable(
onClick = {
onClick()
},
onLongClick = {
onHold()
@ -34,7 +38,17 @@ fun RowListItem(text : String, onHold : () -> Unit, rowButton : @Composable() ()
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text)
Row(verticalAlignment = Alignment.CenterVertically,) {
if(leadingIcon != null) {
Icon(
leadingIcon, "status",
tint = leadingIconColor,
modifier = Modifier.padding(end = 10.dp).size(15.dp)
)
}
Text(text)
}
rowButton()
}
}

View File

@ -9,6 +9,7 @@ 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.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@ -18,7 +19,8 @@ import javax.inject.Inject
@HiltViewModel
class ConfigViewModel @Inject constructor(private val application : Application,
private val tunnelRepo : Repository<TunnelConfig>) : ViewModel() {
private val tunnelRepo : Repository<TunnelConfig>,
private val settingsRepo : Repository<Settings>) : ViewModel() {
private val _tunnel = MutableStateFlow<TunnelConfig?>(null)
private val _tunnelName = MutableStateFlow("")
@ -127,6 +129,17 @@ class ConfigViewModel @Inject constructor(private val application : Application,
wgQuick = wgQuick
)?.let {
tunnelRepo.save(it)
val settings = settingsRepo.getAll()
if(settings != null) {
val setting = settings[0]
if(setting.defaultTunnel != null) {
if(it.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
settingsRepo.save(setting.copy(
defaultTunnel = it.toString()
))
}
}
}
}
}
}

View File

@ -0,0 +1,135 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.detail
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import java.time.Duration
import java.time.Instant
@Composable
fun DetailScreen(
viewModel: DetailViewModel = hiltViewModel(),
padding: PaddingValues,
id : String?
) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val tunnelStats by viewModel.tunnelStats.collectAsStateWithLifecycle(null)
val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle()
val lastHandshake by viewModel.lastHandshake.collectAsStateWithLifecycle(emptyMap())
LaunchedEffect(Unit) {
viewModel.getTunnelById(id)
}
if(tunnel != null) {
val interfaceKey = tunnel?.`interface`?.keyPair?.publicKey?.toBase64().toString()
val addresses = tunnel?.`interface`?.addresses!!.joinToString()
val dnsServers = tunnel?.`interface`?.dnsServers!!.joinToString()
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()
.padding(padding)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(stringResource(R.string.config_interface), fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(stringResource(R.string.name), fontStyle = FontStyle.Italic)
Text(text = tunnelName, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(tunnelName))
})
Text(stringResource(R.string.public_key), fontStyle = FontStyle.Italic)
Text(text = interfaceKey, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(interfaceKey))
})
Text(stringResource(R.string.addresses), fontStyle = FontStyle.Italic)
Text(text = addresses, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(addresses))
})
Text(stringResource(R.string.dns_servers), fontStyle = FontStyle.Italic)
Text(text = dnsServers, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(dnsServers))
})
Text(stringResource(R.string.mtu), fontStyle = FontStyle.Italic)
Text(text = mtu, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(mtu))
})
Box(modifier = Modifier.padding(10.dp))
tunnel?.peers?.forEach{
val peerKey = it.publicKey.toBase64().toString()
val allowedIps = it.allowedIps.joinToString()
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 {
clipboardManager.setText(AnnotatedString(peerKey))
})
Text(stringResource(id = R.string.allowed_ips), fontStyle = FontStyle.Italic)
Text(text = allowedIps, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(allowedIps))
})
Text(stringResource(R.string.endpoint), fontStyle = FontStyle.Italic)
Text(text = endpoint, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(endpoint))
})
if (tunnelStats != null) {
val totalRx = tunnelStats?.totalRx() ?: 0
val totalTx = tunnelStats?.totalTx() ?: 0
if((totalRx + totalTx != 0L)) {
val rxKB = NumberUtils.bytesToKB(tunnelStats!!.totalRx())
val txKB = NumberUtils.bytesToKB(tunnelStats!!.totalTx())
Text(stringResource(R.string.transfer), fontStyle = FontStyle.Italic)
Text("rx: ${NumberUtils.formatDecimalTwoPlaces(rxKB)} KB tx: ${NumberUtils.formatDecimalTwoPlaces(txKB)} KB")
Text(stringResource(R.string.last_handshake), fontStyle = FontStyle.Italic)
val handshakeEpoch = lastHandshake[it.publicKey]
if(handshakeEpoch != null) {
if(handshakeEpoch == 0L) {
Text("Never")
} else {
val time = Instant.ofEpochMilli(handshakeEpoch)
Text("${Duration.between(time, Instant.now()).seconds} seconds ago")
}
}
}
}
}
}
}
}
}
}

View File

@ -0,0 +1,45 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.detail
import androidx.lifecycle.ViewModel
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
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 DetailViewModel @Inject constructor(private val tunnelRepo : Repository<TunnelConfig>, private val vpnService : VpnService
) : ViewModel() {
private val _tunnel = MutableStateFlow<Config?>(null)
val tunnel get() = _tunnel.asStateFlow()
private val _tunnelName = MutableStateFlow<String>("")
val tunnelName = _tunnelName.asStateFlow()
val tunnelStats get() = vpnService.statistics
val lastHandshake get() = vpnService.lastHandshake
private var config : TunnelConfig? = null
suspend fun getTunnelById(id : String?) : TunnelConfig? {
return try {
if(id != null) {
config = tunnelRepo.getById(id.toLong())
if (config != null) {
_tunnel.emit(TunnelConfig.configFromQuick(config!!.wgQuick))
_tunnelName.emit(config!!.name)
}
return config
}
return null
} catch (e : Exception) {
Timber.e(e.message)
null
}
}
}

View File

@ -19,6 +19,7 @@ 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.Circle
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.Divider
@ -58,16 +59,22 @@ 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.HandshakeStatus
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.theme.brickRed
import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.ui.theme.pinkRed
import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValues,
snackbarHostState : SnackbarHostState, navController: NavController) {
fun MainScreen(
viewModel: MainViewModel = hiltViewModel(), padding: PaddingValues,
snackbarHostState: SnackbarHostState, navController: NavController
) {
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
@ -76,12 +83,12 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) }
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(HandshakeStatus.NOT_STARTED)
val viewState = viewModel.viewState.collectAsStateWithLifecycle()
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
LaunchedEffect(viewState.value) {
if (viewState.value.showSnackbarMessage) {
val result = snackbarHostState.showSnackbar(
@ -156,8 +163,15 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
}
.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))
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
@ -170,8 +184,15 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
}
.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))
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)
)
}
}
}
@ -185,36 +206,49 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(tunnels.toList()) { tunnel ->
RowListItem(text = tunnel.name, onHold = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
scope.launch {
viewModel.showSnackBarMessage(context.resources.getString(R.string.turn_off_tunnel))
RowListItem(leadingIcon = Icons.Rounded.Circle,
leadingIconColor = when (handshakeStatus) {
HandshakeStatus.HEALTHY -> mint
HandshakeStatus.UNHEALTHY -> brickRed
HandshakeStatus.NOT_STARTED -> Color.Gray
HandshakeStatus.NEVER_CONNECTED -> brickRed
},
text = tunnel.name,
onHold = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
scope.launch {
viewModel.showSnackBarMessage(context.resources.getString(R.string.turn_off_tunnel))
}
return@RowListItem
}
return@RowListItem
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel;
}, rowButton = {
if (tunnel.id == selectedTunnel?.id) {
Row() {
IconButton(onClick = {
navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}")
}) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
}
IconButton(onClick = { viewModel.onDelete(tunnel) }) {
Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete))
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel;
},
onClick = { navController.navigate("${Routes.Detail.name}/${tunnel.id}") },
rowButton = {
if (tunnel.id == selectedTunnel?.id) {
Row() {
IconButton(onClick = {
navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}")
}) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
}
IconButton(onClick = { viewModel.onDelete(tunnel) }) {
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete)
)
}
}
} else {
Switch(
checked = (state == Tunnel.State.UP && tunnel.name == tunnelName),
onCheckedChange = { checked ->
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
}
)
}
} else {
Switch(
checked = (state == Tunnel.State.UP && tunnel.name == tunnelName),
onCheckedChange = { checked ->
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
}
)
}
})
})
}
}
}

View File

@ -42,6 +42,8 @@ class MainViewModel @Inject constructor(private val application : Application,
val viewState get() = _viewState.asStateFlow()
val tunnels get() = tunnelRepo.itemFlow
val state get() = vpnService.state
val handshakeStatus get() = vpnService.handshakeStatus
val tunnelName get() = vpnService.tunnelName
private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow()
@ -102,33 +104,34 @@ class MainViewModel @Inject constructor(private val application : Application,
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 if(!it.isNullOrEmpty() && it.contains(application.resources.getString(R.string.barcode_downloading))) {
showSnackBarMessage(application.resources.getString(R.string.barcode_downloading_message))
} else {
showSnackBarMessage("Invalid QR code. Try again.")
showSnackBarMessage(application.resources.getString(R.string.barcode_error))
}
}
}
fun onTunnelFileSelected(uri : Uri) {
val fileName = getFileName(application.applicationContext, uri)
val extension = getFileExtensionFromFileName(fileName)
if(extension != ".conf") {
viewModelScope.launch {
showSnackBarMessage(application.resources.getString(R.string.file_extension_message))
}
return
}
val stream = application.applicationContext.contentResolver.openInputStream(uri)
stream ?: return
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
try {
val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
viewModelScope.launch {
tunnelRepo.save(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
val fileName = getFileName(application.applicationContext, uri)
val extension = getFileExtensionFromFileName(fileName)
if(extension != ".conf") {
viewModelScope.launch {
showSnackBarMessage(application.resources.getString(R.string.file_extension_message))
}
return
}
val stream = application.applicationContext.contentResolver.openInputStream(uri)
stream ?: return
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
viewModelScope.launch {
tunnelRepo.save(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
}
stream.close()
} catch(_: BadConfigException) {
viewModelScope.launch {
@ -177,6 +180,10 @@ class MainViewModel @Inject constructor(private val application : Application,
}
private fun getFileExtensionFromFileName(fileName : String) : String {
return fileName.substring(fileName.lastIndexOf('.'))
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e : Exception) {
""
}
}
}

View File

@ -9,4 +9,9 @@ val virdigris = Color(0xFF5BC0BE)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFFFFFFFF)
val Pink40 = Color(0xFFFFFFFF)
//status colors
val brickRed = Color(0xFFCE4257)
val pinkRed = Color(0xFFEF476F)
val mint = Color(0xFF52B788)

View File

@ -0,0 +1,25 @@
package com.zaneschepke.wireguardautotunnel.util
import java.math.BigDecimal
import java.text.DecimalFormat
import java.time.Duration
import java.time.Instant
object NumberUtils {
private const val BYTES_IN_KB = 1024L
fun bytesToKB(bytes : Long) : BigDecimal {
return bytes.toBigDecimal().divide(BYTES_IN_KB.toBigDecimal())
}
fun formatDecimalTwoPlaces(bigDecimal: BigDecimal) : String {
val df = DecimalFormat("#.##")
return df.format(bigDecimal)
}
fun getSecondsBetweenTimestampAndNow(epoch : Long) : Long {
val time = Instant.ofEpochMilli(epoch)
return Duration.between(time, Instant.now()).seconds
}
}

View File

@ -58,4 +58,23 @@
<string name="turn_on">Turn on</string>
<string name="map">Map</string>
<string name="bad_config">Bad config. Please try again.</string>
<string name="config_interface">Interface</string>
<string name="public_key">Public key</string>
<string name="barcode_downloading">Waiting for the Barcode UI module to be downloaded.</string>
<string name="barcode_downloading_message">Barcode module downloading. Try again.</string>
<string name="barcode_error">Invalid QR code. Try again.</string>
<string name="addresses">Addresses</string>
<string name="dns_servers">DNS servers</string>
<string name="mtu">MTU</string>
<string name="peer">Peer</string>
<string name="allowed_ips">Allowed IPs</string>
<string name="endpoint">Endpoint</string>
<string name="transfer">Transfer</string>
<string name="last_handshake">Last handshake</string>
<string name="name">Name</string>
<string name="restart">Restart Tunnel</string>
<string name="vpn_connection_failed">VPN Connection Failed</string>
<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>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

BIN
asset/main_screen_old.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@ -2,19 +2,19 @@
buildscript {
val objectBoxVersion by extra("3.5.1")
val hiltVersion by extra("2.44")
val hiltVersion by extra("2.47")
val accompanistVersion by extra("0.31.2-alpha")
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.6")
classpath("com.google.firebase:firebase-crashlytics-gradle:2.9.7")
}
}
plugins {
id("com.android.application") version "8.2.0-alpha08" apply false
id("org.jetbrains.kotlin.android") version "1.8.21" apply false
id("org.jetbrains.kotlin.android") version "1.8.22" apply false
id("com.google.dagger.hilt.android") version "2.44" apply false
kotlin("plugin.serialization") version "1.8.21" apply false
kotlin("plugin.serialization") version "1.8.22" apply false
}