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