Compare commits

..

30 Commits

Author SHA1 Message Date
Zane Schepke f0ec661223 fix: android 14 foreground permissions
Closes #71
2023-12-21 12:22:55 -05:00
Zane Schepke ffa7a207fb feat: add basic kernel support
Added basic kernel support to allow users to switch between userspace and kernel wireguard

Improved location disclosure flow to only show once once per app install

Fix airplane mode bug

Improve database migration testing

Fix auto-tunneling permission bug.

Lint

Closes #67
Closes #43
2023-12-20 23:17:15 -05:00
Zane Schepke 515e91d191 fix: airplane mode bug 2023-12-09 09:45:54 -05:00
Zane Schepke 16e65aec9f feat: mobile data only auto-tunneling
Added support for configuring auto-tunneling to only tunnel on mobile data with no location permissions necessary.

Improved UI on support screen and updated support resource links.

Changed UI on setting screen to hide trusted wifi networks configuration when not in use.

Changed verbiage on settings screen to make auto-tunneling configuration more intuitive.

Fixed UI bug where analytics expansion could show on deactivated tunnels.

Closes #55
Closes #56
2023-12-05 01:19:00 -05:00
Zane Schepke ff87bee3b4 docs: add support.md 2023-11-29 18:13:29 -05:00
Zane Schepke 9739d35eda chore: update descriptions 2023-11-23 23:48:23 -05:00
Zane Schepke 11ad494fbb feat: add statistics to main screen
Removed Details screen and moved tunnel statistics to be shown on expansion of a tunnel config row

Fixes bug where not scanning a QR code could cause app to crash when navigating back from camera view

Remove vibrations from notifications

Improve navigation of settings screen on AndroidTV
2023-11-23 21:04:26 -05:00
Zane Schepke 90b006acc5 change: build pipeline to fastlane deploy 2023-11-10 18:32:35 -05:00
Zane Schepke eb7b39c379 fix: build warnings and deprecations 2023-11-08 23:43:46 -05:00
Zane Schepke 0a17593310 add: properties to template 2023-11-08 22:26:36 -05:00
Zane Schepke c0e58125dd add: build and release CI
Add build and release pipeline for app

Fixes bug where location permission screen was not appearing on < Android 9

Bump versions
2023-11-08 22:11:52 -05:00
Zane Schepke 3791261f91 add: build and release CI
Add build and release pipeline for app

Fixes bug where location permission screen was not appearing on < Android 9

Bump versions
2023-11-08 21:58:17 -05:00
Zane Schepke d1e61be3ae Update issue templates 2023-11-04 11:45:00 -04:00
Zane Schepke afd4fb127f Update issue templates 2023-11-04 11:41:25 -04:00
Zane Schepke e0cce8fba4 fix: converter backwards compatibility
Fixes database converter to allow for backwards compatabilty.
2023-10-27 00:11:31 -04:00
Zane Schepke b70ecbdfff fix: fdroid build and ssid comma
Fixes bug where commas in SSID names were splitting into multiple SSIDs due to database type converters.
Closes #48

Fixes bug where F-Droid pipeline was failing to build due to lack of proguard rule. Closes #47

Fixes bug where crashes could happen if config QR code or file has improperly configured data.

Bump versions.
2023-10-26 22:05:20 -04:00
Zane Schepke 513d08998b fix: config save bug
Fixes a bug where config changes were saving on the wrong thread, causing a failure to save changes.

Fixes a bug where the quick tile could cause a crash by initializing the tile state before it was ready.
2023-10-23 16:13:37 -04:00
Zane Schepke 79583e0e61 docs: update README 2023-10-21 15:17:09 -04:00
Zane Schepke 75790ec6d5 bump: app version to 3.1.6 2023-10-21 14:13:47 -04:00
Zane Schepke a1941b7229 feat: shortcut intents and battery saver
Added the ability to turn on and off tunnels via intents to the shortcut activity.

Added a setting to enable or disable shortcut/intent control of tunnels.

Added an experimental battery saver setting to auto-tunneling to fix the battery drain issue caused by wakelock.

Fixes a bug where sometimes the config screen could crash if there are issues parsing the tunnel config data.

Database migration
2023-10-21 14:03:36 -04:00
Zane Schepke 37bae82700 feat: zip file import export
Added support for importing zip files containing multiple config files.
Closes #33

Added support for exporting all config files to downloads folder as a zip with biometric or security pin approval.

Added support for editing or viewing private key with biometric or security pin approval.

Fixed a bug where VPN status indicator functionality was unintentionally disabled.

Other various enhancements and refactors.
2023-10-16 22:43:28 -04:00
Zane Schepke 77cd328a71 fix: config screen edit bug
Fixes a bug where config edit will break configs that have commas because a comma and space are required in tunnel configs

 #42
2023-10-14 01:32:46 -04:00
Zane Schepke 5a1430706b fix: config edit whitespace bug
Closes #42
2023-10-13 23:42:39 -04:00
Zane Schepke 321730536d fix: file import handling
Fixes bug causing crashes when importing a file that is not a .conf file.

Fixes file import on AndroidTV and FireTV
Closes #39

Fixes usability issue on AndroidTV where textboxes immediately open the keyboard on hover.
Allows users two keypress access to turning on tunnels from app load.
Closes #36

Improves service efficiencies by making coroutines lifecycle aware.
2023-10-12 20:20:53 -04:00
Zane Schepke 2912238f27 Merge pull request #41 from licaon-kter/patch-1
Gradle 8.3 was released
2023-10-11 09:31:02 -04:00
Licaon_Kter bc7daacd13 Gradle 8.3 was released 2023-10-11 11:42:58 +00:00
Zane Schepke c8f2cfd758 feat: ui update with full config edit
feat: Change config screen UI to accommodate editing of all fields.
Change config screen to allow the addition of new peers.
Closes #31

Change main screen to denote primary tunnel with star.
Change main screen tunnel options to allow changing tunnel to primary.

feat: Change settings screen UI to improve usability.
Closes #32

fix: Change application shortcuts to static shortcuts for the primary tunnel.
Remove dynamic shortcuts.
Closes #38

fix: Database changes from the previous version causing crashes.
#40 #37

fix: bug on detail screen where tunnel name was not showing

feat: Change button color when hovering on AndroidTV to improve visibility
#36

fix: Change file importing method to fix bug on FireTV
Closes #39

docs: update README with new screenshots
2023-10-10 13:19:20 -04:00
Zane Schepke ca3f3fd439 fix: file selection AndroidTV
Closes #35 Closes #34
2023-09-28 09:52:35 -04:00
Zane Schepke 235170508b docs: Update README.md 2023-09-26 17:06:12 -04:00
Zane Schepke 11aea3f1c4 fix: trusted SSIDs bug
Fixes bugs where trusted SSIDs were getting whitespace added every time they were saved to the DB.

