diff --git a/nym-vpn/android/.gitignore b/nym-vpn/android/.gitignore new file mode 100644 index 0000000000..31caa0cedf --- /dev/null +++ b/nym-vpn/android/.gitignore @@ -0,0 +1,71 @@ +# Built application files +*.apk +*.aar +*.ap_ +*.aab +# Files for the ART/Dalvik VM +*.dex +# Java class files +*.class +# Generated files +bin/ +gen/ +out/ +release/ +build/ +# Gradle files +.gradle/ +# Local configuration file (sdk path, etc) +local.properties +# Proguard folder generated by Eclipse +proguard/ +# Log Files +*.log +# Android Studio Navigation editor temp files +.navigation/ +# Android Studio captures folder +captures/ +# IntelliJ +*.iml +.idea/ +# .idea/workspace.xml +# .idea/tasks.xml +# .idea/gradle.xml +# .idea/assetWizardSettings.xml +# .idea/dictionaries +.idea/libraries +# Android Studio 3 in .gitignore file. +.idea/caches +.idea/modules.xml +# Comment next line if keeping position of elements in Navigation Editor is relevant for you +.idea/navEditor.xml +# Keystore files +# Uncomment the following lines if you do not want to check your keystore files in. +*.jks +*.keystore +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild +.cxx/ +# Freeline +freeline.py +freeline/ +freeline_project_description.json +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md +# Version control +vcs.xml +# lint +lint/intermediates/ +lint/generated/ +lint/outputs/ +lint/tmp/ +# lint/reports/ +# MacOS +.DS_Store +# App Specific cases +app/release/output.json +.idea/codeStyles/ diff --git a/nym-vpn/android/.gitkeep b/nym-vpn/android/.gitkeep deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/nym-vpn/android/LICENSE b/nym-vpn/android/LICENSE new file mode 100644 index 0000000000..51f3844dba --- /dev/null +++ b/nym-vpn/android/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 NymConnect + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/nym-vpn/android/README.md b/nym-vpn/android/README.md new file mode 100644 index 0000000000..e4a334e993 --- /dev/null +++ b/nym-vpn/android/README.md @@ -0,0 +1,42 @@ +## NymConnect for Android + +### Prerequisites + +_TODO_ + +### Getting started + +[Install](https://developer.android.com/studio/install) Android Studio and open +the project.\ +Setup an android emulator using AVD.\ +[Run](https://developer.android.com/studio/run/emulator) the project. + +**⚠ NOTE**: be sure +to [set](https://developer.android.com/studio/run#changing-variant) +the build variant to `x86_64Debug` when running on emulator + +### Features + +* Add tunnels via .conf file +* Auto connect to VPN based on Wi-Fi SSID +* Split tunneling by application with search +* Always-on VPN for Android support +* Quick tile support for vpn toggling +* Dynamic shortcuts support for automation integration +* Configurable Trusted Network list +* Optional auto connect on mobile data +* Automatic service restart after reboot +* Service will stay running in background after app has been closed + +### Building + +_TODO_ + +### Credits + +This project is based on the "WG Tunnel" project made by Zane Schepke +https://github.com/zaneschepke/wgtunnel + +### License + +MIT diff --git a/nym-vpn/android/app/.gitignore b/nym-vpn/android/app/.gitignore new file mode 100644 index 0000000000..956c004dc0 --- /dev/null +++ b/nym-vpn/android/app/.gitignore @@ -0,0 +1,2 @@ +/build +/release \ No newline at end of file diff --git a/nym-vpn/android/app/build.gradle.kts b/nym-vpn/android/app/build.gradle.kts new file mode 100644 index 0000000000..bc158e58e9 --- /dev/null +++ b/nym-vpn/android/app/build.gradle.kts @@ -0,0 +1,159 @@ +val rExtra = rootProject.extra + +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + kotlin("kapt") + id("com.google.dagger.hilt.android") + id("org.jetbrains.kotlin.plugin.serialization") + id("io.objectbox") +} + +android { + namespace = "net.nymtech.nymconnect" + compileSdk = 34 + + val versionMajor = 1 + val versionMinor = 0 + val versionPatch = 0 + val versionBuild = 0 + + defaultConfig { + applicationId = "net.nymtech.nymconnect" + minSdk = 28 + targetSdk = 34 + versionCode = versionMajor * 10000 + versionMinor * 1000 + versionPatch * 100 + versionBuild + versionName = "${versionMajor}.${versionMinor}.${versionPatch}" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + buildTypes { + release { + isDebuggable = false + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.4.8" + } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } + + /* flavorDimensions += "abi" + productFlavors { + create("universal") { + dimension = "abi" + ndk { + abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64", "x86") + } + } + create("arch64") { + dimension = "abi" + ndk { + abiFilters += listOf("arm64-v8a", "x86_64") + } + } + create("arm64") { + dimension = "abi" + ndk { + abiFilters += "arm64-v8a" + } + } + create("arm") { + dimension = "abi" + ndk { + abiFilters += "armeabi-v7a" + } + } + create("x86_64") { + dimension = "abi" + ndk { + abiFilters += "x86_64" + } + } + create("x86") { + dimension = "abi" + ndk { + abiFilters += "x86" + } + } + } */ +} + +dependencies { + implementation("androidx.core:core-ktx:1.10.1") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1") + implementation("androidx.activity:activity-compose:1.7.2") + implementation(platform("androidx.compose:compose-bom:2023.03.00")) + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-graphics") + implementation("androidx.compose.ui:ui-tooling-preview") + implementation("androidx.compose.material3:material3:1.1.1") + implementation("androidx.appcompat:appcompat:1.6.1") + + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-tooling") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + //wireguard tunnel + implementation("com.wireguard.android:tunnel:1.0.20230706") + + //logging + implementation("com.jakewharton.timber:timber:5.0.1") + + // compose navigation + implementation("androidx.navigation:navigation-compose:2.7.1") + implementation("androidx.hilt:hilt-navigation-compose:1.0.0") + + // hilt + implementation("com.google.dagger:hilt-android:${rExtra.get("hiltVersion")}") + kapt("com.google.dagger:hilt-android-compiler:${rExtra.get("hiltVersion")}") + + //accompanist + implementation("com.google.accompanist:accompanist-systemuicontroller:${rExtra.get("accompanistVersion")}") + implementation("com.google.accompanist:accompanist-permissions:${rExtra.get("accompanistVersion")}") + implementation("com.google.accompanist:accompanist-flowlayout:${rExtra.get("accompanistVersion")}") + implementation("com.google.accompanist:accompanist-navigation-animation:${rExtra.get("accompanistVersion")}") + implementation("com.google.accompanist:accompanist-drawablepainter:${rExtra.get("accompanistVersion")}") + + //db + implementation("io.objectbox:objectbox-kotlin:${rExtra.get("objectBoxVersion")}") + + //lifecycle + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1") + + //icons + implementation("androidx.compose.material:material-icons-extended:1.5.0") + + + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1") +} + +kapt { + correctErrorTypes = true +} \ No newline at end of file diff --git a/nym-vpn/android/app/objectbox-models/default.json b/nym-vpn/android/app/objectbox-models/default.json new file mode 100644 index 0000000000..dd7a8ab78e --- /dev/null +++ b/nym-vpn/android/app/objectbox-models/default.json @@ -0,0 +1,99 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:2692736974585027589", + "lastPropertyId": "15:5057486545428188436", + "name": "TunnelConfig", + "properties": [ + { + "id": "1:1985347930017457084", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "12:2409068226744965585", + "name": "name", + "indexId": "1:4811206443952699137", + "type": 9, + "flags": 34848 + }, + { + "id": "13:8987443291286312275", + "name": "wgQuick", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "2:8887605597748372702", + "lastPropertyId": "9:4468844863383145378", + "name": "Settings", + "properties": [ + { + "id": "1:7485739868216068651", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:5814013113141456749", + "name": "isAutoTunnelEnabled", + "type": 1 + }, + { + "id": "4:5645665441196906014", + "name": "trustedNetworkSSIDs", + "type": 30 + }, + { + "id": "5:4989886999117763881", + "name": "isTunnelOnMobileDataEnabled", + "type": 1 + }, + { + "id": "6:3370284381040192129", + "name": "defaultTunnel", + "type": 9 + }, + { + "id": "9:4468844863383145378", + "name": "isAlwaysOnVpnEnabled", + "type": 1 + } + ], + "relations": [] + } + ], + "lastEntityId": "2:8887605597748372702", + "lastIndexId": "1:4811206443952699137", + "lastRelationId": "0:0", + "lastSequenceId": "0:0", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [], + "retiredIndexUids": [], + "retiredPropertyUids": [ + 1763475292291320186, + 6483820955437198310, + 8323071516033820771, + 5904440563612311217, + 1408037976996390989, + 7737847485212546994, + 8215616901775229364, + 8021610768066328637, + 6174306582797008721, + 2175939938544485767, + 7555225587864607050, + 969146862000617878, + 5057486545428188436, + 2814640993034665120, + 4981008812459251156 + ], + "retiredRelationUids": [], + "version": 1 +} \ No newline at end of file diff --git a/nym-vpn/android/app/objectbox-models/default.json.bak b/nym-vpn/android/app/objectbox-models/default.json.bak new file mode 100644 index 0000000000..d4084866ef --- /dev/null +++ b/nym-vpn/android/app/objectbox-models/default.json.bak @@ -0,0 +1,94 @@ +{ + "_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.", + "_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.", + "_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.", + "entities": [ + { + "id": "1:2692736974585027589", + "lastPropertyId": "15:5057486545428188436", + "name": "TunnelConfig", + "properties": [ + { + "id": "1:1985347930017457084", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "12:2409068226744965585", + "name": "name", + "indexId": "1:4811206443952699137", + "type": 9, + "flags": 34848 + }, + { + "id": "13:8987443291286312275", + "name": "wgQuick", + "type": 9 + } + ], + "relations": [] + }, + { + "id": "2:8887605597748372702", + "lastPropertyId": "8:4981008812459251156", + "name": "Settings", + "properties": [ + { + "id": "1:7485739868216068651", + "name": "id", + "type": 6, + "flags": 1 + }, + { + "id": "2:5814013113141456749", + "name": "isAutoTunnelEnabled", + "type": 1 + }, + { + "id": "4:5645665441196906014", + "name": "trustedNetworkSSIDs", + "type": 30 + }, + { + "id": "5:4989886999117763881", + "name": "isTunnelOnMobileDataEnabled", + "type": 1 + }, + { + "id": "6:3370284381040192129", + "name": "defaultTunnel", + "type": 9 + } + ], + "relations": [] + } + ], + "lastEntityId": "2:8887605597748372702", + "lastIndexId": "1:4811206443952699137", + "lastRelationId": "0:0", + "lastSequenceId": "0:0", + "modelVersion": 5, + "modelVersionParserMinimum": 5, + "retiredEntityUids": [], + "retiredIndexUids": [], + "retiredPropertyUids": [ + 1763475292291320186, + 6483820955437198310, + 8323071516033820771, + 5904440563612311217, + 1408037976996390989, + 7737847485212546994, + 8215616901775229364, + 8021610768066328637, + 6174306582797008721, + 2175939938544485767, + 7555225587864607050, + 969146862000617878, + 5057486545428188436, + 2814640993034665120, + 4981008812459251156 + ], + "retiredRelationUids": [], + "version": 1 +} \ No newline at end of file diff --git a/nym-vpn/android/app/proguard-rules.pro b/nym-vpn/android/app/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/nym-vpn/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# 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 *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/nym-vpn/android/app/src/androidTest/java/com/zaneschepke/wireguardautotunnel/ExampleInstrumentedTest.kt b/nym-vpn/android/app/src/androidTest/java/com/zaneschepke/wireguardautotunnel/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000..c006861187 --- /dev/null +++ b/nym-vpn/android/app/src/androidTest/java/com/zaneschepke/wireguardautotunnel/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package net.nymtech.nymconnect + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("net.nymtech.nymconnect", appContext.packageName) + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/AndroidManifest.xml b/nym-vpn/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..2ff4f063bb --- /dev/null +++ b/nym-vpn/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/ic_launcher-playstore.png b/nym-vpn/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000..e5d8e99564 Binary files /dev/null and b/nym-vpn/android/app/src/main/ic_launcher-playstore.png differ diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt new file mode 100644 index 0000000000..c34ccaa493 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/Constants.kt @@ -0,0 +1,6 @@ +package net.nymtech.nymconnect + +object Constants { + const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L; + const val VPN_STATISTIC_CHECK_INTERVAL = 10000L; +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt new file mode 100644 index 0000000000..f0d53e5162 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/WireGuardAutoTunnel.kt @@ -0,0 +1,27 @@ +package net.nymtech.nymconnect + +import android.app.Application +import android.content.Context +import android.content.pm.PackageManager +import net.nymtech.nymconnect.repository.Repository +import net.nymtech.nymconnect.service.tunnel.model.Settings +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject + +@HiltAndroidApp +class WireGuardAutoTunnel : Application() { + + @Inject + lateinit var settingsRepo : Repository + + override fun onCreate() { + super.onCreate() + settingsRepo.init() + } + + companion object { + fun isRunningOnAndroidTv(context : Context) : Boolean { + return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/BoxModule.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/BoxModule.kt new file mode 100644 index 0000000000..032232369b --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/BoxModule.kt @@ -0,0 +1,40 @@ +package net.nymtech.nymconnect.module + +import android.content.Context +import net.nymtech.nymconnect.service.tunnel.model.MyObjectBox +import net.nymtech.nymconnect.service.tunnel.model.Settings +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import io.objectbox.Box +import io.objectbox.BoxStore +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class BoxModule { + + @Provides + @Singleton + fun provideBoxStore(@ApplicationContext context : Context) : BoxStore { + return MyObjectBox.builder() + .androidContext(context.applicationContext) + .build() + } + + @Provides + @Singleton + fun provideBoxForSettings(store : BoxStore) : Box { + return store.boxFor(Settings::class.java) + } + + @Provides + @Singleton + fun provideBoxForTunnels(store : BoxStore) : Box { + return store.boxFor(TunnelConfig::class.java) + } + +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/RepositoryModule.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/RepositoryModule.kt new file mode 100644 index 0000000000..e402249cba --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/RepositoryModule.kt @@ -0,0 +1,25 @@ +package net.nymtech.nymconnect.module + +import net.nymtech.nymconnect.repository.Repository +import net.nymtech.nymconnect.repository.SettingsBox +import net.nymtech.nymconnect.repository.TunnelBox +import net.nymtech.nymconnect.service.tunnel.model.Settings +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class RepositoryModule { + + @Binds + @Singleton + abstract fun provideSettingsRepository(settingsBox: SettingsBox) : Repository + + @Binds + @Singleton + abstract fun provideTunnelRepository(tunnelBox: TunnelBox) : Repository +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/ServiceModule.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/ServiceModule.kt new file mode 100644 index 0000000000..fa043ec549 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/ServiceModule.kt @@ -0,0 +1,29 @@ +package net.nymtech.nymconnect.module + +import net.nymtech.nymconnect.service.network.MobileDataService +import net.nymtech.nymconnect.service.network.NetworkService +import net.nymtech.nymconnect.service.network.WifiService +import net.nymtech.nymconnect.service.notification.NotificationService +import net.nymtech.nymconnect.service.notification.WireGuardNotification +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ServiceComponent +import dagger.hilt.android.scopes.ServiceScoped + +@Module +@InstallIn(ServiceComponent::class) +abstract class ServiceModule { + + @Binds + @ServiceScoped + abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification) : NotificationService + + @Binds + @ServiceScoped + abstract fun provideWifiService(wifiService: WifiService) : NetworkService + + @Binds + @ServiceScoped + abstract fun provideMobileDataService(mobileDataService : MobileDataService) : NetworkService +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/TunnelModule.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/TunnelModule.kt new file mode 100644 index 0000000000..b5962a39d9 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/module/TunnelModule.kt @@ -0,0 +1,31 @@ +package net.nymtech.nymconnect.module + +import android.content.Context +import com.wireguard.android.backend.Backend +import com.wireguard.android.backend.GoBackend +import net.nymtech.nymconnect.service.tunnel.VpnService +import net.nymtech.nymconnect.service.tunnel.WireGuardTunnel +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +class TunnelModule { + + @Provides + @Singleton + fun provideBackend(@ApplicationContext context : Context) : Backend { + return GoBackend(context) + } + + @Provides + @Singleton + fun provideVpnService(backend: Backend) : VpnService { + return WireGuardTunnel(backend) + } + +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt new file mode 100644 index 0000000000..8fcf823c4f --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/BootReceiver.kt @@ -0,0 +1,39 @@ +package net.nymtech.nymconnect.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import net.nymtech.nymconnect.repository.Repository +import net.nymtech.nymconnect.service.foreground.ServiceManager +import net.nymtech.nymconnect.service.tunnel.model.Settings +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class BootReceiver : BroadcastReceiver() { + + @Inject + lateinit var settingsRepo : Repository + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_BOOT_COMPLETED) { + CoroutineScope(Dispatchers.IO).launch { + try { + val settings = settingsRepo.getAll() + if (!settings.isNullOrEmpty()) { + val setting = settings.first() + if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) { + ServiceManager.startWatcherService(context, setting.defaultTunnel!!) + } + } + } finally { + cancel() + } + } + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt new file mode 100644 index 0000000000..97033a8f63 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/receiver/NotificationActionReceiver.kt @@ -0,0 +1,39 @@ +package net.nymtech.nymconnect.receiver + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import net.nymtech.nymconnect.repository.Repository +import net.nymtech.nymconnect.service.foreground.ServiceManager +import net.nymtech.nymconnect.service.tunnel.model.Settings +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@AndroidEntryPoint +class NotificationActionReceiver : BroadcastReceiver() { + + @Inject + lateinit var settingsRepo : Repository + override fun onReceive(context: Context, intent: Intent?) { + CoroutineScope(Dispatchers.IO).launch { + try { + val settings = settingsRepo.getAll() + if (!settings.isNullOrEmpty()) { + val setting = settings.first() + if (setting.defaultTunnel != null) { + ServiceManager.stopVpnService(context) + delay(1000) + ServiceManager.startVpnService(context, setting.defaultTunnel.toString()) + } + } + } finally { + cancel() + } + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/Repository.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/Repository.kt new file mode 100644 index 0000000000..187957132c --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/Repository.kt @@ -0,0 +1,16 @@ +package net.nymtech.nymconnect.repository + +import kotlinx.coroutines.flow.Flow + +interface Repository { + suspend fun save(t : T) + suspend fun saveAll(t : List) + suspend fun getById(id : Long) : T? + suspend fun getAll() : List? + suspend fun delete(t : T) : Boolean? + suspend fun count() : Long? + + val itemFlow : Flow> + + fun init() +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/SettingsBox.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/SettingsBox.kt new file mode 100644 index 0000000000..f56247463c --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/SettingsBox.kt @@ -0,0 +1,63 @@ +package net.nymtech.nymconnect.repository + +import net.nymtech.nymconnect.service.tunnel.model.Settings +import io.objectbox.Box +import io.objectbox.BoxStore +import io.objectbox.kotlin.awaitCallInTx +import io.objectbox.kotlin.toFlow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import javax.inject.Inject + + +class SettingsBox @Inject constructor(private val box : Box, private val boxStore : BoxStore) : Repository { + + @OptIn(ExperimentalCoroutinesApi::class) + override val itemFlow = box.query().build().subscribe().toFlow() + + override fun init() { + CoroutineScope(Dispatchers.IO).launch { + if(getAll().isNullOrEmpty()) { + save(Settings()) + } + } + } + + override suspend fun save(t : Settings) { + boxStore.awaitCallInTx { + box.put(t) + } + } + + override suspend fun saveAll(t : List) { + boxStore.awaitCallInTx { + box.put(t) + } + } + + override suspend fun getById(id: Long): Settings? { + return boxStore.awaitCallInTx { + box[id] + } + } + + override suspend fun getAll(): List? { + return boxStore.awaitCallInTx { + box.all + } + } + + override suspend fun delete(t : Settings): Boolean? { + return boxStore.awaitCallInTx { + box.remove(t) + } + } + + override suspend fun count() : Long? { + return boxStore.awaitCallInTx { + box.count() + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/TunnelBox.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/TunnelBox.kt new file mode 100644 index 0000000000..c57621d53c --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/repository/TunnelBox.kt @@ -0,0 +1,57 @@ +package net.nymtech.nymconnect.repository + +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import io.objectbox.Box +import io.objectbox.BoxStore +import io.objectbox.kotlin.awaitCallInTx +import io.objectbox.kotlin.toFlow +import kotlinx.coroutines.ExperimentalCoroutinesApi +import timber.log.Timber +import javax.inject.Inject + +class TunnelBox @Inject constructor(private val box : Box,private val boxStore : BoxStore) : Repository { + + @OptIn(ExperimentalCoroutinesApi::class) + override val itemFlow = box.query().build().subscribe().toFlow() + override fun init() { + + } + + override suspend fun save(t : TunnelConfig) { + Timber.d("Saving tunnel config") + boxStore.awaitCallInTx { + box.put(t) + } + + } + + override suspend fun saveAll(t : List) { + boxStore.awaitCallInTx { + box.put(t) + } + } + + override suspend fun getById(id: Long): TunnelConfig? { + return boxStore.awaitCallInTx { + box[id] + } + } + + override suspend fun getAll(): List? { + return boxStore.awaitCallInTx { + box.all + } + } + + override suspend fun delete(t : TunnelConfig): Boolean? { + return boxStore.awaitCallInTx { + box.remove(t) + } + } + + override suspend fun count() : Long? { + return boxStore.awaitCallInTx { + box.count() + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/Action.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/Action.kt new file mode 100644 index 0000000000..ba4f930b12 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/Action.kt @@ -0,0 +1,6 @@ +package net.nymtech.nymconnect.service.foreground + +enum class Action { + START, + STOP +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt new file mode 100644 index 0000000000..9aaf0b93d8 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ForegroundService.kt @@ -0,0 +1,64 @@ +package net.nymtech.nymconnect.service.foreground + +import android.app.Service +import android.content.Intent +import android.os.Bundle +import android.os.IBinder +import timber.log.Timber + + +open class ForegroundService : Service() { + + private var isServiceStarted = false + + override fun onBind(intent: Intent): IBinder? { + // We don't provide binding, so return null + return null + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Timber.d("onStartCommand executed with startId: $startId") + if (intent != null) { + val action = intent.action + Timber.d("using an intent with action $action") + when (action) { + Action.START.name -> startService(intent.extras) + Action.STOP.name -> stopService(intent.extras) + "android.net.VpnService" -> { + Timber.d("Always-on VPN starting service") + startService(intent.extras) + } + else -> Timber.d("This should never happen. No action in the received intent") + } + } else { + Timber.d( + "with a null intent. It has been probably restarted by the system." + ) + } + // by returning this we make sure the service is restarted if the system kills the service + return START_STICKY + } + + + override fun onDestroy() { + super.onDestroy() + Timber.d("The service has been destroyed") + } + + protected open fun startService(extras : Bundle?) { + if (isServiceStarted) return + Timber.d("Starting ${this.javaClass.simpleName}") + isServiceStarted = true + } + + protected open fun stopService(extras : Bundle?) { + Timber.d("Stopping ${this.javaClass.simpleName}") + try { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } catch (e: Exception) { + Timber.d("Service stopped without being started: ${e.message}") + } + isServiceStarted = false + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceManager.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceManager.kt new file mode 100644 index 0000000000..c00a796453 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceManager.kt @@ -0,0 +1,90 @@ +package net.nymtech.nymconnect.service.foreground + +import android.app.ActivityManager +import android.app.Application +import android.app.Service +import android.content.Context +import android.content.Context.ACTIVITY_SERVICE +import android.content.Intent +import net.nymtech.nymconnect.R +import timber.log.Timber + +object ServiceManager { + @Suppress("DEPRECATION") + private // Deprecated for third party Services. + fun Context.isServiceRunning(service: Class) = + (getSystemService(ACTIVITY_SERVICE) as ActivityManager) + .getRunningServices(Integer.MAX_VALUE) + .any { it.service.className == service.name } + + fun getServiceState(context: Context, cls : Class): ServiceState { + val isServiceRunning = context.isServiceRunning(cls) + return if(isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED + } + + private fun actionOnService(action: Action, context: Context, cls : Class, extras : Map? = null) { + if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return + if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return + val intent = Intent(context, cls).also { + it.action = action.name + extras?.forEach {(k, v) -> + it.putExtra(k, v) + } + } + intent.component?.javaClass + try { + when(action) { + Action.START -> { + try { + context.startForegroundService(intent) + } catch (e : Exception) { + Timber.e("Unable to start service foreground ${e.message}") + context.startService(intent) + } + } + Action.STOP -> context.startService(intent) + } + } catch (e : Exception) { + Timber.tag("ServiceManager").e(e) + } + } + + fun startVpnService(context : Context, tunnelConfig : String) { + actionOnService( + Action.START, + context, + WireGuardTunnelService::class.java, + mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)) + } + fun stopVpnService(context : Context) { + actionOnService( + Action.STOP, + context, + WireGuardTunnelService::class.java + ) + } + + fun startWatcherService(context : Context, tunnelConfig : String) { + actionOnService( + Action.START, context, + WireGuardConnectivityWatcherService::class.java, mapOf(context. + getString(R.string.tunnel_extras_key) to + tunnelConfig)) + } + + fun stopWatcherService(context : Context) { + actionOnService( + Action.STOP, context, + WireGuardConnectivityWatcherService::class.java) + } + + fun toggleWatcherService(context: Context, tunnelConfig : String) { + when(getServiceState( + context, + WireGuardConnectivityWatcherService::class.java, + )) { + ServiceState.STARTED -> stopWatcherService(context) + ServiceState.STOPPED -> startWatcherService(context, tunnelConfig) + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceState.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceState.kt new file mode 100644 index 0000000000..9c26fa0795 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/ServiceState.kt @@ -0,0 +1,6 @@ +package net.nymtech.nymconnect.service.foreground + +enum class ServiceState { + STARTED, + STOPPED, +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt new file mode 100644 index 0000000000..fcfa811ea2 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardConnectivityWatcherService.kt @@ -0,0 +1,210 @@ +package net.nymtech.nymconnect.service.foreground + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.PowerManager +import android.os.SystemClock +import com.wireguard.android.backend.Tunnel +import net.nymtech.nymconnect.Constants +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.repository.Repository +import net.nymtech.nymconnect.service.network.MobileDataService +import net.nymtech.nymconnect.service.network.NetworkService +import net.nymtech.nymconnect.service.network.NetworkStatus +import net.nymtech.nymconnect.service.network.WifiService +import net.nymtech.nymconnect.service.notification.NotificationService +import net.nymtech.nymconnect.service.tunnel.VpnService +import net.nymtech.nymconnect.service.tunnel.model.Settings +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class WireGuardConnectivityWatcherService : ForegroundService() { + + private val foregroundId = 122; + + @Inject + lateinit var wifiService : NetworkService + + @Inject + lateinit var mobileDataService : NetworkService + + @Inject + lateinit var settingsRepo: Repository + + @Inject + lateinit var notificationService : NotificationService + + @Inject + lateinit var vpnService : VpnService + + private var isWifiConnected = false; + private var isMobileDataConnected = false; + private var currentNetworkSSID = ""; + + private lateinit var watcherJob : Job; + private lateinit var setting : Settings + private lateinit var tunnelConfig: String + + private var wakeLock: PowerManager.WakeLock? = null + private val tag = this.javaClass.name; + + + override fun startService(extras: Bundle?) { + super.startService(extras) + val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key)) + if (tunnelId != null) { + this.tunnelConfig = tunnelId + } + // we need this lock so our service gets not affected by Doze Mode + initWakeLock() + cancelWatcherJob() + launchWatcherNotification() + if(this::tunnelConfig.isInitialized) { + startWatcherJob() + } else { + stopService(extras) + } + } + + override fun stopService(extras: Bundle?) { + super.stopService(extras) + wakeLock?.let { + if (it.isHeld) { + it.release() + } + } + cancelWatcherJob() + stopSelf() + } + + private fun launchWatcherNotification() { + val notification = notificationService.createNotification( + channelId = getString(R.string.watcher_channel_id), + channelName = getString(R.string.watcher_channel_name), + description = getString(R.string.watcher_notification_text)) + super.startForeground(foregroundId, notification) + } + + //try to start task again if killed + override fun onTaskRemoved(rootIntent: Intent) { + Timber.d("Task Removed called") + val restartServiceIntent = Intent(rootIntent) + val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE); + applicationContext.getSystemService(Context.ALARM_SERVICE); + val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager; + alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent); + } + + private fun initWakeLock() { + wakeLock = + (getSystemService(Context.POWER_SERVICE) as PowerManager).run { + newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { + acquire() + } + } + } + + private fun cancelWatcherJob() { + if(this::watcherJob.isInitialized) { + watcherJob.cancel() + } + } + + private fun startWatcherJob() { + watcherJob = CoroutineScope(Dispatchers.IO).launch { + val settings = settingsRepo.getAll(); + if(!settings.isNullOrEmpty()) { + setting = settings[0] + } + launch { + watchForWifiConnectivityChanges() + } + if(setting.isTunnelOnMobileDataEnabled) { + launch { + watchForMobileDataConnectivityChanges() + } + } + launch { + manageVpn() + } + } + } + + private suspend fun watchForMobileDataConnectivityChanges() { + mobileDataService.networkStatus.collect { + when(it) { + is NetworkStatus.Available -> { + Timber.d("Gained Mobile data connection") + isMobileDataConnected = true + } + is NetworkStatus.CapabilitiesChanged -> { + isMobileDataConnected = true + Timber.d("Mobile data capabilities changed") + } + is NetworkStatus.Unavailable -> { + isMobileDataConnected = false + Timber.d("Lost mobile data connection") + } + + else -> {} + } + } + } + + private suspend fun watchForWifiConnectivityChanges() { + wifiService.networkStatus.collect { + when (it) { + is NetworkStatus.Available -> { + Timber.d("Gained Wi-Fi connection") + isWifiConnected = true + } + is NetworkStatus.CapabilitiesChanged -> { + Timber.d("Wifi capabilities changed") + isWifiConnected = true + currentNetworkSSID = wifiService.getNetworkName(it.networkCapabilities) ?: ""; + } + is NetworkStatus.Unavailable -> { + isWifiConnected = false + Timber.d("Lost Wi-Fi connection") + } + + else -> {} + } + } + } + + private suspend fun manageVpn() { + while(watcherJob.isActive) { + if(setting.isTunnelOnMobileDataEnabled && + !isWifiConnected && + isMobileDataConnected + && vpnService.getState() == Tunnel.State.DOWN) { + ServiceManager.startVpnService(this, tunnelConfig) + } else if(!setting.isTunnelOnMobileDataEnabled && + !isWifiConnected && + vpnService.getState() == Tunnel.State.UP) { + ServiceManager.stopVpnService(this) + } else if(isWifiConnected && + !setting.trustedNetworkSSIDs.contains(currentNetworkSSID) && + (vpnService.getState() != Tunnel.State.UP)) { + ServiceManager.startVpnService(this, tunnelConfig) + } else if((isWifiConnected && + setting.trustedNetworkSSIDs.contains(currentNetworkSSID)) && + (vpnService.getState() == Tunnel.State.UP)) { + ServiceManager.stopVpnService(this) + } + delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL) + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt new file mode 100644 index 0000000000..776928e649 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/foreground/WireGuardTunnelService.kt @@ -0,0 +1,154 @@ +package net.nymtech.nymconnect.service.foreground + +import android.app.PendingIntent +import android.content.Intent +import android.os.Bundle +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.receiver.NotificationActionReceiver +import net.nymtech.nymconnect.repository.Repository +import net.nymtech.nymconnect.service.notification.NotificationService +import net.nymtech.nymconnect.service.tunnel.HandshakeStatus +import net.nymtech.nymconnect.service.tunnel.VpnService +import net.nymtech.nymconnect.service.tunnel.model.Settings +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class WireGuardTunnelService : ForegroundService() { + + private val foregroundId = 123; + + @Inject + lateinit var vpnService : VpnService + + @Inject + lateinit var settingsRepo: Repository + + @Inject + lateinit var notificationService : NotificationService + + private lateinit var job : Job + + private var tunnelName : String = "" + + override fun startService(extras : Bundle?) { + super.startService(extras) + val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) + cancelJob() + job = CoroutineScope(Dispatchers.IO).launch { + if(tunnelConfigString != null) { + try { + val tunnelConfig = TunnelConfig.from(tunnelConfigString) + tunnelName = tunnelConfig.name + vpnService.startTunnel(tunnelConfig) + launchVpnStartingNotification() + } catch (e : Exception) { + Timber.e("Problem starting tunnel: ${e.message}") + stopService(extras) + } + } else { + Timber.d("Tunnel config null, starting default tunnel") + val settings = settingsRepo.getAll(); + if(!settings.isNullOrEmpty()) { + val setting = settings[0] + if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) { + val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!) + tunnelName = tunnelConfig.name + vpnService.startTunnel(tunnelConfig) + launchVpnStartingNotification() + } + } + } + } + CoroutineScope(job).launch { + var didShowConnected = false + var didShowFailedHandshakeNotification = false + vpnService.handshakeStatus.collect { + when(it) { + HandshakeStatus.NOT_STARTED -> { + } + HandshakeStatus.NEVER_CONNECTED -> { + if(!didShowFailedHandshakeNotification) { + launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message)) + didShowFailedHandshakeNotification = true + didShowConnected = false + } + } + HandshakeStatus.HEALTHY -> { + if(!didShowConnected) { + launchVpnConnectedNotification() + didShowConnected = true + } + } + HandshakeStatus.UNHEALTHY -> { + if(!didShowFailedHandshakeNotification) { + launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message)) + didShowFailedHandshakeNotification = true + didShowConnected = false + } + } + } + } + } + } + + override fun stopService(extras : Bundle?) { + super.stopService(extras) + CoroutineScope(Dispatchers.IO).launch() { + vpnService.stopTunnel() + } + cancelJob() + stopSelf() + } + + private fun launchVpnConnectedNotification() { + val notification = notificationService.createNotification( + channelId = getString(R.string.vpn_channel_id), + channelName = getString(R.string.vpn_channel_name), + title = getString(R.string.tunnel_start_title), + onGoing = false, + showTimestamp = true, + description = "${getString(R.string.tunnel_start_text)} $tunnelName" + ) + super.startForeground(foregroundId, notification) + } + + private fun launchVpnStartingNotification() { + val notification = notificationService.createNotification( + channelId = getString(R.string.vpn_channel_id), + channelName = getString(R.string.vpn_channel_name), + title = getString(R.string.vpn_starting), + onGoing = false, + showTimestamp = true, + description = getString(R.string.attempt_connection) + ) + super.startForeground(foregroundId, notification) + } + + private fun launchVpnConnectionFailedNotification(message : String) { + val notification = notificationService.createNotification( + channelId = getString(R.string.vpn_channel_id), + channelName = getString(R.string.vpn_channel_name), + action = PendingIntent.getBroadcast(this,0,Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE), + actionText = getString(R.string.restart), + title = getString(R.string.vpn_connection_failed), + onGoing = false, + showTimestamp = true, + description = message + ) + super.startForeground(foregroundId, notification) + } + + + private fun cancelJob() { + if(this::job.isInitialized) { + job.cancel() + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/BaseNetworkService.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/BaseNetworkService.kt new file mode 100644 index 0000000000..9574d659ef --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/BaseNetworkService.kt @@ -0,0 +1,117 @@ +package net.nymtech.nymconnect.service.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.net.wifi.SupplicantState +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import android.os.Build +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.map + + +abstract class BaseNetworkService>(val context: Context, networkCapability : Int) : NetworkService { + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + private val wifiManager = + context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + + override val networkStatus = callbackFlow { + val networkStatusCallback = when (Build.VERSION.SDK_INT) { + in Build.VERSION_CODES.S..Int.MAX_VALUE -> { + object : ConnectivityManager.NetworkCallback( + FLAG_INCLUDE_LOCATION_INFO + ) { + override fun onAvailable(network: Network) { + trySend(NetworkStatus.Available(network)) + } + + override fun onLost(network: Network) { + trySend(NetworkStatus.Unavailable(network)) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + trySend(NetworkStatus.CapabilitiesChanged(network, networkCapabilities)) + } + } + } + + else -> { + object : ConnectivityManager.NetworkCallback() { + + override fun onAvailable(network: Network) { + trySend(NetworkStatus.Available(network)) + } + + override fun onLost(network: Network) { + trySend(NetworkStatus.Unavailable(network)) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + trySend(NetworkStatus.CapabilitiesChanged(network, networkCapabilities)) + } + } + } + } + val request = NetworkRequest.Builder() + .addTransportType(networkCapability) + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + connectivityManager.registerNetworkCallback(request, networkStatusCallback) + + awaitClose { + connectivityManager.unregisterNetworkCallback(networkStatusCallback) + } + } + + + override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? { + var ssid: String? = getWifiNameFromCapabilities(networkCapabilities) + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + val info = wifiManager.connectionInfo + if (info.supplicantState === SupplicantState.COMPLETED) { + ssid = info.ssid + } + } + return ssid?.trim('"') + } + + + companion object { + private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val info: WifiInfo + if (networkCapabilities.transportInfo is WifiInfo) { + info = networkCapabilities.transportInfo as WifiInfo + return info.ssid + } + } + return null + } + } +} + +inline fun Flow.map( + crossinline onUnavailable: suspend (network : Network) -> Result, + crossinline onAvailable: suspend (network : Network) -> Result, + crossinline onCapabilitiesChanged: suspend (network : Network, networkCapabilities : NetworkCapabilities) -> Result, +): Flow = map { status -> + when (status) { + is NetworkStatus.Unavailable -> onUnavailable(status.network) + is NetworkStatus.Available -> onAvailable(status.network) + is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(status.network, status.networkCapabilities) + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/MobileDataService.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/MobileDataService.kt new file mode 100644 index 0000000000..a067af06cf --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/MobileDataService.kt @@ -0,0 +1,10 @@ +package net.nymtech.nymconnect.service.network + +import android.content.Context +import android.net.NetworkCapabilities +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class MobileDataService @Inject constructor(@ApplicationContext context: Context) : + BaseNetworkService(context, NetworkCapabilities.TRANSPORT_CELLULAR) { +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/NetworkService.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/NetworkService.kt new file mode 100644 index 0000000000..6c274dab03 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/NetworkService.kt @@ -0,0 +1,10 @@ +package net.nymtech.nymconnect.service.network + +import android.net.NetworkCapabilities +import kotlinx.coroutines.flow.Flow + +interface NetworkService { + fun getNetworkName(networkCapabilities: NetworkCapabilities) : String? + val networkStatus : Flow + +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/NetworkStatus.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/NetworkStatus.kt new file mode 100644 index 0000000000..5446ce52a6 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/NetworkStatus.kt @@ -0,0 +1,10 @@ +package net.nymtech.nymconnect.service.network + +import android.net.Network +import android.net.NetworkCapabilities + +sealed class NetworkStatus { + class Available(val network : Network) : NetworkStatus() + class Unavailable(val network : Network) : NetworkStatus() + class CapabilitiesChanged(val network : Network, val networkCapabilities : NetworkCapabilities) : NetworkStatus() +} diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/WifiService.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/WifiService.kt new file mode 100644 index 0000000000..a01ebba746 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/network/WifiService.kt @@ -0,0 +1,10 @@ +package net.nymtech.nymconnect.service.network + +import android.content.Context +import android.net.NetworkCapabilities +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class WifiService @Inject constructor(@ApplicationContext context: Context) : + BaseNetworkService(context, NetworkCapabilities.TRANSPORT_WIFI) { +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/NotificationService.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/NotificationService.kt new file mode 100644 index 0000000000..4071e64202 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/NotificationService.kt @@ -0,0 +1,21 @@ +package net.nymtech.nymconnect.service.notification + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent + +interface NotificationService { + fun createNotification( + channelId: String, + channelName: String, + title: String = "", + action: PendingIntent? = null, + actionText: String? = null, + description: String, + showTimestamp : Boolean = false, + importance: Int = NotificationManager.IMPORTANCE_HIGH, + vibration: Boolean = true, + onGoing: Boolean = true, + lights: Boolean = true + ): Notification +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/WireGuardNotification.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/WireGuardNotification.kt new file mode 100644 index 0000000000..4e4cdb3e1b --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/notification/WireGuardNotification.kt @@ -0,0 +1,77 @@ +package net.nymtech.nymconnect.service.notification + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Color +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.ui.MainActivity +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) : NotificationService { + + private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; + + override fun createNotification( + channelId: String, + channelName: String, + title: String, + action: PendingIntent?, + actionText: String?, + description: String, + showTimestamp: Boolean, + importance: Int, + vibration: Boolean, + onGoing: Boolean, + lights: Boolean + ): Notification { + val channel = NotificationChannel( + channelId, + channelName, + importance + ).let { + it.description = title + it.enableLights(lights) + it.lightColor = Color.RED + it.enableVibration(vibration) + it.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400) + it + } + notificationManager.createNotificationChannel(channel) + val pendingIntent: PendingIntent = + Intent(context, MainActivity::class.java).let { notificationIntent -> + PendingIntent.getActivity( + context, + 0, + notificationIntent, + PendingIntent.FLAG_IMMUTABLE + ) + } + + val builder: Notification.Builder = + Notification.Builder( + context, + channelId + ) + return builder.let { + if(action != null && actionText != null) { + //TODO find a not deprecated way to do this + it.addAction( + Notification.Action.Builder(0, actionText, action) + .build()) + it.setAutoCancel(true) + } + it.setContentTitle(title) + .setContentText(description) + .setContentIntent(pendingIntent) + .setOngoing(onGoing) + .setShowWhen(showTimestamp) + .setSmallIcon(R.mipmap.ic_launcher_foreground) + .build() + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsActivity.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsActivity.kt new file mode 100644 index 0000000000..c88825e71e --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsActivity.kt @@ -0,0 +1,28 @@ +package net.nymtech.nymconnect.service.shortcut + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.service.foreground.Action +import net.nymtech.nymconnect.service.foreground.ServiceManager +import net.nymtech.nymconnect.service.foreground.WireGuardTunnelService +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ShortcutsActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if(intent.getStringExtra(ShortcutsManager.CLASS_NAME_EXTRA_KEY) + .equals(WireGuardTunnelService::class.java.name)) { + intent.getStringExtra(getString(R.string.tunnel_extras_key))?.let { + ServiceManager.toggleWatcherService(this, it) + } + when(intent.action){ + Action.STOP.name -> ServiceManager.stopVpnService(this) + Action.START.name -> intent.getStringExtra(getString(R.string.tunnel_extras_key)) + ?.let { ServiceManager.startVpnService(this, it) } + } + } + finish() + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsManager.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsManager.kt new file mode 100644 index 0000000000..13df427ebd --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/shortcut/ShortcutsManager.kt @@ -0,0 +1,73 @@ +package net.nymtech.nymconnect.service.shortcut + +import android.content.Context +import android.content.Intent +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.service.foreground.Action +import net.nymtech.nymconnect.service.foreground.WireGuardTunnelService +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig + +object ShortcutsManager { + + private const val SHORT_LABEL_MAX_SIZE = 10; + private const val LONG_LABEL_MAX_SIZE = 25; + private const val APPEND_ON = " On"; + private const val APPEND_OFF = " Off" + const val CLASS_NAME_EXTRA_KEY = "className" + + private fun createAndPushShortcut(context : Context, intent : Intent, id : String, shortLabel : String, + longLabel : String, drawable : Int ) { + val shortcut = ShortcutInfoCompat.Builder(context, id) + .setShortLabel(shortLabel) + .setLongLabel(longLabel) + .setIcon(IconCompat.createWithResource(context, drawable)) + .setIntent(intent) + .build() + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + } + + fun createTunnelShortcuts(context : Context, tunnelConfig : TunnelConfig) { + createAndPushShortcut(context, + createTunnelOnIntent(context, mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig.toString())), + tunnelConfig.id.toString() + APPEND_ON, + tunnelConfig.name.take((SHORT_LABEL_MAX_SIZE - APPEND_ON.length)) + APPEND_ON, + tunnelConfig.name.take((LONG_LABEL_MAX_SIZE - APPEND_ON.length)) + APPEND_ON, + R.drawable.vpn_on + ) + createAndPushShortcut(context, + createTunnelOffIntent(context, mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig.toString())), + tunnelConfig.id.toString() + APPEND_OFF, + tunnelConfig.name.take((SHORT_LABEL_MAX_SIZE - APPEND_OFF.length)) + APPEND_OFF, + tunnelConfig.name.take((LONG_LABEL_MAX_SIZE - APPEND_OFF.length)) + APPEND_OFF, + R.drawable.vpn_off + ) + } + + fun removeTunnelShortcuts(context : Context, tunnelConfig : TunnelConfig) { + ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(tunnelConfig.id.toString() + APPEND_ON, + tunnelConfig.id.toString() + APPEND_OFF )) + } + + private fun createTunnelOnIntent(context: Context, extras : Map) : Intent { + return Intent(context, ShortcutsActivity::class.java).also { + it.action = Action.START.name + it.putExtra(CLASS_NAME_EXTRA_KEY, WireGuardTunnelService::class.java.name) + extras.forEach {(k, v) -> + it.putExtra(k, v) + } + } + } + + private fun createTunnelOffIntent(context : Context, extras : Map) : Intent { + return Intent(context, ShortcutsActivity::class.java).also { + it.action = Action.STOP.name + it.putExtra(CLASS_NAME_EXTRA_KEY, WireGuardTunnelService::class.java.name) + extras.forEach {(k, v) -> + it.putExtra(k, v) + } + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/TunnelControlTile.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/TunnelControlTile.kt new file mode 100644 index 0000000000..38c774c963 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tile/TunnelControlTile.kt @@ -0,0 +1,142 @@ +package net.nymtech.nymconnect.service.tile + +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService +import com.wireguard.android.backend.Tunnel +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.repository.Repository +import net.nymtech.nymconnect.service.foreground.ServiceManager +import net.nymtech.nymconnect.service.tunnel.VpnService +import net.nymtech.nymconnect.service.tunnel.model.Settings +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class TunnelControlTile : TileService() { + + @Inject + lateinit var settingsRepo : Repository + + @Inject + lateinit var configRepo : Repository + + @Inject + lateinit var vpnService : VpnService + + private val scope = CoroutineScope(Dispatchers.Main); + + private lateinit var job : Job + + override fun onStartListening() { + job = scope.launch { + updateTileState() + } + super.onStartListening() + } + + override fun onTileAdded() { + super.onTileAdded() + qsTile.contentDescription = this.resources.getString(R.string.toggle_vpn) + scope.launch { + updateTileState(); + } + } + + override fun onTileRemoved() { + super.onTileRemoved() + cancelJob() + } + + override fun onClick() { + super.onClick() + unlockAndRun { + scope.launch { + try { + val tunnel = determineTileTunnel(); + if(tunnel != null) { + attemptWatcherServiceToggle(tunnel.toString()) + if(vpnService.getState() == Tunnel.State.UP) { + ServiceManager.stopVpnService(this@TunnelControlTile) + } else { + ServiceManager.startVpnService(this@TunnelControlTile, tunnel.toString()) + } + } + } catch (e : Exception) { + Timber.e(e.message) + } finally { + cancel() + } + } + } + } + + private suspend fun determineTileTunnel() : TunnelConfig? { + var tunnelConfig : TunnelConfig? = null; + val settings = settingsRepo.getAll() + if (!settings.isNullOrEmpty()) { + val setting = settings.first() + tunnelConfig = if (setting.defaultTunnel != null) { + TunnelConfig.from(setting.defaultTunnel!!); + } else { + val config = configRepo.getAll()?.first(); + config; + } + } + return tunnelConfig; + } + + + private fun attemptWatcherServiceToggle(tunnelConfig : String) { + scope.launch { + val settings = settingsRepo.getAll() + if (!settings.isNullOrEmpty()) { + val setting = settings.first() + if(setting.isAutoTunnelEnabled) { + ServiceManager.toggleWatcherService(this@TunnelControlTile, tunnelConfig) + } + } + } + } + + private suspend fun updateTileState() { + vpnService.state.collect { + when(it) { + Tunnel.State.UP -> { + qsTile.state = Tile.STATE_ACTIVE + } + Tunnel.State.DOWN -> { + qsTile.state = Tile.STATE_INACTIVE; + } + else -> { + qsTile.state = Tile.STATE_UNAVAILABLE + } + } + val config = determineTileTunnel(); + setTileDescription(config?.name ?: this.resources.getString(R.string.no_tunnel_available)) + qsTile.updateTile() + } + } + + private fun setTileDescription(description : String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + qsTile.subtitle = description + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + qsTile.stateDescription = description; + } + } + + private fun cancelJob() { + if(this::job.isInitialized) { + job.cancel(); + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/HandshakeStatus.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/HandshakeStatus.kt new file mode 100644 index 0000000000..1d2fa54ea7 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/HandshakeStatus.kt @@ -0,0 +1,14 @@ +package net.nymtech.nymconnect.service.tunnel + +enum class HandshakeStatus { + HEALTHY, + UNHEALTHY, + NEVER_CONNECTED, + NOT_STARTED; + + companion object { + private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 120 + const val UNHEALTHY_TIME_LIMIT_SEC = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + 60 + const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30 + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnService.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnService.kt new file mode 100644 index 0000000000..15c8c1b676 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/VpnService.kt @@ -0,0 +1,18 @@ +package net.nymtech.nymconnect.service.tunnel + +import com.wireguard.android.backend.Statistics +import com.wireguard.android.backend.Tunnel +import com.wireguard.crypto.Key +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import kotlinx.coroutines.flow.SharedFlow + +interface VpnService : Tunnel { + suspend fun startTunnel(tunnelConfig : TunnelConfig) : Tunnel.State + suspend fun stopTunnel() + val state : SharedFlow + val tunnelName : SharedFlow + val statistics : SharedFlow + val lastHandshake : SharedFlow> + val handshakeStatus : SharedFlow + fun getState() : Tunnel.State +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt new file mode 100644 index 0000000000..ae9a3c233d --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/WireGuardTunnel.kt @@ -0,0 +1,131 @@ +package net.nymtech.nymconnect.service.tunnel + +import com.wireguard.android.backend.Backend +import com.wireguard.android.backend.BackendException +import com.wireguard.android.backend.Statistics +import com.wireguard.android.backend.Tunnel +import com.wireguard.crypto.Key +import net.nymtech.nymconnect.Constants +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import net.nymtech.nymconnect.util.NumberUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + + +class WireGuardTunnel @Inject constructor(private val backend : Backend, +) : VpnService { + + private val _tunnelName = MutableStateFlow("") + override val tunnelName get() = _tunnelName.asStateFlow() + + private val _state = MutableSharedFlow( + onBufferOverflow = BufferOverflow.DROP_OLDEST, + replay = 1) + + private val _handshakeStatus = MutableSharedFlow(replay = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST) + override val state get() = _state.asSharedFlow() + + private val _statistics = MutableSharedFlow(replay = 1) + override val statistics get() = _statistics.asSharedFlow() + + private val _lastHandshake = MutableSharedFlow>(replay = 1) + override val lastHandshake get() = _lastHandshake.asSharedFlow() + + override val handshakeStatus: SharedFlow + get() = _handshakeStatus.asSharedFlow() + + private lateinit var statsJob : Job + + + override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{ + return try { + if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) { + stopTunnel() + } + _tunnelName.emit(tunnelConfig.name) + val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) + val state = backend.setState( + this, Tunnel.State.UP, config) + _state.emit(state) + state; + } catch (e : Exception) { + Timber.e("Failed to start tunnel with error: ${e.message}") + Tunnel.State.DOWN + } + } + + override fun getName(): String { + return _tunnelName.value + } + + override suspend fun stopTunnel() { + try { + if(getState() == Tunnel.State.UP) { + val state = backend.setState(this, Tunnel.State.DOWN, null) + _state.emit(state) + } + } catch (e : BackendException) { + Timber.e("Failed to stop tunnel with error: ${e.message}") + } + } + + override fun getState(): Tunnel.State { + return backend.getState(this) + } + + override fun onStateChange(state : Tunnel.State) { + val tunnel = this; + _state.tryEmit(state) + if(state == Tunnel.State.UP) { + statsJob = CoroutineScope(Dispatchers.IO).launch { + val handshakeMap = HashMap() + var neverHadHandshakeCounter = 0 + while (true) { + val statistics = backend.getStatistics(tunnel) + _statistics.emit(statistics) + statistics.peers().forEach { + val handshakeEpoch = statistics.peer(it)?.latestHandshakeEpochMillis ?: 0L + handshakeMap[it] = handshakeEpoch + if(handshakeEpoch == 0L) { + if(neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) { + _handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED) + } else { + _handshakeStatus.emit(HandshakeStatus.NOT_STARTED) + } + if(neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) { + neverHadHandshakeCounter += 10 + } + return@forEach + } + if(NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) >= HandshakeStatus.UNHEALTHY_TIME_LIMIT_SEC) { + _handshakeStatus.emit(HandshakeStatus.UNHEALTHY) + } else { + _handshakeStatus.emit(HandshakeStatus.HEALTHY) + } + } + _lastHandshake.emit(handshakeMap) + delay(Constants.VPN_STATISTIC_CHECK_INTERVAL) + } + } + } + if(state == Tunnel.State.DOWN) { + if(this::statsJob.isInitialized) { + statsJob.cancel() + } + _handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED) + _lastHandshake.tryEmit(emptyMap()) + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/model/Settings.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/model/Settings.kt new file mode 100644 index 0000000000..2aff4e9f11 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/model/Settings.kt @@ -0,0 +1,15 @@ +package net.nymtech.nymconnect.service.tunnel.model + +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id + +@Entity +data class Settings( + @Id + var id : Long = 0, + var isAutoTunnelEnabled : Boolean = false, + var isTunnelOnMobileDataEnabled : Boolean = false, + var trustedNetworkSSIDs : MutableList = mutableListOf(), + var defaultTunnel : String? = null, + var isAlwaysOnVpnEnabled : Boolean = false, +) diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/model/TunnelConfig.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/model/TunnelConfig.kt new file mode 100644 index 0000000000..91f7440d0d --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/service/tunnel/model/TunnelConfig.kt @@ -0,0 +1,89 @@ +package net.nymtech.nymconnect.service.tunnel.model + +import com.wireguard.config.Config +import io.objectbox.annotation.ConflictStrategy +import io.objectbox.annotation.Entity +import io.objectbox.annotation.Id +import io.objectbox.annotation.Unique +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.InputStream + + +@Entity +@Serializable +data class TunnelConfig( + @Id + var id : Long = 0, + @Unique(onConflict = ConflictStrategy.REPLACE) + var name : String, + var wgQuick : String +) { + + + override fun toString(): String { + return Json.encodeToString(serializer(), this) + } + + companion object { + private const val INCLUDED_APPLICATIONS = "IncludedApplications = " + private const val EXCLUDED_APPLICATIONS = "ExcludedApplications = " + private const val INTERFACE = "[Interface]" + private const val NEWLINE_CHAR = "\n" + private const val APP_CONFIG_SEPARATOR = ", " + + private fun addApplicationsToConfig(appConfig : String, wgQuick : String) : String { + val configList = wgQuick.split(NEWLINE_CHAR).toMutableList() + val interfaceIndex = configList.indexOf(INTERFACE) + configList.add(interfaceIndex + 1, appConfig) + return configList.joinToString(NEWLINE_CHAR) + } + + fun clearAllApplicationsFromConfig(wgQuick : String) : String { + val configList = wgQuick.split(NEWLINE_CHAR).toMutableList() + val itr = configList.iterator() + while (itr.hasNext()) { + val next = itr.next() + if(next.contains(INCLUDED_APPLICATIONS) || next.contains(EXCLUDED_APPLICATIONS)) { + itr.remove() + } + } + return configList.joinToString(NEWLINE_CHAR) + } + + + fun setExcludedApplicationsOnQuick(packages : List, wgQuick: String) : String { + if(packages.isEmpty()) { + return wgQuick + } + val clearedWgQuick = clearAllApplicationsFromConfig(wgQuick) + val excludeConfig = buildExcludedApplicationsString(packages) + return addApplicationsToConfig(excludeConfig, clearedWgQuick) + } + + fun setIncludedApplicationsOnQuick(packages : List, wgQuick: String) : String { + if(packages.isEmpty()) { + return wgQuick + } + val clearedWgQuick = clearAllApplicationsFromConfig(wgQuick) + val includeConfig = buildIncludedApplicationsString(packages) + return addApplicationsToConfig(includeConfig, clearedWgQuick) + } + + private fun buildExcludedApplicationsString(packages : List) : String { + return EXCLUDED_APPLICATIONS + packages.joinToString(APP_CONFIG_SEPARATOR) + } + + private fun buildIncludedApplicationsString(packages : List) : String { + return INCLUDED_APPLICATIONS + packages.joinToString(APP_CONFIG_SEPARATOR) + } + fun from(string : String) : TunnelConfig { + return Json.decodeFromString(string) + } + fun configFromQuick(wgQuick: String): Config { + val inputStream: InputStream = wgQuick.byteInputStream() + val reader = inputStream.bufferedReader(Charsets.UTF_8) + return Config.parse(reader) + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt new file mode 100644 index 0000000000..edb82cc213 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/MainActivity.kt @@ -0,0 +1,201 @@ +package net.nymtech.nymconnect.ui + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.view.KeyEvent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInHorizontally +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.onKeyEvent +import com.google.accompanist.navigation.animation.AnimatedNavHost +import com.google.accompanist.navigation.animation.composable +import com.google.accompanist.navigation.animation.rememberAnimatedNavController +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.wireguard.android.backend.GoBackend +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.ui.common.PermissionRequestFailedScreen +import net.nymtech.nymconnect.ui.common.navigation.BottomNavBar +import net.nymtech.nymconnect.ui.screens.config.ConfigScreen +import net.nymtech.nymconnect.ui.screens.detail.DetailScreen +import net.nymtech.nymconnect.ui.screens.main.MainScreen +import net.nymtech.nymconnect.ui.screens.settings.SettingsScreen +import net.nymtech.nymconnect.ui.screens.support.SupportScreen +import net.nymtech.nymconnect.ui.theme.TransparentSystemBars +import net.nymtech.nymconnect.ui.theme.WireguardAutoTunnelTheme +import dagger.hilt.android.AndroidEntryPoint +import timber.log.Timber +import java.lang.IllegalStateException + +@AndroidEntryPoint +class MainActivity : AppCompatActivity() { + + @OptIn(ExperimentalAnimationApi::class, + ExperimentalPermissionsApi::class + ) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + val navController = rememberAnimatedNavController() + val focusRequester = remember { FocusRequester() } + + WireguardAutoTunnelTheme { + TransparentSystemBars() + + val snackbarHostState = remember { SnackbarHostState() } + + val notificationPermissionState = + rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) + + fun requestNotificationPermission() { + if (!notificationPermissionState.status.isGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionState.launchPermissionRequest() + } + } + + var vpnIntent by remember { mutableStateOf(GoBackend.VpnService.prepare(this)) } + val vpnActivityResultState = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + onResult = { + val accepted = (it.resultCode == RESULT_OK) + if (accepted) { + vpnIntent = null + } + }) + LaunchedEffect(vpnIntent) { + if (vpnIntent != null) { + vpnActivityResultState.launch(vpnIntent) + } else requestNotificationPermission() + } + + Scaffold(snackbarHost = { SnackbarHost(snackbarHostState)}, + modifier = Modifier.onKeyEvent { + if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { + when (it.nativeKeyEvent.keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> { + try { + focusRequester.requestFocus() + } catch(e : IllegalStateException) { + Timber.e("No D-Pad focus request modifier added to element on screen") + } + false + } else -> { + false; + } + } + } else { + false + } + }, + bottomBar = if (vpnIntent == null && notificationPermissionState.status.isGranted) { + { BottomNavBar(navController, Routes.navItems) } + } else { + {} + }, + ) + { padding -> + if (vpnIntent != null) { + PermissionRequestFailedScreen( + padding = padding, + onRequestAgain = { vpnActivityResultState.launch(vpnIntent) }, + message = getString(R.string.vpn_permission_required), + getString(R.string.retry) + ) + return@Scaffold + } + if (!notificationPermissionState.status.isGranted) { + PermissionRequestFailedScreen( + padding = padding, + onRequestAgain = { + val intentSettings = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intentSettings.data = + Uri.fromParts("package", this.packageName, null) + startActivity(intentSettings); + }, + message = getString(R.string.notification_permission_required), + getString(R.string.open_settings) + ) + return@Scaffold + } + AnimatedNavHost(navController, startDestination = Routes.Main.name) { + composable(Routes.Main.name, enterTransition = { + when (initialState.destination.route) { + Routes.Settings.name, Routes.Support.name -> + slideInHorizontally( + initialOffsetX = { -1000 }, + animationSpec = tween(500) + ) + + else -> { + fadeIn(animationSpec = tween(1000)) + } + } + }) { + MainScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController) + } + composable(Routes.Settings.name, enterTransition = { + when (initialState.destination.route) { + Routes.Main.name -> + slideInHorizontally( + initialOffsetX = { 1000 }, + animationSpec = tween(500) + ) + + Routes.Support.name -> { + slideInHorizontally( + initialOffsetX = { -1000 }, + animationSpec = tween(500) + ) + } + + else -> { + fadeIn(animationSpec = tween(1000)) + } + } + }) { SettingsScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController, focusRequester = focusRequester) } + composable(Routes.Support.name, enterTransition = { + when (initialState.destination.route) { + Routes.Settings.name, Routes.Main.name -> + slideInHorizontally( + initialOffsetX = { 1000 }, + animationSpec = tween(500) + ) + + else -> { + fadeIn(animationSpec = tween(1000)) + } + } + }) { SupportScreen(padding = padding, focusRequester) } + composable("${Routes.Config.name}/{id}", enterTransition = { + fadeIn(animationSpec = tween(1000)) + }) { ConfigScreen(padding = padding, navController = navController, id = it.arguments?.getString("id"), focusRequester = focusRequester)} + composable("${Routes.Detail.name}/{id}", enterTransition = { + fadeIn(animationSpec = tween(1000)) + }) { DetailScreen(padding = padding, id = it.arguments?.getString("id")) } + } + } + } + } + } +} diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Routes.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Routes.kt new file mode 100644 index 0000000000..589fa5fbbf --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/Routes.kt @@ -0,0 +1,36 @@ +package net.nymtech.nymconnect.ui + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.QuestionMark +import androidx.compose.material.icons.rounded.Settings +import net.nymtech.nymconnect.ui.common.navigation.BottomNavItem + +enum class Routes { + Main, + Settings, + Support, + Config, + Detail; + + + companion object { + val navItems = listOf( + BottomNavItem( + name = "Tunnels", + route = Main.name, + icon = Icons.Rounded.Home, + ), + BottomNavItem( + name = "Settings", + route = Settings.name, + icon = Icons.Rounded.Settings, + ), + BottomNavItem( + name = "Support", + route = Support.name, + icon = Icons.Rounded.QuestionMark, + ) + ) + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ViewState.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ViewState.kt new file mode 100644 index 0000000000..66aa0b8790 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/ViewState.kt @@ -0,0 +1,9 @@ +package net.nymtech.nymconnect.ui + +data class ViewState( + val showSnackbarMessage : Boolean = false, + val snackbarMessage : String = "", + val snackbarActionText : String = "", + val onSnackbarActionClick : () -> Unit = {}, + val isLoading : Boolean = false +) diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt new file mode 100644 index 0000000000..2ddd1f27b2 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/ClickableIconButton.kt @@ -0,0 +1,31 @@ +package net.nymtech.nymconnect.ui.common + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector + +@Composable +fun ClickableIconButton(onIconClick : () -> Unit, text : String, icon : ImageVector, enabled : Boolean) { + Button(onClick = {}, + enabled = enabled + ) { + Text(text) + Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) + Icon( + imageVector = icon, + contentDescription = "Delete", + modifier = Modifier.size(ButtonDefaults.IconSize).clickable { + if(enabled) { + onIconClick() + } + } + ) + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/PermissionRequestFailedScreen.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/PermissionRequestFailedScreen.kt new file mode 100644 index 0000000000..a0f81b5745 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/PermissionRequestFailedScreen.kt @@ -0,0 +1,35 @@ +package net.nymtech.nymconnect.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +@Composable +fun PermissionRequestFailedScreen(padding : PaddingValues, onRequestAgain : () -> Unit, message : String, buttonText : String ) { + val scope = rememberCoroutineScope() + Column(horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(padding)) { + Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp)) + Button(onClick = { + scope.launch { + onRequestAgain() + } + }) { + Text(buttonText) + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt new file mode 100644 index 0000000000..33fb6d0bf6 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/RowListItem.kt @@ -0,0 +1,55 @@ +package net.nymtech.nymconnect.ui.common + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun RowListItem(leadingIcon : ImageVector? = null, leadingIconColor : Color = Color.Gray, text : String, onHold : () -> Unit, onClick: () -> Unit, rowButton : @Composable() () -> Unit ) { + Box( + modifier = Modifier + .combinedClickable( + onClick = { + onClick() + }, + onLongClick = { + onHold() + } + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically,) { + if(leadingIcon != null) { + Icon( + leadingIcon, "status", + tint = leadingIconColor, + modifier = Modifier.padding(end = 10.dp).size(15.dp) + ) + } + Text(text) + } + + rowButton() + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/SearchBar.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/SearchBar.kt new file mode 100644 index 0000000000..cec26f5b09 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/SearchBar.kt @@ -0,0 +1,80 @@ +package net.nymtech.nymconnect.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import net.nymtech.nymconnect.R + +@Composable +fun SearchBar( + onQuery : (queryString : String) -> Unit +) { + // Immediately update and keep track of query from text field changes. + var query: String by rememberSaveable { mutableStateOf("") } + var showClearIcon by rememberSaveable { mutableStateOf(false) } + + if (query.isEmpty()) { + showClearIcon = false + } else if (query.isNotEmpty()) { + showClearIcon = true + } + + TextField( + value = query, + onValueChange = { onQueryChanged -> + // If user makes changes to text, immediately updated it. + query = onQueryChanged + onQuery(onQueryChanged) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + tint = MaterialTheme.colorScheme.onBackground, + contentDescription = stringResource(id = R.string.search_icon) + ) + }, + trailingIcon = { + if (showClearIcon) { + IconButton(onClick = { query = "" }) { + Icon( + imageVector = Icons.Rounded.Clear, + tint = MaterialTheme.colorScheme.onBackground, + contentDescription = stringResource(id = R.string.clear_icon) + ) + } + } + }, + maxLines = 1, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ), + placeholder = { Text(text = stringResource(R.string.hint_search_packages)) }, + textStyle = MaterialTheme.typography.bodySmall, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + modifier = Modifier + .fillMaxWidth() + .background(color = MaterialTheme.colorScheme.background, shape = RectangleShape) + ) +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/navigation/BottomNavBar.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/navigation/BottomNavBar.kt new file mode 100644 index 0000000000..957228eece --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/navigation/BottomNavBar.kt @@ -0,0 +1,42 @@ +package net.nymtech.nymconnect.ui.common.navigation + +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.text.font.FontWeight +import androidx.navigation.NavController +import androidx.navigation.compose.currentBackStackEntryAsState + +@Composable +fun BottomNavBar(navController : NavController, bottomNavItems : List) { + + val backStackEntry = navController.currentBackStackEntryAsState() + + NavigationBar( + containerColor = MaterialTheme.colorScheme.background, + ) { + bottomNavItems.forEach { item -> + val selected = item.route == backStackEntry.value?.destination?.route + + NavigationBarItem( + selected = selected, + onClick = { navController.navigate(item.route) }, + label = { + Text( + text = item.name, + fontWeight = FontWeight.SemiBold, + ) + }, + icon = { + Icon( + imageVector = item.icon, + contentDescription = "${item.name} Icon", + ) + } + ) + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/navigation/BottomNavItem.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/navigation/BottomNavItem.kt new file mode 100644 index 0000000000..a4a585ce5e --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/common/navigation/BottomNavItem.kt @@ -0,0 +1,9 @@ +package net.nymtech.nymconnect.ui.common.navigation + +import androidx.compose.ui.graphics.vector.ImageVector + +data class BottomNavItem( + val name: String, + val route: String, + val icon: ImageVector, +) diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt new file mode 100644 index 0000000000..1ecfaf6570 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigScreen.kt @@ -0,0 +1,241 @@ +package net.nymtech.nymconnect.ui.screens.config + +import android.widget.Toast +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Android +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.google.accompanist.drawablepainter.DrawablePainter +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.ui.Routes +import net.nymtech.nymconnect.ui.common.SearchBar +import kotlinx.coroutines.launch + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ConfigScreen( + viewModel: ConfigViewModel = hiltViewModel(), + padding: PaddingValues, + focusRequester: FocusRequester, + navController: NavController, + id : String? +) { + + val context = LocalContext.current + val focusManager = LocalFocusManager.current + + val keyboardController = LocalSoftwareKeyboardController.current + val scope = rememberCoroutineScope() + val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null) + val tunnelName = viewModel.tunnelName.collectAsStateWithLifecycle() + val packages by viewModel.packages.collectAsStateWithLifecycle() + val checkedPackages by viewModel.checkedPackages.collectAsStateWithLifecycle() + val include by viewModel.include.collectAsStateWithLifecycle() + val allApplications by viewModel.allApplications.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.getTunnelById(id) + viewModel.emitQueriedPackages("") + viewModel.emitCurrentPackageConfigurations(id) + } + + if(tunnel != null) { + LazyColumn( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + OutlinedTextField( + modifier = Modifier.focusRequester(focusRequester), + value = tunnelName.value, + onValueChange = { + viewModel.onTunnelNameChange(it) + }, + label = { Text(stringResource(id = R.string.tunnel_name)) }, + maxLines = 1, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + focusManager.clearFocus() + keyboardController?.hide() + viewModel.onTunnelNameChange(tunnelName.value) + } + ), + ) + } + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource(id = R.string.tunnel_all)) + Switch( + checked = allApplications, + onCheckedChange = { + viewModel.onAllApplicationsChange(!allApplications) + } + ) + } + } + if (!allApplications) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource(id = R.string.include)) + Checkbox( + checked = include, + onCheckedChange = { + viewModel.onIncludeChange(!include) + } + ) + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource(id = R.string.exclude)) + Checkbox( + checked = !include, + onCheckedChange = { + viewModel.onIncludeChange(!include) + } + ) + } + } + } + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween) { + SearchBar(viewModel::emitQueriedPackages); + } + } + items(packages) { pack -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(5.dp) + ) { + val drawable = + pack.applicationInfo?.loadIcon(context.packageManager) + if (drawable != null) { + Image( + painter = DrawablePainter(drawable), + stringResource(id = R.string.icon), + modifier = Modifier.size(50.dp, 50.dp) + ) + } else { + Icon( + Icons.Rounded.Android, + stringResource(id = R.string.edit), + modifier = Modifier.size(50.dp, 50.dp) + ) + } + Text( + pack.applicationInfo.loadLabel(context.packageManager) + .toString(), modifier = Modifier.padding(5.dp) + ) + } + Checkbox( + checked = (checkedPackages.contains(pack.packageName)), + onCheckedChange = { + if (it) viewModel.onAddCheckedPackage(pack.packageName) else viewModel.onRemoveCheckedPackage( + pack.packageName + ) + } + ) + } + } + } + item { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Button(onClick = { + scope.launch { + viewModel.onSaveAllChanges() + Toast.makeText( + context, + context.resources.getString(R.string.config_changes_saved), + Toast.LENGTH_LONG + ).show() + navController.navigate(Routes.Main.name) + } + }, Modifier.padding(25.dp)) { + Text(stringResource(id = R.string.save_changes)) + } + } + } + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt new file mode 100644 index 0000000000..325e5a49bd --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/config/ConfigViewModel.kt @@ -0,0 +1,152 @@ +package net.nymtech.nymconnect.ui.screens.config + +import android.Manifest +import android.app.Application +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.toMutableStateList +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import net.nymtech.nymconnect.repository.Repository +import net.nymtech.nymconnect.service.tunnel.model.Settings +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class ConfigViewModel @Inject constructor(private val application : Application, + private val tunnelRepo : Repository, + private val settingsRepo : Repository) : ViewModel() { + + private val _tunnel = MutableStateFlow(null) + private val _tunnelName = MutableStateFlow("") + val tunnelName get() = _tunnelName.asStateFlow() + val tunnel get() = _tunnel.asStateFlow() + private val _packages = MutableStateFlow(emptyList()) + val packages get() = _packages.asStateFlow() + private val packageManager = application.packageManager + + private val _checkedPackages = MutableStateFlow(mutableStateListOf()) + val checkedPackages get() = _checkedPackages.asStateFlow() + private val _include = MutableStateFlow(true) + val include get() = _include.asStateFlow() + + 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) + + } + return config + } + return null + } catch (e : Exception) { + Timber.e(e.message) + null + } + } + + fun onTunnelNameChange(name : String) { + _tunnelName.value = name + } + + fun onIncludeChange(include : Boolean) { + _include.value = include + } + fun onAddCheckedPackage(packageName : String) { + _checkedPackages.value.add(packageName) + } + + fun onAllApplicationsChange(allApplications : Boolean) { + _allApplications.value = allApplications + } + + fun onRemoveCheckedPackage(packageName : String) { + _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 + } + 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) + }) + } + } + + private fun getAllInternetCapablePackages() : List { + return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET)) + } + + private fun getPackagesHoldingPermissions(permissions: Array): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackagesHoldingPermissions(permissions, PackageManager.PackageInfoFlags.of(0L)) + } else { + @Suppress("DEPRECATION") + packageManager.getPackagesHoldingPermissions(permissions, 0) + } + } + + suspend fun onSaveAllChanges() { + var wgQuick = _tunnel.value?.wgQuick + if(wgQuick != null) { + wgQuick = if(_include.value) { + 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) + val settings = settingsRepo.getAll() + if(settings != null) { + val setting = settings[0] + if(setting.defaultTunnel != null) { + if(it.id == TunnelConfig.from(setting.defaultTunnel!!).id) { + settingsRepo.save(setting.copy( + defaultTunnel = it.toString() + )) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt new file mode 100644 index 0000000000..bdf77a5e56 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailScreen.kt @@ -0,0 +1,139 @@ +package net.nymtech.nymconnect.ui.screens.detail + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ClipboardManager +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.util.NumberUtils +import java.time.Duration +import java.time.Instant + +@Composable +fun DetailScreen( + viewModel: DetailViewModel = hiltViewModel(), + padding: PaddingValues, + id : String? +) { + + val clipboardManager: ClipboardManager = LocalClipboardManager.current + val tunnelStats by viewModel.tunnelStats.collectAsStateWithLifecycle(null) + val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null) + val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle() + val lastHandshake by viewModel.lastHandshake.collectAsStateWithLifecycle(emptyMap()) + + + LaunchedEffect(Unit) { + viewModel.getTunnelById(id) + } + + if(tunnel != null) { + 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" + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(padding) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column { + Text(stringResource(R.string.config_interface), fontWeight = FontWeight.Bold, fontSize = 20.sp) + Text(stringResource(R.string.name), fontStyle = FontStyle.Italic) + Text(text = tunnelName, modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(tunnelName)) + }) + Text(stringResource(R.string.public_key), fontStyle = FontStyle.Italic) + Text(text = interfaceKey, modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(interfaceKey)) + }) + Text(stringResource(R.string.addresses), fontStyle = FontStyle.Italic) + Text(text = addresses, modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(addresses)) + }) + Text(stringResource(R.string.dns_servers), fontStyle = FontStyle.Italic) + Text(text = dnsServers, modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(dnsServers)) + }) + Text(stringResource(R.string.mtu), fontStyle = FontStyle.Italic) + Text(text = mtu, modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(mtu)) + }) + Box(modifier = Modifier.padding(10.dp)) + 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" + 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 { + clipboardManager.setText(AnnotatedString(peerKey)) + }) + Text(stringResource(id = R.string.allowed_ips), fontStyle = FontStyle.Italic) + Text(text = allowedIps, modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(allowedIps)) + }) + Text(stringResource(R.string.endpoint), fontStyle = FontStyle.Italic) + Text(text = endpoint, modifier = Modifier.clickable { + clipboardManager.setText(AnnotatedString(endpoint)) + }) + if (tunnelStats != null) { + val totalRx = tunnelStats?.totalRx() ?: 0 + val totalTx = tunnelStats?.totalTx() ?: 0 + if((totalRx + totalTx != 0L)) { + val rxKB = NumberUtils.bytesToKB(tunnelStats!!.totalRx()) + val txKB = NumberUtils.bytesToKB(tunnelStats!!.totalTx()) + Text(stringResource(R.string.transfer), fontStyle = FontStyle.Italic) + Text("rx: ${NumberUtils.formatDecimalTwoPlaces(rxKB)} KB tx: ${NumberUtils.formatDecimalTwoPlaces(txKB)} KB") + Text(stringResource(R.string.last_handshake), fontStyle = FontStyle.Italic) + val handshakeEpoch = lastHandshake[it.publicKey] + if(handshakeEpoch != null) { + if(handshakeEpoch == 0L) { + Text("Never") + } else { + val time = Instant.ofEpochMilli(handshakeEpoch) + Text("${Duration.between(time, Instant.now()).seconds} seconds ago") + } + } + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt new file mode 100644 index 0000000000..cf83878a0b --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/detail/DetailViewModel.kt @@ -0,0 +1,45 @@ +package net.nymtech.nymconnect.ui.screens.detail + +import androidx.lifecycle.ViewModel +import com.wireguard.config.Config +import net.nymtech.nymconnect.repository.Repository +import net.nymtech.nymconnect.service.tunnel.VpnService +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import timber.log.Timber +import javax.inject.Inject + +@HiltViewModel +class DetailViewModel @Inject constructor(private val tunnelRepo : Repository, private val vpnService : VpnService + +) : ViewModel() { + + private val _tunnel = MutableStateFlow(null) + val tunnel get() = _tunnel.asStateFlow() + + 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? { + 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) { + Timber.e(e.message) + null + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt new file mode 100644 index 0000000000..e2abdd07b7 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainScreen.kt @@ -0,0 +1,336 @@ +package net.nymtech.nymconnect.ui.screens.main + +import android.annotation.SuppressLint +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FileOpen +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Circle +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.Divider +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FabPosition +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.wireguard.android.backend.Tunnel +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.WireGuardAutoTunnel +import net.nymtech.nymconnect.service.tunnel.HandshakeStatus +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import net.nymtech.nymconnect.ui.Routes +import net.nymtech.nymconnect.ui.common.RowListItem +import net.nymtech.nymconnect.ui.theme.brickRed +import net.nymtech.nymconnect.ui.theme.mint +import kotlinx.coroutines.launch + +@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + viewModel: MainViewModel = hiltViewModel(), padding: PaddingValues, + snackbarHostState: SnackbarHostState, navController: NavController +) { + + val haptic = LocalHapticFeedback.current + val context = LocalContext.current + val isVisible = rememberSaveable { mutableStateOf(true) } + val scope = rememberCoroutineScope() + + val sheetState = rememberModalBottomSheetState() + var showBottomSheet by remember { mutableStateOf(false) } + val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) + val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(HandshakeStatus.NOT_STARTED) + val viewState = viewModel.viewState.collectAsStateWithLifecycle() + var selectedTunnel by remember { mutableStateOf(null) } + val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN) + val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("") + + // Nested scroll for control FAB + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + // Hide FAB + if (available.y < -1) { + isVisible.value = false + } + // Show FAB + if (available.y > 1) { + isVisible.value = true + } + return Offset.Zero + } + } + } + + LaunchedEffect(viewState.value) { + if (viewState.value.showSnackbarMessage) { + val result = snackbarHostState.showSnackbar( + message = viewState.value.snackbarMessage, + actionLabel = viewState.value.snackbarActionText, + duration = SnackbarDuration.Long, + ) + when (result) { + SnackbarResult.ActionPerformed -> viewState.value.onSnackbarActionClick + SnackbarResult.Dismissed -> viewState.value.onSnackbarActionClick + } + } + } + + val pickFileLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.GetContent() + ) { file -> + if (file != null) { + viewModel.onTunnelFileSelected(file) + } + } + + Scaffold( + modifier = Modifier.pointerInput(Unit) { + detectTapGestures(onTap = { + selectedTunnel = null + }) + }, + floatingActionButtonPosition = FabPosition.End, + floatingActionButton = { + AnimatedVisibility( + visible = isVisible.value, + enter = slideInVertically(initialOffsetY = { it * 2 }), + exit = slideOutVertically(targetOffsetY = { it * 2 }), + ) { + FloatingActionButton( + modifier = Modifier.padding(bottom = 90.dp), + onClick = { + showBottomSheet = true + }, + containerColor = MaterialTheme.colorScheme.secondary, + shape = RoundedCornerShape(16.dp), + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = stringResource(id = R.string.add_tunnel), + tint = Color.DarkGray, + ) + } + } + } + ) { + if (tunnels.isEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic) + } + } + if (showBottomSheet) { + ModalBottomSheet( + onDismissRequest = { + showBottomSheet = false + }, + sheetState = sheetState + ) { + // Sheet content + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + showBottomSheet = false + pickFileLauncher.launch("*/*") + } + .padding(10.dp) + ) { + Icon( + Icons.Filled.FileOpen, + contentDescription = stringResource(id = R.string.open_file), + modifier = Modifier.padding(10.dp) + ) + Text( + stringResource(id = R.string.add_from_file), + modifier = Modifier.padding(10.dp) + ) + } + Divider() + } + } + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + + LazyColumn( + modifier = Modifier.fillMaxSize() + .nestedScroll(nestedScrollConnection), + ) { + itemsIndexed(tunnels.toList()) { index, tunnel -> + val focusRequester = FocusRequester(); + RowListItem(leadingIcon = Icons.Rounded.Circle, + leadingIconColor = if (tunnelName == tunnel.name) when (handshakeStatus) { + HandshakeStatus.HEALTHY -> mint + HandshakeStatus.UNHEALTHY -> brickRed + HandshakeStatus.NOT_STARTED -> Color.Gray + HandshakeStatus.NEVER_CONNECTED -> brickRed + } else Color.Gray, + text = tunnel.name, + onHold = { + if (state == Tunnel.State.UP && tunnel.name == tunnelName) { + scope.launch { + viewModel.showSnackBarMessage(context.resources.getString(R.string.turn_off_tunnel)) + } + return@RowListItem + } + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + selectedTunnel = tunnel; + }, + onClick = { + if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { + navController.navigate("${Routes.Detail.name}/${tunnel.id}") + } else { + focusRequester.requestFocus() + } + }, + rowButton = { + if (tunnel.id == selectedTunnel?.id) { + Row { + IconButton(onClick = { + navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}") + }) { + Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) + } + IconButton( + modifier = Modifier.focusable(), + onClick = { viewModel.onDelete(tunnel) }) { + Icon( + Icons.Rounded.Delete, + stringResource(id = R.string.delete) + ) + } + } + } else { + if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { + Row { + IconButton( + modifier = Modifier.focusRequester(focusRequester), + onClick = { + navController.navigate("${Routes.Detail.name}/${tunnel.id}") + }) { + Icon(Icons.Rounded.Info, "Info") + } + IconButton(onClick = { + if (state == Tunnel.State.UP && tunnel.name == tunnelName) + scope.launch { + viewModel.showSnackBarMessage( + context.resources.getString( + R.string.turn_off_tunnel + ) + ) + } else { + navController.navigate("${Routes.Config.name}/${tunnel.id}") + } + }) { + Icon( + Icons.Rounded.Edit, + stringResource(id = R.string.edit) + ) + } + IconButton(onClick = { + if (state == Tunnel.State.UP && tunnel.name == tunnelName) + scope.launch { + viewModel.showSnackBarMessage( + context.resources.getString( + R.string.turn_off_tunnel + ) + ) + } else { + viewModel.onDelete(tunnel) + } + }) { + Icon( + Icons.Rounded.Delete, + stringResource(id = R.string.delete) + ) + } + Switch( + checked = (state == Tunnel.State.UP && tunnel.name == tunnelName), + onCheckedChange = { checked -> + if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop() + } + ) + } + } else { + Switch( + checked = (state == Tunnel.State.UP && tunnel.name == tunnelName), + onCheckedChange = { checked -> + if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop() + } + ) + } + } + }) + } + } + } + } +} diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt new file mode 100644 index 0000000000..a88b5adcb9 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/main/MainViewModel.kt @@ -0,0 +1,179 @@ +package net.nymtech.nymconnect.ui.screens.main + +import android.annotation.SuppressLint +import android.app.Application +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.wireguard.config.BadConfigException +import com.wireguard.config.Config +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.repository.Repository +import net.nymtech.nymconnect.service.foreground.ServiceState +import net.nymtech.nymconnect.service.foreground.ServiceManager +import net.nymtech.nymconnect.service.foreground.WireGuardConnectivityWatcherService +import net.nymtech.nymconnect.service.shortcut.ShortcutsManager +import net.nymtech.nymconnect.service.tunnel.VpnService +import net.nymtech.nymconnect.service.tunnel.model.Settings +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import net.nymtech.nymconnect.ui.ViewState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject + + +@HiltViewModel +class MainViewModel @Inject constructor(private val application : Application, + private val tunnelRepo : Repository, + private val settingsRepo : Repository, + private val vpnService: VpnService, +) : ViewModel() { + + private val _viewState = MutableStateFlow(ViewState()) + val viewState get() = _viewState.asStateFlow() + val tunnels get() = tunnelRepo.itemFlow + val state get() = vpnService.state + + val handshakeStatus get() = vpnService.handshakeStatus + val tunnelName get() = vpnService.tunnelName + private val _settings = MutableStateFlow(Settings()) + val settings get() = _settings.asStateFlow() + + private val defaultConfigName = { + "tunnel${(Math.random() * 100000).toInt()}" + } + + + init { + viewModelScope.launch { + settingsRepo.itemFlow.collect { + val settings = it.first() + validateWatcherServiceState(settings) + _settings.emit(settings) + } + } + } + + private fun validateWatcherServiceState(settings: Settings) { + val watcherState = ServiceManager.getServiceState(application.applicationContext, WireGuardConnectivityWatcherService::class.java) + if(settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) { + ServiceManager.startWatcherService(application.applicationContext, settings.defaultTunnel!!) + } + } + + + fun onDelete(tunnel : TunnelConfig) { + viewModelScope.launch { + if(tunnelRepo.count() == 1L) { + ServiceManager.stopWatcherService(application.applicationContext) + val settings = settingsRepo.getAll() + if(!settings.isNullOrEmpty()) { + val setting = settings[0] + setting.defaultTunnel = null + setting.isAutoTunnelEnabled = false + setting.isAlwaysOnVpnEnabled = false + settingsRepo.save(setting) + } + } + tunnelRepo.delete(tunnel) + ShortcutsManager.removeTunnelShortcuts(application.applicationContext, tunnel) + } + } + + fun onTunnelStart(tunnelConfig : TunnelConfig) = viewModelScope.launch { + ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString()) + } + + fun onTunnelStop() { + ServiceManager.stopVpnService(application.applicationContext) + } + + fun onTunnelFileSelected(uri : Uri) { + try { + val fileName = getFileName(application.applicationContext, uri) + val extension = getFileExtensionFromFileName(fileName) + if(extension != ".conf") { + viewModelScope.launch { + showSnackBarMessage(application.resources.getString(R.string.file_extension_message)) + } + return + } + val stream = application.applicationContext.contentResolver.openInputStream(uri) + stream ?: return + 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) { + viewModelScope.launch { + showSnackBarMessage(application.applicationContext.getString(R.string.bad_config)) + } + } + } + + private fun saveTunnel(tunnelConfig : TunnelConfig) { + viewModelScope.launch { + tunnelRepo.save(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 defaultConfigName() + cursor.use { + if(cursor.moveToFirst()) { + return cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)) + } + } + } + return defaultConfigName() + } + + suspend fun showSnackBarMessage(message : String) { + _viewState.emit(_viewState.value.copy( + showSnackbarMessage = true, + snackbarMessage = message, + snackbarActionText = "Okay", + onSnackbarActionClick = { + viewModelScope.launch { + dismissSnackBar() + } + } + )) + delay(3000) + dismissSnackBar() + } + + private suspend fun dismissSnackBar() { + _viewState.emit(_viewState.value.copy( + showSnackbarMessage = false + )) + } + + private fun getNameFromFileName(fileName : String) : String { + return fileName.substring(0 , fileName.lastIndexOf('.') ) + } + + private fun getFileExtensionFromFileName(fileName : String) : String { + return try { + fileName.substring(fileName.lastIndexOf('.')) + } catch (e : Exception) { + "" + } + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000000..4616a8fd9a --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,415 @@ +package net.nymtech.nymconnect.ui.screens.settings + +import android.Manifest +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.rounded.LocationOff +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.WireGuardAutoTunnel +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import net.nymtech.nymconnect.ui.Routes +import net.nymtech.nymconnect.ui.common.ClickableIconButton +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class, + ExperimentalLayoutApi::class +) +@Composable +fun SettingsScreen( + viewModel: SettingsViewModel = hiltViewModel(), + padding: PaddingValues, + navController: NavController, + focusRequester: FocusRequester, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() } +) { + + val scope = rememberCoroutineScope() + val context = LocalContext.current + val focusManager = LocalFocusManager.current + val interactionSource = remember { MutableInteractionSource() } + + var expanded by remember { mutableStateOf(false) } + val viewState by viewModel.viewState.collectAsStateWithLifecycle() + val settings by viewModel.settings.collectAsStateWithLifecycle() + val trustedSSIDs by viewModel.trustedSSIDs.collectAsStateWithLifecycle() + val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) + val backgroundLocationState = + rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) + var currentText by remember { mutableStateOf("") } + val scrollState = rememberScrollState() + var isLocationServicesEnabled by remember { mutableStateOf(viewModel.checkLocationServicesEnabled())} + + LaunchedEffect(viewState) { + if (viewState.showSnackbarMessage) { + val result = snackbarHostState.showSnackbar( + message = viewState.snackbarMessage, + actionLabel = viewState.snackbarActionText, + duration = SnackbarDuration.Long, + ) + when (result) { + SnackbarResult.ActionPerformed -> viewState.onSnackbarActionClick + SnackbarResult.Dismissed -> viewState.onSnackbarActionClick + } + } + } + + fun saveTrustedSSID() { + if (currentText.isNotEmpty()) { + scope.launch { + viewModel.onSaveTrustedSSID(currentText) + currentText = "" + } + } + } + + fun openSettings() { + scope.launch { + val intentSettings = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intentSettings.data = + Uri.fromParts("package", context.packageName, null) + context.startActivity(intentSettings) + } + } + + if(!backgroundLocationState.status.isGranted && Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + Column(horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(padding)) { + Icon(Icons.Rounded.LocationOff, contentDescription = stringResource(id = R.string.map), modifier = Modifier + .padding(30.dp) + .size(128.dp)) + Text(stringResource(R.string.prominent_background_location_title), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 20.sp) + Text(stringResource(R.string.prominent_background_location_message), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 15.sp) + Row( + modifier = if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier + .fillMaxWidth() + .padding(10.dp) else Modifier + .fillMaxWidth() + .padding(30.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Button(onClick = { + navController.navigate(Routes.Main.name) + }) { + Text(stringResource(id = R.string.no_thanks)) + } + Button(modifier = Modifier.focusRequester(focusRequester), onClick = { + openSettings() + }) { + Text(stringResource(id = R.string.turn_on)) + } + } + } + return + } + + if(!fineLocationState.status.isGranted) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Text( + stringResource(id = R.string.precise_location_message), + textAlign = TextAlign.Center, + modifier = Modifier.padding(15.dp), + fontStyle = FontStyle.Italic + ) + Button(modifier = Modifier.focusRequester(focusRequester),onClick = { + fineLocationState.launchPermissionRequest() + }) { + Text(stringResource(id = R.string.request)) + } + + } + return + } + + if (tunnels.isEmpty()) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Text( + stringResource(R.string.one_tunnel_required), + textAlign = TextAlign.Center, + modifier = Modifier.padding(15.dp), + fontStyle = FontStyle.Italic + ) + } + return + } + if(!isLocationServicesEnabled && Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { + Text( + stringResource(id = R.string.location_services_not_detected), + textAlign = TextAlign.Center, + modifier = Modifier.padding(15.dp), + fontStyle = FontStyle.Italic + ) + Button(modifier = Modifier.focusRequester(focusRequester), onClick = { + val locationServicesEnabled = viewModel.checkLocationServicesEnabled() + isLocationServicesEnabled = locationServicesEnabled + if(!locationServicesEnabled) { + scope.launch { + viewModel.showSnackBarMessage(context.getString(R.string.detecting_location_services_disabled)) + } + } + }) { + Text(stringResource(id = R.string.check_again)) + } + } + return + } + val screenPadding = if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 5.dp else 15.dp + Column( + horizontalAlignment = Alignment.Start, + verticalArrangement = Arrangement.Top, + modifier = if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier + .fillMaxHeight(.85f) + .fillMaxWidth() + .verticalScroll(scrollState) + .clickable(indication = null, interactionSource = interactionSource) { + focusManager.clearFocus() + } + .padding(padding) else Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .clickable(indication = null, interactionSource = interactionSource) { + focusManager.clearFocus() + } + .padding(padding) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(screenPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource(R.string.enable_auto_tunnel)) + Switch( + modifier = Modifier.focusRequester(focusRequester), + enabled = !settings.isAlwaysOnVpnEnabled, + checked = settings.isAutoTunnelEnabled, + onCheckedChange = { + scope.launch { + viewModel.toggleAutoTunnel() + } + } + ) + } + Text( + stringResource(id = R.string.select_tunnel), + textAlign = TextAlign.Center, + modifier = Modifier.padding(screenPadding, bottom = 5.dp, top = 5.dp) + ) + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { + if(!(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)) { + expanded = !expanded }}, + modifier = Modifier.padding(start = 15.dp, top = 5.dp, bottom = 10.dp).clickable { + expanded = !expanded + }, + ) { + TextField( + enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), + value = settings.defaultTunnel?.let { + TunnelConfig.from(it).name } + ?: "", + readOnly = true, + modifier = Modifier.menuAnchor(), + label = { Text(stringResource(R.string.tunnels)) }, + onValueChange = { }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = expanded + ) + } + ) + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { + expanded = false + } + ) { + tunnels.forEach() { tunnel -> + DropdownMenuItem( + onClick = { + scope.launch { + viewModel.onDefaultTunnelSelected(tunnel) + } + expanded = false + }, + text = { Text(text = tunnel.name) } + ) + } + } + } + Text( + stringResource(R.string.trusted_ssid), + textAlign = TextAlign.Center, + modifier = Modifier.padding(screenPadding, bottom = 5.dp, top = 5.dp) + ) + FlowRow( + modifier = Modifier.padding(screenPadding), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.SpaceEvenly + ) { + trustedSSIDs.forEach { ssid -> + ClickableIconButton(onIconClick = { + scope.launch { + viewModel.onDeleteTrustedSSID(ssid) + } + }, text = ssid, icon = Icons.Filled.Close, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)) + } + } + OutlinedTextField( + enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), + value = currentText, + onValueChange = { currentText = it }, + label = { Text(stringResource(R.string.add_trusted_ssid)) }, + modifier = Modifier.padding(start = screenPadding, top = 5.dp), + maxLines = 1, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + saveTrustedSSID() + } + ), + trailingIcon = { + IconButton(onClick = { saveTrustedSSID() }) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource( + id = R.string.trusted_ssid_value_description + ), + tint = if(currentText == "") Color.Transparent else Color.Green + ) + } + }, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(screenPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource(R.string.tunnel_mobile_data)) + Switch( + enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), + checked = settings.isTunnelOnMobileDataEnabled, + onCheckedChange = { + scope.launch { + viewModel.onToggleTunnelOnMobileData() + } + } + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(screenPadding), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(stringResource(R.string.always_on_vpn_support)) + Switch( + enabled = !settings.isAutoTunnelEnabled, + checked = settings.isAlwaysOnVpnEnabled, + onCheckedChange = { + scope.launch { + viewModel.onToggleAlwaysOnVPN() + } + } + ) + } + } +} + + diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt new file mode 100644 index 0000000000..7143c1b4b6 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/settings/SettingsViewModel.kt @@ -0,0 +1,124 @@ +package net.nymtech.nymconnect.ui.screens.settings + +import android.app.Application +import android.content.Context +import android.location.LocationManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import net.nymtech.nymconnect.R +import net.nymtech.nymconnect.repository.Repository +import net.nymtech.nymconnect.service.foreground.ServiceManager +import net.nymtech.nymconnect.service.tunnel.model.Settings +import net.nymtech.nymconnect.service.tunnel.model.TunnelConfig +import net.nymtech.nymconnect.ui.ViewState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + + +@HiltViewModel +class SettingsViewModel @Inject constructor(private val application : Application, + private val tunnelRepo : Repository, private val settingsRepo : Repository +) : ViewModel() { + + private val _trustedSSIDs = MutableStateFlow(emptyList()) + val trustedSSIDs = _trustedSSIDs.asStateFlow() + private val _settings = MutableStateFlow(Settings()) + val settings get() = _settings.asStateFlow() + val tunnels get() = tunnelRepo.itemFlow + private val _viewState = MutableStateFlow(ViewState()) + val viewState get() = _viewState.asStateFlow() + + init { + checkLocationServicesEnabled() + viewModelScope.launch { + settingsRepo.itemFlow.collect { + val settings = it.first() + _settings.emit(settings) + _trustedSSIDs.emit(settings.trustedNetworkSSIDs.toList()) + } + } + } + + suspend fun onSaveTrustedSSID(ssid: String) { + val trimmed = ssid.trim() + if (!_settings.value.trustedNetworkSSIDs.contains(trimmed)) { + _settings.value.trustedNetworkSSIDs.add(trimmed) + settingsRepo.save(_settings.value) + } else { + showSnackBarMessage("SSID already exists.") + } + } + + suspend fun onDefaultTunnelSelected(tunnelConfig: TunnelConfig) { + settingsRepo.save(_settings.value.copy( + defaultTunnel = tunnelConfig.toString() + )) + } + + suspend fun onToggleTunnelOnMobileData() { + settingsRepo.save(_settings.value.copy( + isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled + )) + } + + suspend fun onDeleteTrustedSSID(ssid: String) { + _settings.value.trustedNetworkSSIDs.remove(ssid) + settingsRepo.save(_settings.value) + } + + suspend fun toggleAutoTunnel() { + if(_settings.value.defaultTunnel.isNullOrEmpty() && !_settings.value.isAutoTunnelEnabled) { + showSnackBarMessage(application.getString(R.string.select_tunnel_message)) + return + } + if(_settings.value.isAutoTunnelEnabled) { + ServiceManager.stopWatcherService(application) + } else { + if(_settings.value.defaultTunnel != null) { + val defaultTunnel = _settings.value.defaultTunnel + ServiceManager.startWatcherService(application, defaultTunnel!!) + } + } + settingsRepo.save(_settings.value.copy( + isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled + )) + } + + suspend fun showSnackBarMessage(message : String) { + _viewState.emit(_viewState.value.copy( + showSnackbarMessage = true, + snackbarMessage = message, + snackbarActionText = "Okay", + onSnackbarActionClick = { + viewModelScope.launch { + dismissSnackBar() + } + } + )) + } + + private suspend fun dismissSnackBar() { + _viewState.emit(_viewState.value.copy( + showSnackbarMessage = false + )) + } + + suspend fun onToggleAlwaysOnVPN() { + if(_settings.value.defaultTunnel != null) { + _settings.emit( + _settings.value.copy(isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled) + ) + 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 + return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt new file mode 100644 index 0000000000..31fc9dbf6a --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/screens/support/SupportScreen.kt @@ -0,0 +1,83 @@ +package net.nymtech.nymconnect.ui.screens.support + +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import net.nymtech.nymconnect.R + +@Composable +fun SupportScreen(padding : PaddingValues, focusRequester: FocusRequester) { + + val context = LocalContext.current + + fun openWebPage(url: String) { + val webpage: Uri = Uri.parse(url) + val intent = Intent(Intent.ACTION_VIEW, webpage) + context.startActivity(intent) + } + + Column(horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .focusable() + .padding(padding)) { + Text(stringResource(R.string.support_text), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 15.sp) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceEvenly + ) { + IconButton(onClick = { + openWebPage(context.resources.getString(R.string.discord_url)) + }) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.discord), "Discord") + } + IconButton(modifier = Modifier.focusRequester(focusRequester),onClick = { + openWebPage(context.resources.getString(R.string.github_url)) + }) { + Icon(imageVector = ImageVector.vectorResource(R.drawable.github), "Github") + } + } + Spacer(modifier = Modifier.weight(1f)) + Text(stringResource(id = R.string.privacy_policy), style = TextStyle(textDecoration = TextDecoration.Underline), + modifier = Modifier.clickable { + openWebPage(context.resources.getString(R.string.privacy_policy_url)) + }) + Text("App version: ${net.nymtech.nymconnect.BuildConfig.VERSION_NAME}", Modifier.padding(25.dp)) + } + } \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Color.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Color.kt new file mode 100644 index 0000000000..3561a75512 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Color.kt @@ -0,0 +1,17 @@ +package net.nymtech.nymconnect.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFF492532) +val virdigris = Color(0xFF5BC0BE) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFFFFFFFF) + +//status colors +val brickRed = Color(0xFFCE4257) +val pinkRed = Color(0xFFEF476F) +val mint = Color(0xFF52B788) diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Theme.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Theme.kt new file mode 100644 index 0000000000..647ad9f2ad --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Theme.kt @@ -0,0 +1,80 @@ +package net.nymtech.nymconnect.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + //primary = Purple80, + primary = virdigris, + secondary = virdigris, + // secondary = PurpleGrey80, + tertiary = virdigris + //tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun WireguardAutoTunnelTheme( + //force dark theme + darkTheme : Boolean = true, + //darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + //turning off dynamic color for now + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = Color.Transparent.toArgb() + window.navigationBarColor = Color.Transparent.toArgb() + WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = !darkTheme + WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightNavigationBars = !darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/TransparentSystemBars.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/TransparentSystemBars.kt new file mode 100644 index 0000000000..6a1c35331c --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/TransparentSystemBars.kt @@ -0,0 +1,22 @@ +package net.nymtech.nymconnect.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.graphics.Color +import com.google.accompanist.systemuicontroller.rememberSystemUiController + +@Composable +fun TransparentSystemBars() { + val systemUiController = rememberSystemUiController() + val useDarkIcons = !isSystemInDarkTheme() + + DisposableEffect(systemUiController, useDarkIcons) { + systemUiController.setSystemBarsColor( + color = Color.Transparent, + darkIcons = useDarkIcons + ) + + onDispose {} + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Type.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Type.kt new file mode 100644 index 0000000000..6b89a8e327 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package net.nymtech.nymconnect.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/NumberUtils.kt b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/NumberUtils.kt new file mode 100644 index 0000000000..aa240e6eb2 --- /dev/null +++ b/nym-vpn/android/app/src/main/java/com/zaneschepke/wireguardautotunnel/util/NumberUtils.kt @@ -0,0 +1,25 @@ +package net.nymtech.nymconnect.util + +import java.math.BigDecimal +import java.text.DecimalFormat +import java.time.Duration +import java.time.Instant + +object NumberUtils { + + private const val BYTES_IN_KB = 1024L + + fun bytesToKB(bytes : Long) : BigDecimal { + return bytes.toBigDecimal().divide(BYTES_IN_KB.toBigDecimal()) + } + + fun formatDecimalTwoPlaces(bigDecimal: BigDecimal) : String { + val df = DecimalFormat("#.##") + return df.format(bigDecimal) + } + + fun getSecondsBetweenTimestampAndNow(epoch : Long) : Long { + val time = Instant.ofEpochMilli(epoch) + return Duration.between(time, Instant.now()).seconds + } +} \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/res/drawable/discord.xml b/nym-vpn/android/app/src/main/res/drawable/discord.xml new file mode 100644 index 0000000000..df43339353 --- /dev/null +++ b/nym-vpn/android/app/src/main/res/drawable/discord.xml @@ -0,0 +1,10 @@ + + + diff --git a/nym-vpn/android/app/src/main/res/drawable/github.xml b/nym-vpn/android/app/src/main/res/drawable/github.xml new file mode 100644 index 0000000000..32f8254ebe --- /dev/null +++ b/nym-vpn/android/app/src/main/res/drawable/github.xml @@ -0,0 +1,12 @@ + + + diff --git a/nym-vpn/android/app/src/main/res/drawable/ic_launcher_background.xml b/nym-vpn/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..07d5da9cbf --- /dev/null +++ b/nym-vpn/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nym-vpn/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/nym-vpn/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..2b068d1146 --- /dev/null +++ b/nym-vpn/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/res/drawable/shield.xml b/nym-vpn/android/app/src/main/res/drawable/shield.xml new file mode 100644 index 0000000000..e49a1f265d --- /dev/null +++ b/nym-vpn/android/app/src/main/res/drawable/shield.xml @@ -0,0 +1,5 @@ + + + diff --git a/nym-vpn/android/app/src/main/res/drawable/vpn_off.xml b/nym-vpn/android/app/src/main/res/drawable/vpn_off.xml new file mode 100644 index 0000000000..d33949b74b --- /dev/null +++ b/nym-vpn/android/app/src/main/res/drawable/vpn_off.xml @@ -0,0 +1,5 @@ + + + diff --git a/nym-vpn/android/app/src/main/res/drawable/vpn_on.xml b/nym-vpn/android/app/src/main/res/drawable/vpn_on.xml new file mode 100644 index 0000000000..1339fb3b8b --- /dev/null +++ b/nym-vpn/android/app/src/main/res/drawable/vpn_on.xml @@ -0,0 +1,5 @@ + + + diff --git a/nym-vpn/android/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml b/nym-vpn/android/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml new file mode 100644 index 0000000000..cf3108b364 --- /dev/null +++ b/nym-vpn/android/app/src/main/res/mipmap-anydpi-v26/ic_banner.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/nym-vpn/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..036d09bc5f --- /dev/null +++ b/nym-vpn/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/nym-vpn/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..036d09bc5f --- /dev/null +++ b/nym-vpn/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/nym-vpn/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000..52206777aa Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/nym-vpn/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..bc0c1b6637 Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/nym-vpn/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..9adc4b2092 Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/nym-vpn/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000..3d0be44a97 Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/nym-vpn/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..106e0e13cc Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/nym-vpn/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..c388f6dae5 Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_banner.png b/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_banner.png new file mode 100644 index 0000000000..fbd39f501d Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_banner.png differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png b/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png new file mode 100644 index 0000000000..634417a640 Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_banner_foreground.png differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000..2dddd0313f Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..5a020f7a5e Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..c89a4abd0e Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/nym-vpn/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..bf191eb817 Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/nym-vpn/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..b3336b2e28 Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/nym-vpn/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..0a5390f963 Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/nym-vpn/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000000..b8e72301e0 Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/nym-vpn/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000000..dae95ed873 Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/nym-vpn/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/nym-vpn/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000..e16ef0b03f Binary files /dev/null and b/nym-vpn/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/nym-vpn/android/app/src/main/res/values/colors.xml b/nym-vpn/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000000..d035176a0b --- /dev/null +++ b/nym-vpn/android/app/src/main/res/values/colors.xml @@ -0,0 +1,11 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF1c1b20 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/res/values/ic_banner_background.xml b/nym-vpn/android/app/src/main/res/values/ic_banner_background.xml new file mode 100644 index 0000000000..5274b8a4f3 --- /dev/null +++ b/nym-vpn/android/app/src/main/res/values/ic_banner_background.xml @@ -0,0 +1,4 @@ + + + #121212 + \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/res/values/ic_launcher_background.xml b/nym-vpn/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..9c1cd0cc32 --- /dev/null +++ b/nym-vpn/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #1C1B20 + \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/res/values/strings.xml b/nym-vpn/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000000..2c1d791d4d --- /dev/null +++ b/nym-vpn/android/app/src/main/res/values/strings.xml @@ -0,0 +1,90 @@ + + NymConnect + tunnelConfig + VPN Channel + VPN Notification Channel + Watcher Channel + Watcher Notification Channel + FOREGROUND_FILE + https://github.com/nymtech/nym + https://github.com/nymtech/nym + File is not a .conf file + Turn off tunnel before editing + No tunnels added yet! + Tunnel name already exists + https://discord.com/invite/nym + Watcher Service + Monitoring network state changes + VPN Connected + Connected to tunnel - + VPN permission is required for the app to work properly. If this permission is not launching, please disable \"Always-on VPN\" in your phone settings for the official WireGuard mobile app and try again. + Notifications permission is required for the app to work properly. + Open Settings + Add Trusted SSID + Trusted SSID + Tunnels + Select Tunnel + Enable auto tunneling + Tunnel on mobile data + \"Allow all the time\" location permission is required for retrieving Wi-Fi SSID in the background. Permission is needed for this feature. + Location permission is required for this feature to work properly. + At least one tunnel required to use this feature + "Retry" + View Privacy Policy + Okay + This feature requires background location permission to enable Wi-Fi SSID monitoring even while the application is closed. For more details, please see the Privacy Policy linked on the Support screen. + Background Location Disclosure + TODO + Enter SSID + Submit SSID + [Interface] + Add tunnel from files + File Open + Tunnel Edit + Tunnel Name + Edit + Delete + Add Tunnel + Exclude + Include + Tunnel all applications + Configuration changes saved. + Save changes + Icon + No thanks + Turn on + Map + Bad config. Please try again. + Interface + Public key + Waiting for the Barcode UI module to be downloaded. + Barcode module downloading. Try again. + Addresses + DNS servers + MTU + Peer + Allowed IPs + Endpoint + Transfer + Last handshake + Name + Restart Tunnel + VPN Connection Failed + Failed connection to - + Attempting to connect to server after 30 seconds of no response. + Attempting to reconnect to server after more than one minute of no response. + Enable Always-On VPN support + Please select a tunnel first + Unable to detect Location Services which are required for this feature. Please enable Location Services. + Check again + Detecting Location Services disabled + This feature requires precise location to access Wi-Fi SSID name. Please enable precise location here or in the app settings. + Request + Toggle VPN + No tunnels available + Search packages + Clear Icon + Search Icon + Attempting connection.. + VPN Starting + \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/res/values/themes.xml b/nym-vpn/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000000..9414a536e4 --- /dev/null +++ b/nym-vpn/android/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/res/xml/backup_rules.xml b/nym-vpn/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000000..fa0f996d2c --- /dev/null +++ b/nym-vpn/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + \ No newline at end of file diff --git a/nym-vpn/android/app/src/main/res/xml/data_extraction_rules.xml b/nym-vpn/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000000..9ee9997b0b --- /dev/null +++ b/nym-vpn/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/nym-vpn/android/app/src/test/java/com/zaneschepke/wireguardautotunnel/ExampleUnitTest.kt b/nym-vpn/android/app/src/test/java/com/zaneschepke/wireguardautotunnel/ExampleUnitTest.kt new file mode 100644 index 0000000000..c3453e018c --- /dev/null +++ b/nym-vpn/android/app/src/test/java/com/zaneschepke/wireguardautotunnel/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package net.nymtech.nymconnect + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/nym-vpn/android/build.gradle.kts b/nym-vpn/android/build.gradle.kts new file mode 100644 index 0000000000..09b1978ecb --- /dev/null +++ b/nym-vpn/android/build.gradle.kts @@ -0,0 +1,19 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + val objectBoxVersion by extra("3.5.1") + val hiltVersion by extra("2.47") + val accompanistVersion by extra("0.31.2-alpha") + + dependencies { + classpath("io.objectbox:objectbox-gradle-plugin:$objectBoxVersion") + classpath("com.google.gms:google-services:4.3.15") + } +} + +plugins { + id("com.android.application") version "8.2.0-beta01" apply false + id("org.jetbrains.kotlin.android") version "1.8.22" apply false + id("com.google.dagger.hilt.android") version "2.44" apply false + kotlin("plugin.serialization") version "1.8.22" apply false +} diff --git a/nym-vpn/android/gradle.properties b/nym-vpn/android/gradle.properties new file mode 100644 index 0000000000..1023be831f --- /dev/null +++ b/nym-vpn/android/gradle.properties @@ -0,0 +1,25 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true +#enable buildconfig values +android.defaults.buildfeatures.buildconfig=true \ No newline at end of file diff --git a/nym-vpn/android/gradle/wrapper/gradle-wrapper.jar b/nym-vpn/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000..e708b1c023 Binary files /dev/null and b/nym-vpn/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/nym-vpn/android/gradle/wrapper/gradle-wrapper.properties b/nym-vpn/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..96f2e7a959 --- /dev/null +++ b/nym-vpn/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Apr 24 22:46:45 EDT 2023 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/nym-vpn/android/gradlew b/nym-vpn/android/gradlew new file mode 100755 index 0000000000..4f906e0c81 --- /dev/null +++ b/nym-vpn/android/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/nym-vpn/android/gradlew.bat b/nym-vpn/android/gradlew.bat new file mode 100644 index 0000000000..107acd32c4 --- /dev/null +++ b/nym-vpn/android/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/nym-vpn/android/settings.gradle.kts b/nym-vpn/android/settings.gradle.kts new file mode 100644 index 0000000000..aaebe93cd6 --- /dev/null +++ b/nym-vpn/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "NymConnect" +include(":app") + \ No newline at end of file