Compare commits

..

9 Commits

Author SHA1 Message Date
dependabot[bot] 196d855579 chore(deps): bump actions/download-artifact from 4 to 5
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-06 13:08:47 +00:00
Zane Schepke 230cd0adb8 refactor: remove prelease build, change icon color for nightly 2025-08-01 11:41:17 -04:00
Zane Schepke 33b51823ab chore: deprecation, warnings 2025-08-01 08:56:09 -04:00
Zane Schepke f333319576 feat: auto-tunnel warning notifications for location permissions and services 2025-08-01 02:06:53 -04:00
Zane Schepke e6ad1531c9 fix: improve permission flow, location permission detection, accessibility, tunnel notifications
Minor changes to Auto-tunnel ui to make starting auto tunnel more intuitive.

Better monitoring of location services and location permission changes to be immediately reflected in network monitor, with warnings displayed on auto tunnel screen if necessary depending on wifi detection method.

Improved detection of when app is backgrounded so we notify user of tunnel errors in notifications vs ui.

Fixes bug where prominent location screen was not showing properly.
2025-07-31 18:45:27 -04:00
Zane Schepke 030082df34 fix: miui segmented button color issue
#875
2025-07-26 07:58:25 -04:00
Zane Schepke a825a2f2a4 fix: tunnel position bug after toggle 2025-07-26 01:13:01 -04:00
Zane Schepke aa1a344bb2 chore: fix short description vi 2025-07-25 23:57:18 -04:00
Zane Schepke 3aa03c1896 chore: fix fastlane missing full descriptions 2025-07-25 21:36:54 -04:00
88 changed files with 906 additions and 695 deletions
-4
View File
@@ -12,7 +12,6 @@ on:
default: debug
options:
- debug
- prerelease
- nightly
- release
flavor:
@@ -105,9 +104,6 @@ jobs:
"release")
./gradlew :app:assemble${flavor^}Release --info
;;
"prerelease")
./gradlew :app:assemble${flavor^}Prerelease --info
;;
"nightly")
./gradlew :app:assemble${flavor^}Nightly --info
;;
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
run: mkdir ${{ github.workspace }}/temp
- name: Download artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: android_artifacts_*
path: ${{ github.workspace }}/temp
+4 -10
View File
@@ -25,7 +25,6 @@ on:
description: "GitHub release type"
options:
- none
- prerelease
- release
default: release
required: true
@@ -60,7 +59,7 @@ jobs:
flavor: fdroid
build-standalone:
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' || inputs.release_type == 'prerelease' || inputs.flavor == 'standalone' }}
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' || inputs.release_type == 'debug' || inputs.flavor == 'standalone' }}
uses: ./.github/workflows/build.yml
secrets: inherit
with:
@@ -109,7 +108,7 @@ jobs:
run: mkdir ${{ github.workspace }}/temp
- name: Download artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
pattern: android_artifacts_*
path: ${{ github.workspace }}/temp
@@ -124,11 +123,6 @@ jobs:
echo "$RELEASE_NOTES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: On prerelease release notes
if: ${{ github.event_name != 'push' && inputs.release_type == 'prerelease' }}
run: |
echo "RELEASE_NOTES=Testing version of app for specific feature." >> $GITHUB_ENV
- name: Get checksum
id: checksum
run: |
@@ -162,8 +156,8 @@ jobs:
tag_name: ${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag_name }}
name: ${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag_name }}
draft: false
prerelease: ${{ github.event_name != 'push' && inputs.release_type == 'prerelease' }}
make_latest: ${{ github.event_name == 'push' || inputs.release_type == 'release' }}
prerelease: false
make_latest: true
files: |
${{ github.workspace }}/temp/**/*.apk
env:
+11 -17
View File
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -20,6 +22,8 @@ android {
includeInBundle = false
}
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
defaultConfig {
applicationId = Constants.APP_ID
minSdk = Constants.MIN_SDK
@@ -27,15 +31,10 @@ android {
versionCode = computeVersionCode()
versionName = computeVersionName()
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
sourceSets { getByName("debug").assets.srcDirs(files("$projectDir/schemas")) }
buildConfigField(
"String[]",
"LANGUAGES",
"new String[]{ ${languageList().joinToString(separator = ", ") { "\"$it\"" }} }",
)
val languagesArray = buildLanguagesArray(languageList())
buildConfigField("String[]", "LANGUAGES", "new String[]{ $languagesArray }")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
@@ -73,22 +72,15 @@ android {
debug {
applicationIdSuffix = ".debug"
resValue("string", "app_name", "WG Tunnel - Debug")
resValue("string", "app_name", "WG Tunnel Debug")
isDebuggable = true
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.debug\"")
}
create(Constants.PRERELEASE) {
initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".prerelease"
resValue("string", "app_name", "WG Tunnel - Pre")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.pre\"")
}
create(Constants.NIGHTLY) {
initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".nightly"
resValue("string", "app_name", "WG Tunnel - Nightly")
resValue("string", "app_name", "WG Tunnel Nightly")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.nightly\"")
}
}
@@ -114,7 +106,9 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = Constants.JVM_TARGET }
kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } }
buildFeatures {
compose = true
buildConfig = true
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#648DB3</color>
</resources>
-1
View File
@@ -12,7 +12,6 @@
<!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -107,7 +107,6 @@ class MainActivity : AppCompatActivity() {
val isTv = isRunningOnTv()
val appUiState by viewModel.uiState.collectAsStateWithLifecycle()
val appViewState by viewModel.appViewState.collectAsStateWithLifecycle()
val tunnelError by viewModel.tunnelManager.errorEvents.collectAsStateWithLifecycle(null)
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
@@ -150,15 +149,6 @@ class MainActivity : AppCompatActivity() {
viewModel.handleEvent(AppEvent.SetBatteryOptimizeDisableShown)
}
LaunchedEffect(tunnelError) {
if (tunnelError == null) return@LaunchedEffect
val message = tunnelError!!.second.toStringRes()
val context = this@MainActivity
snackbar.showSnackbar(
context.getString(R.string.tunnel_error_template, context.getString(message))
)
}
with(appViewState) {
LaunchedEffect(isConfigChanged) {
if (isConfigChanged) {
@@ -265,7 +255,7 @@ class MainActivity : AppCompatActivity() {
SettingsAdvancedScreen(appUiState, viewModel)
}
composable<Route.LocationDisclosure> {
LocationDisclosureScreen(appUiState, viewModel)
LocationDisclosureScreen(viewModel)
}
composable<Route.AutoTunnel> {
AutoTunnelScreen(appUiState, viewModel)
@@ -328,4 +318,15 @@ class MainActivity : AppCompatActivity() {
}
}
}
override fun onResume() {
super.onResume()
WireGuardAutoTunnel.setUiActive(true)
networkMonitor.checkPermissionsAndUpdateState()
}
override fun onPause() {
super.onPause()
WireGuardAutoTunnel.setUiActive(false)
}
}
@@ -4,9 +4,6 @@ import android.app.Application
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import androidx.hilt.work.HiltWorkerFactory
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.Configuration
import com.wireguard.android.backend.GoBackend
import com.zaneschepke.logcatter.LogReader
@@ -23,6 +20,10 @@ import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@@ -50,7 +51,6 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
override fun onCreate() {
super.onCreate()
instance = this
ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleObserver())
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
StrictMode.setThreadPolicy(
@@ -90,30 +90,20 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
}
override fun onTerminate() {
applicationScope.launch {
tunnelManager.setBackendState(BackendState.INACTIVE, emptyList())
}
applicationScope.cancel()
tunnelManager.setBackendState(BackendState.INACTIVE, emptyList())
super.onTerminate()
}
class AppLifecycleObserver : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
Timber.d("Application entered foreground")
foreground = true
}
override fun onPause(owner: LifecycleOwner) {
Timber.d("Application entered background")
foreground = false
}
}
companion object {
private var foreground = false
fun isForeground(): Boolean {
return foreground
private val _uiActive = MutableStateFlow(false)
val uiActive: StateFlow<Boolean>
get() = _uiActive
fun setUiActive(active: Boolean) {
_uiActive.update { active }
}
@Volatile private var lastActiveTunnels: List<Int> = emptyList()
@@ -43,8 +43,13 @@ interface NotificationManager {
fun show(notificationId: Int, notification: Notification)
companion object {
const val AUTO_TUNNEL_LOCATION_PERMISSION_ID = 123
const val AUTO_TUNNEL_LOCATION_SERVICES_ID = 124
// For auto tunnel foreground notification
const val AUTO_TUNNEL_NOTIFICATION_ID = 122
// for tunnel foreground notification
const val VPN_NOTIFICATION_ID = 100
const val TUNNEL_STATUS_NOTIFICATION_ID = 101
const val EXTRA_ID = "id"
}
}
@@ -96,6 +96,8 @@ constructor(
service.stop()
try {
context.unbindService(autoTunnelServiceConnection)
} catch (e: Exception) {
Timber.e(e, "Failed to unbind AutoTunnelService")
} finally {
_tunnelService.value = null
}
@@ -120,6 +122,8 @@ constructor(
service.stop()
try {
context.unbindService(tunnelServiceConnection)
} catch (e: Exception) {
Timber.e(e, "Failed to stop TunnelForegroundService")
} finally {
_tunnelService.value = null
}
@@ -3,11 +3,10 @@ package com.zaneschepke.wireguardautotunnel.core.service.autotunnel
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.networkmonitor.ConnectivityState
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
@@ -26,6 +25,7 @@ import com.zaneschepke.wireguardautotunnel.domain.state.AutoTunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.Tunnels
import com.zaneschepke.wireguardautotunnel.util.extensions.zipWithPrevious
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import javax.inject.Provider
@@ -52,16 +52,12 @@ class AutoTunnelService : LifecycleService() {
private val autoTunnelStateFlow = MutableStateFlow(defaultState)
private var wakeLock: PowerManager.WakeLock? = null
private var killSwitchJob: Job? = null
class LocalBinder(val service: AutoTunnelService) : Binder()
private val binder = LocalBinder(this)
private var isServiceRunning = false
override fun onCreate() {
super.onCreate()
launchWatcherNotification()
@@ -80,22 +76,14 @@ class AutoTunnelService : LifecycleService() {
}
fun start() {
if (isServiceRunning) return
isServiceRunning = true
kotlin
.runCatching {
launchWatcherNotification()
initWakeLock()
startAutoTunnelJob()
startAutoTunnelStateJob()
killSwitchJob = startKillSwitchJob()
}
.onFailure { Timber.e(it) }
launchWatcherNotification()
startAutoTunnelJob()
startAutoTunnelStateJob()
killSwitchJob = startKillSwitchJob()
startNotificationJob()
}
fun stop() {
isServiceRunning = false
wakeLock?.let { if (it.isHeld) it.release() }
stopSelf()
}
@@ -143,32 +131,6 @@ class AutoTunnelService : LifecycleService() {
)
}
private fun initWakeLock() {
wakeLock =
(getSystemService(POWER_SERVICE) as PowerManager).run {
val tag = this.javaClass.name
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
try {
Timber.i("Initiating wakelock with 10 min timeout")
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} finally {
release()
}
}
}
}
private fun buildNetworkState(connectivityState: ConnectivityState): NetworkState {
return with(autoTunnelStateFlow.value.networkState) {
copy(
isWifiConnected = connectivityState.wifiState.connected,
isMobileDataConnected = connectivityState.cellularConnected,
isEthernetConnected = connectivityState.ethernetConnected,
wifiName = connectivityState.wifiState.ssid,
)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun startAutoTunnelStateJob() =
lifecycleScope.launch(ioDispatcher) {
@@ -182,9 +144,9 @@ class AutoTunnelService : LifecycleService() {
old.isKernelEnabled == new.isKernelEnabled
} // Only emit when isKernelEnabled changes
.flatMapLatest {
networkMonitor.connectivityStateFlow.flowOn(ioDispatcher).map {
buildNetworkState(it)
}
networkMonitor.connectivityStateFlow
.flowOn(ioDispatcher)
.map(NetworkState::from)
}
.distinctUntilChanged(),
) { double, networkState ->
@@ -221,6 +183,79 @@ class AutoTunnelService : LifecycleService() {
.distinctUntilChanged()
}
// watch for changes to location permission and notify user it will impact auto-tunneling
// TODO can add deeplinks later back to the app for fixing
// TODO or a recheck button for location permission so we dont have to poll it
private fun startNotificationJob(): Job =
lifecycleScope.launch(ioDispatcher) {
var locationServicesShown = false
var locationPermissionsShown = false
autoTunnelStateFlow.zipWithPrevious().collect { (previous, current) ->
when (current.settings.wifiDetectionMethod) {
AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT,
AndroidNetworkMonitor.WifiDetectionMethod.LEGACY -> {
with(current.networkState) {
if (
locationPermissionGranted == false &&
(previous?.networkState?.locationPermissionGranted == true ||
!locationServicesShown)
) {
locationServicesShown = true
Timber.i("Detected location permission lost")
val notification =
notificationManager.createNotification(
WireGuardNotification.NotificationChannels.AUTO_TUNNEL,
title = getString(R.string.warning),
description =
getString(R.string.location_permissions_missing),
)
notificationManager.show(
NotificationManager.AUTO_TUNNEL_LOCATION_PERMISSION_ID,
notification,
)
}
if (
locationServicesEnabled == false &&
(previous?.networkState?.locationServicesEnabled == true ||
!locationPermissionsShown)
) {
locationPermissionsShown = true
Timber.i("Detected location services lost")
val notification =
notificationManager.createNotification(
WireGuardNotification.NotificationChannels.AUTO_TUNNEL,
title = getString(R.string.warning),
description =
getString(R.string.location_services_not_detected),
)
notificationManager.show(
NotificationManager.AUTO_TUNNEL_LOCATION_SERVICES_ID,
notification,
)
}
if (
locationServicesEnabled == true &&
previous?.networkState?.locationServicesEnabled == false
) {
notificationManager.remove(
NotificationManager.AUTO_TUNNEL_LOCATION_SERVICES_ID
)
}
if (
locationPermissionGranted == true &&
previous?.networkState?.locationPermissionGranted == false
) {
notificationManager.remove(
NotificationManager.AUTO_TUNNEL_LOCATION_PERMISSION_ID
)
}
}
}
else -> Unit
}
}
}
private fun startKillSwitchJob() =
lifecycleScope.launch(ioDispatcher) {
autoTunnelStateFlow.collect {
@@ -5,6 +5,7 @@ import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
@@ -35,6 +36,8 @@ constructor(
}
override suspend fun startBackend(tunnel: TunnelConf) {
// name too long for kernel mode
if (!tunnel.isNameKernelCompatible) throw BackendError.TunnelNameTooLong
try {
updateTunnelStatus(tunnel, TunnelStatus.Starting)
backend.setState(tunnel, Tunnel.State.UP, tunnel.toWgConfig())
@@ -1,5 +1,9 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.Kernel
@@ -10,20 +14,13 @@ import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.util.StringValue
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
@OptIn(ExperimentalCoroutinesApi::class)
class TunnelManager
@Inject
constructor(
@@ -32,6 +29,7 @@ constructor(
private val appDataRepository: AppDataRepository,
@ApplicationScope private val applicationScope: CoroutineScope,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val notificationManager: NotificationManager,
) : TunnelProvider {
@OptIn(ExperimentalCoroutinesApi::class)
@@ -64,8 +62,46 @@ constructor(
initialValue = emptyMap(),
)
override val errorEvents: SharedFlow<Pair<TunnelConf, BackendError>>
get() = tunnelProviderFlow.value.errorEvents
@OptIn(ExperimentalCoroutinesApi::class)
override val errorEvents: SharedFlow<Pair<TunnelConf, BackendError>> =
combine(
tunnelProviderFlow.flatMapLatest { it.errorEvents },
WireGuardAutoTunnel.uiActive,
) { errorEvent, isEnabled ->
if (isEnabled) errorEvent else null
}
.filterNotNull()
.shareIn(
scope = applicationScope.plus(ioDispatcher),
started = SharingStarted.WhileSubscribed(5_000),
replay = 0,
)
// observe tunnel errors and launch notifications if ui is inactive
init {
applicationScope.launch(ioDispatcher) {
tunnelProviderFlow
.flatMapLatest { it.errorEvents }
.collect { (tunnelConf, error) ->
if (!WireGuardAutoTunnel.uiActive.value) {
val notification =
notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = StringValue.DynamicString(tunnelConf.name),
description =
StringValue.StringResource(
R.string.tunnel_error_template,
error.toStringRes(),
),
)
notificationManager.show(
NotificationManager.TUNNEL_STATUS_NOTIFICATION_ID,
notification,
)
}
}
}
}
override val bouncingTunnelIds: ConcurrentHashMap<Int, TunnelStatus.StopReason> =
tunnelProviderFlow.value.bouncingTunnelIds
@@ -106,7 +142,7 @@ constructor(
return tunnelProviderFlow.value.getStatistics(tunnelConf)
}
fun restorePreviousState() =
fun restorePreviousState(): Job =
applicationScope.launch(ioDispatcher) {
val settings = appDataRepository.settings.get()
if (settings.isRestoreOnBootEnabled) {
@@ -46,6 +46,6 @@ interface TunnelConfigDao {
@Query("SELECT * FROM TUNNELCONFIG WHERE is_mobile_data_tunnel=1")
suspend fun findByMobileDataTunnel(): TunnelConfigs
@Query("SELECT * FROM tunnelconfig ORDER BY position ASC")
fun getAllFlow(): Flow<MutableList<TunnelConfig>>
@Query("SELECT * FROM tunnelconfig ORDER BY position")
fun getAllFlow(): Flow<List<TunnelConfig>>
}
@@ -6,6 +6,7 @@ import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.KernelTunnel
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
@@ -99,6 +100,7 @@ class TunnelModule {
appDataRepository: AppDataRepository,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
@ApplicationScope applicationScope: CoroutineScope,
notificationManager: NotificationManager,
): TunnelManager {
return TunnelManager(
kernelTunnel,
@@ -106,6 +108,7 @@ class TunnelModule {
appDataRepository,
applicationScope,
ioDispatcher,
notificationManager,
)
}
@@ -11,23 +11,23 @@ sealed class BackendError : Exception() {
data object KernelModuleName : BackendError()
data object InvalidConfig : BackendError()
data object NotAuthorized : BackendError()
data object ServiceNotRunning : BackendError()
data object Unknown : BackendError()
data object TunnelNameTooLong : BackendError()
fun toStringRes() =
when (this) {
Config -> R.string.config_error
DNS -> R.string.dns_resolve_error
InvalidConfig -> R.string.invalid_config_error
KernelModuleName -> R.string.kernel_name_error
NotAuthorized,
Unauthorized -> R.string.auth_error
ServiceNotRunning -> R.string.service_running_error
Unknown -> R.string.unknown_error
TunnelNameTooLong -> R.string.error_tunnel_name
}
}
@@ -30,6 +30,8 @@ data class TunnelConf(
@Transient private var stateChangeCallback: ((Any) -> Unit)? = null,
) : Tunnel, org.amnezia.awg.backend.Tunnel {
val isNameKernelCompatible: Boolean = (name.length <= 15)
fun setStateChangeCallback(callback: (Any) -> Unit) {
stateChangeCallback = callback
}
@@ -95,6 +97,7 @@ data class TunnelConf(
pingIp,
isEthernetTunnel,
isIpv4Preferred,
position,
)
.apply { stateChangeCallback = this@TunnelConf.stateChangeCallback }
}
@@ -1,12 +1,38 @@
package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.networkmonitor.ConnectivityState
import com.zaneschepke.networkmonitor.util.WifiSecurityType
data class NetworkState(
val isWifiConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val isEthernetConnected: Boolean = false,
val wifiName: String? = null,
val isWifiSecure: Boolean? = null,
val locationServicesEnabled: Boolean? = null,
val locationPermissionGranted: Boolean? = null,
) {
fun hasNoCapabilities(): Boolean {
return !isWifiConnected && !isMobileDataConnected && !isEthernetConnected
}
companion object {
fun from(connectivityState: ConnectivityState): NetworkState {
return NetworkState(
isWifiSecure =
when (connectivityState.wifiState.securityType) {
WifiSecurityType.OPEN,
WifiSecurityType.UNKNOWN -> false
null -> null
else -> true
},
isWifiConnected = connectivityState.wifiState.connected,
isMobileDataConnected = connectivityState.cellularConnected,
isEthernetConnected = connectivityState.ethernetConnected,
wifiName = connectivityState.wifiState.ssid,
locationPermissionGranted = connectivityState.wifiState.locationPermissionsGranted,
locationServicesEnabled = connectivityState.wifiState.locationServicesEnabled,
)
}
}
}
@@ -0,0 +1,74 @@
package com.zaneschepke.wireguardautotunnel.ui.common.banner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.theme.Straw
@Composable
fun WarningBanner(
title: String,
visible: Boolean,
modifier: Modifier = Modifier,
trailing: (@Composable () -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = expandVertically(), exit = shrinkVertically()) {
Surface(
color = MaterialTheme.colorScheme.secondary,
modifier = modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(start = 2.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.Start),
modifier = Modifier.weight(4f, false).fillMaxWidth(),
) {
Icon(
Icons.Outlined.Warning,
stringResource(R.string.warning),
Modifier.size(18.dp),
tint = Straw,
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement =
Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier.fillMaxWidth().weight(1f).padding(start = 6.dp),
) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
trailing?.let {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.padding(start = 16.dp),
) {
it()
}
}
}
}
}
}
@@ -2,34 +2,23 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
@androidx.compose.runtime.Composable
fun IconSurfaceButton(
title: String,
onClick: () -> Unit,
selected: Boolean,
leadingIcon: ImageVector? = null,
leading: (@Composable () -> Unit)? = null,
description: String? = null,
) {
val border: BorderStroke? =
@@ -64,15 +53,7 @@ fun IconSurfaceButton(
modifier =
Modifier.padding(vertical = if (description == null) 10.dp else 0.dp),
) {
leadingIcon?.let {
Icon(
leadingIcon,
leadingIcon.name,
Modifier.size(iconSize),
if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
)
}
leading?.invoke()
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
description?.let {
@@ -1,13 +1,15 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button.surface
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
data class SelectionItem(
val leadingIcon: ImageVector? = null,
val leading: (@Composable () -> Unit)? = null,
val trailing: (@Composable () -> Unit)? = null,
val title: (@Composable () -> Unit),
val description: (@Composable () -> Unit)? = null,
val onClick: (() -> Unit)? = null,
val height: Int = 64,
val modifier: Modifier = Modifier.height(64.dp),
)
@@ -5,19 +5,18 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
@Composable
fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
fun SurfaceSelectionGroupButton(items: List<SelectionItem>, modifier: Modifier = Modifier) {
Card(
modifier = Modifier.fillMaxWidth(),
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
@@ -25,9 +24,10 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier.fillMaxWidth()
modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.then(item.onClick?.let { Modifier.clickable { it() } } ?: Modifier),
.then(item.onClick?.let { modifier.clickable { it() } } ?: modifier),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -37,21 +37,14 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(4f, false).fillMaxWidth(),
) {
item.leadingIcon?.let { icon ->
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
tint = MaterialTheme.colorScheme.onSurface,
)
}
item.leading?.invoke()
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement =
Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier =
Modifier.fillMaxWidth()
.padding(start = if (item.leadingIcon != null) 16.dp else 0.dp)
.padding(start = if (item.leading != null) 16.dp else 0.dp)
.weight(1f)
.padding(
vertical = if (item.description == null) 16.dp else 6.dp
@@ -20,7 +20,6 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.navigation.isCurrentRoute
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
@@ -31,7 +30,6 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
@Composable
fun BottomNavbar(appUiState: AppUiState) {
val navController = LocalNavController.current
val isTv = LocalIsAndroidTV.current
val navBackStackEntry by navController.currentBackStackEntryAsState()
val items =
@@ -48,8 +46,9 @@ fun BottomNavbar(appUiState: AppUiState) {
icon = Icons.Rounded.Bolt,
onClick = {
val route =
if (appUiState.appState.isLocationDisclosureShown) Route.AutoTunnel
else Route.LocationDisclosure
if (appUiState.appState.isLocationDisclosureShown) {
Route.AutoTunnel
} else Route.LocationDisclosure
navController.goFromRoot(route)
},
active = appUiState.isAutoTunnelActive,
@@ -90,7 +89,7 @@ fun BottomNavbar(appUiState: AppUiState) {
Icon(imageVector = item.icon, contentDescription = item.name)
}
},
onClick = { navController.goFromRoot(item.route) },
onClick = item.onClick,
selected = isSelected,
enabled = true,
label = null,
@@ -25,8 +25,6 @@ import com.zaneschepke.wireguardautotunnel.ui.navigation.isCurrentRoute
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.ui.state.NavBarState
import com.zaneschepke.wireguardautotunnel.ui.theme.Brick
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@@ -112,8 +110,6 @@ fun currentNavBackStackEntryAsNavBarState(
when {
backStackEntry.isCurrentRoute(Route.Main::class) -> {
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.tunnels)) },
topTrailing = { TunnelActionBar() },
route = Route.Main,
@@ -121,36 +117,15 @@ fun currentNavBackStackEntryAsNavBarState(
}
backStackEntry.isCurrentRoute(Route.AutoTunnel::class) -> {
val (icon, label, tint) =
if (uiState.appSettings.isAutoTunnelEnabled) {
Triple(Icons.Rounded.Stop, R.string.stop_auto, Brick)
} else {
Triple(Icons.Rounded.PlayArrow, R.string.start_auto, SilverTree)
}
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.auto_tunnel)) },
topTrailing = {
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnel) }
) {
Icon(
icon,
stringResource(label),
tint = tint,
modifier = Modifier.size(iconSize),
)
}
},
route = Route.AutoTunnel,
)
}
backStackEntry.isCurrentRoute(Route.Logs::class) -> {
NavBarState(
showTop = true,
showBottom = false,
topTitle = { Text(stringResource(R.string.logs)) },
topTrailing = {
@@ -166,56 +141,42 @@ fun currentNavBackStackEntryAsNavBarState(
backStackEntry.isCurrentRoute(Route.Settings::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.settings)) },
route = Route.Settings,
)
backStackEntry.isCurrentRoute(Route.Appearance::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.appearance)) },
route = Route.Appearance,
)
backStackEntry.isCurrentRoute(Route.Language::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.language)) },
route = Route.Language,
)
backStackEntry.isCurrentRoute(Route.Display::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.display_theme)) },
route = Route.Display,
)
backStackEntry.isCurrentRoute(Route.WifiDetectionMethod::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.wifi_detection_method)) },
route = Route.WifiDetectionMethod,
)
backStackEntry.isCurrentRoute(Route.KillSwitch::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.kill_switch)) },
route = Route.KillSwitch,
)
backStackEntry.isCurrentRoute(Route.Support::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.support)) },
route = Route.Support,
)
@@ -1,93 +1,93 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel
import android.Manifest
import android.os.Build
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider
import com.zaneschepke.wireguardautotunnel.ui.common.banner.WarningBanner
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.NetworkTunnelingItems
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WifiTunnelingItems
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.isLocationServicesEnabled
import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.launchLocationServicesSettings
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) {
val context = LocalContext.current
val navController = LocalNavController.current
val isTv = LocalIsAndroidTV.current
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
val context = LocalContext.current
var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var showLocationDialog by remember { mutableStateOf(false) }
fun checkFineLocationGranted() {
isBackgroundLocationGranted = fineLocationState.status.isGranted
}
fun isWifiNameReadable(): Boolean {
return when {
!isBackgroundLocationGranted || !fineLocationState.status.isGranted -> {
showLocationDialog = true
false
val showLocationServicesWarning by
remember(
uiState.connectivityState?.wifiState,
uiState.appSettings.trustedNetworkSSIDs,
uiState.appSettings.wifiDetectionMethod,
) {
derivedStateOf {
uiState.connectivityState?.wifiState?.locationServicesEnabled == false &&
uiState.appSettings.wifiDetectionMethod.needsLocationPermissions() &&
uiState.appSettings.trustedNetworkSSIDs.isNotEmpty()
}
!context.isLocationServicesEnabled() -> {
showLocationServicesAlertDialog = true
false
}
else -> true
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) checkFineLocationGranted()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (isTv && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
checkFineLocationGranted()
} else {
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
val showLocationPermissionsWarning by
remember(
uiState.connectivityState?.wifiState,
uiState.appSettings.trustedNetworkSSIDs,
uiState.appSettings.wifiDetectionMethod,
) {
derivedStateOf {
uiState.connectivityState?.wifiState?.locationPermissionsGranted == false &&
uiState.appSettings.wifiDetectionMethod.needsLocationPermissions() &&
uiState.appSettings.trustedNetworkSSIDs.isNotEmpty()
}
}
}
LaunchedEffect(uiState.appSettings.trustedNetworkSSIDs) { currentText = "" }
LocationServicesDialog(
showLocationServicesAlertDialog,
onDismiss = { showLocationServicesAlertDialog = false },
onAttest = { showLocationServicesAlertDialog = false },
)
BackgroundLocationDialog(
showLocationDialog,
onDismiss = { showLocationDialog = false },
onAttest = { showLocationDialog = false },
)
if (showLocationDialog) {
InfoDialog(
onAttest = {
context.launchAppSettings()
showLocationDialog = false
},
onDismiss = { showLocationDialog = false },
title = { Text(stringResource(R.string.location_permissions)) },
body = { Text(stringResource(R.string.location_justification)) },
confirmText = { Text(stringResource(R.string.open_settings)) },
)
}
Column(
horizontalAlignment = Alignment.Start,
@@ -98,16 +98,66 @@ fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) {
.padding(vertical = 24.dp)
.padding(horizontal = 12.dp),
) {
WarningBanner(
stringResource(R.string.location_services_not_detected),
showLocationServicesWarning,
trailing = {
TextButton({ context.launchLocationServicesSettings() }) {
Text(
stringResource(R.string.fix),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
},
)
WarningBanner(
stringResource(R.string.location_permissions_missing),
showLocationPermissionsWarning,
trailing = {
TextButton({ showLocationDialog = true }) {
Text(
stringResource(R.string.fix),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
},
)
val (title, buttonText, icon) =
remember(uiState.isAutoTunnelActive) {
when (uiState.isAutoTunnelActive) {
true ->
Triple(
context.getString(R.string.auto_tunnel_running),
context.getString(R.string.stop),
Icons.Outlined.CheckCircle,
)
false ->
Triple(
context.getString(R.string.auto_tunnel_not_running),
context.getString(R.string.start),
Icons.Outlined.Info,
)
}
}
SurfaceSelectionGroupButton(
items =
WifiTunnelingItems(
uiState,
viewModel,
currentText,
{ currentText = it },
{ isWifiNameReadable() },
listOf(
SelectionItem(
leading = { Icon(icon, null) },
title = { Text(title) },
trailing = {
Button({ viewModel.handleEvent(AppEvent.ToggleAutoTunnel) }) {
Text(buttonText, fontWeight = FontWeight.Bold)
}
},
)
)
)
SurfaceSelectionGroupButton(
items = WifiTunnelingItems(uiState, viewModel, currentText) { currentText = it }
)
SectionDivider()
SurfaceSelectionGroupButton(items = NetworkTunnelingItems(uiState, viewModel))
SectionDivider()
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.compo
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PauseCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -23,7 +24,7 @@ fun DebounceDelaySelector(currentDelay: Int, onEvent: (AppEvent) -> Unit) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
leadingIcon = Icons.Outlined.PauseCircle,
leading = { Icon(Icons.Outlined.PauseCircle, contentDescription = null) },
title = {
Text(
stringResource(R.string.debounce_delay),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -13,7 +14,7 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionIte
@Composable
fun AdvancedSettingsItem(onClick: () -> Unit): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.Settings,
leading = { Icon(Icons.Outlined.Settings, contentDescription = null) },
title = {
Text(
stringResource(R.string.advanced_settings),
@@ -4,6 +4,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PublicOff
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material.icons.outlined.SignalCellular4Bar
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -21,7 +22,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List<SelectionItem> {
return listOf(
SelectionItem(
leadingIcon = Icons.Outlined.SignalCellular4Bar,
leading = { Icon(Icons.Outlined.SignalCellular4Bar, contentDescription = null) },
title = {
Text(
stringResource(R.string.tunnel_mobile_data),
@@ -58,7 +59,7 @@ fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List<Se
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular) },
),
SelectionItem(
leadingIcon = Icons.Outlined.SettingsEthernet,
leading = { Icon(Icons.Outlined.SettingsEthernet, contentDescription = null) },
title = {
Text(
stringResource(R.string.tunnel_on_ethernet),
@@ -95,7 +96,7 @@ fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List<Se
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet) },
),
SelectionItem(
leadingIcon = Icons.Outlined.PublicOff,
leading = { Icon(Icons.Outlined.PublicOff, contentDescription = null) },
title = {
Text(
stringResource(R.string.stop_on_no_internet),
@@ -17,7 +17,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
@@ -39,7 +38,6 @@ fun WifiTunnelingItems(
viewModel: AppViewModel,
currentText: String,
onTextChange: (String) -> Unit,
isWifiNameReadable: () -> Boolean,
): List<SelectionItem> {
val context = LocalContext.current
val navController = LocalNavController.current
@@ -48,7 +46,7 @@ fun WifiTunnelingItems(
val baseItems =
listOf(
SelectionItem(
leadingIcon = Icons.Outlined.Wifi,
leading = { Icon(Icons.Outlined.Wifi, contentDescription = null) },
title = {
Text(
stringResource(R.string.tunnel_on_wifi),
@@ -111,7 +109,7 @@ fun WifiTunnelingItems(
baseItems +
listOf(
SelectionItem(
leadingIcon = Icons.Outlined.WifiFind,
leading = { Icon(Icons.Outlined.WifiFind, contentDescription = null) },
title = {
Text(
stringResource(R.string.wifi_detection_method),
@@ -139,7 +137,7 @@ fun WifiTunnelingItems(
onClick = { navController.navigate(Route.WifiDetectionMethod) },
),
SelectionItem(
leadingIcon = Icons.Outlined.Filter1,
leading = { Icon(Icons.Outlined.Filter1, contentDescription = null) },
title = {
Text(
stringResource(R.string.use_wildcards),
@@ -201,15 +199,7 @@ fun WifiTunnelingItems(
onDelete = { viewModel.handleEvent(AppEvent.DeleteTrustedSSID(it)) },
currentText = currentText,
onSave = { ssid ->
if (
uiState.appSettings.wifiDetectionMethod ==
AndroidNetworkMonitor.WifiDetectionMethod.ROOT ||
uiState.appSettings.wifiDetectionMethod ==
AndroidNetworkMonitor.WifiDetectionMethod.SHIZUKU ||
isWifiNameReadable()
) {
viewModel.handleEvent(AppEvent.SaveTrustedSSID(ssid))
}
viewModel.handleEvent(AppEvent.SaveTrustedSSID(ssid))
},
onValueChange = onTextChange,
supporting = {
@@ -219,7 +209,7 @@ fun WifiTunnelingItems(
},
),
SelectionItem(
leadingIcon = Icons.Outlined.VpnKeyOff,
leading = { Icon(Icons.Outlined.VpnKeyOff, contentDescription = null) },
title = {
Text(
stringResource(R.string.kill_switch_off),
@@ -9,24 +9,17 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.AppSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.LocationDisclosureHeader
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.SkipItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.appSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.skipItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun LocationDisclosureScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val navController = LocalNavController.current
fun LocationDisclosureScreen(viewModel: AppViewModel) {
LaunchedEffect(Unit, appUiState) {
if (appUiState.appState.isLocationDisclosureShown)
navController.goFromRoot(Route.AutoTunnel)
}
LaunchedEffect(Unit) { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -34,7 +27,7 @@ fun LocationDisclosureScreen(appUiState: AppUiState, viewModel: AppViewModel) {
modifier = Modifier.fillMaxSize().padding(top = 18.dp).padding(horizontal = 24.dp),
) {
LocationDisclosureHeader()
SurfaceSelectionGroupButton(items = listOf(AppSettingsItem(viewModel)))
SurfaceSelectionGroupButton(items = listOf(SkipItem(viewModel)))
SurfaceSelectionGroupButton(items = listOf(appSettingsItem()))
SurfaceSelectionGroupButton(items = listOf(skipItem()))
}
}
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.com
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -11,31 +12,20 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun AppSettingsItem(viewModel: AppViewModel): SelectionItem {
fun appSettingsItem(): SelectionItem {
val context = LocalContext.current
return SelectionItem(
leadingIcon = Icons.Outlined.LocationOn,
leading = { Icon(Icons.Outlined.LocationOn, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.launch_app_settings),
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = {
ForwardButton {
context.launchAppSettings().also {
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
}
}
},
onClick = {
context.launchAppSettings().also {
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
}
},
trailing = { ForwardButton { context.launchAppSettings() } },
onClick = { context.launchAppSettings() },
)
}
@@ -5,13 +5,15 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
@Composable
fun SkipItem(viewModel: AppViewModel): SelectionItem {
fun skipItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
title = {
Text(
@@ -19,7 +21,7 @@ fun SkipItem(viewModel: AppViewModel): SelectionItem {
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = { ForwardButton { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) } },
onClick = { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) },
trailing = { ForwardButton { navController.goFromRoot(Route.AutoTunnel) } },
onClick = { navController.goFromRoot(Route.AutoTunnel) },
)
}
@@ -18,9 +18,9 @@ import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.EthernetTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.MobileDataTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.WifiTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.ethernetTunnelItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@Composable
@@ -46,7 +46,7 @@ fun TunnelAutoTunnelScreen(
items =
buildList {
add(MobileDataTunnelItem(tunnelConf, viewModel))
add(EthernetTunnelItem(tunnelConf, viewModel))
add(ethernetTunnelItem(tunnelConf, viewModel))
add(
WifiTunnelItem(tunnelConf, appSettings, viewModel, currentText) {
currentText = it
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PhoneAndroid
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun MobileDataTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.PhoneAndroid,
leading = { Icon(Icons.Outlined.PhoneAndroid, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.mobile_tunnel),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -14,9 +15,9 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun EthernetTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
fun ethernetTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.SettingsEthernet,
leading = { Icon(Icons.Outlined.SettingsEthernet, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.ethernet_tunnel),
@@ -5,19 +5,9 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FolderZip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.material.icons.outlined.FolderZip
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -121,7 +111,7 @@ fun ExportTunnelsBottomSheet(viewModel: AppViewModel) {
private fun ExportOptionRow(label: String, onClick: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(10.dp)) {
Icon(
imageVector = Icons.Filled.FolderZip,
imageVector = Icons.Outlined.FolderZip,
contentDescription = label,
modifier = Modifier.padding(10.dp),
)
@@ -11,7 +11,6 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -44,8 +43,6 @@ fun TunnelList(
val lazyListState = rememberLazyListState()
val sortedTunnels = remember(appUiState.tunnels) { appUiState.tunnels.sortedBy { it.position } }
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
@@ -61,7 +58,7 @@ fun TunnelList(
if (appUiState.tunnels.isEmpty()) {
item { GettingStartedLabel(onClick = { context.openWebUrl(it) }) }
}
items(sortedTunnels, key = { it.id }) { tunnel ->
items(appUiState.tunnels, key = { it.id }) { tunnel ->
val tunnelState =
remember(appUiState.activeTunnels) {
appUiState.activeTunnels.getValueById(tunnel.id) ?: TunnelState()
@@ -18,7 +18,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
@@ -40,22 +42,54 @@ fun TunnelRowItem(
isTv: Boolean,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val leadingIconColor =
remember(state) {
if (state.status.isUp()) tunnelState.statistics.asColor() else Color.Gray
}
val (leadingIcon, size) =
val (leadingIcon, size, typeDescription) =
remember(tunnel) {
when {
tunnel.isPrimaryTunnel -> Pair(Icons.Rounded.Star, 16.dp)
tunnel.isMobileDataTunnel -> Pair(Icons.Rounded.Smartphone, 16.dp)
tunnel.isEthernetTunnel -> Pair(Icons.Rounded.SettingsEthernet, 16.dp)
else -> Pair(Icons.Rounded.Circle, 14.dp)
tunnel.isPrimaryTunnel ->
Triple(Icons.Rounded.Star, 16.dp, context.getString(R.string.primary_tunnel))
tunnel.isMobileDataTunnel ->
Triple(
Icons.Rounded.Smartphone,
16.dp,
context.getString(R.string.mobile_data_tunnel),
)
tunnel.isEthernetTunnel ->
Triple(
Icons.Rounded.SettingsEthernet,
16.dp,
context.getString(R.string.ethernet_tunnel),
)
else -> Triple(Icons.Rounded.Circle, 14.dp, context.getString(R.string.tunnel))
}
}
// Status description based on tunnel state
val statusDescription =
remember(state) {
if (state.status.isUpOrStarting()) {
context.getString(R.string.active)
} else {
context.getString(R.string.inactive)
}
}
// Combined content description for accessibility
val combinedContentDescription =
stringResource(
R.string.tunnel_item_description,
tunnel.tunName,
typeDescription,
statusDescription,
)
ExpandingRowListItem(
modifier = modifier.semantics(mergeDescendants = true) { combinedContentDescription },
leading = {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -63,14 +97,14 @@ fun TunnelRowItem(
) {
if (isTv) {
Checkbox(
isSelected,
checked = isSelected,
onCheckedChange = { onToggleSelectedTunnel(tunnel) },
modifier = Modifier.minimumInteractiveComponentSize().size(12.dp),
)
}
Icon(
leadingIcon,
stringResource(R.string.status),
contentDescription = null,
tint = leadingIconColor,
modifier = Modifier.size(size),
)
@@ -99,6 +133,5 @@ fun TunnelRowItem(
}
},
isSelected = isSelected,
modifier = modifier,
)
}
@@ -68,7 +68,7 @@ fun TunnelOptionsScreen(
listOf(
PrimaryTunnelItem(tunnelConf, viewModel),
AutoTunnelingItem(tunnelConf),
ServerIpv4Item(tunnelConf, viewModel),
serverIpv4Item(tunnelConf, viewModel),
SplitTunnelingItem(tunnelConf),
)
)
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.compon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -17,7 +18,7 @@ import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
fun AutoTunnelingItem(tunnelConf: TunnelConf): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Bolt,
leading = { Icon(Icons.Outlined.Bolt, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.auto_tunneling),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.compon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.NetworkPing
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun PingRestartItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.NetworkPing,
leading = { Icon(Icons.Outlined.NetworkPing, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.restart_on_ping),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.compon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Star
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun PrimaryTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.Star,
leading = { Icon(Icons.Outlined.Star, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.primary_tunnel),
@@ -157,11 +157,12 @@ private fun ConfigTypeSelector(selectedOption: ConfigType, onOptionSelected: (Co
}
},
colors =
SegmentedButtonDefaults.colors()
.copy(
activeContainerColor = Color.White,
inactiveContainerColor = Color.White,
),
SegmentedButtonDefaults.colors(
activeContainerColor = Color.White,
inactiveContainerColor = Color.White,
activeContentColor = Color.Black,
inactiveContentColor = Color.Black,
),
onCheckedChange = { onOptionSelected(entry) },
checked = isActive,
) {
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.compon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.CallSplit
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -17,7 +18,7 @@ import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
fun SplitTunnelingItem(tunnelConf: TunnelConf): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.AutoMirrored.Outlined.CallSplit,
leading = { Icon(Icons.AutoMirrored.Outlined.CallSplit, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.splt_tunneling),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.compon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Dns
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -14,9 +15,9 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun ServerIpv4Item(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
fun serverIpv4Item(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.Dns,
leading = { Icon(Icons.Outlined.Dns, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.server_ipv4),
@@ -20,15 +20,15 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelec
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AlwaysOnVpnItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AppShortcutsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.AppearanceItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.KernelModeItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.KillSwitchItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocalLoggingItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.PinLockItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.ReadLogsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.RestartAtBootItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.alwaysOnVpnItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.appearanceItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.killSwitchItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@@ -64,8 +64,8 @@ fun SettingsScreen(uiState: AppUiState, viewModel: AppViewModel) {
items =
buildList {
add(AppShortcutsItem(uiState, viewModel))
if (!isTv) add(AlwaysOnVpnItem(uiState, viewModel))
add(KillSwitchItem())
if (!isTv) add(alwaysOnVpnItem(uiState, viewModel))
add(killSwitchItem())
add(RestartAtBootItem(uiState, viewModel))
}
)
@@ -73,7 +73,7 @@ fun SettingsScreen(uiState: AppUiState, viewModel: AppViewModel) {
SurfaceSelectionGroupButton(
items =
buildList {
add(AppearanceItem())
add(appearanceItem())
add(LocalLoggingItem(uiState, viewModel))
if (uiState.appState.isLocalLogsEnabled) add(ReadLogsItem())
add(PinLockItem(uiState, viewModel))
@@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SmartToy
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -23,7 +24,7 @@ fun RemoteControlItem(uiState: AppUiState, viewModel: AppViewModel): SelectionIt
val clipboardManager = rememberClipboardHelper()
return SelectionItem(
leadingIcon = Icons.Filled.SmartToy,
leading = { Icon(Icons.Filled.SmartToy, contentDescription = null) },
trailing = {
ScaledSwitch(
checked = uiState.appState.isRemoteControlEnabled,
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.compo
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Contrast
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
fun DisplayThemeItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Contrast,
leading = { Icon(Icons.Outlined.Contrast, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.display_theme),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.compo
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Translate
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
fun LanguageItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.Translate,
leading = { Icon(Icons.Outlined.Translate, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.language),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.compo
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Notifications
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.launchNotificationSet
fun NotificationsItem(): SelectionItem {
val context = LocalContext.current
return SelectionItem(
leadingIcon = Icons.Outlined.Notifications,
leading = { Icon(Icons.Outlined.Notifications, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.notifications),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AppShortcut
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun AppShortcutsItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Filled.AppShortcut,
leading = { Icon(Icons.Filled.AppShortcut, contentDescription = null) },
trailing = {
ScaledSwitch(
checked = uiState.appSettings.isShortcutsEnabled,
@@ -1,53 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings
@Composable
fun BackgroundLocationDialog(show: Boolean, onDismiss: () -> Unit, onAttest: () -> Unit) {
val context = LocalContext.current
if (show) {
val alwaysOnDescription = buildAnnotatedString {
append(stringResource(R.string.background_location_message))
append(" ")
pushStringAnnotation(tag = "appSettings", annotation = "")
withStyle(style = SpanStyle(color = MaterialTheme.colorScheme.primary)) {
append(stringResource(id = R.string.app_settings))
}
pop()
append(" ")
append(stringResource(R.string.background_location_message2))
append(".")
}
InfoDialog(
onDismiss = { onDismiss() },
onAttest = { onDismiss() },
title = { Text(text = stringResource(R.string.vpn_denied_dialog_title)) },
body = {
ClickableText(
text = alwaysOnDescription,
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.outline
),
) {
alwaysOnDescription
.getStringAnnotations(tag = "appSettings", it, it)
.firstOrNull()
?.let { context.launchAppSettings() }
}
},
confirmText = { Text(text = stringResource(R.string.okay)) },
)
}
}
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun KernelModeItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.Code,
leading = { Icon(Icons.Outlined.Code, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.kernel),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.VpnKeyOff
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -13,10 +14,10 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionIte
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
@Composable
fun KillSwitchItem(): SelectionItem {
fun killSwitchItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Outlined.VpnKeyOff,
leading = { Icon(Icons.Outlined.VpnKeyOff, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.kill_switch_options),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ViewHeadline
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun LocalLoggingItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.ViewHeadline,
leading = { Icon(Icons.Outlined.ViewHeadline, contentDescription = null) },
title = {
SelectionItemLabel(stringResource(R.string.local_logging), SelectionLabelType.TITLE)
},
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Pin
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -32,7 +33,7 @@ fun PinLockItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem {
}
return SelectionItem(
leadingIcon = Icons.Outlined.Pin,
leading = { Icon(Icons.Outlined.Pin, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.enable_app_lock),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ViewTimeline
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
fun ReadLogsItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.Filled.ViewTimeline,
leading = { Icon(Icons.Filled.ViewTimeline, contentDescription = null) },
title = {
SelectionItemLabel(stringResource(R.string.read_logs), SelectionLabelType.TITLE)
},
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Restore
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun RestartAtBootItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.Restore,
leading = { Icon(Icons.Outlined.Restore, contentDescription = null) },
trailing = {
ScaledSwitch(
checked = uiState.appSettings.isRestoreOnBootEnabled,
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.VpnLock
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -14,9 +15,9 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun AlwaysOnVpnItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem {
fun alwaysOnVpnItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.VpnLock,
leading = { Icon(Icons.Outlined.VpnLock, contentDescription = null) },
trailing = {
ScaledSwitch(
enabled =
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ViewQuilt
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -13,10 +14,10 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionIte
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
@Composable
fun AppearanceItem(): SelectionItem {
fun appearanceItem(): SelectionItem {
val navController = LocalNavController.current
return SelectionItem(
leadingIcon = Icons.AutoMirrored.Outlined.ViewQuilt,
leading = { Icon(Icons.AutoMirrored.Outlined.ViewQuilt, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.appearance),
@@ -11,7 +11,9 @@ import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.components.LanTrafficItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.components.VpnKillSwitchItem
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.components.nativeKillSwitchItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@@ -26,7 +28,7 @@ fun KillSwitchScreen(uiState: AppUiState, viewModel: AppViewModel) {
modifier = Modifier.fillMaxSize().padding(vertical = 24.dp).padding(horizontal = 12.dp),
) {
if (!isTv) {
SurfaceSelectionGroupButton(items = listOf(NativeKillSwitchItem()))
SurfaceSelectionGroupButton(items = listOf(nativeKillSwitchItem()))
SectionDivider()
}
SurfaceSelectionGroupButton(
@@ -1,7 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Lan
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -16,7 +17,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun LanTrafficItem(uiState: AppUiState, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.Lan,
leading = { Icon(Icons.Outlined.Lan, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.allow_lan_traffic),
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.compo
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.VpnKey
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -14,7 +15,7 @@ import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
@Composable
fun VpnKillSwitchItem(uiState: AppUiState, toggleVpnSwitch: () -> Unit): SelectionItem {
return SelectionItem(
leadingIcon = Icons.Outlined.VpnKey,
leading = { Icon(Icons.Outlined.VpnKey, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.vpn_kill_switch),
@@ -1,7 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch
package com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AdminPanelSettings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -13,10 +14,10 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionIte
import com.zaneschepke.wireguardautotunnel.util.extensions.launchVpnSettings
@Composable
fun NativeKillSwitchItem(): SelectionItem {
fun nativeKillSwitchItem(): SelectionItem {
val context = LocalContext.current
return SelectionItem(
leadingIcon = Icons.Outlined.AdminPanelSettings,
leading = { Icon(Icons.Outlined.AdminPanelSettings, contentDescription = null) },
title = {
Text(
text = stringResource(R.string.native_kill_switch),
@@ -1,9 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support.components
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.Mail
import androidx.compose.material.icons.outlined.Favorite
import androidx.compose.material.icons.outlined.Mail
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
@@ -14,6 +17,7 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionIte
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItemLabel
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionLabelType
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.launchSupportEmail
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
@@ -26,7 +30,13 @@ fun ContactSupportOptions(context: android.content.Context) {
addAll(
listOf(
SelectionItem(
leadingIcon = ImageVector.vectorResource(R.drawable.matrix),
leading = {
Icon(
ImageVector.vectorResource(R.drawable.matrix),
contentDescription = null,
Modifier.size(iconSize),
)
},
title = {
SelectionItemLabel(
stringResource(R.string.join_matrix),
@@ -41,7 +51,13 @@ fun ContactSupportOptions(context: android.content.Context) {
onClick = { context.openWebUrl(context.getString(R.string.matrix_url)) },
),
SelectionItem(
leadingIcon = ImageVector.vectorResource(R.drawable.telegram),
leading = {
Icon(
ImageVector.vectorResource(R.drawable.telegram),
contentDescription = null,
Modifier.size(iconSize),
)
},
title = {
SelectionItemLabel(
stringResource(R.string.join_telegram),
@@ -58,7 +74,13 @@ fun ContactSupportOptions(context: android.content.Context) {
},
),
SelectionItem(
leadingIcon = ImageVector.vectorResource(R.drawable.github),
leading = {
Icon(
ImageVector.vectorResource(R.drawable.github),
contentDescription = null,
Modifier.size(iconSize),
)
},
title = {
SelectionItemLabel(
stringResource(R.string.open_issue),
@@ -73,7 +95,7 @@ fun ContactSupportOptions(context: android.content.Context) {
onClick = { context.openWebUrl(context.getString(R.string.github_url)) },
),
SelectionItem(
leadingIcon = Icons.Filled.Mail,
leading = { Icon(Icons.Outlined.Mail, contentDescription = null) },
title = {
SelectionItemLabel(
stringResource(R.string.email_description),
@@ -88,7 +110,7 @@ fun ContactSupportOptions(context: android.content.Context) {
if (BuildConfig.FLAVOR != Constants.GOOGLE_PLAY_FLAVOR) {
add(
SelectionItem(
leadingIcon = Icons.Filled.Favorite,
leading = { Icon(Icons.Outlined.Favorite, contentDescription = null) },
title = {
SelectionItemLabel(
stringResource(R.string.donate),
@@ -4,6 +4,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Balance
import androidx.compose.material.icons.filled.Book
import androidx.compose.material.icons.filled.Policy
import androidx.compose.material.icons.outlined.Balance
import androidx.compose.material.icons.outlined.Book
import androidx.compose.material.icons.outlined.Policy
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
@@ -24,7 +28,7 @@ fun GeneralSupportOptions(context: android.content.Context) {
buildList {
add(
SelectionItem(
leadingIcon = Icons.Filled.Book,
leading = { Icon(Icons.Outlined.Book, contentDescription = null) },
title = {
SelectionItemLabel(
stringResource(R.string.docs_description),
@@ -41,7 +45,7 @@ fun GeneralSupportOptions(context: android.content.Context) {
)
add(
SelectionItem(
leadingIcon = Icons.Filled.Policy,
leading = { Icon(Icons.Outlined.Policy, contentDescription = null) },
title = {
SelectionItemLabel(
stringResource(R.string.privacy_policy),
@@ -60,7 +64,7 @@ fun GeneralSupportOptions(context: android.content.Context) {
)
add(
SelectionItem(
leadingIcon = Icons.Filled.Balance,
leading = { Icon(Icons.Outlined.Balance, contentDescription = null) },
title = {
SelectionItemLabel(
stringResource(R.string.licenses),
@@ -3,7 +3,9 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support.components
import androidx.compose.foundation.layout.Column
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.outlined.CloudDownload
import androidx.compose.material.icons.rounded.CloudDownload
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.BuildConfig
@@ -18,7 +20,7 @@ fun UpdateSection(onUpdateCheck: () -> Unit = {}) {
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
leadingIcon = Icons.Filled.CloudDownload,
leading = { Icon(Icons.Outlined.CloudDownload, contentDescription = null) },
title = {
SelectionItemLabel(
stringResource(R.string.check_for_update),
@@ -6,7 +6,7 @@ val OffWhite = Color(0xFFF2F2F4)
val CoolGray = Color(0xFF8D9D9F)
val LightGrey = Color(0xFFECEDEF)
val Aqua = Color(0xFF76BEBD)
val Plantation = Color(0xFF264A49)
val Plantation = Color(0xFF2E3538)
val Shark = Color(0xFF21272A)
val BalticSea = Color(0xFF1C1B1F)
@@ -14,7 +14,18 @@ sealed class StringValue {
return when (this) {
is Empty -> ""
is DynamicString -> value
is StringResource -> context?.getString(resId, *args).orEmpty()
is StringResource -> {
val stringArgs =
args
.map { arg ->
when (arg) {
is Int -> context?.getString(arg) ?: arg.toString()
else -> arg
}
}
.toTypedArray()
context?.getString(resId, *stringArgs).orEmpty()
}
}
}
}
@@ -176,14 +176,19 @@ fun Context.launchSettings(): Result<Unit> {
}
fun Context.launchAppSettings() {
kotlin.runCatching {
val intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
}
kotlin
.runCatching {
val intent =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
}
.onFailure {
val fallback = Intent(Settings.ACTION_SETTINGS)
startActivity(fallback)
}
}
fun Context.requestTunnelTileServiceStateUpdate() {
@@ -1,64 +1,18 @@
package com.zaneschepke.wireguardautotunnel.util.extensions
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import java.time.Duration
import java.util.concurrent.ConcurrentLinkedQueue
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.ObsoleteCoroutinesApi
import kotlinx.coroutines.channels.ClosedReceiveChannelException
import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.channels.produce
import kotlinx.coroutines.channels.ticker
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.selects.whileSelect
import timber.log.Timber
/**
* Chunks based on a time or size threshold.
*
* Borrowed from this
* [Stack Overflow question](https://stackoverflow.com/questions/51022533/kotlin-chunk-sequence-based-on-size-and-time).
*/
@OptIn(ObsoleteCoroutinesApi::class, ExperimentalCoroutinesApi::class)
fun <T> ReceiveChannel<T>.chunked(scope: CoroutineScope, size: Int, time: Duration) =
scope.produce<List<T>> {
while (true) { // this loop goes over each chunk
val chunk = ConcurrentLinkedQueue<T>() // current chunk
val ticker = ticker(time.toMillis()) // time-limit for this chunk
try {
whileSelect {
ticker.onReceive {
false // done with chunk when timer ticks, takes priority over received
// elements
}
this@chunked.onReceive {
chunk += it
chunk.size < size // continue whileSelect if chunk is not full
}
}
} catch (e: ClosedReceiveChannelException) {
Timber.e(e)
return@produce
} finally {
ticker.cancel()
if (chunk.isNotEmpty()) {
send(chunk.toList())
}
}
}
}
import kotlinx.coroutines.flow.*
fun <K, V> Flow<Map<K, V>>.distinctByKeys(): Flow<Map<K, V>> {
return distinctUntilChanged { old, new -> old.keys == new.keys }
}
@ExperimentalCoroutinesApi
fun <T> CoroutineScope.asChannel(flow: Flow<T>): ReceiveChannel<T> = produce {
flow.collect { value -> channel.send(value) }
fun <T> Flow<T>.zipWithPrevious(): Flow<Pair<T?, T>> = flow {
var previous: T? = null
collect { current ->
emit(previous to current)
previous = current
}
}
suspend fun <R> StateFlow<AppUiState>.withFirstState(block: suspend (AppUiState) -> R): R {
@@ -65,7 +65,7 @@ constructor(
private val logReader: LogReader,
private val fileUtils: FileUtils,
private val shortcutManager: ShortcutManager,
networkMonitor: NetworkMonitor,
private val networkMonitor: NetworkMonitor,
) : ViewModel() {
private var logsJob: Job? = null
@@ -78,7 +78,7 @@ constructor(
private val _screenCallback = MutableStateFlow<(() -> Unit)?>(null)
private val _appViewState = MutableStateFlow(AppViewState())
val appViewState = _appViewState.asStateFlow()
val appViewState: StateFlow<AppViewState> = _appViewState.asStateFlow()
private val _uiEvent = MutableSharedFlow<UiEvent>()
val uiEvent: SharedFlow<UiEvent> = _uiEvent.asSharedFlow()
@@ -87,7 +87,7 @@ constructor(
val logs: StateFlow<List<LogMessage>> = _logs.asStateFlow()
private val maxLogSize = Constants.MAX_LOG_SIZE
val uiState =
val uiState: StateFlow<AppUiState> =
combine(
appDataRepository.settings.flow,
appDataRepository.tunnels.flow,
@@ -126,14 +126,15 @@ constructor(
handleKillSwitchChange(state.appSettings)
initServicesFromSavedState(state)
if (state.appState.isLocalLogsEnabled) logsJob = startCollectingLogs()
handleTunnelErrors()
}
}
}
fun handleUiEvent(event: UiEvent) =
fun handleUiEvent(event: UiEvent): Job =
viewModelScope.launch(mainDispatcher) { _uiEvent.emit(event) }
fun handleEvent(event: AppEvent) =
fun handleEvent(event: AppEvent): Job =
viewModelScope.launch(ioDispatcher) {
uiState.withFirstState { state ->
when (event) {
@@ -314,9 +315,17 @@ constructor(
}
}
// TODO
private fun handleTunnelErrors() =
viewModelScope.launch { tunnelManager.errorEvents.collect { errorEvent -> } }
viewModelScope.launch {
tunnelManager.errorEvents.collect { errorEvent ->
handleShowMessage(
StringValue.StringResource(
R.string.tunnel_error_template,
errorEvent.second.toStringRes(),
)
)
}
}
private suspend fun handleAppReadyCheck(tunnels: List<TunnelConf>) {
if (tunnels.size == appDataRepository.tunnels.count()) {
+22 -7
View File
@@ -41,7 +41,7 @@
<string name="endpoint">Endpoint</string>
<string name="name">Name</string>
<string name="always_on_vpn_support">Allow Always-On VPN </string>
<string name="location_services_not_detected">Location Services Not Detected</string>
<string name="location_services_not_detected">Location services not detected</string>
<string name="db_name" translatable="false">wg-tunnel-db</string>
<string name="auto_tunneling">Auto-tunneling</string>
<string name="vpn_on">VPN on</string>
@@ -175,7 +175,7 @@
<string name="auto_tunnel_channel_id" translatable="false">Auto-tunnel Channel</string>
<string name="auto_tunnel_channel_name">Auto-tunnel Notification Channel</string>
<string name="auto_tunnel_channel_description">A channel for auto-tunnel state notifications</string>
<string name="stop">stop</string>
<string name="stop">Stop</string>
<string name="splt_tunneling">Split tunneling</string>
<string name="tunnel_specific_settings">Tunnel specific settings</string>
<string name="show_scripts">Show scripts</string>
@@ -224,12 +224,12 @@
<string name="security_template">Security: %1$s</string>
<string name="current_template">Current: %1$s</string>
<string name="flavor_template">Flavor: %1$s</string>
<string name="config_error">config error</string>
<string name="dns_resolve_error">dns resolution error</string>
<string name="config_error">Invalid config</string>
<string name="dns_resolve_error">DNS resolution failed</string>
<string name="invalid_config_error">invalid_config_error</string>
<string name="kernel_name_error">kernel module name error</string>
<string name="auth_error">not authorized error</string>
<string name="service_running_error">service not running error</string>
<string name="kernel_name_error">Kernel module name error</string>
<string name="auth_error">Unauthorized</string>
<string name="service_running_error">Service not running</string>
<string name="inactive">Inactive</string>
<string name="active">Active</string>
<string name="status">Status</string>
@@ -285,4 +285,19 @@
<string name="drag_handle">Drag Handle</string>
<string name="move_up">Move Up</string>
<string name="move_down">Move Down</string>
<string name="error_tunnel_name">Tunnel name must be 15 characters or fewer in kernel mode</string>
<string name="tunnel">tunnel</string>
<string name="tunnel_item_description">1$s, %2$s, %3$s</string>
<string name="warning">Warning</string>
<string name="location_permissions">Location Permissions</string>
<string name="location_justification">In order to read Wi-Fi names in the background with your current detection
method, Android requires apps to be given \'Allow all the time\' and \'Precise\' (Background Location on older
devices) permission. Please enable these permissions in your Android app settings.
</string>
<string name="open_settings">Open Settings</string>
<string name="location_permissions_missing">Location permissions missing</string>
<string name="fix">Fix</string>
<string name="start">Start</string>
<string name="auto_tunnel_running">Auto-tunnel is running</string>
<string name="auto_tunnel_not_running">Auto-tunnel is not running</string>
</resources>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#52357B</color>
</resources>
-1
View File
@@ -10,7 +10,6 @@ object Constants {
// build types
const val RELEASE = "release"
const val NIGHTLY = "nightly"
const val PRERELEASE = "prerelease"
val allowedLicenses = listOf("MIT", "Apache-2.0", "BSD-3-Clause")
val allowedLicenseUrls = listOf("https://github.com/journeyapps/zxing-android-embedded/blob/master/COPYING",
+9 -23
View File
@@ -15,6 +15,11 @@ fun Project.languageList(): List<String> {
.toList() + "en"
}
fun buildLanguagesArray(languages: List<String>): String {
return languages.joinToString(separator = ", ") { "\"$it\"" }
}
// Get the Git commit hash
fun Project.getGitCommitHash(): String {
var grgit: Grgit? = null
@@ -47,21 +52,19 @@ fun Project.getCommitCountSinceLastCommit(): Int {
}
}
// Get versionCode increment for nightly/pre-release
// Get versionCode increment for nightly
fun Project.getVersionCodeIncrement(): Int {
val isNightlyBuild = gradle.startParameter.taskNames.any { it.lowercase().contains("nightly") }
val isPreReleaseBuild = gradle.startParameter.taskNames.any { it.lowercase().contains("prerelease") }
if (!isNightlyBuild && !isPreReleaseBuild) return 0
if (!isNightlyBuild) return 0
return System.getenv("GITHUB_RUN_NUMBER")?.toIntOrNull()
?: System.getenv("CI_BUILD_NUMBER")?.toIntOrNull()
?: getCommitCountSinceLastCommit()
}
// Compute versionName dynamic bumping for nightly/pre-release
// Compute versionName dynamic bumping for nightly
fun Project.computeVersionName(): String {
val isNightlyBuild = isNightlyBuild()
val isPreReleaseBuild = isPrereleaseBuild()
// Static version from Constants.kt
val baseVersion = Semver.parse(Constants.VERSION_NAME) ?: Semver.of(0, 0, 0)
@@ -76,15 +79,6 @@ fun Project.computeVersionName(): String {
)
"${nightlyVersion}-nightly+git.${getGitCommitHash()}"
}
isPreReleaseBuild -> {
// Bump minor for pre-release
val preReleaseVersion = Semver.of(
baseVersion.major,
baseVersion.minor,
0 + 1,
)
"${preReleaseVersion}-beta+git.${getGitCommitHash()}"
}
else -> Constants.VERSION_NAME
}
}
@@ -93,19 +87,11 @@ fun Project.isNightlyBuild(): Boolean {
return gradle.startParameter.taskNames.any { it.lowercase().contains(Constants.NIGHTLY) }
}
fun Project.isPrereleaseBuild(): Boolean {
return gradle.startParameter.taskNames.any { it.lowercase().contains(Constants.PRERELEASE) }
}
// Compute versionCode (static baseline, dynamic bumping for nightly/pre-release)
// Compute versionCode (static baseline, dynamic bumping for nightly)
fun Project.computeVersionCode(): Int {
val isNightlyBuild = isNightlyBuild()
val isPreReleaseBuild = isPrereleaseBuild()
var versionCode = Constants.VERSION_CODE
if (isPreReleaseBuild) {
versionCode += 100 // Minor bump
}
if (isNightlyBuild) {
versionCode += 1 // Patch bump
}
@@ -0,0 +1,14 @@
Features
- Add tunnels via .conf file, zip, manual entry, or QR code
- Auto connect to VPN based on Wi-Fi SSID, ethernet, or mobile data
- Split tunneling by application with search
- WireGuard support for kernel and userspace modes
- Amnezia support for userspace mode for DPI/censorship protection
- Always-On VPN support
- Export Amnezia and WireGuard tunnels to zip
- Quick tile support for VPN toggling
- Static shortcuts support for primary tunnel for automation integration
- Intent automation support for all tunnels
- Automatic service restart after reboot
- Battery preservation measures
@@ -0,0 +1,14 @@
Features
- Add tunnels via .conf file, zip, manual entry, or QR code
- Auto connect to VPN based on Wi-Fi SSID, ethernet, or mobile data
- Split tunneling by application with search
- WireGuard support for kernel and userspace modes
- Amnezia support for userspace mode for DPI/censorship protection
- Always-On VPN support
- Export Amnezia and WireGuard tunnels to zip
- Quick tile support for VPN toggling
- Static shortcuts support for primary tunnel for automation integration
- Intent automation support for all tunnels
- Automatic service restart after reboot
- Battery preservation measures
@@ -0,0 +1,14 @@
Features
- Add tunnels via .conf file, zip, manual entry, or QR code
- Auto connect to VPN based on Wi-Fi SSID, ethernet, or mobile data
- Split tunneling by application with search
- WireGuard support for kernel and userspace modes
- Amnezia support for userspace mode for DPI/censorship protection
- Always-On VPN support
- Export Amnezia and WireGuard tunnels to zip
- Quick tile support for VPN toggling
- Static shortcuts support for primary tunnel for automation integration
- Intent automation support for all tunnels
- Automatic service restart after reboot
- Battery preservation measures
@@ -0,0 +1 @@
An alternative VPN client app for WireGuard with additional features
+1 -1
View File
@@ -15,7 +15,7 @@ hiltCompiler = "1.2.0"
junit = "4.13.2"
kotlinx-serialization-json = "1.8.1"
ktorClientCore = "3.1.3"
lifecycle-runtime-compose = "2.9.1"
lifecycle-runtime-compose = "2.9.2"
material3 = "1.3.2"
navigationCompose = "2.9.0"
pinLockCompose = "1.0.4"
+4 -3
View File
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlin.android)
@@ -23,15 +25,14 @@ android {
)
}
create(Constants.PRERELEASE) { initWith(getByName(Constants.RELEASE)) }
create(Constants.NIGHTLY) { initWith(getByName(Constants.RELEASE)) }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = Constants.JVM_TARGET }
kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } }
}
dependencies {
+4 -2
View File
@@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidLibrary)
alias(libs.plugins.kotlin.android)
@@ -22,7 +24,6 @@ android {
"proguard-rules.pro",
)
}
create(Constants.PRERELEASE) { initWith(getByName(Constants.RELEASE)) }
create(Constants.NIGHTLY) { initWith(getByName(Constants.RELEASE)) }
}
@@ -30,7 +31,8 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions { jvmTarget = Constants.JVM_TARGET }
kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_17 } }
}
dependencies {
@@ -29,4 +29,11 @@ class ActiveWifiStateManager {
fun getLatestValue(): Pair<Network?, NetworkCapabilities?>? {
return _stateFlow.value.entries.lastOrNull()?.value
}
@Synchronized
fun clear() {
_stateFlow.update { currentMap ->
linkedMapOf(*currentMap.toList().toTypedArray()).apply { clear() }
}
}
}
@@ -16,11 +16,7 @@ import android.os.Build
import androidx.core.content.ContextCompat
import com.wireguard.android.util.RootShell
import com.zaneschepke.networkmonitor.shizuku.ShizukuShell
import com.zaneschepke.networkmonitor.util.WIFI_SSID_SHELL_COMMAND
import com.zaneschepke.networkmonitor.util.getCurrentSecurityType
import com.zaneschepke.networkmonitor.util.getCurrentWifiName
import com.zaneschepke.networkmonitor.util.getWifiSsid
import com.zaneschepke.networkmonitor.util.isLocationServicesEnabled
import com.zaneschepke.networkmonitor.util.*
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.*
@@ -32,15 +28,17 @@ class AndroidNetworkMonitor(
private val applicationScope: CoroutineScope,
) : NetworkMonitor {
private val actionPermissionCheck = "${appContext.packageName}.PERMISSION_CHECK"
interface ConfigurationListener {
val detectionMethod: Flow<WifiDetectionMethod>
val rootShell: RootShell
}
companion object {
const val LOCATION_GRANTED = "LOCATION_PERMISSIONS_GRANTED"
const val LOCATION_SERVICES_FILTER = "android.location.PROVIDERS_CHANGED"
const val ANDROID_UNKNOWN_SSID = "<unknown ssid>"
const val LOCATION_SERVICES_FILTER: String = "android.location.PROVIDERS_CHANGED"
const val ANDROID_UNKNOWN_SSID: String = "<unknown ssid>"
}
enum class WifiDetectionMethod(val value: Int) {
@@ -53,9 +51,12 @@ class AndroidNetworkMonitor(
fun fromValue(value: Int): WifiDetectionMethod =
entries.find { it.value == value } ?: DEFAULT
}
fun needsLocationPermissions(): Boolean {
return this == DEFAULT || this == LEGACY
}
}
private val packageName = appContext.packageName
private val connectivityManager =
appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager?
private val wifiManager = appContext.getSystemService(Context.WIFI_SERVICE) as WifiManager?
@@ -65,35 +66,72 @@ class AndroidNetworkMonitor(
// Track active Wi-Fi networks, their capabilities, and last active network ID
private val activeWifiNetworks = ActiveWifiStateManager()
private val permissionsChangedFlow = MutableStateFlow(false)
@OptIn(ExperimentalCoroutinesApi::class)
private val wifiFlow: Flow<TransportEvent> =
configurationListener.detectionMethod.flatMapLatest { detectionMethod
-> // cancels previous flow
Timber.d("Updated detectionMethod=$detectionMethod, recreating wifiFlow")
createWifiNetworkCallbackFlow(detectionMethod) // Create a new flow for each new method
}
combine(configurationListener.detectionMethod, permissionsChangedFlow) {
detectionMethod,
changed ->
Pair(detectionMethod, changed)
}
.flatMapLatest { (detectionMethod, _) -> // cancels previous flow
Timber.d("Permission or detection method changed, recreating wifiFlow")
createWifiNetworkCallbackFlow(detectionMethod)
}
private fun isAndroidTv(): Boolean =
appContext.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
private fun hasRequiredLocationPermissions(): Boolean {
val fineLocationGranted =
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED
val backgroundLocationGranted =
if (
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) &&
// exclude Android TV on Q as background location is not required on this
// version
!(Build.VERSION.SDK_INT == Build.VERSION_CODES.Q && isAndroidTv())
) {
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.ACCESS_BACKGROUND_LOCATION,
) == PackageManager.PERMISSION_GRANTED
} else {
true // No need for ACCESS_BACKGROUND_LOCATION on Android P or Android TV on Q
}
return fineLocationGranted && backgroundLocationGranted
}
private fun createWifiNetworkCallbackFlow(
detectionMethod: WifiDetectionMethod
): Flow<TransportEvent> = callbackFlow {
val locationPermissionReceiver =
// The primary purpose of this receiver is to handle the case that the user enables location
// permissions and then returns to the app
// When this happens, we should check if permissions changed. If so, we need to requery
// Wi-Fi name for the currently connected network
val permissionReceiver =
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Timber.d(
"locationPermissionReceiver received intent with action: ${intent.action}"
)
if (intent.action == "$packageName.$LOCATION_GRANTED") {
Timber.d(
"Received update: Precise and all-the-time location permissions are enabled"
)
activeWifiNetworks.getLatestValue()?.let { details ->
trySend(
TransportEvent.LocationPermissionGranted(
details.first,
details.second,
detectionMethod,
)
if (intent.action == actionPermissionCheck) {
val isGranted = hasRequiredLocationPermissions()
Timber.d("Received permission check broadcast, isGranted: $isGranted")
// get Wi-Fi info on permission change and update permission state
if (
connectivityStateFlow.replayCache
.firstOrNull()
?.wifiState
?.locationPermissionsGranted != isGranted
) {
Timber.d(
"Location permissions have changed, canceling and restarting callback flow"
)
activeWifiNetworks.clear()
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
}
@@ -103,54 +141,42 @@ class AndroidNetworkMonitor(
object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == LOCATION_SERVICES_FILTER) {
val isGpsEnabled =
locationManager?.isProviderEnabled(LocationManager.GPS_PROVIDER)
?: false
val isNetworkEnabled =
locationManager?.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
?: false
val isLocationServicesEnabled = isGpsEnabled || isNetworkEnabled
Timber.d(
"Location Services state changed. Enabled: $isLocationServicesEnabled, GPS: $isGpsEnabled, Network: $isNetworkEnabled"
)
activeWifiNetworks.getLatestValue()?.let { details ->
trySend(
TransportEvent.LocationServicesChanged(
isLocationServicesEnabled,
details.first,
details.second,
detectionMethod,
)
Timber.d("Received location services broadcast")
val isLocationServicesEnabled = locationManager?.isLocationServicesEnabled()
if (
connectivityStateFlow.replayCache
.firstOrNull()
?.wifiState
?.locationServicesEnabled != isLocationServicesEnabled
) {
Timber.d(
"Location services have changed, canceling and restarting callback flow"
)
// trigger cancel and recreate of callbackFlow
activeWifiNetworks.clear()
permissionsChangedFlow.update { !permissionsChangedFlow.value }
}
}
}
}
val permissionReceiverFlags =
val receiverFlags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
Context.RECEIVER_NOT_EXPORTED // Internal broadcast
} else {
0
}
val servicesReceiverFlags =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
Context.RECEIVER_EXPORTED // System broadcast
} else {
0
}
appContext.registerReceiver(
locationPermissionReceiver,
IntentFilter("$packageName.$LOCATION_GRANTED"),
permissionReceiverFlags,
permissionReceiver,
IntentFilter(actionPermissionCheck),
receiverFlags,
)
appContext.registerReceiver(
locationServicesReceiver,
IntentFilter(LOCATION_SERVICES_FILTER),
servicesReceiverFlags,
receiverFlags,
)
fun handleOnWifiLost(network: Network) {
@@ -187,34 +213,36 @@ class AndroidNetworkMonitor(
detectionMethod == WifiDetectionMethod.LEGACY ||
Build.VERSION.SDK_INT < Build.VERSION_CODES.S ->
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
handleOnWifiAvailable(network)
}
override fun onAvailable(network: Network) {
handleOnWifiAvailable(network)
}
override fun onLost(network: Network) {
handleOnWifiLost(network)
override fun onLost(network: Network) {
handleOnWifiLost(network)
}
}
}
.also { Timber.d("Creating Wi-Fi callback without location info flags") }
else ->
object : ConnectivityManager.NetworkCallback(FLAG_INCLUDE_LOCATION_INFO) {
override fun onAvailable(network: Network) {
if (detectionMethod != WifiDetectionMethod.DEFAULT)
handleOnWifiAvailable(network)
}
override fun onAvailable(network: Network) {
if (detectionMethod != WifiDetectionMethod.DEFAULT)
handleOnWifiAvailable(network)
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
if (detectionMethod == WifiDetectionMethod.DEFAULT)
handleOnWifiCapabilitiesChanged(network, networkCapabilities)
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
if (detectionMethod == WifiDetectionMethod.DEFAULT)
handleOnWifiCapabilitiesChanged(network, networkCapabilities)
}
override fun onLost(network: Network) {
handleOnWifiLost(network)
override fun onLost(network: Network) {
handleOnWifiLost(network)
}
}
}
.also { Timber.d("Creating Wi-Fi callback with location info flags") }
}
val request =
@@ -225,11 +253,19 @@ class AndroidNetworkMonitor(
connectivityManager?.registerNetworkCallback(request, callback)
trySend(TransportEvent.Unknown)
trySend(
TransportEvent.Permissions(
permissions =
Permissions(
locationManager?.isLocationServicesEnabled() ?: false,
hasRequiredLocationPermissions(),
)
)
)
awaitClose {
runCatching {
appContext.unregisterReceiver(locationPermissionReceiver)
appContext.unregisterReceiver(permissionReceiver)
appContext.unregisterReceiver(locationServicesReceiver)
connectivityManager?.unregisterNetworkCallback(callback)
}
@@ -295,7 +331,7 @@ class AndroidNetworkMonitor(
}
}
suspend fun getSsidByDetectionMethod(
private suspend fun getSsidByDetectionMethod(
detectionMethod: WifiDetectionMethod?,
networkCapabilities: NetworkCapabilities?,
): String {
@@ -327,15 +363,11 @@ class AndroidNetworkMonitor(
.also { Timber.d("Current SSID via ${method.name}: $it") }
}
override val connectivityStateFlow =
override val connectivityStateFlow: SharedFlow<ConnectivityState> =
combine(
wifiFlow.scan(
WifiState(
locationPermissionsGranted =
ContextCompat.checkSelfPermission(
appContext,
Manifest.permission.ACCESS_FINE_LOCATION,
) == PackageManager.PERMISSION_GRANTED,
locationPermissionsGranted = hasRequiredLocationPermissions(),
locationServicesEnabled =
locationManager?.isLocationServicesEnabled() ?: false,
)
@@ -361,29 +393,16 @@ class AndroidNetworkMonitor(
),
securityType = wifiManager?.getCurrentSecurityType(),
)
is TransportEvent.LocationPermissionGranted ->
is TransportEvent.Permissions -> {
previous.copy(
locationPermissionsGranted = true,
ssid =
getSsidByDetectionMethod(
event.wifiDetectionMethod,
event.networkCapabilities,
),
securityType = wifiManager?.getCurrentSecurityType(),
)
is TransportEvent.LocationServicesChanged ->
previous.copy(
locationServicesEnabled = event.enabled,
ssid =
getSsidByDetectionMethod(
event.wifiDetectionMethod,
event.networkCapabilities,
),
securityType = wifiManager?.getCurrentSecurityType(),
locationPermissionsGranted =
event.permissions.locationPermissionGranted,
locationServicesEnabled = event.permissions.locationServicesEnabled,
)
}
is TransportEvent.Lost ->
previous.copy(connected = false, securityType = null, ssid = null)
TransportEvent.Unknown -> previous
is TransportEvent.Unknown -> previous
}
},
cellularFlow,
@@ -400,4 +419,11 @@ class AndroidNetworkMonitor(
}
.distinctUntilChanged()
.shareIn(applicationScope, SharingStarted.WhileSubscribed(5000), replay = 1)
override fun checkPermissionsAndUpdateState() {
val action = actionPermissionCheck
val intent = Intent(action)
Timber.d("Sending broadcast: $action")
appContext.sendBroadcast(intent)
}
}
@@ -17,3 +17,8 @@ data class WifiState(
val locationPermissionsGranted: Boolean,
val locationServicesEnabled: Boolean,
)
data class Permissions(
val locationServicesEnabled: Boolean = false,
val locationPermissionGranted: Boolean = false,
)
@@ -4,4 +4,6 @@ import kotlinx.coroutines.flow.Flow
interface NetworkMonitor {
val connectivityStateFlow: Flow<ConnectivityState>
fun checkPermissionsAndUpdateState()
}
@@ -17,18 +17,8 @@ sealed class TransportEvent {
val wifiDetectionMethod: AndroidNetworkMonitor.WifiDetectionMethod? = null,
) : TransportEvent()
data class LocationPermissionGranted(
val network: Network?,
val networkCapabilities: NetworkCapabilities?,
val wifiDetectionMethod: AndroidNetworkMonitor.WifiDetectionMethod?,
) : TransportEvent()
data class LocationServicesChanged(
val enabled: Boolean,
val network: Network?,
val networkCapabilities: NetworkCapabilities?,
val wifiDetectionMethod: AndroidNetworkMonitor.WifiDetectionMethod?,
) : TransportEvent()
data class Permissions(val permissions: com.zaneschepke.networkmonitor.Permissions) :
TransportEvent()
data object Unknown : TransportEvent()
}