Compare commits

..

8 Commits

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

Added support for Always-On VPN in settings

Fixes bug where handshake notification is not dismissed after successful handshake

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

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

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

Adds last handshake monitoring with statuses and thresholds.

Adds handshake/connection notifications based on last successful handshake.

Adds status LED next to tunnel on main screen.

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

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

Fixes bug causing crash if wrong config file selected

Update README

Closes #7, Closes #6
2023-07-18 11:53:03 -04:00
Zane Schepke 0c45558293 docs: update README.md 2023-07-09 19:57:07 -04:00
44 changed files with 953 additions and 245 deletions
+6 -5
View File
@@ -5,7 +5,7 @@ WG Tunnel
<span align="center"> <span align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Discord Chat](https://img.shields.io/discord/1108285024631001111.svg)](https://discord.gg/Ad5fuEts) [![Discord Chat](https://img.shields.io/discord/1108285024631001111.svg)](https://discord.gg/rbRRNh6H7V)
</span> </span>
@@ -27,10 +27,10 @@ This is an alternative Android Application for [WireGuard](https://www.wireguard
## Screenshots ## Screenshots
<p float="center"> <p float="center">
<img label="Main" style="padding-right:25px" src="./asset/main_screen.png" width="200" /> <img label="Main" style="padding-right:25px" src="asset/main_screen.png" width="200" />
<img label="Config" style="padding-left:25px" src="./asset/config_screen.png" width="200" /> <img label="Config" style="padding-left:25px" src="./asset/config_screen.png" width="200" />
<img label="Settings" style="padding-left:25px" src="./asset/settings_screen.png" width="200" /> <img label="Settings" style="padding-left:25px" src="asset/settings_screen.png" width="200" />
<img label="Support" style="padding-left:25px" src="./asset/support_screen.png" width="200" /> <img label="Support" style="padding-left:25px" src="asset/support_screen.png" width="200" />
</p> </p>
<span align="left"> <span align="left">
@@ -44,6 +44,7 @@ The inspiration for this app came from the inconvenience of constantly having to
* Add tunnels via .conf file * Add tunnels via .conf file
* Auto connect to VPN based on Wi-Fi SSID * Auto connect to VPN based on Wi-Fi SSID
* Split tunneling by application * Split tunneling by application
* Always-on VPN for Android support
* Configurable Trusted Network list * Configurable Trusted Network list
* Optional auto connect on mobile data * Optional auto connect on mobile data
* Automatic service restart after reboot * Automatic service restart after reboot
@@ -58,4 +59,4 @@ $ cd wgtunnel
$ ./gradlew assembleRelease $ ./gradlew assembleRelease
``` ```
</span> </span>
+8 -7
View File
@@ -5,25 +5,25 @@ plugins {
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
kotlin("kapt") kotlin("kapt")
id("com.google.dagger.hilt.android") id("com.google.dagger.hilt.android")
id("io.objectbox")
id("com.google.gms.google-services") id("com.google.gms.google-services")
id("com.google.firebase.crashlytics") id("com.google.firebase.crashlytics")
id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.plugin.serialization")
id("io.objectbox")
} }
android { android {
namespace = "com.zaneschepke.wireguardautotunnel" namespace = "com.zaneschepke.wireguardautotunnel"
compileSdk = 33 compileSdk = 34
val versionMajor = 2 val versionMajor = 2
val versionMinor = 0 val versionMinor = 2
val versionPatch = 3 val versionPatch = 0
val versionBuild = 0 val versionBuild = 0
defaultConfig { defaultConfig {
applicationId = "com.zaneschepke.wireguardautotunnel" applicationId = "com.zaneschepke.wireguardautotunnel"
minSdk = 29 minSdk = 29
targetSdk = 33 targetSdk = 34
versionCode = versionMajor * 10000 + versionMinor * 1000 + versionPatch * 100 + versionBuild versionCode = versionMajor * 10000 + versionMinor * 1000 + versionPatch * 100 + versionBuild
versionName = "${versionMajor}.${versionMinor}.${versionPatch}" versionName = "${versionMajor}.${versionMinor}.${versionPatch}"
@@ -54,7 +54,7 @@ android {
compose = true compose = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = "1.4.7" kotlinCompilerExtensionVersion = "1.4.8"
} }
packaging { packaging {
resources { resources {
@@ -83,7 +83,7 @@ dependencies {
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")
//wireguard tunnel //wireguard tunnel
implementation("com.wireguard.android:tunnel:1.0.20230405") implementation("com.wireguard.android:tunnel:1.0.20230427")
//logging //logging
implementation("com.jakewharton.timber:timber:5.0.1") implementation("com.jakewharton.timber:timber:5.0.1")
@@ -127,6 +127,7 @@ dependencies {
} }
kapt { kapt {
correctErrorTypes = true correctErrorTypes = true
} }
+6 -1
View File
@@ -31,7 +31,7 @@
}, },
{ {
"id": "2:8887605597748372702", "id": "2:8887605597748372702",
"lastPropertyId": "8:4981008812459251156", "lastPropertyId": "9:4468844863383145378",
"name": "Settings", "name": "Settings",
"properties": [ "properties": [
{ {
@@ -59,6 +59,11 @@
"id": "6:3370284381040192129", "id": "6:3370284381040192129",
"name": "defaultTunnel", "name": "defaultTunnel",
"type": 9 "type": 9
},
{
"id": "9:4468844863383145378",
"name": "isAlwaysOnVpnEnabled",
"type": 1
} }
], ],
"relations": [] "relations": []
+2 -6
View File
@@ -59,11 +59,6 @@
"id": "6:3370284381040192129", "id": "6:3370284381040192129",
"name": "defaultTunnel", "name": "defaultTunnel",
"type": 9 "type": 9
},
{
"id": "8:4981008812459251156",
"name": "showProminentDisclosure",
"type": 1
} }
], ],
"relations": [] "relations": []
@@ -91,7 +86,8 @@
7555225587864607050, 7555225587864607050,
969146862000617878, 969146862000617878,
5057486545428188436, 5057486545428188436,
2814640993034665120 2814640993034665120,
4981008812459251156
], ],
"retiredRelationUids": [], "retiredRelationUids": [],
"version": 1 "version": 1
+30 -4
View File
@@ -1,22 +1,33 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"
android:maxSdkVersion="30" /> android:maxSdkVersion="30"
tools:ignore="LeanbackUsesWifi" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <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_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/>
<!--foreground service permissions--> <!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<!--start service on boot permission--> <!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!--android tv support-->
<uses-feature android:name="android.software.leanback"
android:required="false" />
<uses-feature android:name="android.hardware.touchscreen"
android:required="false" />
<uses-feature
android:name="android.hardware.location.gps"
android:required="false" />
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -28,6 +39,7 @@
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:banner="@mipmap/ic_launcher_foreground"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
@@ -39,34 +51,48 @@
android:theme="@style/Theme.WireguardAutoTunnel"> android:theme="@style/Theme.WireguardAutoTunnel">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service <service
android:name=".service.foreground.ForegroundService" android:name=".service.foreground.ForegroundService"
android:enabled="true" android:enabled="true"
android:foregroundServiceType="remoteMessaging"
android:exported="false"> android:exported="false">
</service> </service>
<service <service
android:name=".service.foreground.WireGuardTunnelService" android:name=".service.foreground.WireGuardTunnelService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:enabled="true" android:enabled="true"
android:foregroundServiceType="remoteMessaging"
android:exported="false"> android:exported="false">
<intent-filter>
<action android:name="android.net.VpnService"/>
</intent-filter>
<meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="true"/>
</service> </service>
<service <service
android:name=".service.foreground.WireGuardConnectivityWatcherService" android:name=".service.foreground.WireGuardConnectivityWatcherService"
android:enabled="true" android:enabled="true"
android:stopWithTask="false" android:stopWithTask="false"
android:foregroundServiceType="location"
android:permission=""
android:exported="false"> android:exported="false">
</service> </service>
<receiver android:enabled="true" android:name=".BootReceiver" <receiver android:enabled="true" android:name=".receiver.BootReceiver"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/> <action android:name="android.intent.action.BOOT_COMPLETED"/>
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/>
<meta-data <meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES" android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui"/> android:value="barcode_ui"/>
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="true" />
</application> </application>
</manifest> </manifest>
@@ -1,6 +1,7 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import android.app.Application import android.app.Application
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.zaneschepke.wireguardautotunnel.repository.Repository import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
@@ -15,7 +16,10 @@ class WireGuardAutoTunnel : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if(BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) if(BuildConfig.DEBUG) {
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(false);
Timber.plant(Timber.DebugTree())
}
settingsRepo.init() settingsRepo.init()
} }
} }
@@ -7,7 +7,6 @@ import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner
import com.zaneschepke.wireguardautotunnel.service.barcode.QRScanner import com.zaneschepke.wireguardautotunnel.service.barcode.QRScanner
import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -1,8 +1,9 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.Repository import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.foreground.Action import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceTracker 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 com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -23,21 +23,19 @@ class BootReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo : Repository<Settings> lateinit var settingsRepo : Repository<Settings>
@OptIn(DelicateCoroutinesApi::class)
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) { if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
CoroutineScope(SupervisorJob()).launch { CoroutineScope(SupervisorJob()).launch {
try { try {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if (!settings.isNullOrEmpty()) { if (!settings.isNullOrEmpty()) {
val setting = settings[0] val setting = settings.first()
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) { if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
val defaultTunnel = TunnelConfig.from(setting.defaultTunnel!!)
ServiceTracker.actionOnService( ServiceTracker.actionOnService(
Action.START, context, Action.START, context,
WireGuardConnectivityWatcherService::class.java, WireGuardConnectivityWatcherService::class.java,
mapOf(context.resources.getString(R.string.tunnel_extras_key) to mapOf(context.resources.getString(R.string.tunnel_extras_key) to
defaultTunnel.toString()) setting.defaultTunnel!!)
) )
} }
} }
@@ -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()
}
}
}
}
@@ -13,6 +13,7 @@ class QRScanner @Inject constructor(private val gmsBarcodeScanner: GmsBarcodeSca
gmsBarcodeScanner.startScan().addOnSuccessListener { gmsBarcodeScanner.startScan().addOnSuccessListener {
trySend(it.rawValue) trySend(it.rawValue)
}.addOnFailureListener { }.addOnFailureListener {
trySend(it.message)
Timber.e(it.message) Timber.e(it.message)
} }
awaitClose { awaitClose {
@@ -4,7 +4,10 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
open class ForegroundService : Service() { open class ForegroundService : Service() {
@@ -24,6 +27,10 @@ open class ForegroundService : Service() {
when (action) { when (action) {
Action.START.name -> startService(intent.extras) Action.START.name -> startService(intent.extras)
Action.STOP.name -> stopService(intent.extras) Action.STOP.name -> stopService(intent.extras)
"android.net.VpnService" -> {
Timber.d("Always-on VPN starting service")
startService(intent.extras)
}
else -> Timber.d("This should never happen. No action in the received intent") else -> Timber.d("This should never happen. No action in the received intent")
} }
} else { } else {
@@ -20,7 +20,6 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -123,7 +122,6 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
} }
} }
@OptIn(DelicateCoroutinesApi::class)
private fun startWatcherJob() { private fun startWatcherJob() {
watcherJob = CoroutineScope(SupervisorJob()).launch { watcherJob = CoroutineScope(SupervisorJob()).launch {
val settings = settingsRepo.getAll(); val settings = settingsRepo.getAll();
@@ -151,13 +149,17 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
isMobileDataConnected = true isMobileDataConnected = true
Timber.d("Mobile data capabilities changed") Timber.d("Mobile data capabilities changed")
if(!isWifiConnected && setting.isTunnelOnMobileDataEnabled if(!disconnecting && !connecting) {
&& vpnService.getState() == Tunnel.State.DOWN) if(!isWifiConnected && setting.isTunnelOnMobileDataEnabled
startVPN() && vpnService.getState() == Tunnel.State.DOWN)
startVPN()
}
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isMobileDataConnected = false 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") Timber.d("Lost mobile data connection")
} }
} }
@@ -178,7 +180,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
Timber.d("Not connect and not disconnecting") Timber.d("Not connect and not disconnecting")
val ssid = wifiService.getNetworkName(it.networkCapabilities); val ssid = wifiService.getNetworkName(it.networkCapabilities);
Timber.d("SSID: $ssid") 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") Timber.d("Starting VPN Tunnel for untrusted network: $ssid")
startVPN() startVPN()
} else if (!disconnecting && vpnService.getState() == Tunnel.State.UP && setting.trustedNetworkSSIDs.contains( } else if (!disconnecting && vpnService.getState() == Tunnel.State.UP && setting.trustedNetworkSSIDs.contains(
@@ -1,10 +1,15 @@
package com.zaneschepke.wireguardautotunnel.service.foreground package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.PendingIntent
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -23,11 +28,16 @@ class WireGuardTunnelService : ForegroundService() {
@Inject @Inject
lateinit var vpnService : VpnService lateinit var vpnService : VpnService
@Inject
lateinit var settingsRepo: Repository<Settings>
@Inject @Inject
lateinit var notificationService : NotificationService lateinit var notificationService : NotificationService
private lateinit var job : Job private lateinit var job : Job
private var tunnelName : String = ""
override fun startService(extras : Bundle?) { override fun startService(extras : Bundle?) {
super.startService(extras) super.startService(extras)
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
@@ -36,16 +46,53 @@ class WireGuardTunnelService : ForegroundService() {
if(tunnelConfigString != null) { if(tunnelConfigString != null) {
try { try {
val tunnelConfig = TunnelConfig.from(tunnelConfigString) val tunnelConfig = TunnelConfig.from(tunnelConfigString)
val state = vpnService.startTunnel(tunnelConfig) tunnelName = tunnelConfig.name
if (state == Tunnel.State.UP) { vpnService.startTunnel(tunnelConfig)
launchVpnConnectedNotification(tunnelConfig.name)
}
} catch (e : Exception) { } catch (e : Exception) {
Timber.e("Problem starting tunnel: ${e.message}") Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras) stopService(extras)
} }
} else { } else {
Timber.e("Tunnel config null") Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll();
if(!settings.isNullOrEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
}
}
}
}
CoroutineScope(job).launch {
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
didShowConnected = false
}
}
HandshakeStatus.HEALTHY -> {
if(!didShowConnected) {
launchVpnConnectedNotification()
didShowConnected = true
}
}
HandshakeStatus.UNHEALTHY -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
}
} }
} }
} }
@@ -59,7 +106,7 @@ class WireGuardTunnelService : ForegroundService() {
stopSelf() stopSelf()
} }
private fun launchVpnConnectedNotification(tunnelName : String) { private fun launchVpnConnectedNotification() {
val notification = notificationService.createNotification( val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
@@ -70,6 +117,22 @@ class WireGuardTunnelService : ForegroundService() {
) )
super.startForeground(foregroundId, notification) 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() { private fun cancelJob() {
if(this::job.isInitialized) { if(this::job.isInitialized) {
job.cancel() job.cancel()
@@ -1,6 +1,5 @@
package com.zaneschepke.wireguardautotunnel.service.network package com.zaneschepke.wireguardautotunnel.service.network
import android.app.Service
import android.content.Context import android.content.Context
import android.net.ConnectivityManager import android.net.ConnectivityManager
import android.net.Network import android.net.Network
@@ -10,12 +9,10 @@ import android.net.wifi.SupplicantState
import android.net.wifi.WifiInfo import android.net.wifi.WifiInfo
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Build import android.os.Build
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject
abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Context, networkCapability : Int) : NetworkService<T> { abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Context, networkCapability : Int) : NetworkService<T> {
@@ -2,12 +2,15 @@ package com.zaneschepke.wireguardautotunnel.service.notification
import android.app.Notification import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
interface NotificationService { interface NotificationService {
fun createNotification( fun createNotification(
channelId: String, channelId: String,
channelName: String, channelName: String,
title: String = "", title: String = "",
action: PendingIntent? = null,
actionText: String? = null,
description: String, description: String,
showTimestamp : Boolean = false, showTimestamp : Boolean = false,
importance: Int = NotificationManager.IMPORTANCE_HIGH, importance: Int = NotificationManager.IMPORTANCE_HIGH,
@@ -20,13 +20,15 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
channelId: String, channelId: String,
channelName: String, channelName: String,
title: String, title: String,
action: PendingIntent?,
actionText: String?,
description: String, description: String,
showTimestamp: Boolean, showTimestamp: Boolean,
importance: Int, importance: Int,
vibration: Boolean, vibration: Boolean,
onGoing: Boolean, onGoing: Boolean,
lights: Boolean lights: Boolean
) : Notification { ): Notification {
val channel = NotificationChannel( val channel = NotificationChannel(
channelId, channelId,
channelName, channelName,
@@ -42,7 +44,12 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
val pendingIntent: PendingIntent = val pendingIntent: PendingIntent =
Intent(context, MainActivity::class.java).let { notificationIntent -> 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 = val builder: Notification.Builder =
@@ -50,14 +57,21 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
context, context,
channelId channelId
) )
return builder.let {
return builder if(action != null && actionText != null) {
.setContentTitle(title) //TODO find a not deprecated way to do this
.setContentText(description) it.addAction(
.setContentIntent(pendingIntent) Notification.Action.Builder(0, actionText, action)
.setOngoing(onGoing) .build())
.setShowWhen(showTimestamp) it.setAutoCancel(true)
.setSmallIcon(R.mipmap.ic_launcher_foreground) }
.build() it.setContentTitle(title)
.setContentText(description)
.setContentIntent(pendingIntent)
.setOngoing(onGoing)
.setShowWhen(showTimestamp)
.setSmallIcon(R.mipmap.ic_launcher_foreground)
.build()
}
} }
} }
@@ -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
}
}
@@ -1,6 +1,8 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
@@ -9,5 +11,8 @@ interface VpnService : Tunnel {
suspend fun stopTunnel() suspend fun stopTunnel()
val state : SharedFlow<Tunnel.State> val state : SharedFlow<Tunnel.State>
val tunnelName : SharedFlow<String> val tunnelName : SharedFlow<String>
val statistics : SharedFlow<Statistics>
val lastHandshake : SharedFlow<Map<Key,Long>>
val handshakeStatus : SharedFlow<HandshakeStatus>
fun getState() : Tunnel.State fun getState() : Tunnel.State
} }
@@ -2,27 +2,51 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig 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.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject 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("") private val _tunnelName = MutableStateFlow("")
override val tunnelName get() = _tunnelName.asStateFlow() override val tunnelName get() = _tunnelName.asStateFlow()
private val _state = MutableSharedFlow<Tunnel.State>( private val _state = MutableSharedFlow<Tunnel.State>(
replay = 1, replay = 1)
onBufferOverflow = BufferOverflow.SUSPEND,
extraBufferCapacity = 1) private val _handshakeStatus = MutableSharedFlow<HandshakeStatus>(replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val state get() = _state.asSharedFlow() 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{ override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{
return try { return try {
if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) { 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) { override fun onStateChange(state : Tunnel.State) {
val tunnel = this;
_state.tryEmit(state) _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())
}
} }
} }
@@ -10,5 +10,6 @@ data class Settings(
var isAutoTunnelEnabled : Boolean = false, var isAutoTunnelEnabled : Boolean = false,
var isTunnelOnMobileDataEnabled : Boolean = false, var isTunnelOnMobileDataEnabled : Boolean = false,
var trustedNetworkSSIDs : MutableList<String> = mutableListOf(), var trustedNetworkSSIDs : MutableList<String> = mutableListOf(),
var defaultTunnel : String? = null var defaultTunnel : String? = null,
var isAlwaysOnVpnEnabled : Boolean = false
) )
@@ -14,7 +14,6 @@ import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState 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.PermissionRequestFailedScreen
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.detail.DetailScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
@@ -44,7 +44,7 @@ import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class, @OptIn(ExperimentalAnimationApi::class,
ExperimentalPermissionsApi::class ExperimentalPermissionsApi::class
) )
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -164,6 +164,9 @@ class MainActivity : AppCompatActivity() {
composable("${Routes.Config.name}/{id}", enterTransition = { composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(1000)) fadeIn(animationSpec = tween(1000))
}) { ConfigScreen(padding = padding, navController = navController, id = it.arguments?.getString("id"))} }) { 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")) }
} }
} }
} }
@@ -10,7 +10,8 @@ enum class Routes {
Main, Main,
Settings, Settings,
Support, Support,
Config; Config,
Detail;
companion object { companion object {
@@ -7,20 +7,24 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @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( Box(
modifier = Modifier modifier = Modifier
.combinedClickable( .combinedClickable(
onClick = { onClick = {
onClick()
}, },
onLongClick = { onLongClick = {
onHold() onHold()
@@ -34,7 +38,17 @@ fun RowListItem(text : String, onHold : () -> Unit, rowButton : @Composable() ()
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween 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() rowButton()
} }
} }
@@ -3,10 +3,8 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.widget.Toast import android.widget.Toast
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@@ -72,127 +70,162 @@ fun ConfigScreen(
} }
if(tunnel != null) { if(tunnel != null) {
Column( LazyColumn(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
Row( item {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier
.padding(horizontal = 20.dp, vertical = 7.dp), .fillMaxWidth()
verticalAlignment = Alignment.CenterVertically, .padding(horizontal = 20.dp, vertical = 7.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
OutlinedTextField(
value = tunnelName.value,
onValueChange = {
viewModel.onTunnelNameChange(it)
},
label = { Text(stringResource(id = R.string.tunnel_name)) },
maxLines = 1,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
viewModel.onTunnelNameChange(tunnelName.value)
}
),
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(id = R.string.tunnel_all))
Switch(
checked = allApplications,
onCheckedChange = {
viewModel.onAllApplicationsChange(!allApplications)
}
)
}
if(!allApplications) {
Row(modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) { horizontalArrangement = Arrangement.SpaceBetween
Row(verticalAlignment = Alignment.CenterVertically, ) {
horizontalArrangement = Arrangement.SpaceBetween){ OutlinedTextField(
Text(stringResource(id = R.string.include)) value = tunnelName.value,
Checkbox( onValueChange = {
checked = include, viewModel.onTunnelNameChange(it)
onCheckedChange = { },
viewModel.onIncludeChange(!include) label = { Text(stringResource(id = R.string.tunnel_name)) },
maxLines = 1,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
viewModel.onTunnelNameChange(tunnelName.value)
} }
) ),
} )
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween){
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !include,
onCheckedChange = {
viewModel.onIncludeChange(!include)
}
)
}
} }
LazyColumn(modifier = Modifier }
.fillMaxWidth() item {
.fillMaxHeight(.75f) Row(
.padding(horizontal = 14.dp, vertical = 7.dp), modifier = Modifier
verticalArrangement = Arrangement.Center, .fillMaxWidth()
horizontalAlignment = Alignment.Start) { .padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(id = R.string.tunnel_all))
Switch(
checked = allApplications,
onCheckedChange = {
viewModel.onAllApplicationsChange(!allApplications)
}
)
}
}
if (!allApplications) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(id = R.string.include))
Checkbox(
checked = include,
onCheckedChange = {
viewModel.onIncludeChange(!include)
}
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !include,
onCheckedChange = {
viewModel.onIncludeChange(!include)
}
)
}
}
}
// LazyColumn(
// modifier = Modifier
// .fillMaxWidth()
// .fillMaxHeight(.75f)
// .padding(horizontal = 14.dp, vertical = 7.dp),
// verticalArrangement = Arrangement.Center,
// horizontalAlignment = Alignment.Start
// ) {
items(packages) { pack -> items(packages) { pack ->
Row(verticalAlignment = Alignment.CenterVertically, Row(
horizontalArrangement = Arrangement.SpaceBetween) { verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row( Row(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(5.dp) modifier = Modifier.padding(5.dp)
) { ) {
val drawable = pack.applicationInfo?.loadIcon(context.packageManager) val drawable =
if(drawable != null) { pack.applicationInfo?.loadIcon(context.packageManager)
Image(painter = DrawablePainter(drawable), stringResource(id = R.string.icon), modifier = Modifier.size(50.dp, 50.dp)) if (drawable != null) {
Image(
painter = DrawablePainter(drawable),
stringResource(id = R.string.icon),
modifier = Modifier.size(50.dp, 50.dp)
)
} else { } else {
Icon(Icons.Rounded.Android, stringResource(id = R.string.edit), modifier = Modifier.size(50.dp, 50.dp)) Icon(
Icons.Rounded.Android,
stringResource(id = R.string.edit),
modifier = Modifier.size(50.dp, 50.dp)
)
} }
Text(pack.applicationInfo.loadLabel(context.packageManager).toString(), modifier = Modifier.padding(5.dp)) Text(
pack.applicationInfo.loadLabel(context.packageManager)
.toString(), modifier = Modifier.padding(5.dp)
)
} }
Checkbox( Checkbox(
checked = (checkedPackages.contains(pack.packageName)), checked = (checkedPackages.contains(pack.packageName)),
onCheckedChange = { onCheckedChange = {
if(it) viewModel.onAddCheckedPackage(pack.packageName) else viewModel.onRemoveCheckedPackage(pack.packageName) if (it) viewModel.onAddCheckedPackage(pack.packageName) else viewModel.onRemoveCheckedPackage(
pack.packageName
)
} }
) )
} }
} }
} }
} item {
Row( Row(
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Button(onClick = { Button(onClick = {
scope.launch { scope.launch {
viewModel.onSaveAllChanges() viewModel.onSaveAllChanges()
Toast.makeText(context, context.resources.getString(R.string.config_changes_saved), Toast.LENGTH_LONG).show() Toast.makeText(
navController.navigate(Routes.Main.name) context,
context.resources.getString(R.string.config_changes_saved),
Toast.LENGTH_LONG
).show()
navController.navigate(Routes.Main.name)
}
}, Modifier.padding(25.dp)) {
Text(stringResource(id = R.string.save_changes))
} }
}, Modifier.padding(25.dp)) {
Text(stringResource(id = R.string.save_changes))
} }
} }
} }
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.zaneschepke.wireguardautotunnel.repository.Repository import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,7 +19,8 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ConfigViewModel @Inject constructor(private val application : Application, 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 _tunnel = MutableStateFlow<TunnelConfig?>(null)
private val _tunnelName = MutableStateFlow("") private val _tunnelName = MutableStateFlow("")
@@ -127,6 +129,17 @@ class ConfigViewModel @Inject constructor(private val application : Application,
wgQuick = wgQuick wgQuick = wgQuick
)?.let { )?.let {
tunnelRepo.save(it) 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()
))
}
}
}
} }
} }
} }
@@ -0,0 +1,139 @@
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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()
.verticalScroll(rememberScrollState())
.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")
}
}
}
}
}
}
}
}
}
}
@@ -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
}
}
}
@@ -3,6 +3,9 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -19,6 +22,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
@@ -42,11 +46,16 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
@@ -58,29 +67,52 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.Routes import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed
import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValues, fun MainScreen(
snackbarHostState : SnackbarHostState, navController: NavController) { viewModel: MainViewModel = hiltViewModel(), padding: PaddingValues,
snackbarHostState: SnackbarHostState, navController: NavController
) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val context = LocalContext.current val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(HandshakeStatus.NOT_STARTED)
val viewState = viewModel.viewState.collectAsStateWithLifecycle() val viewState = viewModel.viewState.collectAsStateWithLifecycle()
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) } var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN) val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("") val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
// Nested scroll for control FAB
val nestedScrollConnection = remember {
object : NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
// Hide FAB
if (available.y < -1) {
isVisible.value = false
}
// Show FAB
if (available.y > 1) {
isVisible.value = true
}
return Offset.Zero
}
}
}
LaunchedEffect(viewState.value) { LaunchedEffect(viewState.value) {
if (viewState.value.showSnackbarMessage) { if (viewState.value.showSnackbarMessage) {
@@ -111,20 +143,26 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
}) })
}, },
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { floatingActionButton = {
FloatingActionButton( AnimatedVisibility(
modifier = Modifier.padding(bottom = 90.dp), visible = isVisible.value,
onClick = { enter = slideInVertically(initialOffsetY = { it * 2 }),
showBottomSheet = true exit = slideOutVertically(targetOffsetY = { it * 2 }),
},
containerColor = MaterialTheme.colorScheme.secondary,
shape = RoundedCornerShape(16.dp),
) { ) {
Icon( FloatingActionButton(
imageVector = Icons.Rounded.Add, modifier = Modifier.padding(bottom = 90.dp),
contentDescription = stringResource(id = R.string.add_tunnel), onClick = {
tint = Color.DarkGray, showBottomSheet = true
) },
containerColor = MaterialTheme.colorScheme.secondary,
shape = RoundedCornerShape(16.dp),
) {
Icon(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray,
)
}
} }
} }
) { ) {
@@ -156,8 +194,15 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
} }
.padding(10.dp) .padding(10.dp)
) { ) {
Icon(Icons.Filled.FileOpen, contentDescription = stringResource(id = R.string.open_file), modifier = Modifier.padding(10.dp)) Icon(
Text(stringResource(id = R.string.add_from_file), modifier = Modifier.padding(10.dp)) 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() Divider()
Row(modifier = Modifier Row(modifier = Modifier
@@ -170,8 +215,15 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
} }
.padding(10.dp) .padding(10.dp)
) { ) {
Icon(Icons.Filled.QrCode, contentDescription = stringResource(id = R.string.qr_scan), modifier = Modifier.padding(10.dp)) Icon(
Text(stringResource(id = R.string.add_from_qr), modifier = Modifier.padding(10.dp)) 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)
)
} }
} }
} }
@@ -183,38 +235,52 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
.padding(padding) .padding(padding)
) { ) {
LazyColumn(modifier = Modifier.fillMaxSize()) { LazyColumn(modifier = Modifier.fillMaxSize()
.nestedScroll(nestedScrollConnection),) {
items(tunnels.toList()) { tunnel -> items(tunnels.toList()) { tunnel ->
RowListItem(text = tunnel.name, onHold = { RowListItem(leadingIcon = Icons.Rounded.Circle,
if (state == Tunnel.State.UP && tunnel.name == tunnelName) { leadingIconColor = when (handshakeStatus) {
scope.launch { HandshakeStatus.HEALTHY -> mint
viewModel.showSnackBarMessage(context.resources.getString(R.string.turn_off_tunnel)) 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;
haptic.performHapticFeedback(HapticFeedbackType.LongPress) },
selectedTunnel = tunnel; onClick = { navController.navigate("${Routes.Detail.name}/${tunnel.id}") },
}, rowButton = { rowButton = {
if (tunnel.id == selectedTunnel?.id) { if (tunnel.id == selectedTunnel?.id) {
Row() { Row() {
IconButton(onClick = { IconButton(onClick = {
navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}") navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}")
}) { }) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
} }
IconButton(onClick = { viewModel.onDelete(tunnel) }) { IconButton(onClick = { viewModel.onDelete(tunnel) }) {
Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete)) 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()
}
)
}
})
} }
} }
} }
@@ -42,6 +42,8 @@ class MainViewModel @Inject constructor(private val application : Application,
val viewState get() = _viewState.asStateFlow() val viewState get() = _viewState.asStateFlow()
val tunnels get() = tunnelRepo.itemFlow val tunnels get() = tunnelRepo.itemFlow
val state get() = vpnService.state val state get() = vpnService.state
val handshakeStatus get() = vpnService.handshakeStatus
val tunnelName get() = vpnService.tunnelName val tunnelName get() = vpnService.tunnelName
private val _settings = MutableStateFlow(Settings()) private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow() val settings get() = _settings.asStateFlow()
@@ -84,6 +86,7 @@ class MainViewModel @Inject constructor(private val application : Application,
val setting = settings[0] val setting = settings[0]
setting.defaultTunnel = null setting.defaultTunnel = null
setting.isAutoTunnelEnabled = false setting.isAutoTunnelEnabled = false
setting.isAlwaysOnVpnEnabled = false
settingsRepo.save(setting) settingsRepo.save(setting)
} }
} }
@@ -102,33 +105,34 @@ class MainViewModel @Inject constructor(private val application : Application,
suspend fun onTunnelQRSelected() { suspend fun onTunnelQRSelected() {
codeScanner.scan().collect { codeScanner.scan().collect {
Timber.d(it)
if(!it.isNullOrEmpty() && it.contains(application.resources.getString(R.string.config_validation))) { if(!it.isNullOrEmpty() && it.contains(application.resources.getString(R.string.config_validation))) {
tunnelRepo.save(TunnelConfig(name = defaultConfigName(), wgQuick = it)) tunnelRepo.save(TunnelConfig(name = defaultConfigName(), wgQuick = it))
} else if(!it.isNullOrEmpty() && it.contains(application.resources.getString(R.string.barcode_downloading))) {
showSnackBarMessage(application.resources.getString(R.string.barcode_downloading_message))
} else { } else {
showSnackBarMessage("Invalid QR code. Try again.") showSnackBarMessage(application.resources.getString(R.string.barcode_error))
} }
} }
} }
fun onTunnelFileSelected(uri : Uri) { 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 { try {
val config = Config.parse(bufferReader) val fileName = getFileName(application.applicationContext, uri)
val tunnelName = getNameFromFileName(fileName) val extension = getFileExtensionFromFileName(fileName)
viewModelScope.launch { if(extension != ".conf") {
tunnelRepo.save(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString())) 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() stream.close()
} catch(_: BadConfigException) { } catch(_: BadConfigException) {
viewModelScope.launch { viewModelScope.launch {
@@ -177,6 +181,10 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
private fun getFileExtensionFromFileName(fileName : String) : String { private fun getFileExtensionFromFileName(fileName : String) : String {
return fileName.substring(fileName.lastIndexOf('.')) return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e : Exception) {
""
}
} }
} }
@@ -16,8 +16,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
@@ -91,6 +93,8 @@ fun SettingsScreen(
val backgroundLocationState = val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
val scrollState = rememberScrollState()
var isLocationServicesEnabled by remember { mutableStateOf(viewModel.checkLocationServicesEnabled())}
LaunchedEffect(viewState) { LaunchedEffect(viewState) {
if (viewState.showSnackbarMessage) { if (viewState.showSnackbarMessage) {
@@ -120,6 +124,7 @@ fun SettingsScreen(
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState)
.padding(padding)) { .padding(padding)) {
Icon(Icons.Rounded.LocationOff, contentDescription = stringResource(id = R.string.map), modifier = Modifier Icon(Icons.Rounded.LocationOff, contentDescription = stringResource(id = R.string.map), modifier = Modifier
.padding(30.dp) .padding(30.dp)
@@ -172,12 +177,41 @@ fun SettingsScreen(
} }
return return
} }
if(!isLocationServicesEnabled) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
Text(
stringResource(id = R.string.location_services_not_detected),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic
)
Button(onClick = {
val locationServicesEnabled = viewModel.checkLocationServicesEnabled()
isLocationServicesEnabled = locationServicesEnabled
if(!locationServicesEnabled) {
scope.launch {
viewModel.showSnackBarMessage(context.getString(R.string.detecting_location_services_disabled))
}
}
}) {
Text(stringResource(id = R.string.check_again))
}
}
return
}
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState)
.clickable(indication = null, interactionSource = interactionSource) { .clickable(indication = null, interactionSource = interactionSource) {
focusManager.clearFocus() focusManager.clearFocus()
} }
@@ -192,6 +226,7 @@ fun SettingsScreen(
) { ) {
Text(stringResource(R.string.enable_auto_tunnel)) Text(stringResource(R.string.enable_auto_tunnel))
Switch( Switch(
enabled = !settings.isAlwaysOnVpnEnabled,
checked = settings.isAutoTunnelEnabled, checked = settings.isAutoTunnelEnabled,
onCheckedChange = { onCheckedChange = {
scope.launch { scope.launch {
@@ -208,12 +243,12 @@ fun SettingsScreen(
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = expanded, expanded = expanded,
onExpandedChange = { onExpandedChange = {
if(!settings.isAutoTunnelEnabled) { if(!(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)) {
expanded = !expanded }}, expanded = !expanded }},
modifier = Modifier.padding(start = 15.dp, top = 5.dp, bottom = 10.dp), modifier = Modifier.padding(start = 15.dp, top = 5.dp, bottom = 10.dp),
) { ) {
TextField( TextField(
enabled = !settings.isAutoTunnelEnabled, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = settings.defaultTunnel?.let { value = settings.defaultTunnel?.let {
TunnelConfig.from(it).name } TunnelConfig.from(it).name }
?: "", ?: "",
@@ -261,11 +296,11 @@ fun SettingsScreen(
scope.launch { scope.launch {
viewModel.onDeleteTrustedSSID(ssid) viewModel.onDeleteTrustedSSID(ssid)
} }
}, text = ssid, icon = Icons.Filled.Close, enabled = !settings.isAutoTunnelEnabled) }, text = ssid, icon = Icons.Filled.Close, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled))
} }
} }
OutlinedTextField( OutlinedTextField(
enabled = !settings.isAutoTunnelEnabled, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = currentText, value = currentText,
onValueChange = { currentText = it }, onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) }, label = { Text(stringResource(R.string.add_trusted_ssid)) },
@@ -301,7 +336,7 @@ fun SettingsScreen(
) { ) {
Text(stringResource(R.string.tunnel_mobile_data)) Text(stringResource(R.string.tunnel_mobile_data))
Switch( Switch(
enabled = !settings.isAutoTunnelEnabled, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnMobileDataEnabled, checked = settings.isTunnelOnMobileDataEnabled,
onCheckedChange = { onCheckedChange = {
scope.launch { scope.launch {
@@ -310,6 +345,24 @@ fun SettingsScreen(
} }
) )
} }
Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(R.string.always_on_vpn_support))
Switch(
enabled = !settings.isAutoTunnelEnabled,
checked = settings.isAlwaysOnVpnEnabled,
onCheckedChange = {
scope.launch {
viewModel.onToggleAlwaysOnVPN()
}
}
)
}
} }
} }
@@ -1,6 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.app.Application import android.app.Application
import android.content.Context
import android.location.LocationManager
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@@ -17,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor(private val application : Application, class SettingsViewModel @Inject constructor(private val application : Application,
private val tunnelRepo : Repository<TunnelConfig>, private val settingsRepo : Repository<Settings> private val tunnelRepo : Repository<TunnelConfig>, private val settingsRepo : Repository<Settings>
@@ -31,6 +34,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
val viewState get() = _viewState.asStateFlow() val viewState get() = _viewState.asStateFlow()
init { init {
checkLocationServicesEnabled()
viewModelScope.launch { viewModelScope.launch {
settingsRepo.itemFlow.collect { settingsRepo.itemFlow.collect {
val settings = it.first() val settings = it.first()
@@ -69,7 +73,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
suspend fun toggleAutoTunnel() { suspend fun toggleAutoTunnel() {
if(_settings.value.defaultTunnel.isNullOrEmpty() && !_settings.value.isAutoTunnelEnabled) { if(_settings.value.defaultTunnel.isNullOrEmpty() && !_settings.value.isAutoTunnelEnabled) {
showSnackBarMessage("Please select a tunnel first") showSnackBarMessage(application.getString(R.string.select_tunnel_message))
return return
} }
if(_settings.value.isAutoTunnelEnabled) { if(_settings.value.isAutoTunnelEnabled) {
@@ -99,8 +103,7 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
} }
} }
} }
suspend fun showSnackBarMessage(message : String) {
private suspend fun showSnackBarMessage(message : String) {
_viewState.emit(_viewState.value.copy( _viewState.emit(_viewState.value.copy(
showSnackbarMessage = true, showSnackbarMessage = true,
snackbarMessage = message, snackbarMessage = message,
@@ -118,4 +121,20 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
showSnackbarMessage = false showSnackbarMessage = false
)) ))
} }
suspend fun onToggleAlwaysOnVPN() {
if(_settings.value.defaultTunnel != null) {
_settings.emit(
_settings.value.copy(isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled)
)
settingsRepo.save(_settings.value)
} else {
showSnackBarMessage(application.getString(R.string.select_tunnel_message))
}
}
fun checkLocationServicesEnabled() : Boolean {
val locationManager =
application.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
}
} }
@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -43,6 +45,7 @@ fun SupportScreen(padding : PaddingValues) {
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(padding)) { .padding(padding)) {
Text(stringResource(R.string.support_text), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 15.sp) Text(stringResource(R.string.support_text), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 15.sp)
Row( Row(
@@ -9,4 +9,9 @@ val virdigris = Color(0xFF5BC0BE)
val Purple40 = Color(0xFF6650a4) val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71) 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)
@@ -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
}
}
+24
View File
@@ -58,4 +58,28 @@
<string name="turn_on">Turn on</string> <string name="turn_on">Turn on</string>
<string name="map">Map</string> <string name="map">Map</string>
<string name="bad_config">Bad config. Please try again.</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>
<string name="always_on_vpn_support">Enable Always-On VPN support</string>
<string name="select_tunnel_message">Please select a tunnel first</string>
<string name="location_services_not_detected">Unable to detect Location Services which are required for this feature. Please enable Location Services.</string>
<string name="check_again">Check again</string>
<string name="detecting_location_services_disabled">Detecting Location Services disabled</string>
</resources> </resources>
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

+4 -4
View File
@@ -2,19 +2,19 @@
buildscript { buildscript {
val objectBoxVersion by extra("3.5.1") val objectBoxVersion by extra("3.5.1")
val hiltVersion by extra("2.44") val hiltVersion by extra("2.47")
val accompanistVersion by extra("0.31.2-alpha") val accompanistVersion by extra("0.31.2-alpha")
dependencies { dependencies {
classpath("io.objectbox:objectbox-gradle-plugin:$objectBoxVersion") classpath("io.objectbox:objectbox-gradle-plugin:$objectBoxVersion")
classpath("com.google.gms:google-services:4.3.15") classpath("com.google.gms:google-services:4.3.15")
classpath("com.google.firebase:firebase-crashlytics-gradle:2.9.6") classpath("com.google.firebase:firebase-crashlytics-gradle:2.9.7")
} }
} }
plugins { plugins {
id("com.android.application") version "8.2.0-alpha08" apply false 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 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
} }