Compare commits
64 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 34d71a6096 | |||
| ff87bee3b4 | |||
| 9739d35eda | |||
| 11ad494fbb | |||
| 90b006acc5 | |||
| eb7b39c379 | |||
| 0a17593310 | |||
| c0e58125dd | |||
| 3791261f91 | |||
| d1e61be3ae | |||
| afd4fb127f | |||
| e0cce8fba4 | |||
| b70ecbdfff | |||
| 513d08998b | |||
| 79583e0e61 | |||
| 75790ec6d5 | |||
| a1941b7229 | |||
| 37bae82700 | |||
| 77cd328a71 | |||
| 5a1430706b | |||
| 321730536d | |||
| 2912238f27 | |||
| bc7daacd13 | |||
| c8f2cfd758 | |||
| ca3f3fd439 | |||
| 235170508b | |||
| 11aea3f1c4 | |||
| 7fbc51af4c | |||
| 1714618f0c | |||
| 7cb798a111 | |||
| f8bc264f30 | |||
| 2174c3f48c | |||
| 413b9a37df | |||
| 6c30c6bae6 | |||
| 3c5aff31aa | |||
| 991d1224ab | |||
| 69b07eec6f | |||
| e81066f508 | |||
| 64bb9f3b82 | |||
| c1b560e822 | |||
| 14fe5821cc | |||
| 9d9b7bebca | |||
| 12d1ccc084 | |||
| 20cc2c09b0 | |||
| eeccc71469 | |||
| 0e64bbb4e1 | |||
| f513297ba0 | |||
| 135f8c0459 | |||
| 7a811f4152 | |||
| 2abf681d17 | |||
| 689c97f452 | |||
| 08d11a53b4 | |||
| 9952e97e1c | |||
| 4cdc974778 | |||
| e31a4c03cd | |||
| 5b94f22359 | |||
| c673a8dc91 | |||
| f6612abe28 | |||
| 7ca5de1836 | |||
| 509d22a98c | |||
| 68b0902398 | |||
| 0c45558293 | |||
| 89212fe191 | |||
| a98a47f54d |
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG] - Problem with app"
|
||||
labels: bug
|
||||
assignees: zaneschepke
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- Android Version: [e.g. iOS8.1]
|
||||
- App Version [e.g. 22]
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots (Only if necessary)**
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE] - New feature request"
|
||||
labels: enhancement
|
||||
assignees: zaneschepke
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Support
|
||||
|
||||
If you are experiencing issues with the app, the following resources are available to help you.
|
||||
|
||||
<ol>
|
||||
<li>
|
||||
See the app docs site <a href="https://zaneschepke.com/wgtunnel-docs/overview.html">here</a> (work in progress).
|
||||
</li>
|
||||
<li>
|
||||
Chat with me and our community on Discord <a href="https://discord.gg/rbRRNh6H7V">here</a>, or open an issue on GitHub <a href="https://github.com/zaneschepke/wgtunnel/issues/new/choose">here</a>.
|
||||
</li>
|
||||
<li>
|
||||
Send me an email <a href="mailto:zanecschepke@gmail.com">here</a>.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
Thank you for using WG Tunnel.
|
||||
@@ -0,0 +1,87 @@
|
||||
# name of the workflow
|
||||
name: Android CI Tag Deployment
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*.*.*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Signed APK
|
||||
# change to macos because of hilt issues on ubuntu in gradle 8.3
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
env:
|
||||
KEY_STORE_PATH: ${{ secrets.KEY_STORE_PATH }}
|
||||
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
|
||||
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
|
||||
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v3
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
cache: gradle
|
||||
- name: Grant execute permission for gradlew
|
||||
run: chmod +x gradlew
|
||||
|
||||
# Here we need to decode keystore.jks from base64 string and place it
|
||||
# in the folder specified in the release signing configuration
|
||||
- name: Decode Keystore
|
||||
id: decode_keystore
|
||||
uses: timheuer/base64-to-file@v1.2
|
||||
with:
|
||||
fileName: 'android_keystore.jks'
|
||||
fileDir: ${{ github.workspace }}/app/keystore/
|
||||
encodedString: ${{ secrets.KEYSTORE }}
|
||||
|
||||
- name: Create service_account.json
|
||||
id: createServiceAccount
|
||||
run: echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
|
||||
|
||||
# Build and sign APK ("-x test" argument is used to skip tests)
|
||||
# add fdroid flavor for apk upload
|
||||
- name: Build Fdroid Release APK
|
||||
run: ./gradlew :app:assembleFdroidRelease -x test
|
||||
|
||||
# get fdroid flavor release apk path
|
||||
- name: Get apk path
|
||||
id: apk-path
|
||||
run: echo "path=$(find . -regex '^.*/build/outputs/apk/fdroid/release/.*\.apk$' -type f | head -1)" >> $GITHUB_OUTPUT
|
||||
|
||||
# Save the APK after the Build job is complete to publish it as a Github release in the next job
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v3.1.2
|
||||
with:
|
||||
name: wgtunnel
|
||||
path: ${{ steps.apk-path.outputs.path }}
|
||||
- name: Download APK from build
|
||||
uses: actions/download-artifact@v1
|
||||
with:
|
||||
name: wgtunnel
|
||||
- name: Create Release with Fastlane changelog notes
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
# fix hardcode changelog file name
|
||||
body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/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)
|
||||
|
||||
@@ -69,3 +69,5 @@ lint/tmp/
|
||||
# App Specific cases
|
||||
app/release/output.json
|
||||
.idea/codeStyles/
|
||||
# where we keep our signing secrets locally
|
||||
app/signing.properties
|
||||
|
||||
@@ -2,50 +2,64 @@
|
||||
WG Tunnel
|
||||
</h1>
|
||||
|
||||
<span align="center">
|
||||
<div align="center">
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://discord.gg/Ad5fuEts)
|
||||
[](https://discord.gg/rbRRNh6H7V)
|
||||
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span align="center">
|
||||
<div align="center">
|
||||
|
||||
|
||||
[](https://play.google.com/store/apps/details?id=com.zaneschepke.wireguardautotunnel)
|
||||
[](https://www.amazon.com/gp/product/B0CFGGL7WK)
|
||||
[](https://f-droid.org/packages/com.zaneschepke.wireguardautotunnel/)
|
||||
|
||||
</span>
|
||||
|
||||
<span align="left">
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
||||
[](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.
|
||||
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span align="center">
|
||||
<div align="center">
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p float="center">
|
||||
<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="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="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="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" />
|
||||
</p>
|
||||
|
||||
<span align="left">
|
||||
<div align="left">
|
||||
|
||||
## Inspiration
|
||||
|
||||
The inspiration for this app came from the inconvenience of constantly having to turn VPN off and on while on different networks. With there being no free solution to this problem, this app was created to meet that need.
|
||||
The original inspiration for this app came from the inconvenience of having to manually turn VPN off and on while on different networks. This app was created to offer a free solution to this problem.
|
||||
|
||||
## Features
|
||||
|
||||
* Add tunnels via .conf file
|
||||
* Add tunnels via .conf file, zip, manual entry, or QR code
|
||||
* Auto connect to VPN based on Wi-Fi SSID
|
||||
* Split tunneling by application
|
||||
* Configurable Trusted Network list
|
||||
* Optional auto connect on mobile data
|
||||
* Split tunneling by application with search
|
||||
* Always-on VPN for Android support
|
||||
* Export tunnels to zip
|
||||
* Quick tile support for VPN toggling
|
||||
* 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
|
||||
* Service will stay running in background after app has been closed
|
||||
|
||||
@@ -55,7 +69,7 @@ The inspiration for this app came from the inconvenience of constantly having to
|
||||
```
|
||||
$ git clone https://github.com/zaneschepke/wgtunnel
|
||||
$ cd wgtunnel
|
||||
$ ./gradlew assembleRelease
|
||||
$ ./gradlew assembleDebug
|
||||
```
|
||||
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
val rExtra = rootProject.extra
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
kotlin("kapt")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("io.objectbox")
|
||||
id("com.google.gms.google-services")
|
||||
id("com.google.firebase.crashlytics")
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.hilt.android)
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.zaneschepke.wireguardautotunnel"
|
||||
compileSdk = 33
|
||||
|
||||
val versionMajor = 2
|
||||
val versionMinor = 0
|
||||
val versionPatch = 1
|
||||
val versionBuild = 0
|
||||
namespace = Constants.APP_ID
|
||||
compileSdk = Constants.TARGET_SDK
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.zaneschepke.wireguardautotunnel"
|
||||
minSdk = 29
|
||||
targetSdk = 33
|
||||
versionCode = versionMajor * 10000 + versionMinor * 1000 + versionPatch * 100 + versionBuild
|
||||
versionName = "${versionMajor}.${versionMinor}.${versionPatch}"
|
||||
applicationId = Constants.APP_ID
|
||||
minSdk = Constants.MIN_SDK
|
||||
targetSdk = Constants.TARGET_SDK
|
||||
versionCode = Constants.VERSION_CODE
|
||||
versionName = Constants.VERSION_NAME
|
||||
|
||||
ksp {
|
||||
arg("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
|
||||
resourceConfigurations.addAll(listOf("en"))
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -33,28 +31,82 @@ 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 {
|
||||
//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 {
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"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 {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
jvmTarget = Constants.JVM_TARGET
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.4.7"
|
||||
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
@@ -63,70 +115,79 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
val generalImplementation by configurations
|
||||
dependencies {
|
||||
implementation("androidx.core:core-ktx:1.10.1")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
|
||||
implementation("androidx.activity:activity-compose:1.7.2")
|
||||
implementation(platform("androidx.compose:compose-bom:2023.03.00"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3:1.1.1")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
// optional - helpers for implementing LifecycleOwner in a Service
|
||||
implementation(libs.androidx.lifecycle.service)
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.ui.graphics)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.androidx.material3)
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation(platform("androidx.compose:compose-bom:2023.03.00"))
|
||||
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
//test
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(libs.androidx.compose.ui.test)
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
debugImplementation(libs.androidx.compose.manifest)
|
||||
|
||||
//wireguard tunnel
|
||||
implementation("com.wireguard.android:tunnel:1.0.20230405")
|
||||
//wg
|
||||
implementation(libs.tunnel)
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
|
||||
//logging
|
||||
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||
implementation(libs.timber)
|
||||
|
||||
// compose navigation
|
||||
implementation("androidx.navigation:navigation-compose:2.6.0")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.androidx.hilt.navigation.compose)
|
||||
|
||||
// hilt
|
||||
implementation("com.google.dagger:hilt-android:${rExtra.get("hiltVersion")}")
|
||||
kapt("com.google.dagger:hilt-android-compiler:${rExtra.get("hiltVersion")}")
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.android.compiler)
|
||||
|
||||
//accompanist
|
||||
implementation("com.google.accompanist:accompanist-systemuicontroller:${rExtra.get("accompanistVersion")}")
|
||||
implementation("com.google.accompanist:accompanist-permissions:${rExtra.get("accompanistVersion")}")
|
||||
implementation("com.google.accompanist:accompanist-flowlayout:${rExtra.get("accompanistVersion")}")
|
||||
implementation("com.google.accompanist:accompanist-navigation-animation:${rExtra.get("accompanistVersion")}")
|
||||
implementation("com.google.accompanist:accompanist-drawablepainter:${rExtra.get("accompanistVersion")}")
|
||||
implementation(libs.accompanist.systemuicontroller)
|
||||
implementation(libs.accompanist.permissions)
|
||||
implementation(libs.accompanist.flowlayout)
|
||||
implementation(libs.accompanist.drawablepainter)
|
||||
|
||||
//db
|
||||
implementation("io.objectbox:objectbox-kotlin:${rExtra.get("objectBoxVersion")}")
|
||||
//room
|
||||
implementation(libs.androidx.room.runtime)
|
||||
ksp(libs.androidx.room.compiler)
|
||||
implementation(libs.androidx.room.ktx)
|
||||
|
||||
//lifecycle
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.6.1")
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
|
||||
|
||||
//icons
|
||||
implementation("androidx.compose.material:material-icons-extended:1.4.3")
|
||||
|
||||
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.5.1")
|
||||
|
||||
implementation(libs.material.icons.extended)
|
||||
//serialization
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
//firebase crashlytics
|
||||
implementation(platform("com.google.firebase:firebase-bom:32.0.0"))
|
||||
implementation("com.google.firebase:firebase-crashlytics-ktx")
|
||||
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||
generalImplementation(platform(libs.firebase.bom))
|
||||
generalImplementation(libs.google.firebase.crashlytics.ktx)
|
||||
generalImplementation(libs.google.firebase.analytics.ktx)
|
||||
|
||||
//barcode scanning
|
||||
implementation("com.google.android.gms:play-services-code-scanner:16.0.0")
|
||||
implementation(libs.zxing.android.embedded)
|
||||
implementation(libs.zxing.core)
|
||||
|
||||
//bio
|
||||
implementation(libs.androidx.biometric.ktx)
|
||||
|
||||
|
||||
}
|
||||
kapt {
|
||||
correctErrorTypes = true
|
||||
//shortcuts
|
||||
implementation(libs.androidx.core)
|
||||
implementation(libs.androidx.core.google.shortcuts)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
-dontwarn com.google.errorprone.annotations.**
|
||||
@@ -1,94 +0,0 @@
|
||||
{
|
||||
"_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,98 +0,0 @@
|
||||
{
|
||||
"_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
|
||||
},
|
||||
{
|
||||
"id": "8:4981008812459251156",
|
||||
"name": "showProminentDisclosure",
|
||||
"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
|
||||
],
|
||||
"retiredRelationUids": [],
|
||||
"version": 1
|
||||
}
|
||||
@@ -18,4 +18,4 @@
|
||||
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 1,
|
||||
"identityHash": "ba86153e6fb0b823197b987239b03e64",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isAutoTunnelEnabled",
|
||||
"columnName": "is_tunnel_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isTunnelOnMobileDataEnabled",
|
||||
"columnName": "is_tunnel_on_mobile_data_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trustedNetworkSSIDs",
|
||||
"columnName": "trusted_network_ssids",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "defaultTunnel",
|
||||
"columnName": "default_tunnel",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isAlwaysOnVpnEnabled",
|
||||
"columnName": "is_always_on_vpn_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isTunnelOnEthernetEnabled",
|
||||
"columnName": "is_tunnel_on_ethernet_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "TunnelConfig",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "wgQuick",
|
||||
"columnName": "wg_quick",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_TunnelConfig_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ba86153e6fb0b823197b987239b03e64')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 2,
|
||||
"identityHash": "65b1c9efff61712231fa64d1f19f3915",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isAutoTunnelEnabled",
|
||||
"columnName": "is_tunnel_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isTunnelOnMobileDataEnabled",
|
||||
"columnName": "is_tunnel_on_mobile_data_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trustedNetworkSSIDs",
|
||||
"columnName": "trusted_network_ssids",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "defaultTunnel",
|
||||
"columnName": "default_tunnel",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isAlwaysOnVpnEnabled",
|
||||
"columnName": "is_always_on_vpn_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isTunnelOnEthernetEnabled",
|
||||
"columnName": "is_tunnel_on_ethernet_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isShortcutsEnabled",
|
||||
"columnName": "is_shortcuts_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "false"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isBatterySaverEnabled",
|
||||
"columnName": "is_battery_saver_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "false"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "TunnelConfig",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "wgQuick",
|
||||
"columnName": "wg_quick",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_TunnelConfig_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '65b1c9efff61712231fa64d1f19f3915')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 3,
|
||||
"identityHash": "6b30daba29bb95f8ddc4d26206329d4f",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "Settings",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isAutoTunnelEnabled",
|
||||
"columnName": "is_tunnel_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isTunnelOnMobileDataEnabled",
|
||||
"columnName": "is_tunnel_on_mobile_data_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "trustedNetworkSSIDs",
|
||||
"columnName": "trusted_network_ssids",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "defaultTunnel",
|
||||
"columnName": "default_tunnel",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "isAlwaysOnVpnEnabled",
|
||||
"columnName": "is_always_on_vpn_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isTunnelOnEthernetEnabled",
|
||||
"columnName": "is_tunnel_on_ethernet_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isShortcutsEnabled",
|
||||
"columnName": "is_shortcuts_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "false"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isBatterySaverEnabled",
|
||||
"columnName": "is_battery_saver_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "false"
|
||||
},
|
||||
{
|
||||
"fieldPath": "isTunnelOnWifiEnabled",
|
||||
"columnName": "is_tunnel_on_wifi_enabled",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "false"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "TunnelConfig",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "wgQuick",
|
||||
"columnName": "wg_quick",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
"name": "index_TunnelConfig_name",
|
||||
"unique": true,
|
||||
"columnNames": [
|
||||
"name"
|
||||
],
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6b30daba29bb95f8ddc4d26206329d4f')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
SIGNING_STORE_PASSWORD=
|
||||
SIGNING_KEY_ALIAS=
|
||||
SIGNING_KEY_PASSWORD=
|
||||
KEY_STORE_PATH=/
|
||||
@@ -1,13 +1,11 @@
|
||||
package com.zaneschepke.wireguardautotunnel
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
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"
|
||||
android:maxSdkVersion="30" />
|
||||
android:maxSdkVersion="30"
|
||||
tools:ignore="LeanbackUsesWifi" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/>
|
||||
<!--foreground service permissions-->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<!--start service on boot permission-->
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<!--android tv support-->
|
||||
<uses-feature android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
<uses-feature android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.location.gps"
|
||||
android:required="false" />
|
||||
<uses-feature
|
||||
android:name="android.hardware.screen.portrait"
|
||||
android:required="false" />
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
@@ -28,6 +46,7 @@
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:banner="@mipmap/ic_banner"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
@@ -39,34 +58,73 @@
|
||||
android:theme="@style/Theme.WireguardAutoTunnel">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</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
|
||||
android:name=".service.foreground.ForegroundService"
|
||||
android:enabled="true"
|
||||
android:foregroundServiceType="remoteMessaging"
|
||||
android:exported="false">
|
||||
</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
|
||||
android:name=".service.foreground.WireGuardTunnelService"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:enabled="true"
|
||||
android:persistent="true"
|
||||
android:foregroundServiceType="remoteMessaging"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService"/>
|
||||
</intent-filter>
|
||||
<meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
|
||||
android:value="true" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".service.foreground.WireGuardConnectivityWatcherService"
|
||||
android:enabled="true"
|
||||
android:stopWithTask="false"
|
||||
android:persistent="true"
|
||||
android:foregroundServiceType="location"
|
||||
android:permission=""
|
||||
android:exported="false">
|
||||
</service>
|
||||
<receiver android:enabled="true" android:name=".BootReceiver"
|
||||
<receiver android:enabled="true" android:name=".receiver.BootReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="barcode_ui"/>
|
||||
<receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,49 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.zaneschepke.wireguardautotunnel.repository.Repository
|
||||
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 kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepo : Repository<Settings>
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||
GlobalScope.launch {
|
||||
try {
|
||||
val settings = settingsRepo.getAll()
|
||||
if (!settings.isNullOrEmpty()) {
|
||||
val setting = settings[0]
|
||||
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
|
||||
val defaultTunnel = TunnelConfig.from(setting.defaultTunnel!!)
|
||||
ServiceTracker.actionOnService(
|
||||
Action.START, context,
|
||||
WireGuardConnectivityWatcherService::class.java,
|
||||
mapOf(context.resources.getString(R.string.tunnel_extras_key) to
|
||||
defaultTunnel.id.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
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,9 +1,14 @@
|
||||
package com.zaneschepke.wireguardautotunnel
|
||||
|
||||
import android.app.Application
|
||||
import com.zaneschepke.wireguardautotunnel.repository.Repository
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
|
||||
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -11,11 +16,31 @@ import javax.inject.Inject
|
||||
class WireGuardAutoTunnel : Application() {
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepo : Repository<Settings>
|
||||
lateinit var settingsRepo : SettingsDoa
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
if(BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
|
||||
settingsRepo.init()
|
||||
if(BuildConfig.DEBUG) {
|
||||
Timber.plant(Timber.DebugTree())
|
||||
}
|
||||
initSettings()
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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,25 +1,27 @@
|
||||
package com.zaneschepke.wireguardautotunnel.module
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.repository.Repository
|
||||
import com.zaneschepke.wireguardautotunnel.repository.SettingsBox
|
||||
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 com.zaneschepke.wireguardautotunnel.repository.AppDatabase
|
||||
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
|
||||
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
class RepositoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun provideSettingsRepository(settingsBox: SettingsBox) : Repository<Settings>
|
||||
@Provides
|
||||
fun provideSettingsRepository(appDatabase: AppDatabase) : SettingsDoa {
|
||||
return appDatabase.settingDao()
|
||||
}
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun provideTunnelRepository(tunnelBox: TunnelBox) : Repository<TunnelConfig>
|
||||
@Provides
|
||||
fun provideTunnelConfigRepository(appDatabase: AppDatabase) : TunnelConfigDao {
|
||||
return appDatabase.tunnelConfigDoa()
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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.Binds
|
||||
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,7 +1,6 @@
|
||||
package com.zaneschepke.wireguardautotunnel.module
|
||||
|
||||
import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner
|
||||
import com.zaneschepke.wireguardautotunnel.service.barcode.QRScanner
|
||||
import com.zaneschepke.wireguardautotunnel.service.network.EthernetService
|
||||
import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService
|
||||
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
|
||||
import com.zaneschepke.wireguardautotunnel.service.network.WifiService
|
||||
@@ -12,7 +11,6 @@ import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.components.ServiceComponent
|
||||
import dagger.hilt.android.scopes.ServiceScoped
|
||||
import dagger.hilt.android.scopes.ViewModelScoped
|
||||
|
||||
@Module
|
||||
@InstallIn(ServiceComponent::class)
|
||||
@@ -29,4 +27,8 @@ abstract class ServiceModule {
|
||||
@Binds
|
||||
@ServiceScoped
|
||||
abstract fun provideMobileDataService(mobileDataService : MobileDataService) : NetworkService<MobileDataService>
|
||||
|
||||
@Binds
|
||||
@ServiceScoped
|
||||
abstract fun provideEthernetService(ethernetService: EthernetService) : NetworkService<EthernetService>
|
||||
}
|
||||
@@ -27,5 +27,4 @@ class TunnelModule {
|
||||
fun provideVpnService(backend: Backend) : VpnService {
|
||||
return WireGuardTunnel(backend)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.zaneschepke.wireguardautotunnel.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.zaneschepke.wireguardautotunnel.goAsync
|
||||
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
|
||||
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.cancel
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepo : SettingsDoa
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) = goAsync {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
|
||||
try {
|
||||
val settings = settingsRepo.getAll()
|
||||
if (settings.isNotEmpty()) {
|
||||
val setting = settings.first()
|
||||
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) {
|
||||
ServiceManager.startWatcherService(context, setting.defaultTunnel!!)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.zaneschepke.wireguardautotunnel.receiver
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.zaneschepke.wireguardautotunnel.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.goAsync
|
||||
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
|
||||
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class NotificationActionReceiver : BroadcastReceiver() {
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepo : SettingsDoa
|
||||
override fun onReceive(context: Context, intent: Intent?) = goAsync {
|
||||
try {
|
||||
val settings = settingsRepo.getAll()
|
||||
if (settings.isNotEmpty()) {
|
||||
val setting = settings.first()
|
||||
if (setting.defaultTunnel != null) {
|
||||
ServiceManager.stopVpnService(context)
|
||||
delay(Constants.TOGGLE_TUNNEL_DELAY)
|
||||
ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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>>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.service.barcode
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface CodeScanner {
|
||||
fun scan() : Flow<String?>
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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 {
|
||||
Timber.e(it.message)
|
||||
}
|
||||
awaitClose {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,6 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
|
||||
|
||||
enum class Action {
|
||||
START,
|
||||
START_FOREGROUND,
|
||||
STOP
|
||||
}
|
||||
@@ -1,29 +1,35 @@
|
||||
package com.zaneschepke.wireguardautotunnel.service.foreground
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import timber.log.Timber
|
||||
|
||||
|
||||
open class ForegroundService : Service() {
|
||||
open class ForegroundService : LifecycleService() {
|
||||
|
||||
private var isServiceStarted = false
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
super.onBind(intent)
|
||||
// We don't provide binding, so return null
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
Timber.d("onStartCommand executed with startId: $startId")
|
||||
if (intent != null) {
|
||||
val action = intent.action
|
||||
Timber.d("using an intent with action $action")
|
||||
when (action) {
|
||||
Action.START.name -> startService(intent.extras)
|
||||
Action.START.name, Action.START_FOREGROUND.name -> startService(intent.extras)
|
||||
Action.STOP.name -> stopService(intent.extras)
|
||||
"android.net.VpnService" -> {
|
||||
Timber.d("Always-on VPN starting service")
|
||||
startService(intent.extras)
|
||||
}
|
||||
else -> Timber.d("This should never happen. No action in the received intent")
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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 android.content.SharedPreferences
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
|
||||
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
|
||||
application.startService(intent)
|
||||
}
|
||||
|
||||
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
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +1,90 @@
|
||||
package com.zaneschepke.wireguardautotunnel.service.foreground
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.Application
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.wireguard.android.backend.Tunnel
|
||||
import com.zaneschepke.wireguardautotunnel.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.repository.Repository
|
||||
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
|
||||
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.NetworkService
|
||||
import com.zaneschepke.wireguardautotunnel.service.network.NetworkStatus
|
||||
import com.zaneschepke.wireguardautotunnel.service.network.WifiService
|
||||
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
class WireGuardConnectivityWatcherService : ForegroundService() {
|
||||
|
||||
private val foregroundId = 122;
|
||||
private val foregroundId = 122
|
||||
|
||||
@Inject
|
||||
lateinit var wifiService : NetworkService<WifiService>
|
||||
lateinit var wifiService: NetworkService<WifiService>
|
||||
|
||||
@Inject
|
||||
lateinit var mobileDataService : NetworkService<MobileDataService>
|
||||
lateinit var mobileDataService: NetworkService<MobileDataService>
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepo: Repository<Settings>
|
||||
lateinit var ethernetService: NetworkService<EthernetService>
|
||||
|
||||
@Inject
|
||||
lateinit var notificationService : NotificationService
|
||||
lateinit var settingsRepo: SettingsDoa
|
||||
|
||||
@Inject
|
||||
lateinit var vpnService : VpnService
|
||||
lateinit var notificationService: NotificationService
|
||||
|
||||
private lateinit var watcherJob : Job;
|
||||
private lateinit var setting : Settings
|
||||
private lateinit var tunnelId: String
|
||||
@Inject
|
||||
lateinit var vpnService: VpnService
|
||||
|
||||
private var connecting = false
|
||||
private var disconnecting = false
|
||||
private var isWifiConnected = false
|
||||
private var isEthernetConnected = false
|
||||
private var isMobileDataConnected = false
|
||||
private var currentNetworkSSID = ""
|
||||
|
||||
private lateinit var watcherJob: Job
|
||||
private lateinit var setting: Settings
|
||||
private lateinit var tunnelConfig: String
|
||||
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private val tag = this.javaClass.name;
|
||||
private val tag = this.javaClass.name
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
launchWatcherNotification()
|
||||
}
|
||||
}
|
||||
|
||||
override fun startService(extras: Bundle?) {
|
||||
super.startService(extras)
|
||||
launchWatcherNotification()
|
||||
val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key))
|
||||
if (tunnelId != null) {
|
||||
this.tunnelId = tunnelId
|
||||
this.tunnelConfig = tunnelId
|
||||
}
|
||||
// we need this lock so our service gets not affected by Doze Mode
|
||||
initWakeLock()
|
||||
lifecycleScope.launch {
|
||||
initWakeLock()
|
||||
}
|
||||
cancelWatcherJob()
|
||||
launchWatcherNotification()
|
||||
if(this::tunnelId.isInitialized) {
|
||||
if (this::tunnelConfig.isInitialized) {
|
||||
startWatcherJob()
|
||||
} else {
|
||||
stopService(extras)
|
||||
@@ -84,7 +99,6 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
|
||||
}
|
||||
}
|
||||
cancelWatcherJob()
|
||||
stopVPN()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
@@ -92,137 +106,177 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
|
||||
val notification = notificationService.createNotification(
|
||||
channelId = getString(R.string.watcher_channel_id),
|
||||
channelName = getString(R.string.watcher_channel_name),
|
||||
description = getString(R.string.watcher_notification_text))
|
||||
description = getString(R.string.watcher_notification_text),
|
||||
vibration = false
|
||||
)
|
||||
super.startForeground(foregroundId, notification)
|
||||
}
|
||||
|
||||
//try to start task again if killed
|
||||
override fun onTaskRemoved(rootIntent: Intent) {
|
||||
Timber.d("Task Removed called")
|
||||
val restartServiceIntent = Intent(applicationContext, this::class.java).also {
|
||||
it.setPackage(packageName)
|
||||
};
|
||||
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent,
|
||||
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE);
|
||||
applicationContext.getSystemService(Context.ALARM_SERVICE);
|
||||
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager;
|
||||
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent);
|
||||
val restartServiceIntent = Intent(rootIntent)
|
||||
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(
|
||||
this, 1, restartServiceIntent,
|
||||
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
applicationContext.getSystemService(Context.ALARM_SERVICE)
|
||||
val alarmService: AlarmManager =
|
||||
applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
alarmService.set(
|
||||
AlarmManager.ELAPSED_REALTIME,
|
||||
SystemClock.elapsedRealtime() + 1000,
|
||||
restartServicePendingIntent
|
||||
)
|
||||
}
|
||||
|
||||
private fun initWakeLock() {
|
||||
private suspend fun initWakeLock() {
|
||||
val isBatterySaverOn = withContext(lifecycleScope.coroutineContext) {
|
||||
settingsRepo.getAll().firstOrNull()?.isBatterySaverEnabled ?: false
|
||||
}
|
||||
wakeLock =
|
||||
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
|
||||
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
|
||||
acquire()
|
||||
if (isBatterySaverOn) {
|
||||
Timber.d("Initiating wakelock with timeout")
|
||||
acquire(Constants.WATCHER_SERVICE_WAKE_LOCK_TIMEOUT)
|
||||
} else {
|
||||
Timber.d("Initiating wakelock with zero timeout")
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelWatcherJob() {
|
||||
if(this::watcherJob.isInitialized) {
|
||||
if (this::watcherJob.isInitialized) {
|
||||
watcherJob.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private fun startWatcherJob() {
|
||||
watcherJob = GlobalScope.launch {
|
||||
val settings = settingsRepo.getAll();
|
||||
if(!settings.isNullOrEmpty()) {
|
||||
watcherJob = lifecycleScope.launch(Dispatchers.IO) {
|
||||
val settings = settingsRepo.getAll()
|
||||
if (settings.isNotEmpty()) {
|
||||
setting = settings[0]
|
||||
}
|
||||
GlobalScope.launch {
|
||||
launch {
|
||||
watchForWifiConnectivityChanges()
|
||||
}
|
||||
if(setting.isTunnelOnMobileDataEnabled) {
|
||||
GlobalScope.launch {
|
||||
if (setting.isTunnelOnMobileDataEnabled) {
|
||||
launch {
|
||||
watchForMobileDataConnectivityChanges()
|
||||
}
|
||||
}
|
||||
if (setting.isTunnelOnEthernetEnabled) {
|
||||
launch {
|
||||
watchForEthernetConnectivityChanges()
|
||||
}
|
||||
}
|
||||
launch {
|
||||
manageVpn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun watchForMobileDataConnectivityChanges() {
|
||||
mobileDataService.networkStatus.collect {
|
||||
when(it) {
|
||||
when (it) {
|
||||
is NetworkStatus.Available -> {
|
||||
Timber.d("Gained Mobile data connection")
|
||||
isMobileDataConnected = true
|
||||
}
|
||||
|
||||
is NetworkStatus.CapabilitiesChanged -> {
|
||||
isMobileDataConnected = true
|
||||
Timber.d("Mobile data capabilities changed")
|
||||
if(!isWifiConnected && setting.isTunnelOnMobileDataEnabled
|
||||
&& vpnService.getState() == Tunnel.State.DOWN)
|
||||
startVPN()
|
||||
}
|
||||
|
||||
is NetworkStatus.Unavailable -> {
|
||||
isMobileDataConnected = false
|
||||
if(!isWifiConnected && vpnService.getState() == Tunnel.State.UP) stopVPN()
|
||||
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() {
|
||||
wifiService.networkStatus.collect {
|
||||
when (it) {
|
||||
is NetworkStatus.Available -> {
|
||||
Timber.d("Gained Wi-Fi connection")
|
||||
isWifiConnected = true
|
||||
}
|
||||
is NetworkStatus.CapabilitiesChanged -> {
|
||||
Timber.d("Wifi capabilities changed")
|
||||
isWifiConnected = true
|
||||
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) == false) && 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 -> {
|
||||
isWifiConnected = false
|
||||
Timber.d("Lost Wi-Fi connection")
|
||||
if(setting.isTunnelOnMobileDataEnabled && vpnService.getState() == Tunnel.State.DOWN
|
||||
&& isMobileDataConnected){
|
||||
Timber.d("Wifi not available so starting vpn for mobile data")
|
||||
startVPN()
|
||||
}
|
||||
}
|
||||
when (it) {
|
||||
is NetworkStatus.Available -> {
|
||||
Timber.d("Gained Wi-Fi connection")
|
||||
isWifiConnected = true
|
||||
}
|
||||
|
||||
is NetworkStatus.CapabilitiesChanged -> {
|
||||
Timber.d("Wifi capabilities changed")
|
||||
isWifiConnected = true
|
||||
currentNetworkSSID = wifiService.getNetworkName(it.networkCapabilities) ?: ""
|
||||
}
|
||||
|
||||
is NetworkStatus.Unavailable -> {
|
||||
isWifiConnected = false
|
||||
Timber.d("Lost Wi-Fi connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun startVPN() {
|
||||
if(!connecting) {
|
||||
connecting = true
|
||||
ServiceTracker.actionOnService(
|
||||
Action.START,
|
||||
this.applicationContext as Application,
|
||||
WireGuardTunnelService::class.java,
|
||||
mapOf(getString(R.string.tunnel_extras_key) to tunnelId))
|
||||
connecting = false
|
||||
}
|
||||
}
|
||||
private fun stopVPN() {
|
||||
if(!disconnecting) {
|
||||
disconnecting = true
|
||||
ServiceTracker.actionOnService(
|
||||
Action.STOP,
|
||||
this.applicationContext as Application,
|
||||
WireGuardTunnelService::class.java
|
||||
)
|
||||
disconnecting = false
|
||||
|
||||
private suspend fun manageVpn() {
|
||||
while (true) {
|
||||
if (isEthernetConnected && setting.isTunnelOnEthernetEnabled && vpnService.getState() == Tunnel.State.DOWN) {
|
||||
ServiceManager.startVpnService(this, tunnelConfig)
|
||||
}
|
||||
if (!isEthernetConnected && setting.isTunnelOnMobileDataEnabled &&
|
||||
!isWifiConnected &&
|
||||
isMobileDataConnected
|
||||
&& vpnService.getState() == Tunnel.State.DOWN
|
||||
) {
|
||||
ServiceManager.startVpnService(this, tunnelConfig)
|
||||
} else if (!isEthernetConnected && !setting.isTunnelOnMobileDataEnabled &&
|
||||
!isWifiConnected &&
|
||||
vpnService.getState() == Tunnel.State.UP
|
||||
) {
|
||||
ServiceManager.stopVpnService(this)
|
||||
} else if (!isEthernetConnected && isWifiConnected &&
|
||||
!setting.trustedNetworkSSIDs.contains(currentNetworkSSID) &&
|
||||
setting.isTunnelOnWifiEnabled &&
|
||||
(vpnService.getState() != Tunnel.State.UP)
|
||||
) {
|
||||
ServiceManager.startVpnService(this, tunnelConfig)
|
||||
} else if (!isEthernetConnected && (isWifiConnected &&
|
||||
setting.trustedNetworkSSIDs.contains(currentNetworkSSID)) &&
|
||||
(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
package com.zaneschepke.wireguardautotunnel.service.foreground
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import com.wireguard.android.backend.Tunnel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
|
||||
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
|
||||
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
@@ -19,59 +21,142 @@ import javax.inject.Inject
|
||||
@AndroidEntryPoint
|
||||
class WireGuardTunnelService : ForegroundService() {
|
||||
|
||||
private val foregroundId = 123;
|
||||
private val foregroundId = 123
|
||||
|
||||
@Inject
|
||||
lateinit var vpnService : VpnService
|
||||
|
||||
@Inject
|
||||
lateinit var settingsRepo: SettingsDoa
|
||||
|
||||
@Inject
|
||||
lateinit var notificationService : NotificationService
|
||||
|
||||
private lateinit var job : Job
|
||||
|
||||
@OptIn(DelicateCoroutinesApi::class)
|
||||
private var tunnelName : String = ""
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
launchVpnStartingNotification()
|
||||
}
|
||||
}
|
||||
|
||||
override fun startService(extras : Bundle?) {
|
||||
super.startService(extras)
|
||||
launchVpnStartingNotification()
|
||||
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
|
||||
cancelJob()
|
||||
job = GlobalScope.launch {
|
||||
if(tunnelConfigString != null) {
|
||||
try {
|
||||
val tunnelConfig = TunnelConfig.from(tunnelConfigString)
|
||||
val state = vpnService.startTunnel(tunnelConfig)
|
||||
if (state == Tunnel.State.UP) {
|
||||
launchVpnConnectedNotification(tunnelConfig.name)
|
||||
job = lifecycleScope.launch(Dispatchers.IO) {
|
||||
launch {
|
||||
if(tunnelConfigString != null) {
|
||||
try {
|
||||
val tunnelConfig = TunnelConfig.from(tunnelConfigString)
|
||||
tunnelName = tunnelConfig.name
|
||||
vpnService.startTunnel(tunnelConfig)
|
||||
} catch (e : Exception) {
|
||||
Timber.e("Problem starting tunnel: ${e.message}")
|
||||
stopService(extras)
|
||||
}
|
||||
} else {
|
||||
Timber.d("Tunnel config null, starting default tunnel")
|
||||
val settings = settingsRepo.getAll()
|
||||
if(settings.isNotEmpty()) {
|
||||
val setting = settings[0]
|
||||
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
|
||||
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
|
||||
tunnelName = tunnelConfig.name
|
||||
vpnService.startTunnel(tunnelConfig)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
launch {
|
||||
var didShowConnected = false
|
||||
var didShowFailedHandshakeNotification = false
|
||||
vpnService.handshakeStatus.collect {
|
||||
when(it) {
|
||||
HandshakeStatus.NOT_STARTED -> {
|
||||
}
|
||||
HandshakeStatus.NEVER_CONNECTED -> {
|
||||
if(!didShowFailedHandshakeNotification) {
|
||||
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message))
|
||||
didShowFailedHandshakeNotification = true
|
||||
didShowConnected = false
|
||||
}
|
||||
}
|
||||
HandshakeStatus.HEALTHY -> {
|
||||
if(!didShowConnected) {
|
||||
launchVpnConnectedNotification()
|
||||
didShowConnected = true
|
||||
}
|
||||
}
|
||||
HandshakeStatus.UNHEALTHY -> {
|
||||
if(!didShowFailedHandshakeNotification) {
|
||||
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message))
|
||||
didShowFailedHandshakeNotification = true
|
||||
didShowConnected = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e : Exception) {
|
||||
Timber.e("Problem starting tunnel: ${e.message}")
|
||||
stopService(extras)
|
||||
}
|
||||
} else {
|
||||
Timber.e("Tunnel config null")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopService(extras : Bundle?) {
|
||||
super.stopService(extras)
|
||||
CoroutineScope(Dispatchers.IO).launch() {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
vpnService.stopTunnel()
|
||||
}
|
||||
cancelJob()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun launchVpnConnectedNotification(tunnelName : String) {
|
||||
private fun launchVpnConnectedNotification() {
|
||||
val notification = notificationService.createNotification(
|
||||
channelId = getString(R.string.vpn_channel_id),
|
||||
channelName = getString(R.string.vpn_channel_name),
|
||||
title = getString(R.string.tunnel_start_title),
|
||||
onGoing = false,
|
||||
vibration = false,
|
||||
showTimestamp = true,
|
||||
description = "${getString(R.string.tunnel_start_text)} $tunnelName"
|
||||
)
|
||||
super.startForeground(foregroundId, notification)
|
||||
}
|
||||
|
||||
private fun launchVpnStartingNotification() {
|
||||
val notification = notificationService.createNotification(
|
||||
channelId = getString(R.string.vpn_channel_id),
|
||||
channelName = getString(R.string.vpn_channel_name),
|
||||
title = getString(R.string.vpn_starting),
|
||||
onGoing = false,
|
||||
vibration = false,
|
||||
showTimestamp = true,
|
||||
description = getString(R.string.attempt_connection)
|
||||
)
|
||||
super.startForeground(foregroundId, notification)
|
||||
}
|
||||
|
||||
private fun launchVpnConnectionFailedNotification(message : String) {
|
||||
val notification = notificationService.createNotification(
|
||||
channelId = getString(R.string.vpn_channel_id),
|
||||
channelName = getString(R.string.vpn_channel_name),
|
||||
action = PendingIntent.getBroadcast(this,0,
|
||||
Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE),
|
||||
actionText = getString(R.string.restart),
|
||||
title = getString(R.string.vpn_connection_failed),
|
||||
onGoing = false,
|
||||
vibration = true,
|
||||
showTimestamp = true,
|
||||
description = message
|
||||
)
|
||||
super.startForeground(foregroundId, notification)
|
||||
}
|
||||
|
||||
|
||||
private fun cancelJob() {
|
||||
if(this::job.isInitialized) {
|
||||
job.cancel()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.zaneschepke.wireguardautotunnel.service.network
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
@@ -10,12 +9,10 @@ import android.net.wifi.SupplicantState
|
||||
import android.net.wifi.WifiInfo
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Context, networkCapability : Int) : NetworkService<T> {
|
||||
@@ -31,7 +28,6 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
|
||||
object : ConnectivityManager.NetworkCallback(
|
||||
FLAG_INCLUDE_LOCATION_INFO
|
||||
) {
|
||||
|
||||
override fun onAvailable(network: Network) {
|
||||
trySend(NetworkStatus.Available(network))
|
||||
}
|
||||
@@ -48,6 +44,7 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
|
||||
@@ -70,6 +67,8 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
|
||||
}
|
||||
val request = NetworkRequest.Builder()
|
||||
.addTransportType(networkCapability)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
.build()
|
||||
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
|
||||
|
||||
@@ -80,8 +79,8 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
|
||||
|
||||
|
||||
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
|
||||
var ssid : String? = getWifiNameFromCapabilities(networkCapabilities)
|
||||
if((Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) || (Build.VERSION.SDK_INT == Build.VERSION_CODES.R)) {
|
||||
var ssid: String? = getWifiNameFromCapabilities(networkCapabilities)
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
|
||||
val info = wifiManager.connectionInfo
|
||||
if (info.supplicantState === SupplicantState.COMPLETED) {
|
||||
ssid = info.ssid
|
||||
@@ -92,14 +91,15 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
|
||||
|
||||
|
||||
companion object {
|
||||
private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities) : String? {
|
||||
val info : WifiInfo
|
||||
if(networkCapabilities.transportInfo is WifiInfo) {
|
||||
info = networkCapabilities.transportInfo as WifiInfo
|
||||
} else {
|
||||
return null
|
||||
private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val info: WifiInfo
|
||||
if (networkCapabilities.transportInfo is WifiInfo) {
|
||||
info = networkCapabilities.transportInfo as WifiInfo
|
||||
return info.ssid
|
||||
}
|
||||
}
|
||||
return info.ssid
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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) {
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import android.net.NetworkCapabilities
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface NetworkService<T> {
|
||||
fun getNetworkName(networkCapabilities: NetworkCapabilities) : String?
|
||||
val networkStatus : Flow<NetworkStatus>
|
||||
|
||||
}
|
||||
fun getNetworkName(networkCapabilities: NetworkCapabilities): String?
|
||||
val networkStatus: Flow<NetworkStatus>
|
||||
}
|
||||
|
||||
@@ -2,16 +2,19 @@ package com.zaneschepke.wireguardautotunnel.service.notification
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
|
||||
interface NotificationService {
|
||||
fun createNotification(
|
||||
channelId: String,
|
||||
channelName: String,
|
||||
title: String = "",
|
||||
action: PendingIntent? = null,
|
||||
actionText: String? = null,
|
||||
description: String,
|
||||
showTimestamp : Boolean = false,
|
||||
importance: Int = NotificationManager.IMPORTANCE_HIGH,
|
||||
vibration: Boolean = true,
|
||||
vibration: Boolean = false,
|
||||
onGoing: Boolean = true,
|
||||
lights: Boolean = true
|
||||
): Notification
|
||||
|
||||
@@ -14,19 +14,21 @@ import javax.inject.Inject
|
||||
|
||||
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(
|
||||
channelId: String,
|
||||
channelName: String,
|
||||
title: String,
|
||||
action: PendingIntent?,
|
||||
actionText: String?,
|
||||
description: String,
|
||||
showTimestamp: Boolean,
|
||||
importance: Int,
|
||||
vibration: Boolean,
|
||||
onGoing: Boolean,
|
||||
lights: Boolean
|
||||
) : Notification {
|
||||
): Notification {
|
||||
val channel = NotificationChannel(
|
||||
channelId,
|
||||
channelName,
|
||||
@@ -42,7 +44,12 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
val pendingIntent: PendingIntent =
|
||||
Intent(context, MainActivity::class.java).let { notificationIntent ->
|
||||
PendingIntent.getActivity(context, 0, notificationIntent, PendingIntent.FLAG_IMMUTABLE)
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
notificationIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
}
|
||||
|
||||
val builder: Notification.Builder =
|
||||
@@ -50,14 +57,21 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
|
||||
context,
|
||||
channelId
|
||||
)
|
||||
|
||||
return builder
|
||||
.setContentTitle(title)
|
||||
.setContentText(description)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(onGoing)
|
||||
.setShowWhen(showTimestamp)
|
||||
.setSmallIcon(R.mipmap.ic_launcher_foreground)
|
||||
.build()
|
||||
return builder.let {
|
||||
if(action != null && actionText != null) {
|
||||
//TODO find a not deprecated way to do this
|
||||
it.addAction(
|
||||
Notification.Action.Builder(0, actionText, action)
|
||||
.build())
|
||||
it.setAutoCancel(true)
|
||||
}
|
||||
it.setContentTitle(title)
|
||||
.setContentText(description)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(onGoing)
|
||||
.setShowWhen(showTimestamp)
|
||||
.setSmallIcon(R.mipmap.ic_launcher_foreground)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.zaneschepke.wireguardautotunnel.service.tunnel
|
||||
|
||||
enum class HandshakeStatus {
|
||||
HEALTHY,
|
||||
UNHEALTHY,
|
||||
NEVER_CONNECTED,
|
||||
NOT_STARTED;
|
||||
|
||||
companion object {
|
||||
private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 120
|
||||
const val UNHEALTHY_TIME_LIMIT_SEC = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + 60
|
||||
const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.zaneschepke.wireguardautotunnel.service.tunnel
|
||||
|
||||
import com.wireguard.android.backend.Statistics
|
||||
import com.wireguard.android.backend.Tunnel
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
|
||||
import com.wireguard.crypto.Key
|
||||
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
interface VpnService : Tunnel {
|
||||
@@ -9,5 +11,8 @@ interface VpnService : Tunnel {
|
||||
suspend fun stopTunnel()
|
||||
val state : SharedFlow<Tunnel.State>
|
||||
val tunnelName : SharedFlow<String>
|
||||
val statistics : SharedFlow<Statistics>
|
||||
val lastHandshake : SharedFlow<Map<Key,Long>>
|
||||
val handshakeStatus : SharedFlow<HandshakeStatus>
|
||||
fun getState() : Tunnel.State
|
||||
}
|
||||
@@ -2,13 +2,23 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
|
||||
|
||||
import com.wireguard.android.backend.Backend
|
||||
import com.wireguard.android.backend.BackendException
|
||||
import com.wireguard.android.backend.Statistics
|
||||
import com.wireguard.android.backend.Tunnel
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
|
||||
import com.wireguard.crypto.Key
|
||||
import com.zaneschepke.wireguardautotunnel.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -17,34 +27,59 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
|
||||
|
||||
private val _tunnelName = MutableStateFlow("")
|
||||
override val tunnelName get() = _tunnelName.asStateFlow()
|
||||
|
||||
private val _state = MutableSharedFlow<Tunnel.State>(
|
||||
replay = 1,
|
||||
onBufferOverflow = BufferOverflow.SUSPEND,
|
||||
extraBufferCapacity = 1)
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||
replay = 1)
|
||||
|
||||
private val _handshakeStatus = MutableSharedFlow<HandshakeStatus>(replay = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val state get() = _state.asSharedFlow()
|
||||
|
||||
private val _statistics = MutableSharedFlow<Statistics>(replay = 1)
|
||||
override val statistics get() = _statistics.asSharedFlow()
|
||||
|
||||
private val _lastHandshake = MutableSharedFlow<Map<Key, Long>>(replay = 1)
|
||||
override val lastHandshake get() = _lastHandshake.asSharedFlow()
|
||||
|
||||
override val handshakeStatus: SharedFlow<HandshakeStatus>
|
||||
get() = _handshakeStatus.asSharedFlow()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
|
||||
private lateinit var statsJob : Job
|
||||
|
||||
|
||||
override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{
|
||||
return try {
|
||||
if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) {
|
||||
stopTunnel()
|
||||
}
|
||||
_tunnelName.emit(tunnelConfig.name)
|
||||
stopTunnelOnConfigChange(tunnelConfig)
|
||||
emitTunnelName(tunnelConfig.name)
|
||||
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
|
||||
val state = backend.setState(
|
||||
this, Tunnel.State.UP, config)
|
||||
_state.emit(state)
|
||||
state;
|
||||
state
|
||||
} catch (e : Exception) {
|
||||
Timber.e("Failed to start tunnel with error: ${e.message}")
|
||||
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 {
|
||||
return _tunnelName.value
|
||||
}
|
||||
|
||||
override suspend fun stopTunnel() {
|
||||
override suspend fun stopTunnel() {
|
||||
try {
|
||||
if(getState() == Tunnel.State.UP) {
|
||||
val state = backend.setState(this, Tunnel.State.DOWN, null)
|
||||
@@ -60,6 +95,48 @@ class WireGuardTunnel @Inject constructor(private val backend : Backend) : VpnSe
|
||||
}
|
||||
|
||||
override fun onStateChange(state : Tunnel.State) {
|
||||
val tunnel = this
|
||||
_state.tryEmit(state)
|
||||
if(state == Tunnel.State.UP) {
|
||||
statsJob = scope.launch {
|
||||
val handshakeMap = HashMap<Key, Long>()
|
||||
var neverHadHandshakeCounter = 0
|
||||
while (true) {
|
||||
val statistics = backend.getStatistics(tunnel)
|
||||
_statistics.emit(statistics)
|
||||
statistics.peers().forEach {
|
||||
val handshakeEpoch = statistics.peer(it)?.latestHandshakeEpochMillis ?: 0L
|
||||
handshakeMap[it] = handshakeEpoch
|
||||
if(handshakeEpoch == 0L) {
|
||||
if(neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
|
||||
_handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED)
|
||||
} else {
|
||||
_handshakeStatus.emit(HandshakeStatus.NOT_STARTED)
|
||||
}
|
||||
if(neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
|
||||
neverHadHandshakeCounter += (1 * Constants.VPN_STATISTIC_CHECK_INTERVAL/1000).toInt()
|
||||
}
|
||||
return@forEach
|
||||
}
|
||||
if((NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) ?: 0L) >= HandshakeStatus.UNHEALTHY_TIME_LIMIT_SEC) {
|
||||
_handshakeStatus.emit(HandshakeStatus.UNHEALTHY)
|
||||
} else {
|
||||
_handshakeStatus.emit(HandshakeStatus.HEALTHY)
|
||||
}
|
||||
}
|
||||
_lastHandshake.emit(handshakeMap)
|
||||
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
if(state == Tunnel.State.DOWN) {
|
||||
if(this::statsJob.isInitialized) {
|
||||
statsJob.cancel()
|
||||
}
|
||||
_handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED)
|
||||
_lastHandshake.tryEmit(emptyMap())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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
|
||||
)
|
||||
@@ -1,89 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui
|
||||
|
||||
import com.journeyapps.barcodescanner.CaptureActivity
|
||||
|
||||
class CaptureActivityPortrait : CaptureActivity()
|
||||
@@ -6,33 +6,46 @@ import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import android.view.KeyEvent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.animation.ExitTransition
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarData
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.google.accompanist.navigation.animation.AnimatedNavHost
|
||||
import com.google.accompanist.navigation.animation.composable
|
||||
import com.google.accompanist.navigation.animation.rememberAnimatedNavController
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.input.key.onKeyEvent
|
||||
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.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.wireguard.android.backend.GoBackend
|
||||
import com.zaneschepke.wireguardautotunnel.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
|
||||
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.main.MainScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
|
||||
@@ -40,17 +53,22 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class, ExperimentalMaterial3Api::class,
|
||||
@OptIn(ExperimentalAnimationApi::class,
|
||||
ExperimentalPermissionsApi::class
|
||||
)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
val navController = rememberAnimatedNavController()
|
||||
val navController = rememberNavController()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
|
||||
WireguardAutoTunnelTheme {
|
||||
TransparentSystemBars()
|
||||
|
||||
@@ -80,7 +98,47 @@ class MainActivity : AppCompatActivity() {
|
||||
} else requestNotificationPermission()
|
||||
}
|
||||
|
||||
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
fun showSnackBarMessage(message : String) {
|
||||
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) {
|
||||
{ BottomNavBar(navController, Routes.navItems) }
|
||||
} else {
|
||||
@@ -104,66 +162,74 @@ class MainActivity : AppCompatActivity() {
|
||||
val intentSettings =
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intentSettings.data =
|
||||
Uri.fromParts("package", this.packageName, null)
|
||||
startActivity(intentSettings);
|
||||
Uri.fromParts(Constants.URI_PACKAGE_SCHEME, this.packageName, null)
|
||||
startActivity(intentSettings)
|
||||
},
|
||||
message = getString(R.string.notification_permission_required),
|
||||
getString(R.string.open_settings)
|
||||
)
|
||||
return@Scaffold
|
||||
}
|
||||
AnimatedNavHost(navController, startDestination = Routes.Main.name) {
|
||||
|
||||
NavHost(navController, startDestination = Routes.Main.name) {
|
||||
composable(Routes.Main.name, enterTransition = {
|
||||
when (initialState.destination.route) {
|
||||
Routes.Settings.name, Routes.Support.name ->
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -1000 },
|
||||
animationSpec = tween(500)
|
||||
initialOffsetX = { -Constants.SLIDE_IN_TRANSITION_OFFSET },
|
||||
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
|
||||
)
|
||||
|
||||
else -> {
|
||||
fadeIn(animationSpec = tween(1000))
|
||||
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
|
||||
}
|
||||
}
|
||||
}) {
|
||||
MainScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController)
|
||||
}, exitTransition = {
|
||||
ExitTransition.None
|
||||
}
|
||||
) {
|
||||
MainScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, navController = navController)
|
||||
}
|
||||
composable(Routes.Settings.name, enterTransition = {
|
||||
when (initialState.destination.route) {
|
||||
Routes.Main.name ->
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { 1000 },
|
||||
animationSpec = tween(500)
|
||||
initialOffsetX = { Constants.SLIDE_IN_TRANSITION_OFFSET },
|
||||
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
|
||||
)
|
||||
|
||||
Routes.Support.name -> {
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { -1000 },
|
||||
animationSpec = tween(500)
|
||||
initialOffsetX = { -Constants.SLIDE_IN_TRANSITION_OFFSET },
|
||||
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
fadeIn(animationSpec = tween(1000))
|
||||
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
|
||||
}
|
||||
}
|
||||
}) { SettingsScreen(padding = padding, snackbarHostState = snackbarHostState, navController = navController) }
|
||||
}) { SettingsScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester) }
|
||||
composable(Routes.Support.name, enterTransition = {
|
||||
when (initialState.destination.route) {
|
||||
Routes.Settings.name, Routes.Main.name ->
|
||||
slideInHorizontally(
|
||||
initialOffsetX = { 1000 },
|
||||
animationSpec = tween(500)
|
||||
initialOffsetX = { Constants.SLIDE_IN_ANIMATION_DURATION },
|
||||
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
|
||||
)
|
||||
|
||||
else -> {
|
||||
fadeIn(animationSpec = tween(1000))
|
||||
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
|
||||
}
|
||||
}
|
||||
}) { SupportScreen(padding = padding) }
|
||||
}) { SupportScreen(padding = padding, focusRequester) }
|
||||
composable("${Routes.Config.name}/{id}", enterTransition = {
|
||||
fadeIn(animationSpec = tween(1000))
|
||||
}) { ConfigScreen(padding = padding, navController = navController, id = it.arguments?.getString("id"))}
|
||||
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
|
||||
}) { it ->
|
||||
val id = it.arguments?.getString("id")
|
||||
if(!id.isNullOrBlank()) {
|
||||
ConfigScreen(navController = navController, id = id, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester)}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui
|
||||
|
||||
data class ViewState(
|
||||
val showSnackbarMessage : Boolean = false,
|
||||
val snackbarMessage : String = "",
|
||||
val snackbarActionText : String = "",
|
||||
val onSnackbarActionClick : () -> Unit = {},
|
||||
val isLoading : Boolean = false
|
||||
)
|
||||
@@ -3,24 +3,26 @@ package com.zaneschepke.wireguardautotunnel.ui.common
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
|
||||
@Composable
|
||||
fun ClickableIconButton(onIconClick : () -> Unit, text : String, icon : ImageVector, enabled : Boolean) {
|
||||
Button(onClick = {},
|
||||
TextButton(onClick = {},
|
||||
enabled = enabled
|
||||
) {
|
||||
Text(text)
|
||||
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = "Delete",
|
||||
contentDescription = stringResource(R.string.delete),
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize).clickable {
|
||||
if(enabled) {
|
||||
onIconClick()
|
||||
|
||||
@@ -1,41 +1,84 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common
|
||||
|
||||
import androidx.compose.animation.animateContentSize
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
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)
|
||||
@Composable
|
||||
fun RowListItem(text : String, onHold : () -> Unit, rowButton : @Composable() () -> Unit ) {
|
||||
fun RowListItem(icon : @Composable () -> Unit, text : String, onHold : () -> Unit,
|
||||
onClick: () -> Unit, rowButton : @Composable () -> Unit,
|
||||
expanded : Boolean, statistics: Statistics?
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.animateContentSize()
|
||||
.clip(RoundedCornerShape(30.dp))
|
||||
.combinedClickable(
|
||||
onClick = {
|
||||
|
||||
onClick()
|
||||
},
|
||||
onLongClick = {
|
||||
onHold()
|
||||
}
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text)
|
||||
rowButton()
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 5.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically,) {
|
||||
icon()
|
||||
Text(text)
|
||||
}
|
||||
rowButton()
|
||||
}
|
||||
if(expanded) {
|
||||
statistics?.peers()?.forEach {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis
|
||||
val peerTx = statistics.peer(it)!!.txBytes
|
||||
val peerRx = statistics.peer(it)!!.rxBytes
|
||||
val peerId = it.toBase64().subSequence(0,3).toString() + "***"
|
||||
val handshakeSec = NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch)
|
||||
val handshake = if(handshakeSec == null) "never" else "$handshakeSec secs ago"
|
||||
val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString()
|
||||
val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString()
|
||||
val fontSize = 9.sp
|
||||
Text("peer: $peerId", fontSize = fontSize)
|
||||
Text("handshake: $handshake", fontSize = fontSize)
|
||||
Text("tx: $peerTxMB MB", fontSize = fontSize)
|
||||
Text("rx: $peerRxMB MB", fontSize = fontSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.config
|
||||
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
|
||||
@Composable
|
||||
fun
|
||||
ConfigurationTextBox(value : String, hint : String, onValueChange : (String) -> Unit, keyboardActions : KeyboardActions, label : String, modifier: Modifier) {
|
||||
OutlinedTextField(
|
||||
modifier = modifier,
|
||||
value = value,
|
||||
singleLine = true,
|
||||
onValueChange = {
|
||||
onValueChange(it)
|
||||
},
|
||||
label = { Text(label) },
|
||||
maxLines = 1,
|
||||
placeholder = {
|
||||
Text(hint)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = keyboardActions,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.prompt
|
||||
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK
|
||||
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
|
||||
@Composable
|
||||
fun AuthorizationPrompt(onSuccess : () -> Unit, onFailure : () -> Unit, onError : (String) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||
val isBiometricAvailable = remember {
|
||||
when(bio){
|
||||
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
|
||||
onError("Biometrics not available")
|
||||
false
|
||||
}
|
||||
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
|
||||
onError("Biometrics not created")
|
||||
false
|
||||
}
|
||||
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
|
||||
onError("Biometric hardware not found")
|
||||
false
|
||||
}
|
||||
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
|
||||
onError("Biometric security update required")
|
||||
false
|
||||
}
|
||||
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
|
||||
onError("Biometrics not supported")
|
||||
false
|
||||
}
|
||||
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
|
||||
onError("Biometrics status unknown")
|
||||
false
|
||||
}
|
||||
BiometricManager.BIOMETRIC_SUCCESS -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
if(isBiometricAvailable) {
|
||||
val executor = remember { ContextCompat.getMainExecutor(context) }
|
||||
|
||||
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
||||
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
|
||||
.setTitle("Biometric Authentication")
|
||||
.setSubtitle("Log in using your biometric credential")
|
||||
.build()
|
||||
|
||||
val biometricPrompt = BiometricPrompt(
|
||||
context as FragmentActivity,
|
||||
executor,
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
super.onAuthenticationError(errorCode, errString)
|
||||
onFailure()
|
||||
}
|
||||
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
super.onAuthenticationSucceeded(result)
|
||||
onSuccess()
|
||||
}
|
||||
|
||||
override fun onAuthenticationFailed() {
|
||||
super.onAuthenticationFailed()
|
||||
onFailure()
|
||||
}
|
||||
}
|
||||
)
|
||||
biometricPrompt.authenticate(promptInfo)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.prompt
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Snackbar
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
|
||||
@Composable
|
||||
fun CustomSnackBar(
|
||||
message: String,
|
||||
isRtl: Boolean = true,
|
||||
containerColor: Color = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
Snackbar(containerColor = containerColor,
|
||||
modifier = Modifier.fillMaxWidth(
|
||||
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1/3f else 2/3f).padding(bottom = 100.dp),
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalLayoutDirection provides
|
||||
if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.width(IntrinsicSize.Max).height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Info,
|
||||
contentDescription = stringResource(R.string.info),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.padding(end = 10.dp)
|
||||
)
|
||||
Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.common.text
|
||||
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun SectionTitle(title : String, padding : Dp) {
|
||||
Text(
|
||||
title,
|
||||
textAlign = TextAlign.Center,
|
||||
style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.ExtraBold),
|
||||
modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.models
|
||||
|
||||
import com.wireguard.config.Interface
|
||||
|
||||
data class InterfaceProxy(
|
||||
var privateKey : String = "",
|
||||
var publicKey : String = "",
|
||||
var addresses : String = "",
|
||||
var dnsServers : String = "",
|
||||
var listenPort : String = "",
|
||||
var mtu : String = "",
|
||||
){
|
||||
companion object {
|
||||
fun from(i : Interface) : InterfaceProxy {
|
||||
return InterfaceProxy(
|
||||
publicKey = i.keyPair.publicKey.toBase64().trim(),
|
||||
privateKey = i.keyPair.privateKey.toBase64().trim(),
|
||||
addresses = i.addresses.joinToString(", ").trim(),
|
||||
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
|
||||
listenPort = if(i.listenPort.isPresent) i.listenPort.get().toString().trim() else "",
|
||||
mtu = if(i.mtu.isPresent) i.mtu.get().toString().trim() else ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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,198 +1,660 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.config
|
||||
|
||||
import android.widget.Toast
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
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.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Android
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material.icons.rounded.ContentCopy
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.Save
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FabPosition
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
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.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
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.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.drawablepainter.DrawablePainter
|
||||
import com.zaneschepke.wireguardautotunnel.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
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 timber.log.Timber
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class,
|
||||
ExperimentalFoundationApi::class
|
||||
)
|
||||
@Composable
|
||||
fun ConfigScreen(
|
||||
viewModel: ConfigViewModel = hiltViewModel(),
|
||||
padding: PaddingValues,
|
||||
focusRequester: FocusRequester,
|
||||
navController: NavController,
|
||||
id : String?
|
||||
showSnackbarMessage: (String) -> Unit,
|
||||
id: String
|
||||
) {
|
||||
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
|
||||
val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null)
|
||||
val tunnelName = viewModel.tunnelName.collectAsStateWithLifecycle()
|
||||
val packages by viewModel.packages.collectAsStateWithLifecycle()
|
||||
val checkedPackages by viewModel.checkedPackages.collectAsStateWithLifecycle()
|
||||
val include by viewModel.include.collectAsStateWithLifecycle()
|
||||
val allApplications by viewModel.allApplications.collectAsStateWithLifecycle()
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.getTunnelById(id)
|
||||
viewModel.emitAllInternetCapablePackages()
|
||||
viewModel.emitCurrentPackageConfigurations(id)
|
||||
val isAllApplicationsEnabled by viewModel.isAllApplicationsEnabled.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()
|
||||
}
|
||||
}
|
||||
|
||||
if(tunnel != null) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
Row(
|
||||
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) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
viewModel.onScreenLoad(id)
|
||||
} catch (e : Exception) {
|
||||
showSnackbarMessage(e.message!!)
|
||||
navController.navigate(Routes.Main.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val applicationButtonText = {
|
||||
"Tunneling apps: " +
|
||||
if (isAllApplicationsEnabled) "all"
|
||||
else "${checkedPackages.size} " + (if (include) "included" else "excluded")
|
||||
|
||||
}
|
||||
|
||||
if(showAuthPrompt) {
|
||||
AuthorizationPrompt(onSuccess = {
|
||||
showAuthPrompt = false
|
||||
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
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
.fillMaxHeight(if (isAllApplicationsEnabled) 1 / 5f else 4 / 5f)
|
||||
) {
|
||||
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)
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(stringResource(id = R.string.tunnel_all))
|
||||
Switch(
|
||||
checked = allApplications,
|
||||
onCheckedChange = {
|
||||
viewModel.onAllApplicationsChange(!allApplications)
|
||||
}
|
||||
)
|
||||
}
|
||||
if(!allApplications) {
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween){
|
||||
Text(stringResource(id = R.string.include))
|
||||
Checkbox(
|
||||
checked = include,
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(stringResource(id = R.string.tunnel_all))
|
||||
Switch(
|
||||
checked = isAllApplicationsEnabled,
|
||||
onCheckedChange = {
|
||||
viewModel.onIncludeChange(!include)
|
||||
viewModel.onAllApplicationsChange(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween){
|
||||
Text(stringResource(id = R.string.exclude))
|
||||
Checkbox(
|
||||
checked = !include,
|
||||
onCheckedChange = {
|
||||
viewModel.onIncludeChange(!include)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
LazyColumn(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight(.75f)
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.Start) {
|
||||
items(packages) { pack ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
if (!isAllApplicationsEnabled) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = 20.dp,
|
||||
vertical = 7.dp
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(5.dp)
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
val drawable = pack.applicationInfo?.loadIcon(context.packageManager)
|
||||
if(drawable != null) {
|
||||
Image(painter = DrawablePainter(drawable), stringResource(id = R.string.icon), modifier = Modifier.size(50.dp, 50.dp))
|
||||
} else {
|
||||
Icon(Icons.Rounded.Android, stringResource(id = R.string.edit), modifier = Modifier.size(50.dp, 50.dp))
|
||||
}
|
||||
Text(pack.applicationInfo.loadLabel(context.packageManager).toString(), modifier = Modifier.padding(5.dp))
|
||||
Text(stringResource(id = R.string.include))
|
||||
Checkbox(
|
||||
checked = include,
|
||||
onCheckedChange = {
|
||||
viewModel.onIncludeChange(!include)
|
||||
}
|
||||
)
|
||||
}
|
||||
Checkbox(
|
||||
checked = (checkedPackages.contains(pack.packageName)),
|
||||
onCheckedChange = {
|
||||
if(it) viewModel.onAddCheckedPackage(pack.packageName) else viewModel.onRemoveCheckedPackage(pack.packageName)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(stringResource(id = R.string.exclude))
|
||||
Checkbox(
|
||||
checked = !include,
|
||||
onCheckedChange = {
|
||||
viewModel.onIncludeChange(!include)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = 20.dp,
|
||||
vertical = 7.dp
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(5.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(
|
||||
fillMaxWidth
|
||||
)
|
||||
) {
|
||||
val drawable =
|
||||
pack.applicationInfo?.loadIcon(
|
||||
context.packageManager
|
||||
)
|
||||
if (drawable != null) {
|
||||
Image(
|
||||
painter = DrawablePainter(
|
||||
drawable
|
||||
),
|
||||
stringResource(id = R.string.icon),
|
||||
modifier = Modifier.size(
|
||||
50.dp,
|
||||
50.dp
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
Icons.Rounded.Android,
|
||||
stringResource(id = R.string.edit),
|
||||
modifier = Modifier.size(
|
||||
50.dp,
|
||||
50.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
Text(
|
||||
viewModel.getPackageLabel(pack),
|
||||
modifier = Modifier.padding(5.dp)
|
||||
)
|
||||
}
|
||||
Checkbox(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
checked = (checkedPackages.contains(pack.packageName)),
|
||||
onCheckedChange = {
|
||||
if (it) viewModel.onAddCheckedPackage(
|
||||
pack.packageName
|
||||
) else viewModel.onRemoveCheckedPackage(
|
||||
pack.packageName
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 5.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showApplicationsDialog = false
|
||||
}) {
|
||||
Text(stringResource(R.string.done))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Button(onClick = {
|
||||
scope.launch {
|
||||
viewModel.onSaveAllChanges()
|
||||
Toast.makeText(context, context.resources.getString(R.string.config_changes_saved), Toast.LENGTH_LONG).show()
|
||||
navController.navigate(Routes.Main.name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (tunnel != null) {
|
||||
Scaffold(
|
||||
floatingActionButtonPosition = FabPosition.End,
|
||||
floatingActionButton = {
|
||||
val secondaryColor = MaterialTheme.colorScheme.secondary
|
||||
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
|
||||
var fobColor by remember { mutableStateOf(secondaryColor) }
|
||||
FloatingActionButton(
|
||||
modifier = Modifier.padding(bottom = 90.dp).onFocusChanged {
|
||||
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
|
||||
fobColor = if (it.isFocused) hoverColor else secondaryColor }
|
||||
},
|
||||
onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
viewModel.onSaveAllChanges()
|
||||
navController.navigate(Routes.Main.name)
|
||||
showSnackbarMessage(context.resources.getString(R.string.config_changes_saved))
|
||||
} catch (e : Exception) {
|
||||
Timber.e(e.message)
|
||||
showSnackbarMessage(e.message!!)
|
||||
}
|
||||
}
|
||||
},
|
||||
containerColor = fobColor,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Save,
|
||||
contentDescription = stringResource(id = R.string.save_changes),
|
||||
tint = Color.DarkGray,
|
||||
)
|
||||
}
|
||||
}) {
|
||||
Column {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.weight(1f, true)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
Surface(
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 2.dp,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context))
|
||||
Modifier
|
||||
.fillMaxHeight(fillMaxHeight)
|
||||
.fillMaxWidth(fillMaxWidth)
|
||||
else Modifier.fillMaxWidth(fillMaxWidth)).padding(
|
||||
top = 50.dp,
|
||||
bottom = 10.dp
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
modifier = Modifier.padding(15.dp).focusGroup()
|
||||
) {
|
||||
SectionTitle(stringResource(R.string.interface_), padding = screenPadding)
|
||||
ConfigurationTextBox(
|
||||
value = tunnelName.value,
|
||||
onValueChange = { value ->
|
||||
viewModel.onTunnelNameChange(value)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.name),
|
||||
hint = stringResource(R.string.tunnel_name).lowercase(),
|
||||
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(focusRequester)
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = baseTextBoxModifier.fillMaxWidth().clickable {
|
||||
showAuthPrompt = true
|
||||
},
|
||||
value = proxyInterface.privateKey,
|
||||
visualTransformation = if((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
|
||||
onValueChange = { value ->
|
||||
viewModel.onPrivateKeyChange(value)
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.focusRequester(FocusRequester.Default),
|
||||
onClick = {
|
||||
viewModel.generateKeyPair()
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Rounded.Refresh,
|
||||
stringResource(R.string.rotate_keys),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.private_key)) },
|
||||
singleLine = true,
|
||||
placeholder = { Text(stringResource(R.string.base64_key)) },
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions
|
||||
)
|
||||
OutlinedTextField(
|
||||
modifier = baseTextBoxModifier.fillMaxWidth().focusRequester(FocusRequester.Default),
|
||||
value = proxyInterface.publicKey,
|
||||
enabled = false,
|
||||
onValueChange = {},
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
modifier = Modifier.focusRequester(FocusRequester.Default),
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(proxyInterface.publicKey))
|
||||
}) {
|
||||
Icon(
|
||||
Icons.Rounded.ContentCopy,
|
||||
stringResource(R.string.copy_public_key),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
},
|
||||
label = { Text(stringResource(R.string.public_key)) },
|
||||
singleLine = true,
|
||||
placeholder = { Text(stringResource(R.string.base64_key)) },
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
ConfigurationTextBox(
|
||||
value = proxyInterface.addresses,
|
||||
onValueChange = { value ->
|
||||
viewModel.onAddressesChanged(value)
|
||||
},
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.addresses),
|
||||
hint = stringResource(R.string.comma_separated_list),
|
||||
modifier = baseTextBoxModifier
|
||||
.fillMaxWidth(3 / 5f)
|
||||
.padding(end = 5.dp)
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = proxyInterface.listenPort,
|
||||
onValueChange = { value -> viewModel.onListenPortChanged(value) },
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.listen_port),
|
||||
hint = stringResource(R.string.random),
|
||||
modifier = baseTextBoxModifier.width(IntrinsicSize.Min)
|
||||
)
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
ConfigurationTextBox(
|
||||
value = proxyInterface.dnsServers,
|
||||
onValueChange = { value -> viewModel.onDnsServersChanged(value) },
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.dns_servers),
|
||||
hint = stringResource(R.string.comma_separated_list),
|
||||
modifier = baseTextBoxModifier
|
||||
.fillMaxWidth(3 / 5f)
|
||||
.padding(end = 5.dp)
|
||||
)
|
||||
ConfigurationTextBox(
|
||||
value = proxyInterface.mtu,
|
||||
onValueChange = { value -> viewModel.onMtuChanged(value) },
|
||||
keyboardActions = keyboardActions,
|
||||
label = stringResource(R.string.mtu),
|
||||
hint = stringResource(R.string.auto),
|
||||
modifier = baseTextBoxModifier.width(IntrinsicSize.Min)
|
||||
)
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 5.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
showApplicationsDialog = true
|
||||
}) {
|
||||
Text(applicationButtonText())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, Modifier.padding(25.dp)) {
|
||||
Text(stringResource(id = R.string.save_changes))
|
||||
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,22 +8,44 @@ import android.os.Build
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.zaneschepke.wireguardautotunnel.repository.Repository
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wireguard.config.Config
|
||||
import com.wireguard.config.Interface
|
||||
import com.wireguard.config.Peer
|
||||
import com.wireguard.crypto.Key
|
||||
import com.wireguard.crypto.KeyPair
|
||||
import com.zaneschepke.wireguardautotunnel.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
|
||||
import com.zaneschepke.wireguardautotunnel.repository.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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import timber.log.Timber
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class ConfigViewModel @Inject constructor(private val application : Application,
|
||||
private val tunnelRepo : Repository<TunnelConfig>) : ViewModel() {
|
||||
private val tunnelRepo : TunnelConfigDao,
|
||||
private val settingsRepo : SettingsDoa
|
||||
) : ViewModel() {
|
||||
|
||||
private val _tunnel = MutableStateFlow<TunnelConfig?>(null)
|
||||
private val _tunnelName = MutableStateFlow("")
|
||||
val tunnelName get() = _tunnelName.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>())
|
||||
val packages get() = _packages.asStateFlow()
|
||||
private val packageManager = application.packageManager
|
||||
@@ -33,27 +55,89 @@ class ConfigViewModel @Inject constructor(private val application : Application,
|
||||
private val _include = MutableStateFlow(true)
|
||||
val include get() = _include.asStateFlow()
|
||||
|
||||
private val _allApplications = MutableStateFlow(true)
|
||||
val allApplications get() = _allApplications.asStateFlow()
|
||||
private val _isAllApplicationsEnabled = MutableStateFlow(false)
|
||||
val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow()
|
||||
private val _isDefaultTunnel = MutableStateFlow(false)
|
||||
val isDefaultTunnel = _isDefaultTunnel.asStateFlow()
|
||||
|
||||
suspend fun getTunnelById(id : String?) : TunnelConfig? {
|
||||
private lateinit var tunnelConfig: 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 {
|
||||
if(id != null) {
|
||||
val config = tunnelRepo.getById(id.toLong())
|
||||
if (config != null) {
|
||||
_tunnel.emit(config)
|
||||
_tunnelName.emit(config.name)
|
||||
|
||||
}
|
||||
return config
|
||||
}
|
||||
return null
|
||||
} catch (e : Exception) {
|
||||
Timber.e(e.message)
|
||||
tunnelRepo.getById(id.toLong())
|
||||
} catch (_ : Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun emitTunnelConfig() {
|
||||
_tunnel.emit(tunnelConfig)
|
||||
}
|
||||
|
||||
private suspend fun emitTunnelConfigName() {
|
||||
_tunnelName.emit(tunnelConfig.name)
|
||||
}
|
||||
|
||||
fun onTunnelNameChange(name : String) {
|
||||
_tunnelName.value = name
|
||||
}
|
||||
@@ -65,39 +149,76 @@ class ConfigViewModel @Inject constructor(private val application : Application,
|
||||
_checkedPackages.value.add(packageName)
|
||||
}
|
||||
|
||||
fun onAllApplicationsChange(allApplications : Boolean) {
|
||||
_allApplications.value = allApplications
|
||||
fun onAllApplicationsChange(isAllApplicationsEnabled : Boolean) {
|
||||
_isAllApplicationsEnabled.value = isAllApplicationsEnabled
|
||||
}
|
||||
|
||||
fun onRemoveCheckedPackage(packageName : String) {
|
||||
_checkedPackages.value.remove(packageName)
|
||||
}
|
||||
|
||||
suspend fun emitCurrentPackageConfigurations(id : String?) {
|
||||
val tunnelConfig = getTunnelById(id)
|
||||
if(tunnelConfig != null) {
|
||||
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
|
||||
val excludedApps = config.`interface`.excludedApplications
|
||||
val includedApps = config.`interface`.includedApplications
|
||||
if(excludedApps.isNullOrEmpty() && includedApps.isNullOrEmpty()) {
|
||||
_allApplications.emit(true)
|
||||
return
|
||||
}
|
||||
if(excludedApps.isEmpty()) {
|
||||
_include.emit(true)
|
||||
_checkedPackages.emit(includedApps.toMutableStateList())
|
||||
} else {
|
||||
_include.emit(false)
|
||||
_checkedPackages.emit(excludedApps.toMutableStateList())
|
||||
}
|
||||
_allApplications.emit(false)
|
||||
private suspend fun emitSplitTunnelConfiguration(config : Config) {
|
||||
val excludedApps = config.`interface`.excludedApplications
|
||||
val includedApps = config.`interface`.includedApplications
|
||||
if (excludedApps.isNotEmpty() || includedApps.isNotEmpty()) {
|
||||
emitTunnelAllApplicationsDisabled()
|
||||
determineAppInclusionState(excludedApps, includedApps)
|
||||
} else {
|
||||
emitTunnelAllApplicationsEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun emitAllInternetCapablePackages() {
|
||||
_packages.emit(getAllInternetCapablePackages())
|
||||
private suspend fun determineAppInclusionState(excludedApps : Set<String>, includedApps : Set<String>) {
|
||||
if (excludedApps.isEmpty()) {
|
||||
emitIncludedAppsExist()
|
||||
emitCheckedApps(includedApps)
|
||||
} else {
|
||||
emitExcludedAppsExist()
|
||||
emitCheckedApps(excludedApps)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun emitIncludedAppsExist() {
|
||||
_include.emit(true)
|
||||
}
|
||||
|
||||
private suspend fun emitExcludedAppsExist() {
|
||||
_include.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) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
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> {
|
||||
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
|
||||
}
|
||||
@@ -106,28 +227,169 @@ class ConfigViewModel @Inject constructor(private val application : Application,
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
packageManager.getPackagesHoldingPermissions(permissions, PackageManager.PackageInfoFlags.of(0L))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
packageManager.getPackagesHoldingPermissions(permissions, 0)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun onSaveAllChanges() {
|
||||
var wgQuick = _tunnel.value?.wgQuick
|
||||
if(wgQuick != null) {
|
||||
wgQuick = if(_include.value) {
|
||||
TunnelConfig.setIncludedApplicationsOnQuick(_checkedPackages.value, wgQuick)
|
||||
} else {
|
||||
TunnelConfig.setExcludedApplicationsOnQuick(_checkedPackages.value, wgQuick)
|
||||
}
|
||||
if(_allApplications.value) {
|
||||
wgQuick = TunnelConfig.clearAllApplicationsFromConfig(wgQuick)
|
||||
}
|
||||
_tunnel.value?.copy(
|
||||
name = _tunnelName.value,
|
||||
wgQuick = wgQuick
|
||||
)?.let {
|
||||
tunnelRepo.save(it)
|
||||
private fun isAllApplicationsEnabled() : Boolean {
|
||||
return _isAllApplicationsEnabled.value
|
||||
}
|
||||
|
||||
private fun isIncludeApplicationsEnabled() : Boolean {
|
||||
return _include.value
|
||||
}
|
||||
|
||||
private suspend fun saveConfig(tunnelConfig: TunnelConfig) {
|
||||
tunnelRepo.save(tunnelConfig)
|
||||
}
|
||||
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) {
|
||||
if(tunnelConfig != null) {
|
||||
saveConfig(tunnelConfig)
|
||||
updateSettingsDefaultTunnel(tunnelConfig)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
|
||||
val settings = settingsRepo.getAll()
|
||||
if(settings.isNotEmpty()) {
|
||||
val setting = settings[0]
|
||||
if(setting.defaultTunnel != null) {
|
||||
if(tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
|
||||
settingsRepo.save(setting.copy(
|
||||
defaultTunnel = tunnelConfig.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("")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,17 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.main
|
||||
|
||||
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.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -12,15 +20,21 @@ import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.QrCode
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Circle
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material.icons.rounded.Info
|
||||
import androidx.compose.material.icons.rounded.Star
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FabPosition
|
||||
@@ -30,23 +44,29 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
@@ -56,51 +76,146 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.journeyapps.barcodescanner.ScanContract
|
||||
import com.journeyapps.barcodescanner.ScanOptions
|
||||
import com.wireguard.android.backend.Tunnel
|
||||
import com.zaneschepke.wireguardautotunnel.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
|
||||
import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Routes
|
||||
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed
|
||||
import com.zaneschepke.wireguardautotunnel.ui.theme.mint
|
||||
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValues,
|
||||
snackbarHostState : SnackbarHostState, navController: NavController) {
|
||||
fun MainScreen(
|
||||
viewModel: MainViewModel = hiltViewModel(),
|
||||
padding: PaddingValues,
|
||||
showSnackbarMessage: (String) -> Unit,
|
||||
navController: NavController
|
||||
) {
|
||||
|
||||
val haptic = LocalHapticFeedback.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val isVisible = rememberSaveable { mutableStateOf(true) }
|
||||
val scope = rememberCoroutineScope { Dispatchers.IO }
|
||||
|
||||
val sheetState = rememberModalBottomSheetState()
|
||||
var showBottomSheet by remember { mutableStateOf(false) }
|
||||
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
|
||||
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
|
||||
val viewState = viewModel.viewState.collectAsStateWithLifecycle()
|
||||
val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(HandshakeStatus.NOT_STARTED)
|
||||
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
|
||||
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
|
||||
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val statistics by viewModel.statistics.collectAsStateWithLifecycle(null)
|
||||
|
||||
|
||||
LaunchedEffect(viewState.value) {
|
||||
if (viewState.value.showSnackbarMessage) {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = viewState.value.snackbarMessage,
|
||||
actionLabel = viewState.value.snackbarActionText,
|
||||
duration = SnackbarDuration.Long,
|
||||
)
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> viewState.value.onSnackbarActionClick
|
||||
SnackbarResult.Dismissed -> viewState.value.onSnackbarActionClick
|
||||
// Nested scroll for control FAB
|
||||
val nestedScrollConnection = remember {
|
||||
object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
// Hide FAB
|
||||
if (available.y < -1) {
|
||||
isVisible.value = false
|
||||
}
|
||||
// Show FAB
|
||||
if (available.y > 1) {
|
||||
isVisible.value = true
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pickFileLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.GetContent()
|
||||
) { file ->
|
||||
if (file != null) {
|
||||
viewModel.onTunnelFileSelected(file)
|
||||
val tunnelFileImportResultLauncher = rememberLauncherForActivityResult(object : ActivityResultContracts.GetContent() {
|
||||
override fun createIntent(context: Context, input: String): Intent {
|
||||
val intent = super.createIntent(context, input)
|
||||
|
||||
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
|
||||
* what we can do, so detect this and throw an exception that we can catch later. */
|
||||
val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()))
|
||||
} else {
|
||||
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
}
|
||||
if (activitiesToResolveIntent.all {
|
||||
val name = it.activityInfo.packageName
|
||||
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
|
||||
}) {
|
||||
throw WgTunnelException(context.getString(R.string.no_file_explorer))
|
||||
}
|
||||
return intent
|
||||
}
|
||||
}) { data ->
|
||||
if (data == null) return@rememberLauncherForActivityResult
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
viewModel.onTunnelFileSelected(data)
|
||||
} catch (e : WgTunnelException) {
|
||||
showSnackbarMessage(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val scanLauncher = rememberLauncherForActivityResult(
|
||||
contract = ScanContract(),
|
||||
onResult = {
|
||||
scope.launch {
|
||||
try {
|
||||
viewModel.onTunnelQrResult(it.contents)
|
||||
} catch (e: Exception) {
|
||||
when(e) {
|
||||
is WgTunnelException -> {
|
||||
showSnackbarMessage(e.message)
|
||||
} else -> {
|
||||
showSnackbarMessage("No QR code scanned")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if(showPrimaryChangeAlertDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
showPrimaryChangeAlertDialog = false
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
scope.launch {
|
||||
viewModel.onDefaultTunnelChange(selectedTunnel)
|
||||
showPrimaryChangeAlertDialog = false
|
||||
selectedTunnel = null
|
||||
}
|
||||
})
|
||||
{ Text(text = stringResource(R.string.okay)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = {
|
||||
showPrimaryChangeAlertDialog = false
|
||||
})
|
||||
{ Text(text = stringResource(R.string.cancel)) }
|
||||
},
|
||||
title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
|
||||
text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) }
|
||||
)
|
||||
}
|
||||
|
||||
fun onTunnelToggle(checked : Boolean , tunnel : TunnelConfig) {
|
||||
try {
|
||||
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
|
||||
} catch (e : Exception) {
|
||||
showSnackbarMessage(e.message!!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,19 +227,35 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
|
||||
},
|
||||
floatingActionButtonPosition = FabPosition.End,
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
modifier = Modifier.padding(bottom = 90.dp),
|
||||
onClick = {
|
||||
showBottomSheet = true
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.secondary,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
AnimatedVisibility(
|
||||
visible = isVisible.value,
|
||||
enter = slideInVertically(initialOffsetY = { it * 2 }),
|
||||
exit = slideOutVertically(targetOffsetY = { it * 2 }),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = stringResource(id = R.string.add_tunnel),
|
||||
tint = Color.DarkGray,
|
||||
)
|
||||
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 = {
|
||||
showBottomSheet = true
|
||||
},
|
||||
containerColor = fobColor,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Add,
|
||||
contentDescription = stringResource(id = R.string.add_tunnel),
|
||||
tint = Color.DarkGray,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) {
|
||||
@@ -152,26 +283,72 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
showBottomSheet = false
|
||||
pickFileLauncher.launch("*/*")
|
||||
try {
|
||||
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
|
||||
} catch (e: Exception) {
|
||||
showSnackbarMessage(e.message!!)
|
||||
}
|
||||
}
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.FileOpen, contentDescription = stringResource(id = R.string.open_file), modifier = Modifier.padding(10.dp))
|
||||
Text(stringResource(id = R.string.add_from_file), modifier = Modifier.padding(10.dp))
|
||||
Icon(
|
||||
Icons.Filled.FileOpen,
|
||||
contentDescription = stringResource(id = R.string.open_file),
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(id = R.string.add_tunnels_text),
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
|
||||
Divider()
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
scope.launch {
|
||||
showBottomSheet = false
|
||||
val scanOptions = ScanOptions()
|
||||
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)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.QrCode,
|
||||
contentDescription = stringResource(id = R.string.qr_scan),
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(id = R.string.add_from_qr),
|
||||
modifier = Modifier.padding(10.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Row(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
scope.launch {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
showBottomSheet = false
|
||||
viewModel.onTunnelQRSelected()
|
||||
navController.navigate("${Routes.Config.name}/${Constants.MANUAL_TUNNEL_CONFIG_ID}")
|
||||
}
|
||||
}
|
||||
.padding(10.dp)
|
||||
.padding(10.dp)
|
||||
) {
|
||||
Icon(Icons.Filled.QrCode, contentDescription = stringResource(id = R.string.qr_scan), modifier = Modifier.padding(10.dp))
|
||||
Text(stringResource(id = R.string.add_from_qr), modifier = Modifier.padding(10.dp))
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,39 +359,157 @@ fun MainScreen(viewModel: MainViewModel = hiltViewModel(), padding : PaddingValu
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
) {
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(tunnels.toList()) { tunnel ->
|
||||
RowListItem(text = tunnel.name, onHold = {
|
||||
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
|
||||
scope.launch {
|
||||
viewModel.showSnackBarMessage(context.resources.getString(R.string.turn_off_tunnel))
|
||||
}
|
||||
return@RowListItem
|
||||
}
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
selectedTunnel = tunnel;
|
||||
}, rowButton = {
|
||||
if (tunnel.id == selectedTunnel?.id) {
|
||||
Row() {
|
||||
IconButton(onClick = {
|
||||
navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}")
|
||||
}) {
|
||||
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
|
||||
}
|
||||
IconButton(onClick = { viewModel.onDelete(tunnel) }) {
|
||||
Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Switch(
|
||||
checked = (state == Tunnel.State.UP && tunnel.name == tunnelName),
|
||||
onCheckedChange = { checked ->
|
||||
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(nestedScrollConnection),
|
||||
) {
|
||||
items(tunnels, key = { tunnel -> tunnel.id }) { tunnel ->
|
||||
val leadingIconColor = (if (tunnelName == tunnel.name) when (handshakeStatus) {
|
||||
HandshakeStatus.HEALTHY -> mint
|
||||
HandshakeStatus.UNHEALTHY -> brickRed
|
||||
HandshakeStatus.NOT_STARTED -> Color.Gray
|
||||
HandshakeStatus.NEVER_CONNECTED -> brickRed
|
||||
} else {Color.Gray})
|
||||
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,
|
||||
onHold = {
|
||||
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
|
||||
showSnackbarMessage(context.resources.getString(R.string.turn_off_tunnel))
|
||||
return@RowListItem
|
||||
}
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
selectedTunnel = tunnel
|
||||
},
|
||||
onClick = {
|
||||
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 = {
|
||||
if (tunnel.id == selectedTunnel?.id && !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(onClick = {
|
||||
navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}")
|
||||
}) {
|
||||
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
|
||||
}
|
||||
IconButton(
|
||||
modifier = Modifier.focusable(),
|
||||
onClick = { viewModel.onDelete(tunnel) }) {
|
||||
Icon(
|
||||
Icons.Rounded.Delete,
|
||||
stringResource(id = R.string.delete)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val checked = state == Tunnel.State.UP && tunnel.name == tunnelName
|
||||
if(!checked) expanded.value = false
|
||||
@Composable
|
||||
fun TunnelSwitch() = Switch(
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
checked = checked,
|
||||
onCheckedChange = { checked ->
|
||||
if(!checked) expanded.value = false
|
||||
onTunnelToggle(checked, tunnel)
|
||||
}
|
||||
)
|
||||
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
|
||||
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,58 +1,59 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.main
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.wireguard.config.Config
|
||||
import com.zaneschepke.wireguardautotunnel.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.repository.Repository
|
||||
import com.zaneschepke.wireguardautotunnel.service.barcode.CodeScanner
|
||||
import com.zaneschepke.wireguardautotunnel.service.foreground.Action
|
||||
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.ServiceManager
|
||||
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.WireGuardTunnelService
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.Settings
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.ui.ViewState
|
||||
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
|
||||
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipInputStream
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class MainViewModel @Inject constructor(private val application : Application,
|
||||
private val tunnelRepo : Repository<TunnelConfig>,
|
||||
private val settingsRepo : Repository<Settings>,
|
||||
private val vpnService: VpnService,
|
||||
private val codeScanner: CodeScanner
|
||||
class MainViewModel @Inject constructor(
|
||||
private val application: Application,
|
||||
private val tunnelRepo: TunnelConfigDao,
|
||||
private val settingsRepo: SettingsDoa,
|
||||
private val vpnService: VpnService
|
||||
) : ViewModel() {
|
||||
|
||||
private val _viewState = MutableStateFlow(ViewState())
|
||||
val viewState get() = _viewState.asStateFlow()
|
||||
val tunnels get() = tunnelRepo.itemFlow
|
||||
val tunnels get() = tunnelRepo.getAllFlow()
|
||||
val state get() = vpnService.state
|
||||
|
||||
val handshakeStatus get() = vpnService.handshakeStatus
|
||||
val tunnelName get() = vpnService.tunnelName
|
||||
private val _settings = MutableStateFlow(Settings())
|
||||
val settings get() = _settings.asStateFlow()
|
||||
|
||||
private val defaultConfigName = {
|
||||
"tunnel${(Math.random() * 100000).toInt()}"
|
||||
}
|
||||
|
||||
val statistics get() = vpnService.statistics
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.itemFlow.collect {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect {
|
||||
val settings = it.first()
|
||||
validateWatcherServiceState(settings)
|
||||
_settings.emit(settings)
|
||||
@@ -61,28 +62,29 @@ class MainViewModel @Inject constructor(private val application : Application,
|
||||
}
|
||||
|
||||
private fun validateWatcherServiceState(settings: Settings) {
|
||||
val watcherState = ServiceTracker.getServiceState(application, WireGuardConnectivityWatcherService::class.java)
|
||||
if(settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) {
|
||||
startWatcherService(settings.defaultTunnel!!)
|
||||
val watcherState = ServiceManager.getServiceState(
|
||||
application.applicationContext,
|
||||
WireGuardConnectivityWatcherService::class.java
|
||||
)
|
||||
if (settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) {
|
||||
ServiceManager.startWatcherService(
|
||||
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 {
|
||||
if(tunnelRepo.count() == 1L) {
|
||||
ServiceTracker.actionOnService( Action.STOP, application, WireGuardConnectivityWatcherService::class.java)
|
||||
if (tunnelRepo.count() == 1L) {
|
||||
ServiceManager.stopWatcherService(application.applicationContext)
|
||||
val settings = settingsRepo.getAll()
|
||||
if(!settings.isNullOrEmpty()) {
|
||||
if (settings.isNotEmpty()) {
|
||||
val setting = settings[0]
|
||||
setting.defaultTunnel = null
|
||||
setting.isAutoTunnelEnabled = false
|
||||
setting.isAlwaysOnVpnEnabled = false
|
||||
settingsRepo.save(setting)
|
||||
}
|
||||
}
|
||||
@@ -90,86 +92,168 @@ class MainViewModel @Inject constructor(private val application : Application,
|
||||
}
|
||||
}
|
||||
|
||||
fun onTunnelStart(tunnelConfig : TunnelConfig) = viewModelScope.launch {
|
||||
ServiceTracker.actionOnService( Action.START, application, WireGuardTunnelService::class.java,
|
||||
mapOf(application.resources.getString(R.string.tunnel_extras_key) to tunnelConfig.toString()))
|
||||
fun onTunnelStart(tunnelConfig: TunnelConfig) {
|
||||
viewModelScope.launch {
|
||||
stopActiveTunnel()
|
||||
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() {
|
||||
ServiceTracker.actionOnService( Action.STOP, application, WireGuardTunnelService::class.java)
|
||||
ServiceManager.stopVpnService(application.applicationContext)
|
||||
}
|
||||
|
||||
suspend fun onTunnelQRSelected() {
|
||||
codeScanner.scan().collect {
|
||||
Timber.d(it)
|
||||
if(!it.isNullOrEmpty() && it.contains(application.resources.getString(R.string.config_validation))) {
|
||||
tunnelRepo.save(TunnelConfig(name = defaultConfigName(), wgQuick = it))
|
||||
} else {
|
||||
showSnackBarMessage("Invalid QR code. Try again.")
|
||||
}
|
||||
private fun validateConfigString(config: String) {
|
||||
TunnelConfig.configFromQuick(config)
|
||||
}
|
||||
|
||||
suspend fun onTunnelQrResult(result: String) {
|
||||
try {
|
||||
validateConfigString(result)
|
||||
val tunnelConfig =
|
||||
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
|
||||
addTunnel(tunnelConfig)
|
||||
} catch (e : Exception) {
|
||||
throw WgTunnelException(e)
|
||||
}
|
||||
}
|
||||
|
||||
fun onTunnelFileSelected(uri : Uri) {
|
||||
val fileName = getFileName(application.applicationContext, uri)
|
||||
val extension = getFileExtensionFromFileName(fileName)
|
||||
if(extension != ".conf") {
|
||||
viewModelScope.launch {
|
||||
showSnackBarMessage(application.resources.getString(R.string.file_extension_message))
|
||||
}
|
||||
return
|
||||
}
|
||||
val stream = application.applicationContext.contentResolver.openInputStream(uri)
|
||||
stream ?: return
|
||||
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
|
||||
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
|
||||
val config = Config.parse(bufferReader)
|
||||
val tunnelName = getNameFromFileName(fileName)
|
||||
viewModelScope.launch {
|
||||
tunnelRepo.save(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
|
||||
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
|
||||
withContext(Dispatchers.IO) {
|
||||
stream.close()
|
||||
}
|
||||
stream.close()
|
||||
}
|
||||
|
||||
@SuppressLint("Range")
|
||||
private fun getFileName(context: Context, uri: Uri): String {
|
||||
if (uri.scheme == "content") {
|
||||
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
||||
cursor ?: return defaultConfigName()
|
||||
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) {
|
||||
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 {
|
||||
if(cursor.moveToFirst()) {
|
||||
return cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME))
|
||||
}
|
||||
return getDisplayNameByCursor(it)
|
||||
}
|
||||
} else {
|
||||
throw WgTunnelException("Failed to initialize cursor")
|
||||
}
|
||||
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 fun getDisplayNameColumnIndex(cursor: Cursor): Int {
|
||||
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
if (columnIndex == -1) {
|
||||
throw WgTunnelException("Cursor out of bounds")
|
||||
}
|
||||
return columnIndex
|
||||
}
|
||||
|
||||
private suspend fun dismissSnackBar() {
|
||||
_viewState.emit(_viewState.value.copy(
|
||||
showSnackbarMessage = false
|
||||
))
|
||||
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 getNameFromFileName(fileName : String) : String {
|
||||
return fileName.substring(0 , fileName.lastIndexOf('.') )
|
||||
private fun validateUriContentScheme(uri: Uri) {
|
||||
if (uri.scheme != Constants.URI_CONTENT_SCHEME) {
|
||||
throw WgTunnelException(application.getString(R.string.file_extension_message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun getFileExtensionFromFileName(fileName : String) : String {
|
||||
return fileName.substring(fileName.lastIndexOf('.'))
|
||||
|
||||
private fun getFileName(context: Context, uri: Uri): String {
|
||||
validateUriContentScheme(uri)
|
||||
return try {
|
||||
getFileNameByCursor(context, uri)
|
||||
} catch (_: Exception) {
|
||||
NumberUtils.generateRandomTunnelName()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNameFromFileName(fileName: String): String {
|
||||
return fileName.substring(0, fileName.lastIndexOf('.'))
|
||||
}
|
||||
|
||||
private fun getFileExtensionFromFileName(fileName: String): String {
|
||||
return try {
|
||||
fileName.substring(fileName.lastIndexOf('.'))
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) {
|
||||
if (selectedTunnel != null) {
|
||||
_settings.emit(
|
||||
_settings.value.copy(
|
||||
defaultTunnel = selectedTunnel.toString()
|
||||
)
|
||||
)
|
||||
settingsRepo.save(_settings.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,51 +3,56 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.settings
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.rounded.LocationOff
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
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.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
@@ -57,96 +62,163 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavController
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.service.tunnel.model.TunnelConfig
|
||||
import com.zaneschepke.wireguardautotunnel.ui.Routes
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
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 java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalPermissionsApi::class,
|
||||
ExperimentalLayoutApi::class
|
||||
@OptIn(
|
||||
ExperimentalPermissionsApi::class,
|
||||
ExperimentalLayoutApi::class, ExperimentalComposeUiApi::class
|
||||
)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
padding: PaddingValues,
|
||||
navController: NavController,
|
||||
snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }
|
||||
showSnackbarMessage: (String) -> Unit,
|
||||
focusRequester: FocusRequester,
|
||||
) {
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val scope = rememberCoroutineScope { Dispatchers.IO }
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val viewState by viewModel.viewState.collectAsStateWithLifecycle()
|
||||
val settings by viewModel.settings.collectAsStateWithLifecycle()
|
||||
val trustedSSIDs by viewModel.trustedSSIDs.collectAsStateWithLifecycle()
|
||||
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
|
||||
val backgroundLocationState =
|
||||
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
||||
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
var currentText by remember { mutableStateOf("") }
|
||||
val scrollState = rememberScrollState()
|
||||
var isLocationDisclaimerNeeded by remember { mutableStateOf(true) }
|
||||
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
|
||||
var showAuthPrompt by remember { mutableStateOf(false) }
|
||||
var didExportFiles by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(viewState) {
|
||||
if (viewState.showSnackbarMessage) {
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = viewState.snackbarMessage,
|
||||
actionLabel = viewState.snackbarActionText,
|
||||
duration = SnackbarDuration.Long,
|
||||
)
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> viewState.onSnackbarActionClick
|
||||
SnackbarResult.Dismissed -> viewState.onSnackbarActionClick
|
||||
|
||||
|
||||
val screenPadding = 5.dp
|
||||
val fillMaxWidth = .85f
|
||||
|
||||
fun exportAllConfigs() {
|
||||
try {
|
||||
val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") }
|
||||
files.forEachIndexed { index, file ->
|
||||
file.outputStream().use {
|
||||
it.write(tunnels[index].wgQuick.toByteArray())
|
||||
}
|
||||
}
|
||||
StorageUtil.saveFilesToZip(context, files)
|
||||
didExportFiles = true
|
||||
showSnackbarMessage(context.getString(R.string.exported_configs_message))
|
||||
} catch (e : Exception) {
|
||||
showSnackbarMessage(e.message!!)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun saveTrustedSSID() {
|
||||
if (currentText.isNotEmpty()) {
|
||||
scope.launch {
|
||||
viewModel.onSaveTrustedSSID(currentText)
|
||||
currentText = ""
|
||||
try {
|
||||
viewModel.onSaveTrustedSSID(currentText)
|
||||
currentText = ""
|
||||
} catch (e : Exception) {
|
||||
showSnackbarMessage(e.message ?: context.getString(R.string.unknown_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(!backgroundLocationState.status.isGranted) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally,
|
||||
fun isAllAutoTunnelPermissionsEnabled() : Boolean {
|
||||
return(isBackgroundLocationGranted && fineLocationState.status.isGranted && !viewModel.isLocationServicesNeeded())
|
||||
}
|
||||
|
||||
|
||||
fun openSettings() {
|
||||
scope.launch {
|
||||
val intentSettings =
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intentSettings.data =
|
||||
Uri.fromParts("package", context.packageName, null)
|
||||
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()
|
||||
.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(
|
||||
.verticalScroll(scrollState)
|
||||
.padding(padding)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.LocationOff,
|
||||
contentDescription = stringResource(id = R.string.map),
|
||||
modifier = Modifier
|
||||
.padding(30.dp)
|
||||
.size(128.dp)
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.prominent_background_location_title),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(30.dp),
|
||||
fontSize = 20.sp
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.prominent_background_location_message),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(30.dp),
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Row(
|
||||
modifier = if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(10.dp) else Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(30.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
Button(onClick = {
|
||||
navController.navigate(Routes.Main.name)
|
||||
TextButton(onClick = {
|
||||
isLocationDisclaimerNeeded = false
|
||||
}) {
|
||||
Text(stringResource(id = R.string.no_thanks))
|
||||
}
|
||||
Button(onClick = {
|
||||
scope.launch {
|
||||
val intentSettings =
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
intentSettings.data =
|
||||
Uri.fromParts("package", context.packageName, null)
|
||||
context.startActivity(intentSettings)
|
||||
}
|
||||
TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
|
||||
openSettings()
|
||||
}) {
|
||||
Text(stringResource(id = R.string.turn_on))
|
||||
}
|
||||
@@ -155,6 +227,22 @@ fun SettingsScreen(
|
||||
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()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
@@ -174,141 +262,225 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.clickable(indication = null, interactionSource = interactionSource) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
.padding(padding)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
Surface(
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 2.dp,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context))
|
||||
Modifier
|
||||
.height(IntrinsicSize.Min)
|
||||
.fillMaxWidth(fillMaxWidth)
|
||||
.padding(top = 10.dp)
|
||||
else Modifier
|
||||
.fillMaxWidth(fillMaxWidth)
|
||||
.padding(top = 60.dp)).padding(bottom = 25.dp)
|
||||
) {
|
||||
Text(stringResource(R.string.enable_auto_tunnel))
|
||||
Switch(
|
||||
checked = settings.isAutoTunnelEnabled,
|
||||
onCheckedChange = {
|
||||
scope.launch {
|
||||
viewModel.toggleAutoTunnel()
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
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) {
|
||||
expanded = !expanded }},
|
||||
modifier = Modifier.padding(start = 15.dp, top = 5.dp, bottom = 10.dp),
|
||||
) {
|
||||
TextField(
|
||||
enabled = !settings.isAutoTunnelEnabled,
|
||||
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
|
||||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.Start,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
modifier = Modifier.padding(15.dp)
|
||||
) {
|
||||
tunnels.forEach() { tunnel ->
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
viewModel.onDefaultTunnelSelected(tunnel)
|
||||
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 {
|
||||
viewModel.onToggleTunnelOnWifi()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.focusRequester(focusRequester)
|
||||
)
|
||||
AnimatedVisibility(visible = settings.isTunnelOnWifiEnabled) {
|
||||
Column {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(screenPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.SpaceEvenly
|
||||
) {
|
||||
trustedSSIDs.forEach { ssid ->
|
||||
ClickableIconButton(
|
||||
onIconClick = {
|
||||
scope.launch {
|
||||
viewModel.onDeleteTrustedSSID(ssid)
|
||||
}
|
||||
},
|
||||
text = ssid,
|
||||
icon = Icons.Filled.Close,
|
||||
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)
|
||||
)
|
||||
}
|
||||
expanded = false
|
||||
},
|
||||
text = { Text(text = tunnel.name) }
|
||||
)
|
||||
if(trustedSSIDs.isEmpty()) {
|
||||
Text(stringResource(R.string.none), fontStyle = FontStyle.Italic, color = Color.Gray)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
|
||||
value = currentText,
|
||||
onValueChange = { currentText = it },
|
||||
label = { Text(stringResource(R.string.add_trusted_ssid)) },
|
||||
modifier = Modifier
|
||||
.padding(start = screenPadding, top = 5.dp, bottom = 10.dp)
|
||||
.onFocusChanged {
|
||||
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
},
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
saveTrustedSSID()
|
||||
}
|
||||
),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { saveTrustedSSID() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Add,
|
||||
contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource(
|
||||
id = R.string.trusted_ssid_value_description
|
||||
),
|
||||
tint = if (currentText == "") Color.Transparent else MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
ConfigurationToggle(stringResource(R.string.tunnel_mobile_data),
|
||||
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
|
||||
checked = settings.isTunnelOnMobileDataEnabled,
|
||||
padding = screenPadding,
|
||||
onCheckChanged = {
|
||||
scope.launch {
|
||||
viewModel.onToggleTunnelOnMobileData()
|
||||
}
|
||||
}
|
||||
)
|
||||
ConfigurationToggle(stringResource(id = R.string.tunnel_on_ethernet),
|
||||
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
|
||||
checked = settings.isTunnelOnEthernetEnabled,
|
||||
padding = screenPadding,
|
||||
onCheckChanged = {
|
||||
scope.launch {
|
||||
viewModel.onToggleTunnelOnEthernet()
|
||||
}
|
||||
}
|
||||
)
|
||||
ConfigurationToggle(
|
||||
stringResource(R.string.battery_saver),
|
||||
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
|
||||
checked = settings.isBatterySaverEnabled,
|
||||
padding = screenPadding,
|
||||
onCheckChanged = {
|
||||
scope.launch {
|
||||
viewModel.onToggleBatterySaver()
|
||||
}
|
||||
}
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 5.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
TextButton(
|
||||
enabled = !settings.isAlwaysOnVpnEnabled,
|
||||
onClick = {
|
||||
//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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.trusted_ssid),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(15.dp, bottom = 5.dp, top = 5.dp)
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(15.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
trustedSSIDs.forEach { ssid ->
|
||||
ClickableIconButton(onIconClick = {
|
||||
scope.launch {
|
||||
viewModel.onDeleteTrustedSSID(ssid)
|
||||
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,
|
||||
checked = settings.isAlwaysOnVpnEnabled,
|
||||
padding = screenPadding,
|
||||
onCheckChanged = {
|
||||
scope.launch {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}, text = ssid, icon = Icons.Filled.Close, enabled = !settings.isAutoTunnelEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
enabled = !settings.isAutoTunnelEnabled,
|
||||
value = currentText,
|
||||
onValueChange = { currentText = it },
|
||||
label = { Text(stringResource(R.string.add_trusted_ssid)) },
|
||||
modifier = Modifier.padding(start = 15.dp, top = 5.dp),
|
||||
maxLines = 1,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
capitalization = KeyboardCapitalization.None,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
saveTrustedSSID()
|
||||
}
|
||||
),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { saveTrustedSSID() }) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Add,
|
||||
contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource(
|
||||
id = R.string.trusted_ssid_value_description
|
||||
),
|
||||
tint = if(currentText == "") Color.Transparent else Color.Green
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(stringResource(R.string.tunnel_mobile_data))
|
||||
Switch(
|
||||
enabled = !settings.isAutoTunnelEnabled,
|
||||
checked = settings.isTunnelOnMobileDataEnabled,
|
||||
onCheckedChange = {
|
||||
scope.launch {
|
||||
viewModel.onToggleTunnelOnMobileData()
|
||||
}
|
||||
}
|
||||
)
|
||||
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
|
||||
Spacer(modifier = Modifier.weight(.17f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,41 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.repository.Repository
|
||||
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 com.zaneschepke.wireguardautotunnel.ui.ViewState
|
||||
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.ServiceManager
|
||||
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
|
||||
@HiltViewModel
|
||||
class SettingsViewModel @Inject constructor(private val application : Application,
|
||||
private val tunnelRepo : Repository<TunnelConfig>, private val settingsRepo : Repository<Settings>
|
||||
private val tunnelRepo : TunnelConfigDao, private val settingsRepo : SettingsDoa
|
||||
) : ViewModel() {
|
||||
|
||||
private val _trustedSSIDs = MutableStateFlow(emptyList<String>())
|
||||
val trustedSSIDs = _trustedSSIDs.asStateFlow()
|
||||
private val _settings = MutableStateFlow(Settings())
|
||||
val settings get() = _settings.asStateFlow()
|
||||
val tunnels get() = tunnelRepo.itemFlow
|
||||
private val _viewState = MutableStateFlow(ViewState())
|
||||
val viewState get() = _viewState.asStateFlow()
|
||||
|
||||
val tunnels get() = tunnelRepo.getAllFlow()
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
settingsRepo.itemFlow.collect {
|
||||
isLocationServicesEnabled()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect {
|
||||
val settings = it.first()
|
||||
_settings.emit(settings)
|
||||
_trustedSSIDs.emit(settings.trustedNetworkSSIDs.toList())
|
||||
@@ -46,16 +49,10 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
|
||||
_settings.value.trustedNetworkSSIDs.add(trimmed)
|
||||
settingsRepo.save(_settings.value)
|
||||
} else {
|
||||
showSnackBarMessage("SSID already exists.")
|
||||
throw WgTunnelException("SSID already exists.")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun onDefaultTunnelSelected(tunnelConfig: TunnelConfig) {
|
||||
settingsRepo.save(_settings.value.copy(
|
||||
defaultTunnel = tunnelConfig.toString()
|
||||
))
|
||||
}
|
||||
|
||||
suspend fun onToggleTunnelOnMobileData() {
|
||||
settingsRepo.save(_settings.value.copy(
|
||||
isTunnelOnMobileDataEnabled = !_settings.value.isTunnelOnMobileDataEnabled
|
||||
@@ -67,55 +64,83 @@ class SettingsViewModel @Inject constructor(private val application : Applicatio
|
||||
settingsRepo.save(_settings.value)
|
||||
}
|
||||
|
||||
private fun emitFirstTunnelAsDefault() = viewModelScope.async {
|
||||
_settings.emit(_settings.value.copy(defaultTunnel = getFirstTunnelConfig().toString()))
|
||||
}
|
||||
|
||||
suspend fun toggleAutoTunnel() {
|
||||
if(_settings.value.defaultTunnel.isNullOrEmpty() && !_settings.value.isAutoTunnelEnabled) {
|
||||
showSnackBarMessage("Please select a tunnel first")
|
||||
return
|
||||
}
|
||||
if(_settings.value.isAutoTunnelEnabled) {
|
||||
actionOnWatcherService(Action.STOP)
|
||||
ServiceManager.stopWatcherService(application)
|
||||
} else {
|
||||
actionOnWatcherService(Action.START)
|
||||
if(_settings.value.defaultTunnel == null) {
|
||||
emitFirstTunnelAsDefault().await()
|
||||
}
|
||||
val defaultTunnel = _settings.value.defaultTunnel
|
||||
ServiceManager.startWatcherService(application, defaultTunnel!!)
|
||||
}
|
||||
settingsRepo.save(_settings.value.copy(
|
||||
isAutoTunnelEnabled = !_settings.value.isAutoTunnelEnabled
|
||||
))
|
||||
}
|
||||
|
||||
private fun actionOnWatcherService(action : Action) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
private suspend fun getFirstTunnelConfig() : TunnelConfig {
|
||||
return tunnelRepo.getAll().first()
|
||||
}
|
||||
|
||||
private suspend fun showSnackBarMessage(message : String) {
|
||||
_viewState.emit(_viewState.value.copy(
|
||||
showSnackbarMessage = true,
|
||||
snackbarMessage = message,
|
||||
snackbarActionText = "Okay",
|
||||
onSnackbarActionClick = {
|
||||
viewModelScope.launch {
|
||||
dismissSnackBar()
|
||||
}
|
||||
}
|
||||
suspend fun onToggleAlwaysOnVPN() {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
private fun isLocationServicesEnabled() : Boolean {
|
||||
val locationManager =
|
||||
application.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
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
|
||||
))
|
||||
}
|
||||
|
||||
private suspend fun dismissSnackBar() {
|
||||
_viewState.emit(_viewState.value.copy(
|
||||
showSnackbarMessage = false
|
||||
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,22 +1,39 @@
|
||||
package com.zaneschepke.wireguardautotunnel.ui.screens.support
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.Intent.createChooser
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -26,41 +43,99 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.ContextCompat.startActivity
|
||||
import com.zaneschepke.wireguardautotunnel.BuildConfig
|
||||
import com.zaneschepke.wireguardautotunnel.Constants
|
||||
import com.zaneschepke.wireguardautotunnel.R
|
||||
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
|
||||
|
||||
@Composable
|
||||
fun SupportScreen(padding : PaddingValues) {
|
||||
fun SupportScreen(padding : PaddingValues, focusRequester: FocusRequester) {
|
||||
|
||||
val context = LocalContext.current
|
||||
val fillMaxWidth = .85f
|
||||
|
||||
fun openWebPage(url: String) {
|
||||
val webpage: Uri = Uri.parse(url)
|
||||
val intent = Intent(Intent.ACTION_VIEW, webpage)
|
||||
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,
|
||||
verticalArrangement = Arrangement.Top,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.focusable()
|
||||
.padding(padding)) {
|
||||
Text(stringResource(R.string.support_text), textAlign = TextAlign.Center, modifier = Modifier.padding(30.dp), fontSize = 15.sp)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceEvenly
|
||||
Surface(
|
||||
tonalElevation = 2.dp,
|
||||
shadowElevation = 2.dp,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context))
|
||||
Modifier
|
||||
.height(IntrinsicSize.Min)
|
||||
.fillMaxWidth(fillMaxWidth)
|
||||
.padding(top = 10.dp)
|
||||
else Modifier
|
||||
.fillMaxWidth(fillMaxWidth)
|
||||
.padding(top = 20.dp)).padding(bottom = 25.dp)
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
openWebPage(context.resources.getString(R.string.discord_url))
|
||||
}) {
|
||||
Icon(imageVector = ImageVector.vectorResource(R.drawable.discord), "Discord")
|
||||
}
|
||||
IconButton(onClick = {
|
||||
openWebPage(context.resources.getString(R.string.github_url))
|
||||
}) {
|
||||
Icon(imageVector = ImageVector.vectorResource(R.drawable.github), "Github")
|
||||
Column(modifier = Modifier.padding(20.dp)) {
|
||||
Text(stringResource(R.string.thank_you), textAlign = TextAlign.Start, modifier = Modifier.padding(bottom = 20.dp), fontSize = 16.sp)
|
||||
Text(stringResource(id = R.string.support_help_text), textAlign = TextAlign.Start, fontSize = 16.sp, modifier = Modifier.padding(bottom = 20.dp))
|
||||
TextButton(onClick = { openWebPage(context.resources.getString(R.string.docs_url)) }, modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) {
|
||||
Row(horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Row {
|
||||
Icon(Icons.Rounded.Book, stringResource(id = R.string.docs))
|
||||
Text(stringResource(id = R.string.docs_description), textAlign = TextAlign.Justify, modifier = Modifier.padding(start = 10.dp))
|
||||
}
|
||||
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
@@ -68,6 +143,6 @@ fun SupportScreen(padding : PaddingValues) {
|
||||
modifier = Modifier.clickable {
|
||||
openWebPage(context.resources.getString(R.string.privacy_policy_url))
|
||||
})
|
||||
Text("App version: ${com.zaneschepke.wireguardautotunnel.BuildConfig.VERSION_NAME}", Modifier.padding(25.dp))
|
||||
Text("App version: ${BuildConfig.VERSION_NAME}", Modifier.padding(25.dp))
|
||||
}
|
||||
}
|
||||
@@ -9,4 +9,9 @@ val virdigris = Color(0xFF5BC0BE)
|
||||
|
||||
val Purple40 = Color(0xFF6650a4)
|
||||
val PurpleGrey40 = Color(0xFF625b71)
|
||||
val Pink40 = Color(0xFFFFFFFF)
|
||||
val Pink40 = Color(0xFFFFFFFF)
|
||||
|
||||
//status colors
|
||||
val brickRed = Color(0xFFCE4257)
|
||||
val pinkRed = Color(0xFFEF476F)
|
||||
val mint = Color(0xFF52B788)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.zaneschepke.wireguardautotunnel.util
|
||||
|
||||
import java.math.BigDecimal
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import kotlin.math.pow
|
||||
|
||||
object NumberUtils {
|
||||
|
||||
private const val BYTES_IN_KB = 1024.0
|
||||
private val BYTES_IN_MB = BYTES_IN_KB.pow(2.0)
|
||||
private val keyValidationRegex = """^[A-Za-z0-9+/]{42}[AEIMQUYcgkosw480]=${'$'}""".toRegex()
|
||||
|
||||
fun bytesToMB(bytes : Long) : BigDecimal {
|
||||
return bytes.toBigDecimal().divide(BYTES_IN_MB.toBigDecimal())
|
||||
}
|
||||
|
||||
fun isValidKey(key : String) : Boolean {
|
||||
return key.matches(keyValidationRegex)
|
||||
}
|
||||
|
||||
fun generateRandomTunnelName() : String {
|
||||
return "tunnel${(Math.random() * 100000).toInt()}"
|
||||
}
|
||||
|
||||
fun getSecondsBetweenTimestampAndNow(epoch : Long) : Long? {
|
||||
return if (epoch != 0L) {
|
||||
val time = Instant.ofEpochMilli(epoch)
|
||||
return Duration.between(time, Instant.now()).seconds
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?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>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_banner_background">#121212</color>
|
||||
</resources>
|
||||
@@ -6,9 +6,10 @@
|
||||
<string name="watcher_channel_id">Watcher Channel</string>
|
||||
<string name="watcher_channel_name">Watcher Notification Channel</string>
|
||||
<string name="foreground_file">FOREGROUND_FILE</string>
|
||||
<string name="github_url">https://github.com/zaneschepke/wgtunnel</string>
|
||||
<string name="privacy_policy_url">https://zaneschepke.github.io/wgtunnel/</string>
|
||||
<string name="file_extension_message">File is not a .conf file</string>
|
||||
<string name="github_url">https://github.com/zaneschepke/wgtunnel/issues</string>
|
||||
<string name="docs_url">https://zaneschepke.com/wgtunnel-docs/overview.html</string>
|
||||
<string name="privacy_policy_url">https://zaneschepke.com/wgtunnel-docs/privacypolicy.html</string>
|
||||
<string name="file_extension_message">File is not a .conf or .zip</string>
|
||||
<string name="turn_off_tunnel">Turn off tunnel before editing</string>
|
||||
<string name="no_tunnels">No tunnels added yet!</string>
|
||||
<string name="tunnel_exists">Tunnel name already exists</string>
|
||||
@@ -20,11 +21,10 @@
|
||||
<string name="vpn_permission_required">VPN permission is required for the app to work properly. If this permission is not launching, please disable \"Always-on VPN\" in your phone settings for the official WireGuard mobile app and try again.</string>
|
||||
<string name="notification_permission_required">Notifications permission is required for the app to work properly.</string>
|
||||
<string name="open_settings">Open Settings</string>
|
||||
<string name="add_trusted_ssid">Add Trusted SSID</string>
|
||||
<string name="trusted_ssid">Trusted SSID</string>
|
||||
<string name="add_trusted_ssid">Add trusted wifi name</string>
|
||||
<string name="tunnels">Tunnels</string>
|
||||
<string name="select_tunnel">Select Tunnel</string>
|
||||
<string name="enable_auto_tunnel">Enable auto tunneling</string>
|
||||
<string name="enable_auto_tunnel">Start auto-tunneling</string>
|
||||
<string name="disable_auto_tunnel">Stop auto-tunneling</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="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="privacy_policy">View Privacy Policy</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_title">Background Location Disclosure</string>
|
||||
<string name="support_text">Thank you for using WG Tunnel! If you are experiencing issues with the app, please reach out on Discord or create an issue on Github. I will try to address the issue as quickly as possible. Thank you!</string>
|
||||
<string name="thank_you">Thank you for using WG Tunnel!</string>
|
||||
<string name="trusted_ssid_empty_description">Enter SSID</string>
|
||||
<string name="trusted_ssid_value_description">Submit SSID</string>
|
||||
<string name="config_validation">[Interface]</string>
|
||||
<string name="invalid_qr">Invalid QR code.</string>
|
||||
<string name="add_from_file">Add tunnel from files</string>
|
||||
<string name="add_tunnels_text">Add from file or zip</string>
|
||||
<string name="open_file">File Open</string>
|
||||
<string name="add_from_qr">Add tunnel from QR code</string>
|
||||
<string name="add_from_qr">Add from QR code</string>
|
||||
<string name="qr_scan">QR Scan</string>
|
||||
<string name="tunnel_edit">Tunnel Edit</string>
|
||||
<string name="tunnel_name">Tunnel Name</string>
|
||||
@@ -52,9 +52,105 @@
|
||||
<string name="include">Include</string>
|
||||
<string name="tunnel_all">Tunnel all applications</string>
|
||||
<string name="config_changes_saved">Configuration changes saved.</string>
|
||||
<string name="save_changes">Save changes</string>
|
||||
<string name="save_changes">Save</string>
|
||||
<string name="icon">Icon</string>
|
||||
<string name="no_thanks">No thanks</string>
|
||||
<string name="turn_on">Turn on</string>
|
||||
<string name="map">Map</string>
|
||||
<string name="bad_config">Bad config. Please try again.</string>
|
||||
<string name="config_interface">Interface</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_message">Barcode module downloading. Try again.</string>
|
||||
<string name="addresses">Addresses</string>
|
||||
<string name="dns_servers">DNS servers</string>
|
||||
<string name="mtu">MTU</string>
|
||||
<string name="peer">Peer</string>
|
||||
<string name="allowed_ips">Allowed IPs</string>
|
||||
<string name="endpoint">Endpoint</string>
|
||||
<string name="transfer">Transfer</string>
|
||||
<string name="last_handshake">Last handshake</string>
|
||||
<string name="name">Name</string>
|
||||
<string name="restart">Restart Tunnel</string>
|
||||
<string name="vpn_connection_failed">VPN Connection Failed</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="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="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="check_again">Check again</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>
|
||||
@@ -0,0 +1,32 @@
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<shortcut
|
||||
android:shortcutId="defaultOn1"
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/vpn_on"
|
||||
android:shortcutShortLabel="@string/vpn_on"
|
||||
android:shortcutLongLabel="@string/default_vpn_on"
|
||||
android:shortcutDisabledMessage="@string/vpn_on">
|
||||
<intent
|
||||
android:action="START"
|
||||
android:targetPackage="com.zaneschepke.wireguardautotunnel"
|
||||
android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity">
|
||||
<extra android:name="className" android:value="WireGuardTunnelService" />
|
||||
</intent>
|
||||
<capability-binding android:key="actions.intent.START" />
|
||||
</shortcut>
|
||||
<shortcut
|
||||
android:shortcutId="defaultOff1"
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/vpn_off"
|
||||
android:shortcutShortLabel="@string/vpn_off"
|
||||
android:shortcutLongLabel="@string/default_vpn_off"
|
||||
android:shortcutDisabledMessage="@string/vpn_off">
|
||||
<intent
|
||||
android:action="STOP"
|
||||
android:targetPackage="com.zaneschepke.wireguardautotunnel"
|
||||
android:targetClass="com.zaneschepke.wireguardautotunnel.service.shortcut.ShortcutsActivity">
|
||||
<extra android:name="className" android:value="WireGuardTunnelService" />
|
||||
</intent>
|
||||
<capability-binding android:key="actions.intent.STOP" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
@@ -1,9 +1,8 @@
|
||||
package com.zaneschepke.wireguardautotunnel
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
import org.junit.Assert.*
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
|
||||
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 43 KiB |