feat: tunnel on ethernet

Adds settings feature to enable auto tunneling on ethernet connections.

Add alphabetical sorting for split tunneling packages

Fix bug where search was not searching by displayed label but by package name

Other refactoring and improvements.

Closes #29 Closes #27 Closes #28
This commit is contained in:
Zane Schepke 2023-09-24 07:40:17 -04:00
parent 1714618f0c
commit 7fbc51af4c
22 changed files with 452 additions and 174 deletions

View File

@ -14,8 +14,8 @@ android {
applicationId = "com.zaneschepke.wireguardautotunnel"
minSdk = 26
targetSdk = 34
versionCode = 30000
versionName = "3.0.0"
versionCode = 30001
versionName = "3.0.1"
multiDexEnabled = true

View File

@ -56,6 +56,7 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES"/>
</intent-filter>
</activity>
<activity
@ -117,8 +118,5 @@
</intent-filter>
</receiver>
<receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui"/>
</application>
</manifest>

View File

@ -4,8 +4,12 @@ object Constants {
const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L
const val VPN_STATISTIC_CHECK_INTERVAL = 10000L
const val SNACKBAR_DELAY = 3000L
const val TOGGLE_TUNNEL_DELAY = 1000L
const val TOGGLE_TUNNEL_DELAY = 500L
const val FADE_IN_ANIMATION_DURATION = 1000
const val SLIDE_IN_ANIMATION_DURATION = 500
const val SLIDE_IN_TRANSITION_OFFSET = 1000
const val VALID_FILE_EXTENSION = ".conf"
const val URI_CONTENT_SCHEME = "content"
const val URI_PACKAGE_SCHEME = "package"
const val ALLOWED_FILE_TYPES = "*/*"
}

View File

@ -1,5 +1,6 @@
package com.zaneschepke.wireguardautotunnel.module
import com.zaneschepke.wireguardautotunnel.service.network.EthernetService
import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
import com.zaneschepke.wireguardautotunnel.service.network.WifiService
@ -26,4 +27,8 @@ abstract class ServiceModule {
@Binds
@ServiceScoped
abstract fun provideMobileDataService(mobileDataService : MobileDataService) : NetworkService<MobileDataService>
@Binds
@ServiceScoped
abstract fun provideEthernetService(ethernetService: EthernetService) : NetworkService<EthernetService>
}

View File

@ -23,7 +23,7 @@ class BootReceiver : BroadcastReceiver() {
CoroutineScope(Dispatchers.IO).launch {
try {
val settings = settingsRepo.getAll()
if (!settings.isNullOrEmpty()) {
if (settings.isNotEmpty()) {
val setting = settings.first()
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
ServiceManager.startWatcherService(context, setting.defaultTunnel!!)

View File

@ -12,4 +12,5 @@ data class Settings(
@ColumnInfo(name = "trusted_network_ssids") var trustedNetworkSSIDs : MutableList<String> = mutableListOf(),
@ColumnInfo(name = "default_tunnel") var defaultTunnel : String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled : Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled : Boolean = false,
)

View File

@ -12,6 +12,7 @@ import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.service.network.EthernetService
import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
import com.zaneschepke.wireguardautotunnel.service.network.NetworkStatus
@ -38,6 +39,9 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
@Inject
lateinit var mobileDataService : NetworkService<MobileDataService>
@Inject
lateinit var ethernetService: NetworkService<EthernetService>
@Inject
lateinit var settingsRepo: SettingsDoa
@ -48,6 +52,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
lateinit var vpnService : VpnService
private var isWifiConnected = false;
private var isEthernetConnected = false;
private var isMobileDataConnected = false;
private var currentNetworkSSID = "";
@ -142,6 +147,11 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
watchForMobileDataConnectivityChanges()
}
}
if(setting.isTunnelOnEthernetEnabled) {
launch {
watchForEthernetConnectivityChanges()
}
}
launch {
manageVpn()
}
@ -167,6 +177,25 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
}
}
private suspend fun watchForEthernetConnectivityChanges() {
ethernetService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Ethernet connection")
isEthernetConnected = true
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Ethernet capabilities changed")
isEthernetConnected = true
}
is NetworkStatus.Unavailable -> {
isEthernetConnected = false
Timber.d("Lost Ethernet connection")
}
}
}
}
private suspend fun watchForWifiConnectivityChanges() {
wifiService.networkStatus.collect {
when (it) {
@ -189,20 +218,23 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
private suspend fun manageVpn() {
while(true) {
if(setting.isTunnelOnMobileDataEnabled &&
if(isEthernetConnected && setting.isTunnelOnEthernetEnabled && vpnService.getState() == Tunnel.State.DOWN) {
ServiceManager.startVpnService(this, tunnelConfig)
}
if(!isEthernetConnected && setting.isTunnelOnMobileDataEnabled &&
!isWifiConnected &&
isMobileDataConnected
&& vpnService.getState() == Tunnel.State.DOWN) {
ServiceManager.startVpnService(this, tunnelConfig)
} else if(!setting.isTunnelOnMobileDataEnabled &&
} else if(!isEthernetConnected && !setting.isTunnelOnMobileDataEnabled &&
!isWifiConnected &&
vpnService.getState() == Tunnel.State.UP) {
ServiceManager.stopVpnService(this)
} else if(isWifiConnected &&
} else if(!isEthernetConnected && isWifiConnected &&
!setting.trustedNetworkSSIDs.contains(currentNetworkSSID) &&
(vpnService.getState() != Tunnel.State.UP)) {
ServiceManager.startVpnService(this, tunnelConfig)
} else if((isWifiConnected &&
} else if(!isEthernetConnected && (isWifiConnected &&
setting.trustedNetworkSSIDs.contains(currentNetworkSSID)) &&
(vpnService.getState() == Tunnel.State.UP)) {
ServiceManager.stopVpnService(this)

View File

@ -0,0 +1,10 @@
package com.zaneschepke.wireguardautotunnel.service.network
import android.content.Context
import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class EthernetService @Inject constructor(@ApplicationContext context: Context) :
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET) {
}

View File

@ -46,10 +46,12 @@ object ShortcutsManager {
)
}
fun removeTunnelShortcuts(context : Context, tunnelConfig : TunnelConfig) {
fun removeTunnelShortcuts(context : Context, tunnelConfig : TunnelConfig?) {
if(tunnelConfig != null) {
ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(tunnelConfig.id.toString() + APPEND_ON,
tunnelConfig.id.toString() + APPEND_OFF ))
}
}
private fun createTunnelOnIntent(context: Context, extras : Map<String,String>) : Intent {
return Intent(context, ShortcutsActivity::class.java).also {

View File

@ -1,6 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui;
import com.journeyapps.barcodescanner.CaptureActivity;
public class CaptureActivityPortrait extends CaptureActivity {
}

View File

@ -0,0 +1,5 @@
package com.zaneschepke.wireguardautotunnel.ui
import com.journeyapps.barcodescanner.CaptureActivity
class CaptureActivityPortrait : CaptureActivity()

View File

@ -44,6 +44,7 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
import java.lang.IllegalStateException
@ -101,7 +102,7 @@ class MainActivity : AppCompatActivity() {
}
false
} else -> {
false;
false
}
}
} else {
@ -131,8 +132,8 @@ class MainActivity : AppCompatActivity() {
val intentSettings =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data =
Uri.fromParts("package", this.packageName, null)
startActivity(intentSettings);
Uri.fromParts(Constants.URI_PACKAGE_SCHEME, this.packageName, null)
startActivity(intentSettings)
},
message = getString(R.string.notification_permission_required),
getString(R.string.open_settings)
@ -190,10 +191,19 @@ class MainActivity : AppCompatActivity() {
}) { SupportScreen(padding = padding, focusRequester) }
composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}) { ConfigScreen(padding = padding, navController = navController, id = it.arguments?.getString("id"), focusRequester = focusRequester)}
}) {
val id = it.arguments?.getString("id")
if(!id.isNullOrBlank()) {
ConfigScreen(padding = padding, navController = navController, id = id, focusRequester = focusRequester)}
}
composable("${Routes.Detail.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}) { DetailScreen(padding = padding, id = it.arguments?.getString("id")) }
}) {
val id = it.arguments?.getString("id")
if(!id.isNullOrBlank()) {
DetailScreen(padding = padding, id = id)
}
}
}
}
}

