feat: add lock, logs, and ping

Fixes bug where control tile tunnel did not match with tunnel being controlled Closes #132
Fixes tunnel config edit screen error message #131

Revert to official lib to fix slow speeds issue Closes #137

Adds local app lock feature Closes #88

Adds restart vpn on ping fail with 1 minute interval and 60 minute cooldown Closes #6

Adds ability to easily make a copy of a tunnel.

Fixes bug on AndroidTV where tunnels were not being deleted properly.

Fixes bug where auto tunneling could be turned on before VPN permission was given.
This commit is contained in:
Zane Schepke 2024-03-18 00:27:11 -04:00
parent 4fc8ffbcbb
commit 5946d7c10d
43 changed files with 928 additions and 576 deletions

View File

@ -89,7 +89,6 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
# fix hardcode changelog file name
body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/${{ env.VERSION_CODE }}.txt
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}

View File

@ -137,7 +137,7 @@ dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
// optional - helpers for implementing LifecycleOwner in a Service
// helpers for implementing LifecycleOwner in a Service
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
@ -201,6 +201,7 @@ dependencies {
// bio
implementation(libs.androidx.biometric.ktx)
implementation(libs.pin.lock.compose)
// shortcuts
implementation(libs.androidx.core)

View File

@ -0,0 +1,168 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "625820076477aca948536f7bccccc7ca",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false, `is_auto_tunnel_paused` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelEnabled",
"columnName": "is_kernel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isAutoTunnelPaused",
"columnName": "is_auto_tunnel_paused",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '625820076477aca948536f7bccccc7ca')"
]
}
}

View File

@ -2,12 +2,14 @@ package com.zaneschepke.wireguardautotunnel
import android.app.Application
import android.content.ComponentName
import android.content.Context
import android.content.pm.PackageManager
import android.service.quicksettings.TileService
import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import xyz.teamgravity.pin_lock_compose.PinManager
@HiltAndroidApp
class WireGuardAutoTunnel : Application() {
@ -15,8 +17,8 @@ class WireGuardAutoTunnel : Application() {
super.onCreate()
instance = this
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) else Timber.plant(ReleaseTree())
PinManager.initialize(this)
}
companion object {
lateinit var instance: WireGuardAutoTunnel
private set
@ -25,9 +27,9 @@ class WireGuardAutoTunnel : Application() {
return instance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
fun requestTileServiceStateUpdate() {
fun requestTileServiceStateUpdate(context : Context) {
TileService.requestListeningState(
instance,
context,
ComponentName(instance, TunnelControlTile::class.java),
)
}

View File

@ -9,7 +9,7 @@ import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
@Database(
entities = [Settings::class, TunnelConfig::class],
version = 5,
version = 6,
autoMigrations =
[
AutoMigration(from = 1, to = 2),
@ -22,6 +22,10 @@ import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
from = 4,
to = 5,
),
AutoMigration(
from = 5,
to = 6,
),
],
exportSchema = true,
)

View File

@ -8,6 +8,7 @@ import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
class DataStoreManager(private val context: Context) {
companion object {
@ -32,7 +33,11 @@ class DataStoreManager(private val context: Context) {
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map { it[key] }
suspend fun <T> getFromStore(key: Preferences.Key<T>) =
context.dataStore.data.first { it.contains(key) }[key]
context.dataStore.data.map{ it[key] }.first()
fun <T> getFromStoreBlocking(key: Preferences.Key<T>) = runBlocking {
context.dataStore.data.map{ it[key] }.first()
}
val preferencesFlow: Flow<Preferences?> = context.dataStore.data
}

View File

@ -51,6 +51,11 @@ data class Settings(
defaultValue = "false",
)
var isAutoTunnelPaused: Boolean = false,
@ColumnInfo(
name = "is_ping_enabled",
defaultValue = "false",
)
var isPingEnabled: Boolean = false,
) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean {
return if (defaultTunnel != null) {

View File

@ -1,12 +1,8 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.PowerManager
import android.os.SystemClock
import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope
import com.wireguard.android.backend.Tunnel
@ -28,10 +24,11 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.net.InetAddress
import javax.inject.Inject
@AndroidEntryPoint
class WireGuardConnectivityWatcherService : ForegroundService() {
private val foregroundId = 122
@ -122,49 +119,18 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
launchWatcherNotification(getString(R.string.watcher_notification_text_paused))
}
// TODO could this be restarting service in a bad state?
// try to start task again if killed
override fun onTaskRemoved(rootIntent: Intent) {
Timber.d("Task Removed called")
val restartServiceIntent = Intent(rootIntent)
val restartServicePendingIntent: PendingIntent =
PendingIntent.getService(
this,
1,
restartServiceIntent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
)
applicationContext.getSystemService(Context.ALARM_SERVICE)
val alarmService: AlarmManager =
applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmService.set(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + 1000,
restartServicePendingIntent,
)
}
private suspend fun initWakeLock() {
val isBatterySaverOn =
withContext(lifecycleScope.coroutineContext) {
settingsRepository.getSettings().isBatterySaverEnabled
}
private fun initWakeLock() {
wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
try {
if (isBatterySaverOn) {
Timber.i("Initiating wakelock with 10 min timeout")
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} else {
Timber.i("Initiating wakelock with 30 min timeout")
acquire(Constants.DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT)
}
} finally {
release()
}
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
try {
Timber.i("Initiating wakelock with 10 min timeout")
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} finally {
release()
}
}
}
}
private fun cancelWatcherJob() {
@ -201,10 +167,17 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
Timber.i("Starting settings watcher")
watchForSettingsChanges()
}
if(setting.isPingEnabled) {
launch {
Timber.i("Starting ping watcher")
watchForPingFailure()
}
}
launch {
Timber.i("Starting management watcher")
manageVpn()
}
}
}
@ -236,6 +209,40 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
}
}
private suspend fun watchForPingFailure() {
try {
do {
if(vpnService.vpnState.value.status == Tunnel.State.UP) {
val config = vpnService.vpnState.value.config
config?.let {
val results = it.peers.map { peer ->
val host = if(peer.endpoint.isPresent &&
peer.endpoint.get().resolved.isPresent)
peer.endpoint.get().resolved.get().host
else Constants.BACKUP_PING_HOST
Timber.i("Checking reachability of: $host")
val reachable = InetAddress.getByName(host).isReachable(Constants.PING_TIMEOUT.toInt())
Timber.i("Result: reachable - $reachable")
reachable
}
if(results.contains(false)) {
Timber.i("Restarting VPN for ping failure")
ServiceManager.stopVpnService(this)
delay(Constants.VPN_RESTART_DELAY)
val tunnel = networkEventsFlow.value.settings.defaultTunnel
ServiceManager.startVpnServiceForeground(this, tunnel!!)
delay(Constants.PING_COOLDOWN)
}
}
}
delay(Constants.PING_INTERVAL)
} while (true)
} catch (e: Exception) {
Timber.e(e)
}
}
private suspend fun watchForSettingsChanges() {
settingsRepository.getSettingsFlow().collect {
if (networkEventsFlow.value.settings.isAutoTunnelPaused != it.isAutoTunnelPaused) {
@ -331,10 +338,9 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
}
}
// TODO clean this up
private suspend fun manageVpn() {
networkEventsFlow.collectLatest {
Timber.i("New watcher state: $it")
val autoTunnel = "Auto-tunnel watcher"
if (!it.settings.isAutoTunnelPaused && it.settings.defaultTunnel != null) {
delay(Constants.TOGGLE_TUNNEL_DELAY)
when {
@ -342,7 +348,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
it.settings.isTunnelOnEthernetEnabled &&
!it.isVpnConnected)) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 1 met")
Timber.i("$autoTunnel condition 1 met")
}
(!it.isEthernetConnected &&
it.settings.isTunnelOnMobileDataEnabled &&
@ -350,14 +356,14 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
it.isMobileDataConnected &&
!it.isVpnConnected) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 2 met")
Timber.i("$autoTunnel condition 2 met")
}
(!it.isEthernetConnected &&
!it.settings.isTunnelOnMobileDataEnabled &&
!it.isWifiConnected &&
it.isVpnConnected) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 3 met")
Timber.i("$autoTunnel condition 3 met")
}
(!it.isEthernetConnected &&
it.isWifiConnected &&
@ -365,31 +371,31 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
it.settings.isTunnelOnWifiEnabled &&
(!it.isVpnConnected)) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 4 met")
Timber.i("$autoTunnel condition 4 met")
}
(!it.isEthernetConnected &&
(it.isWifiConnected &&
it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID)) &&
(it.isVpnConnected)) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 5 met")
Timber.i("$autoTunnel condition 5 met")
}
(!it.isEthernetConnected &&
(it.isWifiConnected &&
!it.settings.isTunnelOnWifiEnabled &&
(it.isVpnConnected))) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 6 met")
Timber.i("$autoTunnel condition 6 met")
}
(!it.isEthernetConnected &&
!it.isWifiConnected &&
!it.isMobileDataConnected &&
(it.isVpnConnected)) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 7 met")
Timber.i("$autoTunnel condition 7 met")
}
else -> {
Timber.i("No condition met")
Timber.i("$autoTunnel no condition met")
}
}
}

