Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 7a5bbf81a7 build(deps): bump compose from 1.7.0 to 1.7.1
Bumps `compose` from 1.7.0 to 1.7.1.

Updates `androidx.compose.ui:ui-test-junit4` from 1.7.0 to 1.7.1

Updates `androidx.compose.ui:ui-tooling` from 1.7.0 to 1.7.1

Updates `androidx.compose.ui:ui-test-manifest` from 1.7.0 to 1.7.1

Updates `androidx.compose.ui:ui-graphics` from 1.7.0 to 1.7.1

Updates `androidx.compose.ui:ui-tooling-preview` from 1.7.0 to 1.7.1

Updates `androidx.compose.ui:ui` from 1.7.0 to 1.7.1

Updates `androidx.compose.material:material-icons-extended` from 1.7.0 to 1.7.1

---
updated-dependencies:
- dependency-name: androidx.compose.ui:ui-test-junit4
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-tooling
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-test-manifest
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-graphics
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui-tooling-preview
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.ui:ui
  dependency-type: direct:production
  update-type: version-update:semver-patch
- dependency-name: androidx.compose.material:material-icons-extended
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-09-15 04:32:05 +00:00
45 changed files with 1205 additions and 1049 deletions
@@ -1,4 +1,4 @@
name: on-issue
name: Issue Updates Workflow
on:
issues:
@@ -7,8 +7,8 @@ on:
jobs:
on-issue:
name: On new issue
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Send Telegram Message
@@ -1,4 +1,4 @@
name: on-publish
name: Release Updates Workflow
on:
release:
@@ -7,8 +7,8 @@ on:
jobs:
on-publish:
name: On publish
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Send Telegram Message
+5 -37
View File
@@ -2,7 +2,7 @@ name: release-android
on:
schedule:
- cron: "4 3 * * *"
- cron: "4 3 * * *"
workflow_dispatch:
inputs:
track:
@@ -14,7 +14,7 @@ on:
- alpha
- beta
- production
default: none
default: alpha
required: true
release_type:
type: choice
@@ -30,30 +30,13 @@ on:
description: "Tag name for release"
required: false
default: nightly
workflow_call:
jobs:
check_date:
runs-on: ubuntu-latest
name: Check latest commit
outputs:
should_run: ${{ steps.should_run.outputs.should_run }}
steps:
- uses: actions/checkout@v4
- name: print latest_commit
run: echo ${{ github.sha }}
- id: should_run
continue-on-error: true
name: check latest commit is less than a day
if: ${{ github.event_name == 'schedule' }}
run: test -z $(git rev-list --after="23 hours" ${{ github.sha }}) && echo "::set-output name=should_run::false"
build:
needs: check_date
if: |
github.event_name != 'schedule' ||
(needs.check_date.outputs.should_run == 'true' && github.event_name == 'schedule')
name: Build Signed APK
if: ${{ inputs.release_type != 'none' }}
runs-on: ubuntu-latest
env:
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
@@ -127,24 +110,9 @@ jobs:
version_code=$(grep "VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk '{print $5}' | tr -d '\n')
echo "VERSION_CODE=$version_code" >> $GITHUB_ENV
- name: Commit and push versionCode changes
if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' || inputs.release_type == 'prerelease' }}
run: |
git config --global user.name 'GitHub Actions'
git config --global user.email 'actions@github.com'
git add versionCode.txt
git commit -m "Automated build update"
- name: Push changes
if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' || inputs.release_type == 'prerelease' }}
uses: ad-m/github-push-action@master
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: ${{ github.ref }}
# Save the APK after the Build job is complete to publish it as a Github release in the next job
- name: Upload APK
uses: actions/upload-artifact@v4.4.0
uses: actions/upload-artifact@v4.3.6
with:
name: wgtunnel
path: ${{ env.APK_PATH }}
+13 -32
View File
@@ -8,21 +8,6 @@ plugins {
alias(libs.plugins.grgit)
}
val versionFile = file("$rootDir/versionCode.txt")
val versionCodeIncrement = with(getBuildTaskName().lowercase()) {
when {
this.contains(Constants.NIGHTLY) || this.contains(Constants.PRERELEASE) -> {
if (versionFile.exists()) {
versionFile.readText().toInt() + 1
} else {
1
}
}
else -> 0
}
}
android {
namespace = Constants.APP_ID
compileSdk = Constants.TARGET_SDK
@@ -35,7 +20,7 @@ android {
applicationId = Constants.APP_ID
minSdk = Constants.MIN_SDK
targetSdk = Constants.TARGET_SDK
versionCode = Constants.VERSION_CODE + versionCodeIncrement
versionCode = determineVersionCode()
versionName = determineVersionName()
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
@@ -172,6 +157,8 @@ dependencies {
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.zaneschepke.multifab)
// hilt
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
@@ -212,6 +199,16 @@ dependencies {
implementation(libs.androidx.core.splashscreen)
}
fun determineVersionCode(): Int {
return with(getBuildTaskName().lowercase()) {
when {
contains(Constants.NIGHTLY) -> Constants.VERSION_CODE + Constants.NIGHTLY_CODE
contains(Constants.PRERELEASE) -> Constants.VERSION_CODE + Constants.PRERELEASE_CODE
else -> Constants.VERSION_CODE
}
}
}
fun determineVersionName(): String {
return with(getBuildTaskName().lowercase()) {
when {
@@ -222,19 +219,3 @@ fun determineVersionName(): String {
}
}
}
val incrementVersionCode by tasks.registering {
doLast {
val versionFile = file("$rootDir/versionCode.txt")
if (versionFile.exists()) {
versionFile.writeText(versionCodeIncrement.toString())
println("Incremented versionCode to $versionCodeIncrement")
}
}
}
tasks.whenTaskAdded {
if (name.startsWith("assemble") && !name.lowercase().contains("debug")) {
dependsOn(incrementVersionCode)
}
}
@@ -4,6 +4,7 @@ data class GeneralState(
val isLocationDisclosureShown: Boolean = LOCATION_DISCLOSURE_SHOWN_DEFAULT,
val isBatteryOptimizationDisableShown: Boolean = BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT,
val isPinLockEnabled: Boolean = PIN_LOCK_ENABLED_DEFAULT,
val lastActiveTunnelId: Int? = null,
) {
companion object {
const val LOCATION_DISCLOSURE_SHOWN_DEFAULT = false
@@ -58,11 +58,6 @@ data class TunnelConfig(
)
var pingIp: String? = null,
) {
fun toAmConfig(): org.amnezia.awg.config.Config {
return configFromAmQuick(if (amQuick != "") amQuick else wgQuick)
}
companion object {
fun configFromWgQuick(wgQuick: String): Config {
@@ -2,47 +2,57 @@ package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import timber.log.Timber
class DataStoreAppStateRepository(
private val dataStoreManager: DataStoreManager,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) :
AppStateRepository {
override suspend fun isLocationDisclosureShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN)
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT
return withContext(ioDispatcher) {
dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN)
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT
}
}
override suspend fun setLocationDisclosureShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, shown)
withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, shown) }
}
override suspend fun isPinLockEnabled(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.IS_PIN_LOCK_ENABLED)
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT
return withContext(ioDispatcher) {
dataStoreManager.getFromStore(DataStoreManager.IS_PIN_LOCK_ENABLED)
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT
}
}
override suspend fun setPinLockEnabled(enabled: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.IS_PIN_LOCK_ENABLED, enabled)
withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.IS_PIN_LOCK_ENABLED, enabled) }
}
override suspend fun isBatteryOptimizationDisableShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN)
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT
return withContext(ioDispatcher) {
dataStoreManager.getFromStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN)
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT
}
}
override suspend fun setBatteryOptimizationDisableShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown)
withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown) }
}
override suspend fun getCurrentSsid(): String? {
return dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID)
return withContext(ioDispatcher) { dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID) }
}
override suspend fun setCurrentSsid(ssid: String) {
dataStoreManager.saveToDataStore(DataStoreManager.CURRENT_SSID, ssid)
withContext(ioDispatcher) { dataStoreManager.saveToDataStore(DataStoreManager.CURRENT_SSID, ssid) }
}
override val generalStateFlow: Flow<GeneralState> =
@@ -72,8 +72,8 @@ class RepositoryModule {
@Provides
@Singleton
fun provideGeneralStateRepository(dataStoreManager: DataStoreManager): AppStateRepository {
return DataStoreAppStateRepository(dataStoreManager)
fun provideGeneralStateRepository(dataStoreManager: DataStoreManager, @IoDispatcher ioDispatcher: CoroutineDispatcher): AppStateRepository {
return DataStoreAppStateRepository(dataStoreManager, ioDispatcher)
}
@Provides
@@ -4,11 +4,8 @@ import android.content.Context
import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.RootTunnelActionHandler
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import dagger.Module
@@ -46,8 +43,8 @@ class TunnelModule {
@Provides
@Singleton
@Kernel
fun provideKernelBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell), RootTunnelActionHandler(rootShell))
fun provideKernelBackend(@ApplicationContext context: Context, rootShell: org.amnezia.awg.util.RootShell): org.amnezia.awg.backend.Backend {
return org.amnezia.awg.backend.AwgQuickBackend(context, rootShell, org.amnezia.awg.util.ToolsInstaller(context, rootShell))
}
@Provides
@@ -60,15 +57,15 @@ class TunnelModule {
@Singleton
fun provideVpnService(
amneziaBackend: Provider<org.amnezia.awg.backend.Backend>,
@Kernel kernelBackend: Provider<Backend>,
@Userspace userspaceBackend: Provider<Backend>,
@Kernel kernelBackend: Provider<org.amnezia.awg.backend.Backend>,
appDataRepository: AppDataRepository,
tunnelConfigRepository: TunnelConfigRepository,
@ApplicationScope applicationScope: CoroutineScope,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
): TunnelService {
return WireGuardTunnel(
amneziaBackend,
tunnelConfigRepository,
userspaceBackend,
kernelBackend,
appDataRepository,
applicationScope,
@@ -7,7 +7,6 @@ import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
@@ -34,9 +33,7 @@ class BootReceiver : BroadcastReceiver() {
with(appDataRepository.settings.getSettings()) {
if (isRestoreOnBootEnabled) {
val activeTunnels = appDataRepository.tunnels.getActive()
val tunState = tunnelService.get().vpnState.value.status
if (activeTunnels.isNotEmpty() && tunState != TunnelState.UP) {
Timber.i("Starting previously active tunnel")
if (activeTunnels.isNotEmpty()) {
context.startTunnelBackground(activeTunnels.first().id)
}
if (isAutoTunnelEnabled) {
@@ -33,7 +33,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -72,7 +71,7 @@ class AutoTunnelService : LifecycleService() {
@MainImmediateDispatcher
lateinit var mainImmediateDispatcher: CoroutineDispatcher
private val autoTunnelStateFlow = MutableStateFlow(AutoTunnelState())
private val networkEventsFlow = MutableStateFlow(AutoTunnelState())
private var wakeLock: PowerManager.WakeLock? = null
@@ -133,7 +132,6 @@ class AutoTunnelService : LifecycleService() {
initWakeLock()
}
startSettingsJob()
startVpnStateJob()
}.onFailure {
Timber.e(it)
}
@@ -193,10 +191,6 @@ class AutoTunnelService : LifecycleService() {
watchForSettingsChanges()
}
private fun startVpnStateJob() = lifecycleScope.launch {
watchForVpnStateChanges()
}
private fun startWifiJob() = lifecycleScope.launch {
watchForWifiConnectivityChanges()
}
@@ -224,7 +218,7 @@ class AutoTunnelService : LifecycleService() {
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Mobile data connection")
autoTunnelStateFlow.update {
networkEventsFlow.update {
it.copy(
isMobileDataConnected = true,
)
@@ -232,7 +226,7 @@ class AutoTunnelService : LifecycleService() {
}
is NetworkStatus.CapabilitiesChanged -> {
autoTunnelStateFlow.update {
networkEventsFlow.update {
it.copy(
isMobileDataConnected = true,
)
@@ -241,7 +235,7 @@ class AutoTunnelService : LifecycleService() {
}
is NetworkStatus.Unavailable -> {
autoTunnelStateFlow.update {
networkEventsFlow.update {
it.copy(
isMobileDataConnected = false,
)
@@ -259,8 +253,7 @@ class AutoTunnelService : LifecycleService() {
runCatching {
do {
val vpnState = tunnelService.get().vpnState.value
val settings = appDataRepository.settings.getSettings()
if (vpnState.status == TunnelState.UP && !settings.isAutoTunnelPaused) {
if (vpnState.status == TunnelState.UP) {
if (vpnState.tunnelConfig != null) {
val config = TunnelConfig.configFromWgQuick(vpnState.tunnelConfig.wgQuick)
val results = if (vpnState.tunnelConfig.pingIp != null) {
@@ -290,8 +283,16 @@ class AutoTunnelService : LifecycleService() {
}
}
private fun updateSettings(settings: Settings) {
networkEventsFlow.update {
it.copy(
settings = settings,
)
}
}
private fun onAutoTunnelPause(paused: Boolean) {
if (autoTunnelStateFlow.value.settings.isAutoTunnelPaused
if (networkEventsFlow.value.settings.isAutoTunnelPaused
!= paused
) {
when (paused) {
@@ -305,36 +306,19 @@ class AutoTunnelService : LifecycleService() {
Timber.i("Starting settings watcher")
withContext(ioDispatcher) {
appDataRepository.settings.getSettingsFlow().combine(
// ignore isActive changes to allow manual tunnel overrides
appDataRepository.tunnels.getTunnelConfigsFlow().distinctUntilChanged { old, new ->
old.map { it.isActive } != new.map { it.isActive }
},
appDataRepository.tunnels.getTunnelConfigsFlow(),
) { settings, tunnels ->
autoTunnelStateFlow.value.copy(
settings = settings,
tunnels = tunnels,
)
}.collect {
onAutoTunnelPause(it.settings.isAutoTunnelPaused)
manageJobsBySettings(it.settings)
autoTunnelStateFlow.emit(it)
}
}
}
private suspend fun watchForVpnStateChanges() {
Timber.i("Starting vpn state watcher")
withContext(ioDispatcher) {
tunnelService.get().vpnState.collect { state ->
state.tunnelConfig?.let {
val settings = appDataRepository.settings.getSettings()
if (it.isPingEnabled && !settings.isPingEnabled) {
pingJob.onNotRunning { pingJob = startPingJob() }
}
if (!it.isPingEnabled && !settings.isPingEnabled) {
cancelAndResetPingJob()
}
val activeTunnel = tunnels.firstOrNull { it.isActive }
if (!settings.isPingEnabled) {
settings.copy(isPingEnabled = activeTunnel?.isPingEnabled ?: false)
} else {
settings
}
}.collect {
Timber.d("Settings change: $it")
onAutoTunnelPause(it.isAutoTunnelPaused)
updateSettings(it)
manageJobsBySettings(it)
}
}
}
@@ -390,7 +374,7 @@ class AutoTunnelService : LifecycleService() {
}
private fun updateEthernet(connected: Boolean) {
autoTunnelStateFlow.update {
networkEventsFlow.update {
it.copy(
isEthernetConnected = connected,
)
@@ -428,7 +412,7 @@ class AutoTunnelService : LifecycleService() {
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Wi-Fi connection")
autoTunnelStateFlow.update {
networkEventsFlow.update {
it.copy(
isWifiConnected = true,
)
@@ -437,7 +421,7 @@ class AutoTunnelService : LifecycleService() {
is NetworkStatus.CapabilitiesChanged -> {
Timber.i("Wifi capabilities changed")
autoTunnelStateFlow.update {
networkEventsFlow.update {
it.copy(
isWifiConnected = true,
)
@@ -450,7 +434,7 @@ class AutoTunnelService : LifecycleService() {
Timber.i("Detected valid SSID")
}
appDataRepository.appState.setCurrentSsid(name)
autoTunnelStateFlow.update {
networkEventsFlow.update {
it.copy(
currentNetworkSSID = name,
)
@@ -459,7 +443,7 @@ class AutoTunnelService : LifecycleService() {
}
is NetworkStatus.Unavailable -> {
autoTunnelStateFlow.update {
networkEventsFlow.update {
it.copy(
isWifiConnected = false,
)
@@ -475,6 +459,10 @@ class AutoTunnelService : LifecycleService() {
return appDataRepository.tunnels.findByMobileDataTunnel().firstOrNull()
}
private suspend fun getSsidTunnel(ssid: String): TunnelConfig? {
return appDataRepository.tunnels.findByTunnelNetworksName(ssid).firstOrNull()
}
private fun isTunnelDown(): Boolean {
return tunnelService.get().vpnState.value.status == TunnelState.DOWN
}
@@ -482,7 +470,7 @@ class AutoTunnelService : LifecycleService() {
private suspend fun handleNetworkEventChanges() {
withContext(ioDispatcher) {
Timber.i("Starting network event watcher")
autoTunnelStateFlow.collectLatest { watcherState ->
networkEventsFlow.collectLatest { watcherState ->
val autoTunnel = "Auto-tunnel watcher"
if (!watcherState.settings.isAutoTunnelPaused) {
// delay for rapid network state changes and then collect latest
@@ -528,7 +516,7 @@ class AutoTunnelService : LifecycleService() {
Timber.i(
"$autoTunnel - tunnel on ssid not associated with current tunnel condition met",
)
watcherState.tunnels.firstOrNull { it.tunnelNetworks.isMatchingToWildcardList(watcherState.currentNetworkSSID) }?.let {
getSsidTunnel(watcherState.currentNetworkSSID)?.let {
Timber.i("Found tunnel associated with this SSID, bringing tunnel up: ${it.name}")
if (isTunnelDown() || activeTunnel?.id != it.id) {
tunnelService.get().startTunnel(it)
@@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
data class AutoTunnelState(
@@ -10,7 +9,6 @@ data class AutoTunnelState(
val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "",
val settings: Settings = Settings(),
val tunnels: TunnelConfigs = emptyList(),
) {
fun isEthernetConditionMet(): Boolean {
return (
@@ -2,24 +2,25 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.Tunnel.State
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.AmneziaStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.WireGuardStatistics
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.amnezia.awg.backend.Tunnel
@@ -31,30 +32,15 @@ class WireGuardTunnel
@Inject
constructor(
private val amneziaBackend: Provider<org.amnezia.awg.backend.Backend>,
tunnelConfigRepository: TunnelConfigRepository,
@Kernel private val kernelBackend: Provider<Backend>,
@Userspace private val userspaceBackend: Provider<Backend>,
@Kernel private val kernelBackend: Provider<org.amnezia.awg.backend.Backend>,
private val appDataRepository: AppDataRepository,
@ApplicationScope private val applicationScope: CoroutineScope,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : TunnelService {
private val _vpnState = MutableStateFlow(VpnState())
override val vpnState: StateFlow<VpnState> = _vpnState.combine(
tunnelConfigRepository.getTunnelConfigsFlow(),
) {
vpnState, tunnels ->
vpnState.copy(
tunnelConfig = tunnels.firstOrNull { it.id == vpnState.tunnelConfig?.id },
)
}.stateIn(applicationScope, SharingStarted.Lazily, VpnState())
private var statsJob: Job? = null
private suspend fun backend(): Any {
val settings = appDataRepository.settings.getSettings()
if (settings.isKernelEnabled) return kernelBackend.get()
return amneziaBackend.get()
}
override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow()
override suspend fun runningTunnelNames(): Set<String> {
return when (val backend = backend()) {
@@ -64,6 +50,8 @@ constructor(
}
}
private var statsJob: Job? = null
private suspend fun setState(tunnelConfig: TunnelConfig, tunnelState: TunnelState): Result<TunnelState> {
return runCatching {
when (val backend = backend()) {
@@ -87,26 +75,34 @@ constructor(
}
}
private suspend fun backend(): Any {
val settings = appDataRepository.settings.getSettings()
if (settings.isKernelEnabled) return kernelBackend.get()
if (settings.isAmneziaEnabled) return amneziaBackend.get()
return userspaceBackend.get()
}
override suspend fun startTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> {
return withContext(ioDispatcher) {
onBeforeStart(tunnelConfig)
if (_vpnState.value.status == TunnelState.UP) vpnState.value.tunnelConfig?.let { stopTunnel(it) }
emitTunnelConfig(tunnelConfig)
setState(tunnelConfig, TunnelState.UP).onSuccess {
emitTunnelState(it)
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true))
}.onFailure {
Timber.e(it)
onStartFailed()
}
}
}
override suspend fun stopTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> {
return withContext(ioDispatcher) {
onBeforeStop(tunnelConfig)
setState(tunnelConfig, TunnelState.DOWN).onSuccess {
emitTunnelState(it)
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false))
resetBackendStatistics()
}.onFailure {
Timber.e(it)
onStopFailed()
}
}
}
@@ -114,7 +110,7 @@ constructor(
// use this when we just want to bounce tunnel and not change tunnelConfig active state
override suspend fun bounceTunnel(tunnelConfig: TunnelConfig): Result<TunnelState> {
toggleTunnel(tunnelConfig)
delay(VPN_RESTART_DELAY)
delay(Constants.VPN_RESTART_DELAY)
return toggleTunnel(tunnelConfig)
}
@@ -129,34 +125,6 @@ constructor(
}
}
private suspend fun onStopFailed() {
_vpnState.value.tunnelConfig?.let {
appDataRepository.tunnels.save(it.copy(isActive = true))
}
}
private suspend fun onStartFailed() {
_vpnState.value.tunnelConfig?.let {
appDataRepository.tunnels.save(it.copy(isActive = false))
}
cancelStatsJob()
resetBackendStatistics()
}
private suspend fun onBeforeStart(tunnelConfig: TunnelConfig) {
if (_vpnState.value.status == TunnelState.UP) vpnState.value.tunnelConfig?.let { stopTunnel(it) }
resetBackendStatistics()
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = true))
emitVpnStateConfig(tunnelConfig)
startStatsJob()
}
private suspend fun onBeforeStop(tunnelConfig: TunnelConfig) {
cancelStatsJob()
resetBackendStatistics()
appDataRepository.tunnels.save(tunnelConfig.copy(isActive = false))
}
private fun emitTunnelState(state: TunnelState) {
_vpnState.tryEmit(
_vpnState.value.copy(
@@ -173,7 +141,7 @@ constructor(
)
}
private fun emitVpnStateConfig(tunnelConfig: TunnelConfig) {
private fun emitTunnelConfig(tunnelConfig: TunnelConfig?) {
_vpnState.tryEmit(
_vpnState.value.copy(
tunnelConfig = tunnelConfig,
@@ -209,9 +177,21 @@ constructor(
return _vpnState.value.tunnelConfig?.name ?: ""
}
override fun onStateChange(newState: Tunnel.State) {
handleStateChange(TunnelState.from(newState))
}
private fun handleStateChange(state: TunnelState) {
emitTunnelState(state)
WireGuardAutoTunnel.instance.requestTunnelTileServiceStateUpdate()
when (state) {
TunnelState.UP -> startStatsJob()
else -> cancelStatsJob()
}
}
private fun startTunnelStatisticsJob() = applicationScope.launch(ioDispatcher) {
val backend = backend()
delay(STATS_START_DELAY)
while (true) {
when (backend) {
is Backend -> emitBackendStatistics(
@@ -225,21 +205,11 @@ constructor(
)
}
}
delay(VPN_STATISTIC_CHECK_INTERVAL)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
}
}
override fun onStateChange(newState: Tunnel.State) {
emitTunnelState(TunnelState.from(newState))
}
override fun onStateChange(state: State) {
emitTunnelState(TunnelState.from(state))
}
companion object {
const val STATS_START_DELAY = 5_000L
const val VPN_STATISTIC_CHECK_INTERVAL = 1_000L
const val VPN_RESTART_DELAY = 1_000L
handleStateChange(TunnelState.from(state))
}
}
@@ -4,18 +4,15 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import xyz.teamgravity.pin_lock_compose.PinManager
import javax.inject.Inject
@@ -24,9 +21,8 @@ class AppViewModel
@Inject
constructor(
private val appDataRepository: AppDataRepository,
tunnelService: TunnelService,
private val tunnelService: TunnelService,
val navHostController: NavHostController,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val _appUiState = MutableStateFlow(AppUiState())
@@ -46,12 +42,12 @@ constructor(
)
}
.stateIn(
viewModelScope + ioDispatcher,
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
_appUiState.value,
)
fun setTunnels(tunnels: TunnelConfigs) = viewModelScope.launch(ioDispatcher) {
fun setTunnels(tunnels: TunnelConfigs) = viewModelScope.launch {
_appUiState.emit(
_appUiState.value.copy(
tunnels = tunnels,
@@ -59,7 +55,7 @@ constructor(
)
}
fun onPinLockDisabled() = viewModelScope.launch(ioDispatcher) {
fun onPinLockDisabled() = viewModelScope.launch {
PinManager.clearPin()
appDataRepository.appState.setPinLockEnabled(false)
}
@@ -11,10 +11,6 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData
@@ -29,25 +25,23 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.toRoute
import com.zaneschepke.wireguardautotunnel.R
import androidx.navigation.navArgument
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.isCurrentRoute
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarControllerProvider
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.options.OptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pinlock.PinLockScreen
@@ -116,31 +110,18 @@ class MainActivity : AppCompatActivity() {
Modifier
.focusable()
.focusProperties {
if (navBackStackEntry?.isCurrentRoute(Route.Lock) == true) {
Unit
} else {
up = focusRequester
when (navBackStackEntry?.destination?.route) {
Screen.Lock.route -> Unit
else -> up = focusRequester
}
},
bottomBar = {
BottomNavBar(
navController,
listOf(
BottomNavItem(
name = stringResource(R.string.tunnels),
route = Route.Main,
icon = Icons.Rounded.Home,
),
BottomNavItem(
name = stringResource(R.string.settings),
route = Route.Settings,
icon = Icons.Rounded.Settings,
),
BottomNavItem(
name = stringResource(R.string.support),
route = Route.Support,
icon = Icons.Rounded.QuestionMark,
),
Screen.Main.navItem,
Screen.Settings.navItem,
Screen.Support.navItem,
),
)
},
@@ -150,16 +131,20 @@ class MainActivity : AppCompatActivity() {
navController,
enterTransition = { fadeIn(tween(Constants.TRANSITION_ANIMATION_TIME)) },
exitTransition = { fadeOut(tween(Constants.TRANSITION_ANIMATION_TIME)) },
startDestination = (if (isPinLockEnabled == true) Route.Lock else Route.Main),
startDestination = (if (isPinLockEnabled == true) Screen.Lock.route else Screen.Main.route),
) {
composable<Route.Main> {
composable(
Screen.Main.route,
) {
MainScreen(
focusRequester = focusRequester,
uiState = appUiState,
navController = navController,
)
}
composable<Route.Settings> {
composable(
Screen.Settings.route,
) {
SettingsScreen(
appViewModel = appViewModel,
uiState = appUiState,
@@ -167,33 +152,58 @@ class MainActivity : AppCompatActivity() {
focusRequester = focusRequester,
)
}
composable<Route.Support> {
composable(
Screen.Support.route,
) {
SupportScreen(
focusRequester = focusRequester,
navController = navController,
appUiState = appUiState,
)
}
composable<Route.Logs> {
composable(Screen.Support.Logs.route) {
LogsScreen()
}
composable<Route.Config> {
val args = it.toRoute<Route.Config>()
ConfigScreen(
focusRequester = focusRequester,
tunnelId = args.id,
)
composable(
"${Screen.Config.route}/{id}?configType={configType}",
arguments =
listOf(
navArgument("id") {
type = NavType.StringType
defaultValue = "0"
},
navArgument("configType") {
type = NavType.StringType
defaultValue = ConfigType.WIREGUARD.name
},
),
) {
val id = it.arguments?.getString("id")
val configType =
ConfigType.valueOf(
it.arguments?.getString("configType") ?: ConfigType.WIREGUARD.name,
)
if (!id.isNullOrBlank()) {
ConfigScreen(
navController = navController,
tunnelId = id,
focusRequester = focusRequester,
configType = configType,
)
}
}
composable<Route.Option> {
val args = it.toRoute<Route.Option>()
OptionsScreen(
navController = navController,
tunnelId = args.id,
focusRequester = focusRequester,
appUiState = appUiState,
)
composable("${Screen.Option.route}/{id}") {
val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) {
OptionsScreen(
navController = navController,
tunnelId = id.toInt(),
focusRequester = focusRequester,
appUiState = appUiState,
)
}
}
composable<Route.Lock> {
composable(Screen.Lock.route) {
PinLockScreen(
navController = navController,
appViewModel = appViewModel,
@@ -1,30 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui
import kotlinx.serialization.Serializable
sealed class Route {
@Serializable
data object Support : Route()
@Serializable
data object Settings : Route()
@Serializable
data object Main : Route()
@Serializable
data class Option(
val id: Int,
) : Route()
@Serializable
data object Lock : Route()
@Serializable
data class Config(
val id: Int,
) : Route()
@Serializable
data object Logs : Route()
}
@@ -0,0 +1,46 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
sealed class Screen(val route: String) {
data object Main : Screen("main") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.tunnels),
route = route,
icon = Icons.Rounded.Home,
)
}
data object Settings : Screen("settings") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.settings),
route = route,
icon = Icons.Rounded.Settings,
)
}
data object Support : Screen("support") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.support),
route = route,
icon = Icons.Rounded.QuestionMark,
)
data object Logs : Screen("support/logs")
}
data object Config : Screen("config")
data object Lock : Screen("lock")
data object Option : Screen("option")
}
@@ -18,7 +18,7 @@ fun ConfigurationToggle(
enabled: Boolean = true,
checked: Boolean,
padding: Dp,
onCheckChanged: (checked: Boolean) -> Unit,
onCheckChanged: () -> Unit,
modifier: Modifier = Modifier,
) {
Row(
@@ -44,7 +44,7 @@ fun ConfigurationToggle(
modifier = modifier,
enabled = enabled,
checked = checked,
onCheckedChange = { onCheckChanged(it) },
onCheckedChange = { onCheckChanged() },
)
}
}
@@ -3,15 +3,12 @@ package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -45,31 +42,25 @@ fun SubmitConfigurationTextBox(
val isFocused by interactionSource.collectIsFocusedAsState()
val keyboardController = LocalSoftwareKeyboardController.current
var stateValue by remember { mutableStateOf(value ?: "") }
var stateValue by remember { mutableStateOf(value) }
OutlinedTextField(
ConfigurationTextBox(
isError = isErrorValue(stateValue),
interactionSource = interactionSource,
value = stateValue ?: "",
onValueChange = {
stateValue = it
},
keyboardOptions = keyboardOptions,
label = label,
hint = hint,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
value = stateValue,
singleLine = true,
interactionSource = interactionSource,
onValueChange = { stateValue = it },
label = { Text(label) },
maxLines = 1,
placeholder = { Text(hint) },
keyboardOptions = keyboardOptions,
keyboardActions = KeyboardActions(
onDone = {
onSubmit(stateValue)
keyboardController?.hide()
},
),
trailingIcon = {
if (!isErrorValue(stateValue) && isFocused) {
trailing = {
if (!stateValue.isNullOrBlank() && !isErrorValue(stateValue) && isFocused) {
IconButton(onClick = {
onSubmit(stateValue)
onSubmit(stateValue!!)
keyboardController?.hide()
focusManager.clearFocus()
}) {
@@ -12,8 +12,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.font.FontWeight
import androidx.navigation.NavController
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -22,22 +20,19 @@ fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavIte
var showBottomBar by rememberSaveable { mutableStateOf(true) }
val navBackStackEntry by navController.currentBackStackEntryAsState()
showBottomBar = bottomNavItems.firstOrNull {
navBackStackEntry?.destination?.hierarchy?.any { dest ->
bottomNavItems.map { dest.hasRoute(route = it.route::class) }.contains(true)
} == true
} != null
showBottomBar = bottomNavItems.firstOrNull { navBackStackEntry?.destination?.route?.contains(it.route) == true } != null
if (showBottomBar) {
NavigationBar(
containerColor = MaterialTheme.colorScheme.surface,
) {
bottomNavItems.forEach { item ->
val selected = navBackStackEntry.isCurrentRoute(item.route)
val selected = navBackStackEntry?.destination?.route?.contains(item.route) == true
NavigationBarItem(
selected = selected,
onClick = {
if (selected) return@NavigationBarItem
if (navBackStackEntry?.destination?.route == item.route) return@NavigationBarItem
navController.navigate(item.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
@@ -1,10 +1,9 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.ui.graphics.vector.ImageVector
import com.zaneschepke.wireguardautotunnel.ui.Route
data class BottomNavItem(
val name: String,
val route: Route,
val route: String,
val icon: ImageVector,
)
@@ -1,12 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavDestination.Companion.hierarchy
import com.zaneschepke.wireguardautotunnel.ui.Route
fun NavBackStackEntry?.isCurrentRoute(route: Route): Boolean {
return this?.destination?.hierarchy?.any {
it.hasRoute(route = route::class)
} == true
}
@@ -1,27 +1,36 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.annotation.SuppressLint
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Save
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
@@ -30,8 +39,10 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -42,6 +53,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@@ -55,25 +67,34 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.drawablepainter.DrawablePainter
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
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.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.ui.screens.config.components.ApplicationSelectionDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.getMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import kotlinx.coroutines.delay
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(
ExperimentalMaterial3Api::class,
)
@Composable
fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
val viewModel = hiltViewModel<ConfigViewModel, ConfigViewModel.ConfigViewModelFactory> { factory ->
factory.create(tunnelId)
}
fun ConfigScreen(
viewModel: ConfigViewModel = hiltViewModel(),
focusRequester: FocusRequester,
navController: NavController,
tunnelId: String,
configType: ConfigType,
) {
val context = LocalContext.current
val snackbar = SnackbarController.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current
@@ -81,11 +102,12 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
var showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) }
var configType by remember { mutableStateOf(ConfigType.WIREGUARD) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
LaunchedEffect(Unit) { viewModel.init(tunnelId) }
LaunchedEffect(uiState.loading) {
if (!uiState.loading && context.isRunningOnTv()) {
delay(Constants.FOCUS_REQUEST_DELAY)
kotlin.runCatching {
@@ -97,12 +119,13 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
}
}
LaunchedEffect(Unit) {
delay(2_000L)
viewModel.cleanUpUninstalledApps()
if (uiState.loading) {
LoadingScreen()
return
}
val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
val fillMaxHeight = .85f
@@ -151,19 +174,182 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
}
if (showApplicationsDialog) {
ApplicationSelectionDialog(viewModel, uiState) {
showApplicationsDialog = false
val sortedPackages =
remember(uiState.packages) {
uiState.packages.sortedBy { viewModel.getPackageLabel(it) }
}
BasicAlertDialog(onDismissRequest = { showApplicationsDialog = false }) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f),
) {
Column(
modifier =
Modifier
.fillMaxWidth(),
) {
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 = uiState.isAllApplicationsEnabled,
onCheckedChange = { viewModel.onAllApplicationsChange(it) },
)
}
if (!uiState.isAllApplicationsEnabled) {
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 = uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!uiState.include)
},
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!uiState.include)
},
)
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
SearchBar(viewModel::emitQueriedPackages)
}
Spacer(Modifier.padding(5.dp))
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxHeight(4 / 5f),
) {
items(sortedPackages, key = { it.packageName }) { pack ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxSize()
.padding(5.dp),
) {
Row(modifier = Modifier.fillMaxWidth(fillMaxWidth)) {
val drawable =
pack.applicationInfo?.loadIcon(context.packageManager)
if (drawable != null) {
Image(
painter = DrawablePainter(drawable),
stringResource(id = R.string.icon),
modifier = Modifier.size(50.dp, 50.dp),
)
} else {
val icon = Icons.Rounded.Android
Icon(
icon,
icon.name,
modifier = Modifier.size(50.dp, 50.dp),
)
}
Text(
viewModel.getPackageLabel(pack),
modifier = Modifier.padding(5.dp),
)
}
Checkbox(
modifier = Modifier.fillMaxSize(),
checked =
(
uiState.checkedPackageNames.contains(
pack.packageName,
)
),
onCheckedChange = {
if (it) {
viewModel.onAddCheckedPackage(pack.packageName)
} else {
viewModel.onRemoveCheckedPackage(pack.packageName)
}
},
)
}
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = { showApplicationsDialog = false }) {
Text(stringResource(R.string.done))
}
}
}
}
}
}
Scaffold(
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton(
onClick = {
viewModel.onSaveAllChanges()
modifier =
Modifier.onFocusChanged {
if (context.isRunningOnTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
containerColor = MaterialTheme.colorScheme.primary,
onClick = {
viewModel.onSaveAllChanges(configType).onSuccess {
snackbar.showMessage(
context.getString(R.string.config_changes_saved),
)
navController.navigate(Screen.Main.route)
}.onFailure {
snackbar.showMessage(it.getMessage(context))
}
},
containerColor = fobColor,
shape = RoundedCornerShape(16.dp),
) {
Icon(
@@ -213,16 +399,9 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
stringResource(R.string.interface_),
padding = screenPadding,
)
ConfigurationToggle(
stringResource(id = R.string.show_amnezia_properties),
checked = configType == ConfigType.AMNEZIA,
padding = screenPadding,
onCheckChanged = { configType = if (it) ConfigType.AMNEZIA else ConfigType.WIREGUARD },
modifier = Modifier.focusRequester(focusRequester),
)
ConfigurationTextBox(
value = uiState.tunnelName,
onValueChange = viewModel::onTunnelNameChange,
onValueChange = { value -> viewModel.onTunnelNameChange(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.name),
hint = stringResource(R.string.tunnel_name).lowercase(),
@@ -238,12 +417,12 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
.clickable { showAuthPrompt = true },
value = uiState.interfaceProxy.privateKey,
visualTransformation =
if ((tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID.toInt()) || isAuthenticated) {
if ((tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
enabled = (tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID.toInt()) || isAuthenticated,
enabled = (tunnelId == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
onValueChange = { value -> viewModel.onPrivateKeyChange(value) },
trailingIcon = {
IconButton(
@@ -293,29 +472,31 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
ConfigurationTextBox(
value = uiState.interfaceProxy.addresses,
onValueChange = viewModel::onAddressesChanged,
keyboardActions = keyboardActions,
label = stringResource(R.string.addresses),
hint = stringResource(R.string.comma_separated_list),
modifier =
Modifier
.fillMaxWidth()
.padding(end = 5.dp),
)
ConfigurationTextBox(
value = uiState.interfaceProxy.listenPort,
onValueChange = viewModel::onListenPortChanged,
keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port),
hint = stringResource(R.string.random),
modifier = Modifier.fillMaxWidth(),
)
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = uiState.interfaceProxy.addresses,
onValueChange = { value -> viewModel.onAddressesChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.addresses),
hint = stringResource(R.string.comma_separated_list),
modifier =
Modifier
.fillMaxWidth(3 / 5f)
.padding(end = 5.dp),
)
ConfigurationTextBox(
value = uiState.interfaceProxy.listenPort,
onValueChange = { value -> viewModel.onListenPortChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port),
hint = stringResource(R.string.random),
modifier = Modifier.width(IntrinsicSize.Min),
)
}
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = uiState.interfaceProxy.dnsServers,
onValueChange = viewModel::onDnsServersChanged,
onValueChange = { value -> viewModel.onDnsServersChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.dns_servers),
hint = stringResource(R.string.comma_separated_list),
@@ -326,7 +507,7 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
)
ConfigurationTextBox(
value = uiState.interfaceProxy.mtu,
onValueChange = viewModel::onMtuChanged,
onValueChange = { value -> viewModel.onMtuChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto),
@@ -336,7 +517,10 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
if (configType == ConfigType.AMNEZIA) {
ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketCount,
onValueChange = viewModel::onJunkPacketCountChanged,
onValueChange = {
value ->
viewModel.onJunkPacketCountChanged(value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_count),
hint = stringResource(R.string.junk_packet_count).lowercase(),
@@ -347,7 +531,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
)
ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketMinSize,
onValueChange = viewModel::onJunkPacketMinSizeChanged,
onValueChange = { value ->
viewModel.onJunkPacketMinSizeChanged(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_minimum_size),
hint =
@@ -361,7 +549,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
)
ConfigurationTextBox(
value = uiState.interfaceProxy.junkPacketMaxSize,
onValueChange = viewModel::onJunkPacketMaxSizeChanged,
onValueChange = { value ->
viewModel.onJunkPacketMaxSizeChanged(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.junk_packet_maximum_size),
hint =
@@ -375,7 +567,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
)
ConfigurationTextBox(
value = uiState.interfaceProxy.initPacketJunkSize,
onValueChange = viewModel::onInitPacketJunkSizeChanged,
onValueChange = { value ->
viewModel.onInitPacketJunkSizeChanged(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.init_packet_junk_size),
hint = stringResource(R.string.init_packet_junk_size).lowercase(),
@@ -386,7 +582,10 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
)
ConfigurationTextBox(
value = uiState.interfaceProxy.responsePacketJunkSize,
onValueChange = viewModel::onResponsePacketJunkSize,
onValueChange = {
value ->
viewModel.onResponsePacketJunkSize(value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.response_packet_junk_size),
hint =
@@ -400,7 +599,10 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
)
ConfigurationTextBox(
value = uiState.interfaceProxy.initPacketMagicHeader,
onValueChange = viewModel::onInitPacketMagicHeader,
onValueChange = {
value ->
viewModel.onInitPacketMagicHeader(value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.init_packet_magic_header),
hint =
@@ -414,7 +616,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
)
ConfigurationTextBox(
value = uiState.interfaceProxy.responsePacketMagicHeader,
onValueChange = viewModel::onResponsePacketMagicHeader,
onValueChange = { value ->
viewModel.onResponsePacketMagicHeader(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.response_packet_magic_header),
hint =
@@ -428,7 +634,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
)
ConfigurationTextBox(
value = uiState.interfaceProxy.underloadPacketMagicHeader,
onValueChange = viewModel::onUnderloadPacketMagicHeader,
onValueChange = { value ->
viewModel.onUnderloadPacketMagicHeader(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.underload_packet_magic_header),
hint =
@@ -442,7 +652,11 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
)
ConfigurationTextBox(
value = uiState.interfaceProxy.transportPacketMagicHeader,
onValueChange = viewModel::onTransportPacketMagicHeader,
onValueChange = { value ->
viewModel.onTransportPacketMagicHeader(
value,
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.transport_packet_magic_header),
hint =
@@ -600,6 +814,9 @@ fun ConfigScreen(tunnelId: Int, focusRequester: FocusRequester) {
}
}
}
if (context.isRunningOnTv()) {
Spacer(modifier = Modifier.weight(.17f))
}
}
}
}
@@ -15,7 +15,7 @@ data class ConfigUiState(
val isAllApplicationsEnabled: Boolean = false,
val loading: Boolean = true,
val tunnel: TunnelConfig? = null,
var tunnelName: String = "",
val tunnelName: String = "",
val isAmneziaEnabled: Boolean = false,
) {
companion object {
@@ -45,6 +45,7 @@ data class ConfigUiState(
}
fun from(config: org.amnezia.awg.config.Config): ConfigUiState {
// TODO update with new values
val proxyPeers = config.peers.map { PeerProxy.from(it) }
val proxyInterface = InterfaceProxy.from(config.`interface`)
var include = true
@@ -68,13 +69,5 @@ data class ConfigUiState(
isAllApplicationsEnabled,
)
}
fun from(tunnel: TunnelConfig): ConfigUiState {
val config = tunnel.toAmConfig()
return from(config).copy(
tunnelName = tunnel.name,
tunnel = tunnel,
)
}
}
}
@@ -6,7 +6,6 @@ import android.content.pm.PackageManager
import android.os.Build
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import com.wireguard.config.Config
import com.wireguard.config.Interface
import com.wireguard.config.Peer
@@ -16,116 +15,100 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.config.model.PeerProxy
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import com.zaneschepke.wireguardautotunnel.util.extensions.removeAt
import com.zaneschepke.wireguardautotunnel.util.extensions.update
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel(assistedFactory = ConfigViewModel.ConfigViewModelFactory::class)
@HiltViewModel
class ConfigViewModel
@AssistedInject
@Inject
constructor(
private val settingsRepository: SettingsRepository,
private val appDataRepository: AppDataRepository,
private val navController: NavHostController,
@Assisted val id: Int,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {
private val packageManager = WireGuardAutoTunnel.instance.packageManager
private val _uiState = MutableStateFlow(ConfigUiState())
val uiState = _uiState.onStart {
appDataRepository.tunnels.getById(id)?.let {
val packages = getQueriedPackages()
_uiState.value = ConfigUiState.from(it).copy(
packages = packages,
)
}
}.stateIn(
viewModelScope + ioDispatcher,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
ConfigUiState(),
)
val uiState = _uiState.asStateFlow()
fun init(tunnelId: String) = viewModelScope.launch(ioDispatcher) {
val packages = getQueriedPackages("")
val state =
if (tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
val tunnelConfig =
appDataRepository.tunnels.getAll()
.firstOrNull { it.id.toString() == tunnelId }
val isAmneziaEnabled = settingsRepository.getSettings().isAmneziaEnabled
if (tunnelConfig != null) {
(
if (isAmneziaEnabled) {
val amConfig =
if (tunnelConfig.amQuick == "") tunnelConfig.wgQuick else tunnelConfig.amQuick
ConfigUiState.from(TunnelConfig.configFromAmQuick(amConfig))
} else {
ConfigUiState.from(
TunnelConfig.configFromWgQuick(tunnelConfig.wgQuick),
)
}
).copy(
packages = packages,
loading = false,
tunnel = tunnelConfig,
tunnelName = tunnelConfig.name,
isAmneziaEnabled = isAmneziaEnabled,
)
} else {
ConfigUiState(loading = false, packages = packages)
}
} else {
ConfigUiState(loading = false, packages = packages)
}
_uiState.value = state
}
fun onTunnelNameChange(name: String) {
_uiState.update {
it.copy(tunnelName = name)
}
_uiState.value = _uiState.value.copy(tunnelName = name)
}
fun onIncludeChange(include: Boolean) {
_uiState.update {
it.copy(include = include)
}
}
fun cleanUpUninstalledApps() = viewModelScope.launch(ioDispatcher) {
uiState.value.tunnel?.let {
val config = it.toAmConfig()
val packages = getQueriedPackages()
val packageSet = packages.map { pack -> pack.packageName }.toSet()
val includedApps = config.`interface`.includedApplications.toMutableList()
val excludedApps = config.`interface`.excludedApplications.toMutableList()
if (includedApps.isEmpty() && excludedApps.isEmpty()) return@launch
if (includedApps.retainAll(packageSet) || excludedApps.retainAll(packageSet)) {
Timber.i("Removing split tunnel package name that no longer exists on the device")
_uiState.update { state ->
state.copy(
checkedPackageNames = if (_uiState.value.include) includedApps else excludedApps,
)
}
val wgQuick = buildConfig().toWgQuickString(true)
val amQuick = buildAmConfig().toAwgQuickString(true)
saveConfig(
it.copy(
amQuick = amQuick,
wgQuick = wgQuick,
),
)
}
}
_uiState.value = _uiState.value.copy(include = include)
}
fun onAddCheckedPackage(packageName: String) {
_uiState.update {
it.copy(
checkedPackageNames = it.checkedPackageNames + packageName,
_uiState.value =
_uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames + packageName,
)
}
}
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_uiState.update {
it.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
}
_uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
}
fun onRemoveCheckedPackage(packageName: String) {
_uiState.update {
it.copy(
checkedPackageNames = it.checkedPackageNames - packageName,
_uiState.value =
_uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames - packageName,
)
}
}
private fun getQueriedPackages(query: String = ""): List<PackageInfo> {
private fun getQueriedPackages(query: String): List<PackageInfo> {
return getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
@@ -154,9 +137,7 @@ constructor(
return _uiState.value.isAllApplicationsEnabled
}
private fun saveConfig(tunnelConfig: TunnelConfig) = viewModelScope.launch {
appDataRepository.tunnels.save(tunnelConfig)
}
private fun saveConfig(tunnelConfig: TunnelConfig) = viewModelScope.launch { appDataRepository.tunnels.save(tunnelConfig) }
private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) = viewModelScope.launch {
if (tunnelConfig != null) {
@@ -193,113 +174,105 @@ constructor(
}
private fun emptyCheckedPackagesList() {
_uiState.update {
it.copy(checkedPackageNames = emptyList())
}
_uiState.value = _uiState.value.copy(checkedPackageNames = emptyList())
}
private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder()
with(_uiState.value.interfaceProxy) {
builder.parsePrivateKey(this.privateKey.trim())
builder.parseAddresses(this.addresses.trim())
if (this.dnsServers.isNotEmpty()) {
builder.parseDnsServers(this.dnsServers.trim())
}
if (this.mtu.isNotEmpty()) {
builder.parseMtu(this.mtu.trim())
}
if (this.listenPort.isNotEmpty()) {
builder.parseListenPort(this.listenPort.trim())
}
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) {
builder.includeApplications(
_uiState.value.checkedPackageNames,
)
}
if (!_uiState.value.include) {
builder.excludeApplications(
_uiState.value.checkedPackageNames,
)
}
builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
if (_uiState.value.interfaceProxy.dnsServers.isNotEmpty()) {
builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim())
}
if (_uiState.value.interfaceProxy.mtu.isNotEmpty()) {
builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim())
}
if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim())
}
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) {
builder.includeApplications(
_uiState.value.checkedPackageNames,
)
}
if (!_uiState.value.include) {
builder.excludeApplications(
_uiState.value.checkedPackageNames,
)
}
return builder.build()
}
private fun buildAmInterfaceListFromProxyInterface(): org.amnezia.awg.config.Interface {
val builder = org.amnezia.awg.config.Interface.Builder()
with(_uiState.value.interfaceProxy) {
builder.parsePrivateKey(this.privateKey.trim())
builder.parseAddresses(this.addresses.trim())
if (this.dnsServers.isNotEmpty()) {
builder.parseDnsServers(this.dnsServers.trim())
}
if (this.mtu.isNotEmpty()) {
builder.parseMtu(this.mtu.trim())
}
if (this.listenPort.isNotEmpty()) {
builder.parseListenPort(this.listenPort.trim())
}
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) {
builder.includeApplications(
_uiState.value.checkedPackageNames,
)
}
if (!_uiState.value.include) {
builder.excludeApplications(
_uiState.value.checkedPackageNames,
)
}
if (this.junkPacketCount.isNotEmpty()) {
builder.setJunkPacketCount(
this.junkPacketCount.trim().toInt(),
)
}
if (this.junkPacketMinSize.isNotEmpty()) {
builder.setJunkPacketMinSize(
this.junkPacketMinSize.trim().toInt(),
)
}
if (this.junkPacketMaxSize.isNotEmpty()) {
builder.setJunkPacketMaxSize(
this.junkPacketMaxSize.trim().toInt(),
)
}
if (this.initPacketJunkSize.isNotEmpty()) {
builder.setInitPacketJunkSize(
this.initPacketJunkSize.trim().toInt(),
)
}
if (this.responsePacketJunkSize.isNotEmpty()) {
builder.setResponsePacketJunkSize(
this.responsePacketJunkSize.trim().toInt(),
)
}
if (this.initPacketMagicHeader.isNotEmpty()) {
builder.setInitPacketMagicHeader(
this.initPacketMagicHeader.trim().toLong(),
)
}
if (this.responsePacketMagicHeader.isNotEmpty()) {
builder.setResponsePacketMagicHeader(
this.responsePacketMagicHeader.trim().toLong(),
)
}
if (this.transportPacketMagicHeader.isNotEmpty()) {
builder.setTransportPacketMagicHeader(
this.transportPacketMagicHeader.trim().toLong(),
)
}
if (this.underloadPacketMagicHeader.isNotEmpty()) {
builder.setUnderloadPacketMagicHeader(
this.underloadPacketMagicHeader.trim().toLong(),
)
}
builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
if (_uiState.value.interfaceProxy.dnsServers.isNotEmpty()) {
builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim())
}
if (_uiState.value.interfaceProxy.mtu.isNotEmpty()) {
builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim())
}
if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim())
}
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) {
builder.includeApplications(
_uiState.value.checkedPackageNames,
)
}
if (!_uiState.value.include) {
builder.excludeApplications(
_uiState.value.checkedPackageNames,
)
}
if (_uiState.value.interfaceProxy.junkPacketCount.isNotEmpty()) {
builder.setJunkPacketCount(
_uiState.value.interfaceProxy.junkPacketCount.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.junkPacketMinSize.isNotEmpty()) {
builder.setJunkPacketMinSize(
_uiState.value.interfaceProxy.junkPacketMinSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.junkPacketMaxSize.isNotEmpty()) {
builder.setJunkPacketMaxSize(
_uiState.value.interfaceProxy.junkPacketMaxSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.initPacketJunkSize.isNotEmpty()) {
builder.setInitPacketJunkSize(
_uiState.value.interfaceProxy.initPacketJunkSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.responsePacketJunkSize.isNotEmpty()) {
builder.setResponsePacketJunkSize(
_uiState.value.interfaceProxy.responsePacketJunkSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.initPacketMagicHeader.isNotEmpty()) {
builder.setInitPacketMagicHeader(
_uiState.value.interfaceProxy.initPacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.responsePacketMagicHeader.isNotEmpty()) {
builder.setResponsePacketMagicHeader(
_uiState.value.interfaceProxy.responsePacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.transportPacketMagicHeader.isNotEmpty()) {
builder.setTransportPacketMagicHeader(
_uiState.value.interfaceProxy.transportPacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.underloadPacketMagicHeader.isNotEmpty()) {
builder.setUnderloadPacketMagicHeader(
_uiState.value.interfaceProxy.underloadPacketMagicHeader.trim().toLong(),
)
}
return builder.build()
}
@@ -318,33 +291,41 @@ constructor(
.build()
}
fun onSaveAllChanges() = viewModelScope.launch {
kotlin.runCatching {
fun onSaveAllChanges(configType: ConfigType): Result<Unit> {
return try {
val wgQuick = buildConfig().toWgQuickString(true)
val amQuick = buildAmConfig().toAwgQuickString(true)
val tunnelConfig = uiState.value.tunnel?.copy(
name = _uiState.value.tunnelName,
amQuick = amQuick,
wgQuick = wgQuick,
) ?: TunnelConfig(
name = _uiState.value.tunnelName,
wgQuick = wgQuick,
amQuick = amQuick,
)
val amQuick =
if (configType == ConfigType.AMNEZIA) {
buildAmConfig().toAwgQuickString(true)
} else {
TunnelConfig.AM_QUICK_DEFAULT
}
val tunnelConfig =
when (uiState.value.tunnel) {
null ->
TunnelConfig(
name = _uiState.value.tunnelName,
wgQuick = wgQuick,
amQuick = amQuick,
)
else ->
uiState.value.tunnel!!.copy(
name = _uiState.value.tunnelName,
wgQuick = wgQuick,
amQuick = amQuick,
)
}
updateTunnelConfig(tunnelConfig)
SnackbarController.showMessage(
StringValue.StringResource(R.string.config_changes_saved),
)
navController.navigate(Route.Main)
}.onFailure {
Timber.e(it)
val message = it.message?.substringAfter(":", missingDelimiterValue = "")
val stringValue = if (message.isNullOrBlank()) {
StringValue.StringResource(R.string.unknown_error)
} else {
StringValue.DynamicString(message)
}
SnackbarController.showMessage(stringValue)
Result.success(Unit)
} catch (e: Exception) {
Timber.e(e)
val message = e.message?.substringAfter(":", missingDelimiterValue = "")
val stringValue =
message?.let {
StringValue.DynamicString(message)
} ?: StringValue.StringResource(R.string.unknown_error)
Result.failure(WgTunnelExceptions.ConfigParseError(stringValue))
}
}
@@ -427,7 +408,7 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
it.interfaceProxy.copy(
_uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64(),
),
@@ -438,7 +419,7 @@ constructor(
fun onAddressesChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = it.interfaceProxy.copy(addresses = value),
interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value),
)
}
}
@@ -446,7 +427,7 @@ constructor(
fun onListenPortChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = it.interfaceProxy.copy(listenPort = value),
interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value),
)
}
}
@@ -454,21 +435,21 @@ constructor(
fun onDnsServersChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = it.interfaceProxy.copy(dnsServers = value),
interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value),
)
}
}
fun onMtuChanged(value: String) {
_uiState.update {
it.copy(interfaceProxy = it.interfaceProxy.copy(mtu = value))
it.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
}
}
private fun onInterfacePublicKeyChange(value: String) {
_uiState.update {
it.copy(
interfaceProxy = it.interfaceProxy.copy(publicKey = value),
interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value),
)
}
}
@@ -476,7 +457,7 @@ constructor(
fun onPrivateKeyChange(value: String) {
_uiState.update {
it.copy(
interfaceProxy = it.interfaceProxy.copy(privateKey = value),
interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value),
)
}
if (NumberUtils.isValidKey(value)) {
@@ -498,7 +479,7 @@ constructor(
fun onJunkPacketCountChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = it.interfaceProxy.copy(junkPacketCount = value),
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketCount = value),
)
}
}
@@ -506,7 +487,7 @@ constructor(
fun onJunkPacketMinSizeChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = it.interfaceProxy.copy(junkPacketMinSize = value),
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMinSize = value),
)
}
}
@@ -514,7 +495,7 @@ constructor(
fun onJunkPacketMaxSizeChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = it.interfaceProxy.copy(junkPacketMaxSize = value),
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMaxSize = value),
)
}
}
@@ -522,7 +503,7 @@ constructor(
fun onInitPacketJunkSizeChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = it.interfaceProxy.copy(initPacketJunkSize = value),
interfaceProxy = _uiState.value.interfaceProxy.copy(initPacketJunkSize = value),
)
}
}
@@ -531,7 +512,7 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
it.interfaceProxy.copy(
_uiState.value.interfaceProxy.copy(
responsePacketJunkSize = value,
),
)
@@ -542,7 +523,7 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
it.interfaceProxy.copy(
_uiState.value.interfaceProxy.copy(
initPacketMagicHeader = value,
),
)
@@ -553,7 +534,7 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
it.interfaceProxy.copy(
_uiState.value.interfaceProxy.copy(
responsePacketMagicHeader = value,
),
)
@@ -564,7 +545,7 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
it.interfaceProxy.copy(
_uiState.value.interfaceProxy.copy(
transportPacketMagicHeader = value,
),
)
@@ -575,15 +556,10 @@ constructor(
_uiState.update {
it.copy(
interfaceProxy =
it.interfaceProxy.copy(
_uiState.value.interfaceProxy.copy(
underloadPacketMagicHeader = value,
),
)
}
}
@AssistedFactory
interface ConfigViewModelFactory {
fun create(id: Int): ConfigViewModel
}
}
@@ -1,199 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config.components
import android.content.pm.PackageInfo
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.accompanist.drawablepainter.DrawablePainter
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigUiState
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ApplicationSelectionDialog(viewModel: ConfigViewModel, uiState: ConfigUiState, onDismiss: () -> Unit) {
val context = LocalContext.current
val licenseComparator = compareBy<PackageInfo> { viewModel.getPackageLabel(it) }
val sortedPackages = remember(uiState.packages, licenseComparator) {
uiState.packages.sortedWith(licenseComparator)
}
BasicAlertDialog(
onDismissRequest = { onDismiss() },
) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f),
) {
Column(
modifier =
Modifier
.fillMaxWidth(),
) {
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 = uiState.isAllApplicationsEnabled,
onCheckedChange = viewModel::onAllApplicationsChange,
)
}
if (!uiState.isAllApplicationsEnabled) {
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 = uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!uiState.include)
},
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!uiState.include)
},
)
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
SearchBar(viewModel::emitQueriedPackages)
}
Spacer(Modifier.padding(5.dp))
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxHeight(19 / 22f),
) {
items(sortedPackages, key = { it.packageName }) { pack ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxSize()
.padding(5.dp).padding(end = 25.dp),
) {
Row(modifier = Modifier.fillMaxWidth().padding(start = 5.dp)) {
val drawable =
pack.applicationInfo?.loadIcon(context.packageManager)
val iconSize = 35.dp
if (drawable != null) {
Image(
painter = DrawablePainter(drawable),
stringResource(id = R.string.icon),
modifier = Modifier.size(iconSize),
)
} else {
val icon = Icons.Rounded.Android
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
)
}
Text(
viewModel.getPackageLabel(pack),
modifier = Modifier.padding(5.dp),
)
}
Checkbox(
modifier = Modifier.fillMaxSize(),
checked =
(
uiState.checkedPackageNames.contains(
pack.packageName,
)
),
onCheckedChange = {
if (it) {
viewModel.onAddCheckedPackage(pack.packageName)
} else {
viewModel.onRemoveCheckedPackage(pack.packageName)
}
},
)
}
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = { onDismiss() }) {
Text(stringResource(R.string.done))
}
}
}
}
}
}
@@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.CopyAll
@@ -30,7 +29,6 @@ import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.FabPosition
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -40,6 +38,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -68,24 +67,26 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.functions.rememberFileImportLauncherForResult
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.GettingStartedLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissMultiFab
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.TunnelImportSheet
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.VpnDeniedDialog
import com.zaneschepke.wireguardautotunnel.ui.theme.corn
import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.getMessage
import com.zaneschepke.wireguardautotunnel.util.extensions.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.mapPeerStats
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.util.extensions.startTunnelBackground
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@@ -95,8 +96,10 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
val haptic = LocalHapticFeedback.current
val context = LocalContext.current
val snackbar = SnackbarController.current
val scope = rememberCoroutineScope()
var showBottomSheet by remember { mutableStateOf(false) }
var configType by remember { mutableStateOf(ConfigType.WIREGUARD) }
var showVpnPermissionDialog by remember { mutableStateOf(false) }
val isVisible = rememberSaveable { mutableStateOf(true) }
var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
@@ -149,7 +152,11 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
context.getString(R.string.error_no_file_explorer),
)
}, onData = { data ->
viewModel.onTunnelFileSelected(data, context)
scope.launch {
viewModel.onTunnelFileSelected(data, configType, context).onFailure {
snackbar.showMessage(it.getMessage(context))
}
}
})
val scanLauncher =
@@ -157,7 +164,11 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
contract = ScanContract(),
onResult = {
if (it.contents != null) {
viewModel.onTunnelQrResult(it.contents)
scope.launch {
viewModel.onTunnelQrResult(it.contents, configType).onFailure { error ->
snackbar.showMessage(error.getMessage(context))
}
}
}
},
)
@@ -216,15 +227,9 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
},
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
ScrollDismissFab(icon = {
val icon = Icons.Filled.Add
Icon(
imageVector = icon,
contentDescription = icon.name,
tint = MaterialTheme.colorScheme.onPrimary,
)
}, focusRequester, isVisible = isVisible.value, onClick = {
ScrollDismissMultiFab(R.drawable.add, focusRequester, isVisible = isVisible.value, onFabItemClicked = {
showBottomSheet = true
configType = ConfigType.valueOf(it.value)
})
},
) {
@@ -235,7 +240,7 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
onQrClick = { launchQrScanner() },
onManualImportClick = {
navController.navigate(
Route.Config(Constants.MANUAL_TUNNEL_CONFIG_ID),
"${Screen.Config.route}/${Constants.MANUAL_TUNNEL_CONFIG_ID}?configType=$configType",
)
},
)
@@ -326,22 +331,20 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
uiState.tunnels,
key = { tunnel -> tunnel.id },
) { tunnel ->
val isActive = uiState.tunnels.any {
it.id == tunnel.id &&
it.isActive
}
val isActive = uiState.tunnels.any { it.id == tunnel.id && it.isActive }
val leadingIconColor =
(
if (
isActive && uiState.vpnState.statistics != null
isActive
) {
uiState.vpnState.statistics.mapPeerStats()
.map { it.value?.handshakeStatus() }
uiState.vpnState.statistics
?.mapPeerStats()
?.map { it.value?.handshakeStatus() }
.let { statuses ->
when {
statuses.all { it == HandshakeStatus.HEALTHY } -> mint
statuses.any { it == HandshakeStatus.STALE } -> corn
statuses.all { it == HandshakeStatus.NOT_STARTED } ->
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> mint
statuses?.any { it == HandshakeStatus.STALE } == true -> corn
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true ->
Color.Gray
else -> {
@@ -381,6 +384,15 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
},
text = tunnel.name,
onHold = {
if (
(uiState.vpnState.status == TunnelState.UP) &&
(tunnel.name == uiState.vpnState.tunnelConfig?.name)
) {
snackbar.showMessage(
context.getString(R.string.turn_off_tunnel),
)
return@RowListItem
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel
},
@@ -407,9 +419,16 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
Row {
IconButton(
onClick = {
selectedTunnel?.let {
if (
uiState.settings.isAutoTunnelEnabled &&
!uiState.settings.isAutoTunnelPaused
) {
snackbar.showMessage(
context.getString(R.string.turn_off_tunnel),
)
} else {
navController.navigate(
Route.Option(it.id),
"${Screen.Option.route}/${selectedTunnel?.id}",
)
}
},
@@ -428,7 +447,6 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
Icon(icon, icon.name)
}
IconButton(
enabled = !isActive,
modifier = Modifier.focusable(),
onClick = { showDeleteTunnelAlertDialog = true },
) {
@@ -453,10 +471,14 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), uiState: AppUiState,
Row {
IconButton(
onClick = {
selectedTunnel = tunnel
selectedTunnel?.let {
if (uiState.settings.isAutoTunnelEnabled && !uiState.settings.isAutoTunnelPaused) {
snackbar.showMessage(
context.getString(R.string.turn_off_auto),
)
} else {
selectedTunnel = tunnel
navController.navigate(
Route.Option(it.id),
"${Screen.Option.route}/${selectedTunnel?.id}",
)
}
},
@@ -6,19 +6,16 @@ import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.IoDispatcher
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelService
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileReadException
import com.zaneschepke.wireguardautotunnel.util.InvalidFileExtensionException
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import com.zaneschepke.wireguardautotunnel.util.extensions.toWgQuickString
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
@@ -73,17 +70,32 @@ constructor(
tunnelService.stopTunnel(tunnel)
}
private fun generateQrCodeDefaultName(config: String): String {
private fun validateConfigString(config: String, configType: ConfigType) {
when (configType) {
ConfigType.AMNEZIA -> TunnelConfig.configFromAmQuick(config)
ConfigType.WIREGUARD -> TunnelConfig.configFromWgQuick(config)
}
}
private fun generateQrCodeDefaultName(config: String, configType: ConfigType): String {
return try {
TunnelConfig.configFromAmQuick(config).peers[0].endpoint.get().host
when (configType) {
ConfigType.AMNEZIA -> {
TunnelConfig.configFromAmQuick(config).peers[0].endpoint.get().host
}
ConfigType.WIREGUARD -> {
TunnelConfig.configFromWgQuick(config).peers[0].endpoint.get().host
}
}
} catch (e: Exception) {
Timber.e(e)
NumberUtils.generateRandomTunnelName()
}
}
private fun generateQrCodeTunnelName(config: String): String {
var defaultName = generateQrCodeDefaultName(config)
private fun generateQrCodeTunnelName(config: String, configType: ConfigType): String {
var defaultName = generateQrCodeDefaultName(config, configType)
val lines = config.lines().toMutableList()
val linesIterator = lines.iterator()
while (linesIterator.hasNext()) {
@@ -96,18 +108,37 @@ constructor(
return defaultName
}
fun onTunnelQrResult(result: String) = viewModelScope.launch(ioDispatcher) {
kotlin.runCatching {
val amConfig = TunnelConfig.configFromAmQuick(result)
val amQuick = amConfig.toAwgQuickString(true)
val wgQuick = amConfig.toWgQuickString()
suspend fun onTunnelQrResult(result: String, configType: ConfigType): Result<Unit> {
return withContext(ioDispatcher) {
try {
validateConfigString(result, configType)
val tunnelName =
makeTunnelNameUnique(generateQrCodeTunnelName(result, configType))
val tunnelConfig =
when (configType) {
ConfigType.AMNEZIA -> {
TunnelConfig(
name = tunnelName,
amQuick = result,
wgQuick =
TunnelConfig.configFromAmQuick(
result,
).toWgQuickString(),
)
}
val tunnelName = makeTunnelNameUnique(generateQrCodeTunnelName(result))
val tunnelConfig = TunnelConfig(name = tunnelName, wgQuick = wgQuick, amQuick = amQuick)
saveTunnel(tunnelConfig)
}.onFailure {
Timber.e(it)
SnackbarController.showMessage(StringValue.StringResource(R.string.error_invalid_code))
ConfigType.WIREGUARD ->
TunnelConfig(
name = tunnelName,
wgQuick = result,
)
}
addTunnel(tunnelConfig)
Result.success(Unit)
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.InvalidQrCode())
}
}
}
@@ -124,70 +155,130 @@ constructor(
}
}
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
val amConfig = stream.use { org.amnezia.awg.config.Config.parse(it) }
val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName))
saveTunnel(
TunnelConfig(
name = tunnelName,
wgQuick = amConfig.toWgQuickString(),
amQuick = amConfig.toAwgQuickString(true),
),
)
private fun saveTunnelConfigFromStream(stream: InputStream, fileName: String, type: ConfigType) {
var amQuick: String? = null
val wgQuick =
stream.use {
when (type) {
ConfigType.AMNEZIA -> {
val config = org.amnezia.awg.config.Config.parse(it)
amQuick = config.toAwgQuickString(true)
config.toWgQuickString()
}
ConfigType.WIREGUARD -> {
Config.parse(it).toWgQuickString(true)
}
}
}
viewModelScope.launch {
val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName))
addTunnel(
TunnelConfig(
name = tunnelName,
wgQuick = wgQuick,
amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
),
)
}
}
private fun getInputStreamFromUri(uri: Uri, context: Context): InputStream? {
return context.applicationContext.contentResolver.openInputStream(uri)
}
fun onTunnelFileSelected(uri: Uri, context: Context) = viewModelScope.launch(ioDispatcher) {
kotlin.runCatching {
if (!isValidUriContentScheme(uri)) throw InvalidFileExtensionException
val fileName = getFileName(context, uri)
when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION ->
saveTunnelFromConfUri(fileName, uri, context)
Constants.ZIP_FILE_EXTENSION ->
saveTunnelsFromZipUri(
uri,
context,
)
else -> throw InvalidFileExtensionException
suspend fun onTunnelFileSelected(uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
return withContext(ioDispatcher) {
try {
if (isValidUriContentScheme(uri)) {
val fileName = getFileName(context, uri)
return@withContext when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION ->
saveTunnelFromConfUri(fileName, uri, configType, context)
Constants.ZIP_FILE_EXTENSION ->
saveTunnelsFromZipUri(
uri,
configType,
context,
)
else -> Result.failure(WgTunnelExceptions.InvalidFileExtension())
}
} else {
Result.failure(WgTunnelExceptions.InvalidFileExtension())
}
} catch (e: Exception) {
Timber.e(e)
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}.onFailure {
Timber.e(it)
if (it is InvalidFileExtensionException) {
SnackbarController.showMessage(StringValue.StringResource(R.string.error_file_extension))
}
}
private suspend fun saveTunnelsFromZipUri(uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
return withContext(ioDispatcher) {
ZipInputStream(getInputStreamFromUri(uri, context)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
withContext(viewModelScope.coroutineContext) {
try {
var amQuick: String? = null
val wgQuick =
when (configType) {
ConfigType.AMNEZIA -> {
val config =
org.amnezia.awg.config.Config.parse(
zip,
)
amQuick = config.toAwgQuickString(true)
config.toWgQuickString()
}
ConfigType.WIREGUARD -> {
Config.parse(zip).toWgQuickString(true)
}
}
addTunnel(
TunnelConfig(
name = makeTunnelNameUnique(name),
wgQuick = wgQuick,
amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
),
)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}
}
Result.success(Unit)
}
}
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
return withContext(ioDispatcher) {
val stream = getInputStreamFromUri(uri, context)
return@withContext if (stream != null) {
try {
saveTunnelConfigFromStream(stream, name, configType)
} catch (e: Exception) {
return@withContext Result.failure(WgTunnelExceptions.ConfigParseError())
}
Result.success(Unit)
} else {
SnackbarController.showMessage(StringValue.StringResource(R.string.error_file_format))
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}
}
private suspend fun saveTunnelsFromZipUri(uri: Uri, context: Context) {
ZipInputStream(getInputStreamFromUri(uri, context)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach { entry ->
val name = getNameFromFileName(entry.name)
val amConf = org.amnezia.awg.config.Config.parse(zip.bufferedReader())
saveTunnel(
TunnelConfig(
name = makeTunnelNameUnique(name),
wgQuick = amConf.toWgQuickString(),
amQuick = amConf.toAwgQuickString(true),
),
)
}
}
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, context: Context) {
val stream = getInputStreamFromUri(uri, context) ?: throw FileReadException
saveTunnelConfigFromStream(stream, name)
private fun addTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
saveTunnel(tunnelConfig)
}
fun pauseAutoTunneling() = viewModelScope.launch {
@@ -209,23 +300,32 @@ constructor(
}
private fun getFileNameByCursor(context: Context, uri: Uri): String? {
return context.contentResolver.query(uri, null, null, null, null)?.use {
getDisplayNameByCursor(it)
context.contentResolver.query(uri, null, null, null, null)?.use {
return getDisplayNameByCursor(it)
}
return null
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (columnIndex == -1) return null
return columnIndex
return if (columnIndex != -1) {
return columnIndex
} else {
null
}
}
private fun getDisplayNameByCursor(cursor: Cursor): String? {
val move = cursor.moveToFirst()
if (!move) return null
val index = getDisplayNameColumnIndex(cursor)
if (index == null) return index
return cursor.getString(index)
return if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
if (index != null) {
cursor.getString(index)
} else {
null
}
} else {
null
}
}
private fun isValidUriContentScheme(uri: Uri): Boolean {
@@ -1,20 +1,41 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.annotation.DrawableRes
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.iamageo.multifablibrary.FabIcon
import com.iamageo.multifablibrary.FabOption
import com.iamageo.multifablibrary.MultiFabItem
import com.iamageo.multifablibrary.MultiFloatingActionButton
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
@Composable
fun ScrollDismissFab(icon: @Composable () -> Unit, focusRequester: FocusRequester, isVisible: Boolean, onClick: () -> Unit) {
fun ScrollDismissMultiFab(
@DrawableRes res: Int,
focusRequester: FocusRequester,
isVisible: Boolean,
onFabItemClicked: (fabItem: MultiFabItem) -> Unit,
) {
// Nested scroll for control FAB
val context = LocalContext.current
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically(initialOffsetY = { it * 2 }),
@@ -24,14 +45,64 @@ fun ScrollDismissFab(icon: @Composable () -> Unit, focusRequester: FocusRequeste
.focusRequester(focusRequester)
.focusGroup(),
) {
FloatingActionButton(
onClick = {
onClick()
val fobColor = MaterialTheme.colorScheme.secondary
val fobIconColor = MaterialTheme.colorScheme.background
MultiFloatingActionButton(
fabIcon =
FabIcon(
iconRes = res,
iconResAfterRotate = R.drawable.close,
iconRotate = 180f,
),
fabOption =
FabOption(
iconTint = fobIconColor,
backgroundTint = fobColor,
),
itemsMultiFab =
listOf(
MultiFabItem(
label = {
Text(
stringResource(id = R.string.amnezia),
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(end = 10.dp),
)
},
modifier =
Modifier
.size(40.dp),
icon = res,
value = ConfigType.AMNEZIA.name,
miniFabOption =
FabOption(
backgroundTint = fobColor,
fobIconColor,
),
),
MultiFabItem(
label = {
Text(
stringResource(id = R.string.wireguard),
color = MaterialTheme.colorScheme.onBackground,
textAlign = TextAlign.Center,
modifier = Modifier.padding(end = 10.dp),
)
},
icon = res,
value = ConfigType.WIREGUARD.name,
miniFabOption =
FabOption(
backgroundTint = fobColor,
fobIconColor,
),
),
),
onFabItemClicked = {
onFabItemClicked(it)
},
shape = RoundedCornerShape(16.dp),
containerColor = MaterialTheme.colorScheme.primary,
) {
icon()
}
)
}
}
@@ -19,7 +19,6 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -50,17 +49,16 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route
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.config.SubmitConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissFab
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.WildcardSupportingLabel
import com.zaneschepke.wireguardautotunnel.ui.screens.main.ConfigType
import com.zaneschepke.wireguardautotunnel.ui.screens.main.components.ScrollDismissMultiFab
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.isValidIpv4orIpv6Address
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import kotlinx.coroutines.delay
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@@ -105,16 +103,10 @@ fun OptionsScreen(
Scaffold(
floatingActionButton = {
ScrollDismissFab(icon = {
val icon = Icons.Filled.Edit
Icon(
imageVector = icon,
contentDescription = icon.name,
tint = MaterialTheme.colorScheme.onPrimary,
)
}, focusRequester, isVisible = true, onClick = {
ScrollDismissMultiFab(R.drawable.edit, focusRequester, isVisible = true, onFabItemClicked = {
val configType = ConfigType.valueOf(it.value)
navController.navigate(
Route.Config(config.id),
"${Screen.Config.route}/${config.id}?configType=${configType.name}",
)
})
},
@@ -248,7 +240,6 @@ fun OptionsScreen(
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(id = R.string.use_tunnel_on_wifi_name)) },
supportingText = { WildcardSupportingLabel { context.openWebUrl(it) } },
modifier =
Modifier
.padding(
@@ -288,10 +279,10 @@ fun OptionsScreen(
stringResource(R.string.set_custom_ping_ip),
stringResource(R.string.default_ping_ip),
focusRequester,
isErrorValue = { !it.isNullOrBlank() && !it.isValidIpv4orIpv6Address() },
isErrorValue = { !(it?.isValidIpv4orIpv6Address() ?: true) },
onSubmit = {
optionsViewModel.saveTunnelChanges(
config.copy(pingIp = it.ifBlank { null }),
config.copy(pingIp = it),
)
},
)
@@ -305,12 +296,11 @@ fun OptionsScreen(
focusRequester,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
),
isErrorValue = ::isSecondsError,
onSubmit = {
optionsViewModel.saveTunnelChanges(
config.copy(pingInterval = if (it.isBlank()) null else it.toLong() * 1000),
config.copy(pingInterval = it.toLong() * 1000),
)
},
)
@@ -325,7 +315,7 @@ fun OptionsScreen(
isErrorValue = ::isSecondsError,
onSubmit = {
optionsViewModel.saveTunnelChanges(
config.copy(pingCooldown = if (it.isBlank()) null else it.toLong() * 1000),
config.copy(pingCooldown = it.toLong() * 1000),
)
},
)
@@ -8,7 +8,7 @@ import androidx.compose.ui.res.stringResource
import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.snackbar.SnackbarController
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
@@ -36,11 +36,11 @@ fun PinLockScreen(navController: NavController, appViewModel: AppViewModel) {
onPinCorrect = {
// pin is correct, navigate or hide pin lock
if (context.isRunningOnTv()) {
navController.navigate(Route.Main)
navController.navigate(Screen.Main.route)
} else {
val isPopped = navController.popBackStack()
if (!isPopped) {
navController.navigate(Route.Main)
navController.navigate(Screen.Main.route)
}
}
},
@@ -5,7 +5,6 @@ import android.app.Activity
import android.content.Context.POWER_SERVICE
import android.content.Intent
import android.net.Uri
import android.net.VpnService
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
@@ -69,7 +68,7 @@ import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.AppViewModel
import com.zaneschepke.wireguardautotunnel.ui.Route
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
@@ -112,7 +111,7 @@ fun SettingsScreen(
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showVpnPermissionDialog by remember { mutableStateOf(false) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
val didExportFiles by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
var showLocationDialog by remember { mutableStateOf(false) }
@@ -127,6 +126,13 @@ fun SettingsScreen(
currentText = ""
}
val notificationPermissionState =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
} else {
null
}
val startForResult =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
@@ -159,20 +165,22 @@ fun SettingsScreen(
fun requestBatteryOptimizationsDisabled() {
val intent =
Intent().apply {
action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.parse("package:${context.packageName}")
this.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.fromParts("package", context.packageName, null)
}
startForResult.launch(intent)
}
fun handleAutoTunnelToggle() {
if (!uiState.generalState.isBatteryOptimizationDisableShown &&
!isBatteryOptimizationsDisabled() && !context.isRunningOnTv()
) {
return requestBatteryOptimizationsDisabled()
if (!uiState.generalState.isBatteryOptimizationDisableShown || !isBatteryOptimizationsDisabled()) return requestBatteryOptimizationsDisabled()
if (notificationPermissionState != null && !notificationPermissionState.status.isGranted) {
snackbar.showMessage(
context.getString(R.string.notification_permission_required),
)
return notificationPermissionState.launchPermissionRequest()
}
val intent = if (!uiState.settings.isKernelEnabled) {
VpnService.prepare(context)
com.wireguard.android.backend.GoBackend.VpnService.prepare(context)
} else {
null
}
@@ -314,20 +322,7 @@ fun SettingsScreen(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
checked = uiState.settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = { checked ->
if (!checked) viewModel.onToggleTunnelOnWifi()
if (checked) {
when (false) {
isBackgroundLocationGranted -> showLocationDialog = true
fineLocationState.status.isGranted -> showLocationDialog = true
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
viewModel.onToggleTunnelOnWifi()
}
}
}
},
onCheckChanged = { viewModel.onToggleTunnelOnWifi() },
modifier =
if (uiState.settings.isAutoTunnelEnabled) {
Modifier
@@ -455,7 +450,23 @@ fun SettingsScreen(
TextButton(
onClick = {
if (uiState.tunnels.isEmpty()) return@TextButton context.showToast(R.string.tunnel_required)
handleAutoTunnelToggle()
if (
uiState.settings.isTunnelOnWifiEnabled &&
!uiState.settings.isAutoTunnelEnabled
) {
when (false) {
isBackgroundLocationGranted -> showLocationDialog = true
fineLocationState.status.isGranted -> showLocationDialog = true
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
handleAutoTunnelToggle()
}
}
} else {
handleAutoTunnelToggle()
}
},
) {
val autoTunnelButtonText =
@@ -488,6 +499,20 @@ fun SettingsScreen(
title = stringResource(id = R.string.backend),
padding = screenPadding,
)
ConfigurationToggle(
stringResource(R.string.use_amnezia),
enabled =
!(
uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == TunnelState.UP) || uiState.settings.isKernelEnabled
),
checked = uiState.settings.isAmneziaEnabled,
padding = screenPadding,
onCheckChanged = {
viewModel.onToggleAmnezia()
},
)
ConfigurationToggle(
stringResource(R.string.use_kernel),
enabled =
@@ -495,7 +520,7 @@ fun SettingsScreen(
uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == TunnelState.UP) ||
!kernelSupport
kernelSupport
),
checked = uiState.settings.isKernelEnabled,
padding = screenPadding,
@@ -581,7 +606,7 @@ fun SettingsScreen(
} else {
// TODO may want to show a dialog before proceeding in the future
PinManager.initialize(WireGuardAutoTunnel.instance)
navController.navigate(Route.Lock)
navController.navigate(Screen.Lock.route)
}
},
)
@@ -18,7 +18,7 @@ fun WildcardSupportingLabel(onClick: (url: String) -> Unit) {
buildAnnotatedString {
pushStringAnnotation(
tag = "details",
annotation = stringResource(id = R.string.docs_wildcards),
annotation = stringResource(id = R.string.docs_features),
)
withStyle(
style = SpanStyle(color = MaterialTheme.colorScheme.primary),
@@ -46,7 +46,7 @@ import androidx.navigation.NavController
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
@@ -244,7 +244,7 @@ fun SupportScreen(navController: NavController, focusRequester: FocusRequester,
color = MaterialTheme.colorScheme.onBackground,
)
TextButton(
onClick = { navController.navigate(Route.Logs) },
onClick = { navController.navigate(Screen.Support.Logs.route) },
modifier = Modifier.padding(vertical = 5.dp),
) {
Row(
@@ -4,10 +4,10 @@ object Constants {
const val BASE_LOG_FILE_NAME = "wg_tunnel_logs"
const val LOG_BUFFER_SIZE = 3_000L
const val MANUAL_TUNNEL_CONFIG_ID = 0
const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1_000L // 10 minutes
const val VPN_STATISTIC_CHECK_INTERVAL = 1_000L
const val WATCHER_COLLECTION_DELAY = 3_000L
const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content"
@@ -16,6 +16,7 @@ object Constants {
const val ZIP_FILE_MIME_TYPE = "application/zip"
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val ALWAYS_ON_VPN_ACTION = "android.net.VpnService"
const val VPN_SETTINGS_PACKAGE = "android.net.vpn.SETTINGS"
const val EMAIL_MIME_TYPE = "plain/text"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
@@ -27,11 +28,12 @@ object Constants {
const val DEFAULT_PING_IP = "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
const val UNREADABLE_SSID = "<unknown ssid>"
val amProperties = listOf("Jc", "Jmin", "Jmax", "S1", "S2", "H1", "H2", "H3", "H4")
val amneziaProperties = listOf("Jc", "Jmin", "Jmax", "S1", "S2", "H1", "H2", "H3", "H4")
const val QR_CODE_NAME_PROPERTY = "# Name ="
}
@@ -1,13 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util
object InvalidFileExtensionException : Exception() {
private fun readResolve(): Any = InvalidFileExtensionException
}
object FileReadException : Exception() {
private fun readResolve(): Any = FileReadException
}
object ConfigExportException : Exception() {
private fun readResolve(): Any = ConfigExportException
}
@@ -118,7 +118,7 @@ class FileUtils(
}
} catch (e: Exception) {
Timber.e(e)
Result.failure(ConfigExportException)
Result.failure(WgTunnelExceptions.ConfigExportFailed())
}
}
}
@@ -0,0 +1,63 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.Context
import com.zaneschepke.wireguardautotunnel.R
sealed class WgTunnelExceptions : Exception() {
abstract fun getMessage(context: Context): String
data class ConfigExportFailed(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.export_configs_failed,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class ConfigParseError(private val appendMessage: StringValue = StringValue.Empty) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return StringValue.StringResource(R.string.config_parse_error).asString(context) + (
if (appendMessage != StringValue.Empty) ": ${appendMessage.asString(context)}" else ""
)
}
}
data class InvalidQrCode(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_invalid_code,
),
) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class InvalidFileExtension(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_file_extension,
),
) : WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
data class FileReadFailed(
private val userMessage: StringValue =
StringValue.StringResource(
R.string.error_file_format,
),
) :
WgTunnelExceptions() {
override fun getMessage(context: Context): String {
return userMessage.asString(context)
}
}
}
@@ -1,7 +1,11 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import android.content.Context
import android.content.pm.PackageInfo
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.StringValue
import com.zaneschepke.wireguardautotunnel.util.WgTunnelExceptions
import java.math.BigDecimal
import java.text.DecimalFormat
@@ -17,3 +21,10 @@ fun <T> List<T>.removeAt(index: Int): List<T> = toMutableList().apply { this.rem
typealias TunnelConfigs = List<TunnelConfig>
typealias Packages = List<PackageInfo>
fun Throwable.getMessage(context: Context): String {
return when (this) {
is WgTunnelExceptions -> this.getMessage(context)
else -> this.message ?: StringValue.StringResource(R.string.unknown_error).asString(context)
}
}
@@ -54,7 +54,7 @@ fun Config.toWgQuickString(): String {
val linesIterator = lines.iterator()
while (linesIterator.hasNext()) {
val next = linesIterator.next()
Constants.amProperties.forEach {
Constants.amneziaProperties.forEach {
if (next.startsWith(it, ignoreCase = true)) {
linesIterator.remove()
}
+1 -3
View File
@@ -8,8 +8,7 @@
<string name="docs_url" translatable="false">https://zaneschepke.com/wgtunnel-docs/overview.html</string>
<string name="docs_features" translatable="false">https://zaneschepke.com/wgtunnel-docs/features.html</string>
<string name="privacy_policy_url" translatable="false">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string>
<string name="docs_wildcards" translatable="false" >https://zaneschepke.com/wgtunnel-docs/features.html#wildcard-wi-fi-name-support</string>
<string name="error_file_extension">File is not a .conf or .zip</string>
<string name="error_file_extension">File is not a .conf or .zip</string>
<string name="turn_off_tunnel">Action requires tunnel off</string>
<string name="no_tunnels">No tunnels added yet!</string>
<string name="discord_url" translatable="false">https://discord.gg/rbRRNh6H7V</string>
@@ -195,5 +194,4 @@
<string name="set_custom_ping_cooldown">Ping restart cooldown (sec)</string>
<string name="wildcard_supported">Learn about supported wildcards.</string>
<string name="details">details</string>
<string name="show_amnezia_properties">Show Amnezia properties</string>
</resources>
+6 -2
View File
@@ -1,7 +1,7 @@
object Constants {
const val VERSION_NAME = "3.5.2"
const val VERSION_NAME = "3.5.1"
const val JVM_TARGET = "17"
const val VERSION_CODE = 35200
const val VERSION_CODE = 35103
const val TARGET_SDK = 34
const val MIN_SDK = 26
const val APP_ID = "com.zaneschepke.wireguardautotunnel"
@@ -15,5 +15,9 @@ object Constants {
const val RELEASE = "release"
const val NIGHTLY = "nightly"
const val PRERELEASE = "prerelease"
const val DEBUG = "debug"
const val TYPE = "type"
const val NIGHTLY_CODE = 42
const val PRERELEASE_CODE = 54
}
@@ -1,5 +0,0 @@
What's new:
- Added wildcard support for wifi names
- Fix slowness on mobile data
- Various bug fixes and improvements
- UI optimizations
+10 -8
View File
@@ -13,19 +13,20 @@ espressoCore = "3.6.1"
hiltAndroid = "2.52"
hiltNavigationCompose = "1.2.0"
junit = "4.13.2"
kotlinx-serialization-json = "1.7.3"
lifecycle-runtime-compose = "2.8.6"
kotlinx-serialization-json = "1.7.2"
lifecycle-runtime-compose = "2.8.5"
material3 = "1.3.0"
navigationCompose = "2.8.1"
multifabVersion = "1.1.1"
navigationCompose = "2.8.0"
pinLockCompose = "1.0.3"
roomVersion = "2.6.1"
timber = "5.0.1"
tunnel = "1.2.1"
androidGradlePlugin = "8.6.1"
tunnel = "1.2.4"
androidGradlePlugin = "8.6.0"
kotlin = "2.0.20"
ksp = "2.0.20-1.0.25"
composeBom = "2024.09.02"
compose = "1.7.2"
ksp = "2.0.20-1.0.24"
composeBom = "2024.09.00"
compose = "1.7.1"
zxingAndroidEmbedded = "4.3.0"
coreSplashscreen = "1.0.1"
gradlePlugins-grgit = "5.2.2"
@@ -89,6 +90,7 @@ pin-lock-compose = { module = "com.zaneschepke:pin_lock_compose", version.ref =
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
tunnel = { module = "com.zaneschepke:wireguard-android", version.ref = "tunnel" }
zaneschepke-multifab = { module = "com.zaneschepke:multifab", version.ref = "multifabVersion" }
zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
-1
View File
@@ -1 +0,0 @@
9