Compare commits

..

3 Commits

Author SHA1 Message Date
Zane Schepke b3cb7a7988 docs: update README support screenshot 2023-07-23 00:39:30 -04:00
Zane Schepke 6415f49377 chore: add feature graphic and banner to assets 2023-07-23 00:34:17 -04:00
Zane Schepke baed8ff2e7 feat: support for Always-On VPN and Android TV
Added support for Android TV

Added support for Always-On VPN in settings

Fixes bug where handshake notification is not dismissed after successful handshake

Closes #2,  Closes #5
2023-07-23 00:16:16 -04:00
131 changed files with 1926 additions and 4473 deletions
-31
View File
@@ -1,31 +0,0 @@
---
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
@@ -1,20 +0,0 @@
---
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
@@ -1,17 +0,0 @@
# 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
@@ -1,87 +0,0 @@
# 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/32300.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,5 +69,3 @@ 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
@@ -1,3 +0,0 @@
source "https://rubygems.org"
gem "fastlane"
+15 -28
View File
@@ -2,64 +2,51 @@
WG Tunnel WG Tunnel
</h1> </h1>
<div align="center"> <span 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)
</div> </span>
<div align="center"> <span 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)
[![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>
</div> <span align="left">
<div align="center">
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/N4N8NMJN2)
</div>
<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.
</div> </span>
<div align="center"> <span 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>
<div align="left"> <span align="left">
## Inspiration ## Inspiration
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. 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.
## Features ## Features
* Add tunnels via .conf file, zip, manual entry, or QR code * Add tunnels via .conf file
* 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
* Always-on VPN for Android support * Always-on VPN for Android support
* Export tunnels to zip * Configurable Trusted Network list
* Quick tile support for VPN toggling * Optional auto connect on mobile data
* Static shortcuts support for primary tunnel for automation integration
* 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
@@ -69,7 +56,7 @@ The original inspiration for this app came from the inconvenience of having to m
``` ```
$ git clone https://github.com/zaneschepke/wgtunnel $ git clone https://github.com/zaneschepke/wgtunnel
$ cd wgtunnel $ cd wgtunnel
$ ./gradlew assembleDebug $ ./gradlew assembleRelease
``` ```
</span> </span>
+68 -128
View File
@@ -1,29 +1,31 @@
import java.util.Properties val rExtra = rootProject.extra
plugins { plugins {
alias(libs.plugins.android.application) id("com.android.application")
alias(libs.plugins.kotlin.android) id("org.jetbrains.kotlin.android")
alias(libs.plugins.hilt.android) kotlin("kapt")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
id("org.jetbrains.kotlin.plugin.serialization") id("org.jetbrains.kotlin.plugin.serialization")
alias(libs.plugins.ksp) id("io.objectbox")
} }
android { android {
namespace = Constants.APP_ID namespace = "com.zaneschepke.wireguardautotunnel"
compileSdk = Constants.TARGET_SDK compileSdk = 34
val versionMajor = 2
val versionMinor = 2
val versionPatch = 0
val versionBuild = 0
defaultConfig { defaultConfig {
applicationId = Constants.APP_ID applicationId = "com.zaneschepke.wireguardautotunnel"
minSdk = Constants.MIN_SDK minSdk = 29
targetSdk = Constants.TARGET_SDK targetSdk = 34
versionCode = Constants.VERSION_CODE versionCode = versionMajor * 10000 + versionMinor * 1000 + versionPatch * 100 + versionBuild
versionName = Constants.VERSION_NAME versionName = "${versionMajor}.${versionMinor}.${versionPatch}"
ksp {
arg("room.schemaLocation", "$projectDir/schemas")
}
resourceConfigurations.addAll(listOf("en"))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -31,82 +33,28 @@ 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 = false
isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
) )
signingConfig = signingConfigs.getByName(Constants.RELEASE)
}
debug {
isDebuggable = true
}
}
flavorDimensions.add(Constants.TYPE)
productFlavors {
create("fdroid") {
dimension = Constants.TYPE
proguardFile("fdroid-rules.pro")
}
create("general") {
dimension = Constants.TYPE
if (BuildHelper.isReleaseBuild(gradle) && BuildHelper.isGeneralFlavor(gradle))
{
apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics")
}
} }
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
jvmTarget = Constants.JVM_TARGET jvmTarget = "17"
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
composeOptions { composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() kotlinCompilerExtensionVersion = "1.4.8"
} }
packaging { packaging {
resources { resources {
@@ -115,79 +63,71 @@ android {
} }
} }
val generalImplementation by configurations
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation("androidx.core:core-ktx:1.10.1")
implementation(libs.androidx.lifecycle.runtime.ktx) implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
// optional - helpers for implementing LifecycleOwner in a Service implementation("androidx.activity:activity-compose:1.7.2")
implementation(libs.androidx.lifecycle.service) implementation(platform("androidx.compose:compose-bom:2023.03.00"))
implementation(libs.androidx.activity.compose) implementation("androidx.compose.ui:ui")
implementation(platform(libs.androidx.compose.bom)) implementation("androidx.compose.ui:ui-graphics")
implementation(libs.androidx.compose.ui) implementation("androidx.compose.ui:ui-tooling-preview")
implementation(libs.androidx.compose.ui.graphics) implementation("androidx.compose.material3:material3:1.1.1")
implementation(libs.androidx.compose.ui.tooling.preview) implementation("androidx.appcompat:appcompat:1.6.1")
implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat)
//test testImplementation("junit:junit:4.13.2")
testImplementation(libs.junit) androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation(libs.androidx.junit) androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00"))
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation(libs.androidx.compose.ui.test) debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation("androidx.compose.ui:ui-test-manifest")
debugImplementation(libs.androidx.compose.manifest)
//wg //wireguard tunnel
implementation(libs.tunnel) implementation("com.wireguard.android:tunnel:1.0.20230427")
coreLibraryDesugaring(libs.desugar.jdk.libs)
//logging //logging
implementation(libs.timber) implementation("com.jakewharton.timber:timber:5.0.1")
// compose navigation // compose navigation
implementation(libs.androidx.navigation.compose) implementation("androidx.navigation:navigation-compose:2.6.0")
implementation(libs.androidx.hilt.navigation.compose) implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
// hilt // hilt
implementation(libs.hilt.android) implementation("com.google.dagger:hilt-android:${rExtra.get("hiltVersion")}")
ksp(libs.hilt.android.compiler) kapt("com.google.dagger:hilt-android-compiler:${rExtra.get("hiltVersion")}")
//accompanist //accompanist
implementation(libs.accompanist.systemuicontroller) implementation("com.google.accompanist:accompanist-systemuicontroller:${rExtra.get("accompanistVersion")}")
implementation(libs.accompanist.permissions) implementation("com.google.accompanist:accompanist-permissions:${rExtra.get("accompanistVersion")}")
implementation(libs.accompanist.flowlayout) implementation("com.google.accompanist:accompanist-flowlayout:${rExtra.get("accompanistVersion")}")
implementation(libs.accompanist.drawablepainter) implementation("com.google.accompanist:accompanist-navigation-animation:${rExtra.get("accompanistVersion")}")
implementation("com.google.accompanist:accompanist-drawablepainter:${rExtra.get("accompanistVersion")}")
//room //db
implementation(libs.androidx.room.runtime) implementation("io.objectbox:objectbox-kotlin:${rExtra.get("objectBoxVersion")}")
ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx)
//lifecycle //lifecycle
implementation(libs.lifecycle.runtime.compose) implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process)
//icons //icons
implementation(libs.material.icons.extended) implementation("androidx.compose.material:material-icons-extended:1.4.3")
//serialization
implementation(libs.kotlinx.serialization.json)
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
//firebase crashlytics //firebase crashlytics
generalImplementation(platform(libs.firebase.bom)) implementation(platform("com.google.firebase:firebase-bom:32.0.0"))
generalImplementation(libs.google.firebase.crashlytics.ktx) implementation("com.google.firebase:firebase-crashlytics-ktx")
generalImplementation(libs.google.firebase.analytics.ktx) implementation("com.google.firebase:firebase-analytics-ktx")
//barcode scanning //barcode scanning
implementation(libs.zxing.android.embedded) implementation("com.google.android.gms:play-services-code-scanner:16.0.0")
implementation(libs.zxing.core)
//bio
implementation(libs.androidx.biometric.ktx)
//shortcuts }
implementation(libs.androidx.core)
implementation(libs.androidx.core.google.shortcuts) kapt {
correctErrorTypes = true
} }
-1
View File
@@ -1 +0,0 @@
-dontwarn com.google.errorprone.annotations.**
+99
View File
@@ -0,0 +1,99 @@
{
"_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.",
"_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.",
"_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.",
"entities": [
{
"id": "1:2692736974585027589",
"lastPropertyId": "15:5057486545428188436",
"name": "TunnelConfig",
"properties": [
{
"id": "1:1985347930017457084",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "12:2409068226744965585",
"name": "name",
"indexId": "1:4811206443952699137",
"type": 9,
"flags": 34848
},
{
"id": "13:8987443291286312275",
"name": "wgQuick",
"type": 9
}
],
"relations": []
},
{
"id": "2:8887605597748372702",
"lastPropertyId": "9:4468844863383145378",
"name": "Settings",
"properties": [
{
"id": "1:7485739868216068651",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "2:5814013113141456749",
"name": "isAutoTunnelEnabled",
"type": 1
},
{
"id": "4:5645665441196906014",
"name": "trustedNetworkSSIDs",
"type": 30
},
{
"id": "5:4989886999117763881",
"name": "isTunnelOnMobileDataEnabled",
"type": 1
},
{
"id": "6:3370284381040192129",
"name": "defaultTunnel",
"type": 9
},
{
"id": "9:4468844863383145378",
"name": "isAlwaysOnVpnEnabled",
"type": 1
}
],
"relations": []
}
],
"lastEntityId": "2:8887605597748372702",
"lastIndexId": "1:4811206443952699137",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 5,
"modelVersionParserMinimum": 5,
"retiredEntityUids": [],
"retiredIndexUids": [],
"retiredPropertyUids": [
1763475292291320186,
6483820955437198310,
8323071516033820771,
5904440563612311217,
1408037976996390989,
7737847485212546994,
8215616901775229364,
8021610768066328637,
6174306582797008721,
2175939938544485767,
7555225587864607050,
969146862000617878,
5057486545428188436,
2814640993034665120,
4981008812459251156
],
"retiredRelationUids": [],
"version": 1
}
+94
View File
@@ -0,0 +1,94 @@
{
"_note1": "KEEP THIS FILE! Check it into a version control system (VCS) like git.",
"_note2": "ObjectBox manages crucial IDs for your object model. See docs for details.",
"_note3": "If you have VCS merge conflicts, you must resolve them according to ObjectBox docs.",
"entities": [
{
"id": "1:2692736974585027589",
"lastPropertyId": "15:5057486545428188436",
"name": "TunnelConfig",
"properties": [
{
"id": "1:1985347930017457084",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "12:2409068226744965585",
"name": "name",
"indexId": "1:4811206443952699137",
"type": 9,
"flags": 34848
},
{
"id": "13:8987443291286312275",
"name": "wgQuick",
"type": 9
}
],
"relations": []
},
{
"id": "2:8887605597748372702",
"lastPropertyId": "8:4981008812459251156",
"name": "Settings",
"properties": [
{
"id": "1:7485739868216068651",
"name": "id",
"type": 6,
"flags": 1
},
{
"id": "2:5814013113141456749",
"name": "isAutoTunnelEnabled",
"type": 1
},
{
"id": "4:5645665441196906014",
"name": "trustedNetworkSSIDs",
"type": 30
},
{
"id": "5:4989886999117763881",
"name": "isTunnelOnMobileDataEnabled",
"type": 1
},
{
"id": "6:3370284381040192129",
"name": "defaultTunnel",
"type": 9
}
],
"relations": []
}
],
"lastEntityId": "2:8887605597748372702",
"lastIndexId": "1:4811206443952699137",
"lastRelationId": "0:0",
"lastSequenceId": "0:0",
"modelVersion": 5,
"modelVersionParserMinimum": 5,
"retiredEntityUids": [],
"retiredIndexUids": [],
"retiredPropertyUids": [
1763475292291320186,
6483820955437198310,
8323071516033820771,
5904440563612311217,
1408037976996390989,
7737847485212546994,
8215616901775229364,
8021610768066328637,
6174306582797008721,
2175939938544485767,
7555225587864607050,
969146862000617878,
5057486545428188436,
2814640993034665120,
4981008812459251156
],
"retiredRelationUids": [],
"version": 1
}
@@ -1,112 +0,0 @@
{
"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')"
]
}
}
@@ -1,126 +0,0 @@
{
"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')"
]
}
}
@@ -1,133 +0,0 @@
{
"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')"
]
}
}
-4
View File
@@ -1,4 +0,0 @@
SIGNING_STORE_PASSWORD=
SIGNING_KEY_ALIAS=
SIGNING_KEY_PASSWORD=
KEY_STORE_PATH=/
@@ -1,11 +1,13 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals import androidx.test.ext.junit.runners.AndroidJUnit4
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.
* *
+7 -39
View File
@@ -4,10 +4,6 @@
<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" />
@@ -32,9 +28,6 @@
<uses-feature <uses-feature
android:name="android.hardware.location.gps" android:name="android.hardware.location.gps"
android:required="false" /> android:required="false" />
<uses-feature
android:name="android.hardware.screen.portrait"
android:required="false" />
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -46,7 +39,7 @@
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:banner="@mipmap/ic_banner" android:banner="@mipmap/ic_launcher_foreground"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
@@ -60,48 +53,18 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<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"/>
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity> </activity>
<activity
android:name=".ui.CaptureActivityPortrait"
android:screenOrientation="fullSensor"
android:stateNotNeeded="true"
android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden" />
<activity
android:finishOnTaskLaunch="true"
android:enabled="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay"
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="remoteMessaging"
android:exported="false"> android:exported="false">
</service> </service>
<service
android:exported="true"
android:name=".service.tile.TunnelControlTile"
android:icon="@drawable/shield"
android:label="WG Tunnel"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<meta-data android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
<meta-data android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<service <service
android:name=".service.foreground.WireGuardTunnelService" android:name=".service.foreground.WireGuardTunnelService"
android:permission="android.permission.BIND_VPN_SERVICE" android:permission="android.permission.BIND_VPN_SERVICE"
android:enabled="true" android:enabled="true"
android:persistent="true"
android:foregroundServiceType="remoteMessaging" android:foregroundServiceType="remoteMessaging"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
@@ -114,7 +77,6 @@
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:foregroundServiceType="location" android:foregroundServiceType="location"
android:permission="" android:permission=""
android:exported="false"> android:exported="false">
@@ -126,5 +88,11 @@
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/> <receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/>
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="barcode_ui"/>
<meta-data
android:name="firebase_crashlytics_collection_enabled"
android:value="true" />
</application> </application>
</manifest> </manifest>
@@ -1,20 +0,0 @@
package com.zaneschepke.wireguardautotunnel
object Constants {
const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val WATCHER_SERVICE_WAKE_LOCK_TIMEOUT = 10*60*1000L /*10 minute*/
const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L
const val VPN_STATISTIC_CHECK_INTERVAL = 1000L
const val TOGGLE_TUNNEL_DELAY = 500L
const val FADE_IN_ANIMATION_DURATION = 1000
const val SLIDE_IN_ANIMATION_DURATION = 500
const val SLIDE_IN_TRANSITION_OFFSET = 1000
const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content"
const val URI_PACKAGE_SCHEME = "package"
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"
}
@@ -1,31 +0,0 @@
package com.zaneschepke.wireguardautotunnel
import android.content.BroadcastReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.math.BigDecimal
import java.text.DecimalFormat
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
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)
}
@@ -1,14 +1,10 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import android.app.Application import android.app.Application
import android.content.Context import com.google.firebase.crashlytics.FirebaseCrashlytics
import android.content.pm.PackageManager import com.zaneschepke.wireguardautotunnel.repository.Repository
import androidx.lifecycle.ProcessLifecycleOwner import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -16,31 +12,14 @@ import javax.inject.Inject
class WireGuardAutoTunnel : Application() { class WireGuardAutoTunnel : Application() {
@Inject @Inject
lateinit var settingsRepo : SettingsDoa lateinit var settingsRepo : Repository<Settings>
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if(BuildConfig.DEBUG) { if(BuildConfig.DEBUG) {
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(false);
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
} }
initSettings() settingsRepo.init()
}
private fun initSettings() {
with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch {
if(settingsRepo.getAll().isEmpty()) {
settingsRepo.save(Settings())
}
}
}
}
companion object {
fun isRunningOnAndroidTv(context : Context) : Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
} }
} }
@@ -0,0 +1,40 @@
package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.MyObjectBox
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import io.objectbox.Box
import io.objectbox.BoxStore
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class BoxModule {
@Provides
@Singleton
fun provideBoxStore(@ApplicationContext context : Context) : BoxStore {
return MyObjectBox.builder()
.androidContext(context.applicationContext)
.build()
}
@Provides
@Singleton
fun provideBoxForSettings(store : BoxStore) : Box<Settings> {
return store.boxFor(Settings::class.java)
}
@Provides
@Singleton
fun provideBoxForTunnels(store : BoxStore) : Box<TunnelConfig> {
return store.boxFor(TunnelConfig::class.java)
}
}
@@ -1,26 +0,0 @@
package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import androidx.room.Room
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context : Context) : AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java, context.getString(R.string.db_name))
.fallbackToDestructiveMigration()
.build()
}
}
@@ -1,27 +1,25 @@
package com.zaneschepke.wireguardautotunnel.module package com.zaneschepke.wireguardautotunnel.module
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.SettingsBox
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.repository.TunnelBox
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.Binds
import dagger.Module import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
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 { abstract class RepositoryModule {
@Binds
@Singleton @Singleton
@Provides abstract fun provideSettingsRepository(settingsBox: SettingsBox) : Repository<Settings>
fun provideSettingsRepository(appDatabase: AppDatabase) : SettingsDoa {
return appDatabase.settingDao()
}
@Binds
@Singleton @Singleton
@Provides abstract fun provideTunnelRepository(tunnelBox: TunnelBox) : Repository<TunnelConfig>
fun provideTunnelConfigRepository(appDatabase: AppDatabase) : TunnelConfigDao {
return appDatabase.tunnelConfigDoa()
}
} }
@@ -0,0 +1,40 @@
package com.zaneschepke.wireguardautotunnel.module
import android.content.Context
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScanner
import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions
import com.google.mlkit.vision.codescanner.GmsBarcodeScanning
import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner
import com.zaneschepke.wireguardautotunnel.service.barcode.QRScanner
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.scopes.ViewModelScoped
@Module
@InstallIn(ViewModelComponent::class)
class ScannerModule {
@ViewModelScoped
@Provides
fun provideBarCodeOptions() : GmsBarcodeScannerOptions {
return GmsBarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
}
@ViewModelScoped
@Provides
fun provideBarCodeScanner(@ApplicationContext context: Context, options: GmsBarcodeScannerOptions) : GmsBarcodeScanner {
return GmsBarcodeScanning.getClient(context, options)
}
@ViewModelScoped
@Provides
fun provideQRScanner(gmsBarcodeScanner: GmsBarcodeScanner) : CodeScanner {
return QRScanner(gmsBarcodeScanner)
}
}
@@ -1,6 +1,7 @@
package com.zaneschepke.wireguardautotunnel.module package com.zaneschepke.wireguardautotunnel.module
import com.zaneschepke.wireguardautotunnel.service.network.EthernetService import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner
import com.zaneschepke.wireguardautotunnel.service.barcode.QRScanner
import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
import com.zaneschepke.wireguardautotunnel.service.network.WifiService import com.zaneschepke.wireguardautotunnel.service.network.WifiService
@@ -11,6 +12,7 @@ import dagger.Module
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.components.ServiceComponent import dagger.hilt.android.components.ServiceComponent
import dagger.hilt.android.scopes.ServiceScoped import dagger.hilt.android.scopes.ServiceScoped
import dagger.hilt.android.scopes.ViewModelScoped
@Module @Module
@InstallIn(ServiceComponent::class) @InstallIn(ServiceComponent::class)
@@ -27,8 +29,4 @@ abstract class ServiceModule {
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideMobileDataService(mobileDataService : MobileDataService) : NetworkService<MobileDataService> abstract fun provideMobileDataService(mobileDataService : MobileDataService) : NetworkService<MobileDataService>
@Binds
@ServiceScoped
abstract fun provideEthernetService(ethernetService: EthernetService) : NetworkService<EthernetService>
} }
@@ -27,4 +27,5 @@ class TunnelModule {
fun provideVpnService(backend: Backend) : VpnService { fun provideVpnService(backend: Backend) : VpnService {
return WireGuardTunnel(backend) return WireGuardTunnel(backend)
} }
} }
@@ -3,27 +3,40 @@ 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.R
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceTracker
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo : SettingsDoa lateinit var settingsRepo : Repository<Settings>
override fun onReceive(context: Context, intent: Intent) = goAsync { override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED) { if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
CoroutineScope(SupervisorJob()).launch {
try { try {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) { if (!settings.isNullOrEmpty()) {
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!!) ServiceTracker.actionOnService(
Action.START, context,
WireGuardConnectivityWatcherService::class.java,
mapOf(context.resources.getString(R.string.tunnel_extras_key) to
setting.defaultTunnel!!)
)
} }
} }
} finally { } finally {
@@ -32,3 +45,4 @@ class BootReceiver : BroadcastReceiver() {
} }
} }
} }
}
@@ -3,29 +3,49 @@ 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.Constants import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.goAsync import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceTracker
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() { class NotificationActionReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo : SettingsDoa lateinit var settingsRepo : Repository<Settings>
override fun onReceive(context: Context, intent: Intent?) = goAsync { override fun onReceive(context: Context, intent: Intent?) {
CoroutineScope(SupervisorJob()).launch {
try { try {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) { if (!settings.isNullOrEmpty()) {
val setting = settings.first() val setting = settings.first()
if (setting.defaultTunnel != null) { if (setting.defaultTunnel != null) {
ServiceManager.stopVpnService(context) ServiceTracker.actionOnService(
delay(Constants.TOGGLE_TUNNEL_DELAY) Action.STOP, context,
ServiceManager.startVpnService(context, setting.defaultTunnel.toString()) WireGuardTunnelService::class.java,
mapOf(
context.resources.getString(R.string.tunnel_extras_key) to
setting.defaultTunnel!!
)
)
delay(1000)
ServiceTracker.actionOnService(
Action.START, context,
WireGuardTunnelService::class.java,
mapOf(
context.resources.getString(R.string.tunnel_extras_key) to
setting.defaultTunnel!!
)
)
} }
} }
} finally { } finally {
@@ -33,3 +53,4 @@ class NotificationActionReceiver : BroadcastReceiver() {
} }
} }
} }
}
@@ -1,17 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
@Database(entities = [Settings::class, TunnelConfig::class], version = 3, autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3)
], exportSchema = true)
@TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun settingDao(): SettingsDoa
abstract fun tunnelConfigDoa() : TunnelConfigDao
}
@@ -1,23 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class DatabaseListConverters {
@TypeConverter
fun listToString(value: MutableList<String>): String {
return Json.encodeToString(value)
}
@TypeConverter
fun stringToList(value: String): MutableList<String> {
if(value.isEmpty()) return mutableListOf()
return try {
Json.decodeFromString<MutableList<String>>(value)
} catch (e : Exception) {
val list = value.split(",").toMutableList()
val json = listToString(list)
Json.decodeFromString<MutableList<String>>(json)
}
}
}
@@ -0,0 +1,16 @@
package com.zaneschepke.wireguardautotunnel.repository
import kotlinx.coroutines.flow.Flow
interface Repository<T> {
suspend fun save(t : T)
suspend fun saveAll(t : List<T>)
suspend fun getById(id : Long) : T?
suspend fun getAll() : List<T>?
suspend fun delete(t : T) : Boolean?
suspend fun count() : Long?
val itemFlow : Flow<MutableList<T>>
fun init()
}
@@ -0,0 +1,63 @@
package com.zaneschepke.wireguardautotunnel.repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import io.objectbox.Box
import io.objectbox.BoxStore
import io.objectbox.kotlin.awaitCallInTx
import io.objectbox.kotlin.toFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import javax.inject.Inject
class SettingsBox @Inject constructor(private val box : Box<Settings>, private val boxStore : BoxStore) : Repository<Settings> {
@OptIn(ExperimentalCoroutinesApi::class)
override val itemFlow = box.query().build().subscribe().toFlow()
override fun init() {
CoroutineScope(Dispatchers.IO).launch {
if(getAll().isNullOrEmpty()) {
save(Settings())
}
}
}
override suspend fun save(t : Settings) {
boxStore.awaitCallInTx {
box.put(t)
}
}
override suspend fun saveAll(t : List<Settings>) {
boxStore.awaitCallInTx {
box.put(t)
}
}
override suspend fun getById(id: Long): Settings? {
return boxStore.awaitCallInTx {
box[id]
}
}
override suspend fun getAll(): List<Settings>? {
return boxStore.awaitCallInTx {
box.all
}
}
override suspend fun delete(t : Settings): Boolean? {
return boxStore.awaitCallInTx {
box.remove(t)
}
}
override suspend fun count() : Long? {
return boxStore.awaitCallInTx {
box.count()
}
}
}
@@ -1,34 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import kotlinx.coroutines.flow.Flow
@Dao
interface SettingsDoa {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: Settings)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveAll(t: List<Settings>)
@Query("SELECT * FROM settings WHERE id=:id")
suspend fun getById(id: Long): Settings?
@Query("SELECT * FROM settings")
suspend fun getAll(): List<Settings>
@Query("SELECT * FROM settings")
fun getAllFlow(): Flow<MutableList<Settings>>
@Delete
suspend fun delete(t: Settings)
@Query("SELECT COUNT('id') FROM settings")
suspend fun count(): Long
}
@@ -0,0 +1,57 @@
package com.zaneschepke.wireguardautotunnel.repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import io.objectbox.Box
import io.objectbox.BoxStore
import io.objectbox.kotlin.awaitCallInTx
import io.objectbox.kotlin.toFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import timber.log.Timber
import javax.inject.Inject
class TunnelBox @Inject constructor(private val box : Box<TunnelConfig>,private val boxStore : BoxStore) : Repository<TunnelConfig> {
@OptIn(ExperimentalCoroutinesApi::class)
override val itemFlow = box.query().build().subscribe().toFlow()
override fun init() {
}
override suspend fun save(t : TunnelConfig) {
Timber.d("Saving tunnel config")
boxStore.awaitCallInTx {
box.put(t)
}
}
override suspend fun saveAll(t : List<TunnelConfig>) {
boxStore.awaitCallInTx {
box.put(t)
}
}
override suspend fun getById(id: Long): TunnelConfig? {
return boxStore.awaitCallInTx {
box[id]
}
}
override suspend fun getAll(): List<TunnelConfig>? {
return boxStore.awaitCallInTx {
box.all
}
}
override suspend fun delete(t : TunnelConfig): Boolean? {
return boxStore.awaitCallInTx {
box.remove(t)
}
}
override suspend fun count() : Long? {
return boxStore.awaitCallInTx {
box.count()
}
}
}
@@ -1,34 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import kotlinx.coroutines.flow.Flow
@Dao
interface TunnelConfigDao{
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: TunnelConfig)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveAll(t: List<TunnelConfig>)
@Query("SELECT * FROM TunnelConfig WHERE id=:id")
suspend fun getById(id: Long): TunnelConfig?
@Query("SELECT * FROM TunnelConfig")
suspend fun getAll(): List<TunnelConfig>
@Delete
suspend fun delete(t: TunnelConfig)
@Query("SELECT COUNT('id') FROM TunnelConfig")
suspend fun count(): Long
@Query("SELECT * FROM tunnelconfig")
fun getAllFlow(): Flow<MutableList<TunnelConfig>>
}
@@ -1,28 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Settings(
@PrimaryKey(autoGenerate = true) val id : Int = 0,
@ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled : 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 = "default_tunnel") var defaultTunnel : String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled : Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled : Boolean = false,
@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,
) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig) : Boolean {
return if (defaultTunnel != null) {
val defaultConfig = TunnelConfig.from(defaultTunnel!!)
(tunnelConfig.id == defaultConfig.id)
} else {
false
}
}
}
@@ -1,35 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.wireguard.config.Config
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.InputStream
@Entity(indices = [Index(value = ["name"], unique = true)])
@Serializable
data class TunnelConfig(
@PrimaryKey(autoGenerate = true) val id : Int = 0,
@ColumnInfo(name = "name") var name : String,
@ColumnInfo(name = "wg_quick") var wgQuick : String,
){
override fun toString(): String {
return Json.encodeToString(serializer(), this)
}
companion object {
fun from(string : String) : TunnelConfig {
return Json.decodeFromString<TunnelConfig>(string)
}
fun configFromQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream()
val reader = inputStream.bufferedReader(Charsets.UTF_8)
return Config.parse(reader)
}
}
}
@@ -0,0 +1,7 @@
package com.zaneschepke.wireguardautotunnel.service.barcode
import kotlinx.coroutines.flow.Flow
interface CodeScanner {
fun scan() : Flow<String?>
}
@@ -0,0 +1,23 @@
package com.zaneschepke.wireguardautotunnel.service.barcode
import com.google.mlkit.vision.codescanner.GmsBarcodeScanner
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import timber.log.Timber
import javax.inject.Inject
class QRScanner @Inject constructor(private val gmsBarcodeScanner: GmsBarcodeScanner) : CodeScanner {
override fun scan(): Flow<String?> {
return callbackFlow {
gmsBarcodeScanner.startScan().addOnSuccessListener {
trySend(it.rawValue)
}.addOnFailureListener {
trySend(it.message)
Timber.e(it.message)
}
awaitClose {
}
}
}
}
@@ -2,6 +2,5 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
enum class Action { enum class Action {
START, START,
START_FOREGROUND,
STOP STOP
} }
@@ -1,30 +1,31 @@
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 com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
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
Timber.d("using an intent with action $action") Timber.d("using an intent with action $action")
when (action) { when (action) {
Action.START.name, Action.START_FOREGROUND.name -> startService(intent.extras) Action.START.name -> startService(intent.extras)
Action.STOP.name -> stopService(intent.extras) Action.STOP.name -> stopService(intent.extras)
"android.net.VpnService" -> { "android.net.VpnService" -> {
Timber.d("Always-on VPN starting service") Timber.d("Always-on VPN starting service")
@@ -1,101 +0,0 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.ActivityManager
import android.app.Service
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.Intent
import com.zaneschepke.wireguardautotunnel.R
import timber.log.Timber
object ServiceManager {
@Suppress("DEPRECATION")
private // Deprecated for third party Services.
fun <T> Context.isServiceRunning(service: Class<T>) =
(getSystemService(ACTIVITY_SERVICE) as ActivityManager)
.getRunningServices(Integer.MAX_VALUE)
.any { it.service.className == service.name }
fun <T : Service> getServiceState(context: Context, cls : Class<T>): ServiceState {
val isServiceRunning = context.isServiceRunning(cls)
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) {
if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return
if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return
val intent = Intent(context, cls).also {
it.action = action.name
extras?.forEach {(k, v) ->
it.putExtra(k, v)
}
}
intent.component?.javaClass
try {
when(action) {
Action.START_FOREGROUND -> {
context.startForegroundService(intent)
}
Action.START -> {
context.startService(intent)
}
Action.STOP -> context.startService(intent)
}
} catch (e : Exception) {
Timber.e(e.message)
}
}
fun startVpnService(context : Context, tunnelConfig : String) {
actionOnService(
Action.START,
context,
WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig))
}
fun stopVpnService(context : Context) {
actionOnService(
Action.STOP,
context,
WireGuardTunnelService::class.java
)
}
fun startVpnServiceForeground(context : Context, tunnelConfig : String) {
actionOnService(
Action.START_FOREGROUND,
context,
WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig))
}
private fun startWatcherServiceForeground(context : Context, tunnelConfig : String) {
actionOnService(
Action.START, context,
WireGuardConnectivityWatcherService::class.java, mapOf(context.
getString(R.string.tunnel_extras_key) to
tunnelConfig))
}
fun startWatcherService(context : Context, tunnelConfig : String) {
actionOnService(
Action.START, context,
WireGuardConnectivityWatcherService::class.java, mapOf(context.
getString(R.string.tunnel_extras_key) to
tunnelConfig))
}
fun stopWatcherService(context : Context) {
actionOnService(
Action.STOP, context,
WireGuardConnectivityWatcherService::class.java)
}
fun toggleWatcherServiceForeground(context: Context, tunnelConfig : String) {
when(getServiceState( context,
WireGuardConnectivityWatcherService::class.java,)) {
ServiceState.STARTED -> stopWatcherService(context)
ServiceState.STOPPED -> startWatcherServiceForeground(context, tunnelConfig)
}
}
}
@@ -0,0 +1,56 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.ActivityManager
import android.app.Application
import android.app.Service
import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.Intent
import com.google.firebase.crashlytics.ktx.crashlytics
import com.google.firebase.ktx.Firebase
object ServiceTracker {
@Suppress("DEPRECATION")
private // Deprecated for third party Services.
fun <T> Context.isServiceRunning(service: Class<T>) =
(getSystemService(ACTIVITY_SERVICE) as ActivityManager)
.getRunningServices(Integer.MAX_VALUE)
.any { it.service.className == service.name }
fun <T : Service> getServiceState(context: Context, cls : Class<T>): ServiceState {
val isServiceRunning = context.isServiceRunning(cls)
return if(isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED
}
fun <T : Service> actionOnService(action: Action, application: Application, cls : Class<T>, extras : Map<String,String>? = null) {
if (getServiceState(application, cls) == ServiceState.STOPPED && action == Action.STOP) return
val intent = Intent(application, cls).also {
it.action = action.name
extras?.forEach {(k, v) ->
it.putExtra(k, v)
}
}
intent.component?.javaClass
try {
application.startService(intent)
} catch (e : Exception) {
e.message?.let { Firebase.crashlytics.log(it) }
}
}
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
val intent = Intent(context, cls).also {
it.action = action.name
extras?.forEach {(k, v) ->
it.putExtra(k, v)
}
}
intent.component?.javaClass
try {
context.startService(intent)
} catch (e : Exception) {
e.message?.let { Firebase.crashlytics.log(it) }
}
}
}
@@ -1,38 +1,35 @@
package com.zaneschepke.wireguardautotunnel.service.foreground package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.AlarmManager import android.app.AlarmManager
import android.app.Application
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent 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.lifecycle.lifecycleScope
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.service.network.EthernetService
import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
import com.zaneschepke.wireguardautotunnel.service.network.NetworkStatus import com.zaneschepke.wireguardautotunnel.service.network.NetworkStatus
import com.zaneschepke.wireguardautotunnel.service.network.WifiService 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 com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject 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>
@@ -41,10 +38,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
lateinit var mobileDataService : NetworkService<MobileDataService> lateinit var mobileDataService : NetworkService<MobileDataService>
@Inject @Inject
lateinit var ethernetService: NetworkService<EthernetService> lateinit var settingsRepo: Repository<Settings>
@Inject
lateinit var settingsRepo: SettingsDoa
@Inject @Inject
lateinit var notificationService : NotificationService lateinit var notificationService : NotificationService
@@ -52,39 +46,30 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
@Inject @Inject
lateinit var vpnService : VpnService lateinit var vpnService : VpnService
private var isWifiConnected = false private lateinit var watcherJob : Job;
private var isEthernetConnected = false
private var isMobileDataConnected = false
private var currentNetworkSSID = ""
private lateinit var watcherJob: Job
private lateinit var setting : Settings private lateinit var setting : Settings
private lateinit var tunnelConfig: String private lateinit var tunnelId: String
private var connecting = false
private var disconnecting = false
private var isWifiConnected = false
private var isMobileDataConnected = false
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() {
super.onCreate()
lifecycleScope.launch(Dispatchers.Main) {
launchWatcherNotification()
}
}
override fun startService(extras: Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
launchWatcherNotification()
val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key)) val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key))
if (tunnelId != null) { if (tunnelId != null) {
this.tunnelConfig = tunnelId this.tunnelId = tunnelId
} }
// we need this lock so our service gets not affected by Doze Mode // we need this lock so our service gets not affected by Doze Mode
lifecycleScope.launch {
initWakeLock() initWakeLock()
}
cancelWatcherJob() cancelWatcherJob()
if (this::tunnelConfig.isInitialized) { launchWatcherNotification()
if(this::tunnelId.isInitialized) {
startWatcherJob() startWatcherJob()
} else { } else {
stopService(extras) stopService(extras)
@@ -99,6 +84,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
} }
} }
cancelWatcherJob() cancelWatcherJob()
stopVPN()
stopSelf() stopSelf()
} }
@@ -106,9 +92,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
val notification = notificationService.createNotification( val notification = notificationService.createNotification(
channelId = getString(R.string.watcher_channel_id), channelId = getString(R.string.watcher_channel_id),
channelName = getString(R.string.watcher_channel_name), channelName = getString(R.string.watcher_channel_name),
description = getString(R.string.watcher_notification_text), description = getString(R.string.watcher_notification_text))
vibration = false
)
super.startForeground(foregroundId, notification) super.startForeground(foregroundId, notification)
} }
@@ -116,37 +100,21 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
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( val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent,
this, 1, restartServiceIntent, PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE);
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE applicationContext.getSystemService(Context.ALARM_SERVICE);
) val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
applicationContext.getSystemService(Context.ALARM_SERVICE) alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
val alarmService: AlarmManager =
applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmService.set(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + 1000,
restartServicePendingIntent
)
} }
private suspend fun initWakeLock() { private 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 {
if (isBatterySaverOn) {
Timber.d("Initiating wakelock with timeout")
acquire(Constants.WATCHER_SERVICE_WAKE_LOCK_TIMEOUT)
} else {
Timber.d("Initiating wakelock with zero timeout")
acquire() acquire()
} }
} }
} }
}
private fun cancelWatcherJob() { private fun cancelWatcherJob() {
if(this::watcherJob.isInitialized) { if(this::watcherJob.isInitialized) {
@@ -155,27 +123,19 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
} }
private fun startWatcherJob() { private fun startWatcherJob() {
watcherJob = lifecycleScope.launch(Dispatchers.IO) { watcherJob = CoroutineScope(SupervisorJob()).launch {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll();
if (settings.isNotEmpty()) { if(!settings.isNullOrEmpty()) {
setting = settings[0] setting = settings[0]
} }
launch { CoroutineScope(watcherJob).launch {
watchForWifiConnectivityChanges() watchForWifiConnectivityChanges()
} }
if(setting.isTunnelOnMobileDataEnabled) { if(setting.isTunnelOnMobileDataEnabled) {
launch { CoroutineScope(watcherJob).launch {
watchForMobileDataConnectivityChanges() watchForMobileDataConnectivityChanges()
} }
} }
if (setting.isTunnelOnEthernetEnabled) {
launch {
watchForEthernetConnectivityChanges()
}
}
launch {
manageVpn()
}
} }
} }
@@ -186,41 +146,26 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
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")
if(!disconnecting && !connecting) {
if(!isWifiConnected && setting.isTunnelOnMobileDataEnabled
&& vpnService.getState() == Tunnel.State.DOWN)
startVPN()
}
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isMobileDataConnected = false isMobileDataConnected = false
if(!disconnecting && !connecting) {
if(!isWifiConnected && vpnService.getState() == Tunnel.State.UP) stopVPN()
}
Timber.d("Lost mobile data connection") Timber.d("Lost mobile data connection")
} }
} }
} }
} }
private suspend fun watchForEthernetConnectivityChanges() {
ethernetService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Ethernet connection")
isEthernetConnected = true
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Ethernet capabilities changed")
isEthernetConnected = true
}
is NetworkStatus.Unavailable -> {
isEthernetConnected = false
Timber.d("Lost Ethernet connection")
}
}
}
}
private suspend fun watchForWifiConnectivityChanges() { private suspend fun watchForWifiConnectivityChanges() {
wifiService.networkStatus.collect { wifiService.networkStatus.collect {
when (it) { when (it) {
@@ -228,55 +173,64 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
Timber.d("Gained Wi-Fi connection") Timber.d("Gained Wi-Fi connection")
isWifiConnected = true isWifiConnected = true
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Wifi capabilities changed") Timber.d("Wifi capabilities changed")
isWifiConnected = true isWifiConnected = true
currentNetworkSSID = wifiService.getNetworkName(it.networkCapabilities) ?: "" if (!connecting && !disconnecting) {
Timber.d("Not connect and not disconnecting")
val ssid = wifiService.getNetworkName(it.networkCapabilities);
Timber.d("SSID: $ssid")
if (!setting.trustedNetworkSSIDs.contains(ssid) && vpnService.getState() == Tunnel.State.DOWN) {
Timber.d("Starting VPN Tunnel for untrusted network: $ssid")
startVPN()
} else if (!disconnecting && vpnService.getState() == Tunnel.State.UP && setting.trustedNetworkSSIDs.contains(
ssid
)
) {
Timber.d("Stopping VPN Tunnel for trusted network with ssid: $ssid")
stopVPN()
}
}
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isWifiConnected = false isWifiConnected = false
Timber.d("Lost Wi-Fi connection") Timber.d("Lost Wi-Fi connection")
if(!connecting || !disconnecting) {
if(setting.isTunnelOnMobileDataEnabled && vpnService.getState() == Tunnel.State.DOWN
&& isMobileDataConnected){
Timber.d("Wifi not available so starting vpn for mobile data")
startVPN()
} }
} if(!setting.isTunnelOnMobileDataEnabled && vpnService.getState() == Tunnel.State.UP) {
Timber.d("Lost WiFi connection, disabling vpn")
stopVPN()
} }
} }
private suspend fun manageVpn() { }
while (true) { }
if (isEthernetConnected && setting.isTunnelOnEthernetEnabled && vpnService.getState() == Tunnel.State.DOWN) { }
ServiceManager.startVpnService(this, tunnelConfig) }
} private fun startVPN() {
if (!isEthernetConnected && setting.isTunnelOnMobileDataEnabled && if(!connecting) {
!isWifiConnected && connecting = true
isMobileDataConnected ServiceTracker.actionOnService(
&& vpnService.getState() == Tunnel.State.DOWN Action.START,
) { this.applicationContext as Application,
ServiceManager.startVpnService(this, tunnelConfig) WireGuardTunnelService::class.java,
} else if (!isEthernetConnected && !setting.isTunnelOnMobileDataEnabled && mapOf(getString(R.string.tunnel_extras_key) to tunnelId))
!isWifiConnected && connecting = false
vpnService.getState() == Tunnel.State.UP }
) { }
ServiceManager.stopVpnService(this) private fun stopVPN() {
} else if (!isEthernetConnected && isWifiConnected && if(!disconnecting) {
!setting.trustedNetworkSSIDs.contains(currentNetworkSSID) && disconnecting = true
setting.isTunnelOnWifiEnabled && ServiceTracker.actionOnService(
(vpnService.getState() != Tunnel.State.UP) Action.STOP,
) { this.applicationContext as Application,
ServiceManager.startVpnService(this, tunnelConfig) WireGuardTunnelService::class.java
} else if (!isEthernetConnected && (isWifiConnected && )
setting.trustedNetworkSSIDs.contains(currentNetworkSSID)) && disconnecting = false
(vpnService.getState() == Tunnel.State.UP)
) {
ServiceManager.stopVpnService(this)
} else if (!isEthernetConnected && (isWifiConnected &&
!setting.isTunnelOnWifiEnabled &&
(vpnService.getState() == Tunnel.State.UP)
)) {
ServiceManager.stopVpnService(this)
}
delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL)
} }
} }
} }
@@ -3,17 +3,19 @@ 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.lifecycle.lifecycleScope
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.Repository
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.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -21,13 +23,13 @@ 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: Repository<Settings>
@Inject @Inject
lateinit var notificationService : NotificationService lateinit var notificationService : NotificationService
@@ -36,20 +38,11 @@ class WireGuardTunnelService : ForegroundService() {
private var tunnelName : String = "" private var tunnelName : String = ""
override fun onCreate() {
super.onCreate()
lifecycleScope.launch(Dispatchers.Main) {
launchVpnStartingNotification()
}
}
override fun startService(extras : Bundle?) { override fun startService(extras : Bundle?) {
super.startService(extras) super.startService(extras)
launchVpnStartingNotification()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
cancelJob() cancelJob()
job = lifecycleScope.launch(Dispatchers.IO) { job = CoroutineScope(SupervisorJob()).launch {
launch {
if(tunnelConfigString != null) { if(tunnelConfigString != null) {
try { try {
val tunnelConfig = TunnelConfig.from(tunnelConfigString) val tunnelConfig = TunnelConfig.from(tunnelConfigString)
@@ -61,8 +54,8 @@ class WireGuardTunnelService : ForegroundService() {
} }
} else { } else {
Timber.d("Tunnel config null, starting default tunnel") Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll();
if(settings.isNotEmpty()) { if(!settings.isNullOrEmpty()) {
val setting = settings[0] val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) { if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!) val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
@@ -72,7 +65,7 @@ class WireGuardTunnelService : ForegroundService() {
} }
} }
} }
launch { CoroutineScope(job).launch {
var didShowConnected = false var didShowConnected = false
var didShowFailedHandshakeNotification = false var didShowFailedHandshakeNotification = false
vpnService.handshakeStatus.collect { vpnService.handshakeStatus.collect {
@@ -103,11 +96,10 @@ class WireGuardTunnelService : ForegroundService() {
} }
} }
} }
}
override fun stopService(extras : Bundle?) { override fun stopService(extras : Bundle?) {
super.stopService(extras) super.stopService(extras)
lifecycleScope.launch(Dispatchers.IO) { CoroutineScope(Dispatchers.IO).launch() {
vpnService.stopTunnel() vpnService.stopTunnel()
} }
cancelJob() cancelJob()
@@ -120,36 +112,20 @@ class WireGuardTunnelService : ForegroundService() {
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
title = getString(R.string.tunnel_start_title), title = getString(R.string.tunnel_start_title),
onGoing = false, onGoing = false,
vibration = false,
showTimestamp = true, showTimestamp = true,
description = "${getString(R.string.tunnel_start_text)} $tunnelName" description = "${getString(R.string.tunnel_start_text)} $tunnelName"
) )
super.startForeground(foregroundId, notification) super.startForeground(foregroundId, notification)
} }
private fun launchVpnStartingNotification() {
val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
title = getString(R.string.vpn_starting),
onGoing = false,
vibration = false,
showTimestamp = true,
description = getString(R.string.attempt_connection)
)
super.startForeground(foregroundId, notification)
}
private fun launchVpnConnectionFailedNotification(message : String) { private fun launchVpnConnectionFailedNotification(message : String) {
val notification = notificationService.createNotification( val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
action = PendingIntent.getBroadcast(this,0, action = PendingIntent.getBroadcast(this,0,Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE),
Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE),
actionText = getString(R.string.restart), actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed), title = getString(R.string.vpn_connection_failed),
onGoing = false, onGoing = false,
vibration = true,
showTimestamp = true, showTimestamp = true,
description = message description = message
) )
@@ -28,6 +28,7 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
object : ConnectivityManager.NetworkCallback( object : ConnectivityManager.NetworkCallback(
FLAG_INCLUDE_LOCATION_INFO FLAG_INCLUDE_LOCATION_INFO
) { ) {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network)) trySend(NetworkStatus.Available(network))
} }
@@ -44,7 +45,6 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
} }
} }
} }
else -> { else -> {
object : ConnectivityManager.NetworkCallback() { object : ConnectivityManager.NetworkCallback() {
@@ -67,8 +67,6 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
} }
val request = NetworkRequest.Builder() val request = NetworkRequest.Builder()
.addTransportType(networkCapability) .addTransportType(networkCapability)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build() .build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback) connectivityManager.registerNetworkCallback(request, networkStatusCallback)
@@ -80,7 +78,7 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? { override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid : String? = getWifiNameFromCapabilities(networkCapabilities) var ssid : String? = getWifiNameFromCapabilities(networkCapabilities)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { if((Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) || (Build.VERSION.SDK_INT == Build.VERSION_CODES.R)) {
val info = wifiManager.connectionInfo val info = wifiManager.connectionInfo
if (info.supplicantState === SupplicantState.COMPLETED) { if (info.supplicantState === SupplicantState.COMPLETED) {
ssid = info.ssid ssid = info.ssid
@@ -92,15 +90,14 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
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) {
val info : WifiInfo val info : WifiInfo
if(networkCapabilities.transportInfo is WifiInfo) { if(networkCapabilities.transportInfo is WifiInfo) {
info = networkCapabilities.transportInfo as WifiInfo info = networkCapabilities.transportInfo as WifiInfo
return info.ssid } else {
}
}
return null return null
} }
return info.ssid
}
} }
} }
@@ -1,10 +0,0 @@
package com.zaneschepke.wireguardautotunnel.service.network
import android.content.Context
import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
class EthernetService @Inject constructor(@ApplicationContext context: Context) :
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET) {
}
@@ -6,4 +6,5 @@ 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>
} }
@@ -14,7 +14,7 @@ interface NotificationService {
description: String, description: String,
showTimestamp : Boolean = false, showTimestamp : Boolean = false,
importance: Int = NotificationManager.IMPORTANCE_HIGH, importance: Int = NotificationManager.IMPORTANCE_HIGH,
vibration: Boolean = false, vibration: Boolean = true,
onGoing: Boolean = true, onGoing: Boolean = true,
lights: Boolean = true lights: Boolean = true
): Notification ): Notification
@@ -14,7 +14,7 @@ import javax.inject.Inject
class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) : NotificationService { class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) : NotificationService {
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager;
override fun createNotification( override fun createNotification(
channelId: String, channelId: String,
@@ -1,83 +0,0 @@
package com.zaneschepke.wireguardautotunnel.service.shortcut
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
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.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class ShortcutsActivity : ComponentActivity() {
@Inject
lateinit var settingsRepo : SettingsDoa
@Inject
lateinit var tunnelConfigRepo : TunnelConfigDao
private fun attemptWatcherServiceToggle(tunnelConfig : String) {
lifecycleScope.launch(Dispatchers.Main) {
val settings = getSettings()
if(settings.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if(intent.getStringExtra(CLASS_NAME_EXTRA_KEY)
.equals(WireGuardTunnelService::class.java.simpleName)) {
lifecycleScope.launch(Dispatchers.Main) {
val settings = getSettings()
if(settings.isShortcutsEnabled) {
try {
val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
val tunnelConfig = 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()
}
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,148 +0,0 @@
package com.zaneschepke.wireguardautotunnel.service.tile
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class TunnelControlTile : TileService() {
@Inject
lateinit var settingsRepo : SettingsDoa
@Inject
lateinit var configRepo : TunnelConfigDao
@Inject
lateinit var vpnService : VpnService
private val scope = CoroutineScope(Dispatchers.Main)
private lateinit var job : Job
override fun onStartListening() {
job = scope.launch {
updateTileState()
}
super.onStartListening()
}
override fun onTileRemoved() {
super.onTileRemoved()
cancelJob()
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
override fun onClick() {
super.onClick()
unlockAndRun {
scope.launch {
try {
val tunnel = determineTileTunnel()
if(tunnel != null) {
attemptWatcherServiceToggle(tunnel.toString())
if(vpnService.getState() == Tunnel.State.UP) {
ServiceManager.stopVpnService(this@TunnelControlTile)
} else {
ServiceManager.startVpnServiceForeground(this@TunnelControlTile, tunnel.toString())
}
}
} catch (e : Exception) {
Timber.e(e.message)
} finally {
cancel()
}
}
}
}
private suspend fun determineTileTunnel() : TunnelConfig? {
var tunnelConfig : TunnelConfig? = null
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
tunnelConfig = if (setting.defaultTunnel != null) {
TunnelConfig.from(setting.defaultTunnel!!)
} else {
val configs = configRepo.getAll()
val config = if(configs.isNotEmpty()) {
configs.first()
} else {
null
}
config
}
}
return tunnelConfig
}
private fun attemptWatcherServiceToggle(tunnelConfig : String) {
scope.launch {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
if(setting.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(this@TunnelControlTile, tunnelConfig)
}
}
}
}
private suspend fun updateTileState() {
vpnService.state.collect {
try {
when(it) {
Tunnel.State.UP -> {
qsTile.state = Tile.STATE_ACTIVE
}
Tunnel.State.DOWN -> {
qsTile.state = Tile.STATE_INACTIVE
}
else -> {
qsTile.state = Tile.STATE_UNAVAILABLE
}
}
val config = determineTileTunnel()
setTileDescription(config?.name ?: this.resources.getString(R.string.no_tunnel_available))
qsTile.updateTile()
} catch (e : Exception) {
Timber.e("Unable to update tile state")
}
}
}
private fun setTileDescription(description : String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
qsTile.subtitle = description
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = description
}
}
private fun cancelJob() {
if(this::job.isInitialized) {
job.cancel()
}
}
}
@@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
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.crypto.Key import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
interface VpnService : Tunnel { interface VpnService : Tunnel {
@@ -5,8 +5,7 @@ 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.crypto.Key import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -23,13 +22,13 @@ import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnService { class WireGuardTunnel @Inject constructor(private val backend : Backend,
) : 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 = MutableSharedFlow<Tunnel.State>(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
replay = 1) replay = 1)
private val _handshakeStatus = MutableSharedFlow<HandshakeStatus>(replay = 1, private val _handshakeStatus = MutableSharedFlow<HandshakeStatus>(replay = 1,
@@ -45,36 +44,26 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
override val handshakeStatus: SharedFlow<HandshakeStatus> override val handshakeStatus: SharedFlow<HandshakeStatus>
get() = _handshakeStatus.asSharedFlow() get() = _handshakeStatus.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var statsJob : Job private lateinit var statsJob : Job
override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{ override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{
return try { return try {
stopTunnelOnConfigChange(tunnelConfig) if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) {
emitTunnelName(tunnelConfig.name) stopTunnel()
}
_tunnelName.emit(tunnelConfig.name)
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val state = backend.setState( val state = backend.setState(
this, Tunnel.State.UP, config) 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
} }
@@ -95,10 +84,10 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
} }
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 = scope.launch { statsJob = CoroutineScope(Dispatchers.IO).launch {
val handshakeMap = HashMap<Key, Long>() val handshakeMap = HashMap<Key, Long>()
var neverHadHandshakeCounter = 0 var neverHadHandshakeCounter = 0
while (true) { while (true) {
@@ -114,18 +103,18 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
_handshakeStatus.emit(HandshakeStatus.NOT_STARTED) _handshakeStatus.emit(HandshakeStatus.NOT_STARTED)
} }
if(neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) { if(neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
neverHadHandshakeCounter += (1 * Constants.VPN_STATISTIC_CHECK_INTERVAL/1000).toInt() neverHadHandshakeCounter++
} }
return@forEach return@forEach
} }
if((NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) ?: 0L) >= HandshakeStatus.UNHEALTHY_TIME_LIMIT_SEC) { if(NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) >= HandshakeStatus.UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.UNHEALTHY) _handshakeStatus.emit(HandshakeStatus.UNHEALTHY)
} else { } else {
_handshakeStatus.emit(HandshakeStatus.HEALTHY) _handshakeStatus.emit(HandshakeStatus.HEALTHY)
} }
} }
_lastHandshake.emit(handshakeMap) _lastHandshake.emit(handshakeMap)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL) delay(1000)
} }
} }
} }
@@ -137,6 +126,4 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
_lastHandshake.tryEmit(emptyMap()) _lastHandshake.tryEmit(emptyMap())
} }
} }
} }
@@ -0,0 +1,15 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel.model
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
@Entity
data class Settings(
@Id
var id : Long = 0,
var isAutoTunnelEnabled : Boolean = false,
var isTunnelOnMobileDataEnabled : Boolean = false,
var trustedNetworkSSIDs : MutableList<String> = mutableListOf(),
var defaultTunnel : String? = null,
var isAlwaysOnVpnEnabled : Boolean = false
)
@@ -0,0 +1,89 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel.model
import com.wireguard.config.Config
import io.objectbox.annotation.ConflictStrategy
import io.objectbox.annotation.Entity
import io.objectbox.annotation.Id
import io.objectbox.annotation.Unique
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.InputStream
@Entity
@Serializable
data class TunnelConfig(
@Id
var id : Long = 0,
@Unique(onConflict = ConflictStrategy.REPLACE)
var name : String,
var wgQuick : String
) {
override fun toString(): String {
return Json.encodeToString(serializer(), this)
}
companion object {
private const val INCLUDED_APPLICATIONS = "IncludedApplications = "
private const val EXCLUDED_APPLICATIONS = "ExcludedApplications = "
private const val INTERFACE = "[Interface]"
private const val NEWLINE_CHAR = "\n"
private const val APP_CONFIG_SEPARATOR = ", "
private fun addApplicationsToConfig(appConfig : String, wgQuick : String) : String {
val configList = wgQuick.split(NEWLINE_CHAR).toMutableList()
val interfaceIndex = configList.indexOf(INTERFACE)
configList.add(interfaceIndex + 1, appConfig)
return configList.joinToString(NEWLINE_CHAR)
}
fun clearAllApplicationsFromConfig(wgQuick : String) : String {
val configList = wgQuick.split(NEWLINE_CHAR).toMutableList()
val itr = configList.iterator()
while (itr.hasNext()) {
val next = itr.next()
if(next.contains(INCLUDED_APPLICATIONS) || next.contains(EXCLUDED_APPLICATIONS)) {
itr.remove()
}
}
return configList.joinToString(NEWLINE_CHAR)
}
fun setExcludedApplicationsOnQuick(packages : List<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)
}
fun configFromQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream()
val reader = inputStream.bufferedReader(Charsets.UTF_8)
return Config.parse(reader)
}
}
}
@@ -1,5 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui
import com.journeyapps.barcodescanner.CaptureActivity
class CaptureActivityPortrait : CaptureActivity()
@@ -6,56 +6,40 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import android.view.KeyEvent
import androidx.activity.compose.rememberLauncherForActivityResult 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.ExitTransition
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
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
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import com.google.accompanist.navigation.animation.AnimatedNavHost
import androidx.compose.ui.focus.FocusRequester import com.google.accompanist.navigation.animation.composable
import androidx.compose.ui.input.key.onKeyEvent import com.google.accompanist.navigation.animation.rememberAnimatedNavController
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
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
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
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 dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -66,9 +50,7 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
val navController = rememberNavController() val navController = rememberAnimatedNavController()
val focusRequester = remember { FocusRequester() }
WireguardAutoTunnelTheme { WireguardAutoTunnelTheme {
TransparentSystemBars() TransparentSystemBars()
@@ -98,47 +80,7 @@ class MainActivity : AppCompatActivity() {
} else requestNotificationPermission() } else requestNotificationPermission()
} }
fun showSnackBarMessage(message : String) { Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) },
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) {
when (it.nativeKeyEvent.keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> {
try {
focusRequester.requestFocus()
} catch(e : IllegalStateException) {
Timber.e("No D-Pad focus request modifier added to element on screen")
}
false
} else -> {
false
}
}
} else {
false
}
},
bottomBar = if (vpnIntent == null && notificationPermissionState.status.isGranted) { bottomBar = if (vpnIntent == null && notificationPermissionState.status.isGranted) {
{ BottomNavBar(navController, Routes.navItems) } { BottomNavBar(navController, Routes.navItems) }
} else { } else {
@@ -162,74 +104,69 @@ 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("package", this.packageName, null)
startActivity(intentSettings) startActivity(intentSettings);
}, },
message = getString(R.string.notification_permission_required), message = getString(R.string.notification_permission_required),
getString(R.string.open_settings) getString(R.string.open_settings)
) )
return@Scaffold return@Scaffold
} }
AnimatedNavHost(navController, startDestination = Routes.Main.name) {
NavHost(navController, startDestination = Routes.Main.name) {
composable(Routes.Main.name, enterTransition = { composable(Routes.Main.name, enterTransition = {
when (initialState.destination.route) { when (initialState.destination.route) {
Routes.Settings.name, Routes.Support.name -> Routes.Settings.name, Routes.Support.name ->
slideInHorizontally( slideInHorizontally(
initialOffsetX = { -Constants.SLIDE_IN_TRANSITION_OFFSET }, initialOffsetX = { -1000 },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) animationSpec = tween(500)
) )
else -> { else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(animationSpec = tween(1000))
} }
} }
}, 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) {
Routes.Main.name -> Routes.Main.name ->
slideInHorizontally( slideInHorizontally(
initialOffsetX = { Constants.SLIDE_IN_TRANSITION_OFFSET }, initialOffsetX = { 1000 },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) animationSpec = tween(500)
) )
Routes.Support.name -> { Routes.Support.name -> {
slideInHorizontally( slideInHorizontally(
initialOffsetX = { -Constants.SLIDE_IN_TRANSITION_OFFSET }, initialOffsetX = { -1000 },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) animationSpec = tween(500)
) )
} }
else -> { else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(animationSpec = tween(1000))
} }
} }
}) { SettingsScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester) } }) { SettingsScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController) }
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 ->
slideInHorizontally( slideInHorizontally(
initialOffsetX = { Constants.SLIDE_IN_ANIMATION_DURATION }, initialOffsetX = { 1000 },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) animationSpec = tween(500)
) )
else -> { else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(animationSpec = tween(1000))
} }
} }
}) { SupportScreen(padding = padding, focusRequester) } }) { SupportScreen(padding = padding) }
composable("${Routes.Config.name}/{id}", enterTransition = { composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION)) fadeIn(animationSpec = tween(1000))
}) { it -> }) { ConfigScreen(padding = padding, navController = navController, id = it.arguments?.getString("id"))}
val id = it.arguments?.getString("id") composable("${Routes.Detail.name}/{id}", enterTransition = {
if(!id.isNullOrBlank()) { fadeIn(animationSpec = tween(1000))
ConfigScreen(navController = navController, id = id, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester)} }) { DetailScreen(padding = padding, id = it.arguments?.getString("id")) }
}
} }
} }
} }
@@ -10,7 +10,8 @@ enum class Routes {
Main, Main,
Settings, Settings,
Support, Support,
Config; Config,
Detail;
companion object { companion object {
@@ -0,0 +1,9 @@
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
)
@@ -3,26 +3,24 @@ package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
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.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 com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun ClickableIconButton(onIconClick : () -> Unit, text : String, icon : ImageVector, enabled : Boolean) { fun ClickableIconButton(onIconClick : () -> Unit, text : String, icon : ImageVector, enabled : Boolean) {
TextButton(onClick = {}, Button(onClick = {},
enabled = enabled enabled = enabled
) { ) {
Text(text) Text(text)
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = stringResource(R.string.delete), contentDescription = "Delete",
modifier = Modifier.size(ButtonDefaults.IconSize).clickable { modifier = Modifier.size(ButtonDefaults.IconSize).clickable {
if(enabled) { if(enabled) {
onIconClick() onIconClick()
@@ -1,36 +1,27 @@
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.shape.RoundedCornerShape import androidx.compose.foundation.layout.size
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.draw.clip import androidx.compose.ui.graphics.Color
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(icon : @Composable () -> Unit, text : String, onHold : () -> Unit, fun RowListItem(leadingIcon : ImageVector? = null, leadingIconColor : Color = Color.Gray, text : String, onHold : () -> Unit, onClick: () -> Unit, rowButton : @Composable() () -> 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()
@@ -40,45 +31,25 @@ fun RowListItem(icon : @Composable () -> Unit, text : String, onHold : () -> Uni
} }
) )
) { ) {
Column {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 5.dp), .padding(14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
Row(verticalAlignment = Alignment.CenterVertically,) { Row(verticalAlignment = Alignment.CenterVertically,) {
icon() if(leadingIcon != null) {
Icon(
leadingIcon, "status",
tint = leadingIconColor,
modifier = Modifier.padding(end = 10.dp).size(15.dp)
)
}
Text(text) Text(text)
} }
rowButton() 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)
}
}
}
}
} }
} }
@@ -1,80 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Clear
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun SearchBar(
onQuery : (queryString : String) -> Unit
) {
// Immediately update and keep track of query from text field changes.
var query: String by rememberSaveable { mutableStateOf("") }
var showClearIcon by rememberSaveable { mutableStateOf(false) }
if (query.isEmpty()) {
showClearIcon = false
} else if (query.isNotEmpty()) {
showClearIcon = true
}
TextField(
value = query,
onValueChange = { onQueryChanged ->
// If user makes changes to text, immediately updated it.
query = onQueryChanged
onQuery(onQueryChanged)
},
leadingIcon = {
Icon(
imageVector = Icons.Rounded.Search,
tint = MaterialTheme.colorScheme.onBackground,
contentDescription = stringResource(id = R.string.search_icon)
)
},
trailingIcon = {
if (showClearIcon) {
IconButton(onClick = { query = "" }) {
Icon(
imageVector = Icons.Rounded.Clear,
tint = MaterialTheme.colorScheme.onBackground,
contentDescription = stringResource(id = R.string.clear_icon)
)
}
}
},
maxLines = 1,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
),
placeholder = { Text(text = stringResource(R.string.hint_search_packages)) },
textStyle = MaterialTheme.typography.bodySmall,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = Modifier
.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background, shape = RectangleShape)
)
}
@@ -1,33 +0,0 @@
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,
)
}
@@ -1,34 +0,0 @@
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()
}
)
}
}
@@ -1,79 +0,0 @@
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)
}
}
@@ -1,61 +0,0 @@
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))
}
}
}
}
@@ -1,22 +0,0 @@
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)
)
}
@@ -1,25 +0,0 @@
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 ""
)
}
}
}
@@ -1,32 +0,0 @@
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,192 +1,112 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.annotation.SuppressLint import android.widget.Toast
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.Column import androidx.compose.foundation.layout.PaddingValues
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.material.icons.rounded.ContentCopy import androidx.compose.material3.Button
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.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.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.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
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @OptIn(ExperimentalComposeUiApi::class)
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class,
ExperimentalFoundationApi::class
)
@Composable @Composable
fun ConfigScreen( fun ConfigScreen(
viewModel: ConfigViewModel = hiltViewModel(), viewModel: ConfigViewModel = hiltViewModel(),
focusRequester: FocusRequester, padding: PaddingValues,
navController: NavController, navController: NavController,
showSnackbarMessage: (String) -> Unit, id : String?
id: String
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val focusManager = LocalFocusManager.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val scope = rememberCoroutineScope()
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 isAllApplicationsEnabled by viewModel.isAllApplicationsEnabled.collectAsStateWithLifecycle() val allApplications by viewModel.allApplications.collectAsStateWithLifecycle()
val proxyPeers by viewModel.proxyPeers.collectAsStateWithLifecycle()
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) {
scope.launch(Dispatchers.IO) { viewModel.getTunnelById(id)
try { viewModel.emitAllInternetCapablePackages()
viewModel.onScreenLoad(id) viewModel.emitCurrentPackageConfigurations(id)
} catch (e : Exception) {
showSnackbarMessage(e.message!!)
navController.navigate(Routes.Main.name)
}
}
} }
val applicationButtonText = { if(tunnel != null) {
"Tunneling apps: " + LazyColumn(
if (isAllApplicationsEnabled) "all" horizontalAlignment = Alignment.Start,
else "${checkedPackages.size} " + (if (include) "included" else "excluded") verticalArrangement = Arrangement.Top,
modifier = Modifier
} .fillMaxSize()
.padding(padding)
if(showAuthPrompt) { ) {
AuthorizationPrompt(onSuccess = { item {
showAuthPrompt = false Row(
isAuthenticated = true },
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 modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight(if (isAllApplicationsEnabled) 1 / 5f else 4 / 5f) .padding(horizontal = 20.dp, vertical = 7.dp),
) { verticalAlignment = Alignment.CenterVertically,
Column( horizontalArrangement = Arrangement.SpaceBetween
modifier = Modifier.fillMaxWidth()
) { ) {
OutlinedTextField(
value = tunnelName.value,
onValueChange = {
viewModel.onTunnelNameChange(it)
},
label = { Text(stringResource(id = R.string.tunnel_name)) },
maxLines = 1,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
focusManager.clearFocus()
keyboardController?.hide()
viewModel.onTunnelNameChange(tunnelName.value)
}
),
)
}
}
item {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -196,20 +116,19 @@ fun ConfigScreen(
) { ) {
Text(stringResource(id = R.string.tunnel_all)) Text(stringResource(id = R.string.tunnel_all))
Switch( Switch(
checked = isAllApplicationsEnabled, checked = allApplications,
onCheckedChange = { onCheckedChange = {
viewModel.onAllApplicationsChange(it) viewModel.onAllApplicationsChange(!allApplications)
} }
) )
} }
if (!isAllApplicationsEnabled) { }
if (!allApplications) {
item {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding( .padding(horizontal = 20.dp, vertical = 7.dp),
horizontal = 20.dp,
vertical = 7.dp
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
@@ -238,77 +157,49 @@ fun ConfigScreen(
) )
} }
} }
}
// LazyColumn(
// modifier = Modifier
// .fillMaxWidth()
// .fillMaxHeight(.75f)
// .padding(horizontal = 14.dp, vertical = 7.dp),
// verticalArrangement = Arrangement.Center,
// horizontalAlignment = Alignment.Start
// ) {
items(packages) { pack ->
Row( Row(
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = 20.dp,
vertical = 7.dp
),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween
) { ) {
SearchBar(viewModel::emitQueriedPackages)
}
Spacer(Modifier.padding(5.dp))
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxHeight(4 / 5f)
) {
items(
sortedPackages,
key = { it.packageName }) { pack ->
Row( Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.padding(5.dp)
modifier = Modifier
.fillMaxSize()
.padding(5.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(
fillMaxWidth
)
) { ) {
val drawable = val drawable =
pack.applicationInfo?.loadIcon( pack.applicationInfo?.loadIcon(context.packageManager)
context.packageManager
)
if (drawable != null) { if (drawable != null) {
Image( Image(
painter = DrawablePainter( painter = DrawablePainter(drawable),
drawable
),
stringResource(id = R.string.icon), stringResource(id = R.string.icon),
modifier = Modifier.size( modifier = Modifier.size(50.dp, 50.dp)
50.dp,
50.dp
)
) )
} else { } else {
Icon( Icon(
Icons.Rounded.Android, Icons.Rounded.Android,
stringResource(id = R.string.edit), stringResource(id = R.string.edit),
modifier = Modifier.size( modifier = Modifier.size(50.dp, 50.dp)
50.dp,
50.dp
)
) )
} }
Text( Text(
viewModel.getPackageLabel(pack), pack.applicationInfo.loadLabel(context.packageManager)
modifier = Modifier.padding(5.dp) .toString(), modifier = Modifier.padding(5.dp)
) )
} }
Checkbox( Checkbox(
modifier = Modifier.fillMaxSize(),
checked = (checkedPackages.contains(pack.packageName)), checked = (checkedPackages.contains(pack.packageName)),
onCheckedChange = { onCheckedChange = {
if (it) viewModel.onAddCheckedPackage( if (it) viewModel.onAddCheckedPackage(pack.packageName) else viewModel.onRemoveCheckedPackage(
pack.packageName
) else viewModel.onRemoveCheckedPackage(
pack.packageName pack.packageName
) )
} }
@@ -316,345 +207,25 @@ fun ConfigScreen(
} }
} }
} }
} item {
Row( Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier.fillMaxWidth()
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) { ) {
TextButton( Button(onClick = {
onClick = {
showApplicationsDialog = false
}) {
Text(stringResource(R.string.done))
}
}
}
}
}
}
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 { scope.launch {
try {
viewModel.onSaveAllChanges() viewModel.onSaveAllChanges()
Toast.makeText(
context,
context.resources.getString(R.string.config_changes_saved),
Toast.LENGTH_LONG
).show()
navController.navigate(Routes.Main.name) navController.navigate(Routes.Main.name)
showSnackbarMessage(context.resources.getString(R.string.config_changes_saved))
} catch (e : Exception) {
Timber.e(e.message)
showSnackbarMessage(e.message!!)
} }
} }, Modifier.padding(25.dp)) {
}, Text(stringResource(id = R.string.save_changes))
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))
} }
} }
} }
@@ -8,44 +8,24 @@ import android.os.Build
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.toMutableStateList import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.wireguard.config.Config import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.wireguard.config.Interface import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
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.TunnelConfigDao
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 dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
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 timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ConfigViewModel @Inject constructor(private val application : Application, class ConfigViewModel @Inject constructor(private val application : Application,
private val tunnelRepo : TunnelConfigDao, private val tunnelRepo : Repository<TunnelConfig>,
private val settingsRepo : SettingsDoa private val settingsRepo : Repository<Settings>) : ViewModel() {
) : 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
@@ -55,89 +35,27 @@ 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 _isAllApplicationsEnabled = MutableStateFlow(false) private val _allApplications = MutableStateFlow(true)
val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow() val allApplications get() = _allApplications.asStateFlow()
private val _isDefaultTunnel = MutableStateFlow(false)
val isDefaultTunnel = _isDefaultTunnel.asStateFlow()
private lateinit var tunnelConfig: TunnelConfig suspend fun getTunnelById(id : String?) : TunnelConfig? {
suspend fun onScreenLoad(id : String) {
if(id != Constants.MANUAL_TUNNEL_CONFIG_ID) {
tunnelConfig = getTunnelConfigById(id) ?: throw WgTunnelException("Config not found")
emitScreenData()
} else {
emitEmptyScreenData()
}
}
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()) if(id != null) {
} catch (_ : Exception) { val config = tunnelRepo.getById(id.toLong())
if (config != null) {
_tunnel.emit(config)
_tunnelName.emit(config.name)
}
return config
}
return null
} catch (e : Exception) {
Timber.e(e.message)
null null
} }
} }
private suspend fun emitTunnelConfig() {
_tunnel.emit(tunnelConfig)
}
private suspend fun emitTunnelConfigName() {
_tunnelName.emit(tunnelConfig.name)
}
fun onTunnelNameChange(name : String) { fun onTunnelNameChange(name : String) {
_tunnelName.value = name _tunnelName.value = name
} }
@@ -149,75 +67,38 @@ class ConfigViewModel @Inject constructor(private val application : Application,
_checkedPackages.value.add(packageName) _checkedPackages.value.add(packageName)
} }
fun onAllApplicationsChange(isAllApplicationsEnabled : Boolean) { fun onAllApplicationsChange(allApplications : Boolean) {
_isAllApplicationsEnabled.value = isAllApplicationsEnabled _allApplications.value = allApplications
} }
fun onRemoveCheckedPackage(packageName : String) { fun onRemoveCheckedPackage(packageName : String) {
_checkedPackages.value.remove(packageName) _checkedPackages.value.remove(packageName)
} }
private suspend fun emitSplitTunnelConfiguration(config : Config) { suspend fun emitCurrentPackageConfigurations(id : String?) {
val tunnelConfig = getTunnelById(id)
if(tunnelConfig != null) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
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.isNullOrEmpty() && includedApps.isNullOrEmpty()) {
emitTunnelAllApplicationsDisabled() _allApplications.emit(true)
determineAppInclusionState(excludedApps, includedApps) return
} else {
emitTunnelAllApplicationsEnabled()
} }
}
private suspend fun determineAppInclusionState(excludedApps : Set<String>, includedApps : Set<String>) {
if(excludedApps.isEmpty()) { if(excludedApps.isEmpty()) {
emitIncludedAppsExist()
emitCheckedApps(includedApps)
} else {
emitExcludedAppsExist()
emitCheckedApps(excludedApps)
}
}
private suspend fun emitIncludedAppsExist() {
_include.emit(true) _include.emit(true)
} _checkedPackages.emit(includedApps.toMutableStateList())
} else {
private suspend fun emitExcludedAppsExist() {
_include.emit(false) _include.emit(false)
_checkedPackages.emit(excludedApps.toMutableStateList())
} }
_allApplications.emit(false)
private suspend fun emitCheckedApps(apps : Set<String>) {
_checkedPackages.emit(apps.toMutableStateList())
}
private suspend fun emitTunnelAllApplicationsEnabled() {
_isAllApplicationsEnabled.emit(true)
}
private suspend fun emitTunnelAllApplicationsDisabled() {
_isAllApplicationsEnabled.emit(false)
}
private fun emitCurrentPackageConfigurations() {
viewModelScope.launch(Dispatchers.IO) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
emitSplitTunnelConfiguration(config)
} }
} }
fun emitQueriedPackages(query : String) { suspend fun emitAllInternetCapablePackages() {
viewModelScope.launch(Dispatchers.IO) { _packages.emit(getAllInternetCapablePackages())
val packages = getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
} }
_packages.emit(packages)
}
}
fun getPackageLabel(packageInfo : PackageInfo) : String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
}
private fun getAllInternetCapablePackages() : List<PackageInfo> { private fun getAllInternetCapablePackages() : List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET)) return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
@@ -227,169 +108,39 @@ class ConfigViewModel @Inject constructor(private val application : Application,
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 {
@Suppress("DEPRECATION")
packageManager.getPackagesHoldingPermissions(permissions, 0) packageManager.getPackagesHoldingPermissions(permissions, 0)
} }
} }
private fun isAllApplicationsEnabled() : Boolean { suspend fun onSaveAllChanges() {
return _isAllApplicationsEnabled.value var wgQuick = _tunnel.value?.wgQuick
if(wgQuick != null) {
wgQuick = if(_include.value) {
TunnelConfig.setIncludedApplicationsOnQuick(_checkedPackages.value, wgQuick)
} else {
TunnelConfig.setExcludedApplicationsOnQuick(_checkedPackages.value, wgQuick)
} }
if(_allApplications.value) {
private fun isIncludeApplicationsEnabled() : Boolean { wgQuick = TunnelConfig.clearAllApplicationsFromConfig(wgQuick)
return _include.value
} }
_tunnel.value?.copy(
private suspend fun saveConfig(tunnelConfig: TunnelConfig) { name = _tunnelName.value,
tunnelRepo.save(tunnelConfig) wgQuick = wgQuick
} )?.let {
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) { tunnelRepo.save(it)
if(tunnelConfig != null) {
saveConfig(tunnelConfig)
updateSettingsDefaultTunnel(tunnelConfig)
}
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) { if(settings != null) {
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(it.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
settingsRepo.save(setting.copy( settingsRepo.save(setting.copy(
defaultTunnel = tunnelConfig.toString() defaultTunnel = it.toString()
)) ))
} }
} }
} }
} }
fun buildPeerListFromProxyPeers() : List<Peer> {
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()
}
}
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() {
try {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig = _tunnel.value?.copy(
name = _tunnelName.value,
wgQuick = config.toWgQuickString()
)
updateTunnelConfig(tunnelConfig)
} catch (e : Exception) {
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("")
} }
} }
} }
@@ -0,0 +1,139 @@
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.getTunnelById(id)
}
if(tunnel != null) {
val interfaceKey = tunnel?.`interface`?.keyPair?.publicKey?.toBase64().toString()
val addresses = tunnel?.`interface`?.addresses!!.joinToString()
val dnsServers = tunnel?.`interface`?.dnsServers!!.joinToString()
val optionalMtu = tunnel?.`interface`?.mtu
val mtu = if(optionalMtu?.isPresent == true) optionalMtu.get().toString() else "None"
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(padding)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(stringResource(R.string.config_interface), fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(stringResource(R.string.name), fontStyle = FontStyle.Italic)
Text(text = tunnelName, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(tunnelName))
})
Text(stringResource(R.string.public_key), fontStyle = FontStyle.Italic)
Text(text = interfaceKey, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(interfaceKey))
})
Text(stringResource(R.string.addresses), fontStyle = FontStyle.Italic)
Text(text = addresses, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(addresses))
})
Text(stringResource(R.string.dns_servers), fontStyle = FontStyle.Italic)
Text(text = dnsServers, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(dnsServers))
})
Text(stringResource(R.string.mtu), fontStyle = FontStyle.Italic)
Text(text = mtu, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(mtu))
})
Box(modifier = Modifier.padding(10.dp))
tunnel?.peers?.forEach{
val peerKey = it.publicKey.toBase64().toString()
val allowedIps = it.allowedIps.joinToString()
val endpoint = if(it.endpoint.isPresent) it.endpoint.get().toString() else "None"
Text(stringResource(R.string.peer), fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(stringResource(R.string.public_key), fontStyle = FontStyle.Italic)
Text(text = peerKey, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(peerKey))
})
Text(stringResource(id = R.string.allowed_ips), fontStyle = FontStyle.Italic)
Text(text = allowedIps, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(allowedIps))
})
Text(stringResource(R.string.endpoint), fontStyle = FontStyle.Italic)
Text(text = endpoint, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(endpoint))
})
if (tunnelStats != null) {
val totalRx = tunnelStats?.totalRx() ?: 0
val totalTx = tunnelStats?.totalTx() ?: 0
if((totalRx + totalTx != 0L)) {
val rxKB = NumberUtils.bytesToKB(tunnelStats!!.totalRx())
val txKB = NumberUtils.bytesToKB(tunnelStats!!.totalTx())
Text(stringResource(R.string.transfer), fontStyle = FontStyle.Italic)
Text("rx: ${NumberUtils.formatDecimalTwoPlaces(rxKB)} KB tx: ${NumberUtils.formatDecimalTwoPlaces(txKB)} KB")
Text(stringResource(R.string.last_handshake), fontStyle = FontStyle.Italic)
val handshakeEpoch = lastHandshake[it.publicKey]
if(handshakeEpoch != null) {
if(handshakeEpoch == 0L) {
Text("Never")
} else {
val time = Instant.ofEpochMilli(handshakeEpoch)
Text("${Duration.between(time, Instant.now()).seconds} seconds ago")
}
}
}
}
}
}
}
}
}
}
@@ -0,0 +1,45 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.detail
import androidx.lifecycle.ViewModel
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class DetailViewModel @Inject constructor(private val tunnelRepo : Repository<TunnelConfig>, private val vpnService : VpnService
) : ViewModel() {
private val _tunnel = MutableStateFlow<Config?>(null)
val tunnel get() = _tunnel.asStateFlow()
private val _tunnelName = MutableStateFlow<String>("")
val tunnelName = _tunnelName.asStateFlow()
val tunnelStats get() = vpnService.statistics
val lastHandshake get() = vpnService.lastHandshake
private var config : TunnelConfig? = null
suspend fun getTunnelById(id : String?) : TunnelConfig? {
return try {
if(id != null) {
config = tunnelRepo.getById(id.toLong())
if (config != null) {
_tunnel.emit(TunnelConfig.configFromQuick(config!!.wgQuick))
_tunnelName.emit(config!!.name)
}
return config
}
return null
} catch (e : Exception) {
Timber.e(e.message)
null
}
}
}
@@ -1,17 +1,12 @@
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.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
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -20,21 +15,16 @@ 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
import androidx.compose.material.icons.rounded.Circle 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.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
@@ -44,12 +34,14 @@ 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
@@ -58,9 +50,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
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
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
@@ -76,48 +65,37 @@ 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.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
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.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(), viewModel: MainViewModel = hiltViewModel(), padding: PaddingValues,
padding: PaddingValues, snackbarHostState: SnackbarHostState, navController: NavController
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 { Dispatchers.IO } val scope = rememberCoroutineScope()
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(HandshakeStatus.NOT_STARTED)
val viewState = viewModel.viewState.collectAsStateWithLifecycle()
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 = remember {
@@ -136,86 +114,25 @@ fun MainScreen(
} }
} }
val tunnelFileImportResultLauncher = rememberLauncherForActivityResult(object : ActivityResultContracts.GetContent() { LaunchedEffect(viewState.value) {
override fun createIntent(context: Context, input: String): Intent { if (viewState.value.showSnackbarMessage) {
val intent = super.createIntent(context, input) val result = snackbarHostState.showSnackbar(
message = viewState.value.snackbarMessage,
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than actionLabel = viewState.value.snackbarActionText,
* what we can do, so detect this and throw an exception that we can catch later. */ duration = SnackbarDuration.Long,
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)
}
}
}
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")
}
}
}
}
}
) )
when (result) {
if(showPrimaryChangeAlertDialog) { SnackbarResult.ActionPerformed -> viewState.value.onSnackbarActionClick
AlertDialog( SnackbarResult.Dismissed -> viewState.value.onSnackbarActionClick
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)) }
)
} }
fun onTunnelToggle(checked : Boolean , tunnel : TunnelConfig) { val pickFileLauncher = rememberLauncherForActivityResult(
try { ActivityResultContracts.GetContent()
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop() ) { file ->
} catch (e : Exception) { if (file != null) {
showSnackbarMessage(e.message!!) viewModel.onTunnelFileSelected(file)
} }
} }
@@ -232,22 +149,12 @@ fun MainScreen(
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 modifier = Modifier.padding(bottom = 90.dp),
.padding(bottom = 90.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
}
,
onClick = { onClick = {
showBottomSheet = true showBottomSheet = true
}, },
containerColor = fobColor, containerColor = MaterialTheme.colorScheme.secondary,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
) { ) {
Icon( Icon(
@@ -283,11 +190,7 @@ fun MainScreen(
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
try { pickFileLauncher.launch("*/*")
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
}
} }
.padding(10.dp) .padding(10.dp)
) { ) {
@@ -297,24 +200,17 @@ fun MainScreen(
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) )
Text( Text(
stringResource(id = R.string.add_tunnels_text), stringResource(id = R.string.add_from_file),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp)
) )
} }
if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Divider() Divider()
Row(modifier = Modifier Row(modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
scope.launch { scope.launch {
showBottomSheet = false showBottomSheet = false
val scanOptions = ScanOptions() viewModel.onTunnelQRSelected()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(context.getString(R.string.scanning_qr))
scanOptions.setBeepEnabled(false)
scanOptions.captureActivity = CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
} }
} }
.padding(10.dp) .padding(10.dp)
@@ -330,27 +226,6 @@ fun MainScreen(
) )
} }
} }
Divider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
showBottomSheet = false
navController.navigate("${Routes.Config.name}/${Constants.MANUAL_TUNNEL_CONFIG_ID}")
}
.padding(10.dp)
) {
Icon(
Icons.Filled.Create,
contentDescription = stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp)
)
Text(
stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp)
)
}
}
} }
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
@@ -359,80 +234,38 @@ fun MainScreen(
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(padding)
) { ) {
LazyColumn(
modifier = Modifier LazyColumn(modifier = Modifier.fillMaxSize()
.fillMaxSize() .nestedScroll(nestedScrollConnection),) {
.nestedScroll(nestedScrollConnection), items(tunnels.toList()) { tunnel ->
) { RowListItem(leadingIcon = Icons.Rounded.Circle,
items(tunnels, key = { tunnel -> tunnel.id }) { tunnel -> leadingIconColor = when (handshakeStatus) {
val leadingIconColor = (if (tunnelName == tunnel.name) when (handshakeStatus) {
HandshakeStatus.HEALTHY -> mint HandshakeStatus.HEALTHY -> mint
HandshakeStatus.UNHEALTHY -> brickRed HandshakeStatus.UNHEALTHY -> brickRed
HandshakeStatus.NOT_STARTED -> Color.Gray HandshakeStatus.NOT_STARTED -> Color.Gray
HandshakeStatus.NEVER_CONNECTED -> brickRed 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) {
showSnackbarMessage(context.resources.getString(R.string.turn_off_tunnel)) scope.launch {
viewModel.showSnackBarMessage(context.resources.getString(R.string.turn_off_tunnel))
}
return@RowListItem return@RowListItem
} }
haptic.performHapticFeedback(HapticFeedbackType.LongPress) haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel selectedTunnel = tunnel;
}, },
onClick = { onClick = { navController.navigate("${Routes.Detail.name}/${tunnel.id}") },
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
if(state == Tunnel.State.UP && (tunnelName == tunnel.name) ) {
expanded.value = !expanded.value
}
} else {
selectedTunnel = tunnel
focusRequester.requestFocus()
}
},
statistics = statistics,
expanded = expanded.value,
rowButton = { rowButton = {
if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (tunnel.id == selectedTunnel?.id) {
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(onClick = { viewModel.onDelete(tunnel) }) {
modifier = Modifier.focusable(),
onClick = { viewModel.onDelete(tunnel) }) {
Icon( Icon(
Icons.Rounded.Delete, Icons.Rounded.Delete,
stringResource(id = R.string.delete) stringResource(id = R.string.delete)
@@ -440,74 +273,12 @@ fun MainScreen(
} }
} }
} else { } else {
val checked = state == Tunnel.State.UP && tunnel.name == tunnelName Switch(
if(!checked) expanded.value = false checked = (state == Tunnel.State.UP && tunnel.name == tunnelName),
@Composable
fun TunnelSwitch() = Switch(
modifier = Modifier.focusRequester(focusRequester),
checked = checked,
onCheckedChange = { checked -> onCheckedChange = { checked ->
if(!checked) expanded.value = false if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
onTunnelToggle(checked, tunnel)
} }
) )
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
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(
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(
Icons.Rounded.Edit,
stringResource(id = R.string.edit)
)
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName)
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
else {
viewModel.onDelete(tunnel)
}
}) {
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete)
)
}
TunnelSwitch()
}
} else {
TunnelSwitch()
}
} }
}) })
} }
@@ -1,59 +1,61 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.annotation.SuppressLint
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.database.Cursor
import android.net.Uri 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.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceTracker
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.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.ViewState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
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.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import timber.log.Timber
import java.io.InputStream
import java.util.zip.ZipInputStream
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel @Inject constructor(private val application : Application,
private val application: Application, private val tunnelRepo : Repository<TunnelConfig>,
private val tunnelRepo: TunnelConfigDao, private val settingsRepo : Repository<Settings>,
private val settingsRepo: SettingsDoa, private val vpnService: VpnService,
private val vpnService: VpnService private val codeScanner: CodeScanner
) : ViewModel() { ) : ViewModel() {
val tunnels get() = tunnelRepo.getAllFlow() private val _viewState = MutableStateFlow(ViewState())
val viewState get() = _viewState.asStateFlow()
val tunnels get() = tunnelRepo.itemFlow
val state get() = vpnService.state val state get() = vpnService.state
val handshakeStatus get() = vpnService.handshakeStatus val handshakeStatus get() = vpnService.handshakeStatus
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
private val defaultConfigName = {
"tunnel${(Math.random() * 100000).toInt()}"
}
init { init {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch {
settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect { settingsRepo.itemFlow.collect {
val settings = it.first() val settings = it.first()
validateWatcherServiceState(settings) validateWatcherServiceState(settings)
_settings.emit(settings) _settings.emit(settings)
@@ -62,25 +64,25 @@ class MainViewModel @Inject constructor(
} }
private fun validateWatcherServiceState(settings: Settings) { private fun validateWatcherServiceState(settings: Settings) {
val watcherState = ServiceManager.getServiceState( val watcherState = ServiceTracker.getServiceState(application, WireGuardConnectivityWatcherService::class.java)
application.applicationContext,
WireGuardConnectivityWatcherService::class.java
)
if(settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) { if(settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) {
ServiceManager.startWatcherService( startWatcherService(settings.defaultTunnel!!)
application.applicationContext,
settings.defaultTunnel!!
)
} }
} }
private fun startWatcherService(tunnel : String) {
ServiceTracker.actionOnService(
Action.START, application,
WireGuardConnectivityWatcherService::class.java,
mapOf(application.resources.getString(R.string.tunnel_extras_key) to tunnel))
}
fun onDelete(tunnel : TunnelConfig) { fun onDelete(tunnel : TunnelConfig) {
viewModelScope.launch { viewModelScope.launch {
if(tunnelRepo.count() == 1L) { if(tunnelRepo.count() == 1L) {
ServiceManager.stopWatcherService(application.applicationContext) ServiceTracker.actionOnService( Action.STOP, application, WireGuardConnectivityWatcherService::class.java)
val settings = settingsRepo.getAll() val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) { if(!settings.isNullOrEmpty()) {
val setting = settings[0] val setting = settings[0]
setting.defaultTunnel = null setting.defaultTunnel = null
setting.isAutoTunnelEnabled = false setting.isAutoTunnelEnabled = false
@@ -92,147 +94,87 @@ class MainViewModel @Inject constructor(
} }
} }
fun onTunnelStart(tunnelConfig: TunnelConfig) { fun onTunnelStart(tunnelConfig : TunnelConfig) = viewModelScope.launch {
viewModelScope.launch { ServiceTracker.actionOnService( Action.START, application, WireGuardTunnelService::class.java,
stopActiveTunnel() mapOf(application.resources.getString(R.string.tunnel_extras_key) to tunnelConfig.toString()))
startTunnel(tunnelConfig)
}
}
private fun startTunnel(tunnelConfig: TunnelConfig) {
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
}
private suspend fun stopActiveTunnel() {
if (ServiceManager.getServiceState(
application.applicationContext,
WireGuardTunnelService::class.java,
) == ServiceState.STARTED
) {
onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY)
}
} }
fun onTunnelStop() { fun onTunnelStop() {
ServiceManager.stopVpnService(application.applicationContext) ServiceTracker.actionOnService( Action.STOP, application, WireGuardTunnelService::class.java)
} }
private fun validateConfigString(config: String) { suspend fun onTunnelQRSelected() {
TunnelConfig.configFromQuick(config) codeScanner.scan().collect {
if(!it.isNullOrEmpty() && it.contains(application.resources.getString(R.string.config_validation))) {
tunnelRepo.save(TunnelConfig(name = defaultConfigName(), wgQuick = it))
} else if(!it.isNullOrEmpty() && it.contains(application.resources.getString(R.string.barcode_downloading))) {
showSnackBarMessage(application.resources.getString(R.string.barcode_downloading_message))
} else {
showSnackBarMessage(application.resources.getString(R.string.barcode_error))
}
}
} }
suspend fun onTunnelQrResult(result: String) { fun onTunnelFileSelected(uri : Uri) {
try { try {
validateConfigString(result) val fileName = getFileName(application.applicationContext, uri)
val tunnelConfig = val extension = getFileExtensionFromFileName(fileName)
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) if(extension != ".conf") {
addTunnel(tunnelConfig) viewModelScope.launch {
} catch (e : Exception) { showSnackBarMessage(application.resources.getString(R.string.file_extension_message))
throw WgTunnelException(e)
} }
return
} }
val stream = application.applicationContext.contentResolver.openInputStream(uri)
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) { stream ?: return
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8) val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader) val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName) val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString())) viewModelScope.launch {
withContext(Dispatchers.IO) { tunnelRepo.save(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
}
stream.close() stream.close()
} } catch(_: BadConfigException) {
} viewModelScope.launch {
showSnackBarMessage(application.applicationContext.getString(R.string.bad_config))
private fun getInputStreamFromUri(uri: Uri): InputStream {
return application.applicationContext.contentResolver.openInputStream(uri)
?: throw WgTunnelException(application.getString(R.string.stream_failed))
}
suspend fun onTunnelFileSelected(uri: Uri) {
try {
val fileName = getFileName(application.applicationContext, uri)
val fileExtension = getFileExtensionFromFileName(fileName)
when(fileExtension){
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri)
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> throw WgTunnelException(application.getString(R.string.file_extension_message))
}
} catch (e: Exception) {
throw WgTunnelException(e)
}
}
private suspend fun saveTunnelsFromZipUri(uri: Uri) {
ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot { it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION }
.forEach {
val name = getNameFromFileName(it.name)
val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
}
} }
} }
} }
private suspend fun saveTunnelFromConfUri(name : String, uri: Uri) { @SuppressLint("Range")
val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, name)
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig)
}
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig)
}
private fun getFileNameByCursor(context: Context, uri: Uri): String {
val cursor = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null) {
cursor.use {
return getDisplayNameByCursor(it)
}
} else {
throw WgTunnelException("Failed to initialize cursor")
}
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (columnIndex == -1) {
throw WgTunnelException("Cursor out of bounds")
}
return columnIndex
}
private fun getDisplayNameByCursor(cursor: Cursor): String {
if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
return cursor.getString(index)
} else {
throw WgTunnelException("Cursor failed to move to first")
}
}
private fun validateUriContentScheme(uri: Uri) {
if (uri.scheme != Constants.URI_CONTENT_SCHEME) {
throw WgTunnelException(application.getString(R.string.file_extension_message))
}
}
private fun getFileName(context: Context, uri: Uri): String { private fun getFileName(context: Context, uri: Uri): String {
validateUriContentScheme(uri) if (uri.scheme == "content") {
return try { val cursor = context.contentResolver.query(uri, null, null, null, null)
getFileNameByCursor(context, uri) cursor ?: return defaultConfigName()
} catch (_: Exception) { cursor.use {
NumberUtils.generateRandomTunnelName() if(cursor.moveToFirst()) {
return cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
} }
} }
}
return defaultConfigName()
}
suspend fun showSnackBarMessage(message : String) {
_viewState.emit(_viewState.value.copy(
showSnackbarMessage = true,
snackbarMessage = message,
snackbarActionText = "Okay",
onSnackbarActionClick = {
viewModelScope.launch {
dismissSnackBar()
}
}
))
delay(3000)
dismissSnackBar()
}
private suspend fun dismissSnackBar() {
_viewState.emit(_viewState.value.copy(
showSnackbarMessage = false
))
}
private fun getNameFromFileName(fileName : String) : String { private fun getNameFromFileName(fileName : String) : String {
return fileName.substring(0 , fileName.lastIndexOf('.') ) return fileName.substring(0 , fileName.lastIndexOf('.') )
@@ -245,15 +187,4 @@ class MainViewModel @Inject constructor(
"" ""
} }
} }
suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) {
if (selectedTunnel != null) {
_settings.emit(
_settings.value.copy(
defaultTunnel = selectedTunnel.toString()
)
)
settingsRepo.save(_settings.value)
}
}
} }
@@ -3,26 +3,20 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.Manifest import android.Manifest
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
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.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
@@ -30,29 +24,32 @@ 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.Surface import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextField
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
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.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
@@ -62,90 +59,92 @@ 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.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
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.StorageUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File
@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,
showSnackbarMessage: (String) -> Unit, navController: NavController,
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 backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var isLocationDisclaimerNeeded by remember { mutableStateOf(true) } 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) }
LaunchedEffect(viewState) {
if (viewState.showSnackbarMessage) {
val screenPadding = 5.dp val result = snackbarHostState.showSnackbar(
val fillMaxWidth = .85f message = viewState.snackbarMessage,
actionLabel = viewState.snackbarActionText,
fun exportAllConfigs() { duration = SnackbarDuration.Long,
try { )
val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") } when (result) {
files.forEachIndexed { index, file -> SnackbarResult.ActionPerformed -> viewState.onSnackbarActionClick
file.outputStream().use { SnackbarResult.Dismissed -> viewState.onSnackbarActionClick
it.write(tunnels[index].wgQuick.toByteArray())
} }
} }
StorageUtil.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 {
try {
viewModel.onSaveTrustedSSID(currentText) viewModel.onSaveTrustedSSID(currentText)
currentText = "" currentText = ""
} catch (e : Exception) {
showSnackbarMessage(e.message ?: context.getString(R.string.unknown_error))
}
} }
} }
} }
fun isAllAutoTunnelPermissionsEnabled() : Boolean { if(!backgroundLocationState.status.isGranted) {
return(isBackgroundLocationGranted && fineLocationState.status.isGranted && !viewModel.isLocationServicesNeeded()) Column(horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(padding)) {
Icon(Icons.Rounded.LocationOff, contentDescription = stringResource(id = R.string.map), modifier = Modifier
.padding(30.dp)
.size(128.dp))
Text(stringResource(R.string.prominent_background_location_title), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 20.sp)
Text(stringResource(R.string.prominent_background_location_message), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 15.sp)
//Spacer(modifier = Modifier.weight(1f))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(30.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(onClick = {
navController.navigate(Routes.Main.name)
}) {
Text(stringResource(id = R.string.no_thanks))
} }
Button(onClick = {
fun openSettings() {
scope.launch { scope.launch {
val intentSettings = val intentSettings =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
@@ -153,72 +152,6 @@ fun SettingsScreen(
Uri.fromParts("package", context.packageName, null) Uri.fromParts("package", context.packageName, null)
context.startActivity(intentSettings) context.startActivity(intentSettings)
} }
}
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
if(!backgroundLocationState.status.isGranted) {
isBackgroundLocationGranted = false
} else {
isLocationDisclaimerNeeded = false
isBackgroundLocationGranted = true
}
}
if(Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
if(!fineLocationState.status.isGranted) {
isBackgroundLocationGranted = false
} else {
isLocationDisclaimerNeeded = false
isBackgroundLocationGranted = true
}
}
if(isLocationDisclaimerNeeded) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(padding)
) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier = Modifier
.padding(30.dp)
.size(128.dp)
)
Text(
stringResource(R.string.prominent_background_location_title),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 20.sp
)
Text(
stringResource(R.string.prominent_background_location_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 15.sp
)
Row(
modifier = if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier
.fillMaxWidth()
.padding(10.dp) else Modifier
.fillMaxWidth()
.padding(30.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = {
isLocationDisclaimerNeeded = false
}) {
Text(stringResource(id = R.string.no_thanks))
}
TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
openSettings()
}) { }) {
Text(stringResource(id = R.string.turn_on)) Text(stringResource(id = R.string.turn_on))
} }
@@ -227,22 +160,6 @@ fun SettingsScreen(
return return
} }
if(showAuthPrompt) {
AuthorizationPrompt(onSuccess = {
showAuthPrompt = false
exportAllConfigs() },
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(context.getString(R.string.authentication_failed))
})
}
if (tunnels.isEmpty()) { if (tunnels.isEmpty()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
@@ -260,9 +177,37 @@ fun SettingsScreen(
} }
return return
} }
if(!isLocationServicesEnabled) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, 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(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
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -270,60 +215,88 @@ fun SettingsScreen(
.clickable(indication = null, interactionSource = interactionSource) { .clickable(indication = null, interactionSource = interactionSource) {
focusManager.clearFocus() focusManager.clearFocus()
} }
.padding(padding)
) { ) {
Surface( Row(
tonalElevation = 2.dp, modifier = Modifier
shadowElevation = 2.dp, .fillMaxWidth()
shape = RoundedCornerShape(12.dp), .padding(14.dp),
color = MaterialTheme.colorScheme.surface, verticalAlignment = Alignment.CenterVertically,
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) horizontalArrangement = Arrangement.SpaceBetween
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
else Modifier
.fillMaxWidth(fillMaxWidth)
.padding(top = 60.dp)).padding(bottom = 25.dp)
) { ) {
Column( Text(stringResource(R.string.enable_auto_tunnel))
horizontalAlignment = Alignment.Start, Switch(
verticalArrangement = Arrangement.Top, enabled = !settings.isAlwaysOnVpnEnabled,
modifier = Modifier.padding(15.dp) checked = settings.isAutoTunnelEnabled,
) { onCheckedChange = {
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.onToggleTunnelOnWifi() viewModel.toggleAutoTunnel()
}
} }
},
modifier = Modifier.focusRequester(focusRequester)
) )
AnimatedVisibility(visible = settings.isTunnelOnWifiEnabled) { }
Column { Text(
stringResource(id = R.string.select_tunnel),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp, 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),
) {
TextField(
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = settings.defaultTunnel?.let {
TunnelConfig.from(it).name }
?: "",
readOnly = true,
modifier = Modifier.menuAnchor(),
label = { Text(stringResource(R.string.tunnels)) },
onValueChange = { },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = expanded
)
}
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = {
expanded = false
}
) {
tunnels.forEach() { tunnel ->
DropdownMenuItem(
onClick = {
scope.launch {
viewModel.onDefaultTunnelSelected(tunnel)
}
expanded = false
},
text = { Text(text = tunnel.name) }
)
}
}
}
Text(
stringResource(R.string.trusted_ssid),
textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp, bottom = 5.dp, top = 5.dp)
)
FlowRow( FlowRow(
modifier = Modifier.padding(screenPadding), modifier = Modifier.padding(15.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.SpaceEvenly verticalAlignment = Alignment.CenterVertically
) { ) {
trustedSSIDs.forEach { ssid -> trustedSSIDs.forEach { ssid ->
ClickableIconButton( ClickableIconButton(onIconClick = {
onIconClick = {
scope.launch { scope.launch {
viewModel.onDeleteTrustedSSID(ssid) viewModel.onDeleteTrustedSSID(ssid)
} }
}, }, text = ssid, icon = Icons.Filled.Close, enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled))
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( OutlinedTextField(
@@ -331,13 +304,7 @@ fun SettingsScreen(
value = currentText, value = currentText,
onValueChange = { currentText = it }, onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) }, label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier = Modifier modifier = Modifier.padding(start = 15.dp, top = 5.dp),
.padding(start = screenPadding, top = 5.dp, bottom = 10.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide()
}
},
maxLines = 1, maxLines = 1,
keyboardOptions = KeyboardOptions( keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
@@ -355,132 +322,46 @@ fun SettingsScreen(
contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource( contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource(
id = R.string.trusted_ssid_value_description id = R.string.trusted_ssid_value_description
), ),
tint = if (currentText == "") Color.Transparent else MaterialTheme.colorScheme.primary tint = if(currentText == "") Color.Transparent else Color.Green
) )
} }
}, },
) )
} Row(
} modifier = Modifier
ConfigurationToggle(stringResource(R.string.tunnel_mobile_data), .fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(stringResource(R.string.tunnel_mobile_data))
Switch(
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled), enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnMobileDataEnabled, checked = settings.isTunnelOnMobileDataEnabled,
padding = screenPadding, onCheckedChange = {
onCheckChanged = {
scope.launch { scope.launch {
viewModel.onToggleTunnelOnMobileData() 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( Row(
modifier = Modifier
.fillMaxWidth()
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier horizontalArrangement = Arrangement.SpaceBetween
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center
) { ) {
TextButton( Text(stringResource(R.string.always_on_vpn_support))
enabled = !settings.isAlwaysOnVpnEnabled, Switch(
onClick = {
//TODO fix logic for mobile only
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(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier
.fillMaxWidth(fillMaxWidth)
.height(IntrinsicSize.Min)
.padding(bottom = 180.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)
) {
SectionTitle(title = stringResource(id = R.string.other), padding = screenPadding)
ConfigurationToggle(stringResource(R.string.always_on_vpn_support),
enabled = !settings.isAutoTunnelEnabled, enabled = !settings.isAutoTunnelEnabled,
checked = settings.isAlwaysOnVpnEnabled, checked = settings.isAlwaysOnVpnEnabled,
padding = screenPadding, onCheckedChange = {
onCheckChanged = {
scope.launch { scope.launch {
viewModel.onToggleAlwaysOnVPN() viewModel.onToggleAlwaysOnVPN()
} }
} }
) )
ConfigurationToggle(stringResource(R.string.enabled_app_shortcuts),
enabled = true,
checked = settings.isShortcutsEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleShortcutsEnabled()
}
}
)
Row(
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)) {
Spacer(modifier = Modifier.weight(.17f))
} }
} }
} }
@@ -3,39 +3,40 @@ 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.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.repository.Repository
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceTracker
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.ViewState
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
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.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor(private val application : Application, class SettingsViewModel @Inject constructor(private val application : Application,
private val tunnelRepo : TunnelConfigDao, private val settingsRepo : SettingsDoa private val tunnelRepo : Repository<TunnelConfig>, private val settingsRepo : Repository<Settings>
) : 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 tunnels get() = tunnelRepo.getAllFlow() val tunnels get() = tunnelRepo.itemFlow
private val _viewState = MutableStateFlow(ViewState())
val viewState get() = _viewState.asStateFlow()
init { init {
isLocationServicesEnabled() checkLocationServicesEnabled()
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch {
settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect { settingsRepo.itemFlow.collect {
val settings = it.first() val settings = it.first()
_settings.emit(settings) _settings.emit(settings)
_trustedSSIDs.emit(settings.trustedNetworkSSIDs.toList()) _trustedSSIDs.emit(settings.trustedNetworkSSIDs.toList())
@@ -49,10 +50,16 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
_settings.value.trustedNetworkSSIDs.add(trimmed) _settings.value.trustedNetworkSSIDs.add(trimmed)
settingsRepo.save(_settings.value) settingsRepo.save(_settings.value)
} else { } else {
throw WgTunnelException("SSID already exists.") showSnackBarMessage("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(_settings.value.copy(
isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled
@@ -64,83 +71,70 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
settingsRepo.save(_settings.value) settingsRepo.save(_settings.value)
} }
private fun emitFirstTunnelAsDefault() = viewModelScope.async {
_settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString()))
}
suspend fun toggleAutoTunnel() { suspend fun toggleAutoTunnel() {
if(_settings.value.isAutoTunnelEnabled) { if(_settings.value.defaultTunnel.isNullOrEmpty() && !_settings.value.isAutoTunnelEnabled) {
ServiceManager.stopWatcherService(application) showSnackBarMessage(application.getString(R.string.select_tunnel_message))
} else { return
if(_settings.value.defaultTunnel == null) {
emitFirstTunnelAsDefault().await()
} }
val defaultTunnel = _settings.value.defaultTunnel if(_settings.value.isAutoTunnelEnabled) {
ServiceManager.startWatcherService(application, defaultTunnel!!) actionOnWatcherService(Action.STOP)
} else {
actionOnWatcherService(Action.START)
} }
settingsRepo.save(_settings.value.copy( settingsRepo.save(_settings.value.copy(
isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled
)) ))
} }
private suspend fun getFirstTunnelConfig() : TunnelConfig { private fun actionOnWatcherService(action : Action) {
return tunnelRepo.getAll().first() when(action) {
Action.START -> {
if(_settings.value.defaultTunnel != null) {
val defaultTunnel = _settings.value.defaultTunnel
ServiceTracker.actionOnService(
action, application,
WireGuardConnectivityWatcherService::class.java,
mapOf(application.resources.getString(R.string.tunnel_extras_key) to defaultTunnel.toString()))
}
}
Action.STOP -> {
ServiceTracker.actionOnService( Action.STOP, application,
WireGuardConnectivityWatcherService::class.java)
}
}
}
suspend fun showSnackBarMessage(message : String) {
_viewState.emit(_viewState.value.copy(
showSnackbarMessage = true,
snackbarMessage = message,
snackbarActionText = "Okay",
onSnackbarActionClick = {
viewModelScope.launch {
dismissSnackBar()
}
}
))
}
private suspend fun dismissSnackBar() {
_viewState.emit(_viewState.value.copy(
showSnackbarMessage = false
))
} }
suspend fun onToggleAlwaysOnVPN() { suspend fun onToggleAlwaysOnVPN() {
if(_settings.value.defaultTunnel == null) { if(_settings.value.defaultTunnel != null) {
emitFirstTunnelAsDefault().await()
}
val updatedSettings = _settings.value.copy(isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled)
emitSettings(updatedSettings)
saveSettings(updatedSettings)
}
private suspend fun emitSettings(settings: Settings) {
_settings.emit( _settings.emit(
settings _settings.value.copy(isAlwaysOnVpnEnabled = !_settings.value.isAlwaysOnVpnEnabled)
)
}
private suspend fun saveSettings(settings: Settings) {
settingsRepo.save(settings)
}
suspend fun onToggleTunnelOnEthernet() {
if(_settings.value.defaultTunnel == null) {
emitFirstTunnelAsDefault().await()
}
_settings.emit(
_settings.value.copy(isTunnelOnEthernetEnabled = !_settings.value.isTunnelOnEthernetEnabled)
) )
settingsRepo.save(_settings.value) settingsRepo.save(_settings.value)
} else {
showSnackBarMessage(application.getString(R.string.select_tunnel_message))
} }
}
private fun isLocationServicesEnabled() : Boolean { fun checkLocationServicesEnabled() : 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
))
}
suspend fun onToggleTunnelOnWifi() {
settingsRepo.save(_settings.value.copy(
isTunnelOnWifiEnabled = !_settings.value.isTunnelOnWifiEnabled
))
}
} }
@@ -1,39 +1,24 @@
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.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.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.MaterialTheme import androidx.compose.material3.IconButton
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.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
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -43,17 +28,12 @@ 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 com.zaneschepke.wireguardautotunnel.BuildConfig
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
@Composable @Composable
fun SupportScreen(padding : PaddingValues, focusRequester: FocusRequester) { fun SupportScreen(padding : PaddingValues) {
val context = LocalContext.current val context = LocalContext.current
val fillMaxWidth = .85f
fun openWebPage(url: String) { fun openWebPage(url: String) {
val webpage: Uri = Uri.parse(url) val webpage: Uri = Uri.parse(url)
@@ -61,81 +41,29 @@ fun SupportScreen(padding : PaddingValues, focusRequester: FocusRequester) {
context.startActivity(intent) context.startActivity(intent)
} }
fun launchEmail() {
val intent = Intent(Intent.ACTION_SEND).apply {
type = Constants.EMAIL_MIME_TYPE
putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email))
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject))
}
startActivity(context,createChooser(intent, context.getString(R.string.email_chooser)),null)
}
Column(horizontalAlignment = Alignment.CenterHorizontally, Column(horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.focusable()
.padding(padding)) { .padding(padding)) {
Surface( Text(stringResource(R.string.support_text), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 15.sp)
tonalElevation = 2.dp, Row(
shadowElevation = 2.dp, modifier = Modifier
shape = RoundedCornerShape(12.dp), .fillMaxWidth()
color = MaterialTheme.colorScheme.surface, .padding(14.dp),
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) verticalAlignment = Alignment.CenterVertically,
Modifier horizontalArrangement = Arrangement.SpaceEvenly
.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)) { IconButton(onClick = {
Text(stringResource(R.string.thank_you), textAlign = TextAlign.Start, modifier = Modifier.padding(bottom = 20.dp), fontSize = 16.sp) openWebPage(context.resources.getString(R.string.discord_url))
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)) { Icon(imageVector = ImageVector.vectorResource(R.drawable.discord), "Discord")
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))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(onClick = { openWebPage(context.resources.getString(R.string.discord_url)) }, 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))
}
} }
IconButton(onClick = {
openWebPage(context.resources.getString(R.string.github_url))
}) {
Icon(imageVector = ImageVector.vectorResource(R.drawable.github), "Github")
} }
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
@@ -143,6 +71,6 @@ fun SupportScreen(padding : PaddingValues, focusRequester: FocusRequester) {
modifier = Modifier.clickable { modifier = Modifier.clickable {
openWebPage(context.resources.getString(R.string.privacy_policy_url)) openWebPage(context.resources.getString(R.string.privacy_policy_url))
}) })
Text("App version: ${BuildConfig.VERSION_NAME}", Modifier.padding(25.dp)) Text("App version: ${com.zaneschepke.wireguardautotunnel.BuildConfig.VERSION_NAME}", Modifier.padding(25.dp))
} }
} }
@@ -1,34 +1,25 @@
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 const val BYTES_IN_KB = 1024L
private val BYTES_IN_MB = BYTES_IN_KB.pow(2.0)
private val keyValidationRegex = """^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=${'$'}""".toRegex()
fun bytesToMB(bytes : Long) : BigDecimal { fun bytesToKB(bytes : Long) : BigDecimal {
return bytes.toBigDecimal().divide(BYTES_IN_MB.toBigDecimal()) return bytes.toBigDecimal().divide(BYTES_IN_KB.toBigDecimal())
} }
fun isValidKey(key : String) : Boolean { fun formatDecimalTwoPlaces(bigDecimal: BigDecimal) : String {
return key.matches(keyValidationRegex) val df = DecimalFormat("#.##")
return df.format(bigDecimal)
} }
fun generateRandomTunnelName() : String { fun getSecondsBetweenTimestampAndNow(epoch : Long) : Long {
return "tunnel${(Math.random() * 100000).toInt()}"
}
fun getSecondsBetweenTimestampAndNow(epoch : Long) : Long? {
return if (epoch != 0L) {
val time = Instant.ofEpochMilli(epoch) val time = Instant.ofEpochMilli(epoch)
return Duration.between(time, Instant.now()).seconds return Duration.between(time, Instant.now()).seconds
} else {
null
}
} }
} }
@@ -1,53 +0,0 @@
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 StorageUtil {
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,15 +0,0 @@
package com.zaneschepke.wireguardautotunnel.util
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"
}
}
}
-5
View File
@@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12V5l-9,-4z"/>
</vector>
-5
View File
@@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M20.83,18H21v-4h2v-4H12.83L20.83,18zM19.78,22.61l1.41,-1.41L2.81,2.81L1.39,4.22l2.59,2.59C2.2,7.85 1,9.79 1,12c0,3.31 2.69,6 6,6c2.21,0 4.15,-1.2 5.18,-2.99L19.78,22.61zM8.99,11.82C9,11.88 9,11.94 9,12c0,1.1 -0.9,2 -2,2s-2,-0.9 -2,-2s0.9,-2 2,-2c0.06,0 0.12,0 0.18,0.01L8.99,11.82z"/>
</vector>
-5
View File
@@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"/>
</vector>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_banner_background"/>
<foreground android:drawable="@mipmap/ic_banner_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_banner_background">#121212</color>
</resources>
+14 -85
View File
@@ -6,10 +6,9 @@
<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/issues</string> <string name="github_url">https://github.com/zaneschepke/wgtunnel</string>
<string name="docs_url">https://zaneschepke.com/wgtunnel-docs/overview.html</string> <string name="privacy_policy_url">https://zaneschepke.github.io/wgtunnel/</string>
<string name="privacy_policy_url">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string> <string name="file_extension_message">File is not a .conf file</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>
@@ -21,10 +20,11 @@
<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 wifi name</string> <string name="add_trusted_ssid">Add Trusted SSID</string>
<string name="trusted_ssid">Trusted SSID</string>
<string name="tunnels">Tunnels</string> <string name="tunnels">Tunnels</string>
<string name="enable_auto_tunnel">Start auto-tunneling</string> <string name="select_tunnel">Select Tunnel</string>
<string name="disable_auto_tunnel">Stop auto-tunneling</string> <string name="enable_auto_tunnel">Enable 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,16 +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="thank_you">Thank you for using WG Tunnel!</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="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_tunnels_text">Add from file or zip</string> <string name="invalid_qr">Invalid QR code.</string>
<string name="add_from_file">Add tunnel from files</string>
<string name="open_file">File Open</string> <string name="open_file">File Open</string>
<string name="add_from_qr">Add from QR code</string> <string name="add_from_qr">Add tunnel 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>
@@ -52,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</string> <string name="save_changes">Save changes</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>
@@ -62,6 +62,7 @@
<string name="public_key">Public key</string> <string name="public_key">Public key</string>
<string name="barcode_downloading">Waiting for the Barcode UI module to be downloaded.</string> <string name="barcode_downloading">Waiting for the Barcode UI module to be downloaded.</string>
<string name="barcode_downloading_message">Barcode module downloading. Try again.</string> <string name="barcode_downloading_message">Barcode module downloading. Try again.</string>
<string name="barcode_error">Invalid QR code. Try again.</string>
<string name="addresses">Addresses</string> <string name="addresses">Addresses</string>
<string name="dns_servers">DNS servers</string> <string name="dns_servers">DNS servers</string>
<string name="mtu">MTU</string> <string name="mtu">MTU</string>
@@ -76,81 +77,9 @@
<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">Allow Always-On VPN </string> <string name="always_on_vpn_support">Enable Always-On VPN support</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>
<string name="detecting_location_services_disabled">Detecting Location Services disabled</string> <string name="detecting_location_services_disabled">Detecting Location Services disabled</string>
<string name="precise_location_message">This feature requires precise location to access Wi-Fi SSID name. Please enable precise location here or in the app settings.</string>
<string name="request">Request</string>
<string name="toggle_vpn">Toggle VPN</string>
<string name="no_tunnel_available">No tunnels available</string>
<string name="hint_search_packages">Search packages</string>
<string name="clear_icon">Clear Icon</string>
<string name="search_icon">Search Icon</string>
<string name="attempt_connection">Attempting connection..</string>
<string name="vpn_starting">VPN Starting</string>
<string name="db_name">wg-tunnel-db</string>
<string name="scanning_qr">Scanning for QR</string>
<string name="qr_result_failed">QR scan failed</string>
<string name="none">No trusted wifi names</string>
<string name="never">Never</string>
<string name="stream_failed">Failed to open file stream.</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>
</resources> </resources>
-32
View File
@@ -1,32 +0,0 @@
<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,8 +1,9 @@
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: 0 B

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File
View File

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