View File

@ -45,8 +45,7 @@ class TunnelControlTile() : TileService() {
setUnavailable()
return@collect
}
tunnelName =
it.name.ifBlank {
tunnelName = it.name.run {
val settings = settingsRepository.getSettings()
if (settings.defaultTunnel != null) {
TunnelConfig.from(settings.defaultTunnel!!).name
@ -72,15 +71,18 @@ class TunnelControlTile() : TileService() {
unlockAndRun {
scope.launch {
try {
val tunnelConfig =
tunnelConfigRepository.getAll().first { it.name == tunnelName }
val defaultTunnel = settingsRepository.getSettings().defaultTunnel
val config = defaultTunnel ?: run {
val tunnelConfigs = tunnelConfigRepository.getAll()
return@run tunnelConfigs.find { it.name == tunnelName }
}
toggleWatcherServicePause()
if (vpnService.getState() == Tunnel.State.UP) {
ServiceManager.stopVpnService(this@TunnelControlTile)
} else {
ServiceManager.startVpnServiceForeground(
this@TunnelControlTile,
tunnelConfig.toString(),
config.toString(),
)
}
} catch (e: Exception) {

View File

@ -2,9 +2,12 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
data class VpnState(
val status: Tunnel.State = Tunnel.State.DOWN,
val name: String = "",
val config: Config? = null,
val statistics: Statistics? = null
)

View File

@ -63,6 +63,7 @@ constructor(
stopTunnelOnConfigChange(tunnelConfig)
emitTunnelName(tunnelConfig.name)
config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
emitTunnelConfig(config)
val state =
backend.setState(
this,
@ -71,7 +72,7 @@ constructor(
)
emitTunnelState(state)
state
} catch (e: Exception) {
} catch (e: BackendException) {
Timber.e("Failed to start tunnel with error: ${e.message}")
State.DOWN
}
@ -101,6 +102,14 @@ constructor(
)
}
private suspend fun emitTunnelConfig(config : Config?) {
_vpnState.emit(
_vpnState.value.copy(
config = config,
),
)
}
private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) {
if (getState() == State.UP && _vpnState.value.name != tunnelConfig.name) {
stopTunnel()
@ -129,7 +138,7 @@ constructor(
override fun onStateChange(state: State) {
val tunnel = this
emitTunnelState(state)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
WireGuardAutoTunnel.requestTileServiceStateUpdate(WireGuardAutoTunnel.instance)
if (state == State.UP) {
statsJob =
scope.launch {

View File

@ -0,0 +1,9 @@
package com.zaneschepke.wireguardautotunnel.ui
data class AppUiState(
val snackbarMessage: String = "",
val snackbarMessageConsumed: Boolean = true,
val vpnPermissionAccepted: Boolean = false,
val notificationPermissionAccepted: Boolean = false,
val requestPermissions: Boolean = false
)

View File

@ -4,13 +4,26 @@ import android.app.Application
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.android.backend.GoBackend
import com.zaneschepke.logcatter.Logcatter
import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
@ -19,9 +32,32 @@ class AppViewModel
constructor(
private val application: Application,
) : ViewModel() {
val vpnIntent: Intent? = GoBackend.VpnService.prepare(WireGuardAutoTunnel.instance)
private val _snackbarState = MutableStateFlow(SnackBarState())
val snackBarState = _snackbarState.asStateFlow()
private val _appUiState = MutableStateFlow(AppUiState(
vpnPermissionAccepted = vpnIntent == null
))
val appUiState = _appUiState.asStateFlow()
fun isRequiredPermissionGranted() : Boolean {
val allAccepted = (_appUiState.value.vpnPermissionAccepted && _appUiState.value.vpnPermissionAccepted)
if(!allAccepted) requestPermissions()
return allAccepted
}
private fun requestPermissions() {
_appUiState.value = _appUiState.value.copy(
requestPermissions = true
)
}
fun permissionsRequested() {
_appUiState.value = _appUiState.value.copy(
requestPermissions = false
)
}
fun openWebPage(url: String) {
try {
@ -35,6 +71,12 @@ constructor(
showSnackbarMessage(application.getString(R.string.no_browser_detected))
}
}
fun onVpnPermissionAccepted() {
_appUiState.value = _appUiState.value.copy(
vpnPermissionAccepted = true
)
}
fun launchEmail() {
try {
@ -55,16 +97,46 @@ constructor(
}
}
fun showSnackbarMessage(message : String) {
_snackbarState.value = _snackbarState.value.copy(
_appUiState.value = _appUiState.value.copy(
snackbarMessage = message,
snackbarMessageConsumed = false
)
}
fun snackbarMessageConsumed() {
_snackbarState.value = _snackbarState.value.copy(
_appUiState.value = _appUiState.value.copy(
snackbarMessage = "",
snackbarMessageConsumed = true
)
}
val logs = mutableStateListOf<LogMessage>()
fun readLogCatOutput() = viewModelScope.launch(viewModelScope.coroutineContext + Dispatchers.IO) {
launch {
Logcatter.logs {
logs.add(it)
if (logs.size > Constants.LOG_BUFFER_SIZE) {
logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt())
}
}
}
}
fun clearLogs() {
logs.clear()
Logcatter.clear()
}
fun saveLogsToFile() {
val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
val content = logs.joinToString(separator = "\n")
FileUtils.saveFileToDownloads(application.applicationContext, content, fileName)
Toast.makeText(application, application.getString(R.string.logs_saved), Toast.LENGTH_SHORT).show()
}
fun setNotificationPermissionAccepted(accepted: Boolean) {
_appUiState.value = _appUiState.value.copy(
notificationPermissionAccepted = accepted
)
}
}

View File

@ -1,17 +1,16 @@
package com.zaneschepke.wireguardautotunnel.ui
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import androidx.activity.SystemBarStyle
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -20,10 +19,14 @@ import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
@ -35,6 +38,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
@ -43,22 +47,22 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pinlock.PinLockScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.StringValue
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.IOException
import xyz.teamgravity.pin_lock_compose.PinLock
import xyz.teamgravity.pin_lock_compose.PinManager
import javax.inject.Inject
@AndroidEntryPoint
@ -67,9 +71,11 @@ class MainActivity : AppCompatActivity() {
@Inject
lateinit var dataStoreManager: DataStoreManager
@Inject lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var settingsRepository: SettingsRepository
@OptIn(
ExperimentalPermissionsApi::class
ExperimentalPermissionsApi::class,
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -78,56 +84,78 @@ class MainActivity : AppCompatActivity() {
// load preferences into memory and init data
lifecycleScope.launch {
try {
dataStoreManager.init()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
} catch (e: IOException) {
Timber.e("Failed to load preferences")
dataStoreManager.init()
WireGuardAutoTunnel.requestTileServiceStateUpdate(this@MainActivity)
val settings = settingsRepository.getSettings()
if (settings.isAutoTunnelEnabled) {
ServiceManager.startWatcherService(application.applicationContext)
}
}
setContent {
val appViewModel = hiltViewModel<AppViewModel>()
val snackBarState by appViewModel.snackBarState.collectAsStateWithLifecycle()
val appUiState by appViewModel.appUiState.collectAsStateWithLifecycle()
val navController = rememberNavController()
val focusRequester = remember { FocusRequester() }
val navBackStackEntry by navController.currentBackStackEntryAsState()
WireguardAutoTunnelTheme {
val snackbarHostState = remember { SnackbarHostState() }
val notificationPermissionState = if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
val notificationPermissionState =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) else null
fun requestNotificationPermission() {
if (notificationPermissionState != null && !notificationPermissionState.status.isGranted
) {
notificationPermissionState.launchPermissionRequest()
}
}
val focusRequester = remember { FocusRequester() }
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
requestNotificationPermission()
}
val vpnActivityResultState =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
val accepted = (it.resultCode == RESULT_OK)
if (accepted) {
appViewModel.onVpnPermissionAccepted()
}
},
)
fun showSnackBarMessage(message: StringValue) {
lifecycleScope.launch(Dispatchers.Main) {
val result =
snackbarHostState.showSnackbar(
message = message.asString(this@MainActivity),
duration = SnackbarDuration.Short,
)
when (result) {
SnackbarResult.ActionPerformed,
SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
fun showSnackBarMessage(message: StringValue) {
lifecycleScope.launch(Dispatchers.Main) {
val result =
snackbarHostState.showSnackbar(
message = message.asString(this@MainActivity),
duration = SnackbarDuration.Short,
)
when (result) {
SnackbarResult.ActionPerformed,
SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
}
}
}
LaunchedEffect(snackBarState.snackbarMessageConsumed) {
if(!snackBarState.snackbarMessageConsumed) {
showSnackBarMessage(StringValue.DynamicString(snackBarState.snackbarMessage))
LaunchedEffect(appUiState.requestPermissions) {
if (appUiState.requestPermissions) {
appViewModel.permissionsRequested()
if (notificationPermissionState != null && !notificationPermissionState.status.isGranted
) {
showSnackBarMessage(StringValue.StringResource(R.string.notification_permission_required))
return@LaunchedEffect notificationPermissionState.launchPermissionRequest()
}
if (!appUiState.vpnPermissionAccepted) {
return@LaunchedEffect vpnActivityResultState.launch(appViewModel.vpnIntent)
}
}
}
WireguardAutoTunnelTheme {
LaunchedEffect(Unit) {
appViewModel.setNotificationPermissionAccepted(
notificationPermissionState?.status?.isGranted ?: true,
)
if(!WireGuardAutoTunnel.isRunningOnAndroidTv()) appViewModel.readLogCatOutput()
}
LaunchedEffect(appUiState.snackbarMessageConsumed) {
if (!appUiState.snackbarMessageConsumed) {
showSnackBarMessage(StringValue.DynamicString(appUiState.snackbarMessage))
appViewModel.snackbarMessageConsumed()
}
}
@ -139,95 +167,84 @@ class MainActivity : AppCompatActivity() {
snackbarData.visuals.message,
isRtl = false,
containerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp,
),
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp,
),
)
}
},
//TODO refactor
modifier = Modifier
.focusable()
.focusProperties { up = focusRequester },
bottomBar =
if (notificationPermissionState == null || notificationPermissionState.status.isGranted) {
{
BottomNavBar(
navController,
listOf(
Screen.Main.navItem,
Screen.Settings.navItem,
Screen.Support.navItem,
),
)
}
} else {
{}
},
.focusProperties { when(navBackStackEntry?.destination?.route) {
Screen.Lock.route -> Unit
else -> up = focusRequester }
},
bottomBar = {
BottomNavBar(
navController,
listOf(
Screen.Main.navItem,
Screen.Settings.navItem,
Screen.Support.navItem,
),
)
},
) { padding ->
if (notificationPermissionState != null && !notificationPermissionState.status.isGranted) {
Column(modifier = Modifier.padding(padding)) {
PermissionRequestFailedScreen(
onRequestAgain = {
val intentSettings =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data =
Uri.fromParts(
Constants.URI_PACKAGE_SCHEME,
this@MainActivity.packageName,
null,
)
startActivity(intentSettings)
},
message = getString(R.string.notification_permission_required),
getString(R.string.open_settings),
NavHost(
navController,
startDestination =
(if (PinManager.pinExists()) Screen.Lock.route else Screen.Main.route),
modifier =
Modifier
.padding(padding)
.fillMaxSize(),
) {
composable(
Screen.Main.route,
) {
MainScreen(
focusRequester = focusRequester,
appViewModel = appViewModel,
navController = navController,
)
}
composable(
Screen.Settings.route,
) {
SettingsScreen(
appViewModel = appViewModel,
navController = navController,
focusRequester = focusRequester
)
}
composable(
Screen.Support.route,
) {
SupportScreen(
focusRequester = focusRequester,
appViewModel = appViewModel,
navController = navController,
)
}
composable(Screen.Support.Logs.route) {
LogsScreen(appViewModel)
}
composable("${Screen.Config.route}/{id}") {
val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) {
ConfigScreen(
navController = navController,
id = id,
appViewModel = appViewModel,
focusRequester = focusRequester,
)
return@Scaffold
}
}
Column(modifier = Modifier.padding(padding)) {
NavHost(navController, startDestination = Screen.Main.route) {
composable(
Screen.Main.route,
) {
MainScreen(
focusRequester = focusRequester,
appViewModel = appViewModel,
navController = navController,
)
}
composable(
Screen.Settings.route,
) {
SettingsScreen(
appViewModel = appViewModel,
focusRequester = focusRequester,
)
}
composable(
Screen.Support.route,
) {
SupportScreen(
focusRequester = focusRequester,
appViewModel = appViewModel,
navController = navController
)
}
composable(Screen.Support.Logs.route,) {
LogsScreen()
}
composable("${Screen.Config.route}/{id}") {
val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) {
ConfigScreen(
navController = navController,
id = id,
appViewModel = appViewModel,
focusRequester = focusRequester,
)
}
}
}
composable(Screen.Lock.route) {
PinLockScreen(navController = navController, appViewModel = appViewModel)
}
}
}
}
}

View File

@ -36,4 +36,5 @@ sealed class Screen(val route: String) {
}
data object Config : Screen("config")
data object Lock : Screen("lock")
}

View File

@ -1,6 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui
data class SnackBarState(
val snackbarMessage: String = "",
val snackbarMessageConsumed: Boolean = true,
)

View File

@ -29,7 +29,7 @@ fun ClickableIconButton(
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Icon(
imageVector = icon,
contentDescription = stringResource(R.string.delete),
contentDescription = icon.name,
modifier =
Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable {
if (enabled) {

View File

@ -1,36 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
fun PermissionRequestFailedScreen(
onRequestAgain: () -> Unit,
message: String,
buttonText: String
) {
val scope = rememberCoroutineScope()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize(),
) {
Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp))
Button(
onClick = { scope.launch { onRequestAgain() } },
) {
Text(buttonText)
}
}
}

View File

@ -49,7 +49,6 @@ fun RowListItem(
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(.60f),
) {
icon()
Text(text)
@ -65,6 +64,7 @@ fun RowListItem(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
//TODO change these to string resources
val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis
val peerTx = statistics.peer(it)!!.txBytes
val peerRx = statistics.peer(it)!!.rxBytes

View File

@ -6,18 +6,33 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import com.zaneschepke.wireguardautotunnel.ui.Screen
@Composable
fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavItem>) {
val backStackEntry = navController.currentBackStackEntryAsState()
var showBottomBar by rememberSaveable { mutableStateOf(true) }
val navBackStackEntry by navController.currentBackStackEntryAsState()
//TODO find a better way to hide nav bar
showBottomBar = when (navBackStackEntry?.destination?.route) {
Screen.Lock.route -> false
else -> true
}
NavigationBar(
containerColor = MaterialTheme.colorScheme.background,
containerColor = if(!showBottomBar) Color.Transparent else MaterialTheme.colorScheme.background,
) {
bottomNavItems.forEach { item ->
if(showBottomBar) bottomNavItems.forEach { item ->
val selected = item.route == backStackEntry.value?.destination?.route
NavigationBarItem(

View File

@ -49,9 +49,10 @@ fun CustomSnackBar(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
val icon = Icons.Rounded.Info
Icon(
Icons.Rounded.Info,
contentDescription = stringResource(R.string.info),
icon,
contentDescription = icon.name,
tint = Color.White,
modifier = Modifier.padding(end = 10.dp),
)

View File

@ -257,9 +257,10 @@ fun ConfigScreen(
modifier = Modifier.size(50.dp, 50.dp),
)
} else {
val icon = Icons.Rounded.Android
Icon(
Icons.Rounded.Android,
stringResource(id = R.string.edit),
icon,
icon.name,
modifier = Modifier.size(50.dp, 50.dp),
)
}
@ -530,7 +531,8 @@ fun ConfigScreen(
padding = screenPadding,
)
IconButton(onClick = { viewModel.onDeletePeer(index) }) {
Icon(Icons.Rounded.Delete, stringResource(R.string.delete))
val icon = Icons.Rounded.Delete
Icon(icon, icon.name)
}
}

View File

@ -7,8 +7,10 @@ import android.content.pm.PackageManager
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.config.BadConfigException
import com.wireguard.config.Config
import com.wireguard.config.Interface
import com.wireguard.config.ParseException
import com.wireguard.config.Peer
import com.wireguard.crypto.Key
import com.wireguard.crypto.KeyPair
@ -152,7 +154,7 @@ constructor(
viewModelScope.launch {
if (tunnelConfig != null) {
saveConfig(tunnelConfig).join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
WireGuardAutoTunnel.requestTileServiceStateUpdate(application)
updateSettingsDefaultTunnel(tunnelConfig)
}
}
@ -218,7 +220,8 @@ constructor(
Result.Success(Event.Message.ConfigSaved)
} catch (e: Exception) {
Timber.e(e)
Result.Error(Event.Error.Exception(e))
val message = e.message?.substringAfter(":", missingDelimiterValue = "")
Result.Error(Event.Error.ConfigParseError(message ?: ""))
}
}

View File

@ -7,7 +7,6 @@ import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@ -37,6 +36,7 @@ import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.CopyAll
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Info
@ -84,7 +84,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
@ -102,6 +101,7 @@ import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.Result
import com.zaneschepke.wireguardautotunnel.util.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.mapPeerStats
import com.zaneschepke.wireguardautotunnel.util.truncateWithEllipsis
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -128,29 +128,13 @@ fun MainScreen(
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var vpnIntent by remember { mutableStateOf(GoBackend.VpnService.prepare(WireGuardAutoTunnel.instance)) }
val vpnActivityResultState =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
val accepted = (it.resultCode == AppCompatActivity.RESULT_OK)
if (accepted) {
vpnIntent = null
}
},
)
LaunchedEffect(uiState.loading) {
if (!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) {
LaunchedEffect(Unit) {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
}
}
if (uiState.loading) {
LoadingScreen()
return
}
val tunnelFileImportResultLauncher =
rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() {
@ -262,21 +246,24 @@ fun MainScreen(
}
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
if (vpnIntent != null) {
return vpnActivityResultState.launch(vpnIntent)
if(appViewModel.isRequiredPermissionGranted()) {
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
}
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
}
if(uiState.loading) {
return LoadingScreen()
}
Scaffold(
modifier =
Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) selectedTunnel = null
},
)
},
Modifier.pointerInput(Unit) {
detectTapGestures(
onTap = {
selectedTunnel = null
},
)
},
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
AnimatedVisibility(
@ -318,7 +305,7 @@ fun MainScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxSize()
.fillMaxSize(),
) {
Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic)
}
@ -362,7 +349,7 @@ fun MainScreen(
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(
context.getString(R.string.scanning_qr)
context.getString(R.string.scanning_qr),
)
scanOptions.setBeepEnabled(false)
scanOptions.captureActivity =
@ -422,27 +409,30 @@ fun MainScreen(
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
item {
if(uiState.settings.isAutoTunnelEnabled){
if (uiState.settings.isAutoTunnelEnabled) {
val autoTunnelingLabel = buildAnnotatedString {
append(stringResource(id = R.string.auto_tunneling))
append(": ")
if(uiState.settings.isAutoTunnelPaused) append(
stringResource(id = R.string.paused)
if (uiState.settings.isAutoTunnelPaused) append(
stringResource(id = R.string.paused),
) else append(
stringResource(id = R.string.active),
)
}
RowListItem(
icon = { Icon(
Icons.Rounded.Bolt,
stringResource(id = R.string.auto),
modifier = Modifier
.padding(end = 10.dp)
.size(25.dp),
tint =
if (uiState.settings.isAutoTunnelPaused) Color.Gray
else mint,
) },
icon = {
val icon = Icons.Rounded.Bolt
Icon(
icon,
icon.name,
modifier = Modifier
.padding(end = 10.dp)
.size(25.dp),
tint =
if (uiState.settings.isAutoTunnelPaused) Color.Gray
else mint,
)
},
text = autoTunnelingLabel.text,
rowButton = {
if (uiState.settings.isAutoTunnelPaused) {
@ -457,7 +447,7 @@ fun MainScreen(
) {
Text(stringResource(id = R.string.pause))
}
}
}
},
onClick = {},
onHold = {},
@ -473,7 +463,7 @@ fun MainScreen(
val leadingIconColor =
(if (
uiState.vpnState.name == tunnel.name &&
uiState.vpnState.status == Tunnel.State.UP
uiState.vpnState.status == Tunnel.State.UP
) {
uiState.vpnState.statistics
?.mapPeerStats()
@ -484,6 +474,7 @@ fun MainScreen(
statuses?.any { it == HandshakeStatus.STALE } == true -> corn
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true ->
Color.Gray
else -> {
Color.Gray
}
@ -515,11 +506,11 @@ fun MainScreen(
)
}
},
text = tunnel.name,
text = tunnel.name.truncateWithEllipsis(15),
onHold = {
if (
(uiState.vpnState.status == Tunnel.State.UP) &&
(tunnel.name == uiState.vpnState.name)
(tunnel.name == uiState.vpnState.name)
) {
appViewModel.showSnackbarMessage(Event.Message.TunnelOffAction.message)
return@RowListItem
@ -531,7 +522,7 @@ fun MainScreen(
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
if (
uiState.vpnState.status == Tunnel.State.UP &&
(uiState.vpnState.name == tunnel.name)
(uiState.vpnState.name == tunnel.name)
) {
expanded.value = !expanded.value
}
@ -545,7 +536,7 @@ fun MainScreen(
rowButton = {
if (
tunnel.id == selectedTunnel?.id &&
!WireGuardAutoTunnel.isRunningOnAndroidTv()
!WireGuardAutoTunnel.isRunningOnAndroidTv()
) {
Row {
if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
@ -553,7 +544,7 @@ fun MainScreen(
onClick = {
if (
uiState.settings.isAutoTunnelEnabled &&
!uiState.settings.isAutoTunnelPaused
!uiState.settings.isAutoTunnelPaused
) {
appViewModel.showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message,
@ -563,9 +554,10 @@ fun MainScreen(
}
},
) {
val icon = Icons.Rounded.Star
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary),
icon,
icon.name,
)
}
}
@ -573,10 +565,10 @@ fun MainScreen(
onClick = {
if (
uiState.settings.isAutoTunnelEnabled &&
uiState.settings.isTunnelConfigDefault(
tunnel,
) &&
!uiState.settings.isAutoTunnelPaused
uiState.settings.isTunnelConfigDefault(
tunnel,
) &&
!uiState.settings.isAutoTunnelPaused
) {
appViewModel.showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message,
@ -587,13 +579,22 @@ fun MainScreen(
)
},
) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
val icon = Icons.Rounded.Edit
Icon(icon, icon.name)
}
IconButton(
modifier = Modifier.focusable(),
onClick = { viewModel.onCopyTunnel(selectedTunnel) },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
modifier = Modifier.focusable(),
onClick = { showDeleteTunnelAlertDialog = true },
) {
Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete))
val icon = Icons.Rounded.Delete
Icon(icon, icon.name)
}
}
} else {
@ -630,9 +631,10 @@ fun MainScreen(
}
},
) {
val icon = Icons.Rounded.Star
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary),
icon,
icon.name,
)
}
}
@ -641,26 +643,27 @@ fun MainScreen(
onClick = {
if (
uiState.vpnState.status == Tunnel.State.UP &&
(uiState.vpnState.name == tunnel.name)
(uiState.vpnState.name == tunnel.name)
) {
expanded.value = !expanded.value
} else {
appViewModel.showSnackbarMessage(
Event.Message.TunnelOnAction.message
Event.Message.TunnelOnAction.message,
)
}
},
) {
Icon(Icons.Rounded.Info, stringResource(R.string.info))
val icon = Icons.Rounded.Info
Icon(icon, icon.name)
}
IconButton(
onClick = {
if (
uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name
tunnel.name == uiState.vpnState.name
) {
appViewModel.showSnackbarMessage(
Event.Message.TunnelOffAction.message
Event.Message.TunnelOffAction.message,
)
} else {
navController.navigate(
@ -669,25 +672,34 @@ fun MainScreen(
}
},
) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
val icon = Icons.Rounded.Edit
Icon(icon, icon.name)
}
IconButton(
onClick = { viewModel.onCopyTunnel(tunnel) },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
onClick = {
if (
uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name
tunnel.name == uiState.vpnState.name
) {
appViewModel.showSnackbarMessage(
Event.Message.TunnelOffAction.message
Event.Message.TunnelOffAction.message,
)
} else {
selectedTunnel = tunnel
showDeleteTunnelAlertDialog = true
}
},
) {
val icon = Icons.Rounded.Delete
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete),
icon,
icon.name
)
}
TunnelSwitch()

View File

@ -7,6 +7,7 @@ import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.zxing.common.StringUtils
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.model.Settings
@ -49,7 +50,6 @@ constructor(
tunnelConfigRepository.getTunnelConfigsFlow(),
vpnService.vpnState,
) { settings, tunnels, vpnState ->
validateWatcherServiceState(settings)
MainUiState(settings, tunnels, vpnState, false)
}
.stateIn(
@ -58,13 +58,6 @@ constructor(
MainUiState(),
)
private fun validateWatcherServiceState(settings: Settings) =
viewModelScope.launch(Dispatchers.IO) {
if (settings.isAutoTunnelEnabled) {
ServiceManager.startWatcherService(application.applicationContext)
}
}
private fun stopWatcherService() =
viewModelScope.launch(Dispatchers.IO) {
ServiceManager.stopWatcherService(application.applicationContext)
@ -72,16 +65,17 @@ constructor(
fun onDelete(tunnel: TunnelConfig) {
viewModelScope.launch(Dispatchers.IO) {
if (tunnelConfigRepository.count() == 1) {
val settings = settingsRepository.getSettings()
val isDefault = settings.isTunnelConfigDefault(tunnel)
if (tunnelConfigRepository.count() == 1 || isDefault) {
stopWatcherService()
val settings = settingsRepository.getSettings()
settings.defaultTunnel = null
settings.isAutoTunnelEnabled = false
settings.isAlwaysOnVpnEnabled = false
saveSettings(settings)
}
tunnelConfigRepository.delete(tunnel)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
WireGuardAutoTunnel.requestTileServiceStateUpdate(application)
}
}
@ -106,7 +100,7 @@ constructor(
fun onTunnelStop() =
viewModelScope.launch(Dispatchers.IO) {
Timber.d("Stopping active tunnel")
Timber.i("Stopping active tunnel")
ServiceManager.stopVpnService(application.applicationContext)
}
@ -192,8 +186,9 @@ constructor(
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
val firstTunnel = tunnelConfigRepository.count() == 0
saveTunnel(tunnelConfig)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
if(firstTunnel) WireGuardAutoTunnel.requestTileServiceStateUpdate(application)
}
fun pauseAutoTunneling() =
@ -264,7 +259,13 @@ constructor(
if (selectedTunnel != null) {
saveSettings(uiState.value.settings.copy(defaultTunnel = selectedTunnel.toString()))
.join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
WireGuardAutoTunnel.requestTileServiceStateUpdate(application)
}
}
fun onCopyTunnel(tunnel: TunnelConfig?) = viewModelScope.launch {
tunnel?.let {
saveTunnel(TunnelConfig(name = it.name.plus(NumberUtils.randomThree()), wgQuick = it.wgQuick))
}
}
}

View File

@ -0,0 +1,49 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.pinlock
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.util.StringValue
import xyz.teamgravity.pin_lock_compose.PinLock
@Composable
fun PinLockScreen(navController: NavController, appViewModel: AppViewModel) {
val context = LocalContext.current
PinLock(
title = { pinExists ->
Text(
text = if (pinExists) stringResource(id = R.string.enter_pin) else stringResource(
id = R.string.create_pin,
),
)
},
color = MaterialTheme.colorScheme.surface,
onPinCorrect = {
// pin is correct, navigate or hide pin lock
if(WireGuardAutoTunnel.isRunningOnAndroidTv()) {
navController.navigate(Screen.Main.route)
} else {
val isPopped = navController.popBackStack()
if(!isPopped) {
navController.navigate(Screen.Main.route)
}
}
},
onPinIncorrect = {
// pin is incorrect, show error
appViewModel.showSnackbarMessage(StringValue.StringResource(R.string.incorrect_pin).asString(context))
},
onPinCreated = {
// pin created for the first time, navigate or hide pin lock
appViewModel.showSnackbarMessage(StringValue.StringResource(R.string.pin_created).asString(context))
},
)
}

View File

@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -54,7 +53,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@ -68,6 +66,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@ -76,10 +75,10 @@ import com.wireguard.android.backend.WgQuickBackend
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.FileUtils
@ -87,6 +86,7 @@ import com.zaneschepke.wireguardautotunnel.util.Result
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import xyz.teamgravity.pin_lock_compose.PinManager
import java.io.File
@OptIn(
@ -97,13 +97,15 @@ import java.io.File
fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
appViewModel: AppViewModel,
focusRequester: FocusRequester
navController: NavController,
focusRequester: FocusRequester,
) {
val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() }
val pinExists = remember { mutableStateOf(PinManager.pinExists()) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -113,19 +115,12 @@ fun SettingsScreen(
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
val focusRequester2 = remember { FocusRequester() }
val screenPadding = 5.dp
val fillMaxWidth = .85f
if (uiState.loading) {
LoadingScreen()
return
}
val startForResult =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
result.data
// Handle the Intent
@ -164,7 +159,9 @@ fun SettingsScreen(
fun handleAutoTunnelToggle() {
if (uiState.isBatteryOptimizeDisableShown || isBatteryOptimizationsDisabled()) {
viewModel.toggleAutoTunnel()
if (appViewModel.isRequiredPermissionGranted()) {
viewModel.toggleAutoTunnel()
}
} else {
requestBatteryOptimizationsDisabled()
}
@ -202,7 +199,7 @@ fun SettingsScreen(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (
WireGuardAutoTunnel.isRunningOnAndroidTv() &&
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q
) {
checkFineLocationGranted()
} else {
@ -249,12 +246,16 @@ fun SettingsScreen(
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().verticalScroll(scrollState),
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState),
) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier = Modifier.padding(30.dp).size(128.dp),
modifier = Modifier
.padding(30.dp)
.size(128.dp),
)
Text(
stringResource(R.string.prominent_background_location_title),
@ -270,11 +271,15 @@ fun SettingsScreen(
)
Row(
modifier =
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.fillMaxWidth().padding(10.dp)
} else {
Modifier.fillMaxWidth().padding(30.dp)
},
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier
.fillMaxWidth()
.padding(10.dp)
} else {
Modifier
.fillMaxWidth()
.padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
@ -330,12 +335,15 @@ fun SettingsScreen(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier.fillMaxSize().verticalScroll(scrollState).clickable(
indication = null,
interactionSource = interactionSource,
) {
focusManager.clearFocus()
},
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.clickable(
indication = null,
interactionSource = interactionSource,
) {
focusManager.clearFocus()
},
) {
Surface(
tonalElevation = 2.dp,
@ -343,14 +351,17 @@ fun SettingsScreen(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp)
})
.padding(bottom = 10.dp),
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier
.fillMaxWidth(fillMaxWidth)
.padding(top = 20.dp)
})
.padding(bottom = 10.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
@ -364,38 +375,43 @@ fun SettingsScreen(
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_wifi),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnWifi() },
modifier =
if (uiState.settings.isAutoTunnelEnabled) Modifier
else
Modifier.focusRequester(focusRequester).focusProperties {
down = focusRequester2
},
if (uiState.settings.isAutoTunnelEnabled) Modifier
else
Modifier
.focusRequester(focusRequester),
)
AnimatedVisibility(visible = uiState.settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier = Modifier.padding(screenPadding).fillMaxWidth(),
modifier = Modifier
.padding(screenPadding)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
uiState.settings.trustedNetworkSSIDs.forEach { ssid ->
ClickableIconButton(
onClick = {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
focusRequester.requestFocus()
viewModel.onDeleteTrustedSSID(ssid)
focusRequester2.requestFocus()
}
},
onIconClick = { viewModel.onDeleteTrustedSSID(ssid) },
onIconClick = {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) focusRequester.requestFocus()
viewModel.onDeleteTrustedSSID(ssid)
},
text = ssid,
icon = Icons.Filled.Close,
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
)
}
if (uiState.settings.trustedNetworkSSIDs.isEmpty()) {
@ -408,24 +424,24 @@ fun SettingsScreen(
}
OutlinedTextField(
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier =
Modifier.padding(
start = screenPadding,
top = 5.dp,
bottom = 10.dp,
)
.focusRequester(focusRequester2),
Modifier
.padding(
start = screenPadding,
top = 5.dp,
bottom = 10.dp,
),
maxLines = 1,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }),
trailingIcon = {
if (currentText != "") {
@ -433,19 +449,19 @@ fun SettingsScreen(
Icon(
imageVector = Icons.Outlined.Add,
contentDescription =
if (currentText == "") {
stringResource(
id =
R.string
.trusted_ssid_empty_description,
)
} else {
stringResource(
id =
R.string
.trusted_ssid_value_description,
)
},
if (currentText == "") {
stringResource(
id =
R.string
.trusted_ssid_empty_description,
)
} else {
stringResource(
id =
R.string
.trusted_ssid_value_description,
)
},
tint = MaterialTheme.colorScheme.primary,
)
}
@ -457,8 +473,8 @@ fun SettingsScreen(
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnMobileData() },
@ -466,29 +482,29 @@ fun SettingsScreen(
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnEthernet() },
)
ConfigurationToggle(
stringResource(R.string.battery_saver),
stringResource(R.string.restart_on_ping),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isBatterySaverEnabled,
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isPingEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleBatterySaver() },
onCheckChanged = { viewModel.onToggleRestartOnPing() },
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
(if (!uiState.settings.isAutoTunnelEnabled) Modifier
else
Modifier.focusRequester(
focusRequester,
))
(if (!uiState.settings.isAutoTunnelEnabled) Modifier
else
Modifier.focusRequester(
focusRequester,
))
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
@ -498,19 +514,22 @@ fun SettingsScreen(
onClick = {
if (
uiState.settings.isTunnelOnWifiEnabled &&
!uiState.settings.isAutoTunnelEnabled
!uiState.settings.isAutoTunnelEnabled
) {
when (false) {
isBackgroundLocationGranted ->
appViewModel.showSnackbarMessage(
Event.Error.BackgroundLocationRequired.message
Event.Error.BackgroundLocationRequired.message,
)
fineLocationState.status.isGranted ->
appViewModel.showSnackbarMessage(
Event.Error.PreciseLocationRequired.message
Event.Error.PreciseLocationRequired.message,
)
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
handleAutoTunnelToggle()
}
@ -537,7 +556,9 @@ fun SettingsScreen(
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(fillMaxWidth).padding(vertical = 10.dp),
modifier = Modifier
.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
@ -551,9 +572,9 @@ fun SettingsScreen(
ConfigurationToggle(
stringResource(R.string.use_kernel),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == Tunnel.State.UP)),
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == Tunnel.State.UP)),
checked = uiState.settings.isKernelEnabled,
padding = screenPadding,
onCheckChanged = {
@ -568,26 +589,27 @@ fun SettingsScreen(
}
}
}
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp)
.padding(bottom = 140.dp),
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier
.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp)
.padding(bottom = 140.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
SectionTitle(
title = stringResource(id = R.string.other),
padding = screenPadding,
)
SectionTitle(
title = stringResource(id = R.string.other),
padding = screenPadding,
)
if(!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
ConfigurationToggle(
stringResource(R.string.always_on_vpn_support),
enabled = !uiState.settings.isAutoTunnelEnabled,
@ -602,9 +624,27 @@ fun SettingsScreen(
padding = screenPadding,
onCheckChanged = { viewModel.onToggleShortcutsEnabled() },
)
}
ConfigurationToggle(
stringResource(R.string.enable_app_lock),
enabled = true,
checked = pinExists.value,
padding = screenPadding,
onCheckChanged = {
if (pinExists.value) {
PinManager.clearPin()
pinExists.value = PinManager.pinExists()
} else {
navController.navigate(Screen.Lock.route)
}
},
)
if(!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(top = 5.dp),
modifier = Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
@ -617,9 +657,6 @@ fun SettingsScreen(
}
}
}
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f))
}
}
}
}

View File

@ -9,6 +9,5 @@ data class SettingsUiState(
val tunnels: List<TunnelConfig> = emptyList(),
val vpnState: VpnState = VpnState(),
val isLocationDisclosureShown: Boolean = true,
val isBatteryOptimizeDisableShown: Boolean = false,
val loading: Boolean = true
val isBatteryOptimizeDisableShown: Boolean = false
)

View File

@ -49,7 +49,6 @@ constructor(
tunnelState,
preferences?.get(DataStoreManager.LOCATION_DISCLOSURE_SHOWN) ?: false,
preferences?.get(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN) ?: false,
false
)
}
.stateIn(
@ -195,4 +194,12 @@ constructor(
}
return Result.Success(Unit)
}
fun onToggleRestartOnPing() = viewModelScope.launch {
saveSettings(
uiState.value.settings.copy(
isPingEnabled = !uiState.value.settings.isPingEnabled,
),
)
}
}

View File

@ -64,11 +64,6 @@ fun SupportScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
if (uiState.loading) {
LoadingScreen()
return
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
@ -229,32 +224,34 @@ fun SupportScreen(
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.onBackground
)
TextButton(
onClick = { navController.navigate(Screen.Support.Logs.route) },
modifier = Modifier.padding(vertical = 5.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
if(!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.onBackground
)
TextButton(
onClick = { navController.navigate(Screen.Support.Logs.route) },
modifier = Modifier.padding(vertical = 5.dp),
) {
Row {
val icon = Icons.Rounded.FormatListNumbered
Icon(icon, icon.name)
Text(
stringResource(id = R.string.read_logs),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row {
val icon = Icons.Rounded.FormatListNumbered
Icon(icon, icon.name)
Text(
stringResource(id = R.string.read_logs),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
)
}
Icon(
Icons.AutoMirrored.Rounded.ArrowForward,
stringResource(id = R.string.go)
)
}
Icon(
Icons.AutoMirrored.Rounded.ArrowForward,
stringResource(id = R.string.go)
)
}
}
}

View File

@ -2,4 +2,4 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support
import com.zaneschepke.wireguardautotunnel.data.model.Settings
data class SupportUiState(val settings: Settings = Settings(), val loading: Boolean = true)
data class SupportUiState(val settings: Settings = Settings())

View File

@ -17,7 +17,7 @@ class SupportViewModel @Inject constructor(private val settingsRepository: Setti
val uiState =
settingsRepository
.getSettingsFlow()
.map { SupportUiState(it, false) }
.map { SupportUiState(it) }
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),

View File

@ -32,26 +32,22 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.common.text.LogTypeLabel
import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun LogsScreen(logsViewModel: LogsViewModel = hiltViewModel()) {
fun LogsScreen(appViewModel: AppViewModel) {
val logs = remember {
logsViewModel.logs
appViewModel.logs
}
val lazyColumnListState = rememberLazyListState()
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
logsViewModel.readLogCatOutput()
}
LaunchedEffect(logs.size){
scope.launch {
lazyColumnListState.animateScrollToItem(logs.size)
@ -62,7 +58,7 @@ fun LogsScreen(logsViewModel: LogsViewModel = hiltViewModel()) {
floatingActionButton = {
FloatingActionButton(
onClick = {
logsViewModel.saveLogsToFile()
appViewModel.saveLogsToFile()
},
shape = RoundedCornerShape(16.dp),
containerColor = MaterialTheme.colorScheme.primary

View File

@ -1,53 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.logs
import android.app.Application
import android.widget.Toast
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.logcatter.Logcatter
import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
class LogsViewModel
@Inject
constructor(
private val application: Application
) : ViewModel() {
val logs = mutableStateListOf<LogMessage>()
fun readLogCatOutput() = viewModelScope.launch(viewModelScope.coroutineContext + Dispatchers.IO) {
launch {
Logcatter.logs {
logs.add(it)
if (logs.size > Constants.LOG_BUFFER_SIZE) {
logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt())
}
}
}
}
fun clearLogs() {
logs.clear()
Logcatter.clear()
}
fun saveLogsToFile() {
val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
val content = logs.joinToString(separator = "\n")
FileUtils.saveFileToDownloads(application.applicationContext, content, fileName)
Toast.makeText(application, application.getString(R.string.logs_saved), Toast.LENGTH_SHORT).show()
}
}

View File

@ -7,14 +7,12 @@ object Constants {
const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1_000L // 10 minutes
const val DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT = 30 * 60 * 1_000L // 30 minutes
const val VPN_STATISTIC_CHECK_INTERVAL = 1_000L
const val VPN_CONNECTED_NOTIFICATION_DELAY = 3_000L
const val TOGGLE_TUNNEL_DELAY = 300L
const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content"
const val URI_PACKAGE_SCHEME = "package"
const val ALLOWED_FILE_TYPES = "*/*"
const val TEXT_MIME_TYPE = "text/plain"
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
@ -24,4 +22,11 @@ object Constants {
const val SUBSCRIPTION_TIMEOUT = 5_000L
const val FOCUS_REQUEST_DELAY = 500L
const val BACKUP_PING_HOST = "1.1.1.1"
const val PING_TIMEOUT = 5_000L
const val VPN_RESTART_DELAY = 1_000L
const val PING_INTERVAL = 60_000L
const val PING_COOLDOWN = PING_INTERVAL * 60 //one hour
}

View File

@ -18,6 +18,12 @@ sealed class Event {
get() = WireGuardAutoTunnel.instance.getString(R.string.error_ssid_exists)
}
data class ConfigParseError(val appendedMessage : String) : Error() {
override val message: String =
WireGuardAutoTunnel.instance.getString(R.string.config_parse_error) + (
if (appendedMessage != "") ": ${appendedMessage.trim()}" else "")
}
data object RootDenied : Error() {
override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_root_denied)

View File

@ -31,6 +31,12 @@ fun BroadcastReceiver.goAsync(
}
}
fun String.truncateWithEllipsis(allowedLength : Int) : String {
return if(this.length > allowedLength + 3) {
this.substring(0, allowedLength) + "***"
} else this
}
fun BigDecimal.toThreeDecimalPlaceString(): String {
val df = DecimalFormat("#.###")
return df.format(this)

View File

@ -19,7 +19,15 @@ object NumberUtils {
}
fun generateRandomTunnelName(): String {
return "tunnel${(Math.random() * 100000).toInt()}"
return "tunnel${randomFive()}"
}
private fun randomFive() : Int {
return (Math.random() * 100000).toInt()
}
fun randomThree() : Int {
return (Math.random() * 1000).toInt()
}
fun getSecondsBetweenTimestampAndNow(epoch: Long): Long? {

View File

@ -20,7 +20,7 @@
<string name="tunnel_start_title">VPN Connected</string>
<string name="tunnel_start_text">Connected to tunnel -</string>
<string name="vpn_permission_required">VPN permission is required for the app to work properly. If this permission is not launching, please disable \"Always-on VPN\" in your phone settings for the official WireGuard mobile app and try again.</string>
<string name="notification_permission_required">Notifications permission is required for the app to work properly.</string>
<string name="notification_permission_required">Notifications permission required.</string>
<string name="open_settings">Open Settings</string>
<string name="add_trusted_ssid">Add trusted wifi name</string>
<string name="tunnels">Tunnels</string>
@ -46,8 +46,6 @@
<string name="qr_scan">QR Scan</string>
<string name="tunnel_edit">Tunnel Edit</string>
<string name="tunnel_name">Tunnel Name</string>
<string name="edit">Edit</string>
<string name="delete">Delete</string>
<string name="add_tunnel">Add Tunnel</string>
<string name="exclude">Exclude</string>
<string name="include">Include</string>
@ -105,11 +103,9 @@
<string name="default_vpn_on">Primary VPN on</string>
<string name="default_vpn_off">Primary VPN off</string>
<string name="create_import">Create from scratch</string>
<string name="set_primary">Set primary</string>
<string name="turn_off_auto">Action requires auto-tunnel disabled</string>
<string name="turn_on_tunnel">Action requires active tunnel</string>
<string name="add_peer">Add peer</string>
<string name="info">Info</string>
<string name="done">Done</string>
<string name="interface_">Interface</string>
<string name="rotate_keys">Rotate keys</string>
@ -119,7 +115,6 @@
<string name="comma_separated_list">comma separated list</string>
<string name="listen_port">Listen port</string>
<string name="random">(random)</string>
<string name="auto">(auto)</string>
<string name="optional">(optional)</string>
<string name="optional_no_recommend">(optional, not recommended)</string>
<string name="preshared_key">Pre-shared key</string>
@ -177,4 +172,12 @@
<string name="logs_saved">Logs saved to downloads</string>
<string name="open_issue">Open an issue</string>
<string name="read_logs">Read the logs</string>
<string name="auto">(auto)</string>
<string name="config_parse_error">Failed to parse config</string>
<string name="incorrect_pin">Pin is incorrect</string>
<string name="pin_created">Pin successfully created</string>
<string name="enter_pin">Enter your pin</string>
<string name="create_pin">Create pin</string>
<string name="enable_app_lock">Enabled app lock</string>
<string name="restart_on_ping">Restart on ping fail</string>
</resources>

View File

@ -1,7 +1,7 @@
object Constants {
const val VERSION_NAME = "3.3.8-ipv6"
const val VERSION_NAME = "3.3.9"
const val JVM_TARGET = "17"
const val VERSION_CODE = 33803
const val VERSION_CODE = 33900
const val TARGET_SDK = 34
const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel"

View File

@ -0,0 +1,5 @@
What's new:
- Add logs screen
- Add local app lock
- Add restart vpn on failed ping
- Various bug fixes

View File

@ -9,27 +9,28 @@ coreKtx = "1.12.0"
datastorePreferences = "1.0.0"
desugar_jdk_libs = "2.0.4"
espressoCore = "3.5.1"
hiltAndroid = "2.50"
hiltAndroid = "2.51"
hiltNavigationCompose = "1.2.0"
junit = "4.13.2"
kotlinx-serialization-json = "1.6.3"
lifecycle-runtime-compose = "2.7.0"
material3 = "1.2.0"
material3 = "1.2.1"
navigationCompose = "2.7.7"
pinLockCompose = "1.0.3"
roomVersion = "2.6.1"
timber = "5.0.1"
tunnel = "1.1.0"
androidGradlePlugin = "8.3.0"
tunnel = "1.0.20230706"
androidGradlePlugin = "8.3.1"
kotlin = "1.9.22"
ksp = "1.9.22-1.0.17"
composeBom = "2024.02.01"
composeBom = "2024.02.02"
compose = "1.6.3"
zxingAndroidEmbedded = "4.3.0"
zxingCore = "3.5.3"
#plugins
gradlePlugins-kotlinxSerialization = "1.8.21"
material = "1.10.0"
material = "1.11.0"
[libraries]
@ -80,8 +81,9 @@ kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serializa
lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle-runtime-compose" }
material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "compose" }
pin-lock-compose = { module = "com.zaneschepke:pin_lock_compose", version.ref = "pinLockCompose" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
tunnel = { module = "com.zaneschepke:wireguard-android", version.ref = "tunnel" }
tunnel = { module = "com.wireguard.android:tunnel", version.ref = "tunnel" }
zxing-core = { module = "com.google.zxing:core", version.ref = "zxingCore" }
zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }

View File

@ -7,21 +7,11 @@ pluginManagement {
}
}
val GITHUB_USER_VAR = "GH_USER"
val GITHUB_TOKEN_VAR = "GH_TOKEN"
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
mavenLocal()
maven {
name = "GitHubPackages"
url = uri("https://maven.pkg.github.com/zaneschepke/wireguard-android")
credentials {
username = getLocalProperty(GITHUB_USER_VAR) ?: System.getenv(GITHUB_USER_VAR)
password = getLocalProperty(GITHUB_TOKEN_VAR) ?: System.getenv(GITHUB_TOKEN_VAR)
}
}
maven("https://gitea.zaneschepke.com/api/packages/zane/maven")
google()
mavenCentral()
}