Fixes file explorer bug on AndroidTV where the app crashes if they do not have a file explorer installed on the device.
2023-09-25 06:49:07 -04:00
128 changed files with 5225 additions and 2162 deletions
+31
View File
@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG] - Problem with app"
labels: bug
assignees: zaneschepke
---
**Describe the bug**
A clear and concise description of what the bug is.
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- Android Version: [e.g. iOS8.1]
- App Version [e.g. 22]
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots (Only if necessary)**
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE] - New feature request"
labels: enhancement
assignees: zaneschepke
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+17
View File
@@ -0,0 +1,17 @@
# Support
If you are experiencing issues with the app, the following resources are available to help you.
<ol>
<li>
See the app docs site <a href="https://zaneschepke.com/wgtunnel-docs/overview.html">here</a> (work in progress).
</li>
<li>
Chat with me and our community on Discord <a href="https://discord.gg/rbRRNh6H7V">here</a>, or open an issue on GitHub <a href="https://github.com/zaneschepke/wgtunnel/issues/new/choose">here</a>.
</li>
<li>
Send me an email <a href="mailto:zanecschepke@gmail.com">here</a>.
</li>
</ol>
Thank you for using WG Tunnel.
+87
View File
@@ -0,0 +1,87 @@
# name of the workflow
name: Android CI Tag Deployment
on:
push:
tags:
- '*.*.*'
jobs:
build:
name: Build Signed APK
# change to macos because of hilt issues on ubuntu in gradle 8.3
runs-on: ubuntu-latest
env:
KEY_STORE_PATH: ${{ secrets.KEY_STORE_PATH }}
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
# Here we need to decode keystore.jks from base64 string and place it
# in the folder specified in the release signing configuration
- name: Decode Keystore
id: decode_keystore
uses: timheuer/base64-to-file@v1.2
with:
fileName: 'android_keystore.jks'
fileDir: ${{ github.workspace }}/app/keystore/
encodedString: ${{ secrets.KEYSTORE }}
- name: Create service_account.json
id: createServiceAccount
run: echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
# Build and sign APK ("-x test" argument is used to skip tests)
# add fdroid flavor for apk upload
- name: Build Fdroid Release APK
run: ./gradlew :app:assembleFdroidRelease -x test
# get fdroid flavor release apk path
- name: Get apk path
id: apk-path
run: echo "path=$(find . -regex '^.*/build/outputs/apk/fdroid/release/.*\.apk$' -type f | head -1)" >> $GITHUB_OUTPUT
# Save the APK after the Build job is complete to publish it as a Github release in the next job
- name: Upload APK
uses: actions/upload-artifact@v3.1.2
with:
name: wgtunnel
path: ${{ steps.apk-path.outputs.path }}
- name: Download APK from build
uses: actions/download-artifact@v1
with:
name: wgtunnel
- name: Create Release with Fastlane changelog notes
id: create_release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
# fix hardcode changelog file name
body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/32500.txt
tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }}
draft: false
prerelease: false
files: ${{ github.workspace }}/${{ steps.apk-path.outputs.path }}
- name: Deploy with fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2' # Not needed with a .ruby-version file
bundler-cache: true
- name: Distribute app to Beta track 🚀
run: (cd ${{ github.workspace }} && bundle install && bundle exec fastlane beta)
+2
View File
@@ -69,3 +69,5 @@ lint/tmp/
# App Specific cases # App Specific cases
app/release/output.json app/release/output.json
.idea/codeStyles/ .idea/codeStyles/
# where we keep our signing secrets locally
app/signing.properties
+3
View File
@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"
+21 -18
View File
@@ -2,61 +2,64 @@
WG Tunnel WG Tunnel
</h1> </h1>
<span align="center"> <div align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Discord Chat](https://img.shields.io/discord/1108285024631001111.svg)](https://discord.gg/rbRRNh6H7V) [![Discord Chat](https://img.shields.io/discord/1108285024631001111.svg)](https://discord.gg/rbRRNh6H7V)
</span> </div>
<span align="center"> <div align="center">
[![Google Play](https://img.shields.io/badge/Google_Play-414141?style=for-the-badge&logo=google-play&logoColor=white)](https://play.google.com/store/apps/details?id=com.zaneschepke.wireguardautotunnel) [![Google Play](https://img.shields.io/badge/Google_Play-414141?style=for-the-badge&logo=google-play&logoColor=white)](https://play.google.com/store/apps/details?id=com.zaneschepke.wireguardautotunnel)
[![Fire TV](https://img.shields.io/badge/fire%20tv-fc3b2d?style=for-the-badge&logo=amazon%20fire%20tv&logoColor=white)](https://www.amazon.com/gp/product/B0CFGGL7WK) [![Fire TV](https://img.shields.io/badge/fire%20tv-fc3b2d?style=for-the-badge&logo=amazon%20fire%20tv&logoColor=white)](https://www.amazon.com/gp/product/B0CFGGL7WK)
[![F-Droid](https://img.shields.io/static/v1?style=for-the-badge&message=F-Droid&color=1976D2&logo=F-Droid&logoColor=FFFFFF&label=)](https://f-droid.org/packages/com.zaneschepke.wireguardautotunnel/)
</span>
<span align="center"> </div>
<div align="center">
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/N4N8NMJN2) [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/N4N8NMJN2)
</span> </div>
<span align="left"> <div align="left">
This is an alternative Android Application for [WireGuard](https://www.wireguard.com/) with added features. Built using the [wireguard-android](https://github.com/WireGuard/wireguard-android) library and [Jetpack Compose](https://developer.android.com/jetpack/compose), this application was inspired by the official [WireGuard Android](https://github.com/WireGuard/wireguard-android) app. This is an alternative Android Application for [WireGuard](https://www.wireguard.com/) with added features. Built using the [wireguard-android](https://github.com/WireGuard/wireguard-android) library and [Jetpack Compose](https://developer.android.com/jetpack/compose), this application was inspired by the official [WireGuard Android](https://github.com/WireGuard/wireguard-android) app.
</span> </div>
<span align="center"> <div align="center">
## Screenshots ## Screenshots
<p float="center"> <p float="center">
<img label="Main" style="padding-right:25px" src="asset/main_screen.png" width="200" /> <img label="Main" style="padding-right:25px" src="asset/main_screen.png" width="200" />
<img label="Config" style="padding-left:25px" src="./asset/config_screen.png" width="200" /> <img label="Config" style="padding-left:25px" src="asset/config_screen.png" width="200" />
<img label="Settings" style="padding-left:25px" src="asset/settings_screen.png" width="200" /> <img label="Settings" style="padding-left:25px" src="asset/settings_screen.png" width="200" />
<img label="Support" style="padding-left:25px" src="asset/support_screen.png" width="200" /> <img label="Support" style="padding-left:25px" src="asset/support_screen.png" width="200" />
</p> </p>
<span align="left"> <div align="left">
## Inspiration ## Inspiration
The inspiration for this app came from the inconvenience of constantly having to turn VPN off and on while on different networks. With there being no free solution to this problem, this app was created to meet that need. The original inspiration for this app came from the inconvenience of having to manually turn VPN off and on while on different networks. This app was created to offer a free solution to this problem.
## Features ## Features
* Add tunnels via .conf file * Add tunnels via .conf file, zip, manual entry, or QR code
* Auto connect to VPN based on Wi-Fi SSID * Auto connect to VPN based on Wi-Fi SSID
* Split tunneling by application with search * Split tunneling by application with search
* Always-on VPN for Android support * Always-on VPN for Android support
* Quick tile support for vpn toggling * Export tunnels to zip
* Dynamic shortcuts support for automation integration * Quick tile support for VPN toggling
* Configurable Trusted Network list * Static shortcuts support for primary tunnel for automation integration
* Optional auto connect on mobile data * Intent automation support for all tunnels
* Optional auto connect on mobile data, ethernet
* Automatic service restart after reboot * Automatic service restart after reboot
* Service will stay running in background after app has been closed * Service will stay running in background after app has been closed
@@ -66,7 +69,7 @@ The inspiration for this app came from the inconvenience of constantly having to
``` ```
$ git clone https://github.com/zaneschepke/wgtunnel $ git clone https://github.com/zaneschepke/wgtunnel
$ cd wgtunnel $ cd wgtunnel
$ ./gradlew assembleRelease $ ./gradlew assembleDebug
``` ```
</span> </span>
+98 -26
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@@ -7,17 +9,23 @@ plugins {
} }
android { android {
namespace = "com.zaneschepke.wireguardautotunnel" namespace = Constants.APP_ID
compileSdk = 34 compileSdk = Constants.TARGET_SDK
defaultConfig { defaultConfig {
applicationId = "com.zaneschepke.wireguardautotunnel" applicationId = Constants.APP_ID
minSdk = 26 minSdk = Constants.MIN_SDK
targetSdk = 34 targetSdk = Constants.TARGET_SDK
versionCode = 30001 versionCode = Constants.VERSION_CODE
versionName = "3.0.1" versionName = Constants.VERSION_NAME
multiDexEnabled = true ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room
}
resourceConfigurations.addAll(listOf("en")) resourceConfigurations.addAll(listOf("en"))
@@ -27,7 +35,56 @@ android {
} }
} }
signingConfigs {
create(Constants.RELEASE) {
val properties =
Properties().apply {
// created local file for signing details
try {
load(file("signing.properties").reader())
} catch (_: Exception) {
load(file("signing_template.properties").reader())
}
}
// try to get secrets from env first for pipeline build, then properties file for local build
storeFile = file(
System.getenv().getOrDefault(
Constants.KEY_STORE_PATH_VAR,
properties.getProperty(Constants.KEY_STORE_PATH_VAR)
)
)
storePassword = System.getenv().getOrDefault(
Constants.STORE_PASS_VAR,
properties.getProperty(Constants.STORE_PASS_VAR)
)
keyAlias = System.getenv().getOrDefault(
Constants.KEY_ALIAS_VAR,
properties.getProperty(Constants.KEY_ALIAS_VAR)
)
keyPassword = System.getenv().getOrDefault(
Constants.KEY_PASS_VAR,
properties.getProperty(Constants.KEY_PASS_VAR)
)
}
}
buildTypes { buildTypes {
// don't strip
packaging.jniLibs.keepDebugSymbols.addAll(
listOf("libwg-go.so", "libwg-quick.so", "libwg.so")
)
applicationVariants.all {
val variant = this
variant.outputs
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
.forEach { output ->
val outputFileName =
"${Constants.APP_NAME}-${variant.flavorName}-${variant.buildType.name}-${variant.versionName}.apk"
output.outputFileName = outputFileName
}
}
release { release {
isDebuggable = false isDebuggable = false
isMinifyEnabled = true isMinifyEnabled = true
@@ -36,20 +93,21 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
signingConfig = signingConfigs.getByName(Constants.RELEASE)
} }
debug { debug {
isDebuggable = true isDebuggable = true
} }
} }
flavorDimensions.add("type") flavorDimensions.add(Constants.TYPE)
productFlavors { productFlavors {
create("fdroid") { create("fdroid") {
dimension = "type" dimension = Constants.TYPE
proguardFile("fdroid-rules.pro")
} }
create("general") { create("general") {
dimension = "type" dimension = Constants.TYPE
if (BuildHelper.isReleaseBuild(gradle) && BuildHelper.isGeneralFlavor(gradle)) if (BuildHelper.isReleaseBuild(gradle) && BuildHelper.isGeneralFlavor(gradle)) {
{
apply(plugin = "com.google.gms.google-services") apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics") apply(plugin = "com.google.firebase.crashlytics")
} }
@@ -58,14 +116,14 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
jvmTarget = "17" jvmTarget = Constants.JVM_TARGET
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
@@ -81,6 +139,8 @@ val generalImplementation by configurations
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
// optional - helpers for implementing LifecycleOwner in a Service
implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
@@ -89,19 +149,22 @@ dependencies {
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
//test // test
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test) androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.androidx.room.testing)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.manifest) debugImplementation(libs.androidx.compose.manifest)
//wg // wg
implementation(libs.tunnel) implementation(libs.tunnel)
coreLibraryDesugaring(libs.desugar.jdk.libs)
//logging // logging
implementation(libs.timber) implementation(libs.timber)
// compose navigation // compose navigation
@@ -112,32 +175,41 @@ dependencies {
implementation(libs.hilt.android) implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
//accompanist // accompanist
implementation(libs.accompanist.systemuicontroller) implementation(libs.accompanist.systemuicontroller)
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.navigation.animation)
implementation(libs.accompanist.drawablepainter) implementation(libs.accompanist.drawablepainter)
//room // storage
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
implementation(libs.androidx.datastore.preferences)
//lifecycle // lifecycle
implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process)
//icons // icons
implementation(libs.material.icons.extended) implementation(libs.material.icons.extended)
//serialization // serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
//firebase crashlytics // firebase crashlytics
generalImplementation(platform(libs.firebase.bom)) generalImplementation(platform(libs.firebase.bom))
generalImplementation(libs.google.firebase.crashlytics.ktx) generalImplementation(libs.google.firebase.crashlytics.ktx)
generalImplementation(libs.google.firebase.analytics.ktx) generalImplementation(libs.google.firebase.analytics.ktx)
//barcode scanning // barcode scanning
implementation(libs.zxing.android.embedded) implementation(libs.zxing.android.embedded)
implementation(libs.zxing.core) implementation(libs.zxing.core)
// bio
implementation(libs.androidx.biometric.ktx)
// shortcuts
implementation(libs.androidx.core)
implementation(libs.androidx.core.google.shortcuts)
} }
+5
View File
@@ -0,0 +1,5 @@
-dontwarn com.google.errorprone.annotations.**
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
+3
View File
@@ -19,3 +19,6 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
@@ -0,0 +1,112 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "ba86153e6fb0b823197b987239b03e64",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ba86153e6fb0b823197b987239b03e64')"
]
}
}
@@ -0,0 +1,126 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "65b1c9efff61712231fa64d1f19f3915",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '65b1c9efff61712231fa64d1f19f3915')"
]
}
}
@@ -0,0 +1,133 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "6b30daba29bb95f8ddc4d26206329d4f",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6b30daba29bb95f8ddc4d26206329d4f')"
]
}
}
@@ -0,0 +1,154 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "aee55639422df8dadfe74c3bad204477",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelEnabled",
"columnName": "is_kernel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aee55639422df8dadfe74c3bad204477')"
]
}
}
+4
View File
@@ -0,0 +1,4 @@
SIGNING_STORE_PASSWORD=
SIGNING_KEY_ALIAS=
SIGNING_KEY_PASSWORD=
KEY_STORE_PATH=/
@@ -1,13 +1,11 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *
@@ -0,0 +1,63 @@
package com.zaneschepke.wireguardautotunnel
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase
import java.io.IOException
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MigrationTest {
private val dbName = "migration-test"
@get:Rule
val helper: MigrationTestHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java
)
@Test
@Throws(IOException::class)
fun migrate2To3() {
helper.createDatabase(dbName, 3).apply {
// Database has schema version 1. Insert some data using SQL queries.
// You can't use DAO classes because they expect the latest schema.
execSQL(
"INSERT INTO Settings (is_tunnel_enabled, " +
"is_tunnel_on_mobile_data_enabled," +
"trusted_network_ssids," +
"default_tunnel, " +
"is_always_on_vpn_enabled," +
"is_tunnel_on_ethernet_enabled," +
"is_shortcuts_enabled," +
"is_battery_saver_enabled," +
"is_tunnel_on_wifi_enabled)" +
" VALUES (" +
"false," +
"false," +
"'[trustedSSID1,trustedSSID2]'," +
"'defaultTunnel'," +
"false," +
"false," +
"false," +
"false," +
"false)"
)
execSQL(
"INSERT INTO TunnelConfig (name, wg_quick)" +
" VALUES ('hello', 'hello')"
)
// Prepare for the next version.
close()
}
// Re-open the database with version 2 and provide
// MIGRATION_1_2 as the migration process.
helper.runMigrationsAndValidate(dbName, 4, true)
// MigrationTestHelper automatically verifies the schema changes,
// but you need to validate that the data was migrated properly.
}
}
+22 -8
View File
@@ -4,6 +4,10 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" <uses-permission android:name="android.permission.ACCESS_WIFI_STATE"
android:maxSdkVersion="30" android:maxSdkVersion="30"
tools:ignore="LeanbackUsesWifi" /> tools:ignore="LeanbackUsesWifi" />
@@ -13,8 +17,11 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/> <!--foreground service exempt android 14-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<!--foreground service permissions--> <!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
@@ -38,6 +45,7 @@
</queries> </queries>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:name=".WireGuardAutoTunnel" android:name=".WireGuardAutoTunnel"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@@ -47,7 +55,7 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.WireguardAutoTunnel" android:theme="@style/Theme.WireguardAutoTunnel"
tools:targetApi="31"> tools:targetApi="tiramisu">
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
@@ -58,6 +66,8 @@
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES"/> <action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity> </activity>
<activity <activity
android:name=".ui.CaptureActivityPortrait" android:name=".ui.CaptureActivityPortrait"
@@ -67,12 +77,15 @@
android:windowSoftInputMode="stateAlwaysHidden" /> android:windowSoftInputMode="stateAlwaysHidden" />
<activity <activity
android:finishOnTaskLaunch="true" android:finishOnTaskLaunch="true"
android:enabled="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay" android:theme="@android:style/Theme.NoDisplay"
android:name=".service.shortcut.ShortcutsActivity"/> android:name=".service.shortcut.ShortcutsActivity"/>
<service <service
android:name=".service.foreground.ForegroundService" android:name=".service.foreground.ForegroundService"
android:enabled="true" android:enabled="true"
android:foregroundServiceType="remoteMessaging" android:foregroundServiceType="systemExempted|specialUse"
tools:node="merge"
android:exported="false"> android:exported="false">
</service> </service>
<service <service
@@ -94,21 +107,22 @@
android:permission="android.permission.BIND_VPN_SERVICE" android:permission="android.permission.BIND_VPN_SERVICE"
android:enabled="true" android:enabled="true"
android:persistent="true" android:persistent="true"
android:foregroundServiceType="remoteMessaging" android:foregroundServiceType="systemExempted|specialUse"
tools:node="merge"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.net.VpnService"/> <action android:name="android.net.VpnService"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON" <meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="true"/> android:value="true" />
</service> </service>
<service <service
android:name=".service.foreground.WireGuardConnectivityWatcherService" android:name=".service.foreground.WireGuardConnectivityWatcherService"
android:enabled="true" android:enabled="true"
android:stopWithTask="false" android:stopWithTask="false"
android:persistent="true" android:persistent="true"
android:foregroundServiceType="location" android:foregroundServiceType="systemExempted|specialUse"
android:permission="" tools:node="merge"
android:exported="false"> android:exported="false">
</service> </service>
<receiver android:enabled="true" android:name=".receiver.BootReceiver" <receiver android:enabled="true" android:name=".receiver.BootReceiver"
@@ -1,15 +1,21 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
object Constants { object Constants {
const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val WATCHER_SERVICE_WAKE_LOCK_TIMEOUT = 10 * 60 * 1000L // 10 minutes
const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L
const val VPN_STATISTIC_CHECK_INTERVAL = 10000L const val VPN_STATISTIC_CHECK_INTERVAL = 1000L
const val SNACKBAR_DELAY = 3000L
const val TOGGLE_TUNNEL_DELAY = 500L const val TOGGLE_TUNNEL_DELAY = 500L
const val FADE_IN_ANIMATION_DURATION = 1000 const val FADE_IN_ANIMATION_DURATION = 1000
const val SLIDE_IN_ANIMATION_DURATION = 500 const val SLIDE_IN_ANIMATION_DURATION = 500
const val SLIDE_IN_TRANSITION_OFFSET = 1000 const val SLIDE_IN_TRANSITION_OFFSET = 1000
const val VALID_FILE_EXTENSION = ".conf" const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content" const val URI_CONTENT_SCHEME = "content"
const val URI_PACKAGE_SCHEME = "package" const val URI_PACKAGE_SCHEME = "package"
const val ALLOWED_FILE_TYPES = "*/*" const val ALLOWED_FILE_TYPES = "*/*"
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val EMAIL_MIME_TYPE = "message/rfc822"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
} }
@@ -0,0 +1,31 @@
package com.zaneschepke.wireguardautotunnel
import android.content.BroadcastReceiver
import java.math.BigDecimal
import java.text.DecimalFormat
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
fun BroadcastReceiver.goAsync(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
) {
val pendingResult = goAsync()
@OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback.
GlobalScope.launch(context) {
try {
block()
} finally {
pendingResult.finish()
}
}
}
fun BigDecimal.toThreeDecimalPlaceString(): String {
val df = DecimalFormat("#.###")
return df.format(this)
}
@@ -3,21 +3,53 @@ package com.zaneschepke.wireguardautotunnel
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@HiltAndroidApp @HiltAndroidApp
class WireGuardAutoTunnel : Application() { class WireGuardAutoTunnel : Application() {
@Inject
lateinit var settingsRepo: SettingsDoa
@Inject
lateinit var dataStoreManager: DataStoreManager
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if(BuildConfig.DEBUG) { if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
Timber.plant(Timber.DebugTree()) initSettings()
with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch {
try {
// load preferences into memory
dataStoreManager.init()
} catch (e: IOException) {
Timber.e("Failed to load preferences")
}
}
}
}
private fun initSettings() {
with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch {
if (settingsRepo.getAll().isEmpty()) {
settingsRepo.save(Settings())
}
}
} }
} }
companion object { companion object {
fun isRunningOnAndroidTv(context : Context) : Boolean { fun isRunningOnAndroidTv(context: Context): Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
} }
} }
@@ -16,10 +16,15 @@ import javax.inject.Singleton
class DatabaseModule { class DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context : Context) : AppDatabase { fun provideDatabase(
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder( return Room.databaseBuilder(
context, context,
AppDatabase::class.java, context.getString(R.string.db_name) AppDatabase::class.java,
).build() context.getString(R.string.db_name)
)
.fallbackToDestructiveMigration()
.build()
} }
} }
@@ -0,0 +1,7 @@
package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Kernel
@@ -1,27 +1,35 @@
package com.zaneschepke.wireguardautotunnel.module package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase import com.zaneschepke.wireguardautotunnel.repository.AppDatabase
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class RepositoryModule { class RepositoryModule {
@Singleton @Singleton
@Provides @Provides
fun provideSettingsRepository(appDatabase: AppDatabase) : SettingsDoa { fun provideSettingsRepository(appDatabase: AppDatabase): SettingsDoa {
return appDatabase.settingDao() return appDatabase.settingDao()
} }
@Singleton @Singleton
@Provides @Provides
fun provideTunnelConfigRepository(appDatabase: AppDatabase) : TunnelConfigDao { fun provideTunnelConfigRepository(appDatabase: AppDatabase): TunnelConfigDao {
return appDatabase.tunnelConfigDoa() return appDatabase.tunnelConfigDoa()
} }
@Singleton
@Provides
fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager {
return DataStoreManager(context)
}
} }
@@ -15,20 +15,19 @@ import dagger.hilt.android.scopes.ServiceScoped
@Module @Module
@InstallIn(ServiceComponent::class) @InstallIn(ServiceComponent::class)
abstract class ServiceModule { abstract class ServiceModule {
@Binds
@ServiceScoped
abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification): NotificationService
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification) : NotificationService abstract fun provideWifiService(wifiService: WifiService): NetworkService<WifiService>
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideWifiService(wifiService: WifiService) : NetworkService<WifiService> abstract fun provideMobileDataService(mobileDataService: MobileDataService): NetworkService<MobileDataService>
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideMobileDataService(mobileDataService : MobileDataService) : NetworkService<MobileDataService> abstract fun provideEthernetService(ethernetService: EthernetService): NetworkService<EthernetService>
@Binds
@ServiceScoped
abstract fun provideEthernetService(ethernetService: EthernetService) : NetworkService<EthernetService>
} }
@@ -3,6 +3,10 @@ package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import android.content.Context
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import dagger.Module import dagger.Module
@@ -15,17 +19,40 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class TunnelModule { class TunnelModule {
@Provides
@Singleton
fun provideRootShell(
@ApplicationContext context: Context
): RootShell {
return RootShell(context)
}
@Provides @Provides
@Singleton @Singleton
fun provideBackend(@ApplicationContext context : Context) : Backend { @Userspace
fun provideUserspaceBackend(
@ApplicationContext context: Context
): Backend {
return GoBackend(context) return GoBackend(context)
} }
@Provides @Provides
@Singleton @Singleton
fun provideVpnService(backend: Backend) : VpnService { @Kernel
return WireGuardTunnel(backend) fun provideKernelBackend(
@ApplicationContext context: Context,
rootShell: RootShell
): Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell))
} }
@Provides
@Singleton
fun provideVpnService(
@Userspace userspaceBackend: Backend,
@Kernel kernelBackend: Backend,
settingsDoa: SettingsDoa
): VpnService {
return WireGuardTunnel(userspaceBackend, kernelBackend, settingsDoa)
}
} }
@@ -0,0 +1,7 @@
package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Userspace
@@ -3,35 +3,33 @@ package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint 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 import javax.inject.Inject
import kotlinx.coroutines.cancel
@AndroidEntryPoint @AndroidEntryPoint
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo : SettingsDoa lateinit var settingsRepo: SettingsDoa
override fun onReceive(context: Context, intent: Intent) { override fun onReceive(
context: Context,
intent: Intent
) = goAsync {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) { if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
CoroutineScope(Dispatchers.IO).launch { try {
try { val settings = settingsRepo.getAll()
val settings = settingsRepo.getAll() if (settings.isNotEmpty()) {
if (settings.isNotEmpty()) { val setting = settings.first()
val setting = settings.first() if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) { ServiceManager.startWatcherService(context, setting.defaultTunnel!!)
ServiceManager.startWatcherService(context, setting.defaultTunnel!!)
}
} }
} finally {
cancel()
} }
} finally {
cancel()
} }
} }
} }
@@ -4,36 +4,35 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() { class NotificationActionReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo : SettingsDoa lateinit var settingsRepo: SettingsDoa
override fun onReceive(context: Context, intent: Intent?) {
CoroutineScope(Dispatchers.IO).launch { override fun onReceive(
try { context: Context,
val settings = settingsRepo.getAll() intent: Intent?
if (settings.isNotEmpty()) { ) = goAsync {
val setting = settings.first() try {
if (setting.defaultTunnel != null) { val settings = settingsRepo.getAll()
ServiceManager.stopVpnService(context) if (settings.isNotEmpty()) {
delay(Constants.TOGGLE_TUNNEL_DELAY) val setting = settings.first()
ServiceManager.startVpnService(context, setting.defaultTunnel.toString()) if (setting.defaultTunnel != null) {
} ServiceManager.stopVpnService(context)
delay(Constants.TOGGLE_TUNNEL_DELAY)
ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
} }
} finally {
cancel()
} }
} finally {
cancel()
} }
} }
} }
@@ -1,14 +1,26 @@
package com.zaneschepke.wireguardautotunnel.repository package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.AutoMigration
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
@Database(entities = [Settings::class, TunnelConfig::class], version = 1, exportSchema = false) @Database(
entities = [Settings::class, TunnelConfig::class],
version = 4,
autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(
from = 3,
to = 4
)
],
exportSchema = true
)
@TypeConverters(DatabaseListConverters::class) @TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun settingDao(): SettingsDoa abstract fun settingDao(): SettingsDoa
abstract fun tunnelConfigDoa() : TunnelConfigDao
abstract fun tunnelConfigDoa(): TunnelConfigDao
} }
@@ -1,15 +1,24 @@
package com.zaneschepke.wireguardautotunnel.repository package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.TypeConverter import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class DatabaseListConverters { class DatabaseListConverters {
@TypeConverter @TypeConverter
fun listToString(value: MutableList<String>): String { fun listToString(value: MutableList<String>): String {
return value.joinToString() return Json.encodeToString(value)
} }
@TypeConverter @TypeConverter
fun <T> stringToList(value: String): MutableList<String> { fun stringToList(value: String): MutableList<String> {
if(value.isEmpty()) return mutableListOf() if (value.isEmpty()) return mutableListOf()
return value.split(",").toMutableList() return try {
Json.decodeFromString<MutableList<String>>(value)
} catch (e: Exception) {
val list = value.split(",").toMutableList()
val json = listToString(list)
Json.decodeFromString<MutableList<String>>(json)
}
} }
} }
@@ -10,7 +10,6 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface SettingsDoa { interface SettingsDoa {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: Settings) suspend fun save(t: Settings)
@@ -9,8 +9,7 @@ import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface TunnelConfigDao{ interface TunnelConfigDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: TunnelConfig) suspend fun save(t: TunnelConfig)
@@ -0,0 +1,39 @@
package com.zaneschepke.wireguardautotunnel.repository.datastore
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class DataStoreManager(private val context: Context) {
companion object {
val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
}
// preferences
private val preferencesKey = "preferences"
private val Context.dataStore by preferencesDataStore(
name = preferencesKey
)
suspend fun init() {
context.dataStore.data.first()
}
suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) =
context.dataStore.edit {
it[key] = value
}
fun <T> getFromStore(key: Preferences.Key<T>) =
context.dataStore.data.map {
it[key]
}
val locationDisclosureFlow: Flow<Boolean?> = context.dataStore.data.map {
it[LOCATION_DISCLOSURE_SHOWN]
}
}
@@ -6,11 +6,44 @@ import androidx.room.PrimaryKey
@Entity @Entity
data class Settings( data class Settings(
@PrimaryKey(autoGenerate = true) val id : Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled : Boolean = false, @ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") var isTunnelOnMobileDataEnabled : Boolean = false, @ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") var isTunnelOnMobileDataEnabled: Boolean = false,
@ColumnInfo(name = "trusted_network_ssids") var trustedNetworkSSIDs : MutableList<String> = mutableListOf(), @ColumnInfo(name = "trusted_network_ssids") var trustedNetworkSSIDs: MutableList<String> = mutableListOf(),
@ColumnInfo(name = "default_tunnel") var defaultTunnel : String? = null, @ColumnInfo(name = "default_tunnel") var defaultTunnel: String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled : Boolean = false, @ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled : Boolean = false, @ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled: Boolean = false,
) @ColumnInfo(
name = "is_shortcuts_enabled",
defaultValue = "false"
) var isShortcutsEnabled: Boolean = false,
@ColumnInfo(
name = "is_battery_saver_enabled",
defaultValue = "false"
) var isBatterySaverEnabled: Boolean = false,
@ColumnInfo(
name = "is_tunnel_on_wifi_enabled",
defaultValue = "false"
) var isTunnelOnWifiEnabled: Boolean = false,
@ColumnInfo(
name = "is_kernel_enabled",
defaultValue = "false"
) var isKernelEnabled: Boolean = false,
@ColumnInfo(
name = "is_restore_on_boot_enabled",
defaultValue = "false"
) var isRestoreOnBootEnabled: Boolean = false,
@ColumnInfo(
name = "is_multi_tunnel_enabled",
defaultValue = "false"
) var isMultiTunnelEnabled: Boolean = false
) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean {
return if (defaultTunnel != null) {
val defaultConfig = TunnelConfig.from(defaultTunnel!!)
(tunnelConfig.id == defaultConfig.id)
} else {
false
}
}
}
@@ -5,77 +5,26 @@ import androidx.room.Entity
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.wireguard.config.Config import com.wireguard.config.Config
import java.io.InputStream
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.io.InputStream
@Entity(indices = [Index(value = ["name"], unique = true)]) @Entity(indices = [Index(value = ["name"], unique = true)])
@Serializable @Serializable
data class TunnelConfig( data class TunnelConfig(
@PrimaryKey(autoGenerate = true) val id : Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "name") var name : String, @ColumnInfo(name = "name") var name: String,
@ColumnInfo(name = "wg_quick") var wgQuick : String, @ColumnInfo(name = "wg_quick") var wgQuick: String
){ ) {
override fun toString(): String { override fun toString(): String {
return Json.encodeToString(serializer(), this) return Json.encodeToString(serializer(), this)
} }
companion object { companion object {
private const val INCLUDED_APPLICATIONS = "IncludedApplications = " fun from(string: String): TunnelConfig {
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<String>, wgQuick: String) : String {
if(packages.isEmpty()) {
return wgQuick
}
val clearedWgQuick = clearAllApplicationsFromConfig(wgQuick)
val excludeConfig = buildExcludedApplicationsString(packages)
return addApplicationsToConfig(excludeConfig, clearedWgQuick)
}
fun setIncludedApplicationsOnQuick(packages : List<String>, 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>) : String {
return EXCLUDED_APPLICATIONS + packages.joinToString(APP_CONFIG_SEPARATOR)
}
private fun buildIncludedApplicationsString(packages : List<String>) : String {
return INCLUDED_APPLICATIONS + packages.joinToString(APP_CONFIG_SEPARATOR)
}
fun from(string : String) : TunnelConfig {
return Json.decodeFromString<TunnelConfig>(string) return Json.decodeFromString<TunnelConfig>(string)
} }
fun configFromQuick(wgQuick: String): Config { fun configFromQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream() val inputStream: InputStream = wgQuick.byteInputStream()
val reader = inputStream.bufferedReader(Charsets.UTF_8) val reader = inputStream.bufferedReader(Charsets.UTF_8)
@@ -1,22 +1,26 @@
package com.zaneschepke.wireguardautotunnel.service.foreground package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import androidx.lifecycle.LifecycleService
import timber.log.Timber import timber.log.Timber
open class ForegroundService : LifecycleService() {
open class ForegroundService : Service() {
private var isServiceStarted = false private var isServiceStarted = false
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
super.onBind(intent)
// We don't provide binding, so return null // We don't provide binding, so return null
return null return null
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int
): Int {
super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId") Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) { if (intent != null) {
val action = intent.action val action = intent.action
@@ -39,19 +43,18 @@ open class ForegroundService : Service() {
return START_STICKY return START_STICKY
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
Timber.d("The service has been destroyed") Timber.d("The service has been destroyed")
} }
protected open fun startService(extras : Bundle?) { protected open fun startService(extras: Bundle?) {
if (isServiceStarted) return if (isServiceStarted) return
Timber.d("Starting ${this.javaClass.simpleName}") Timber.d("Starting ${this.javaClass.simpleName}")
isServiceStarted = true isServiceStarted = true
} }
protected open fun stopService(extras : Bundle?) { protected open fun stopService(extras: Bundle?) {
Timber.d("Stopping ${this.javaClass.simpleName}") Timber.d("Stopping ${this.javaClass.simpleName}")
try { try {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
@@ -16,44 +16,60 @@ object ServiceManager {
.getRunningServices(Integer.MAX_VALUE) .getRunningServices(Integer.MAX_VALUE)
.any { it.service.className == service.name } .any { it.service.className == service.name }
fun <T : Service> getServiceState(context: Context, cls : Class<T>): ServiceState { fun <T : Service> getServiceState(
context: Context,
cls: Class<T>
): ServiceState {
val isServiceRunning = context.isServiceRunning(cls) val isServiceRunning = context.isServiceRunning(cls)
return if(isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED return if (isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED
} }
private fun <T : Service> actionOnService(action: Action, context: Context, cls : Class<T>, extras : Map<String,String>? = null) { private fun <T : Service> actionOnService(
action: Action,
context: Context,
cls: Class<T>,
extras: Map<String, String>? = null
) {
if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return
if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return
val intent = Intent(context, cls).also { val intent =
it.action = action.name Intent(context, cls).also {
extras?.forEach {(k, v) -> it.action = action.name
it.putExtra(k, v) extras?.forEach { (k, v) ->
it.putExtra(k, v)
}
} }
}
intent.component?.javaClass intent.component?.javaClass
try { try {
when(action) { when (action) {
Action.START_FOREGROUND -> { Action.START_FOREGROUND -> {
context.startForegroundService(intent) context.startForegroundService(intent)
} }
Action.START -> { Action.START -> {
context.startService(intent) context.startService(intent)
} }
Action.STOP -> context.startService(intent) Action.STOP -> context.startService(intent)
} }
} catch (e : Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
} }
} }
fun startVpnService(context : Context, tunnelConfig : String) { fun startVpnService(
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START, Action.START,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)
)
} }
fun stopVpnService(context : Context) {
fun stopVpnService(context: Context) {
actionOnService( actionOnService(
Action.STOP, Action.STOP,
context, context,
@@ -61,47 +77,68 @@ object ServiceManager {
) )
} }
fun startVpnServiceForeground(context : Context, tunnelConfig : String) { fun startVpnServiceForeground(
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START_FOREGROUND, Action.START_FOREGROUND,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)
)
} }
private fun startWatcherServiceForeground(context : Context, tunnelConfig : String) { private fun startWatcherServiceForeground(
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START, context, Action.START,
WireGuardConnectivityWatcherService::class.java, mapOf(context. context,
getString(R.string.tunnel_extras_key) to WireGuardConnectivityWatcherService::class.java,
tunnelConfig)) mapOf(
context
.getString(R.string.tunnel_extras_key) to
tunnelConfig
)
)
} }
fun startWatcherService(context : Context, tunnelConfig : String) { fun startWatcherService(
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START, context, Action.START,
WireGuardConnectivityWatcherService::class.java, mapOf(context. context,
getString(R.string.tunnel_extras_key) to WireGuardConnectivityWatcherService::class.java,
tunnelConfig)) mapOf(
context
.getString(R.string.tunnel_extras_key) to
tunnelConfig
)
)
} }
fun stopWatcherService(context : Context) { fun stopWatcherService(context: Context) {
actionOnService( actionOnService(
Action.STOP, context, Action.STOP,
WireGuardConnectivityWatcherService::class.java) context,
WireGuardConnectivityWatcherService::class.java
)
} }
fun toggleWatcherService(context: Context, tunnelConfig : String) { fun toggleWatcherServiceForeground(
when(getServiceState( context, context: Context,
WireGuardConnectivityWatcherService::class.java,)) { tunnelConfig: String
ServiceState.STARTED -> stopWatcherService(context) ) {
ServiceState.STOPPED -> startWatcherService(context, tunnelConfig) when (
} getServiceState(
} context,
WireGuardConnectivityWatcherService::class.java
fun toggleWatcherServiceForeground(context: Context, tunnelConfig : String) { )
when(getServiceState( context, ) {
WireGuardConnectivityWatcherService::class.java,)) {
ServiceState.STARTED -> stopWatcherService(context) ServiceState.STARTED -> stopWatcherService(context)
ServiceState.STOPPED -> startWatcherServiceForeground(context, tunnelConfig) ServiceState.STOPPED -> startWatcherServiceForeground(context, tunnelConfig)
} }
@@ -7,6 +7,8 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@@ -20,24 +22,23 @@ import com.zaneschepke.wireguardautotunnel.service.network.WifiService
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardConnectivityWatcherService : ForegroundService() { class WireGuardConnectivityWatcherService : ForegroundService() {
private val foregroundId = 122
private val foregroundId = 122;
@Inject @Inject
lateinit var wifiService : NetworkService<WifiService> lateinit var wifiService: NetworkService<WifiService>
@Inject @Inject
lateinit var mobileDataService : NetworkService<MobileDataService> lateinit var mobileDataService: NetworkService<MobileDataService>
@Inject @Inject
lateinit var ethernetService: NetworkService<EthernetService> lateinit var ethernetService: NetworkService<EthernetService>
@@ -46,45 +47,54 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepo: SettingsDoa
@Inject @Inject
lateinit var notificationService : NotificationService lateinit var notificationService: NotificationService
@Inject @Inject
lateinit var vpnService : VpnService lateinit var vpnService: VpnService
private var isWifiConnected = false; private var isWifiConnected = false
private var isEthernetConnected = false; private var isEthernetConnected = false
private var isMobileDataConnected = false; private var isMobileDataConnected = false
private var currentNetworkSSID = ""; private var currentNetworkSSID = ""
private lateinit var watcherJob : Job; private lateinit var watcherJob: Job
private lateinit var setting : Settings private lateinit var setting: Settings
private lateinit var tunnelConfig: String private lateinit var tunnelConfig: String
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private val tag = this.javaClass.name; private val tag = this.javaClass.name
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
CoroutineScope(Dispatchers.Main).launch { lifecycleScope.launch(Dispatchers.Main) {
launchWatcherNotification() try {
launchWatcherNotification()
} catch (e: Exception) {
Timber.e("Failed to start watcher service, not enough permissions")
}
} }
} }
override fun startService(extras: Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
launchWatcherNotification() try {
val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key)) launchWatcherNotification()
if (tunnelId != null) { val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key))
this.tunnelConfig = tunnelId if (tunnelId != null) {
} this.tunnelConfig = tunnelId
// we need this lock so our service gets not affected by Doze Mode }
initWakeLock() // we need this lock so our service gets not affected by Doze Mode
cancelWatcherJob() lifecycleScope.launch {
if(this::tunnelConfig.isInitialized) { initWakeLock()
startWatcherJob() }
} else { cancelWatcherJob()
stopService(extras) if (this::tunnelConfig.isInitialized) {
startWatcherJob()
} else {
stopService(extras)
}
} catch (e: Exception) {
Timber.e("Failed to launch watcher service, no permissions")
} }
} }
@@ -100,75 +110,106 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
} }
private fun launchWatcherNotification() { private fun launchWatcherNotification() {
val notification = notificationService.createNotification( val notification =
channelId = getString(R.string.watcher_channel_id), notificationService.createNotification(
channelName = getString(R.string.watcher_channel_name), channelId = getString(R.string.watcher_channel_id),
description = getString(R.string.watcher_notification_text)) channelName = getString(R.string.watcher_channel_name),
super.startForeground(foregroundId, notification) description = getString(R.string.watcher_notification_text),
vibration = false
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
)
} }
//try to start task again if killed // try to start task again if killed
override fun onTaskRemoved(rootIntent: Intent) { override fun onTaskRemoved(rootIntent: Intent) {
Timber.d("Task Removed called") Timber.d("Task Removed called")
val restartServiceIntent = Intent(rootIntent) val restartServiceIntent = Intent(rootIntent)
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, val restartServicePendingIntent: PendingIntent =
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE); PendingIntent.getService(
applicationContext.getSystemService(Context.ALARM_SERVICE); this,
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager; 1,
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent); 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() { private suspend fun initWakeLock() {
val isBatterySaverOn =
withContext(lifecycleScope.coroutineContext) {
settingsRepo.getAll().firstOrNull()?.isBatterySaverEnabled ?: false
}
wakeLock = wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run { (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
acquire() if (isBatterySaverOn) {
Timber.d("Initiating wakelock with timeout")
acquire(Constants.WATCHER_SERVICE_WAKE_LOCK_TIMEOUT)
} else {
Timber.d("Initiating wakelock with zero timeout")
acquire()
}
} }
} }
} }
private fun cancelWatcherJob() { private fun cancelWatcherJob() {
if(this::watcherJob.isInitialized) { if (this::watcherJob.isInitialized) {
watcherJob.cancel() watcherJob.cancel()
} }
} }
private fun startWatcherJob() { private fun startWatcherJob() {
watcherJob = CoroutineScope(Dispatchers.IO).launch { watcherJob =
val settings = settingsRepo.getAll(); lifecycleScope.launch(Dispatchers.IO) {
if(settings.isNotEmpty()) { val settings = settingsRepo.getAll()
setting = settings[0] if (settings.isNotEmpty()) {
} setting = settings[0]
launch { }
watchForWifiConnectivityChanges()
}
if(setting.isTunnelOnMobileDataEnabled) {
launch { launch {
watchForMobileDataConnectivityChanges() watchForWifiConnectivityChanges()
}
if (setting.isTunnelOnMobileDataEnabled) {
launch {
watchForMobileDataConnectivityChanges()
}
}
if (setting.isTunnelOnEthernetEnabled) {
launch {
watchForEthernetConnectivityChanges()
}
}
launch {
manageVpn()
} }
} }
if(setting.isTunnelOnEthernetEnabled) {
launch {
watchForEthernetConnectivityChanges()
}
}
launch {
manageVpn()
}
}
} }
private suspend fun watchForMobileDataConnectivityChanges() { private suspend fun watchForMobileDataConnectivityChanges() {
mobileDataService.networkStatus.collect { mobileDataService.networkStatus.collect {
when(it) { when (it) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.d("Gained Mobile data connection") Timber.d("Gained Mobile data connection")
isMobileDataConnected = true isMobileDataConnected = true
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
isMobileDataConnected = true isMobileDataConnected = true
Timber.d("Mobile data capabilities changed") Timber.d("Mobile data capabilities changed")
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isMobileDataConnected = false isMobileDataConnected = false
Timber.d("Lost mobile data connection") Timber.d("Lost mobile data connection")
@@ -184,10 +225,12 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
Timber.d("Gained Ethernet connection") Timber.d("Gained Ethernet connection")
isEthernetConnected = true isEthernetConnected = true
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Ethernet capabilities changed") Timber.d("Ethernet capabilities changed")
isEthernetConnected = true isEthernetConnected = true
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isEthernetConnected = false isEthernetConnected = false
Timber.d("Lost Ethernet connection") Timber.d("Lost Ethernet connection")
@@ -198,46 +241,93 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
private suspend fun watchForWifiConnectivityChanges() { private suspend fun watchForWifiConnectivityChanges() {
wifiService.networkStatus.collect { wifiService.networkStatus.collect {
when (it) { when (it) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.d("Gained Wi-Fi connection") Timber.d("Gained Wi-Fi connection")
isWifiConnected = true isWifiConnected = true
} }
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Wifi capabilities changed") is NetworkStatus.CapabilitiesChanged -> {
isWifiConnected = true Timber.d("Wifi capabilities changed")
currentNetworkSSID = wifiService.getNetworkName(it.networkCapabilities) ?: ""; isWifiConnected = true
} val ssid = wifiService.getNetworkName(it.networkCapabilities) ?: ""
is NetworkStatus.Unavailable -> { Timber.d("Detected SSID: $ssid")
isWifiConnected = false currentNetworkSSID = ssid
Timber.d("Lost Wi-Fi connection") }
}
is NetworkStatus.Unavailable -> {
isWifiConnected = false
Timber.d("Lost Wi-Fi connection")
} }
} }
} }
}
private suspend fun manageVpn() { private suspend fun manageVpn() {
while(true) { while (true) {
if(isEthernetConnected && setting.isTunnelOnEthernetEnabled && vpnService.getState() == Tunnel.State.DOWN) { when {
ServiceManager.startVpnService(this, tunnelConfig) (
} (
if(!isEthernetConnected && setting.isTunnelOnMobileDataEnabled && isEthernetConnected &&
!isWifiConnected && setting.isTunnelOnEthernetEnabled &&
isMobileDataConnected vpnService.getState() == Tunnel.State.DOWN
&& vpnService.getState() == Tunnel.State.DOWN) { )
ServiceManager.startVpnService(this, tunnelConfig) ) ->
} else if(!isEthernetConnected && !setting.isTunnelOnMobileDataEnabled && ServiceManager.startVpnService(this, tunnelConfig)
!isWifiConnected &&
vpnService.getState() == Tunnel.State.UP) { (
ServiceManager.stopVpnService(this) !isEthernetConnected &&
} else if(!isEthernetConnected && isWifiConnected && setting.isTunnelOnMobileDataEnabled &&
!setting.trustedNetworkSSIDs.contains(currentNetworkSSID) && !isWifiConnected &&
(vpnService.getState() != Tunnel.State.UP)) { isMobileDataConnected &&
ServiceManager.startVpnService(this, tunnelConfig) vpnService.getState() == Tunnel.State.DOWN
} else if(!isEthernetConnected && (isWifiConnected && ) ->
setting.trustedNetworkSSIDs.contains(currentNetworkSSID)) && ServiceManager.startVpnService(this, tunnelConfig)
(vpnService.getState() == Tunnel.State.UP)) {
ServiceManager.stopVpnService(this) (
!isEthernetConnected &&
!setting.isTunnelOnMobileDataEnabled &&
!isWifiConnected &&
vpnService.getState() == Tunnel.State.UP
) ->
ServiceManager.stopVpnService(this)
(
!isEthernetConnected && isWifiConnected &&
!setting.trustedNetworkSSIDs.contains(currentNetworkSSID) &&
setting.isTunnelOnWifiEnabled &&
(vpnService.getState() != Tunnel.State.UP)
) ->
ServiceManager.startVpnService(this, tunnelConfig)
(
!isEthernetConnected && (
isWifiConnected &&
setting.trustedNetworkSSIDs.contains(currentNetworkSSID)
) &&
(vpnService.getState() == Tunnel.State.UP)
) ->
ServiceManager.stopVpnService(this)
(
!isEthernetConnected && (
isWifiConnected &&
!setting.isTunnelOnWifiEnabled &&
(vpnService.getState() == Tunnel.State.UP)
)
) ->
ServiceManager.stopVpnService(this)
(
!isEthernetConnected && !isWifiConnected &&
!isMobileDataConnected &&
(vpnService.getState() == Tunnel.State.UP)
) ->
ServiceManager.stopVpnService(this)
else -> {
// Do nothing
}
} }
delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL) delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL)
} }
@@ -3,109 +3,120 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardTunnelService : ForegroundService() { class WireGuardTunnelService : ForegroundService() {
private val foregroundId = 123
private val foregroundId = 123;
@Inject @Inject
lateinit var vpnService : VpnService lateinit var vpnService: VpnService
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepo: SettingsDoa
@Inject @Inject
lateinit var notificationService : NotificationService lateinit var notificationService: NotificationService
private lateinit var job : Job private lateinit var job: Job
private var tunnelName : String = "" private var tunnelName: String = ""
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
CoroutineScope(Dispatchers.Main).launch { lifecycleScope.launch(Dispatchers.Main) {
launchVpnStartingNotification() launchVpnStartingNotification()
} }
} }
override fun startService(extras : Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
// TODO fix grapheneOS calls always-on on install
launchVpnStartingNotification() launchVpnStartingNotification()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
cancelJob() cancelJob()
job = CoroutineScope(Dispatchers.IO).launch { job =
if(tunnelConfigString != null) { lifecycleScope.launch(Dispatchers.IO) {
try { launch {
val tunnelConfig = TunnelConfig.from(tunnelConfigString) if (tunnelConfigString != null) {
tunnelName = tunnelConfig.name try {
vpnService.startTunnel(tunnelConfig) val tunnelConfig = TunnelConfig.from(tunnelConfigString)
} catch (e : Exception) { tunnelName = tunnelConfig.name
Timber.e("Problem starting tunnel: ${e.message}") vpnService.startTunnel(tunnelConfig)
stopService(extras) } catch (e: Exception) {
} Timber.e("Problem starting tunnel: ${e.message}")
} else { stopService(extras)
Timber.d("Tunnel config null, starting default tunnel") }
val settings = settingsRepo.getAll(); } else {
if(settings.isNotEmpty()) { Timber.d("Tunnel config null, starting default tunnel")
val setting = settings[0] val settings = settingsRepo.getAll()
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) { if (settings.isNotEmpty()) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!) val setting = settings[0]
tunnelName = tunnelConfig.name if (setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
vpnService.startTunnel(tunnelConfig) val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
} tunnelName = tunnelConfig.name
} vpnService.startTunnel(tunnelConfig)
} }
}
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) { launch {
launchVpnConnectedNotification() var didShowConnected = false
didShowConnected = true var didShowFailedHandshakeNotification = false
} vpnService.handshakeStatus.collect {
} when (it) {
HandshakeStatus.UNHEALTHY -> { HandshakeStatus.NOT_STARTED -> {
if(!didShowFailedHandshakeNotification) { }
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message)) HandshakeStatus.NEVER_CONNECTED -> {
didShowFailedHandshakeNotification = true if (!didShowFailedHandshakeNotification) {
didShowConnected = false launchVpnConnectionFailedNotification(
getString(R.string.initial_connection_failure_message)
)
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
HandshakeStatus.HEALTHY -> {
if (!didShowConnected) {
launchVpnConnectedNotification()
didShowConnected = true
}
}
HandshakeStatus.STALE -> {}
HandshakeStatus.UNHEALTHY -> {
if (!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(
getString(R.string.lost_connection_failure_message)
)
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
} }
} }
} }
} }
}
} }
override fun stopService(extras : Bundle?) { override fun stopService(extras: Bundle?) {
super.stopService(extras) super.stopService(extras)
CoroutineScope(Dispatchers.IO).launch { lifecycleScope.launch(Dispatchers.IO) {
vpnService.stopTunnel() vpnService.stopTunnel()
} }
cancelJob() cancelJob()
@@ -113,46 +124,72 @@ class WireGuardTunnelService : ForegroundService() {
} }
private fun launchVpnConnectedNotification() { private fun launchVpnConnectedNotification() {
val notification = notificationService.createNotification( val notification =
channelId = getString(R.string.vpn_channel_id), notificationService.createNotification(
channelName = getString(R.string.vpn_channel_name), channelId = getString(R.string.vpn_channel_id),
title = getString(R.string.tunnel_start_title), channelName = getString(R.string.vpn_channel_name),
onGoing = false, title = getString(R.string.tunnel_start_title),
showTimestamp = true, onGoing = false,
description = "${getString(R.string.tunnel_start_text)} $tunnelName" vibration = false,
showTimestamp = true,
description = "${getString(R.string.tunnel_start_text)} $tunnelName"
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
) )
super.startForeground(foregroundId, notification)
} }
private fun launchVpnStartingNotification() { private fun launchVpnStartingNotification() {
val notification = notificationService.createNotification( val notification =
channelId = getString(R.string.vpn_channel_id), notificationService.createNotification(
channelName = getString(R.string.vpn_channel_name), channelId = getString(R.string.vpn_channel_id),
title = getString(R.string.vpn_starting), channelName = getString(R.string.vpn_channel_name),
onGoing = false, title = getString(R.string.vpn_starting),
showTimestamp = true, onGoing = false,
description = getString(R.string.attempt_connection) vibration = false,
showTimestamp = true,
description = getString(R.string.attempt_connection)
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
) )
super.startForeground(foregroundId, notification)
} }
private fun launchVpnConnectionFailedNotification(message : String) { private fun launchVpnConnectionFailedNotification(message: String) {
val notification = notificationService.createNotification( val notification =
channelId = getString(R.string.vpn_channel_id), notificationService.createNotification(
channelName = getString(R.string.vpn_channel_name), channelId = getString(R.string.vpn_channel_id),
action = PendingIntent.getBroadcast(this,0,Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE), channelName = getString(R.string.vpn_channel_name),
actionText = getString(R.string.restart), action =
title = getString(R.string.vpn_connection_failed), PendingIntent.getBroadcast(
onGoing = false, this,
showTimestamp = true, 0,
description = message Intent(this, NotificationActionReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE
),
actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed),
onGoing = false,
vibration = true,
showTimestamp = true,
description = message
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
) )
super.startForeground(foregroundId, notification)
} }
private fun cancelJob() { private fun cancelJob() {
if(this::job.isInitialized) { if (this::job.isInitialized) {
job.cancel() job.cancel()
} }
} }
@@ -14,69 +14,82 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
abstract class BaseNetworkService<T : BaseNetworkService<T>>(
abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Context, networkCapability : Int) : NetworkService<T> { val context: Context,
networkCapability: Int
) : NetworkService<T> {
private val connectivityManager = private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val wifiManager = private val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
override val networkStatus = callbackFlow { override val networkStatus =
val networkStatusCallback = when (Build.VERSION.SDK_INT) { callbackFlow {
in Build.VERSION_CODES.S..Int.MAX_VALUE -> { val networkStatusCallback =
object : ConnectivityManager.NetworkCallback( when (Build.VERSION.SDK_INT) {
FLAG_INCLUDE_LOCATION_INFO in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
) { object : ConnectivityManager.NetworkCallback(
override fun onAvailable(network: Network) { FLAG_INCLUDE_LOCATION_INFO
trySend(NetworkStatus.Available(network)) ) {
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
)
)
}
}
} }
override fun onLost(network: Network) { else -> {
trySend(NetworkStatus.Unavailable(network)) object : ConnectivityManager.NetworkCallback() {
} override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network))
}
override fun onCapabilitiesChanged( override fun onLost(network: Network) {
network: Network, trySend(NetworkStatus.Unavailable(network))
networkCapabilities: NetworkCapabilities }
) {
trySend(NetworkStatus.CapabilitiesChanged(network, networkCapabilities)) 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)
else -> { awaitClose {
object : ConnectivityManager.NetworkCallback() { connectivityManager.unregisterNetworkCallback(networkStatusCallback)
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? { override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid: String? = getWifiNameFromCapabilities(networkCapabilities) var ssid: String? = getWifiNameFromCapabilities(networkCapabilities)
@@ -89,7 +102,6 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
return ssid?.trim('"') return ssid?.trim('"')
} }
companion object { companion object {
private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? { private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -105,13 +117,20 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
} }
inline fun <Result> Flow<NetworkStatus>.map( inline fun <Result> Flow<NetworkStatus>.map(
crossinline onUnavailable: suspend (network : Network) -> Result, crossinline onUnavailable: suspend (network: Network) -> Result,
crossinline onAvailable: suspend (network : Network) -> Result, crossinline onAvailable: suspend (network: Network) -> Result,
crossinline onCapabilitiesChanged: suspend (network : Network, networkCapabilities : NetworkCapabilities) -> Result, crossinline onCapabilitiesChanged: suspend (
): Flow<Result> = map { status -> network: Network,
when (status) { networkCapabilities: NetworkCapabilities
is NetworkStatus.Unavailable -> onUnavailable(status.network) ) -> Result
is NetworkStatus.Available -> onAvailable(status.network) ): Flow<Result> =
is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(status.network, status.networkCapabilities) map { status ->
when (status) {
is NetworkStatus.Unavailable -> onUnavailable(status.network)
is NetworkStatus.Available -> onAvailable(status.network)
is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(
status.network,
status.networkCapabilities
)
}
} }
}
@@ -5,6 +5,9 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class EthernetService @Inject constructor(@ApplicationContext context: Context) : class EthernetService
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET) { @Inject
} constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET)
@@ -5,6 +5,9 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class MobileDataService @Inject constructor(@ApplicationContext context: Context) : class MobileDataService
BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR) { @Inject
} constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR)
@@ -4,7 +4,7 @@ import android.net.NetworkCapabilities
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface NetworkService<T> { interface NetworkService<T> {
fun getNetworkName(networkCapabilities: NetworkCapabilities) : String? fun getNetworkName(networkCapabilities: NetworkCapabilities): String?
val networkStatus : Flow<NetworkStatus>
val networkStatus: Flow<NetworkStatus>
} }
@@ -4,7 +4,10 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
sealed class NetworkStatus { sealed class NetworkStatus {
class Available(val network : Network) : NetworkStatus() class Available(val network: Network) : NetworkStatus()
class Unavailable(val network : Network) : NetworkStatus()
class CapabilitiesChanged(val network : Network, val networkCapabilities : NetworkCapabilities) : NetworkStatus() class Unavailable(val network: Network) : NetworkStatus()
class CapabilitiesChanged(val network: Network, val networkCapabilities: NetworkCapabilities) :
NetworkStatus()
} }
@@ -5,6 +5,9 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WifiService @Inject constructor(@ApplicationContext context: Context) : class WifiService
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI) { @Inject
} constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI)
@@ -12,9 +12,9 @@ interface NotificationService {
action: PendingIntent? = null, action: PendingIntent? = null,
actionText: String? = null, actionText: String? = null,
description: String, description: String,
showTimestamp : Boolean = false, showTimestamp: Boolean = false,
importance: Int = NotificationManager.IMPORTANCE_HIGH, importance: Int = NotificationManager.IMPORTANCE_HIGH,
vibration: Boolean = true, vibration: Boolean = false,
onGoing: Boolean = true, onGoing: Boolean = true,
lights: Boolean = true lights: Boolean = true
): Notification ): Notification
@@ -12,9 +12,13 @@ import com.zaneschepke.wireguardautotunnel.ui.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) : NotificationService { class WireGuardNotification
@Inject
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; constructor(
@ApplicationContext private val context: Context
) : NotificationService {
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
override fun createNotification( override fun createNotification(
channelId: String, channelId: String,
@@ -29,18 +33,19 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
onGoing: Boolean, onGoing: Boolean,
lights: Boolean lights: Boolean
): Notification { ): Notification {
val channel = NotificationChannel( val channel =
channelId, NotificationChannel(
channelName, channelId,
importance channelName,
).let { importance
it.description = title ).let {
it.enableLights(lights) it.description = title
it.lightColor = Color.RED it.enableLights(lights)
it.enableVibration(vibration) it.lightColor = Color.RED
it.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400) it.enableVibration(vibration)
it it.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400)
} it
}
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
val pendingIntent: PendingIntent = val pendingIntent: PendingIntent =
Intent(context, MainActivity::class.java).let { notificationIntent -> Intent(context, MainActivity::class.java).let { notificationIntent ->
@@ -58,14 +63,15 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
channelId channelId
) )
return builder.let { return builder.let {
if(action != null && actionText != null) { if (action != null && actionText != null) {
//TODO find a not deprecated way to do this // TODO find a not deprecated way to do this
it.addAction( it.addAction(
Notification.Action.Builder(0, actionText, action) Notification.Action.Builder(0, actionText, action)
.build()) .build()
it.setAutoCancel(true) )
it.setAutoCancel(true)
} }
it.setContentTitle(title) it.setContentTitle(title)
.setContentText(description) .setContentText(description)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setOngoing(onGoing) .setOngoing(onGoing)
@@ -1,52 +1,90 @@
package com.zaneschepke.wireguardautotunnel.service.shortcut package com.zaneschepke.wireguardautotunnel.service.shortcut
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.activity.ComponentActivity
import com.zaneschepke.wireguardautotunnel.R import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.Action import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class ShortcutsActivity : AppCompatActivity() { class ShortcutsActivity : ComponentActivity() {
@Inject
lateinit var settingsRepo: SettingsDoa
@Inject @Inject
lateinit var settingsRepo : SettingsDoa lateinit var tunnelConfigRepo: TunnelConfigDao
private val scope = CoroutineScope(Dispatchers.Main); private fun attemptWatcherServiceToggle(tunnelConfig: String) {
lifecycleScope.launch(Dispatchers.Main) {
private fun attemptWatcherServiceToggle(tunnelConfig : String) { val settings = getSettings()
scope.launch { if (settings.isAutoTunnelEnabled) {
val settings = settingsRepo.getAll() ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig)
if (settings.isNotEmpty()) {
val setting = settings.first()
if(setting.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig)
}
} }
} }
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if(intent.getStringExtra(ShortcutsManager.CLASS_NAME_EXTRA_KEY) if (intent.getStringExtra(CLASS_NAME_EXTRA_KEY)
.equals(WireGuardTunnelService::class.java.name)) { .equals(WireGuardTunnelService::class.java.simpleName)
) {
intent.getStringExtra(getString(R.string.tunnel_extras_key))?.let { lifecycleScope.launch(Dispatchers.Main) {
attemptWatcherServiceToggle(it) val settings = getSettings()
} if (settings.isShortcutsEnabled) {
when(intent.action){ try {
Action.STOP.name -> ServiceManager.stopVpnService(this) val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
Action.START.name -> intent.getStringExtra(getString(R.string.tunnel_extras_key)) val tunnelConfig =
?.let { ServiceManager.startVpnService(this, it) } if (tunnelName != null) {
tunnelConfigRepo.getAll().firstOrNull { it.name == tunnelName }
} else {
if (settings.defaultTunnel == null) {
tunnelConfigRepo.getAll().first()
} else {
TunnelConfig.from(settings.defaultTunnel!!)
}
}
tunnelConfig ?: return@launch
attemptWatcherServiceToggle(tunnelConfig.toString())
when (intent.action) {
Action.STOP.name -> ServiceManager.stopVpnService(
this@ShortcutsActivity
)
Action.START.name -> ServiceManager.startVpnService(
this@ShortcutsActivity,
tunnelConfig.toString()
)
}
} catch (e: Exception) {
Timber.e(e.message)
}
}
} }
} }
finish() finish()
} }
private suspend fun getSettings(): Settings {
val settings = settingsRepo.getAll()
return if (settings.isNotEmpty()) {
settings.first()
} else {
throw WgTunnelException("Settings empty")
}
}
companion object {
const val TUNNEL_NAME_EXTRA_KEY = "tunnelName"
const val CLASS_NAME_EXTRA_KEY = "className"
}
} }
@@ -1,75 +0,0 @@
package com.zaneschepke.wireguardautotunnel.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 com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.repository.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?) {
if(tunnelConfig != null) {
ShortcutManagerCompat.removeDynamicShortcuts(context, listOf(tunnelConfig.id.toString() + APPEND_ON,
tunnelConfig.id.toString() + APPEND_OFF ))
}
}
private fun createTunnelOnIntent(context: Context, extras : Map<String,String>) : Intent {
return Intent(context, ShortcutsActivity::class.java).also {
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<String,String>) : 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)
}
}
}
}
@@ -11,65 +11,65 @@ import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TunnelControlTile : TileService() { class TunnelControlTile : TileService() {
@Inject
lateinit var settingsRepo: SettingsDoa
@Inject @Inject
lateinit var settingsRepo : SettingsDoa lateinit var configRepo: TunnelConfigDao
@Inject @Inject
lateinit var configRepo : TunnelConfigDao lateinit var vpnService: VpnService
@Inject private val scope = CoroutineScope(Dispatchers.Main)
lateinit var vpnService : VpnService
private val scope = CoroutineScope(Dispatchers.Main); private lateinit var job: Job
private lateinit var job : Job
override fun onStartListening() { override fun onStartListening() {
job = scope.launch { job =
updateTileState() scope.launch {
} updateTileState()
}
super.onStartListening() super.onStartListening()
} }
override fun onTileAdded() {
super.onTileAdded()
qsTile.contentDescription = this.resources.getString(R.string.toggle_vpn)
scope.launch {
updateTileState();
}
}
override fun onTileRemoved() { override fun onTileRemoved() {
super.onTileRemoved() super.onTileRemoved()
cancelJob() cancelJob()
} }
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
override fun onClick() { override fun onClick() {
super.onClick() super.onClick()
unlockAndRun { unlockAndRun {
scope.launch { scope.launch {
try { try {
val tunnel = determineTileTunnel(); val tunnel = determineTileTunnel()
if(tunnel != null) { if (tunnel != null) {
attemptWatcherServiceToggle(tunnel.toString()) attemptWatcherServiceToggle(tunnel.toString())
if(vpnService.getState() == Tunnel.State.UP) { if (vpnService.getState() == Tunnel.State.UP) {
ServiceManager.stopVpnService(this@TunnelControlTile) ServiceManager.stopVpnService(this@TunnelControlTile)
} else { } else {
ServiceManager.startVpnServiceForeground(this@TunnelControlTile, tunnel.toString()) ServiceManager.startVpnServiceForeground(
this@TunnelControlTile,
tunnel.toString()
)
} }
} }
} catch (e : Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
} finally { } finally {
cancel() cancel()
@@ -78,34 +78,38 @@ class TunnelControlTile : TileService() {
} }
} }
private suspend fun determineTileTunnel() : TunnelConfig? { private suspend fun determineTileTunnel(): TunnelConfig? {
var tunnelConfig : TunnelConfig? = null; var tunnelConfig: TunnelConfig? = null
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) { if (settings.isNotEmpty()) {
val setting = settings.first() val setting = settings.first()
tunnelConfig = if (setting.defaultTunnel != null) { tunnelConfig =
TunnelConfig.from(setting.defaultTunnel!!); if (setting.defaultTunnel != null) {
} else { TunnelConfig.from(setting.defaultTunnel!!)
val configs = configRepo.getAll();
val config = if(configs.isNotEmpty()) {
configs.first();
} else { } else {
null val configs = configRepo.getAll()
val config =
if (configs.isNotEmpty()) {
configs.first()
} else {
null
}
config
} }
config
}
} }
return tunnelConfig; return tunnelConfig
} }
private fun attemptWatcherServiceToggle(tunnelConfig: String) {
private fun attemptWatcherServiceToggle(tunnelConfig : String) {
scope.launch { scope.launch {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) { if (settings.isNotEmpty()) {
val setting = settings.first() val setting = settings.first()
if(setting.isAutoTunnelEnabled) { if (setting.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(this@TunnelControlTile, tunnelConfig) ServiceManager.toggleWatcherServiceForeground(
this@TunnelControlTile,
tunnelConfig
)
} }
} }
} }
@@ -113,35 +117,43 @@ class TunnelControlTile : TileService() {
private suspend fun updateTileState() { private suspend fun updateTileState() {
vpnService.state.collect { vpnService.state.collect {
when(it) { try {
Tunnel.State.UP -> { when (it) {
qsTile.state = Tile.STATE_ACTIVE Tunnel.State.UP -> {
} qsTile.state = Tile.STATE_ACTIVE
Tunnel.State.DOWN -> { }
qsTile.state = Tile.STATE_INACTIVE;
} Tunnel.State.DOWN -> {
else -> { qsTile.state = Tile.STATE_INACTIVE
qsTile.state = Tile.STATE_UNAVAILABLE }
else -> {
qsTile.state = Tile.STATE_UNAVAILABLE
}
} }
val config = determineTileTunnel()
setTileDescription(
config?.name ?: this.resources.getString(R.string.no_tunnel_available)
)
qsTile.updateTile()
} catch (e: Exception) {
Timber.e("Unable to update tile state")
} }
val config = determineTileTunnel();
setTileDescription(config?.name ?: this.resources.getString(R.string.no_tunnel_available))
qsTile.updateTile()
} }
} }
private fun setTileDescription(description : String) { private fun setTileDescription(description: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
qsTile.subtitle = description qsTile.subtitle = description
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = description; qsTile.stateDescription = description
} }
} }
private fun cancelJob() { private fun cancelJob() {
if(this::job.isInitialized) { if (this::job.isInitialized) {
job.cancel(); job.cancel()
} }
} }
} }
@@ -2,13 +2,16 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
enum class HandshakeStatus { enum class HandshakeStatus {
HEALTHY, HEALTHY,
STALE,
UNHEALTHY, UNHEALTHY,
NEVER_CONNECTED, NEVER_CONNECTED,
NOT_STARTED; NOT_STARTED
;
companion object { companion object {
private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 120 private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 180
const val UNHEALTHY_TIME_LIMIT_SEC = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + 60 const val STATUS_CHANGE_TIME_BUFFER = 30
const val STALE_TIME_LIMIT_SEC = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + STATUS_CHANGE_TIME_BUFFER
const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30 const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30
} }
} }
@@ -7,12 +7,15 @@ import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
interface VpnService : Tunnel { interface VpnService : Tunnel {
suspend fun startTunnel(tunnelConfig : TunnelConfig) : Tunnel.State suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State
suspend fun stopTunnel() suspend fun stopTunnel()
val state : SharedFlow<Tunnel.State>
val tunnelName : SharedFlow<String> val state: SharedFlow<Tunnel.State>
val statistics : SharedFlow<Statistics> val tunnelName: SharedFlow<String>
val lastHandshake : SharedFlow<Map<Key,Long>> val statistics: SharedFlow<Statistics>
val handshakeStatus : SharedFlow<HandshakeStatus> val lastHandshake: SharedFlow<Map<Key, Long>>
fun getState() : Tunnel.State val handshakeStatus: SharedFlow<HandshakeStatus>
fun getState(): Tunnel.State
} }
@@ -4,10 +4,15 @@ import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Statistics import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import com.wireguard.crypto.Key import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -20,21 +25,28 @@ import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
class WireGuardTunnel
class WireGuardTunnel @Inject constructor(private val backend : Backend, @Inject
constructor(
@Userspace private val userspaceBackend: Backend,
@Kernel private val kernelBackend: Backend,
private val settingsRepo: SettingsDoa
) : VpnService { ) : VpnService {
private val _tunnelName = MutableStateFlow("") private val _tunnelName = MutableStateFlow("")
override val tunnelName get() = _tunnelName.asStateFlow() override val tunnelName get() = _tunnelName.asStateFlow()
private val _state = MutableSharedFlow<Tunnel.State>( private val _state =
onBufferOverflow = BufferOverflow.DROP_OLDEST, MutableSharedFlow<Tunnel.State>(
replay = 1) onBufferOverflow = BufferOverflow.DROP_OLDEST,
replay = 1
)
private val _handshakeStatus = MutableSharedFlow<HandshakeStatus>(replay = 1, private val _handshakeStatus =
onBufferOverflow = BufferOverflow.DROP_OLDEST) MutableSharedFlow<HandshakeStatus>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val state get() = _state.asSharedFlow() override val state get() = _state.asSharedFlow()
private val _statistics = MutableSharedFlow<Statistics>(replay = 1) private val _statistics = MutableSharedFlow<Statistics>(replay = 1)
@@ -46,37 +58,73 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
override val handshakeStatus: SharedFlow<HandshakeStatus> override val handshakeStatus: SharedFlow<HandshakeStatus>
get() = _handshakeStatus.asSharedFlow() get() = _handshakeStatus.asSharedFlow()
private lateinit var statsJob : Job private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var statsJob: Job
override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{ private var config: Config? = null
return try {
if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) { private var backend: Backend = userspaceBackend
stopTunnel()
private var backendIsUserspace = true
init {
scope.launch {
settingsRepo.getAllFlow().collect {
val settings = it.first()
if (settings.isKernelEnabled && backendIsUserspace) {
Timber.d("Setting kernel backend")
backend = kernelBackend
backendIsUserspace = false
} else if (!settings.isKernelEnabled && !backendIsUserspace) {
Timber.d("Setting userspace backend")
backend = userspaceBackend
backendIsUserspace = true
}
} }
_tunnelName.emit(tunnelConfig.name) }
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) }
val state = backend.setState(
this, Tunnel.State.UP, config) override suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State {
return try {
stopTunnelOnConfigChange(tunnelConfig)
emitTunnelName(tunnelConfig.name)
config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val state =
backend.setState(
this,
Tunnel.State.UP,
config
)
_state.emit(state) _state.emit(state)
state; state
} catch (e : Exception) { } catch (e: Exception) {
Timber.e("Failed to start tunnel with error: ${e.message}") Timber.e("Failed to start tunnel with error: ${e.message}")
Tunnel.State.DOWN Tunnel.State.DOWN
} }
} }
private suspend fun emitTunnelName(name: String) {
_tunnelName.emit(name)
}
private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) {
if (getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) {
stopTunnel()
}
}
override fun getName(): String { override fun getName(): String {
return _tunnelName.value return _tunnelName.value
} }
override suspend fun stopTunnel() { override suspend fun stopTunnel() {
try { try {
if(getState() == Tunnel.State.UP) { if (getState() == Tunnel.State.UP) {
val state = backend.setState(this, Tunnel.State.DOWN, null) val state = backend.setState(this, Tunnel.State.DOWN, null)
_state.emit(state) _state.emit(state)
} }
} catch (e : BackendException) { } catch (e: BackendException) {
Timber.e("Failed to stop tunnel with error: ${e.message}") Timber.e("Failed to stop tunnel with error: ${e.message}")
} }
} }
@@ -85,43 +133,51 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend,
return backend.getState(this) return backend.getState(this)
} }
override fun onStateChange(state : Tunnel.State) { override fun onStateChange(state: Tunnel.State) {
val tunnel = this; val tunnel = this
_state.tryEmit(state) _state.tryEmit(state)
if(state == Tunnel.State.UP) { if (state == Tunnel.State.UP) {
statsJob = CoroutineScope(Dispatchers.IO).launch { statsJob =
val handshakeMap = HashMap<Key, Long>() scope.launch {
var neverHadHandshakeCounter = 0 val handshakeMap = HashMap<Key, Long>()
while (true) { var neverHadHandshakeCounter = 0
val statistics = backend.getStatistics(tunnel) while (true) {
_statistics.emit(statistics) val statistics = backend.getStatistics(tunnel)
statistics.peers().forEach { _statistics.emit(statistics)
val handshakeEpoch = statistics.peer(it)?.latestHandshakeEpochMillis ?: 0L statistics.peers().forEach { key ->
handshakeMap[it] = handshakeEpoch val handshakeEpoch =
if(handshakeEpoch == 0L) { statistics.peer(key)?.latestHandshakeEpochMillis ?: 0L
if(neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) { handshakeMap[key] = handshakeEpoch
_handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED) if (handshakeEpoch == 0L) {
} else { if (neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.NOT_STARTED) _handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED)
} else {
_handshakeStatus.emit(HandshakeStatus.NOT_STARTED)
}
if (neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
neverHadHandshakeCounter += (1 * Constants.VPN_STATISTIC_CHECK_INTERVAL / 1000).toInt()
}
return@forEach
} }
if(neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) { // TODO one day make each peer have their own dedicated status
neverHadHandshakeCounter += 10 val lastHandshake = NumberUtils.getSecondsBetweenTimestampAndNow(
handshakeEpoch
)
if (lastHandshake != null) {
if (lastHandshake >= HandshakeStatus.STALE_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.STALE)
} else {
_handshakeStatus.emit(HandshakeStatus.HEALTHY)
}
} }
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)
} }
_lastHandshake.emit(handshakeMap)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
} }
}
} }
if(state == Tunnel.State.DOWN) { if (state == Tunnel.State.DOWN) {
if(this::statsJob.isInitialized) { if (this::statsJob.isInitialized) {
statsJob.cancel() statsJob.cancel()
} }
_handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED) _handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED)
@@ -0,0 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.lifecycle.ViewModel
import javax.inject.Inject
class ActivityViewModel @Inject constructor() : ViewModel() {
// TODO move shared logic to shared viewmodel
}
@@ -11,13 +11,18 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -26,9 +31,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import com.google.accompanist.navigation.animation.AnimatedNavHost import androidx.compose.ui.unit.dp
import com.google.accompanist.navigation.animation.composable import androidx.lifecycle.lifecycleScope
import com.google.accompanist.navigation.animation.rememberAnimatedNavController import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
@@ -37,28 +44,29 @@ import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.detail.DetailScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.lang.IllegalStateException
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@OptIn(
@OptIn(ExperimentalAnimationApi::class,
ExperimentalPermissionsApi::class ExperimentalPermissionsApi::class
) )
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
val navController = rememberAnimatedNavController() // TODO move shared logic to shared viewmodel
// val sharedViewModel = hiltViewModel<ActivityViewModel>()
val navController = rememberNavController()
val focusRequester = remember { FocusRequester() } val focusRequester = remember { FocusRequester() }
WireguardAutoTunnelTheme { WireguardAutoTunnelTheme {
@@ -76,46 +84,86 @@ class MainActivity : AppCompatActivity() {
} }
var vpnIntent by remember { mutableStateOf(GoBackend.VpnService.prepare(this)) } var vpnIntent by remember { mutableStateOf(GoBackend.VpnService.prepare(this)) }
val vpnActivityResultState = rememberLauncherForActivityResult( val vpnActivityResultState =
ActivityResultContracts.StartActivityForResult(), rememberLauncherForActivityResult(
onResult = { ActivityResultContracts.StartActivityForResult(),
val accepted = (it.resultCode == RESULT_OK) onResult = {
if (accepted) { val accepted = (it.resultCode == RESULT_OK)
vpnIntent = null if (accepted) {
vpnIntent = null
}
} }
}) )
LaunchedEffect(vpnIntent) { LaunchedEffect(vpnIntent) {
if (vpnIntent != null) { if (vpnIntent != null) {
vpnActivityResultState.launch(vpnIntent) vpnActivityResultState.launch(vpnIntent)
} else requestNotificationPermission() } else {
requestNotificationPermission()
}
} }
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState)}, fun showSnackBarMessage(message: String) {
modifier = Modifier.onKeyEvent { lifecycleScope.launch(Dispatchers.Main) {
val result =
snackbarHostState.showSnackbar(
message = message,
actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short
)
when (result) {
SnackbarResult.ActionPerformed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
}
}
}
Scaffold(
snackbarHost = {
SnackbarHost(snackbarHostState) { snackbarData: SnackbarData ->
CustomSnackBar(
snackbarData.visuals.message,
isRtl = false,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp
)
)
}
},
modifier =
Modifier.onKeyEvent {
if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) {
when (it.nativeKeyEvent.keyCode) { when (it.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> { KeyEvent.KEYCODE_DPAD_UP -> {
try { try {
focusRequester.requestFocus() focusRequester.requestFocus()
} catch(e : IllegalStateException) { } catch (e: IllegalStateException) {
Timber.e("No D-Pad focus request modifier added to element on screen") Timber.e(
"No D-Pad focus request modifier added to element on screen"
)
} }
false false
} else -> { }
false
else -> {
false
} }
} }
} else { } else {
false false
} }
}, },
bottomBar = if (vpnIntent == null && notificationPermissionState.status.isGranted) { bottomBar =
if (vpnIntent == null && notificationPermissionState.status.isGranted) {
{ BottomNavBar(navController, Routes.navItems) } { BottomNavBar(navController, Routes.navItems) }
} else { } else {
{} {}
}, }
) ) { padding ->
{ padding ->
if (vpnIntent != null) { if (vpnIntent != null) {
PermissionRequestFailedScreen( PermissionRequestFailedScreen(
padding = padding, padding = padding,
@@ -132,7 +180,11 @@ class MainActivity : AppCompatActivity() {
val intentSettings = val intentSettings =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data = intentSettings.data =
Uri.fromParts(Constants.URI_PACKAGE_SCHEME, this.packageName, null) Uri.fromParts(
Constants.URI_PACKAGE_SCHEME,
this.packageName,
null
)
startActivity(intentSettings) startActivity(intentSettings)
}, },
message = getString(R.string.notification_permission_required), message = getString(R.string.notification_permission_required),
@@ -140,21 +192,38 @@ class MainActivity : AppCompatActivity() {
) )
return@Scaffold 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 = { -Constants.SLIDE_IN_TRANSITION_OFFSET },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
)
else -> { NavHost(navController, startDestination = Routes.Main.name) {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) composable(
Routes.Main.name,
enterTransition = {
when (initialState.destination.route) {
Routes.Settings.name, Routes.Support.name ->
slideInHorizontally(
initialOffsetX = {
-Constants.SLIDE_IN_TRANSITION_OFFSET
},
animationSpec = tween(
Constants.SLIDE_IN_ANIMATION_DURATION
)
)
else -> {
fadeIn(
animationSpec = tween(
Constants.FADE_IN_ANIMATION_DURATION
)
)
}
} }
},
exitTransition = {
ExitTransition.None
} }
}) { ) {
MainScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController) MainScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message)
}, navController = navController)
} }
composable(Routes.Settings.name, enterTransition = { composable(Routes.Settings.name, enterTransition = {
when (initialState.destination.route) { when (initialState.destination.route) {
@@ -172,10 +241,16 @@ class MainActivity : AppCompatActivity() {
} }
else -> { else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(
animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)
)
} }
} }
}) { SettingsScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController, focusRequester = focusRequester) } }) {
SettingsScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message)
}, focusRequester = focusRequester)
}
composable(Routes.Support.name, enterTransition = { composable(Routes.Support.name, enterTransition = {
when (initialState.destination.route) { when (initialState.destination.route) {
Routes.Settings.name, Routes.Main.name -> Routes.Settings.name, Routes.Main.name ->
@@ -185,23 +260,25 @@ class MainActivity : AppCompatActivity() {
) )
else -> { else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(
animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)
)
} }
} }
}) { SupportScreen(padding = padding, focusRequester) } }) { SupportScreen(padding = padding, focusRequester = focusRequester) }
composable("${Routes.Config.name}/{id}", enterTransition = { composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}) { }) {
val id = it.arguments?.getString("id") val id = it.arguments?.getString("id")
if(!id.isNullOrBlank()) { if (!id.isNullOrBlank()) {
ConfigScreen(padding = padding, navController = navController, id = id, focusRequester = focusRequester)} ConfigScreen(
} navController = navController,
composable("${Routes.Detail.name}/{id}", enterTransition = { id = id,
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) showSnackbarMessage = { message ->
}) { showSnackBarMessage(message)
val id = it.arguments?.getString("id") },
if(!id.isNullOrBlank()) { focusRequester = focusRequester
DetailScreen(padding = padding, id = id) )
} }
} }
} }
@@ -10,27 +10,27 @@ enum class Routes {
Main, Main,
Settings, Settings,
Support, Support,
Config, Config
Detail; ;
companion object { companion object {
val navItems = listOf( val navItems =
BottomNavItem( listOf(
name = "Tunnels", BottomNavItem(
route = Main.name, name = "Tunnels",
icon = Icons.Rounded.Home, route = Main.name,
), icon = Icons.Rounded.Home
BottomNavItem( ),
name = "Settings", BottomNavItem(
route = Settings.name, name = "Settings",
icon = Icons.Rounded.Settings, route = Settings.name,
), icon = Icons.Rounded.Settings
BottomNavItem( ),
name = "Support", BottomNavItem(
route = Support.name, name = "Support",
icon = Icons.Rounded.QuestionMark, route = Support.name,
icon = Icons.Rounded.QuestionMark
)
) )
)
} }
} }
@@ -1,9 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui
data class ViewState(
val showSnackbarMessage : Boolean = false,
val snackbarMessage : String = "",
val snackbarActionText : String = "",
val onSnackbarActionClick : () -> Unit = {},
val isLoading : Boolean = false
)
@@ -1,28 +1,40 @@
package com.zaneschepke.wireguardautotunnel.ui.common package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun ClickableIconButton(onIconClick : () -> Unit, text : String, icon : ImageVector, enabled : Boolean) { fun ClickableIconButton(
Button(onClick = {}, onIconClick: () -> Unit,
text: String,
icon: ImageVector,
enabled: Boolean
) {
TextButton(
onClick = {},
enabled = enabled enabled = enabled
) { ) {
Text(text) Text(text, Modifier.weight(1f, false))
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = "Delete", contentDescription = stringResource(R.string.delete),
modifier = Modifier.size(ButtonDefaults.IconSize).clickable { modifier =
if(enabled) { Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable {
if (enabled) {
onIconClick() onIconClick()
} }
} }
@@ -16,13 +16,21 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun PermissionRequestFailedScreen(padding : PaddingValues, onRequestAgain : () -> Unit, message : String, buttonText : String ) { fun PermissionRequestFailedScreen(
padding: PaddingValues,
onRequestAgain: () -> Unit,
message: String,
buttonText: String
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column(horizontalAlignment = Alignment.CenterHorizontally, Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding)) { .padding(padding)
) {
Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp)) Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp))
Button(onClick = { Button(onClick = {
scope.launch { scope.launch {
@@ -1,27 +1,42 @@
package com.zaneschepke.wireguardautotunnel.ui.common package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wireguard.android.backend.Statistics
import com.zaneschepke.wireguardautotunnel.toThreeDecimalPlaceString
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun RowListItem(leadingIcon : ImageVector? = null, leadingIconColor : Color = Color.Gray, text : String, onHold : () -> Unit, onClick: () -> Unit, rowButton : @Composable() () -> Unit ) { fun RowListItem(
icon: @Composable () -> Unit,
text: String,
onHold: () -> Unit,
onClick: () -> Unit,
rowButton: @Composable () -> Unit,
expanded: Boolean,
statistics: Statistics?
) {
Box( Box(
modifier = Modifier modifier =
Modifier
.animateContentSize()
.clip(RoundedCornerShape(30.dp))
.combinedClickable( .combinedClickable(
onClick = { onClick = {
onClick() onClick()
@@ -31,25 +46,52 @@ fun RowListItem(leadingIcon : ImageVector? = null, leadingIconColor : Color = Co
} }
) )
) { ) {
Row( Column {
modifier = Modifier Row(
.fillMaxWidth() modifier =
.padding(14.dp), Modifier
verticalAlignment = Alignment.CenterVertically, .fillMaxWidth()
horizontalArrangement = Arrangement.SpaceBetween .padding(horizontal = 14.dp, vertical = 5.dp),
) { verticalAlignment = Alignment.CenterVertically,
Row(verticalAlignment = Alignment.CenterVertically,) { horizontalArrangement = Arrangement.SpaceBetween
if(leadingIcon != null) { ) {
Icon( Row(
leadingIcon, "status", verticalAlignment = Alignment.CenterVertically,
tint = leadingIconColor, modifier = Modifier.fillMaxWidth(.60f)
modifier = Modifier.padding(end = 10.dp).size(15.dp) ) {
) icon()
Text(text)
}
rowButton()
}
if (expanded) {
statistics?.peers()?.forEach {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis
val peerTx = statistics.peer(it)!!.txBytes
val peerRx = statistics.peer(it)!!.rxBytes
val peerId = it.toBase64().subSequence(0, 3).toString() + "***"
val handshakeSec =
NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch)
val handshake =
if (handshakeSec == null) "never" else "$handshakeSec secs ago"
val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString()
val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString()
val fontSize = 9.sp
Text("peer: $peerId", fontSize = fontSize)
Text("handshake: $handshake", fontSize = fontSize)
Text("tx: $peerTxMB MB", fontSize = fontSize)
Text("rx: $peerRxMB MB", fontSize = fontSize)
}
} }
Text(text)
} }
rowButton()
} }
} }
} }
@@ -25,9 +25,7 @@ import androidx.compose.ui.text.input.KeyboardType
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun SearchBar( fun SearchBar(onQuery: (queryString: String) -> Unit) {
onQuery : (queryString : String) -> Unit
) {
// Immediately update and keep track of query from text field changes. // Immediately update and keep track of query from text field changes.
var query: String by rememberSaveable { mutableStateOf("") } var query: String by rememberSaveable { mutableStateOf("") }
var showClearIcon by rememberSaveable { mutableStateOf(false) } var showClearIcon by rememberSaveable { mutableStateOf(false) }
@@ -64,16 +62,18 @@ fun SearchBar(
} }
}, },
maxLines = 1, maxLines = 1,
colors = TextFieldDefaults.colors( colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent
), ),
placeholder = { Text(text = stringResource(R.string.hint_search_packages)) }, placeholder = { Text(text = stringResource(R.string.hint_search_packages)) },
textStyle = MaterialTheme.typography.bodySmall, textStyle = MaterialTheme.typography.bodySmall,
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background, shape = RectangleShape) .background(color = MaterialTheme.colorScheme.background, shape = RectangleShape)
) )
@@ -0,0 +1,40 @@
package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@Composable
fun ConfigurationTextBox(
value: String,
hint: String,
onValueChange: (String) -> Unit,
keyboardActions: KeyboardActions,
label: String,
modifier: Modifier
) {
OutlinedTextField(
modifier = modifier,
value = value,
singleLine = true,
onValueChange = {
onValueChange(it)
},
label = { Text(label) },
maxLines = 1,
placeholder = {
Text(hint)
},
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions = keyboardActions
)
}
@@ -0,0 +1,41 @@
package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
@Composable
fun ConfigurationToggle(
label: String,
enabled: Boolean,
checked: Boolean,
padding: Dp,
onCheckChanged: () -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(padding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(label)
Switch(
modifier = modifier,
enabled = enabled,
checked = checked,
onCheckedChange = {
onCheckChanged()
}
)
}
}
@@ -11,12 +11,14 @@ import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@Composable @Composable
fun BottomNavBar(navController : NavController, bottomNavItems : List<BottomNavItem>) { fun BottomNavBar(
navController: NavController,
bottomNavItems: List<BottomNavItem>
) {
val backStackEntry = navController.currentBackStackEntryAsState() val backStackEntry = navController.currentBackStackEntryAsState()
NavigationBar( NavigationBar(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background
) { ) {
bottomNavItems.forEach { item -> bottomNavItems.forEach { item ->
val selected = item.route == backStackEntry.value?.destination?.route val selected = item.route == backStackEntry.value?.destination?.route
@@ -27,13 +29,13 @@ fun BottomNavBar(navController : NavController, bottomNavItems : List<BottomNavI
label = { label = {
Text( Text(
text = item.name, text = item.name,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold
) )
}, },
icon = { icon = {
Icon( Icon(
imageVector = item.icon, imageVector = item.icon,
contentDescription = "${item.name} Icon", contentDescription = "${item.name} Icon"
) )
} }
) )
@@ -0,0 +1,97 @@
package com.zaneschepke.wireguardautotunnel.ui.common.prompt
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
@Composable
fun AuthorizationPrompt(
onSuccess: () -> Unit,
onFailure: () -> Unit,
onError: (String) -> Unit
) {
val context = LocalContext.current
val biometricManager = BiometricManager.from(context)
val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
val isBiometricAvailable =
remember {
when (bio) {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
onError("Biometrics not available")
false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
}
}
if (isBiometricAvailable) {
val executor = remember { ContextCompat.getMainExecutor(context) }
val promptInfo =
BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.setTitle("Biometric Authentication")
.setSubtitle("Log in using your biometric credential")
.build()
val biometricPrompt =
BiometricPrompt(
context as FragmentActivity,
executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(
errorCode: Int,
errString: CharSequence
) {
super.onAuthenticationError(errorCode, errString)
onFailure()
}
override fun onAuthenticationSucceeded(
result: BiometricPrompt.AuthenticationResult
) {
super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
onFailure()
}
}
)
biometricPrompt.authenticate(promptInfo)
}
}
@@ -0,0 +1,64 @@
package com.zaneschepke.wireguardautotunnel.ui.common.prompt
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
@Composable
fun CustomSnackBar(
message: String,
isRtl: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.surface
) {
val context = LocalContext.current
Snackbar(
containerColor = containerColor,
modifier =
Modifier.fillMaxWidth(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1 / 3f else 2 / 3f
).padding(bottom = 100.dp),
shape = RoundedCornerShape(16.dp)
) {
CompositionLocalProvider(
LocalLayoutDirection provides
if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
) {
Row(
modifier = Modifier.width(IntrinsicSize.Max).height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start
) {
Icon(
Icons.Rounded.Info,
contentDescription = stringResource(R.string.info),
tint = Color.White,
modifier = Modifier.padding(end = 10.dp)
)
Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp))
}
}
}
}
@@ -0,0 +1,25 @@
package com.zaneschepke.wireguardautotunnel.ui.common.text
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun SectionTitle(
title: String,
padding: Dp
) {
Text(
title,
textAlign = TextAlign.Center,
style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.ExtraBold),
modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp)
)
}
@@ -0,0 +1,30 @@
package com.zaneschepke.wireguardautotunnel.ui.models
import com.wireguard.config.Interface
data class InterfaceProxy(
var privateKey: String = "",
var publicKey: String = "",
var addresses: String = "",
var dnsServers: String = "",
var listenPort: String = "",
var mtu: String = ""
) {
companion object {
fun from(i: Interface): InterfaceProxy {
return InterfaceProxy(
publicKey = i.keyPair.publicKey.toBase64().trim(),
privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = if (i.listenPort.isPresent) {
i.listenPort.get().toString()
.trim()
} else {
""
},
mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else ""
)
}
}
}
@@ -0,0 +1,49 @@
package com.zaneschepke.wireguardautotunnel.ui.models
import com.wireguard.config.Peer
data class PeerProxy(
var publicKey: String = "",
var preSharedKey: String = "",
var persistentKeepalive: String = "",
var endpoint: String = "",
var allowedIps: String = IPV4_WILDCARD.joinToString(", ").trim()
) {
companion object {
fun from(peer: Peer): PeerProxy {
return PeerProxy(
publicKey = peer.publicKey.toBase64(),
preSharedKey = if (peer.preSharedKey.isPresent) {
peer.preSharedKey.get().toBase64()
.trim()
} else {
""
},
persistentKeepalive = if (peer.persistentKeepalive.isPresent) {
peer.persistentKeepalive.get()
.toString().trim()
} else {
""
},
endpoint = if (peer.endpoint.isPresent) {
peer.endpoint.get().toString()
.trim()
} else {
""
},
allowedIps = peer.allowedIps.joinToString(", ").trim()
)
}
val IPV4_PUBLIC_NETWORKS =
setOf(
"0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3",
"64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12",
"172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7",
"176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16",
"192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10",
"193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4"
)
val IPV4_WILDCARD = setOf("0.0.0.0/0")
}
}
@@ -1,142 +1,227 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.widget.Toast import android.annotation.SuppressLint
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Android import androidx.compose.material.icons.rounded.Android
import androidx.compose.material3.Button import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Save
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import com.google.accompanist.drawablepainter.DrawablePainter import com.google.accompanist.drawablepainter.DrawablePainter
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.Routes import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar import com.zaneschepke.wireguardautotunnel.ui.common.SearchBar
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationTextBox
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
@OptIn(ExperimentalComposeUiApi::class) @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(
ExperimentalComposeUiApi::class,
ExperimentalMaterial3Api::class,
ExperimentalFoundationApi::class
)
@Composable @Composable
fun ConfigScreen( fun ConfigScreen(
viewModel: ConfigViewModel = hiltViewModel(), viewModel: ConfigViewModel = hiltViewModel(),
padding: PaddingValues,
focusRequester: FocusRequester, focusRequester: FocusRequester,
navController: NavController, navController: NavController,
id : String showSnackbarMessage: (String) -> Unit,
id: String
) { ) {
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null) val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null)
val tunnelName = viewModel.tunnelName.collectAsStateWithLifecycle() val tunnelName = viewModel.tunnelName.collectAsStateWithLifecycle()
val packages by viewModel.packages.collectAsStateWithLifecycle() val packages by viewModel.packages.collectAsStateWithLifecycle()
val checkedPackages by viewModel.checkedPackages.collectAsStateWithLifecycle() val checkedPackages by viewModel.checkedPackages.collectAsStateWithLifecycle()
val include by viewModel.include.collectAsStateWithLifecycle() val include by viewModel.include.collectAsStateWithLifecycle()
val allApplications by viewModel.allApplications.collectAsStateWithLifecycle() val isAllApplicationsEnabled by viewModel.isAllApplicationsEnabled.collectAsStateWithLifecycle()
val sortedPackages = remember(packages) { val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle()
packages.sortedBy { viewModel.getPackageLabel(it) } val proxyInterface by viewModel.interfaceProxy.collectAsStateWithLifecycle()
} var showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) }
val baseTextBoxModifier =
Modifier.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide()
}
}
val keyboardActions =
KeyboardActions(
onDone = {
keyboardController?.hide()
}
)
val keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
)
val fillMaxHeight = .85f
val fillMaxWidth = .85f
val screenPadding = 5.dp
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.emitScreenData(id) scope.launch(Dispatchers.IO) {
try {
viewModel.onScreenLoad(id)
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
navController.navigate(Routes.Main.name)
}
}
} }
if(tunnel != null) { val applicationButtonText = {
LazyColumn( "Tunneling apps: " +
horizontalAlignment = Alignment.Start, if (isAllApplicationsEnabled) {
verticalArrangement = Arrangement.Top, "all"
modifier = Modifier } else {
.fillMaxSize() "${checkedPackages.size} " + (if (include) "included" else "excluded")
.padding(padding) }
) { }
item {
Row( if (showAuthPrompt) {
modifier = Modifier AuthorizationPrompt(
.fillMaxWidth() onSuccess = {
.padding(horizontal = 20.dp, vertical = 7.dp), showAuthPrompt = false
verticalAlignment = Alignment.CenterVertically, isAuthenticated = true
horizontalArrangement = Arrangement.SpaceBetween },
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed))
}
)
}
if (showApplicationsDialog) {
val sortedPackages =
remember(packages) {
packages.sortedBy { viewModel.getPackageLabel(it) }
}
AlertDialog(onDismissRequest = {
showApplicationsDialog = false
}) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(if (isAllApplicationsEnabled) 1 / 5f else 4 / 5f)
) {
Column(
modifier = Modifier.fillMaxWidth()
) { ) {
OutlinedTextField( Row(
modifier = Modifier.focusRequester(focusRequester), modifier =
value = tunnelName.value, Modifier
onValueChange = { .fillMaxWidth()
viewModel.onTunnelNameChange(it) .padding(horizontal = 20.dp, vertical = 7.dp),
}, verticalAlignment = Alignment.CenterVertically,
label = { Text(stringResource(id = R.string.tunnel_name)) }, horizontalArrangement = Arrangement.SpaceBetween
maxLines = 1, ) {
keyboardOptions = KeyboardOptions( Text(stringResource(id = R.string.tunnel_all))
capitalization = KeyboardCapitalization.None, Switch(
imeAction = ImeAction.Done checked = isAllApplicationsEnabled,
), onCheckedChange = {
keyboardActions = KeyboardActions( viewModel.onAllApplicationsChange(it)
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
viewModel.onTunnelNameChange(tunnelName.value)
} }
), )
) }
} if (!isAllApplicationsEnabled) {
}
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( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp), .padding(
horizontal = 20.dp,
vertical = 7.dp
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
@@ -165,78 +250,485 @@ fun ConfigScreen(
) )
} }
} }
}
item {
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp), .padding(
verticalAlignment = Alignment.CenterVertically, horizontal = 20.dp,
horizontalArrangement = Arrangement.SpaceBetween) { vertical = 7.dp
SearchBar(viewModel::emitQueriedPackages); ),
}
}
items(sortedPackages, key = { it.packageName }) { pack ->
Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Row( SearchBar(viewModel::emitQueriedPackages)
horizontalArrangement = Arrangement.Center, }
verticalAlignment = Alignment.CenterVertically, Spacer(Modifier.padding(5.dp))
modifier = Modifier.padding(5.dp) LazyColumn(
) { horizontalAlignment = Alignment.Start,
val drawable = verticalArrangement = Arrangement.Top,
pack.applicationInfo?.loadIcon(context.packageManager) modifier =
if (drawable != null) { Modifier
Image( .fillMaxHeight(4 / 5f)
painter = DrawablePainter(drawable), ) {
stringResource(id = R.string.icon), items(
modifier = Modifier.size(50.dp, 50.dp) sortedPackages,
) key = { it.packageName }
} else { ) { pack ->
Icon( Row(
Icons.Rounded.Android, verticalAlignment = Alignment.CenterVertically,
stringResource(id = R.string.edit), horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.size(50.dp, 50.dp) modifier =
Modifier
.fillMaxSize()
.padding(5.dp)
) {
Row(
modifier =
Modifier.fillMaxWidth(
fillMaxWidth
)
) {
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(
viewModel.getPackageLabel(pack),
modifier = Modifier.padding(5.dp)
)
}
Checkbox(
modifier = Modifier.fillMaxSize(),
checked = (checkedPackages.contains(pack.packageName)),
onCheckedChange = {
if (it) {
viewModel.onAddCheckedPackage(
pack.packageName
)
} else {
viewModel.onRemoveCheckedPackage(
pack.packageName
)
}
}
) )
} }
Text(
viewModel.getPackageLabel(pack), modifier = Modifier.padding(5.dp)
)
} }
Checkbox(
checked = (checkedPackages.contains(pack.packageName)),
onCheckedChange = {
if (it) viewModel.onAddCheckedPackage(pack.packageName) else viewModel.onRemoveCheckedPackage(
pack.packageName
)
}
)
} }
} }
} Row(
item { verticalAlignment = Alignment.CenterVertically,
Row( modifier =
horizontalArrangement = Arrangement.Center, Modifier
verticalAlignment = Alignment.CenterVertically, .fillMaxSize()
modifier = Modifier.fillMaxWidth() .padding(top = 5.dp),
) { horizontalArrangement = Arrangement.Center
Button(onClick = { ) {
scope.launch { TextButton(
viewModel.onSaveAllChanges() onClick = {
Toast.makeText( showApplicationsDialog = false
context, }
context.resources.getString(R.string.config_changes_saved), ) {
Toast.LENGTH_LONG Text(stringResource(R.string.done))
).show()
navController.navigate(Routes.Main.name)
} }
}, Modifier.padding(25.dp)) {
Text(stringResource(id = R.string.save_changes))
} }
} }
} }
} }
} }
if (tunnel != null) {
Scaffold(
floatingActionButtonPosition = FabPosition.End,
floatingActionButton = {
val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton(
modifier =
Modifier.padding(bottom = 90.dp).onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
onClick = {
scope.launch {
try {
viewModel.onSaveAllChanges()
navController.navigate(Routes.Main.name)
showSnackbarMessage(
context.resources.getString(R.string.config_changes_saved)
)
} catch (e: Exception) {
Timber.e(e.message)
showSnackbarMessage(e.message!!)
}
}
},
containerColor = fobColor,
shape = RoundedCornerShape(16.dp)
) {
Icon(
imageVector = Icons.Rounded.Save,
contentDescription = stringResource(id = R.string.save_changes),
tint = Color.DarkGray
)
}
}
) {
Column {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.verticalScroll(rememberScrollState())
.weight(1f, true)
.fillMaxSize()
) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier
.fillMaxHeight(fillMaxHeight)
.fillMaxWidth(fillMaxWidth)
} else {
Modifier.fillMaxWidth(fillMaxWidth)
}
).padding(
top = 50.dp,
bottom = 10.dp
)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp).focusGroup()
) {
SectionTitle(
stringResource(R.string.interface_),
padding = screenPadding
)
ConfigurationTextBox(
value = tunnelName.value,
onValueChange = { value ->
viewModel.onTunnelNameChange(value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.name),
hint = stringResource(R.string.tunnel_name).lowercase(),
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(
focusRequester
)
)
OutlinedTextField(
modifier =
baseTextBoxModifier.fillMaxWidth().clickable {
showAuthPrompt = true
},
value = proxyInterface.privateKey,
visualTransformation = if ((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(),
enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
onValueChange = { value ->
viewModel.onPrivateKeyChange(value)
},
trailingIcon = {
IconButton(
modifier = Modifier.focusRequester(FocusRequester.Default),
onClick = {
viewModel.generateKeyPair()
}
) {
Icon(
Icons.Rounded.Refresh,
stringResource(R.string.rotate_keys),
tint = Color.White
)
}
},
label = { Text(stringResource(R.string.private_key)) },
singleLine = true,
placeholder = { Text(stringResource(R.string.base64_key)) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions
)
OutlinedTextField(
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(
FocusRequester.Default
),
value = proxyInterface.publicKey,
enabled = false,
onValueChange = {},
trailingIcon = {
IconButton(
modifier = Modifier.focusRequester(FocusRequester.Default),
onClick = {
clipboardManager.setText(
AnnotatedString(proxyInterface.publicKey)
)
}
) {
Icon(
Icons.Rounded.ContentCopy,
stringResource(R.string.copy_public_key),
tint = Color.White
)
}
},
label = { Text(stringResource(R.string.public_key)) },
singleLine = true,
placeholder = { Text(stringResource(R.string.base64_key)) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions
)
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = proxyInterface.addresses,
onValueChange = { value ->
viewModel.onAddressesChanged(value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.addresses),
hint = stringResource(R.string.comma_separated_list),
modifier =
baseTextBoxModifier
.fillMaxWidth(3 / 5f)
.padding(end = 5.dp)
)
ConfigurationTextBox(
value = proxyInterface.listenPort,
onValueChange = { value -> viewModel.onListenPortChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port),
hint = stringResource(R.string.random),
modifier = baseTextBoxModifier.width(IntrinsicSize.Min)
)
}
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = proxyInterface.dnsServers,
onValueChange = { value -> viewModel.onDnsServersChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.dns_servers),
hint = stringResource(R.string.comma_separated_list),
modifier =
baseTextBoxModifier
.fillMaxWidth(3 / 5f)
.padding(end = 5.dp)
)
ConfigurationTextBox(
value = proxyInterface.mtu,
onValueChange = { value -> viewModel.onMtuChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto),
modifier = baseTextBoxModifier.width(IntrinsicSize.Min)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
onClick = {
showApplicationsDialog = true
}
) {
Text(applicationButtonText())
}
}
}
}
proxyPeers.forEachIndexed { index, peer ->
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier
.fillMaxHeight(fillMaxHeight)
.fillMaxWidth(fillMaxWidth)
} else {
Modifier.fillMaxWidth(fillMaxWidth)
}
).padding(
top = 10.dp,
bottom = 10.dp
)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.padding(horizontal = 15.dp)
.padding(bottom = 10.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 5.dp)
) {
SectionTitle(
stringResource(R.string.peer),
padding = screenPadding
)
IconButton(
onClick = {
viewModel.onDeletePeer(index)
}
) {
Icon(Icons.Rounded.Delete, stringResource(R.string.delete))
}
}
ConfigurationTextBox(
value = peer.publicKey,
onValueChange = { value ->
viewModel.onPeerPublicKeyChange(
index,
value
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.public_key),
hint = stringResource(R.string.base64_key),
modifier = baseTextBoxModifier.fillMaxWidth()
)
ConfigurationTextBox(
value = peer.preSharedKey,
onValueChange = { value ->
viewModel.onPreSharedKeyChange(
index,
value
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.preshared_key),
hint = stringResource(R.string.optional),
modifier = baseTextBoxModifier.fillMaxWidth()
)
OutlinedTextField(
modifier = baseTextBoxModifier.fillMaxWidth(),
value = peer.persistentKeepalive,
enabled = true,
onValueChange = { value ->
viewModel.onPersistentKeepaliveChanged(index, value)
},
trailingIcon = {
Text(
stringResource(R.string.seconds),
modifier = Modifier.padding(end = 10.dp)
)
},
label = { Text(stringResource(R.string.persistent_keepalive)) },
singleLine = true,
placeholder = {
Text(stringResource(R.string.optional_no_recommend))
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions
)
ConfigurationTextBox(
value = peer.endpoint,
onValueChange = { value ->
viewModel.onEndpointChange(
index,
value
)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.endpoint),
hint = stringResource(R.string.endpoint).lowercase(),
modifier = baseTextBoxModifier.fillMaxWidth()
)
OutlinedTextField(
modifier = baseTextBoxModifier.fillMaxWidth(),
value = peer.allowedIps,
enabled = true,
onValueChange = { value ->
viewModel.onAllowedIpsChange(
index,
value
)
},
label = { Text(stringResource(R.string.allowed_ips)) },
singleLine = true,
placeholder = {
Text(stringResource(R.string.comma_separated_list))
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions
)
}
}
}
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(bottom = 140.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
TextButton(
onClick = {
viewModel.addEmptyPeer()
}
) {
Text(stringResource(R.string.add_peer))
}
}
}
}
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Spacer(modifier = Modifier.weight(.17f))
}
}
}
}
} }
@@ -10,28 +10,44 @@ import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config import com.wireguard.config.Config
import com.wireguard.config.Interface
import com.wireguard.config.Peer
import com.wireguard.crypto.Key
import com.wireguard.crypto.KeyPair
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsManager
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ConfigViewModel @Inject constructor(private val application : Application, class ConfigViewModel
private val tunnelRepo : TunnelConfigDao, @Inject
private val settingsRepo : SettingsDoa) : ViewModel() { constructor(
private val application: Application,
private val tunnelRepo: TunnelConfigDao,
private val settingsRepo: SettingsDoa
) : ViewModel() {
private val _tunnel = MutableStateFlow<TunnelConfig?>(null) private val _tunnel = MutableStateFlow<TunnelConfig?>(null)
private val _tunnelName = MutableStateFlow("") private val _tunnelName = MutableStateFlow("")
val tunnelName get() = _tunnelName.asStateFlow() val tunnelName get() = _tunnelName.asStateFlow()
val tunnel get() = _tunnel.asStateFlow() val tunnel get() = _tunnel.asStateFlow()
private var _proxyPeers = MutableStateFlow(mutableStateListOf<PeerProxy>())
val proxyPeers get() = _proxyPeers.asStateFlow()
private var _interface = MutableStateFlow(InterfaceProxy())
val interfaceProxy = _interface.asStateFlow()
private val _packages = MutableStateFlow(emptyList<PackageInfo>()) private val _packages = MutableStateFlow(emptyList<PackageInfo>())
val packages get() = _packages.asStateFlow() val packages get() = _packages.asStateFlow()
private val packageManager = application.packageManager private val packageManager = application.packageManager
@@ -41,60 +57,110 @@ class ConfigViewModel @Inject constructor(private val application : Application,
private val _include = MutableStateFlow(true) private val _include = MutableStateFlow(true)
val include get() = _include.asStateFlow() val include get() = _include.asStateFlow()
private val _allApplications = MutableStateFlow(true) private val _isAllApplicationsEnabled = MutableStateFlow(false)
val allApplications get() = _allApplications.asStateFlow() val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow()
private val _isDefaultTunnel = MutableStateFlow(false)
fun emitScreenData(id : String) { private lateinit var tunnelConfig: TunnelConfig
viewModelScope.launch(Dispatchers.IO) {
val tunnelConfig = getTunnelConfigById(id); suspend fun onScreenLoad(id: String) {
emitTunnelConfig(tunnelConfig); if (id != Constants.MANUAL_TUNNEL_CONFIG_ID) {
emitTunnelConfigName(tunnelConfig?.name) tunnelConfig = getTunnelConfigById(id) ?: throw WgTunnelException(
emitQueriedPackages("") "Config not found"
emitCurrentPackageConfigurations(id) )
emitScreenData()
} else {
emitEmptyScreenData()
} }
} }
private suspend fun getTunnelConfigById(id : String) : TunnelConfig? { private fun emitEmptyScreenData() {
tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = "")
viewModelScope.launch {
emitTunnelConfig()
emitPeerProxy(PeerProxy())
emitInterfaceProxy(InterfaceProxy())
emitTunnelConfigName()
emitDefaultTunnelStatus()
emitQueriedPackages("")
emitTunnelAllApplicationsEnabled()
}
}
private suspend fun emitScreenData() {
emitTunnelConfig()
emitPeersFromConfig()
emitInterfaceFromConfig()
emitTunnelConfigName()
emitDefaultTunnelStatus()
emitQueriedPackages("")
emitCurrentPackageConfigurations()
}
private suspend fun emitDefaultTunnelStatus() {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
_isDefaultTunnel.value = settings.first().isTunnelConfigDefault(tunnelConfig)
}
}
private fun emitInterfaceFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
_interface.value = InterfaceProxy.from(config.`interface`)
}
private fun emitPeersFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
config.peers.forEach {
_proxyPeers.value.add(PeerProxy.from(it))
}
}
private fun emitPeerProxy(peerProxy: PeerProxy) {
_proxyPeers.value.add(peerProxy)
}
private fun emitInterfaceProxy(interfaceProxy: InterfaceProxy) {
_interface.value = interfaceProxy
}
private suspend fun getTunnelConfigById(id: String): TunnelConfig? {
return try { return try {
tunnelRepo.getById(id.toLong()) tunnelRepo.getById(id.toLong())
} catch (e : Exception) { } catch (_: Exception) {
Timber.e(e.message)
null null
} }
} }
private suspend fun emitTunnelConfig(tunnelConfig: TunnelConfig?) { private suspend fun emitTunnelConfig() {
if(tunnelConfig != null) { _tunnel.emit(tunnelConfig)
_tunnel.emit(tunnelConfig)
}
} }
private suspend fun emitTunnelConfigName(name : String?) { private suspend fun emitTunnelConfigName() {
if(name != null) { _tunnelName.emit(tunnelConfig.name)
_tunnelName.emit(name)
}
} }
fun onTunnelNameChange(name : String) { fun onTunnelNameChange(name: String) {
_tunnelName.value = name _tunnelName.value = name
} }
fun onIncludeChange(include : Boolean) { fun onIncludeChange(include: Boolean) {
_include.value = include _include.value = include
} }
fun onAddCheckedPackage(packageName : String) {
fun onAddCheckedPackage(packageName: String) {
_checkedPackages.value.add(packageName) _checkedPackages.value.add(packageName)
} }
fun onAllApplicationsChange(allApplications : Boolean) { fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_allApplications.value = allApplications _isAllApplicationsEnabled.value = isAllApplicationsEnabled
} }
fun onRemoveCheckedPackage(packageName : String) { fun onRemoveCheckedPackage(packageName: String) {
_checkedPackages.value.remove(packageName) _checkedPackages.value.remove(packageName)
} }
private suspend fun emitSplitTunnelConfiguration(config : Config) { private suspend fun emitSplitTunnelConfiguration(config: Config) {
val excludedApps = config.`interface`.excludedApplications val excludedApps = config.`interface`.excludedApplications
val includedApps = config.`interface`.includedApplications val includedApps = config.`interface`.includedApplications
if (excludedApps.isNotEmpty() || includedApps.isNotEmpty()) { if (excludedApps.isNotEmpty() || includedApps.isNotEmpty()) {
@@ -105,7 +171,10 @@ class ConfigViewModel @Inject constructor(private val application : Application,
} }
} }
private suspend fun determineAppInclusionState(excludedApps : Set<String>, includedApps : Set<String>) { private suspend fun determineAppInclusionState(
excludedApps: Set<String>,
includedApps: Set<String>
) {
if (excludedApps.isEmpty()) { if (excludedApps.isEmpty()) {
emitIncludedAppsExist() emitIncludedAppsExist()
emitCheckedApps(includedApps) emitCheckedApps(includedApps)
@@ -123,125 +192,248 @@ class ConfigViewModel @Inject constructor(private val application : Application,
_include.emit(false) _include.emit(false)
} }
private suspend fun emitCheckedApps(apps : Set<String>) { private suspend fun emitCheckedApps(apps: Set<String>) {
_checkedPackages.emit(apps.toMutableStateList()) _checkedPackages.emit(apps.toMutableStateList())
} }
private suspend fun emitTunnelAllApplicationsEnabled() { private suspend fun emitTunnelAllApplicationsEnabled() {
_allApplications.emit(true) _isAllApplicationsEnabled.emit(true)
} }
private suspend fun emitTunnelAllApplicationsDisabled() { private suspend fun emitTunnelAllApplicationsDisabled() {
_allApplications.emit(false) _isAllApplicationsEnabled.emit(false)
} }
private fun emitCurrentPackageConfigurations(id : String) { private fun emitCurrentPackageConfigurations() {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val tunnelConfig = getTunnelConfigById(id) val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
if (tunnelConfig != null) { emitSplitTunnelConfiguration(config)
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
emitSplitTunnelConfiguration(config)
}
} }
} }
fun emitQueriedPackages(query : String) { fun emitQueriedPackages(query: String) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val packages = getAllInternetCapablePackages().filter { val packages =
getPackageLabel(it).lowercase().contains(query.lowercase()) getAllInternetCapablePackages().filter {
} getPackageLabel(it).lowercase().contains(query.lowercase())
}
_packages.emit(packages) _packages.emit(packages)
} }
} }
fun getPackageLabel(packageInfo : PackageInfo) : String { fun getPackageLabel(packageInfo: PackageInfo): String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString() return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
} }
private fun getAllInternetCapablePackages(): List<PackageInfo> {
private fun getAllInternetCapablePackages() : List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET)) return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
} }
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> { private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(permissions, PackageManager.PackageInfoFlags.of(0L)) packageManager.getPackagesHoldingPermissions(
permissions,
PackageManager.PackageInfoFlags.of(0L)
)
} else { } else {
packageManager.getPackagesHoldingPermissions(permissions, 0) packageManager.getPackagesHoldingPermissions(permissions, 0)
} }
} }
private fun removeTunnelShortcuts(tunnelConfig: TunnelConfig?) { private fun isAllApplicationsEnabled(): Boolean {
if(tunnelConfig != null) { return _isAllApplicationsEnabled.value
ShortcutsManager.removeTunnelShortcuts(application, tunnelConfig)
}
}
private fun isAllApplicationsEnabled() : Boolean {
return _allApplications.value
}
private fun isIncludeApplicationsEnabled() : Boolean {
return _include.value
}
private fun updateQuickStringWithSelectedPackages() : String {
var wgQuick = _tunnel.value?.wgQuick
if(wgQuick != null) {
wgQuick = if(isAllApplicationsEnabled()) {
TunnelConfig.clearAllApplicationsFromConfig(wgQuick)
} else if(isIncludeApplicationsEnabled()) {
TunnelConfig.setIncludedApplicationsOnQuick(_checkedPackages.value, wgQuick)
} else {
TunnelConfig.setExcludedApplicationsOnQuick(_checkedPackages.value, wgQuick)
}
} else {
throw WgTunnelException("Wg quick string is null")
}
return wgQuick;
} }
private suspend fun saveConfig(tunnelConfig: TunnelConfig) { private suspend fun saveConfig(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig) tunnelRepo.save(tunnelConfig)
} }
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) { private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) {
if(tunnelConfig != null) { if (tunnelConfig != null) {
saveConfig(tunnelConfig) saveConfig(tunnelConfig)
addTunnelShortcuts(tunnelConfig)
updateSettingsDefaultTunnel(tunnelConfig) updateSettingsDefaultTunnel(tunnelConfig)
} }
} }
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) { private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) { if (settings.isNotEmpty()) {
val setting = settings[0] val setting = settings[0]
if(setting.defaultTunnel != null) { if (setting.defaultTunnel != null) {
if(tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) { if (tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
settingsRepo.save(setting.copy( settingsRepo.save(
defaultTunnel = tunnelConfig.toString() setting.copy(
)) defaultTunnel = tunnelConfig.toString()
)
)
} }
} }
} }
} }
private fun addTunnelShortcuts(tunnelConfig: TunnelConfig) { private fun buildPeerListFromProxyPeers(): List<Peer> {
ShortcutsManager.createTunnelShortcuts(application, tunnelConfig) return _proxyPeers.value.map {
val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(
it.persistentKeepalive.trim()
)
}
builder.build()
}
}
private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder()
builder.parsePrivateKey(_interface.value.privateKey.trim())
builder.parseAddresses(_interface.value.addresses.trim())
builder.parseDnsServers(_interface.value.dnsServers.trim())
if (_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim())
if (_interface.value.listenPort.isNotEmpty()) {
builder.parseListenPort(
_interface.value.listenPort.trim()
)
}
if (isAllApplicationsEnabled()) _checkedPackages.value.clear()
if (_include.value) builder.includeApplications(_checkedPackages.value)
if (!_include.value) builder.excludeApplications(_checkedPackages.value)
return builder.build()
} }
suspend fun onSaveAllChanges() { suspend fun onSaveAllChanges() {
try { try {
removeTunnelShortcuts(_tunnel.value) val peerList = buildPeerListFromProxyPeers()
val wgQuick = updateQuickStringWithSelectedPackages() val wgInterface = buildInterfaceListFromProxyInterface()
val tunnelConfig = _tunnel.value?.copy( val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
name = _tunnelName.value, val tunnelConfig =
wgQuick = wgQuick _tunnel.value?.copy(
) name = _tunnelName.value,
wgQuick = config.toWgQuickString()
)
updateTunnelConfig(tunnelConfig) updateTunnelConfig(tunnelConfig)
} catch (e : Exception) { } catch (e: Exception) {
Timber.e(e.message) throw WgTunnelException(
"Error: ${e.cause?.message?.lowercase() ?: "unknown error occurred"}"
)
}
}
fun onPeerPublicKeyChange(
index: Int,
publicKey: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
publicKey = publicKey
)
}
fun onPreSharedKeyChange(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
preSharedKey = value
)
}
fun onEndpointChange(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
endpoint = value
)
}
fun onAllowedIpsChange(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
allowedIps = value
)
}
fun onPersistentKeepaliveChanged(
index: Int,
value: String
) {
_proxyPeers.value[index] =
_proxyPeers.value[index].copy(
persistentKeepalive = value
)
}
fun onDeletePeer(index: Int) {
proxyPeers.value.removeAt(index)
}
fun addEmptyPeer() {
_proxyPeers.value.add(PeerProxy())
}
fun generateKeyPair() {
val keyPair = KeyPair()
_interface.value =
_interface.value.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64()
)
}
fun onAddressesChanged(value: String) {
_interface.value =
_interface.value.copy(
addresses = value
)
}
fun onListenPortChanged(value: String) {
_interface.value =
_interface.value.copy(
listenPort = value
)
}
fun onDnsServersChanged(value: String) {
_interface.value =
_interface.value.copy(
dnsServers = value
)
}
fun onMtuChanged(value: String) {
_interface.value =
_interface.value.copy(
mtu = value
)
}
private fun onInterfacePublicKeyChange(value: String) {
_interface.value =
_interface.value.copy(
publicKey = value
)
}
fun onPrivateKeyChange(value: String) {
_interface.value =
_interface.value.copy(
privateKey = value
)
if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else {
onInterfacePublicKeyChange("")
} }
} }
} }
@@ -1,143 +0,0 @@
package com.zaneschepke.wireguardautotunnel.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 com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.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.emitConfig(id)
}
if(null != tunnel) {
val interfaceKey = tunnel?.`interface`?.keyPair?.publicKey?.toBase64().toString()
val addresses = tunnel?.`interface`?.addresses!!.joinToString()
val dnsServers = tunnel?.`interface`?.dnsServers!!.joinToString()
val optionalMtu = tunnel?.`interface`?.mtu
val mtu = if(optionalMtu?.isPresent == true) optionalMtu.get().toString() else stringResource(
id = R.string.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 stringResource(
id = R.string.none
)
Text(stringResource(R.string.peer), fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(stringResource(R.string.public_key), fontStyle = FontStyle.Italic)
Text(text = peerKey, modifier = Modifier.clickable {
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(stringResource(id = R.string.never))
} else {
val time = Instant.ofEpochMilli(handshakeEpoch)
Text("${Duration.between(time, Instant.now()).seconds} seconds ago")
}
}
}
}
}
}
}
}
}
}
@@ -1,45 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class DetailViewModel @Inject constructor(private val tunnelRepo : TunnelConfigDao, private val vpnService : VpnService
) : ViewModel() {
private val _tunnel = MutableStateFlow<Config?>(null)
val tunnel get() = _tunnel.asStateFlow()
private val _tunnelName = MutableStateFlow("")
val tunnelName = _tunnelName.asStateFlow()
val tunnelStats get() = vpnService.statistics
val lastHandshake get() = vpnService.lastHandshake
private suspend fun getTunnelConfigById(id: String): TunnelConfig? {
return try {
tunnelRepo.getById(id.toLong())
} catch (e: Exception) {
Timber.e(e.message)
null
}
}
fun emitConfig(id: String) {
viewModelScope.launch(Dispatchers.IO) {
val tunnelConfig = getTunnelConfigById(id)
if(tunnelConfig != null) {
_tunnel.emit(TunnelConfig.configFromQuick(tunnelConfig.wgQuick))
}
}
}
}
@@ -1,7 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
@@ -17,10 +20,12 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
@@ -28,6 +33,8 @@ import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Divider import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
@@ -37,14 +44,12 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold 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.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -55,6 +60,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
@@ -82,82 +88,168 @@ import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
import com.zaneschepke.wireguardautotunnel.ui.Routes import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed
import com.zaneschepke.wireguardautotunnel.ui.theme.corn
import com.zaneschepke.wireguardautotunnel.ui.theme.mint import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel = hiltViewModel(), padding: PaddingValues, viewModel: MainViewModel = hiltViewModel(),
snackbarHostState: SnackbarHostState, navController: NavController padding: PaddingValues,
showSnackbarMessage: (String) -> Unit,
navController: NavController
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val context = LocalContext.current val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) } val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope { Dispatchers.IO }
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(HandshakeStatus.NOT_STARTED) val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(
val viewState = viewModel.viewState.collectAsStateWithLifecycle() HandshakeStatus.NOT_STARTED
)
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) } var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN) val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("") val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
val settings by viewModel.settings.collectAsStateWithLifecycle()
val statistics by viewModel.statistics.collectAsStateWithLifecycle(null)
// Nested scroll for control FAB // Nested scroll for control FAB
val nestedScrollConnection = remember { val nestedScrollConnection =
object : NestedScrollConnection { remember {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { object : NestedScrollConnection {
// Hide FAB override fun onPreScroll(
if (available.y < -1) { available: Offset,
isVisible.value = false source: NestedScrollSource
): Offset {
// Hide FAB
if (available.y < -1) {
isVisible.value = false
}
// Show FAB
if (available.y > 1) {
isVisible.value = true
}
return Offset.Zero
} }
// Show FAB }
if (available.y > 1) { }
isVisible.value = true
val tunnelFileImportResultLauncher =
rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() {
override fun createIntent(
context: Context,
input: String
): Intent {
val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */
val activitiesToResolveIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(
intent,
PackageManager.ResolveInfoFlags.of(
PackageManager.MATCH_DEFAULT_ONLY.toLong()
)
)
} else {
context.packageManager.queryIntentActivities(
intent,
PackageManager.MATCH_DEFAULT_ONLY
)
}
if (activitiesToResolveIntent.all {
val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith(
Constants.ANDROID_TV_EXPLORER_STUB
)
}
) {
throw WgTunnelException(context.getString(R.string.no_file_explorer))
}
return intent
}
}
) { data ->
if (data == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
try {
viewModel.onTunnelFileSelected(data)
} catch (e: WgTunnelException) {
showSnackbarMessage(e.message)
} }
return Offset.Zero
} }
} }
val scanLauncher =
rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = {
scope.launch {
try {
viewModel.onTunnelQrResult(it.contents)
} catch (e: Exception) {
when (e) {
is WgTunnelException -> {
showSnackbarMessage(e.message)
}
else -> {
showSnackbarMessage("No QR code scanned")
}
}
}
}
}
)
if (showPrimaryChangeAlertDialog) {
AlertDialog(
onDismissRequest = {
showPrimaryChangeAlertDialog = false
},
confirmButton = {
TextButton(onClick = {
scope.launch {
viewModel.onDefaultTunnelChange(selectedTunnel)
showPrimaryChangeAlertDialog = false
selectedTunnel = null
}
}) { Text(text = stringResource(R.string.okay)) }
},
dismissButton = {
TextButton(onClick = {
showPrimaryChangeAlertDialog = false
}) { Text(text = stringResource(R.string.cancel)) }
},
title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) }
)
} }
LaunchedEffect(viewState.value) { fun onTunnelToggle(
if (viewState.value.showSnackbarMessage) { checked: Boolean,
val result = snackbarHostState.showSnackbar( tunnel: TunnelConfig
message = viewState.value.snackbarMessage, ) {
actionLabel = viewState.value.snackbarActionText, try {
duration = SnackbarDuration.Long, if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
) } catch (e: Exception) {
when (result) { showSnackbarMessage(e.message!!)
SnackbarResult.ActionPerformed -> viewState.value.onSnackbarActionClick
SnackbarResult.Dismissed -> viewState.value.onSnackbarActionClick
}
} }
} }
val pickFileLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
result.data?.data?.let { viewModel.onTunnelFileSelected(it) }
}
val scanLauncher = rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = {
try {
viewModel.onTunnelQrResult(it.contents)
} catch (e : Exception) {
viewModel.showSnackBarMessage(context.getString(R.string.qr_result_failed))
}
}
)
Scaffold( Scaffold(
modifier = Modifier.pointerInput(Unit) { modifier =
Modifier.pointerInput(Unit) {
detectTapGestures(onTap = { detectTapGestures(onTap = {
selectedTunnel = null selectedTunnel = null
}) })
@@ -167,20 +259,30 @@ fun MainScreen(
AnimatedVisibility( AnimatedVisibility(
visible = isVisible.value, visible = isVisible.value,
enter = slideInVertically(initialOffsetY = { it * 2 }), enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 }), exit = slideOutVertically(targetOffsetY = { it * 2 })
) { ) {
val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton( FloatingActionButton(
modifier = Modifier.padding(bottom = 90.dp), modifier =
Modifier
.padding(bottom = 90.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
onClick = { onClick = {
showBottomSheet = true showBottomSheet = true
}, },
containerColor = MaterialTheme.colorScheme.secondary, containerColor = fobColor,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.add_tunnel), contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray, tint = Color.DarkGray
) )
} }
} }
@@ -190,7 +292,8 @@ fun MainScreen(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
@@ -206,15 +309,16 @@ fun MainScreen(
) { ) {
// Sheet content // Sheet content
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
val fileSelectionIntent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { try {
addCategory(Intent.CATEGORY_OPENABLE) tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
type = Constants.ALLOWED_FILE_TYPES } catch (e: Exception) {
showSnackbarMessage(e.message!!)
} }
pickFileLauncher.launch(fileSelectionIntent)
} }
.padding(10.dp) .padding(10.dp)
) { ) {
@@ -224,34 +328,62 @@ fun MainScreen(
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) )
Text( Text(
stringResource(id = R.string.add_from_file), stringResource(id = R.string.add_tunnels_text),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) )
} }
Divider() if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Row(modifier = Modifier Divider()
.fillMaxWidth() Row(
.clickable { modifier =
scope.launch { Modifier
showBottomSheet = false .fillMaxWidth()
val scanOptions = ScanOptions() .clickable {
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE) scope.launch {
scanOptions.setOrientationLocked(true) showBottomSheet = false
scanOptions.setPrompt(context.getString(R.string.scanning_qr)) val scanOptions = ScanOptions()
scanOptions.setBeepEnabled(false) scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.captureActivity = CaptureActivityPortrait::class.java scanOptions.setOrientationLocked(true)
scanLauncher.launch(scanOptions) scanOptions.setPrompt(context.getString(R.string.scanning_qr))
} scanOptions.setBeepEnabled(false)
scanOptions.captureActivity =
CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
}
}
.padding(10.dp)
) {
Icon(
Icons.Filled.QrCode,
contentDescription = stringResource(id = R.string.qr_scan),
modifier = Modifier.padding(10.dp)
)
Text(
stringResource(id = R.string.add_from_qr),
modifier = Modifier.padding(10.dp)
)
} }
.padding(10.dp) }
Divider()
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
showBottomSheet = false
navController.navigate(
"${Routes.Config.name}/${Constants.MANUAL_TUNNEL_CONFIG_ID}"
)
}
.padding(10.dp)
) { ) {
Icon( Icon(
Icons.Filled.QrCode, Icons.Filled.Create,
contentDescription = stringResource(id = R.string.qr_scan), contentDescription = stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) )
Text( Text(
stringResource(id = R.string.add_from_qr), stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) )
} }
@@ -260,31 +392,67 @@ fun MainScreen(
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
LazyColumn( LazyColumn(
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.nestedScroll(nestedScrollConnection), .padding(top = 10.dp)
.nestedScroll(nestedScrollConnection)
) { ) {
items(tunnels, key = { tunnel -> tunnel.id }) {tunnel -> items(tunnels, key = { tunnel -> tunnel.id }) { tunnel ->
val focusRequester = FocusRequester() val leadingIconColor = (
RowListItem(leadingIcon = Icons.Rounded.Circle, if (tunnelName == tunnel.name) {
leadingIconColor = if (tunnelName == tunnel.name) when (handshakeStatus) { when (handshakeStatus) {
HandshakeStatus.HEALTHY -> mint HandshakeStatus.HEALTHY -> mint
HandshakeStatus.UNHEALTHY -> brickRed HandshakeStatus.UNHEALTHY -> brickRed
HandshakeStatus.NOT_STARTED -> Color.Gray HandshakeStatus.STALE -> corn
HandshakeStatus.NEVER_CONNECTED -> brickRed HandshakeStatus.NOT_STARTED -> Color.Gray
} else Color.Gray, HandshakeStatus.NEVER_CONNECTED -> brickRed
}
} else {
Color.Gray
}
)
val focusRequester = remember { FocusRequester() }
val expanded =
remember {
mutableStateOf(false)
}
RowListItem(
icon = {
if (settings.isTunnelConfigDefault(tunnel)) {
Icon(
Icons.Rounded.Star,
stringResource(R.string.status),
tint = leadingIconColor,
modifier =
Modifier
.padding(end = 10.dp)
.size(20.dp)
)
} else {
Icon(
Icons.Rounded.Circle,
stringResource(R.string.status),
tint = leadingIconColor,
modifier =
Modifier
.padding(end = 15.dp)
.size(15.dp)
)
}
},
text = tunnel.name, text = tunnel.name,
onHold = { onHold = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) { if ((state == Tunnel.State.UP) && (tunnel.name == tunnelName)) {
scope.launch { showSnackbarMessage(
viewModel.showSnackBarMessage(context.resources.getString(R.string.turn_off_tunnel)) context.resources.getString(R.string.turn_off_tunnel)
} )
return@RowListItem return@RowListItem
} }
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
@@ -292,22 +460,51 @@ fun MainScreen(
}, },
onClick = { onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
navController.navigate("${Routes.Detail.name}/${tunnel.id}") if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) {
expanded.value = !expanded.value
}
} else { } else {
selectedTunnel = tunnel
focusRequester.requestFocus() focusRequester.requestFocus()
} }
}, },
statistics = statistics,
expanded = expanded.value,
rowButton = { rowButton = {
if (tunnel.id == selectedTunnel?.id) { if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv(
context
)
) {
Row { Row {
if (!settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = {
if (settings.isAutoTunnelEnabled) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_auto
)
)
} else {
showPrimaryChangeAlertDialog = true
}
}) {
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary)
)
}
}
IconButton(onClick = { IconButton(onClick = {
navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}") navController.navigate(
"${Routes.Config.name}/${selectedTunnel?.id}"
)
}) { }) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
} }
IconButton( IconButton(
modifier = Modifier.focusable(), modifier = Modifier.focusable(),
onClick = { viewModel.onDelete(tunnel) }) { onClick = { viewModel.onDelete(tunnel) }
) {
Icon( Icon(
Icons.Rounded.Delete, Icons.Rounded.Delete,
stringResource(id = R.string.delete) stringResource(id = R.string.delete)
@@ -315,25 +512,60 @@ fun MainScreen(
} }
} }
} else { } else {
val checked = state == Tunnel.State.UP && tunnel.name == tunnelName
if (!checked) expanded.value = false
@Composable
fun TunnelSwitch() =
Switch(
modifier = Modifier.focusRequester(focusRequester),
checked = checked,
onCheckedChange = { checked ->
if (!checked) expanded.value = false
onTunnelToggle(checked, tunnel)
}
)
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Row { Row {
IconButton( if (!settings.isTunnelConfigDefault(tunnel)) {
modifier = Modifier.focusRequester(focusRequester), IconButton(onClick = {
onClick = { if (settings.isAutoTunnelEnabled) {
navController.navigate("${Routes.Detail.name}/${tunnel.id}") showSnackbarMessage(
}) {
Icon(Icons.Rounded.Info, "Info")
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName)
scope.launch {
viewModel.showSnackBarMessage(
context.resources.getString( context.resources.getString(
R.string.turn_off_tunnel R.string.turn_off_auto
) )
) )
} else { } else {
navController.navigate("${Routes.Config.name}/${tunnel.id}") showPrimaryChangeAlertDialog = true
}
}) {
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary)
)
}
}
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) {
expanded.value = !expanded.value
}
}
) {
Icon(Icons.Rounded.Info, stringResource(R.string.info))
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
} else {
navController.navigate(
"${Routes.Config.name}/${tunnel.id}"
)
} }
}) { }) {
Icon( Icon(
@@ -342,14 +574,13 @@ fun MainScreen(
) )
} }
IconButton(onClick = { IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
scope.launch { showSnackbarMessage(
viewModel.showSnackBarMessage( context.resources.getString(
context.resources.getString( R.string.turn_off_tunnel
R.string.turn_off_tunnel
)
) )
} else { )
} else {
viewModel.onDelete(tunnel) viewModel.onDelete(tunnel)
} }
}) { }) {
@@ -358,23 +589,14 @@ fun MainScreen(
stringResource(id = R.string.delete) stringResource(id = R.string.delete)
) )
} }
Switch( TunnelSwitch()
checked = (state == Tunnel.State.UP && tunnel.name == tunnelName),
onCheckedChange = { checked ->
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
}
)
} }
} else { } else {
Switch( TunnelSwitch()
checked = (state == Tunnel.State.UP && tunnel.name == tunnelName),
onCheckedChange = { checked ->
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
}
)
} }
} }
}) }
)
} }
} }
} }
@@ -7,7 +7,6 @@ import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.config.BadConfigException
import com.wireguard.config.Config import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@@ -19,32 +18,30 @@ import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.ui.ViewState
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope import java.io.InputStream
import java.util.zip.ZipInputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.InputStream import kotlinx.coroutines.withContext
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor(private val application : Application, class MainViewModel
private val tunnelRepo : TunnelConfigDao, @Inject
private val settingsRepo : SettingsDoa, constructor(
private val vpnService: VpnService private val application: Application,
private val tunnelRepo: TunnelConfigDao,
private val settingsRepo: SettingsDoa,
private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
private val _viewState = MutableStateFlow(ViewState())
val viewState get() = _viewState.asStateFlow()
val tunnels get() = tunnelRepo.getAllFlow() val tunnels get() = tunnelRepo.getAllFlow()
val state get() = vpnService.state val state get() = vpnService.state
@@ -52,6 +49,7 @@ class MainViewModel @Inject constructor(private val application : Application,
val tunnelName get() = vpnService.tunnelName val tunnelName get() = vpnService.tunnelName
private val _settings = MutableStateFlow(Settings()) private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow() val settings get() = _settings.asStateFlow()
val statistics get() = vpnService.statistics
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
@@ -64,19 +62,25 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
private fun validateWatcherServiceState(settings: Settings) { private fun validateWatcherServiceState(settings: Settings) {
val watcherState = ServiceManager.getServiceState(application.applicationContext, WireGuardConnectivityWatcherService::class.java) val watcherState =
if(settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) { ServiceManager.getServiceState(
ServiceManager.startWatcherService(application.applicationContext, settings.defaultTunnel!!) application.applicationContext,
WireGuardConnectivityWatcherService::class.java
)
if (settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) {
ServiceManager.startWatcherService(
application.applicationContext,
settings.defaultTunnel!!
)
} }
} }
fun onDelete(tunnel: TunnelConfig) {
fun onDelete(tunnel : TunnelConfig) {
viewModelScope.launch { viewModelScope.launch {
if(tunnelRepo.count() == 1L) { if (tunnelRepo.count() == 1L) {
ServiceManager.stopWatcherService(application.applicationContext) ServiceManager.stopWatcherService(application.applicationContext)
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) { if (settings.isNotEmpty()) {
val setting = settings[0] val setting = settings[0]
setting.defaultTunnel = null setting.defaultTunnel = null
setting.isAutoTunnelEnabled = false setting.isAutoTunnelEnabled = false
@@ -85,11 +89,10 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
} }
tunnelRepo.delete(tunnel) tunnelRepo.delete(tunnel)
ShortcutsManager.removeTunnelShortcuts(application.applicationContext, tunnel)
} }
} }
fun onTunnelStart(tunnelConfig : TunnelConfig) { fun onTunnelStart(tunnelConfig: TunnelConfig) {
viewModelScope.launch { viewModelScope.launch {
stopActiveTunnel() stopActiveTunnel()
startTunnel(tunnelConfig) startTunnel(tunnelConfig)
@@ -101,8 +104,11 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
private suspend fun stopActiveTunnel() { private suspend fun stopActiveTunnel() {
if(ServiceManager.getServiceState(application.applicationContext, if (ServiceManager.getServiceState(
WireGuardTunnelService::class.java, ) == ServiceState.STARTED) { application.applicationContext,
WireGuardTunnelService::class.java
) == ServiceState.STARTED
) {
onTunnelStop() onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY) delay(Constants.TOGGLE_TUNNEL_DELAY)
} }
@@ -112,37 +118,30 @@ class MainViewModel @Inject constructor(private val application : Application,
ServiceManager.stopVpnService(application.applicationContext) ServiceManager.stopVpnService(application.applicationContext)
} }
private fun validateConfigString(config : String) { private fun validateConfigString(config: String) {
if(!config.contains(application.getString(R.string.config_validation))) { TunnelConfig.configFromQuick(config)
throw WgTunnelException(application.getString(R.string.config_validation)) }
suspend fun onTunnelQrResult(result: String) {
try {
validateConfigString(result)
val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig)
} catch (e: Exception) {
throw WgTunnelException(e)
} }
} }
fun onTunnelQrResult(result : String) { private suspend fun saveTunnelConfigFromStream(
viewModelScope.launch(Dispatchers.IO) { stream: InputStream,
try { fileName: String
validateConfigString(result) ) {
val tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
addTunnel(tunnelConfig) val config = Config.parse(bufferReader)
} catch (e : WgTunnelException) { val tunnelName = getNameFromFileName(fileName)
showSnackBarMessage(e.message ?: application.getString(R.string.unknown_error_message)) addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
} withContext(Dispatchers.IO) {
}
}
private fun validateFileExtension(fileName : String) {
val extension = getFileExtensionFromFileName(fileName)
if(extension != Constants.VALID_FILE_EXTENSION) {
throw WgTunnelException(application.getString(R.string.file_extension_message))
}
}
private fun saveTunnelConfigFromStream(stream : InputStream, fileName : String) {
viewModelScope.launch(Dispatchers.IO) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
stream.close() stream.close()
} }
} }
@@ -152,45 +151,61 @@ class MainViewModel @Inject constructor(private val application : Application,
?: throw WgTunnelException(application.getString(R.string.stream_failed)) ?: throw WgTunnelException(application.getString(R.string.stream_failed))
} }
fun onTunnelFileSelected(uri : Uri) { suspend fun onTunnelFileSelected(uri: Uri) {
try { try {
val fileName = getFileName(application.applicationContext, uri) val fileName = getFileName(application.applicationContext, uri)
validateFileExtension(fileName) val fileExtension = getFileExtensionFromFileName(fileName)
val stream = getInputStreamFromUri(uri) when (fileExtension) {
saveTunnelConfigFromStream(stream, fileName) Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri)
} catch (e : Exception) { Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
showExceptionMessage(e) else -> throw WgTunnelException(
application.getString(R.string.file_extension_message)
)
}
} catch (e: Exception) {
throw WgTunnelException(e)
} }
} }
private fun showExceptionMessage(e : Exception) { private suspend fun saveTunnelsFromZipUri(uri: Uri) {
when(e) { ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
is BadConfigException -> { generateSequence { zip.nextEntry }
showSnackBarMessage(application.getString(R.string.bad_config)) .filterNot {
} it.isDirectory ||
is WgTunnelException -> { getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
showSnackBarMessage(e.message ?: application.getString(R.string.unknown_error_message)) }
} .forEach {
else -> showSnackBarMessage(application.getString(R.string.unknown_error_message)) val name = getNameFromFileName(it.name)
val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
}
}
} }
} }
private suspend fun addTunnel(tunnelConfig: TunnelConfig) { private suspend fun saveTunnelFromConfUri(
name: String,
uri: Uri
) {
val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, name)
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig) saveTunnel(tunnelConfig)
createTunnelAppShortcuts(tunnelConfig)
} }
private suspend fun saveTunnel(tunnelConfig : TunnelConfig) { private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig) tunnelRepo.save(tunnelConfig)
} }
private fun createTunnelAppShortcuts(tunnelConfig: TunnelConfig) { private fun getFileNameByCursor(
ShortcutsManager.createTunnelShortcuts(application.applicationContext, tunnelConfig) context: Context,
} uri: Uri
): String {
private fun getFileNameByCursor(context: Context, uri: Uri) : String {
val cursor = context.contentResolver.query(uri, null, null, null, null) val cursor = context.contentResolver.query(uri, null, null, null, null)
if(cursor != null) { if (cursor != null) {
cursor.use { cursor.use {
return getDisplayNameByCursor(it) return getDisplayNameByCursor(it)
} }
@@ -199,16 +214,16 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
} }
private fun getDisplayNameColumnIndex(cursor: Cursor) : Int { private fun getDisplayNameColumnIndex(cursor: Cursor): Int {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if(columnIndex == -1) { if (columnIndex == -1) {
throw WgTunnelException("Cursor out of bounds") throw WgTunnelException("Cursor out of bounds")
} }
return columnIndex return columnIndex
} }
private fun getDisplayNameByCursor(cursor: Cursor) : String { private fun getDisplayNameByCursor(cursor: Cursor): String {
if(cursor.moveToFirst()) { if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor) val index = getDisplayNameColumnIndex(cursor)
return cursor.getString(index) return cursor.getString(index)
} else { } else {
@@ -216,14 +231,16 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
} }
private fun validateUriContentScheme(uri : Uri) { private fun validateUriContentScheme(uri: Uri) {
if (uri.scheme != Constants.URI_CONTENT_SCHEME) { if (uri.scheme != Constants.URI_CONTENT_SCHEME) {
throw WgTunnelException(application.getString(R.string.file_extension_message)) throw WgTunnelException(application.getString(R.string.file_extension_message))
} }
} }
private fun getFileName(
private fun getFileName(context: Context, uri: Uri): String { context: Context,
uri: Uri
): String {
validateUriContentScheme(uri) validateUriContentScheme(uri)
return try { return try {
getFileNameByCursor(context, uri) getFileNameByCursor(context, uri)
@@ -232,38 +249,26 @@ class MainViewModel @Inject constructor(private val application : Application,
} }
} }
fun showSnackBarMessage(message : String) { private fun getNameFromFileName(fileName: String): String {
CoroutineScope(Dispatchers.IO).launch { return fileName.substring(0, fileName.lastIndexOf('.'))
_viewState.emit(_viewState.value.copy(
showSnackbarMessage = true,
snackbarMessage = message,
snackbarActionText = application.getString(R.string.okay),
onSnackbarActionClick = {
viewModelScope.launch {
dismissSnackBar()
}
}
))
delay(Constants.SNACKBAR_DELAY)
dismissSnackBar()
}
} }
private suspend fun dismissSnackBar() { private fun getFileExtensionFromFileName(fileName: String): String {
_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 { return try {
fileName.substring(fileName.lastIndexOf('.')) fileName.substring(fileName.lastIndexOf('.'))
} catch (e : Exception) { } catch (e: Exception) {
"" ""
} }
} }
suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) {
if (selectedTunnel != null) {
_settings.emit(
_settings.value.copy(
defaultTunnel = selectedTunnel.toString()
)
)
settingsRepo.save(_settings.value)
}
}
} }
@@ -5,20 +5,24 @@ import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings import android.provider.Settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
@@ -26,34 +30,30 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.rounded.LocationOff 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.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.Surface
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -63,67 +63,102 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.wireguard.android.backend.Tunnel
import com.wireguard.android.backend.WgQuickBackend
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.ui.Routes
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
ExperimentalLayoutApi::class ExperimentalLayoutApi::class,
ExperimentalComposeUiApi::class
) )
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
padding: PaddingValues, padding: PaddingValues,
navController: NavController, showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester, focusRequester: FocusRequester
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
) { ) {
val scope = rememberCoroutineScope { Dispatchers.IO }
val scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
var expanded by remember { mutableStateOf(false) }
val viewState by viewModel.viewState.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle()
val trustedSSIDs by viewModel.trustedSSIDs.collectAsStateWithLifecycle() val trustedSSIDs by viewModel.trustedSSIDs.collectAsStateWithLifecycle()
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var isLocationServicesEnabled by remember { mutableStateOf(viewModel.checkLocationServicesEnabled())} var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showAuthPrompt by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) }
val isLocationDisclosureShown by viewModel.disclosureShown.collectAsStateWithLifecycle(
null
)
val vpnState = viewModel.vpnState.collectAsStateWithLifecycle(initialValue = Tunnel.State.DOWN)
LaunchedEffect(viewState) { val screenPadding = 5.dp
if (viewState.showSnackbarMessage) { val fillMaxWidth = .85f
val result = snackbarHostState.showSnackbar(
message = viewState.snackbarMessage, fun setLocationDisclosureShown() = scope.launch {
actionLabel = viewState.snackbarActionText, viewModel.dataStoreManager.saveToDataStore(
duration = SnackbarDuration.Long, DataStoreManager.LOCATION_DISCLOSURE_SHOWN,
) true
when (result) { )
SnackbarResult.ActionPerformed -> viewState.onSnackbarActionClick }
SnackbarResult.Dismissed -> viewState.onSnackbarActionClick
fun exportAllConfigs() {
try {
val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") }
files.forEachIndexed { index, file ->
file.outputStream().use {
it.write(tunnels[index].wgQuick.toByteArray())
}
} }
FileUtils.saveFilesToZip(context, files)
didExportFiles = true
showSnackbarMessage(context.getString(R.string.exported_configs_message))
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
} }
} }
fun saveTrustedSSID() { fun saveTrustedSSID() {
if (currentText.isNotEmpty()) { if (currentText.isNotEmpty()) {
scope.launch { scope.launch {
viewModel.onSaveTrustedSSID(currentText) try {
currentText = "" viewModel.onSaveTrustedSSID(currentText)
currentText = ""
} catch (e: Exception) {
showSnackbarMessage(e.message ?: context.getString(R.string.unknown_error))
}
} }
} }
} }
fun isAllAutoTunnelPermissionsEnabled(): Boolean {
return (
isBackgroundLocationGranted &&
fineLocationState.status.isGranted &&
!viewModel.isLocationServicesNeeded()
)
}
fun openSettings() { fun openSettings() {
scope.launch { scope.launch {
val intentSettings = val intentSettings =
@@ -133,37 +168,83 @@ fun SettingsScreen(
context.startActivity(intentSettings) context.startActivity(intentSettings)
} }
} }
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundLocationState = val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
if(!backgroundLocationState.status.isGranted) { isBackgroundLocationGranted = if (!backgroundLocationState.status.isGranted) {
Column(horizontalAlignment = Alignment.CenterHorizontally, false
} else {
SideEffect {
setLocationDisclosureShown()
}
true
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
if (!fineLocationState.status.isGranted) {
isBackgroundLocationGranted = false
} else {
SideEffect {
setLocationDisclosureShown()
}
isBackgroundLocationGranted = true
}
}
if (isLocationDisclosureShown != true) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(scrollState) .verticalScroll(scrollState)
.padding(padding)) { .padding(padding)
Icon(Icons.Rounded.LocationOff, contentDescription = stringResource(id = R.string.map), modifier = Modifier ) {
.padding(30.dp) Icon(
.size(128.dp)) Icons.Rounded.LocationOff,
Text(stringResource(R.string.prominent_background_location_title), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 20.sp) contentDescription = stringResource(id = R.string.map),
Text(stringResource(R.string.prominent_background_location_message), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 15.sp) 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( Row(
modifier = if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier modifier =
.fillMaxWidth() if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
.padding(10.dp) else Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(30.dp), .padding(10.dp)
} else {
Modifier
.fillMaxWidth()
.padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
Button(onClick = { TextButton(onClick = {
navController.navigate(Routes.Main.name) setLocationDisclosureShown()
}) { }) {
Text(stringResource(id = R.string.no_thanks)) Text(stringResource(id = R.string.no_thanks))
} }
Button(modifier = Modifier.focusRequester(focusRequester), onClick = { TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
openSettings() openSettings()
setLocationDisclosureShown()
}) { }) {
Text(stringResource(id = R.string.turn_on)) Text(stringResource(id = R.string.turn_on))
} }
@@ -171,264 +252,345 @@ fun SettingsScreen(
} }
return return
} }
}
if(!fineLocationState.status.isGranted) { if (showAuthPrompt) {
Column( AuthorizationPrompt(
horizontalAlignment = Alignment.CenterHorizontally, onSuccess = {
verticalArrangement = Arrangement.Center, showAuthPrompt = false
modifier = Modifier exportAllConfigs()
.fillMaxSize() },
.padding(padding) onError = { error ->
) { showSnackbarMessage(error)
Text( showAuthPrompt = false
stringResource(id = R.string.precise_location_message), },
textAlign = TextAlign.Center, onFailure = {
modifier = Modifier.padding(15.dp), showAuthPrompt = false
fontStyle = FontStyle.Italic showSnackbarMessage(context.getString(R.string.authentication_failed))
}
) )
Button(modifier = Modifier.focusRequester(focusRequester),onClick = { }
fineLocationState.launchPermissionRequest()
}) {
Text(stringResource(id = R.string.request))
}
} if (tunnels.isEmpty()) {
return Column(
} horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
if (tunnels.isEmpty()) { modifier =
Column( Modifier
horizontalAlignment = Alignment.CenterHorizontally, .fillMaxSize()
verticalArrangement = Arrangement.Center, .padding(padding)
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 -> Text(
DropdownMenuItem( stringResource(R.string.one_tunnel_required),
onClick = { textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic
)
}
return
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier =
Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.clickable(indication = null, interactionSource = interactionSource) {
focusManager.clearFocus()
}
) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier
.fillMaxWidth(fillMaxWidth)
.padding(top = 60.dp)
}
).padding(bottom = 10.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)
) {
SectionTitle(
title = stringResource(id = R.string.auto_tunneling),
padding = screenPadding
)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_wifi),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch { scope.launch {
viewModel.onDefaultTunnelSelected(tunnel) viewModel.onToggleTunnelOnWifi()
} }
expanded = false
}, },
text = { Text(text = tunnel.name) } modifier = Modifier.focusRequester(focusRequester)
) )
AnimatedVisibility(visible = settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier = Modifier
.padding(screenPadding)
.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
trustedSSIDs.forEach { ssid ->
ClickableIconButton(
onIconClick = {
scope.launch {
viewModel.onDeleteTrustedSSID(ssid)
}
},
text = ssid,
icon = Icons.Filled.Close,
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)
)
}
if (trustedSSIDs.isEmpty()) {
Text(
stringResource(R.string.none),
fontStyle = FontStyle.Italic,
color = Color.Gray
)
}
}
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, bottom = 10.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide()
}
},
maxLines = 1,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions =
KeyboardActions(
onDone = {
saveTrustedSSID()
}
),
trailingIcon = {
if (currentText != "") {
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 = MaterialTheme.colorScheme.primary
)
}
}
}
)
}
}
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleTunnelOnMobileData()
}
}
)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleTunnelOnEthernet()
}
}
)
ConfigurationToggle(
stringResource(R.string.battery_saver),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isBatterySaverEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleBatterySaver()
}
}
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
enabled = !settings.isAlwaysOnVpnEnabled,
onClick = {
if (!isAllAutoTunnelPermissionsEnabled() && settings.isTunnelOnWifiEnabled) {
val message =
if (!isBackgroundLocationGranted) {
context.getString(R.string.background_location_required)
} else if (viewModel.isLocationServicesNeeded()) {
context.getString(R.string.location_services_required)
} else {
context.getString(R.string.precise_location_required)
}
showSnackbarMessage(message)
} else {
scope.launch {
viewModel.toggleAutoTunnel()
}
}
}
) {
val autoTunnelButtonText =
if (settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText)
}
}
} }
} }
} if (WgQuickBackend.hasKernelSupport()) {
Text( Surface(
stringResource(R.string.trusted_ssid), tonalElevation = 2.dp,
textAlign = TextAlign.Center, shadowElevation = 2.dp,
modifier = Modifier.padding(screenPadding, bottom = 5.dp, top = 5.dp) shape = RoundedCornerShape(12.dp),
) color = MaterialTheme.colorScheme.surface,
FlowRow( modifier = Modifier
modifier = Modifier.padding(screenPadding), .fillMaxWidth(fillMaxWidth)
horizontalArrangement = Arrangement.spacedBy(8.dp), .padding(vertical = 10.dp)
verticalArrangement = Arrangement.SpaceEvenly ) {
) { Column(
trustedSSIDs.forEach { ssid -> horizontalAlignment = Alignment.Start,
ClickableIconButton(onIconClick = { verticalArrangement = Arrangement.Top,
scope.launch { modifier = Modifier.padding(15.dp)
viewModel.onDeleteTrustedSSID(ssid) ) {
SectionTitle(
title = stringResource(id = R.string.kernel),
padding = screenPadding
)
ConfigurationToggle(
stringResource(R.string.use_kernel),
enabled = !(
settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled ||
(vpnState.value == Tunnel.State.UP)
),
checked = settings.isKernelEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
try {
viewModel.onToggleKernelMode()
} catch (e: WgTunnelException) {
showSnackbarMessage(e.message)
}
}
}
)
} }
}, text = ssid, icon = Icons.Filled.Close, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)) }
} }
} if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
OutlinedTextField( Surface(
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), tonalElevation = 2.dp,
value = currentText, shadowElevation = 2.dp,
onValueChange = { currentText = it }, shape = RoundedCornerShape(12.dp),
label = { Text(stringResource(R.string.add_trusted_ssid)) }, color = MaterialTheme.colorScheme.surface,
modifier = Modifier.padding(start = screenPadding, top = 5.dp), modifier = Modifier
maxLines = 1, .fillMaxWidth(fillMaxWidth).padding(vertical = 10.dp)
keyboardOptions = KeyboardOptions( .padding(bottom = 140.dp)
capitalization = KeyboardCapitalization.None, ) {
imeAction = ImeAction.Done Column(
), horizontalAlignment = Alignment.Start,
keyboardActions = KeyboardActions( verticalArrangement = Arrangement.Top,
onDone = { modifier = Modifier.padding(15.dp)
saveTrustedSSID() ) {
} SectionTitle(
), title = stringResource(id = R.string.other),
trailingIcon = { padding = screenPadding
IconButton(onClick = { saveTrustedSSID() }) { )
Icon( ConfigurationToggle(
imageVector = Icons.Outlined.Add, stringResource(R.string.always_on_vpn_support),
contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource( enabled = !settings.isAutoTunnelEnabled,
id = R.string.trusted_ssid_value_description checked = settings.isAlwaysOnVpnEnabled,
), padding = screenPadding,
tint = if(currentText == "") Color.Transparent else Color.Green onCheckChanged = {
) scope.launch {
} viewModel.onToggleAlwaysOnVPN()
}, }
) }
Row( )
modifier = Modifier ConfigurationToggle(
.fillMaxWidth() stringResource(R.string.enabled_app_shortcuts),
.padding(screenPadding), enabled = true,
verticalAlignment = Alignment.CenterVertically, checked = settings.isShortcutsEnabled,
horizontalArrangement = Arrangement.SpaceBetween padding = screenPadding,
) { onCheckChanged = {
Text(stringResource(R.string.tunnel_mobile_data)) scope.launch {
Switch( viewModel.onToggleShortcutsEnabled()
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), }
checked = settings.isTunnelOnMobileDataEnabled, }
onCheckedChange = { )
scope.launch { Row(
viewModel.onToggleTunnelOnMobileData() verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) {
TextButton(
enabled = !didExportFiles,
onClick = {
showAuthPrompt = true
}
) {
Text(stringResource(R.string.export_configs))
}
}
} }
} }
) }
} if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Row( Spacer(modifier = Modifier.weight(.17f))
modifier = Modifier }
.fillMaxWidth()
.padding(screenPadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("Tunnel on Ethernet")
Switch(
enabled = !settings.isAutoTunnelEnabled,
checked = settings.isTunnelOnEthernetEnabled,
onCheckedChange = {
scope.launch {
viewModel.onToggleTunnelOnEthernet()
}
}
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.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()
}
}
)
} }
} }
}
@@ -3,42 +3,50 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.location.LocationManager import android.location.LocationManager
import android.os.Build
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.R import com.wireguard.android.util.RootShell
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.ui.ViewState import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import timber.log.Timber
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor(private val application : Application, class SettingsViewModel
private val tunnelRepo : TunnelConfigDao, private val settingsRepo : SettingsDoa @Inject
constructor(
private val application: Application,
private val tunnelRepo: TunnelConfigDao,
private val settingsRepo: SettingsDoa,
val dataStoreManager: DataStoreManager,
private val rootShell: RootShell,
private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
private val _trustedSSIDs = MutableStateFlow(emptyList<String>()) private val _trustedSSIDs = MutableStateFlow(emptyList<String>())
val trustedSSIDs = _trustedSSIDs.asStateFlow() val trustedSSIDs = _trustedSSIDs.asStateFlow()
private val _settings = MutableStateFlow(Settings()) private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow() val settings get() = _settings.asStateFlow()
val vpnState get() = vpnService.state
val tunnels get() = tunnelRepo.getAllFlow() val tunnels get() = tunnelRepo.getAllFlow()
private val _viewState = MutableStateFlow(ViewState()) val disclosureShown = dataStoreManager.locationDisclosureFlow
val viewState get() = _viewState.asStateFlow()
init { init {
checkLocationServicesEnabled() isLocationServicesEnabled()
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect { settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect {
val settings = it.first() val settings = it.first()
@@ -47,27 +55,22 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
} }
} }
} }
suspend fun onSaveTrustedSSID(ssid: String) { suspend fun onSaveTrustedSSID(ssid: String) {
val trimmed = ssid.trim() val trimmed = ssid.trim()
if (!_settings.value.trustedNetworkSSIDs.contains(trimmed)) { if (!_settings.value.trustedNetworkSSIDs.contains(trimmed)) {
_settings.value.trustedNetworkSSIDs.add(trimmed) _settings.value.trustedNetworkSSIDs.add(trimmed)
settingsRepo.save(_settings.value) settingsRepo.save(_settings.value)
} else { } else {
showSnackBarMessage("SSID already exists.") throw WgTunnelException("SSID already exists.")
} }
} }
suspend fun onDefaultTunnelSelected(tunnelConfig: TunnelConfig) {
settingsRepo.save(_settings.value.copy(
defaultTunnel = tunnelConfig.toString()
))
}
suspend fun onToggleTunnelOnMobileData() { suspend fun onToggleTunnelOnMobileData() {
settingsRepo.save(_settings.value.copy( settingsRepo.save(
isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled _settings.value.copy(
)) isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled
)
)
} }
suspend fun onDeleteTrustedSSID(ssid: String) { suspend fun onDeleteTrustedSSID(ssid: String) {
@@ -75,68 +78,120 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
settingsRepo.save(_settings.value) settingsRepo.save(_settings.value)
} }
suspend fun toggleAutoTunnel() { private fun emitFirstTunnelAsDefault() =
if(_settings.value.defaultTunnel.isNullOrEmpty() && !_settings.value.isAutoTunnelEnabled) { viewModelScope.async {
showSnackBarMessage(application.getString(R.string.select_tunnel_message)) _settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString()))
return
} }
if(_settings.value.isAutoTunnelEnabled) {
suspend fun toggleAutoTunnel() {
if (_settings.value.isAutoTunnelEnabled) {
ServiceManager.stopWatcherService(application) ServiceManager.stopWatcherService(application)
} else { } else {
if(_settings.value.defaultTunnel != null) { if (_settings.value.defaultTunnel == null) {
val defaultTunnel = _settings.value.defaultTunnel emitFirstTunnelAsDefault().await()
ServiceManager.startWatcherService(application, defaultTunnel!!)
} }
val defaultTunnel = _settings.value.defaultTunnel
ServiceManager.startWatcherService(application, defaultTunnel!!)
} }
settingsRepo.save(_settings.value.copy( settingsRepo.save(
isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled _settings.value.copy(
)) isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled
)
)
} }
suspend fun showSnackBarMessage(message : String) { private suspend fun getFirstTunnelConfig(): TunnelConfig {
_viewState.emit(_viewState.value.copy( return tunnelRepo.getAll().first()
showSnackbarMessage = true,
snackbarMessage = message,
snackbarActionText = "Okay",
onSnackbarActionClick = {
viewModelScope.launch {
dismissSnackBar()
}
}
))
}
private suspend fun dismissSnackBar() {
_viewState.emit(_viewState.value.copy(
showSnackbarMessage = false
))
} }
suspend fun onToggleAlwaysOnVPN() { suspend fun onToggleAlwaysOnVPN() {
if(_settings.value.defaultTunnel != null) { if (_settings.value.defaultTunnel == null) {
_settings.emit( emitFirstTunnelAsDefault().await()
_settings.value.copy(isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled)
)
settingsRepo.save(_settings.value)
} else {
showSnackBarMessage(application.getString(R.string.select_tunnel_message))
} }
val updatedSettings =
_settings.value.copy(
isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled
)
emitSettings(updatedSettings)
saveSettings(updatedSettings)
}
private suspend fun emitSettings(settings: Settings) {
_settings.emit(
settings
)
}
private suspend fun saveSettings(settings: Settings) {
settingsRepo.save(settings)
} }
suspend fun onToggleTunnelOnEthernet() { suspend fun onToggleTunnelOnEthernet() {
if(_settings.value.defaultTunnel != null) { if (_settings.value.defaultTunnel == null) {
_settings.emit( emitFirstTunnelAsDefault().await()
_settings.value.copy(isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled)
)
settingsRepo.save(_settings.value)
} else {
showSnackBarMessage(application.getString(R.string.select_tunnel_message))
} }
_settings.emit(
_settings.value.copy(
isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled
)
)
settingsRepo.save(_settings.value)
} }
fun checkLocationServicesEnabled() : Boolean { private fun isLocationServicesEnabled(): Boolean {
val locationManager = val locationManager =
application.getSystemService(Context.LOCATION_SERVICE) as LocationManager application.getSystemService(Context.LOCATION_SERVICE) as LocationManager
return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER) return locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)
} }
fun isLocationServicesNeeded(): Boolean {
return (!isLocationServicesEnabled() && Build.VERSION.SDK_INT > Build.VERSION_CODES.P)
}
suspend fun onToggleShortcutsEnabled() {
settingsRepo.save(
_settings.value.copy(
isShortcutsEnabled = !_settings.value.isShortcutsEnabled
)
)
}
suspend fun onToggleBatterySaver() {
settingsRepo.save(
_settings.value.copy(
isBatterySaverEnabled = !_settings.value.isBatterySaverEnabled
)
)
}
private suspend fun saveKernelMode(on: Boolean) {
settingsRepo.save(
_settings.value.copy(
isKernelEnabled = on
)
)
}
suspend fun onToggleKernelMode() {
if (!_settings.value.isKernelEnabled) {
try {
rootShell.start()
Timber.d("Root shell accepted!")
saveKernelMode(on = true)
} catch (e: RootShell.RootShellException) {
saveKernelMode(on = false)
throw WgTunnelException("Root shell denied!")
}
} else {
saveKernelMode(on = false)
}
}
suspend fun onToggleTunnelOnWifi() {
settingsRepo.save(
_settings.value.copy(
isTunnelOnWifiEnabled = !_settings.value.isTunnelOnWifiEnabled
)
)
}
} }
@@ -1,26 +1,36 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support package com.zaneschepke.wireguardautotunnel.ui.screens.support
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.Intent.createChooser
import android.net.Uri import android.net.Uri
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowForward
import androidx.compose.material.icons.rounded.Book
import androidx.compose.material.icons.rounded.Mail
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
@@ -34,12 +44,25 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat.startActivity
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsViewModel
@Composable @Composable
fun SupportScreen(padding : PaddingValues, focusRequester: FocusRequester) { fun SupportScreen(
viewModel: SettingsViewModel = hiltViewModel(),
padding: PaddingValues,
focusRequester: FocusRequester
) {
val context = LocalContext.current val context = LocalContext.current
val fillMaxWidth = .85f
val settings by viewModel.settings.collectAsStateWithLifecycle()
fun openWebPage(url: String) { fun openWebPage(url: String) {
val webpage: Uri = Uri.parse(url) val webpage: Uri = Uri.parse(url)
@@ -47,37 +70,175 @@ fun SupportScreen(padding : PaddingValues, focusRequester: FocusRequester) {
context.startActivity(intent) context.startActivity(intent)
} }
Column(horizontalAlignment = Alignment.CenterHorizontally, fun launchEmail() {
verticalArrangement = Arrangement.Top, val intent =
modifier = Modifier Intent(Intent.ACTION_SEND).apply {
.fillMaxSize() type = Constants.EMAIL_MIME_TYPE
.verticalScroll(rememberScrollState()) putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email))
.focusable() putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject))
.padding(padding)) { }
Text(stringResource(R.string.support_text), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 15.sp) startActivity(
Row( context,
modifier = Modifier createChooser(intent, context.getString(R.string.email_chooser)),
.fillMaxWidth() null
.padding(14.dp), )
verticalAlignment = Alignment.CenterVertically, }
horizontalArrangement = Arrangement.SpaceEvenly
) { Column(
IconButton(onClick = { horizontalAlignment = Alignment.CenterHorizontally,
openWebPage(context.resources.getString(R.string.discord_url)) verticalArrangement = Arrangement.Top,
}) { modifier =
Icon(imageVector = ImageVector.vectorResource(R.drawable.discord), "Discord") Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.focusable()
.padding(padding)
) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier
.fillMaxWidth(fillMaxWidth)
.padding(top = 20.dp)
}
).padding(bottom = 25.dp)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
stringResource(R.string.thank_you),
textAlign = TextAlign.Start,
modifier = Modifier.padding(bottom = 20.dp),
fontSize = 16.sp
)
Text(
stringResource(id = R.string.support_help_text),
textAlign = TextAlign.Start,
fontSize = 16.sp,
modifier = Modifier.padding(bottom = 20.dp)
)
TextButton(onClick = {
openWebPage(context.resources.getString(R.string.docs_url))
}, modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row {
Icon(Icons.Rounded.Book, stringResource(id = R.string.docs))
Text(
stringResource(id = R.string.docs_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp)
)
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
} }
IconButton(modifier = Modifier.focusRequester(focusRequester),onClick = { Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
openWebPage(context.resources.getString(R.string.github_url)) TextButton(
}) { onClick = { openWebPage(context.resources.getString(R.string.discord_url)) },
Icon(imageVector = ImageVector.vectorResource(R.drawable.github), "Github") modifier = Modifier.padding(vertical = 5.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.discord),
stringResource(
id = R.string.discord
),
Modifier.size(25.dp)
)
Text(
stringResource(id = R.string.discord_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp)
)
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { openWebPage(context.resources.getString(R.string.github_url)) },
modifier = Modifier.padding(vertical = 5.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.github),
stringResource(
id = R.string.github
),
Modifier.size(25.dp)
)
Text(
"Open an issue",
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp)
)
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { launchEmail() },
modifier = Modifier.padding(vertical = 5.dp)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Row {
Icon(Icons.Rounded.Mail, stringResource(id = R.string.email))
Text(
stringResource(id = R.string.email_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp)
)
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
} }
} }
Spacer(modifier = Modifier.weight(1f)) }
Text(stringResource(id = R.string.privacy_policy), style = TextStyle(textDecoration = TextDecoration.Underline), Spacer(modifier = Modifier.weight(1f))
modifier = Modifier.clickable { Text(
stringResource(id = R.string.privacy_policy),
style = TextStyle(textDecoration = TextDecoration.Underline),
fontSize = 16.sp,
modifier =
Modifier.clickable {
openWebPage(context.resources.getString(R.string.privacy_policy_url)) openWebPage(context.resources.getString(R.string.privacy_policy_url))
}) }
Text("App version: ${com.zaneschepke.wireguardautotunnel.BuildConfig.VERSION_NAME}", Modifier.padding(25.dp)) )
Row(
horizontalArrangement = Arrangement.spacedBy(25.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(25.dp)
) {
Text("Version: ${BuildConfig.VERSION_NAME}")
Text("Mode: ${if (settings.isKernelEnabled) "Kernel" else "Userspace" }")
} }
} }
}
@@ -0,0 +1,25 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.support
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class SupportViewModel @Inject constructor(
private val settingsRepo: SettingsDoa
) : ViewModel() {
private val _settings = MutableStateFlow(Settings())
val settings get() = _settings.asStateFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
_settings.value = settingsRepo.getAll().first()
}
}
}
@@ -11,7 +11,8 @@ val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71) val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFFFFFFFF) val Pink40 = Color(0xFFFFFFFF)
//status colors // status colors
val brickRed = Color(0xFFCE4257) val brickRed = Color(0xFFCE4257)
val corn = Color(0xFFFBEC5D)
val pinkRed = Color(0xFFEF476F) val pinkRed = Color(0xFFEF476F)
val mint = Color(0xFF52B788) val mint = Color(0xFF52B788)
@@ -15,51 +15,52 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
private val DarkColorScheme = darkColorScheme( private val DarkColorScheme =
//primary = Purple80, darkColorScheme(
primary = virdigris, // primary = Purple80,
secondary = virdigris, primary = virdigris,
// secondary = PurpleGrey80, secondary = virdigris,
tertiary = virdigris // secondary = PurpleGrey80,
//tertiary = Pink80 tertiary = virdigris
) // tertiary = Pink80
)
private val LightColorScheme = lightColorScheme( private val LightColorScheme =
primary = Purple40, lightColorScheme(
secondary = PurpleGrey40, primary = Purple40,
tertiary = Pink40 secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override /* Other default colors to override
background = Color(0xFFFFFBFE), background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE), surface = Color(0xFFFFFBFE),
onPrimary = Color.White, onPrimary = Color.White,
onSecondary = Color.White, onSecondary = Color.White,
onTertiary = Color.White, onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F), onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F), onSurface = Color(0xFF1C1B1F),
*/ */
) )
@Composable @Composable
fun WireguardAutoTunnelTheme( fun WireguardAutoTunnelTheme(
//force dark theme // force dark theme
darkTheme : Boolean = true, darkTheme: Boolean = true,
//darkTheme: Boolean = isSystemInDarkTheme(), // darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ // Dynamic color is available on Android 12+
//turning off dynamic color for now // turning off dynamic color for now
dynamicColor: Boolean = false, dynamicColor: Boolean = false,
content: @Composable () -> Unit 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)
}
val colorScheme = when { darkTheme -> DarkColorScheme
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { else -> LightColorScheme
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
@@ -7,28 +7,30 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with // Set of Material typography styles to start with
val Typography = Typography( val Typography =
bodyLarge = TextStyle( Typography(
fontFamily = FontFamily.Default, bodyLarge =
fontWeight = FontWeight.Normal, TextStyle(
fontSize = 16.sp, fontFamily = FontFamily.Default,
lineHeight = 24.sp, fontWeight = FontWeight.Normal,
letterSpacing = 0.5.sp 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
)
*/
) )
/* 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
)
*/
)
@@ -0,0 +1,66 @@
package com.zaneschepke.wireguardautotunnel.util
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.provider.MediaStore.MediaColumns
import com.zaneschepke.wireguardautotunnel.Constants
import java.io.File
import java.io.OutputStream
import java.time.Instant
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
object FileUtils {
private const val ZIP_FILE_MIME_TYPE = "application/zip"
private fun createDownloadsFileOutputStream(
context: Context,
fileName: String,
mimeType: String = Constants.ALLOWED_FILE_TYPES
): OutputStream? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val contentValues =
ContentValues().apply {
put(MediaColumns.DISPLAY_NAME, fileName)
put(MediaColumns.MIME_TYPE, mimeType)
put(MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
return resolver.openOutputStream(uri)
}
} else {
val target =
File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName
)
return target.outputStream()
}
return null
}
fun saveFilesToZip(
context: Context,
files: List<File>
) {
val zipOutputStream = createDownloadsFileOutputStream(
context,
"wg-export_${Instant.now().epochSecond}.zip",
ZIP_FILE_MIME_TYPE
)
ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file ->
val entry = ZipEntry(file.name)
zos.putNextEntry(entry)
if (file.isFile) {
file.inputStream().use { fis -> fis.copyTo(zos) }
}
}
}
}
}
@@ -1,29 +1,33 @@
package com.zaneschepke.wireguardautotunnel.util package com.zaneschepke.wireguardautotunnel.util
import java.math.BigDecimal import java.math.BigDecimal
import java.text.DecimalFormat
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import kotlin.math.pow
object NumberUtils { object NumberUtils {
private const val BYTES_IN_KB = 1024.0
private val BYTES_IN_MB = BYTES_IN_KB.pow(2.0)
private val keyValidationRegex = """^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=${'$'}""".toRegex()
private const val BYTES_IN_KB = 1024L fun bytesToMB(bytes: Long): BigDecimal {
return bytes.toBigDecimal().divide(BYTES_IN_MB.toBigDecimal())
fun bytesToKB(bytes : Long) : BigDecimal {
return bytes.toBigDecimal().divide(BYTES_IN_KB.toBigDecimal())
} }
fun generateRandomTunnelName() : String { fun isValidKey(key: String): Boolean {
return key.matches(keyValidationRegex)
}
fun generateRandomTunnelName(): String {
return "tunnel${(Math.random() * 100000).toInt()}" return "tunnel${(Math.random() * 100000).toInt()}"
} }
fun formatDecimalTwoPlaces(bigDecimal: BigDecimal) : String { fun getSecondsBetweenTimestampAndNow(epoch: Long): Long? {
val df = DecimalFormat("#.##") return if (epoch != 0L) {
return df.format(bigDecimal) val time = Instant.ofEpochMilli(epoch)
} return Duration.between(time, Instant.now()).seconds
} else {
fun getSecondsBetweenTimestampAndNow(epoch : Long) : Long { null
val time = Instant.ofEpochMilli(epoch) }
return Duration.between(time, Instant.now()).seconds
} }
} }
@@ -1,3 +1,16 @@
package com.zaneschepke.wireguardautotunnel.util package com.zaneschepke.wireguardautotunnel.util
class WgTunnelException(message: String) : Exception(message) import com.wireguard.config.BadConfigException
class WgTunnelException(e: Exception) : Exception() {
constructor(message: String) : this(Exception(message))
override val message: String = generateExceptionMessage(e)
private fun generateExceptionMessage(e: Exception): String {
return when (e) {
is BadConfigException -> "${e.section.name} ${e.location.name} ${e.reason.name}"
else -> e.message ?: "Unknown error occurred"
}
}
}
+71 -13
View File
@@ -6,9 +6,10 @@
<string name="watcher_channel_id">Watcher Channel</string> <string name="watcher_channel_id">Watcher Channel</string>
<string name="watcher_channel_name">Watcher Notification Channel</string> <string name="watcher_channel_name">Watcher Notification Channel</string>
<string name="foreground_file">FOREGROUND_FILE</string> <string name="foreground_file">FOREGROUND_FILE</string>
<string name="github_url">https://github.com/zaneschepke/wgtunnel</string> <string name="github_url">https://github.com/zaneschepke/wgtunnel/issues</string>
<string name="privacy_policy_url">https://zaneschepke.github.io/wgtunnel/</string> <string name="docs_url">https://zaneschepke.com/wgtunnel-docs/overview.html</string>
<string name="file_extension_message">File is not a .conf file</string> <string name="privacy_policy_url">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string>
<string name="file_extension_message">File is not a .conf or .zip</string>
<string name="turn_off_tunnel">Turn off tunnel before editing</string> <string name="turn_off_tunnel">Turn off tunnel before editing</string>
<string name="no_tunnels">No tunnels added yet!</string> <string name="no_tunnels">No tunnels added yet!</string>
<string name="tunnel_exists">Tunnel name already exists</string> <string name="tunnel_exists">Tunnel name already exists</string>
@@ -20,11 +21,10 @@
<string name="vpn_permission_required">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.</string> <string name="vpn_permission_required">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.</string>
<string name="notification_permission_required">Notifications permission is required for the app to work properly.</string> <string name="notification_permission_required">Notifications permission is required for the app to work properly.</string>
<string name="open_settings">Open Settings</string> <string name="open_settings">Open Settings</string>
<string name="add_trusted_ssid">Add Trusted SSID</string> <string name="add_trusted_ssid">Add trusted wifi name</string>
<string name="trusted_ssid">Trusted SSID</string>
<string name="tunnels">Tunnels</string> <string name="tunnels">Tunnels</string>
<string name="select_tunnel">Select Tunnel</string> <string name="enable_auto_tunnel">Start auto-tunneling</string>
<string name="enable_auto_tunnel">Enable auto tunneling</string> <string name="disable_auto_tunnel">Stop auto-tunneling</string>
<string name="tunnel_mobile_data">Tunnel on mobile data</string> <string name="tunnel_mobile_data">Tunnel on mobile data</string>
<string name="background_location_reason">\"Allow all the time\" location permission is required for retrieving Wi-Fi SSID in the background. Permission is needed for this feature.</string> <string name="background_location_reason">\"Allow all the time\" location permission is required for retrieving Wi-Fi SSID in the background. Permission is needed for this feature.</string>
<string name="location_permission_reason">Location permission is required for this feature to work properly.</string> <string name="location_permission_reason">Location permission is required for this feature to work properly.</string>
@@ -32,15 +32,16 @@
<string name="retry">"Retry"</string> <string name="retry">"Retry"</string>
<string name="privacy_policy">View Privacy Policy</string> <string name="privacy_policy">View Privacy Policy</string>
<string name="okay">Okay</string> <string name="okay">Okay</string>
<string name="tunnel_on_ethernet">Tunnel on ethernet</string>
<string name="prominent_background_location_message">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.</string> <string name="prominent_background_location_message">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.</string>
<string name="prominent_background_location_title">Background Location Disclosure</string> <string name="prominent_background_location_title">Background Location Disclosure</string>
<string name="support_text">Thank you for using WG Tunnel! If you are experiencing issues with the app, please reach out on Discord or create an issue on Github. I will try to address the issue as quickly as possible. Thank you!</string> <string name="thank_you">Thank you for using WG Tunnel!</string>
<string name="trusted_ssid_empty_description">Enter SSID</string> <string name="trusted_ssid_empty_description">Enter SSID</string>
<string name="trusted_ssid_value_description">Submit SSID</string> <string name="trusted_ssid_value_description">Submit SSID</string>
<string name="config_validation">[Interface]</string> <string name="config_validation">[Interface]</string>
<string name="add_from_file">Add tunnel from files</string> <string name="add_tunnels_text">Add from file or zip</string>
<string name="open_file">File Open</string> <string name="open_file">File Open</string>
<string name="add_from_qr">Add tunnel from QR code</string> <string name="add_from_qr">Add from QR code</string>
<string name="qr_scan">QR Scan</string> <string name="qr_scan">QR Scan</string>
<string name="tunnel_edit">Tunnel Edit</string> <string name="tunnel_edit">Tunnel Edit</string>
<string name="tunnel_name">Tunnel Name</string> <string name="tunnel_name">Tunnel Name</string>
@@ -51,7 +52,7 @@
<string name="include">Include</string> <string name="include">Include</string>
<string name="tunnel_all">Tunnel all applications</string> <string name="tunnel_all">Tunnel all applications</string>
<string name="config_changes_saved">Configuration changes saved.</string> <string name="config_changes_saved">Configuration changes saved.</string>
<string name="save_changes">Save changes</string> <string name="save_changes">Save</string>
<string name="icon">Icon</string> <string name="icon">Icon</string>
<string name="no_thanks">No thanks</string> <string name="no_thanks">No thanks</string>
<string name="turn_on">Turn on</string> <string name="turn_on">Turn on</string>
@@ -75,7 +76,7 @@
<string name="failed_connection_to">Failed connection to -</string> <string name="failed_connection_to">Failed connection to -</string>
<string name="initial_connection_failure_message">Attempting to connect to server after 30 seconds of no response.</string> <string name="initial_connection_failure_message">Attempting to connect to server after 30 seconds of no response.</string>
<string name="lost_connection_failure_message">Attempting to reconnect to server after more than one minute of no response.</string> <string name="lost_connection_failure_message">Attempting to reconnect to server after more than one minute of no response.</string>
<string name="always_on_vpn_support">Enable Always-On VPN support</string> <string name="always_on_vpn_support">Allow Always-On VPN </string>
<string name="select_tunnel_message">Please select a tunnel first</string> <string name="select_tunnel_message">Please select a tunnel first</string>
<string name="location_services_not_detected">Unable to detect Location Services which are required for this feature. Please enable Location Services.</string> <string name="location_services_not_detected">Unable to detect Location Services which are required for this feature. Please enable Location Services.</string>
<string name="check_again">Check again</string> <string name="check_again">Check again</string>
@@ -92,8 +93,65 @@
<string name="db_name">wg-tunnel-db</string> <string name="db_name">wg-tunnel-db</string>
<string name="scanning_qr">Scanning for QR</string> <string name="scanning_qr">Scanning for QR</string>
<string name="qr_result_failed">QR scan failed</string> <string name="qr_result_failed">QR scan failed</string>
<string name="none">None</string> <string name="none">No trusted wifi names</string>
<string name="never">Never</string> <string name="never">Never</string>
<string name="stream_failed">Failed to open file stream.</string> <string name="stream_failed">Failed to open file stream.</string>
<string name="unknown_error_message">An unknown error occurred.</string> <string name="unknown_error_message">An unknown error occurred.</string>
<string name="no_file_app">No file app installed.</string>
<string name="other">Other</string>
<string name="auto_tunneling">Auto-tunneling</string>
<string name="select_tunnel">Select tunnel to use</string>
<string name="vpn_on">VPN on</string>
<string name="vpn_off">VPN off</string>
<string name="default_vpn_on">Primary VPN on</string>
<string name="default_vpn_off">Primary VPN off</string>
<string name="create_import">Create from scratch</string>
<string name="set_primary">Set primary</string>
<string name="turn_off_auto">Action requires auto-tunnel disabled</string>
<string name="add_peer">Add peer</string>
<string name="info">Info</string>
<string name="done">Done</string>
<string name="interface_">Interface</string>
<string name="rotate_keys">Rotate keys</string>
<string name="private_key">Private key</string>
<string name="copy_public_key">Copy public key</string>
<string name="base64_key">base64 key</string>
<string name="comma_separated_list">comma separated list</string>
<string name="listen_port">Listen port</string>
<string name="random">(random)</string>
<string name="auto">(auto)</string>
<string name="optional">(optional)</string>
<string name="optional_no_recommend">(optional, not recommended)</string>
<string name="preshared_key">Pre-shared key</string>
<string name="seconds">seconds</string>
<string name="persistent_keepalive">Persistent keepalive</string>
<string name="cancel">Cancel</string>
<string name="primary_tunnel_change">Primary tunnel change</string>
<string name="primary_tunnel_change_question">Would you like to make this your primary tunnel?</string>
<string name="authentication_failed">Authentication failed</string>
<string name="enabled_app_shortcuts">Enable app shortcuts</string>
<string name="export_configs">Export configs</string>
<string name="battery_saver">Battery saver (beta)</string>
<string name="location_services_required">Location services required</string>
<string name="background_location_required">Background location required</string>
<string name="precise_location_required">Precise location required</string>
<string name="unknown_error">Unknown error occurred</string>
<string name="exported_configs_message">Exported configs to downloads</string>
<string name="no_file_explorer">No file explorer installed</string>
<string name="status">status</string>
<string name="tunnel_on_wifi">Tunnel on untrusted wifi</string>
<string name="my_email">zanecschepke@gmail.com</string>
<string name="email_subject">WG Tunnel Support</string>
<string name="email_chooser">Send an email…</string>
<string name="go">go</string>
<string name="docs_description">Read the docs (WIP)</string>
<string name="discord_description">Join the community</string>
<string name="discord">Discord</string>
<string name="docs">Docs</string>
<string name="github">GitHub</string>
<string name="email">Email</string>
<string name="email_description">Send me an email</string>
<string name="support_help_text">If you are experiencing issues, have improvement ideas, or just want to engage, the following resources are available:</string>
<string name="kernel">Kernel</string>
<string name="use_kernel">Use kernel module</string>
</resources> </resources>
+32
View File
@@ -0,0 +1,32 @@
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="defaultOn1"
android:enabled="true"
android:icon="@drawable/vpn_on"
android:shortcutShortLabel="@string/vpn_on"
android:shortcutLongLabel="@string/default_vpn_on"
android:shortcutDisabledMessage="@string/vpn_on">
<intent
android:action="START"
android:targetPackage="com.zaneschepke.wireguardautotunnel"
android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity">
<extra android:name="className" android:value="WireGuardTunnelService" />
</intent>
<capability-binding android:key="actions.intent.START" />
</shortcut>
<shortcut
android:shortcutId="defaultOff1"
android:enabled="true"
android:icon="@drawable/vpn_off"
android:shortcutShortLabel="@string/vpn_off"
android:shortcutLongLabel="@string/default_vpn_off"
android:shortcutDisabledMessage="@string/vpn_off">
<intent
android:action="STOP"
android:targetPackage="com.zaneschepke.wireguardautotunnel"
android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity">
<extra android:name="className" android:value="WireGuardTunnelService" />
</intent>
<capability-binding android:key="actions.intent.STOP" />
</shortcut>
</shortcuts>
@@ -1,9 +1,8 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.Assert.*
/** /**
* Example local unit test, which will execute on the development machine (host). * Example local unit test, which will execute on the development machine (host).
* *
Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 0 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 0 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 0 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 0 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 0 B

Some files were not shown because too many files have changed in this diff Show More