View File

@ -24,6 +24,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
@ -53,7 +54,7 @@ fun ConfigScreen(
padding: PaddingValues,
focusRequester: FocusRequester,
navController: NavController,
id : String?
id : String
) {
val context = LocalContext.current
@ -67,11 +68,12 @@ fun ConfigScreen(
val checkedPackages by viewModel.checkedPackages.collectAsStateWithLifecycle()
val include by viewModel.include.collectAsStateWithLifecycle()
val allApplications by viewModel.allApplications.collectAsStateWithLifecycle()
val sortedPackages = remember(packages) {
packages.sortedBy { viewModel.getPackageLabel(it) }
}
LaunchedEffect(Unit) {
viewModel.getTunnelById(id)
viewModel.emitQueriedPackages("")
viewModel.emitCurrentPackageConfigurations(id)
viewModel.emitScreenData(id)
}
if(tunnel != null) {
@ -174,7 +176,7 @@ fun ConfigScreen(
SearchBar(viewModel::emitQueriedPackages);
}
}
items(packages) { pack ->
items(sortedPackages, key = { it.packageName }) { pack ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
@ -200,8 +202,7 @@ fun ConfigScreen(
)
}
Text(
pack.applicationInfo.loadLabel(context.packageManager)
.toString(), modifier = Modifier.padding(5.dp)
viewModel.getPackageLabel(pack), modifier = Modifier.padding(5.dp)
)
}
Checkbox(

View File

@ -9,11 +9,14 @@ import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsManager
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@ -41,24 +44,37 @@ class ConfigViewModel @Inject constructor(private val application : Application,
private val _allApplications = MutableStateFlow(true)
val allApplications get() = _allApplications.asStateFlow()
suspend fun getTunnelById(id : String?) : TunnelConfig? {
return try {
if(id != null) {
val config = tunnelRepo.getById(id.toLong())
if (config != null) {
_tunnel.emit(config)
_tunnelName.emit(config.name)
fun emitScreenData(id : String) {
viewModelScope.launch(Dispatchers.IO) {
val tunnelConfig = getTunnelConfigById(id);
emitTunnelConfig(tunnelConfig);
emitTunnelConfigName(tunnelConfig?.name)
emitQueriedPackages("")
emitCurrentPackageConfigurations(id)
}
}
}
return config
}
return null
private suspend fun getTunnelConfigById(id : String) : TunnelConfig? {
return try {
tunnelRepo.getById(id.toLong())
} catch (e : Exception) {
Timber.e(e.message)
null
}
}
private suspend fun emitTunnelConfig(tunnelConfig: TunnelConfig?) {
if(tunnelConfig != null) {
_tunnel.emit(tunnelConfig)
}
}
private suspend fun emitTunnelConfigName(name : String?) {
if(name != null) {
_tunnelName.emit(name)
}
}
fun onTunnelNameChange(name : String) {
_tunnelName.value = name
}
@ -78,35 +94,71 @@ class ConfigViewModel @Inject constructor(private val application : Application,
_checkedPackages.value.remove(packageName)
}
suspend fun emitCurrentPackageConfigurations(id : String?) {
val tunnelConfig = getTunnelById(id)
if(tunnelConfig != null) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
private suspend fun emitSplitTunnelConfiguration(config : Config) {
val excludedApps = config.`interface`.excludedApplications
val includedApps = config.`interface`.includedApplications
if(excludedApps.isNullOrEmpty() && includedApps.isNullOrEmpty()) {
_allApplications.emit(true)
return
}
if(excludedApps.isEmpty()) {
_include.emit(true)
_checkedPackages.emit(includedApps.toMutableStateList())
if (excludedApps.isNotEmpty() || includedApps.isNotEmpty()) {
emitTunnelAllApplicationsDisabled()
determineAppInclusionState(excludedApps, includedApps)
} else {
_include.emit(false)
_checkedPackages.emit(excludedApps.toMutableStateList())
emitTunnelAllApplicationsEnabled()
}
}
private suspend fun determineAppInclusionState(excludedApps : Set<String>, includedApps : Set<String>) {
if (excludedApps.isEmpty()) {
emitIncludedAppsExist()
emitCheckedApps(includedApps)
} else {
emitExcludedAppsExist()
emitCheckedApps(excludedApps)
}
}
private suspend fun emitIncludedAppsExist() {
_include.emit(true)
}
private suspend fun emitExcludedAppsExist() {
_include.emit(false)
}
private suspend fun emitCheckedApps(apps : Set<String>) {
_checkedPackages.emit(apps.toMutableStateList())
}
private suspend fun emitTunnelAllApplicationsEnabled() {
_allApplications.emit(true)
}
private suspend fun emitTunnelAllApplicationsDisabled() {
_allApplications.emit(false)
}
private fun emitCurrentPackageConfigurations(id : String) {
viewModelScope.launch(Dispatchers.IO) {
val tunnelConfig = getTunnelConfigById(id)
if (tunnelConfig != null) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
emitSplitTunnelConfiguration(config)
}
}
}
fun emitQueriedPackages(query : String) {
viewModelScope.launch {
_packages.emit(getAllInternetCapablePackages().filter {
it.packageName.contains(query)
})
viewModelScope.launch(Dispatchers.IO) {
val packages = getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_packages.emit(packages)
}
}
fun getPackageLabel(packageInfo : PackageInfo) : String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
}
private fun getAllInternetCapablePackages() : List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
}
@ -119,39 +171,77 @@ class ConfigViewModel @Inject constructor(private val application : Application,
}
}
suspend fun onSaveAllChanges() {
if(_tunnel.value != null) {
ShortcutsManager.removeTunnelShortcuts(application, _tunnel.value!!)
private fun removeTunnelShortcuts(tunnelConfig: TunnelConfig?) {
if(tunnelConfig != null) {
ShortcutsManager.removeTunnelShortcuts(application, tunnelConfig)
}
}
private fun isAllApplicationsEnabled() : Boolean {
return _allApplications.value
}
private fun isIncludeApplicationsEnabled() : Boolean {
return _include.value
}
private fun updateQuickStringWithSelectedPackages() : String {
var wgQuick = _tunnel.value?.wgQuick
if(wgQuick != null) {
wgQuick = if(_include.value) {
wgQuick = if(isAllApplicationsEnabled()) {
TunnelConfig.clearAllApplicationsFromConfig(wgQuick)
} else if(isIncludeApplicationsEnabled()) {
TunnelConfig.setIncludedApplicationsOnQuick(_checkedPackages.value, wgQuick)
} else {
TunnelConfig.setExcludedApplicationsOnQuick(_checkedPackages.value, wgQuick)
}
if(_allApplications.value) {
wgQuick = TunnelConfig.clearAllApplicationsFromConfig(wgQuick)
} else {
throw WgTunnelException("Wg quick string is null")
}
_tunnel.value?.copy(
name = _tunnelName.value,
wgQuick = wgQuick
)?.let {
tunnelRepo.save(it)
ShortcutsManager.createTunnelShortcuts(application, it)
return wgQuick;
}
private suspend fun saveConfig(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig)
}
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) {
if(tunnelConfig != null) {
saveConfig(tunnelConfig)
addTunnelShortcuts(tunnelConfig)
updateSettingsDefaultTunnel(tunnelConfig)
}
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepo.getAll()
if(settings.isEmpty()) {
return
}
if(settings.isNotEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null) {
if(it.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
if(tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
settingsRepo.save(setting.copy(
defaultTunnel = it.toString()
defaultTunnel = tunnelConfig.toString()
))
}
}
}
}
private fun addTunnelShortcuts(tunnelConfig: TunnelConfig) {
ShortcutsManager.createTunnelShortcuts(application, tunnelConfig)
}
suspend fun onSaveAllChanges() {
try {
removeTunnelShortcuts(_tunnel.value)
val wgQuick = updateQuickStringWithSelectedPackages()
val tunnelConfig = _tunnel.value?.copy(
name = _tunnelName.value,
wgQuick = wgQuick
)
updateTunnelConfig(tunnelConfig)
} catch (e : Exception) {
Timber.e(e.message)
}
}
}

View File

@ -36,7 +36,7 @@ import java.time.Instant
fun DetailScreen(
viewModel: DetailViewModel = hiltViewModel(),
padding: PaddingValues,
id : String?
id : String
) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
@ -47,15 +47,17 @@ fun DetailScreen(
LaunchedEffect(Unit) {
viewModel.getTunnelById(id)
viewModel.emitConfig(id)
}
if(tunnel != null) {
if(null != tunnel) {
val interfaceKey = tunnel?.`interface`?.keyPair?.publicKey?.toBase64().toString()
val addresses = tunnel?.`interface`?.addresses!!.joinToString()
val dnsServers = tunnel?.`interface`?.dnsServers!!.joinToString()
val optionalMtu = tunnel?.`interface`?.mtu
val mtu = if(optionalMtu?.isPresent == true) optionalMtu.get().toString() else "None"
val mtu = if(optionalMtu?.isPresent == true) optionalMtu.get().toString() else stringResource(
id = R.string.none
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
@ -97,7 +99,9 @@ fun DetailScreen(
tunnel?.peers?.forEach{
val peerKey = it.publicKey.toBase64().toString()
val allowedIps = it.allowedIps.joinToString()
val endpoint = if(it.endpoint.isPresent) it.endpoint.get().toString() else "None"
val endpoint = if(it.endpoint.isPresent) it.endpoint.get().toString() else stringResource(
id = R.string.none
)
Text(stringResource(R.string.peer), fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(stringResource(R.string.public_key), fontStyle = FontStyle.Italic)
Text(text = peerKey, modifier = Modifier.clickable {
@ -123,7 +127,7 @@ fun DetailScreen(
val handshakeEpoch = lastHandshake[it.publicKey]
if(handshakeEpoch != null) {
if(handshakeEpoch == 0L) {
Text("Never")
Text(stringResource(id = R.string.never))
} else {
val time = Instant.ofEpochMilli(handshakeEpoch)
Text("${Duration.between(time, Instant.now()).seconds} seconds ago")

View File

@ -1,45 +1,45 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class DetailViewModel @Inject constructor(private val tunnelRepo : TunnelConfigDao, private val vpnService : VpnService
) : ViewModel() {
private val _tunnel = MutableStateFlow<Config?>(null)
val tunnel get() = _tunnel.asStateFlow()
private val _tunnelName = MutableStateFlow<String>("")
private val _tunnelName = MutableStateFlow("")
val tunnelName = _tunnelName.asStateFlow()
val tunnelStats get() = vpnService.statistics
val lastHandshake get() = vpnService.lastHandshake
private var config : TunnelConfig? = null
suspend fun getTunnelById(id : String?) : TunnelConfig? {
private suspend fun getTunnelConfigById(id: String): TunnelConfig? {
return try {
if(id != null) {
config = tunnelRepo.getById(id.toLong())
if (config != null) {
_tunnel.emit(TunnelConfig.configFromQuick(config!!.wgQuick))
_tunnelName.emit(config!!.name)
}
return config
}
return null
tunnelRepo.getById(id.toLong())
} catch (e: Exception) {
Timber.e(e.message)
null
}
}
fun emitConfig(id: String) {
viewModelScope.launch(Dispatchers.IO) {
val tunnelConfig = getTunnelConfigById(id)
if(tunnelConfig != null) {
_tunnel.emit(TunnelConfig.configFromQuick(tunnelConfig.wgQuick))
}
}
}
}

View File

@ -73,11 +73,12 @@ import androidx.navigation.NavController
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed
@ -146,7 +147,13 @@ fun MainScreen(
val scanLauncher = rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = { result -> viewModel.onTunnelQrResult(result.contents) }
onResult = {
try {
viewModel.onTunnelQrResult(it.contents)
} catch (e : Exception) {
viewModel.showSnackBarMessage(context.getString(R.string.qr_result_failed))
}
}
)
Scaffold(
@ -205,7 +212,7 @@ fun MainScreen(
showBottomSheet = false
val fileSelectionIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "*/*"
type = Constants.ALLOWED_FILE_TYPES
}
pickFileLauncher.launch(fileSelectionIntent)
}
@ -232,7 +239,7 @@ fun MainScreen(
scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(context.getString(R.string.scanning_qr))
scanOptions.setBeepEnabled(false)
scanOptions.captureActivity = CaptureActivityPortrait().javaClass
scanOptions.captureActivity = CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
}
}
@ -264,7 +271,7 @@ fun MainScreen(
.nestedScroll(nestedScrollConnection),
) {
items(tunnels, key = { tunnel -> tunnel.id }) {tunnel ->
val focusRequester = FocusRequester();
val focusRequester = FocusRequester()
RowListItem(leadingIcon = Icons.Rounded.Circle,
leadingIconColor = if (tunnelName == tunnel.name) when (handshakeStatus) {
HandshakeStatus.HEALTHY -> mint
@ -281,7 +288,7 @@ fun MainScreen(
return@RowListItem
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel;
selectedTunnel = tunnel
},
onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {

View File

@ -1,8 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel
@ -18,18 +18,21 @@ import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.ui.ViewState
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.InputStream
import javax.inject.Inject
@ -86,80 +89,155 @@ class MainViewModel @Inject constructor(private val application : Application,
}
}
fun onTunnelStart(tunnelConfig : TunnelConfig) = viewModelScope.launch {
fun onTunnelStart(tunnelConfig : TunnelConfig) {
viewModelScope.launch {
stopActiveTunnel()
startTunnel(tunnelConfig)
}
}
private fun startTunnel(tunnelConfig: TunnelConfig) {
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
}
private suspend fun stopActiveTunnel() {
if(ServiceManager.getServiceState(application.applicationContext,
WireGuardTunnelService::class.java, ) == ServiceState.STARTED) {
onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY)
}
}
fun onTunnelStop() {
ServiceManager.stopVpnService(application.applicationContext)
}
private fun validateConfigString(config : String) {
if(!config.contains(application.getString(R.string.config_validation))) {
throw WgTunnelException(application.getString(R.string.config_validation))
}
}
fun onTunnelQrResult(result : String) {
viewModelScope.launch(Dispatchers.IO) {
if(result.contains(application.resources.getString(R.string.config_validation))) {
try {
validateConfigString(result)
val tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
saveTunnel(tunnelConfig)
} else {
showSnackBarMessage(application.resources.getString(R.string.barcode_error))
addTunnel(tunnelConfig)
} catch (e : WgTunnelException) {
showSnackBarMessage(e.message ?: application.getString(R.string.unknown_error_message))
}
}
}
fun onTunnelFileSelected(uri : Uri) {
viewModelScope.launch(Dispatchers.IO) {
try {
val fileName = getFileName(application.applicationContext, uri)
private fun validateFileExtension(fileName : String) {
val extension = getFileExtensionFromFileName(fileName)
if (extension != ".conf") {
launch {
showSnackBarMessage(application.resources.getString(R.string.file_extension_message))
if(extension != Constants.VALID_FILE_EXTENSION) {
throw WgTunnelException(application.getString(R.string.file_extension_message))
}
return@launch
}
val stream = application.applicationContext.contentResolver.openInputStream(uri)
stream ?: return@launch
private fun saveTunnelConfigFromStream(stream : InputStream, fileName : String) {
viewModelScope.launch(Dispatchers.IO) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
saveTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
stream.close()
} catch (_: BadConfigException) {
launch {
showSnackBarMessage(application.applicationContext.getString(R.string.bad_config))
}
}
private fun getInputStreamFromUri(uri: Uri): InputStream {
return application.applicationContext.contentResolver.openInputStream(uri)
?: throw WgTunnelException(application.getString(R.string.stream_failed))
}
fun onTunnelFileSelected(uri : Uri) {
try {
val fileName = getFileName(application.applicationContext, uri)
validateFileExtension(fileName)
val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, fileName)
} catch (e : Exception) {
showExceptionMessage(e)
}
}
private fun showExceptionMessage(e : Exception) {
when(e) {
is BadConfigException -> {
showSnackBarMessage(application.getString(R.string.bad_config))
}
is WgTunnelException -> {
showSnackBarMessage(e.message ?: application.getString(R.string.unknown_error_message))
}
else -> showSnackBarMessage(application.getString(R.string.unknown_error_message))
}
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig)
createTunnelAppShortcuts(tunnelConfig)
}
private suspend fun saveTunnel(tunnelConfig : TunnelConfig) {
tunnelRepo.save(tunnelConfig)
}
private fun createTunnelAppShortcuts(tunnelConfig: TunnelConfig) {
ShortcutsManager.createTunnelShortcuts(application.applicationContext, tunnelConfig)
}
@SuppressLint("Range")
private fun getFileName(context: Context, uri: Uri): String {
if (uri.scheme == "content") {
val cursor = try {
context.contentResolver.query(uri, null, null, null, null)
} catch (e : Exception) {
Timber.d("Exception getting config name")
null
}
cursor ?: return NumberUtils.generateRandomTunnelName()
private fun getFileNameByCursor(context: Context, uri: Uri) : String {
val cursor = context.contentResolver.query(uri, null, null, null, null)
if(cursor != null) {
cursor.use {
if(cursor.moveToFirst()) {
return cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
return getDisplayNameByCursor(it)
}
} else {
throw WgTunnelException("Failed to initialize cursor")
}
}
return NumberUtils.generateRandomTunnelName()
}
suspend fun showSnackBarMessage(message : String) {
private fun getDisplayNameColumnIndex(cursor: Cursor) : Int {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if(columnIndex == -1) {
throw WgTunnelException("Cursor out of bounds")
}
return columnIndex
}
private fun getDisplayNameByCursor(cursor: Cursor) : String {
if(cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
return cursor.getString(index)
} else {
throw WgTunnelException("Cursor failed to move to first")
}
}
private fun validateUriContentScheme(uri : Uri) {
if (uri.scheme != Constants.URI_CONTENT_SCHEME) {
throw WgTunnelException(application.getString(R.string.file_extension_message))
}
}
private fun getFileName(context: Context, uri: Uri): String {
validateUriContentScheme(uri)
return try {
getFileNameByCursor(context, uri)
} catch (_: Exception) {
NumberUtils.generateRandomTunnelName()
}
}
fun showSnackBarMessage(message : String) {
CoroutineScope(Dispatchers.IO).launch {
_viewState.emit(_viewState.value.copy(
showSnackbarMessage = true,
snackbarMessage = message,
snackbarActionText = "Okay",
snackbarActionText = application.getString(R.string.okay),
onSnackbarActionClick = {
viewModelScope.launch {
dismissSnackBar()
@ -169,6 +247,7 @@ class MainViewModel @Inject constructor(private val application : Application,
delay(Constants.SNACKBAR_DELAY)
dismissSnackBar()
}
}
private suspend fun dismissSnackBar() {
_viewState.emit(_viewState.value.copy(

View File

@ -392,6 +392,24 @@ fun SettingsScreen(
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(screenPadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Tunnel on Ethernet")
Switch(
enabled = !settings.isAutoTunnelEnabled,
checked = settings.isTunnelOnEthernetEnabled,
onCheckedChange = {
scope.launch {
viewModel.onToggleTunnelOnEthernet()
}
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()

View File

@ -122,6 +122,18 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
showSnackBarMessage(application.getString(R.string.select_tunnel_message))
}
}
suspend fun onToggleTunnelOnEthernet() {
if(_settings.value.defaultTunnel != null) {
_settings.emit(
_settings.value.copy(isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled)
)
settingsRepo.save(_settings.value)
} else {
showSnackBarMessage(application.getString(R.string.select_tunnel_message))
}
}
fun checkLocationServicesEnabled() : Boolean {
val locationManager =
application.getSystemService(Context.LOCATION_SERVICE) as LocationManager

View File

@ -0,0 +1,3 @@
package com.zaneschepke.wireguardautotunnel.util
class WgTunnelException(message: String) : Exception(message)

View File

@ -38,7 +38,6 @@
<string name="trusted_ssid_empty_description">Enter SSID</string>
<string name="trusted_ssid_value_description">Submit SSID</string>
<string name="config_validation">[Interface]</string>
<string name="invalid_qr">Invalid QR code.</string>
<string name="add_from_file">Add tunnel from files</string>
<string name="open_file">File Open</string>
<string name="add_from_qr">Add tunnel from QR code</string>
@ -62,7 +61,6 @@
<string name="public_key">Public key</string>
<string name="barcode_downloading">Waiting for the Barcode UI module to be downloaded.</string>
<string name="barcode_downloading_message">Barcode module downloading. Try again.</string>
<string name="barcode_error">Invalid QR code. Try again.</string>
<string name="addresses">Addresses</string>
<string name="dns_servers">DNS servers</string>
<string name="mtu">MTU</string>
@ -92,5 +90,10 @@
<string name="attempt_connection">Attempting connection..</string>
<string name="vpn_starting">VPN Starting</string>
<string name="db_name">wg-tunnel-db</string>
<string name="scanning_qr">Reading QR code</string>
<string name="scanning_qr">Scanning for QR</string>
<string name="qr_result_failed">QR scan failed</string>
<string name="none">None</string>
<string name="never">Never</string>
<string name="stream_failed">Failed to open file stream.</string>
<string name="unknown_error_message">An unknown error occurred.</string>
</resources>