diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 002020ad..ee9ca314 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -1,6 +1,6 @@ name: Run tests -on: [ push, pull_request ] +on: [ pull_request ] jobs: build: @@ -17,9 +17,9 @@ jobs: -x :ics-openvpn-main:buildCMakeRelWithDebInfo[x86] -x :ics-openvpn-main:configureCMakeRelWithDebInfo[x86_64] -x :ics-openvpn-main:buildCMakeRelWithDebInfo[x86_64] - -x :ics-openvpn-main:externalNativeBuildUiRelease - -x :ics-openvpn-main:packageUiReleaseResources - -x :ics-openvpn-main:compileUiReleaseJavaWithJavac + -x :ics-openvpn-main:externalNativeBuildUiOvpn23Release + -x :ics-openvpn-main:packageUiOvpn23ReleaseResources + -x :ics-openvpn-main:compileUiOvpn23ReleaseJavaWithJavac -x :ics-openvpn-main:configureCMakeDebug[x86_64] -x :ics-openvpn-main:buildCMakeDebug[x86_64] -x :ics-openvpn-main:buildCMakeDebug[arm64-v8a] @@ -29,14 +29,16 @@ jobs: matrix: api-level: [ 31 ] steps: - - name: Use Java 17 - run: echo "JAVA_HOME=${!JAVA_HOME_17_X64}" >> $GITHUB_ENV - - name: Checkout repository and submodules - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: submodules: recursive - + - name: Install Java 17 + uses: actions/setup-java@v3 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' # Based on https://github.com/actions/cache/blob/main/examples.md#java---gradle - name: Cache Gradle caches and wrapper uses: actions/cache@v3 diff --git a/app/build.gradle b/app/build.gradle index 77ca38b0..aa710135 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,15 +9,15 @@ plugins { } android { - compileSdk 33 + compileSdk 34 defaultConfig { applicationId "nl.eduvpn.app" minSdkVersion 21 - targetSdkVersion 31 + targetSdkVersion 34 versionCode 23 versionName "3.1.1" - ndkVersion "21.0.6113669" + ndkVersion "26.1.10909125" vectorDrawables.useSupportLibrary = true @@ -34,12 +34,12 @@ android { buildConfigField "String", "OAUTH_CLIENT_ID", "\"org.eduvpn.app.android\"" buildConfigField "String", "OAUTH_REDIRECT_URI", "\"org.eduvpn.app:/api/callback\"" buildConfigField "String", "OAUTH_SCOPE", "\"config\"" - buildConfigField "String", "CERTIFICATE_DISPLAY_NAME", "\"eduVPN for Android\"" manifestPlaceholders = [ 'redirectScheme': 'org.eduvpn.app' ] - missingDimensionStrategy 'implementation', 'ui' // Skeleton is no option for us because we need the log activity + missingDimensionStrategy 'implementation', 'ui' // Skeleton is no option for us because we need the log activity + missingDimensionStrategy 'ovpnimpl', 'ovpn23' // This excludes some pretty old ABIs such armeabi or mips. LibSodium-JNI still includes binaries for these, which // leads to some devices selecting these as the app ABI, but the OpenVPN library did not include a binary for these, @@ -77,7 +77,7 @@ android { } } - flavorDimensions "product" + flavorDimensions = ["product"] productFlavors { basic { @@ -88,6 +88,18 @@ android { dimension "product" applicationId "nl.eduvpn.app.dev" } + gov { + dimension "product" + applicationId "org.govvpn.app" + // API discovery is disabled, and only custom URLs can be entered. + buildConfigField "boolean", "API_DISCOVERY_ENABLED", "false" + buildConfigField "String", "OAUTH_CLIENT_ID", "\"org.govvpn.app.android\"" + buildConfigField "String", "OAUTH_REDIRECT_URI", "\"org.govvpn.app:/api/callback\"" + + manifestPlaceholders = [ + 'redirectScheme': 'org.govvpn.app.android' + ] + } home { dimension "product" applicationId "org.letsconnect_vpn.app" @@ -95,7 +107,6 @@ android { buildConfigField "boolean", "API_DISCOVERY_ENABLED", "false" buildConfigField "String", "OAUTH_CLIENT_ID", "\"org.letsconnect-vpn.app.android\"" buildConfigField "String", "OAUTH_REDIRECT_URI", "\"org.letsconnect-vpn.app:/api/callback\"" - buildConfigField "String", "CERTIFICATE_DISPLAY_NAME", "\"Let's Connect! for Android\"" manifestPlaceholders = [ 'redirectScheme': 'org.letsconnect-vpn.app' @@ -103,23 +114,17 @@ android { } } - lintOptions { - disable 'GradleDependency', // Gradle dependencies can be a bit outdated, since we prefer to use the same versions as in the VPN library - 'UnsafeNativeCodeLocation', // The OpenVPN .so files are put to a different place, as per the documentation - 'RtlSymmetry', 'RtlHardcoded', // No support for RTL as of now - 'MissingTranslation' // The OpenVPN library contains translations for a lot off languages. This app only has english, so it complains that we are missing translations for the other languages - } compileOptions { // Support Java 8+ on sdk < 24, also necessary for WireGuard coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = JavaVersion.VERSION_11 + jvmTarget = JavaVersion.VERSION_17 } kapt { @@ -132,22 +137,26 @@ android { option("-Werror") } } - packagingOptions { - // Created by kotlinx-coroutines-core for debugging - exclude "DebugProbesKt.bin" + resources { + excludes += ['DebugProbesKt.bin'] + } + } + + namespace 'nl.eduvpn.app' + lint { + disable 'GradleDependency', 'UnsafeNativeCodeLocation', 'RtlSymmetry', 'RtlHardcoded', 'MissingTranslation' } } -def daggerVersion = "2.44" -def okHttpVersion = "4.10.0" +def daggerVersion = "2.48" def lifecycleVersion = "2.2.0" dependencies { // OpenVPN library implementation project(path: ':ics-openvpn-main') // WireGuard VPN library - implementation 'com.wireguard.android:tunnel:1.0.20230427' + implementation 'com.wireguard.android:tunnel:1.0.20230706' // eduVPN common library written in Go, stores all data and does the communication with the servers implementation project(path: ':common') // Please try to stay in sync with the versions used in the ics-openvpn module diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 36173d70..e28a6787 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,17 +1,5 @@ -# Add project specific ProGuard rules here. -# By default, the flags in this file are appended to flags specified -# in /home/fkooman/Android/Sdk/tools/proguard/proguard-android.txt -# You can edit the include path and order by changing the proguardFiles -# directive in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# Add any project specific keep options here: - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +-dontwarn com.google.errorprone.annotations.* +-dontwarn org.bouncycastle.jsse.** +-dontwarn org.conscrypt.* +-dontwarn org.openjsse.javax.net.ssl.* +-dontwarn org.openjsse.net.ssl.* diff --git a/app/src/gov/res/drawable-hdpi/logo_black.png b/app/src/gov/res/drawable-hdpi/logo_black.png new file mode 100644 index 00000000..8cd1cdaf Binary files /dev/null and b/app/src/gov/res/drawable-hdpi/logo_black.png differ diff --git a/app/src/gov/res/drawable-mdpi/logo_black.png b/app/src/gov/res/drawable-mdpi/logo_black.png new file mode 100644 index 00000000..e54c4a5c Binary files /dev/null and b/app/src/gov/res/drawable-mdpi/logo_black.png differ diff --git a/app/src/gov/res/drawable-xhdpi/logo_black.png b/app/src/gov/res/drawable-xhdpi/logo_black.png new file mode 100644 index 00000000..240dd822 Binary files /dev/null and b/app/src/gov/res/drawable-xhdpi/logo_black.png differ diff --git a/app/src/gov/res/drawable-xxhdpi/logo_black.png b/app/src/gov/res/drawable-xxhdpi/logo_black.png new file mode 100644 index 00000000..3acc6e80 Binary files /dev/null and b/app/src/gov/res/drawable-xxhdpi/logo_black.png differ diff --git a/app/src/gov/res/drawable-xxxhdpi/logo_black.png b/app/src/gov/res/drawable-xxxhdpi/logo_black.png new file mode 100644 index 00000000..ed2651b9 Binary files /dev/null and b/app/src/gov/res/drawable-xxxhdpi/logo_black.png differ diff --git a/app/src/gov/res/drawable/ic_application_logo.xml b/app/src/gov/res/drawable/ic_application_logo.xml new file mode 100644 index 00000000..eb028ce9 --- /dev/null +++ b/app/src/gov/res/drawable/ic_application_logo.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/app/src/gov/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/gov/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/app/src/gov/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/gov/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/gov/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/app/src/gov/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/gov/res/mipmap-hdpi/ic_launcher.webp b/app/src/gov/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..901ebb46 Binary files /dev/null and b/app/src/gov/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/gov/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/gov/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..11ffd5ed Binary files /dev/null and b/app/src/gov/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/gov/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/gov/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..933a9299 Binary files /dev/null and b/app/src/gov/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/gov/res/mipmap-mdpi/ic_launcher.webp b/app/src/gov/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..04f58078 Binary files /dev/null and b/app/src/gov/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/gov/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/gov/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..48e5ea20 Binary files /dev/null and b/app/src/gov/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/gov/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/gov/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..e5cdc73f Binary files /dev/null and b/app/src/gov/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/gov/res/mipmap-xhdpi/ic_launcher.webp b/app/src/gov/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..8020e4f5 Binary files /dev/null and b/app/src/gov/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/gov/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/gov/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..7c8d65a7 Binary files /dev/null and b/app/src/gov/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/gov/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/gov/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..957ae6b0 Binary files /dev/null and b/app/src/gov/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/gov/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/gov/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..91bc0c96 Binary files /dev/null and b/app/src/gov/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/gov/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/gov/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..625be23a Binary files /dev/null and b/app/src/gov/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/gov/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/gov/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..80b4e503 Binary files /dev/null and b/app/src/gov/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/gov/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/gov/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..80dfd9a2 Binary files /dev/null and b/app/src/gov/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/gov/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/gov/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..2de12545 Binary files /dev/null and b/app/src/gov/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/gov/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/gov/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..badc9f68 Binary files /dev/null and b/app/src/gov/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/gov/res/values/colors.xml b/app/src/gov/res/values/colors.xml new file mode 100644 index 00000000..1bf8f0c5 --- /dev/null +++ b/app/src/gov/res/values/colors.xml @@ -0,0 +1,42 @@ + + + + + #A0A0A0 + #A0A0A0 + #808080 + #a0a0a0 + #D0D0D0 + #303030 + #FFFFFF + #808080 + #22B9FF + #f0f0f0 + #202020 + #C8C8C8 + #FFFFFF + #CF0000 + #808080 + #00000000 + #000000 + #808080 + #A0A0A0 + #303030 + #505050 + #606060 + diff --git a/app/src/gov/res/values/ic_launcher_background.xml b/app/src/gov/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..beab31f7 --- /dev/null +++ b/app/src/gov/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #000000 + \ No newline at end of file diff --git a/app/src/gov/res/values/strings.xml b/app/src/gov/res/values/strings.xml new file mode 100644 index 00000000..1f49b610 --- /dev/null +++ b/app/src/gov/res/values/strings.xml @@ -0,0 +1,21 @@ + + + + govVPN + govVPN + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4e8fade6..913ed012 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,13 +1,15 @@ + xmlns:tools="http://schemas.android.com/tools"> + + + + + + @@ -153,6 +163,11 @@ + + () { + + override val layout = R.layout.activity_api_logs + + @Inject + protected lateinit var viewModelFactory: ViewModelFactory + + private val viewModel by viewModels { viewModelFactory } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + EduVPNApplication.get(this).component().inject(this) + } + + override fun onResume() { + super.onResume() + binding.logContents.text = viewModel.getLogFileContents() + } +} \ No newline at end of file diff --git a/app/src/main/java/nl/eduvpn/app/MainActivity.kt b/app/src/main/java/nl/eduvpn/app/MainActivity.kt index 6bd9ebc4..28945ada 100644 --- a/app/src/main/java/nl/eduvpn/app/MainActivity.kt +++ b/app/src/main/java/nl/eduvpn/app/MainActivity.kt @@ -120,12 +120,8 @@ class MainActivity : BaseActivity() { } is MainViewModel.MainParentAction.ConnectWithConfig -> { - viewModel.parseConfigAndStartConnection(this, parentAction.config) - val currentFragment = - supportFragmentManager.findFragmentById(R.id.content_frame) - if (currentFragment !is ConnectionStatusFragment) { - openFragment(ConnectionStatusFragment(), false) - } + viewModel.parseConfigAndStartConnection(this, parentAction.config, parentAction.forceTCP) + openFragment(ConnectionStatusFragment(), false) } is MainViewModel.MainParentAction.ShowCountriesDialog -> { @@ -182,7 +178,7 @@ class MainActivity : BaseActivity() { } fun selectCountry(cookie: Int? = null) { - viewModel.getCountryList(this, cookie) + viewModel.getCountryList(cookie) } private fun openLink(oAuthUrl: String) { diff --git a/app/src/main/java/nl/eduvpn/app/fragment/SettingsFragment.kt b/app/src/main/java/nl/eduvpn/app/fragment/SettingsFragment.kt index d376b866..caa7ca55 100644 --- a/app/src/main/java/nl/eduvpn/app/fragment/SettingsFragment.kt +++ b/app/src/main/java/nl/eduvpn/app/fragment/SettingsFragment.kt @@ -21,7 +21,10 @@ import android.content.Intent import android.os.Bundle import android.view.View import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import androidx.fragment.app.viewModels import de.blinkt.openvpn.activities.LogWindow +import nl.eduvpn.app.ApiLogsActivity import nl.eduvpn.app.BuildConfig import nl.eduvpn.app.EduVPNApplication import nl.eduvpn.app.LicenseActivity @@ -32,6 +35,7 @@ import nl.eduvpn.app.databinding.FragmentSettingsBinding import nl.eduvpn.app.entity.Settings import nl.eduvpn.app.service.HistoryService import nl.eduvpn.app.service.PreferencesService +import nl.eduvpn.app.viewmodel.SettingsViewModel import javax.inject.Inject /** @@ -39,18 +43,15 @@ import javax.inject.Inject * Created by Daniel Zolnai on 2016-10-22. */ class SettingsFragment : BaseFragment() { - @Inject - lateinit var preferencesService: PreferencesService - @Inject - lateinit var historyService: HistoryService + val viewModel by viewModels{ viewModelFactory } override val layout = R.layout.fragment_settings override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) EduVPNApplication.get(view.context).component().inject(this) - val originalSettings = preferencesService.getAppSettings() + val originalSettings = viewModel.appSettings binding.useCustomTabsSwitch.isChecked = originalSettings.useCustomTabs() binding.forceTcpSwitch.isChecked = originalSettings.forceTcp() binding.useCustomTabsSwitch.setOnClickListener { saveSettings() } @@ -64,10 +65,15 @@ class SettingsFragment : BaseFragment() { ) } binding.resetDataButton.setOnClickListener { onResetDataClicked() } - binding.viewLogButton.setOnClickListener { + binding.viewOpenvpnLogsButton.setOnClickListener { val intent = Intent(activity, LogWindow::class.java) startActivity(intent) } + binding.viewApiLogsButton.setOnClickListener { + val intent = Intent(activity, ApiLogsActivity::class.java) + startActivity(intent) + } + binding.viewApiLogsContainer.isVisible = viewModel.apiLogFile != null if (!BuildConfig.API_DISCOVERY_ENABLED) { binding.resetDataSeparator.visibility = View.GONE binding.resetAppDataContainer.visibility = View.GONE @@ -75,14 +81,14 @@ class SettingsFragment : BaseFragment() { } private fun onResetDataClicked() { - if (historyService.addedServers?.hasServers() == true) { + if (viewModel.hasAddedServers) { val resetDataDialog = AlertDialog.Builder(requireContext()) .setTitle(R.string.reset_data_dialog_title) .setMessage(R.string.reset_data_dialog_message) .setPositiveButton(R.string.reset_data_dialog_yes) { dialog: DialogInterface, _: Int -> dialog.dismiss() try { - historyService.removeOrganizationData() + viewModel.removeOrganizationData() } catch (ex: Exception) { AlertDialog.Builder(requireContext()) .setTitle(R.string.unexpected_error) @@ -109,6 +115,6 @@ class SettingsFragment : BaseFragment() { private fun saveSettings() { val useCustomTabs = binding.useCustomTabsSwitch.isChecked val forceTcp = binding.forceTcpSwitch.isChecked - preferencesService.storeAppSettings(Settings(useCustomTabs, forceTcp)) + viewModel.storeAppSettings(Settings(useCustomTabs, forceTcp)) } } \ No newline at end of file diff --git a/app/src/main/java/nl/eduvpn/app/inject/EduVPNComponent.kt b/app/src/main/java/nl/eduvpn/app/inject/EduVPNComponent.kt index 39db792c..a829d7b3 100644 --- a/app/src/main/java/nl/eduvpn/app/inject/EduVPNComponent.kt +++ b/app/src/main/java/nl/eduvpn/app/inject/EduVPNComponent.kt @@ -17,6 +17,7 @@ package nl.eduvpn.app.inject import dagger.Component +import nl.eduvpn.app.ApiLogsActivity import nl.eduvpn.app.CertExpiredBroadcastReceiver import nl.eduvpn.app.DisconnectVPNBroadcastReceiver import nl.eduvpn.app.EduVPNApplication @@ -41,6 +42,7 @@ interface EduVPNComponent { fun inject(organizationSelectionFragment: OrganizationSelectionFragment) fun inject(mainActivity: MainActivity) + fun inject(apiLogsActivity: ApiLogsActivity) fun inject(connectionStatusFragment: ConnectionStatusFragment) fun inject(homeFragment: ProfileSelectionFragment) fun inject(settingsFragment: SettingsFragment) diff --git a/app/src/main/java/nl/eduvpn/app/inject/ViewModelModule.kt b/app/src/main/java/nl/eduvpn/app/inject/ViewModelModule.kt index f4d1ecc8..99d50374 100644 --- a/app/src/main/java/nl/eduvpn/app/inject/ViewModelModule.kt +++ b/app/src/main/java/nl/eduvpn/app/inject/ViewModelModule.kt @@ -18,6 +18,7 @@ package nl.eduvpn.app.inject +import android.view.View import androidx.lifecycle.ViewModel import dagger.Binds import dagger.Module @@ -45,15 +46,25 @@ interface ViewModelModule { @Binds @IntoMap @ViewModelKey(ProfileSelectionViewModel::class) - fun bindProfileSelectionViewModel(profileSelectionViewModel: ProfileSelectionViewModel) : ViewModel + fun bindProfileSelectionViewModel(profileSelectionViewModel: ProfileSelectionViewModel): ViewModel @Binds @IntoMap @ViewModelKey(AddServerViewModel::class) - fun bindAddServerViewModel(addServerViewModel: AddServerViewModel) : ViewModel + fun bindAddServerViewModel(addServerViewModel: AddServerViewModel): ViewModel @Binds @IntoMap @ViewModelKey(MainViewModel::class) - fun bindMainViewModel(mainViewModel: MainViewModel) : ViewModel + fun bindMainViewModel(mainViewModel: MainViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(SettingsViewModel::class) + fun bindSettingsViewModel(settingsViewModel: SettingsViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(ApiLogsViewModel::class) + fun bindApiLogsViewModel(apiLogsViewModel: ApiLogsViewModel): ViewModel } diff --git a/app/src/main/java/nl/eduvpn/app/livedata/IPs.kt b/app/src/main/java/nl/eduvpn/app/livedata/IPs.kt index 84716e73..a3ee86a8 100644 --- a/app/src/main/java/nl/eduvpn/app/livedata/IPs.kt +++ b/app/src/main/java/nl/eduvpn/app/livedata/IPs.kt @@ -1,3 +1,4 @@ package nl.eduvpn.app.livedata -data class IPs(val ipv4: String?, val ipv6: String?) +data class IPs(val clientIpv4: String?, val clientIpv6: String?, val tunnelData: TunnelData?) +data class TunnelData(val tunnelIp: String?, val mtu: Int?) \ No newline at end of file diff --git a/app/src/main/java/nl/eduvpn/app/livedata/openvpn/IPLiveData.kt b/app/src/main/java/nl/eduvpn/app/livedata/openvpn/IPLiveData.kt index 13c7454c..468d156b 100644 --- a/app/src/main/java/nl/eduvpn/app/livedata/openvpn/IPLiveData.kt +++ b/app/src/main/java/nl/eduvpn/app/livedata/openvpn/IPLiveData.kt @@ -47,13 +47,13 @@ class IPLiveData : LiveData() { intent: Intent? ) { if (EduVPNOpenVPNService.connectionStatusToVPNStatus(level) == VPNService.VPNStatus.DISCONNECTED) { - postValue(IPs(null, null)) + postValue(IPs(null, null, tunnelData = null)) return } // Try to get the address from a lookup var ips: Pair? = lookupVpnIpAddresses() if (ips != null) { - postValue(IPs(ips.first, ips.second)) + postValue(IPs(ips.first, ips.second, tunnelData = null)) } else { Log.i( TAG, @@ -61,7 +61,7 @@ class IPLiveData : LiveData() { ) ips = logmessage?.let { lm -> parseVpnIpAddressesFromLogMessage(lm) } if (ips != null) { - postValue(IPs(ips.first, ips.second)) + postValue(IPs(ips.first, ips.second, tunnelData = null)) } } } diff --git a/app/src/main/java/nl/eduvpn/app/service/BackendService.kt b/app/src/main/java/nl/eduvpn/app/service/BackendService.kt index 00fe4a06..083039c9 100644 --- a/app/src/main/java/nl/eduvpn/app/service/BackendService.kt +++ b/app/src/main/java/nl/eduvpn/app/service/BackendService.kt @@ -2,14 +2,19 @@ package nl.eduvpn.app.service import android.content.Context import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import nl.eduvpn.app.BuildConfig import nl.eduvpn.app.entity.AddedServers import nl.eduvpn.app.entity.AuthorizationType import nl.eduvpn.app.entity.CertExpiryTimes import nl.eduvpn.app.entity.CurrentServer import nl.eduvpn.app.entity.Instance -import nl.eduvpn.app.entity.SerializedVpnConfig import nl.eduvpn.app.entity.Profile +import nl.eduvpn.app.entity.SerializedVpnConfig import nl.eduvpn.app.entity.exception.CommonException import nl.eduvpn.app.service.SerializerService.UnknownFormatException import nl.eduvpn.app.utils.Log @@ -17,6 +22,11 @@ import org.eduvpn.common.GoBackend import org.eduvpn.common.GoBackend.Callback import org.eduvpn.common.ServerType import java.io.File +import java.net.InetAddress +import java.net.NetworkInterface +import java.util.Collections +import java.util.Locale + class BackendService( private val context: Context, @@ -43,17 +53,17 @@ class BackendService( var lastSelectedProfile: String? = null private set - private var onConfigReady: ((SerializedVpnConfig) -> Unit)? = null + private var onConfigReady: ((SerializedVpnConfig, Boolean) -> Unit)? = null fun register( startOAuth: (String) -> Unit, selectProfiles: (List) -> Unit, selectCountry: (Int?) -> Unit, - connectWithConfig: (SerializedVpnConfig) -> Unit, + connectWithConfig: (SerializedVpnConfig, Boolean) -> Unit, showError: (Throwable) -> Unit ): String? { - onConfigReady = { - connectWithConfig(it) + onConfigReady = { config, forceTcp -> + connectWithConfig(config, forceTcp) } GoBackend.callbackFunction = object : Callback { @@ -242,11 +252,11 @@ class BackendService( } @kotlin.jvm.Throws(CommonException::class, UnknownFormatException::class) - suspend fun getConfig(instance: Instance, preferTcp: Boolean) { + suspend fun getConfig(instance: Instance, forceTCP: Boolean) = withContext(Dispatchers.IO) { val dataErrorTuple = goBackend.getProfiles( instance.authorizationType.toNativeServerType().nativeValue, instance.baseURI, - preferTcp, + forceTCP, false ) @@ -254,9 +264,7 @@ class BackendService( throw CommonException(dataErrorTuple.error) } val config = serializerService.deserializeSerializedVpnConfig(dataErrorTuple.data) - if (config != null) { - onConfigReady?.invoke(config) - } + onConfigReady?.invoke(config, forceTCP) } @kotlin.jvm.Throws(CommonException::class) @@ -310,5 +318,64 @@ class BackendService( pendingOAuthCookie = null } } + + fun getLogFile() : File? { + val configDirectory = File(context.cacheDir, DIRECTORY_BACKEND_CONFIG_FILES) + val configFile = File(configDirectory, "log") + if (configFile.exists()) { + return configFile + } + return null + } + + /** + * Starts checking if there's a stable connection on the tunnel. Only works on WireGuard for now. + */ + suspend fun startFailOver(service: VPNService, onFailOverNeeded: () -> Unit) { + goBackend.updateRxBytesRead(0) + val updateBytesJob = GlobalScope.launch { + service.byteCountFlow.collectLatest { + goBackend.updateRxBytesRead(it?.bytesIn ?: 0L) + } + } + service.ipFlow.collectLatest { ips -> + val tunnelIp = ips?.tunnelData?.tunnelIp + var mtu = ips?.tunnelData?.mtu + if (tunnelIp == null) { + throw CommonException("Could not start failover, because IP was missing!") + } + if (mtu == null) { + // We could try to get it from the actual network interface + try { + val tunnelInterface = NetworkInterface.getNetworkInterfaces().toList().firstOrNull { + it.inetAddresses.toList().any { + if (it.hostAddress != null) { + it.hostAddress!! in (ips.clientIpv4?.split(",") ?: emptyList()) || + it.hostAddress!! in (ips.clientIpv6?.split(",") ?: emptyList()) + } else { + false + } + } + } + mtu = tunnelInterface?.mtu + } catch (ex: Exception) { + Log.e(TAG, "Could not determine MTU!", ex) + } + } + if (mtu == null) { + throw CommonException("Could not start failover, because MTU was missing!") + } + Log.v(TAG, "Failover started with tunnel IP: $tunnelIp and MTU: $mtu") + val result = goBackend.startFailOver(tunnelIp, mtu) + Log.v(TAG, "Failover ended with result: ${result.doesRequireFailover}") + updateBytesJob.cancel() + if (result.isError) { + throw CommonException(result.error) + } + if (result.doesRequireFailover) { + onFailOverNeeded() + } + } + } } diff --git a/app/src/main/java/nl/eduvpn/app/service/VPNConnectionService.kt b/app/src/main/java/nl/eduvpn/app/service/VPNConnectionService.kt index 45f7820c..70deb706 100644 --- a/app/src/main/java/nl/eduvpn/app/service/VPNConnectionService.kt +++ b/app/src/main/java/nl/eduvpn/app/service/VPNConnectionService.kt @@ -98,8 +98,8 @@ class VPNConnectionService( .setOngoing(true) .setPriority(NotificationCompat.PRIORITY_LOW) // Only used on Android <= 7.1 .addAction( - R.drawable.ic_menu_close_clear_cancel, - context.getString(R.string.cancel_connection), disconnectVPNPendingIntent + de.blinkt.openvpn.R.drawable.ic_menu_close_clear_cancel, + context.getString(de.blinkt.openvpn.R.string.cancel_connection), disconnectVPNPendingIntent ) .build() @@ -107,11 +107,8 @@ class VPNConnectionService( context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager // Prevent recreating the notification in case we just removed it - if(statusObserver != null) { - notificationManager.notify(notificationID, notification) - - vpnService.startForeground(notificationID, notification) - } + notificationManager.notify(notificationID, notification) + vpnService.startForeground(notificationID, notification) } private fun removeVPNNotification(context: Context, vpnService: VPNService) { diff --git a/app/src/main/java/nl/eduvpn/app/service/WireGuardService.kt b/app/src/main/java/nl/eduvpn/app/service/WireGuardService.kt index 2230ed14..e67c5953 100644 --- a/app/src/main/java/nl/eduvpn/app/service/WireGuardService.kt +++ b/app/src/main/java/nl/eduvpn/app/service/WireGuardService.kt @@ -11,6 +11,7 @@ import com.wireguard.config.Interface import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map @@ -18,11 +19,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import nl.eduvpn.app.livedata.ByteCount import nl.eduvpn.app.livedata.IPs +import nl.eduvpn.app.livedata.TunnelData import nl.eduvpn.app.utils.Log import nl.eduvpn.app.utils.WireGuardTunnel +import java.net.Inet4Address import java.util.concurrent.Executors import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import kotlin.jvm.optionals.getOrNull /** * Service responsible for managing the WireGuard profiles and the connection. @@ -159,6 +163,33 @@ class WireGuardService(private val context: Context, timer: Flow): VPNServ } companion object { + private fun calculateTunnelAddress(ipAddress: String?, subnetMask: Int): String? { + if (ipAddress == null) { + return null + } + try { + val ipParts = ipAddress.split(".") + if (ipParts.size != 4) { + return null // Invalid IP address format + } + + val subnetMaskParts = Array(4) { 0 } + for (i in 0 until subnetMask) { + subnetMaskParts[i / 8] = subnetMaskParts[i / 8] or (1 shl (7 - i % 8)) + } + + val networkAddressParts = Array(4) { 0 } + for (i in 0 until 4) { + networkAddressParts[i] = ipParts[i].toInt() and subnetMaskParts[i] + } + // Calculate the first valid IP address by adding 1 to the last part of the network address + networkAddressParts[3]++ + return networkAddressParts.joinToString(".") + } catch (e: Exception) { + return null + } + } + private fun getIPs(wgInterface: Interface): IPs { val ipv4Addresses = wgInterface.addresses .filter { network -> network.address is java.net.Inet4Address } @@ -168,10 +199,19 @@ class WireGuardService(private val context: Context, timer: Flow): VPNServ .filter { network -> network.address is java.net.Inet6Address } .mapNotNull { ip -> ip.address.hostAddress } + val tunnelIp = wgInterface.addresses + .firstOrNull { network -> network.address is Inet4Address } + ?.let { ip -> + calculateTunnelAddress(ip.address.hostAddress, ip.mask) + } fun ipListToString(ipList: List): String? { return ipList.reduceOrNull { s1, s2 -> "$s1, $s2" } } - return IPs(ipListToString(ipv4Addresses), ipListToString(ipv6Addresses)) + return IPs( + ipListToString(ipv4Addresses), + ipListToString(ipv6Addresses), + TunnelData(tunnelIp, wgInterface.mtu.getOrNull()) + ) } } } diff --git a/app/src/main/java/nl/eduvpn/app/utils/LiveEvent.kt b/app/src/main/java/nl/eduvpn/app/utils/LiveEvent.kt index 8ef82ed8..3664fc5f 100644 --- a/app/src/main/java/nl/eduvpn/app/utils/LiveEvent.kt +++ b/app/src/main/java/nl/eduvpn/app/utils/LiveEvent.kt @@ -79,9 +79,9 @@ class LiveEvent : MediatorLiveData() { private val pending = AtomicBoolean(false) - override fun onChanged(t: T?) { + override fun onChanged(value: T) { if (pending.compareAndSet(true, false)) { - observer.onChanged(t) + observer.onChanged(value) } } diff --git a/app/src/main/java/nl/eduvpn/app/viewmodel/AddServerViewModel.kt b/app/src/main/java/nl/eduvpn/app/viewmodel/AddServerViewModel.kt index dcbaa8a3..87d4e05d 100644 --- a/app/src/main/java/nl/eduvpn/app/viewmodel/AddServerViewModel.kt +++ b/app/src/main/java/nl/eduvpn/app/viewmodel/AddServerViewModel.kt @@ -20,7 +20,7 @@ package nl.eduvpn.app.viewmodel import android.content.Context import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations +import androidx.lifecycle.map import nl.eduvpn.app.service.* import javax.inject.Inject @@ -40,7 +40,7 @@ class AddServerViewModel @Inject constructor( val serverUrl = MutableLiveData("") - val addButtonEnabled = Transformations.map(serverUrl) { url -> + val addButtonEnabled = serverUrl.map { url -> url != null && url.contains(".") && url.length > 3 } } diff --git a/app/src/main/java/nl/eduvpn/app/viewmodel/ApiLogsViewModel.kt b/app/src/main/java/nl/eduvpn/app/viewmodel/ApiLogsViewModel.kt new file mode 100644 index 00000000..2d3a2ea6 --- /dev/null +++ b/app/src/main/java/nl/eduvpn/app/viewmodel/ApiLogsViewModel.kt @@ -0,0 +1,17 @@ +package nl.eduvpn.app.viewmodel + +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import nl.eduvpn.app.entity.Settings +import nl.eduvpn.app.service.BackendService +import nl.eduvpn.app.service.HistoryService +import nl.eduvpn.app.service.PreferencesService +import javax.inject.Inject + +class ApiLogsViewModel @Inject constructor( + private val backendService: BackendService +) : ViewModel() { + fun getLogFileContents() : String { + return backendService.getLogFile()!!.readLines().joinToString("\n") + } +} \ No newline at end of file diff --git a/app/src/main/java/nl/eduvpn/app/viewmodel/MainViewModel.kt b/app/src/main/java/nl/eduvpn/app/viewmodel/MainViewModel.kt index 4a627c8f..5c7a9847 100644 --- a/app/src/main/java/nl/eduvpn/app/viewmodel/MainViewModel.kt +++ b/app/src/main/java/nl/eduvpn/app/viewmodel/MainViewModel.kt @@ -2,13 +2,12 @@ package nl.eduvpn.app.viewmodel import android.content.Context import android.net.Uri -import androidx.appcompat.app.AlertDialog import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.wireguard.config.Config import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import nl.eduvpn.app.MainActivity import nl.eduvpn.app.R import nl.eduvpn.app.entity.AuthorizationType @@ -16,12 +15,14 @@ import nl.eduvpn.app.entity.Instance import nl.eduvpn.app.entity.Profile import nl.eduvpn.app.entity.SerializedVpnConfig import nl.eduvpn.app.entity.VPNConfig +import nl.eduvpn.app.entity.exception.CommonException import nl.eduvpn.app.service.BackendService import nl.eduvpn.app.service.EduVPNOpenVPNService import nl.eduvpn.app.service.HistoryService import nl.eduvpn.app.service.OrganizationService import nl.eduvpn.app.service.PreferencesService import nl.eduvpn.app.service.VPNConnectionService +import nl.eduvpn.app.utils.Log import nl.eduvpn.app.utils.getCountryText import nl.eduvpn.app.utils.toSingleEvent import org.eduvpn.common.Protocol @@ -44,11 +45,16 @@ class MainViewModel @Inject constructor( preferencesService, vpnConnectionService ) { + + companion object { + private val TAG = MainViewModel::class.simpleName + } + sealed class MainParentAction { data class OpenLink(val oAuthUrl: String) : MainParentAction() data class SelectCountry(val cookie: Int?) : MainParentAction() data class SelectProfiles(val profileList: List): MainParentAction() - data class ConnectWithConfig(val config: SerializedVpnConfig) : MainParentAction() + data class ConnectWithConfig(val config: SerializedVpnConfig, val forceTCP: Boolean) : MainParentAction() data class ShowCountriesDialog(val instancesWithNames: List>, val cookie: Int?): MainParentAction() data class ShowError(val throwable: Throwable) : MainParentAction() } @@ -67,8 +73,8 @@ class MainViewModel @Inject constructor( selectProfiles = { profileList -> _mainParentAction.postValue(MainParentAction.SelectProfiles(profileList)) }, - connectWithConfig = { config -> - _mainParentAction.postValue(MainParentAction.ConnectWithConfig(config)) + connectWithConfig = { config, forceTcp -> + _mainParentAction.postValue(MainParentAction.ConnectWithConfig(config, forceTcp)) }, showError = { throwable -> _mainParentAction.postValue(MainParentAction.ShowError(throwable)) @@ -90,7 +96,11 @@ class MainViewModel @Inject constructor( fun hasServers() = historyService.addedServers?.hasServers() == true - fun parseConfigAndStartConnection(activity: MainActivity, config: SerializedVpnConfig) { + fun parseConfigAndStartConnection( + activity: MainActivity, + config: SerializedVpnConfig, + forceTCP: Boolean + ) { preferencesService.setCurrentProtocol(config.protocol) val parsedConfig = if (config.protocol == Protocol.OpenVPN.nativeValue) { eduVpnOpenVpnService.importConfig( @@ -104,7 +114,31 @@ class MainViewModel @Inject constructor( } else { throw IllegalArgumentException("Unexpected protocol type: ${config.protocol}") } - vpnConnectionService.connectionToConfig(viewModelScope, activity, parsedConfig) + val service = vpnConnectionService.connectionToConfig(viewModelScope, activity, parsedConfig) + if (config.protocol == Protocol.WireGuard.nativeValue && !forceTCP) { + viewModelScope.launch(Dispatchers.IO) { + try { + // Waits a bit so that the network interface has been surely set up + delay(1_000L) + backendService.startFailOver(service) { + // Failover needed, request a new profile with TCP enforced. + preferencesService.getCurrentInstance()?.let { currentInstance -> + viewModelScope.launch { + // Disconnect first, otherwise we don't have any internet :) + service.disconnect() + // Wait a bit for the disconnection to finish + delay(500L) + // Fetch a new profile, now with TCP forced + backendService.getConfig(currentInstance, forceTCP = true) + } + } + } + } catch (ex: CommonException) { + // These are just warnings, so we log them, but don't display to the user + Log.w( TAG, "Unable to start failover detection", ex) + } + } + } } suspend fun handleRedirection(data: Uri?) : Boolean { @@ -114,7 +148,7 @@ class MainViewModel @Inject constructor( } fun useCustomTabs() = preferencesService.getAppSettings().useCustomTabs() - fun getCountryList(activity: MainActivity, cookie: Int? = null) { + fun getCountryList(cookie: Int? = null) { viewModelScope.launch(Dispatchers.IO) { try { val allInstances = organizationService.fetchServerList().serverList diff --git a/app/src/main/java/nl/eduvpn/app/viewmodel/OrganizationSelectionViewModel.kt b/app/src/main/java/nl/eduvpn/app/viewmodel/OrganizationSelectionViewModel.kt index fa5c2b5e..ed2f1a38 100644 --- a/app/src/main/java/nl/eduvpn/app/viewmodel/OrganizationSelectionViewModel.kt +++ b/app/src/main/java/nl/eduvpn/app/viewmodel/OrganizationSelectionViewModel.kt @@ -20,7 +20,6 @@ package nl.eduvpn.app.viewmodel import android.content.Context import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.Transformations import androidx.lifecycle.map import androidx.lifecycle.switchMap import androidx.lifecycle.viewModelScope @@ -234,8 +233,8 @@ class OrganizationSelectionViewModel @Inject constructor( } } - val noItemsFound = Transformations.switchMap(connectionState) { state -> - Transformations.map(adapterItems) { items -> + val noItemsFound = connectionState.switchMap { state -> + adapterItems.map { items -> items.isEmpty() && state == ConnectionState.Ready } } diff --git a/app/src/main/java/nl/eduvpn/app/viewmodel/SettingsViewModel.kt b/app/src/main/java/nl/eduvpn/app/viewmodel/SettingsViewModel.kt new file mode 100644 index 00000000..67859b2f --- /dev/null +++ b/app/src/main/java/nl/eduvpn/app/viewmodel/SettingsViewModel.kt @@ -0,0 +1,30 @@ +package nl.eduvpn.app.viewmodel + +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import nl.eduvpn.app.entity.Settings +import nl.eduvpn.app.service.BackendService +import nl.eduvpn.app.service.HistoryService +import nl.eduvpn.app.service.PreferencesService +import javax.inject.Inject + +class SettingsViewModel @Inject constructor( + private val historyService: HistoryService, + private val preferencesService: PreferencesService, + private val backendService: BackendService +) : ViewModel() { + + val appSettings get() = preferencesService.getAppSettings() + + val apiLogFile get() = backendService.getLogFile() + + val hasAddedServers get() = historyService.addedServers?.hasServers() == true + + fun removeOrganizationData() { + historyService.removeOrganizationData() + } + + fun storeAppSettings(appSettings: Settings) { + preferencesService.storeAppSettings(appSettings) + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_api_logs.xml b/app/src/main/res/layout/activity_api_logs.xml new file mode 100644 index 00000000..8295b34c --- /dev/null +++ b/app/src/main/res/layout/activity_api_logs.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_connection_status.xml b/app/src/main/res/layout/fragment_connection_status.xml index 134fdc29..3885c894 100644 --- a/app/src/main/res/layout/fragment_connection_status.xml +++ b/app/src/main/res/layout/fragment_connection_status.xml @@ -316,7 +316,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" - android:text="@{ips.ipv4 ?? @string/not_available}" + android:text="@{ips.clientIpv4 ?? @string/not_available}" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintTop_toBottomOf="@id/label_ipv4" tools:text="123.123.123" /> @@ -338,7 +338,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="2dp" - android:text="@{ips.ipv6 ?? @string/not_available}" + android:text="@{ips.clientIpv6 ?? @string/not_available}" app:layout_constraintLeft_toLeftOf="@id/guide_vertical_divide" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toBottomOf="@id/label_ipv6" diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index 3301050f..9fd98056 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -91,19 +91,19 @@ android:layout_below="@id/useCustomTabsSwitch" /> + android:text="@string/settings_view_openvpn_logs_title" />