diff --git a/README.md b/README.md index 5581c80..12dc1ee 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ This is an alternative Android Application for [WireGuard](https://www.wireguard ## Screenshots

- + diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9169f10..eab4a92 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7e594a4..580267a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + @@ -59,12 +60,13 @@ android:stopWithTask="false" android:exported="false"> - + diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/BootReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt similarity index 84% rename from app/src/main/java/com/zaneschepke/wireguardautotunnel/BootReceiver.kt rename to app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt index e1d35f8..ca90313 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/BootReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt @@ -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 - @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!!) ) } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt new file mode 100644 index 0000000..2f79f54 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt @@ -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 + 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() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/barcode/QRScanner.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/barcode/QRScanner.kt index ab71f7c..dd502c7 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/barcode/QRScanner.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/barcode/QRScanner.kt @@ -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 { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt index 9845fbf..4e5b792 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt @@ -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( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt index 816bf3a..68a2853 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt @@ -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() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/BaseNetworkService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/BaseNetworkService.kt index cdf5151..f6f3745 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/BaseNetworkService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/BaseNetworkService.kt @@ -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>(val context: Context, networkCapability : Int) : NetworkService { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/NotificationService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/NotificationService.kt index cc8f0d9..fb3d476 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/NotificationService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/NotificationService.kt @@ -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, diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/WireGuardNotification.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/WireGuardNotification.kt index bc29647..ad68582 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/WireGuardNotification.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/WireGuardNotification.kt @@ -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() + } } } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/HandshakeStatus.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/HandshakeStatus.kt new file mode 100644 index 0000000..c6bbf35 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/HandshakeStatus.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnService.kt index 91be50c..346a49a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnService.kt @@ -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 val tunnelName : SharedFlow + val statistics : SharedFlow + val lastHandshake : SharedFlow> + val handshakeStatus : SharedFlow fun getState() : Tunnel.State } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt index def01b1..289af22 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt @@ -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( - replay = 1, - onBufferOverflow = BufferOverflow.SUSPEND, - extraBufferCapacity = 1) + replay = 1) + + private val _handshakeStatus = MutableSharedFlow(replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST) override val state get() = _state.asSharedFlow() + private val _statistics = MutableSharedFlow(replay = 1) + override val statistics get() = _statistics.asSharedFlow() + + private val _lastHandshake = MutableSharedFlow>(replay = 1) + override val lastHandshake get() = _lastHandshake.asSharedFlow() + + override val handshakeStatus: SharedFlow + 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() + 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()) + } } } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt index 5e59c86..4cce6a2 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt @@ -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")) } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Routes.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Routes.kt index a78fedd..6a5387c 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Routes.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Routes.kt @@ -10,7 +10,8 @@ enum class Routes { Main, Settings, Support, - Config; + Config, + Detail; companion object { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt index e3d5203..2a9ce58 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt @@ -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() } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt index caae8b9..8d57350 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt @@ -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) : ViewModel() { + private val tunnelRepo : Repository, + private val settingsRepo : Repository) : ViewModel() { private val _tunnel = MutableStateFlow(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() + )) + } + } + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt new file mode 100644 index 0000000..ca8bc06 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt @@ -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") + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt new file mode 100644 index 0000000..59c5698 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt @@ -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, private val vpnService : VpnService + +) : ViewModel() { + + private val _tunnel = MutableStateFlow(null) + val tunnel get() = _tunnel.asStateFlow() + + private val _tunnelName = MutableStateFlow("") + 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 + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt index 8ed00be..7a31bea 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt @@ -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(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() - } - ) - } - }) + }) } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt index 91ea6cf..1400cf1 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt @@ -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) { + "" + } } } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Color.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Color.kt index ca25516..b886444 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Color.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Color.kt @@ -9,4 +9,9 @@ val virdigris = Color(0xFF5BC0BE) val Purple40 = Color(0xFF6650a4) val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFFFFFFFF) \ No newline at end of file +val Pink40 = Color(0xFFFFFFFF) + +//status colors +val brickRed = Color(0xFFCE4257) +val pinkRed = Color(0xFFEF476F) +val mint = Color(0xFF52B788) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/NumberUtils.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/NumberUtils.kt new file mode 100644 index 0000000..7cdeddc --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/NumberUtils.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1879d0b..030e873 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -58,4 +58,23 @@ Turn on Map Bad config. Please try again. + Interface + Public key + Waiting for the Barcode UI module to be downloaded. + Barcode module downloading. Try again. + Invalid QR code. Try again. + Addresses + DNS servers + MTU + Peer + Allowed IPs + Endpoint + Transfer + Last handshake + Name + Restart Tunnel + VPN Connection Failed + Failed connection to - + Attempting to connect to server after 30 seconds of no response. + Attempting to reconnect to server after more than one minute of no response. \ No newline at end of file diff --git a/asset/main_screen.png b/asset/main_screen.png index 09b9a2e..2b3c626 100644 Binary files a/asset/main_screen.png and b/asset/main_screen.png differ diff --git a/asset/main_screen_old.png b/asset/main_screen_old.png new file mode 100644 index 0000000..09b9a2e Binary files /dev/null and b/asset/main_screen_old.png differ diff --git a/build.gradle.kts b/build.gradle.kts index 7b15349..d626eec 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 }