diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 044109e..dce8bc1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3d9332b..be059cb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -56,6 +56,7 @@ + - \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt index 2cf4afe..d4283bc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt @@ -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 = "*/*" } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/ServiceModule.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/ServiceModule.kt index 59f6644..addf983 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/ServiceModule.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/ServiceModule.kt @@ -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 + + @Binds + @ServiceScoped + abstract fun provideEthernetService(ethernetService: EthernetService) : NetworkService } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt index ae743f0..8d85636 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt @@ -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!!) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/model/Settings.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/model/Settings.kt index dd67942..07879dc 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/model/Settings.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/model/Settings.kt @@ -12,4 +12,5 @@ data class Settings( @ColumnInfo(name = "trusted_network_ssids") var trustedNetworkSSIDs : MutableList = 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, ) \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt index 37e24d4..e6dcf4e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt @@ -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 + @Inject + lateinit var ethernetService: NetworkService + @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) diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/EthernetService.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/EthernetService.kt new file mode 100644 index 0000000..5450ca3 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/EthernetService.kt @@ -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(context, NetworkCapabilities.TRANSPORT_ETHERNET) { +} \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsManager.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsManager.kt index 8798319..3b02d19 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsManager.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsManager.kt @@ -46,9 +46,11 @@ object ShortcutsManager { ) } - fun removeTunnelShortcuts(context : Context, tunnelConfig : TunnelConfig) { - ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(tunnelConfig.id.toString() + APPEND_ON, - tunnelConfig.id.toString() + APPEND_OFF )) + 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) : Intent { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/CaptureActivityPortrait.java b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/CaptureActivityPortrait.java deleted file mode 100644 index f9770eb..0000000 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/CaptureActivityPortrait.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.zaneschepke.wireguardautotunnel.ui; - -import com.journeyapps.barcodescanner.CaptureActivity; - -public class CaptureActivityPortrait extends CaptureActivity { -} diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/CaptureActivityPortrait.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/CaptureActivityPortrait.kt new file mode 100644 index 0000000..9972857 --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/CaptureActivityPortrait.kt @@ -0,0 +1,5 @@ +package com.zaneschepke.wireguardautotunnel.ui + +import com.journeyapps.barcodescanner.CaptureActivity + +class CaptureActivityPortrait : CaptureActivity() \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt index b72f801..548355a 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt @@ -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) + } + } } } } diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt index bc38ed4..ccb75b6 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt @@ -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( diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt index 6afe299..770b47e 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt @@ -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) - val excludedApps = config.`interface`.excludedApplications - val includedApps = config.`interface`.includedApplications - if(excludedApps.isNullOrEmpty() && includedApps.isNullOrEmpty()) { - _allApplications.emit(true) - return + private suspend fun emitSplitTunnelConfiguration(config : Config) { + val excludedApps = config.`interface`.excludedApplications + val includedApps = config.`interface`.includedApplications + if (excludedApps.isNotEmpty() || includedApps.isNotEmpty()) { + emitTunnelAllApplicationsDisabled() + determineAppInclusionState(excludedApps, includedApps) + } else { + emitTunnelAllApplicationsEnabled() + } + } + + private suspend fun determineAppInclusionState(excludedApps : Set, includedApps : Set) { + 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) { + _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) } - if(excludedApps.isEmpty()) { - _include.emit(true) - _checkedPackages.emit(includedApps.toMutableStateList()) - } else { - _include.emit(false) - _checkedPackages.emit(excludedApps.toMutableStateList()) - } - _allApplications.emit(false) } } 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 { 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) - } - _tunnel.value?.copy( - name = _tunnelName.value, - wgQuick = wgQuick - )?.let { - tunnelRepo.save(it) - ShortcutsManager.createTunnelShortcuts(application, it) - val settings = settingsRepo.getAll() - if(settings.isEmpty()) { - return - } - val setting = settings[0] - if(setting.defaultTunnel != null) { - if(it.id == TunnelConfig.from(setting.defaultTunnel!!).id) { - settingsRepo.save(setting.copy( - defaultTunnel = it.toString() - )) - } + } else { + throw WgTunnelException("Wg quick string is null") + } + 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.isNotEmpty()) { + val setting = settings[0] + if(setting.defaultTunnel != null) { + if(tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) { + settingsRepo.save(setting.copy( + 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) + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt index c663ebd..bf19e36 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt @@ -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") diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt index c6f39ac..ec48f74 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt @@ -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(null) val tunnel get() = _tunnel.asStateFlow() - private val _tunnelName = MutableStateFlow("") + 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 - } catch (e : Exception) { + 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)) + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt index 58a8ae4..3a8195f 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt @@ -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)) { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt index aab12fe..31625af 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt @@ -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,88 +89,164 @@ 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) - val extension = getFileExtensionFromFileName(fileName) - if (extension != ".conf") { - launch { - showSnackBarMessage(application.resources.getString(R.string.file_extension_message)) - } - return@launch - } - val stream = application.applicationContext.contentResolver.openInputStream(uri) - stream ?: return@launch - val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8) - val config = Config.parse(bufferReader) - val tunnelName = getNameFromFileName(fileName) - saveTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString())) - stream.close() - } catch (_: BadConfigException) { - launch { - showSnackBarMessage(application.applicationContext.getString(R.string.bad_config)) - } - } + private fun validateFileExtension(fileName : String) { + val extension = getFileExtensionFromFileName(fileName) + if(extension != Constants.VALID_FILE_EXTENSION) { + throw WgTunnelException(application.getString(R.string.file_extension_message)) } } + 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) + addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString())) + stream.close() + } + } + + 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) { - _viewState.emit(_viewState.value.copy( - showSnackbarMessage = true, - snackbarMessage = message, - snackbarActionText = "Okay", - onSnackbarActionClick = { - viewModelScope.launch { - dismissSnackBar() + 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 = application.getString(R.string.okay), + onSnackbarActionClick = { + viewModelScope.launch { + dismissSnackBar() + } } - } - )) - delay(Constants.SNACKBAR_DELAY) - dismissSnackBar() + )) + delay(Constants.SNACKBAR_DELAY) + dismissSnackBar() + } } private suspend fun dismissSnackBar() { diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt index 3275815..d187479 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt @@ -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() diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt index 0a4ecde..4d9bd35 100644 --- a/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt @@ -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 diff --git a/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/WgTunnelException.kt b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/WgTunnelException.kt new file mode 100644 index 0000000..8a34abe --- /dev/null +++ b/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/WgTunnelException.kt @@ -0,0 +1,3 @@ +package com.zaneschepke.wireguardautotunnel.util + +class WgTunnelException(message: String) : Exception(message) \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index da5c9e0..cfbec0b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,7 +38,6 @@ Enter SSID Submit SSID [Interface] - Invalid QR code. Add tunnel from files File Open Add tunnel from QR code @@ -62,7 +61,6 @@ Public key Waiting for the Barcode UI module to be downloaded. Barcode module downloading. Try again. - Invalid QR code. Try again. Addresses DNS servers MTU @@ -92,5 +90,10 @@ Attempting connection.. VPN Starting wg-tunnel-db - Reading QR code + Scanning for QR + QR scan failed + None + Never + Failed to open file stream. + An unknown error occurred. \ No newline at end of file