Compare commits

..

15 Commits

Author SHA1 Message Date
dependabot[bot] 6c007a8ca8 build(deps): bump actions/upload-artifact from 4.3.4 to 4.3.5 (#302)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-05 16:51:41 -04:00
Zane Schepke 8e6a9bb309 fix: remove old nightly versions (#295) 2024-07-31 00:00:14 -04:00
Zane Schepke 594834a908 fix: tasker launch of shortcuts (#290) 2024-07-29 17:14:08 -04:00
Zane Schepke a5e9aa83b8 feat: check for always-on VPN (#289) 2024-07-28 22:21:32 -04:00
Languages add-on 5a77661fb3 Added translation using Weblate (Ukrainian) 2024-07-28 15:37:44 -04:00
Luiz Fellipe Carneiro ee5d3ea6a9 Translated using Weblate (Portuguese)
Currently translated at 18.5% (5 of 27 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/pt/
2024-07-28 15:37:06 -04:00
Languages add-on f6da0fe31b Added translation using Weblate (Portuguese) 2024-07-28 15:37:06 -04:00
Zane Schepke 80a02382e1 ci: add basic ci (#287)
- add ktlint
2024-07-28 14:49:35 -04:00
Zane Schepke b9a8400453 fix: app lock bypass (#286) 2024-07-28 14:24:22 -04:00
Zane Schepke 3a17d2855b fix: fastlane deploy (#285) 2024-07-28 14:05:15 -04:00
Zane Schepke 086b48c79d fix: signing config bug 2024-07-28 12:18:17 -04:00
Zane Schepke 1f561fbf38 Fix/app signature (#284) 2024-07-28 12:11:16 -04:00
Zane Schepke 45e63e9910 fix versioning (#280) 2024-07-28 03:27:12 -04:00
Zane Schepke 66e89c83e2 chore: fmt 2024-07-28 02:17:01 -04:00
Zane Schepke 470fa0191b fix: signing issue (#279) 2024-07-28 02:13:18 -04:00
128 changed files with 8258 additions and 7742 deletions
+23 -11
View File
@@ -1,8 +1,14 @@
[{*.kt,*.kts}] root = true
indent_style = space
insert_final_newline = true [*]
max_line_length = 100 charset = utf-8
indent_size = 4 indent_size = 4
indent_style = tab
max_line_length = 150
trim_trailing_whitespace = true
insert_final_newline = true
[{*.kt,*.kts}]
ij_continuation_indent_size = 4 ij_continuation_indent_size = 4
ij_java_names_count_to_use_import_on_demand = 9999 ij_java_names_count_to_use_import_on_demand = 9999
ij_kotlin_align_in_columns_case_branch = false ij_kotlin_align_in_columns_case_branch = false
@@ -11,8 +17,6 @@ ij_kotlin_align_multiline_extends_list = false
ij_kotlin_align_multiline_method_parentheses = false ij_kotlin_align_multiline_method_parentheses = false
ij_kotlin_align_multiline_parameters = true ij_kotlin_align_multiline_parameters = true
ij_kotlin_align_multiline_parameters_in_calls = false ij_kotlin_align_multiline_parameters_in_calls = false
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
ij_kotlin_assignment_wrap = normal ij_kotlin_assignment_wrap = normal
ij_kotlin_blank_lines_after_class_header = 0 ij_kotlin_blank_lines_after_class_header = 0
ij_kotlin_blank_lines_around_block_when_branches = 0 ij_kotlin_blank_lines_around_block_when_branches = 0
@@ -20,10 +24,7 @@ ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_
ij_kotlin_block_comment_at_first_column = true ij_kotlin_block_comment_at_first_column = true
ij_kotlin_call_parameters_new_line_after_left_paren = true ij_kotlin_call_parameters_new_line_after_left_paren = true
ij_kotlin_call_parameters_right_paren_on_new_line = false ij_kotlin_call_parameters_right_paren_on_new_line = false
ij_kotlin_call_parameters_wrap = on_every_item
ij_kotlin_catch_on_new_line = false ij_kotlin_catch_on_new_line = false
ij_kotlin_class_annotation_wrap = split_into_lines
ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL
ij_kotlin_continuation_indent_for_chained_calls = true ij_kotlin_continuation_indent_for_chained_calls = true
ij_kotlin_continuation_indent_for_expression_bodies = true ij_kotlin_continuation_indent_for_expression_bodies = true
ij_kotlin_continuation_indent_in_argument_lists = true ij_kotlin_continuation_indent_in_argument_lists = true
@@ -52,7 +53,6 @@ ij_kotlin_method_annotation_wrap = split_into_lines
ij_kotlin_method_call_chain_wrap = normal ij_kotlin_method_call_chain_wrap = normal
ij_kotlin_method_parameters_new_line_after_left_paren = true ij_kotlin_method_parameters_new_line_after_left_paren = true
ij_kotlin_method_parameters_right_paren_on_new_line = true ij_kotlin_method_parameters_right_paren_on_new_line = true
ij_kotlin_method_parameters_wrap = on_every_item
ij_kotlin_name_count_to_use_star_import = 9999 ij_kotlin_name_count_to_use_star_import = 9999
ij_kotlin_name_count_to_use_star_import_for_members = 9999 ij_kotlin_name_count_to_use_star_import_for_members = 9999
ij_kotlin_parameter_annotation_wrap = off ij_kotlin_parameter_annotation_wrap = off
@@ -82,4 +82,16 @@ ij_kotlin_variable_annotation_wrap = off
ij_kotlin_while_on_new_line = false ij_kotlin_while_on_new_line = false
ij_kotlin_wrap_elvis_expressions = 1 ij_kotlin_wrap_elvis_expressions = 1
ij_kotlin_wrap_expression_body_functions = 1 ij_kotlin_wrap_expression_body_functions = 1
ij_kotlin_wrap_first_method_in_call_chain = false ij_kotlin_wrap_first_method_in_call_chain = false
#compose
ktlint_standard_filename = disabled
ktlint_standard_no-wildcard-imports = disabled
ktlint_standard_function-naming = disabled
ktlint_standard_property-naming = disabled
ktlint_standard_package-naming = disabled
ktlint_function_naming_ignore_when_annotated_with = Composable
ktlint_code_style = android_studio
ktlint_standard_import-ordering = disabled
ktlint_standard_package-naming = disabled
ij_kotlin_allow_trailing_comma = true
ij_kotlin_allow_trailing_comma_on_call_site = true
+23
View File
@@ -0,0 +1,23 @@
name: ci-android
on:
workflow_dispatch:
pull_request:
jobs:
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run ktlint
run: ./gradlew ktlintCheck
+46 -21
View File
@@ -86,9 +86,12 @@ jobs:
# Build and sign APK ("-x test" argument is used to skip tests) # Build and sign APK ("-x test" argument is used to skip tests)
# add fdroid flavor for apk upload # add fdroid flavor for apk upload
- name: Build Fdroid Release APK - name: Build Fdroid Release APK
if: ${{ inputs.release_type != '' && inputs.release_type != 'nightly' }} if: ${{ inputs.release_type != '' && inputs.release_type == 'release' }}
run: ./gradlew :app:assembleFdroidRelease -x test run: ./gradlew :app:assembleFdroidRelease -x test
- name: Build Fdroid Prerelease APK
if: ${{ inputs.release_type != '' && inputs.release_type == 'prerelease' }}
run: ./gradlew :app:assembleFdroidPrerelease -x test
- name: Build Fdroid Nightly APK - name: Build Fdroid Nightly APK
if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' }} if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' }}
@@ -96,18 +99,20 @@ jobs:
- if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' }} - if: ${{ inputs.release_type == '' || inputs.release_type == 'nightly' }}
run: echo "APK_PATH=$(find . -regex '^.*/build/outputs/apk/fdroid/nightly/.*\.apk$' -type f | head -1)" >> $GITHUB_ENV run: echo "APK_PATH=$(find . -regex '^.*/build/outputs/apk/fdroid/nightly/.*\.apk$' -type f | head -1)" >> $GITHUB_ENV
- if: ${{ inputs.release_type != '' && inputs.release_type != 'nightly' }} - if: ${{ inputs.release_type != '' && inputs.release_type == 'release' }}
run: echo "APK_PATH=$(find . -regex '^.*/build/outputs/apk/fdroid/release/.*\.apk$' -type f | head -1)" >> $GITHUB_ENV run: echo "APK_PATH=$(find . -regex '^.*/build/outputs/apk/fdroid/release/.*\.apk$' -type f | head -1)" >> $GITHUB_ENV
- if: ${{ inputs.release_type != '' && inputs.release_type == 'prerelease' }}
run: echo "APK_PATH=$(find . -regex '^.*/build/outputs/apk/fdroid/prerelease/.*\.apk$' -type f | head -1)" >> $GITHUB_ENV
- name: Get version code - name: Get version code
if: ${{ inputs.release_type == 'release' || inputs.release_type == 'prerelease' }} if: ${{ inputs.release_type == 'release' }}
run: | run: |
version_code=$(grep "VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk '{print $5}' | tr -d '\n') version_code=$(grep "VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk '{print $5}' | tr -d '\n')
echo "VERSION_CODE=$version_code" >> $GITHUB_ENV echo "VERSION_CODE=$version_code" >> $GITHUB_ENV
# Save the APK after the Build job is complete to publish it as a Github release in the next job # Save the APK after the Build job is complete to publish it as a Github release in the next job
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v4.3.4 uses: actions/upload-artifact@v4.3.5
with: with:
name: wgtunnel name: wgtunnel
path: ${{ env.APK_PATH }} path: ${{ env.APK_PATH }}
@@ -125,32 +130,32 @@ jobs:
repository: zaneschepke/fdroid repository: zaneschepke/fdroid
event-type: fdroid-update event-type: fdroid-update
- name: Set version release notes
if: ${{ inputs.release_type == 'release' || inputs.release_type == 'prerelease' }}
run: |
RELEASE_NOTES="$(cat ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/${{ env.VERSION_CODE }}.txt)"
echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV
echo "$RELEASE_NOTES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: On nightly release
if: ${{ contains(env.TAG_NAME, 'nightly') }}
run: |
echo "RELEASE_NOTES=Nightly build for the latest development version of the app." >> $GITHUB_ENV
gh release delete nightly --yes || true
# Setup TAG_NAME, which is used as a general "name" # Setup TAG_NAME, which is used as a general "name"
- if: github.event_name == 'workflow_dispatch' - if: github.event_name == 'workflow_dispatch'
run: echo "TAG_NAME=${{ github.event.inputs.tag_name }}" >> $GITHUB_ENV run: echo "TAG_NAME=${{ github.event.inputs.tag_name }}" >> $GITHUB_ENV
- if: github.event_name == 'schedule' - if: github.event_name == 'schedule'
run: echo "TAG_NAME=nightly" >> $GITHUB_ENV run: echo "TAG_NAME=nightly" >> $GITHUB_ENV
- name: On nightly release - name: Set version release notes
if: ${{ inputs.release_type == 'release' }}
run: |
RELEASE_NOTES="$(cat ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/${{ env.VERSION_CODE }}.txt)"
echo "RELEASE_NOTES<<EOF" >> $GITHUB_ENV
echo "$RELEASE_NOTES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: On nightly release notes
if: ${{ contains(env.TAG_NAME, 'nightly') }} if: ${{ contains(env.TAG_NAME, 'nightly') }}
run: | run: |
echo "RELEASE_NOTES=Nightly build of the latest development version of the android client." >> $GITHUB_ENV echo "RELEASE_NOTES=Nightly build for the latest development version of the app." >> $GITHUB_ENV
gh release delete nightly --yes || true gh release delete nightly --yes || true
- name: On prerelease release notes
if: ${{ inputs.release_type == 'prerelease' }}
run: |
echo "RELEASE_NOTES=Testing version of app for specific feature." >> $GITHUB_ENV
gh release delete ${{ github.event.inputs.tag_name }} --yes || true
- name: Get checksum - name: Get checksum
id: checksum id: checksum
run: echo "checksum=$(apksigner verify -print-certs ${{ env.APK_PATH }} | grep -Po "(?<=SHA-256 digest:) .*" | tr -d "[:blank:]")" >> $GITHUB_OUTPUT run: echo "checksum=$(apksigner verify -print-certs ${{ env.APK_PATH }} | grep -Po "(?<=SHA-256 digest:) .*" | tr -d "[:blank:]")" >> $GITHUB_OUTPUT
@@ -164,7 +169,7 @@ jobs:
with: with:
body: | body: |
${{ env.RELEASE_NOTES }} ${{ env.RELEASE_NOTES }}
SHA256 fingerprint: SHA256 fingerprint:
```${{ steps.checksum.outputs.checksum }}``` ```${{ steps.checksum.outputs.checksum }}```
tag_name: ${{ env.TAG_NAME }} tag_name: ${{ env.TAG_NAME }}
@@ -200,6 +205,26 @@ jobs:
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x 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: ${{ env.KEY_STORE_FILE }}
fileDir: ${{ env.KEY_STORE_LOCATION }}
encodedString: ${{ secrets.KEYSTORE }}
# create keystore path for gradle to read
- name: Create keystore path env var
run: |
store_path=${{ env.KEY_STORE_LOCATION }}${{ env.KEY_STORE_FILE }}
echo "KEY_STORE_PATH=$store_path" >> $GITHUB_ENV
- name: Create service_account.json
id: createServiceAccount
run: echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
- name: Deploy with fastlane - name: Deploy with fastlane
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
+169 -154
View File
@@ -1,194 +1,209 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt.android) alias(libs.plugins.hilt.android)
alias(libs.plugins.kotlinxSerialization) alias(libs.plugins.kotlinxSerialization)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.compose.compiler) alias(libs.plugins.compose.compiler)
alias(libs.plugins.grgit) alias(libs.plugins.grgit)
} }
android { android {
namespace = Constants.APP_ID namespace = Constants.APP_ID
compileSdk = Constants.TARGET_SDK compileSdk = Constants.TARGET_SDK
compileSdkPreview = "VanillaIceCream"
androidResources { androidResources {
generateLocaleConfig = true generateLocaleConfig = true
} }
defaultConfig { defaultConfig {
applicationId = Constants.APP_ID applicationId = Constants.APP_ID
minSdk = Constants.MIN_SDK minSdk = Constants.MIN_SDK
targetSdk = Constants.TARGET_SDK targetSdk = Constants.TARGET_SDK
versionCode = Constants.VERSION_CODE versionCode = determineVersionCode()
versionName = Constants.VERSION_NAME versionName = determineVersionName()
ksp { arg("room.schemaLocation", "$projectDir/schemas") } ksp { arg("room.schemaLocation", "$projectDir/schemas") }
sourceSets { sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room
} }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true } vectorDrawables { useSupportLibrary = true }
} }
signingConfigs { signingConfigs {
create(Constants.RELEASE) { create(Constants.RELEASE) {
storeFile = getStoreFile() storeFile = getStoreFile()
storePassword = getSigningProperty(Constants.STORE_PASS_VAR) storePassword = getSigningProperty(Constants.STORE_PASS_VAR)
keyAlias = getSigningProperty(Constants.KEY_ALIAS_VAR) keyAlias = getSigningProperty(Constants.KEY_ALIAS_VAR)
keyPassword = getSigningProperty(Constants.KEY_PASS_VAR) keyPassword = getSigningProperty(Constants.KEY_PASS_VAR)
} }
} }
buildTypes { buildTypes {
// don't strip // don't strip
packaging.jniLibs.keepDebugSymbols.addAll( packaging.jniLibs.keepDebugSymbols.addAll(
listOf("libwg-go.so", "libwg-quick.so", "libwg.so"), listOf("libwg-go.so", "libwg-quick.so", "libwg.so"),
) )
applicationVariants.all { release {
val variant = this isDebuggable = false
variant.outputs isMinifyEnabled = true
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl } isShrinkResources = true
.forEach { output -> proguardFiles(
val outputFileName = getDefaultProguardFile("proguard-android-optimize.txt"),
"${Constants.APP_NAME}-${variant.flavorName}-${variant.buildType.name}-${variant.versionName}.apk" "proguard-rules.pro",
output.outputFileName = outputFileName )
} signingConfig = signingConfigs.getByName(Constants.RELEASE)
} }
release { debug { isDebuggable = true }
isDebuggable = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.getByName(Constants.RELEASE)
}
debug { isDebuggable = true }
create(Constants.NIGHTLY) { create(Constants.PRERELEASE) {
initWith(getByName("release")) initWith(buildTypes.getByName(Constants.RELEASE))
defaultConfig.versionName = nightlyVersionName() }
defaultConfig.versionCode = nightlyVersionCode()
} create(Constants.NIGHTLY) {
} initWith(buildTypes.getByName(Constants.RELEASE))
flavorDimensions.add(Constants.TYPE) }
productFlavors {
create("fdroid") { applicationVariants.all {
dimension = Constants.TYPE val variant = this
proguardFile("fdroid-rules.pro") variant.outputs
} .map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
create("general") { .forEach { output ->
dimension = Constants.TYPE val outputFileName =
} "${Constants.APP_NAME}-${variant.flavorName}-" +
} "${variant.buildType.name}-${variant.versionName}.apk"
compileOptions { output.outputFileName = outputFileName
sourceCompatibility = JavaVersion.VERSION_17 }
targetCompatibility = JavaVersion.VERSION_17 }
isCoreLibraryDesugaringEnabled = true }
} flavorDimensions.add(Constants.TYPE)
kotlinOptions { jvmTarget = Constants.JVM_TARGET } productFlavors {
buildFeatures { create("fdroid") {
compose = true dimension = Constants.TYPE
buildConfig = true proguardFile("fdroid-rules.pro")
} }
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } } create("general") {
dimension = Constants.TYPE
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions { jvmTarget = Constants.JVM_TARGET }
buildFeatures {
compose = true
buildConfig = true
}
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
} }
val generalImplementation by configurations val generalImplementation by configurations
dependencies { dependencies {
implementation(project(":logcatter")) implementation(project(":logcatter"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
// helpers for implementing LifecycleOwner in a Service // helpers for implementing LifecycleOwner in a Service
implementation(libs.androidx.lifecycle.service) implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
// test // test
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.androidx.junit) testImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test) androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.androidx.room.testing)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.manifest) debugImplementation(libs.androidx.compose.manifest)
// get tunnel lib from github packages or mavenLocal // get tunnel lib from github packages or mavenLocal
implementation(libs.tunnel) implementation(libs.tunnel)
implementation(libs.amneziawg.android) implementation(libs.amneziawg.android)
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)
// logging // logging
implementation(libs.timber) implementation(libs.timber)
// compose navigation
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
// compose navigation implementation(libs.zaneschepke.multifab)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose)
implementation(libs.zaneschepke.multifab) // hilt
implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler)
// hilt // accompanist
implementation(libs.hilt.android) implementation(libs.accompanist.permissions)
ksp(libs.hilt.android.compiler) implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.drawablepainter)
// accompanist // storage
implementation(libs.accompanist.permissions) implementation(libs.androidx.room.runtime)
implementation(libs.accompanist.flowlayout) ksp(libs.androidx.room.compiler)
implementation(libs.accompanist.drawablepainter) implementation(libs.androidx.room.ktx)
implementation(libs.androidx.datastore.preferences)
// storage // lifecycle
implementation(libs.androidx.room.runtime) implementation(libs.lifecycle.runtime.compose)
ksp(libs.androidx.room.compiler) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.lifecycle.process)
implementation(libs.androidx.datastore.preferences)
// lifecycle // icons
implementation(libs.lifecycle.runtime.compose) implementation(libs.material.icons.extended)
implementation(libs.androidx.lifecycle.runtime.ktx) // serialization
implementation(libs.androidx.lifecycle.process) implementation(libs.kotlinx.serialization.json)
// icons // barcode scanning
implementation(libs.material.icons.extended) implementation(libs.zxing.android.embedded)
// serialization
implementation(libs.kotlinx.serialization.json)
// barcode scanning // bio
implementation(libs.zxing.android.embedded) implementation(libs.androidx.biometric.ktx)
implementation(libs.pin.lock.compose)
// bio // shortcuts
implementation(libs.androidx.biometric.ktx) implementation(libs.androidx.core)
implementation(libs.pin.lock.compose) implementation(libs.androidx.core.google.shortcuts)
// shortcuts // splash
implementation(libs.androidx.core) implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.core.google.shortcuts)
// splash
implementation(libs.androidx.core.splashscreen)
} }
fun nightlyVersionCode() : Int { fun determineVersionCode(): Int {
return Constants.VERSION_CODE + Constants.NIGHTLY_CODE return with(getBuildTaskName().lowercase()) {
when {
contains(Constants.NIGHTLY) -> Constants.VERSION_CODE + Constants.NIGHTLY_CODE
contains(Constants.PRERELEASE) -> Constants.VERSION_CODE + Constants.PRERELEASE_CODE
else -> Constants.VERSION_CODE
}
}
} }
fun nightlyVersionName() : String { fun determineVersionName(): String {
return Constants.VERSION_NAME + "-${grgitService.service.get().grgit.head().abbreviatedId}" return with(getBuildTaskName().lowercase()) {
when {
contains(Constants.NIGHTLY) || contains(Constants.PRERELEASE) ->
Constants.VERSION_NAME +
"-${grgitService.service.get().grgit.head().abbreviatedId}"
else -> Constants.VERSION_NAME
}
}
} }
@@ -13,10 +13,10 @@ import org.junit.runner.RunWith
*/ */
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest { class ExampleInstrumentedTest {
@Test @Test
fun useAppContext() { fun useAppContext() {
// Context of the app under test. // Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.zaneschepke.wireguardautotunnel", appContext.packageName) assertEquals("com.zaneschepke.wireguardautotunnel", appContext.packageName)
} }
} }
@@ -12,33 +12,33 @@ import java.io.IOException
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MigrationTest { class MigrationTest {
private val dbName = "migration-test" private val dbName = "migration-test"
@get:Rule @get:Rule
val helper: MigrationTestHelper = val helper: MigrationTestHelper =
MigrationTestHelper( MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(), InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java, AppDatabase::class.java,
) )
@Test @Test
@Throws(IOException::class) @Throws(IOException::class)
fun migrate6To7() { fun migrate6To7() {
helper.createDatabase(dbName, 6).apply { helper.createDatabase(dbName, 6).apply {
// Database has schema version 1. Insert some data using SQL queries. // Database has schema version 1. Insert some data using SQL queries.
// You can't use DAO classes because they expect the latest schema. // You can't use DAO classes because they expect the latest schema.
execSQL(Queries.createDefaultSettings()) execSQL(Queries.createDefaultSettings())
execSQL( execSQL(
Queries.createTunnelConfig(), Queries.createTunnelConfig(),
) )
// Prepare for the next version. // Prepare for the next version.
close() close()
} }
// Re-open the database with version 2 and provide // Re-open the database with version 2 and provide
// MIGRATION_1_2 as the migration process. // MIGRATION_1_2 as the migration process.
helper.runMigrationsAndValidate(dbName, 7, true) helper.runMigrationsAndValidate(dbName, 7, true)
// MigrationTestHelper automatically verifies the schema changes, // MigrationTestHelper automatically verifies the schema changes,
// but you need to validate that the data was migrated properly. // but you need to validate that the data was migrated properly.
} }
} }
+7 -6
View File
@@ -71,7 +71,7 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter> </intent-filter>
<meta-data <meta-data
android:name="android.app.shortcuts" android:name="android.app.shortcuts"
@@ -81,19 +81,20 @@
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
android:theme="@style/Theme.WireguardAutoTunnel"> android:theme="@style/Theme.WireguardAutoTunnel">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter>
</activity> </activity>
<activity <activity
android:name="com.journeyapps.barcodescanner.CaptureActivity" android:name="com.journeyapps.barcodescanner.CaptureActivity"
android:screenOrientation="portrait" android:screenOrientation="portrait"
tools:replace="screenOrientation" /> tools:replace="screenOrientation" />
<activity <activity
android:name=".service.shortcut.ShortcutsActivity" android:name=".service.shortcut.ShortcutsActivity"
android:enabled="true" android:enabled="true"
android:exported="true" android:exported="true"
android:finishOnTaskLaunch="true" android:noHistory="true"
android:excludeFromRecents="true"
android:finishOnTaskLaunch="true"
android:launchMode="singleInstance"
android:theme="@android:style/Theme.NoDisplay" /> android:theme="@android:style/Theme.NoDisplay" />
<service <service
@@ -177,4 +178,4 @@
android:name=".receiver.NotificationActionReceiver" android:name=".receiver.NotificationActionReceiver"
android:exported="false" /> android:exported="false" />
</application> </application>
</manifest> </manifest>
@@ -14,44 +14,44 @@ import timber.log.Timber
@HiltAndroidApp @HiltAndroidApp
class WireGuardAutoTunnel : Application() { class WireGuardAutoTunnel : Application() {
override fun onCreate() {
super.onCreate()
instance = this
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
StrictMode.setThreadPolicy(
ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build(),
)
} else {
Timber.plant(ReleaseTree())
}
}
override fun onCreate() { companion object {
super.onCreate() lateinit var instance: WireGuardAutoTunnel
instance = this private set
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
StrictMode.setThreadPolicy(
ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build(),
)
} else Timber.plant(ReleaseTree())
}
companion object { fun isRunningOnAndroidTv(): Boolean {
return instance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
lateinit var instance: WireGuardAutoTunnel fun requestTunnelTileServiceStateUpdate() {
private set TileService.requestListeningState(
instance,
ComponentName(instance, TunnelControlTile::class.java),
)
}
fun isRunningOnAndroidTv(): Boolean { fun requestAutoTunnelTileServiceUpdate() {
return instance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) TileService.requestListeningState(
} instance,
ComponentName(instance, AutoTunnelControlTile::class.java),
fun requestTunnelTileServiceStateUpdate() { )
TileService.requestListeningState( }
instance, }
ComponentName(instance, TunnelControlTile::class.java),
)
}
fun requestAutoTunnelTileServiceUpdate() {
TileService.requestListeningState(
instance,
ComponentName(instance, AutoTunnelControlTile::class.java),
)
}
}
} }
@@ -10,46 +10,46 @@ import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
@Database( @Database(
entities = [Settings::class, TunnelConfig::class], entities = [Settings::class, TunnelConfig::class],
version = 8, version = 8,
autoMigrations = autoMigrations =
[ [
AutoMigration(from = 1, to = 2), AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3), AutoMigration(from = 2, to = 3),
AutoMigration( AutoMigration(
from = 3, from = 3,
to = 4, to = 4,
), ),
AutoMigration( AutoMigration(
from = 4, from = 4,
to = 5, to = 5,
), ),
AutoMigration( AutoMigration(
from = 5, from = 5,
to = 6, to = 6,
), ),
AutoMigration( AutoMigration(
from = 6, from = 6,
to = 7, to = 7,
spec = RemoveLegacySettingColumnsMigration::class, spec = RemoveLegacySettingColumnsMigration::class,
), ),
AutoMigration(7, 8), AutoMigration(7, 8),
], ],
exportSchema = true, exportSchema = true,
) )
@TypeConverters(DatabaseListConverters::class) @TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun settingDao(): SettingsDao abstract fun settingDao(): SettingsDao
abstract fun tunnelConfigDoa(): TunnelConfigDao abstract fun tunnelConfigDoa(): TunnelConfigDao
} }
@DeleteColumn( @DeleteColumn(
tableName = "Settings", tableName = "Settings",
columnName = "default_tunnel", columnName = "default_tunnel",
) )
@DeleteColumn( @DeleteColumn(
tableName = "Settings", tableName = "Settings",
columnName = "is_battery_saver_enabled", columnName = "is_battery_saver_enabled",
) )
class RemoveLegacySettingColumnsMigration : AutoMigrationSpec class RemoveLegacySettingColumnsMigration : AutoMigrationSpec
@@ -5,17 +5,17 @@ import androidx.sqlite.db.SupportSQLiteDatabase
import timber.log.Timber import timber.log.Timber
class DatabaseCallback : RoomDatabase.Callback() { class DatabaseCallback : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) = db.run { override fun onCreate(db: SupportSQLiteDatabase) = db.run {
// Notice non-ui thread is here // Notice non-ui thread is here
beginTransaction() beginTransaction()
try { try {
execSQL(Queries.createDefaultSettings()) execSQL(Queries.createDefaultSettings())
Timber.i("Bootstrapping settings data") Timber.i("Bootstrapping settings data")
setTransactionSuccessful() setTransactionSuccessful()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
} finally { } finally {
endTransaction() endTransaction()
} }
} }
} }
@@ -5,20 +5,20 @@ import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class DatabaseListConverters { class DatabaseListConverters {
@TypeConverter @TypeConverter
fun listToString(value: MutableList<String>): String { fun listToString(value: MutableList<String>): String {
return Json.encodeToString(value) return Json.encodeToString(value)
} }
@TypeConverter @TypeConverter
fun stringToList(value: String): MutableList<String> { fun stringToList(value: String): MutableList<String> {
if (value.isBlank() || value.isEmpty()) return mutableListOf() if (value.isBlank() || value.isEmpty()) return mutableListOf()
return try { return try {
Json.decodeFromString<MutableList<String>>(value) Json.decodeFromString<MutableList<String>>(value)
} catch (e: Exception) { } catch (e: Exception) {
val list = value.split(",").toMutableList() val list = value.split(",").toMutableList()
val json = listToString(list) val json = listToString(list)
Json.decodeFromString<MutableList<String>>(json) Json.decodeFromString<MutableList<String>>(json)
} }
} }
} }
@@ -1,35 +1,35 @@
package com.zaneschepke.wireguardautotunnel.data package com.zaneschepke.wireguardautotunnel.data
object Queries { object Queries {
fun createDefaultSettings(): String { fun createDefaultSettings(): String {
return """ return """
INSERT INTO Settings (is_tunnel_enabled, INSERT INTO Settings (is_tunnel_enabled,
is_tunnel_on_mobile_data_enabled, is_tunnel_on_mobile_data_enabled,
trusted_network_ssids, trusted_network_ssids,
is_always_on_vpn_enabled, is_always_on_vpn_enabled,
is_tunnel_on_ethernet_enabled, is_tunnel_on_ethernet_enabled,
is_shortcuts_enabled, is_shortcuts_enabled,
is_tunnel_on_wifi_enabled, is_tunnel_on_wifi_enabled,
is_kernel_enabled, is_kernel_enabled,
is_restore_on_boot_enabled, is_restore_on_boot_enabled,
is_multi_tunnel_enabled) is_multi_tunnel_enabled)
VALUES VALUES
('false', ('false',
'false', 'false',
'sampleSSID1,sampleSSID2', 'sampleSSID1,sampleSSID2',
'false', 'false',
'false', 'false',
'false', 'false',
'false', 'false',
'false', 'false',
'false', 'false',
'false') 'false')
""".trimIndent() """.trimIndent()
} }
fun createTunnelConfig(): String { fun createTunnelConfig(): String {
return """ return """
INSERT INTO TunnelConfig (name, wg_quick) VALUES ('test', 'test') INSERT INTO TunnelConfig (name, wg_quick) VALUES ('test', 'test')
""".trimIndent() """.trimIndent()
} }
} }
@@ -10,27 +10,27 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface SettingsDao { interface SettingsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: Settings) suspend fun save(t: Settings)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveAll(t: List<Settings>) suspend fun saveAll(t: List<Settings>)
@Query("SELECT * FROM settings WHERE id=:id") @Query("SELECT * FROM settings WHERE id=:id")
suspend fun getById(id: Long): Settings? suspend fun getById(id: Long): Settings?
@Query("SELECT * FROM settings") @Query("SELECT * FROM settings")
suspend fun getAll(): List<Settings> suspend fun getAll(): List<Settings>
@Query("SELECT * FROM settings LIMIT 1") @Query("SELECT * FROM settings LIMIT 1")
fun getSettingsFlow(): Flow<Settings> fun getSettingsFlow(): Flow<Settings>
@Query("SELECT * FROM settings") @Query("SELECT * FROM settings")
fun getAllFlow(): Flow<MutableList<Settings>> fun getAllFlow(): Flow<MutableList<Settings>>
@Delete @Delete
suspend fun delete(t: Settings) suspend fun delete(t: Settings)
@Query("SELECT COUNT('id') FROM settings") @Query("SELECT COUNT('id') FROM settings")
suspend fun count(): Long suspend fun count(): Long
} }
@@ -11,42 +11,42 @@ import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface TunnelConfigDao { interface TunnelConfigDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: TunnelConfig) suspend fun save(t: TunnelConfig)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveAll(t: TunnelConfigs) suspend fun saveAll(t: TunnelConfigs)
@Query("SELECT * FROM TunnelConfig WHERE id=:id") @Query("SELECT * FROM TunnelConfig WHERE id=:id")
suspend fun getById(id: Long): TunnelConfig? suspend fun getById(id: Long): TunnelConfig?
@Query("SELECT * FROM TunnelConfig WHERE name=:name") @Query("SELECT * FROM TunnelConfig WHERE name=:name")
suspend fun getByName(name: String): TunnelConfig? suspend fun getByName(name: String): TunnelConfig?
@Query("SELECT * FROM TunnelConfig") @Query("SELECT * FROM TunnelConfig")
suspend fun getAll(): TunnelConfigs suspend fun getAll(): TunnelConfigs
@Delete @Delete
suspend fun delete(t: TunnelConfig) suspend fun delete(t: TunnelConfig)
@Query("SELECT COUNT('id') FROM TunnelConfig") @Query("SELECT COUNT('id') FROM TunnelConfig")
suspend fun count(): Long suspend fun count(): Long
@Query("SELECT * FROM TunnelConfig WHERE tunnel_networks LIKE '%' || :name || '%'") @Query("SELECT * FROM TunnelConfig WHERE tunnel_networks LIKE '%' || :name || '%'")
suspend fun findByTunnelNetworkName(name: String): TunnelConfigs suspend fun findByTunnelNetworkName(name: String): TunnelConfigs
@Query("UPDATE TunnelConfig SET is_primary_tunnel = 0 WHERE is_primary_tunnel =1") @Query("UPDATE TunnelConfig SET is_primary_tunnel = 0 WHERE is_primary_tunnel =1")
suspend fun resetPrimaryTunnel() suspend fun resetPrimaryTunnel()
@Query("UPDATE TunnelConfig SET is_mobile_data_tunnel = 0 WHERE is_mobile_data_tunnel =1") @Query("UPDATE TunnelConfig SET is_mobile_data_tunnel = 0 WHERE is_mobile_data_tunnel =1")
suspend fun resetMobileDataTunnel() suspend fun resetMobileDataTunnel()
@Query("SELECT * FROM TUNNELCONFIG WHERE is_primary_tunnel=1") @Query("SELECT * FROM TUNNELCONFIG WHERE is_primary_tunnel=1")
suspend fun findByPrimary(): TunnelConfigs suspend fun findByPrimary(): TunnelConfigs
@Query("SELECT * FROM TUNNELCONFIG WHERE is_mobile_data_tunnel=1") @Query("SELECT * FROM TUNNELCONFIG WHERE is_mobile_data_tunnel=1")
suspend fun findByMobileDataTunnel(): TunnelConfigs suspend fun findByMobileDataTunnel(): TunnelConfigs
@Query("SELECT * FROM tunnelconfig") @Query("SELECT * FROM tunnelconfig")
fun getAllFlow(): Flow<MutableList<TunnelConfig>> fun getAllFlow(): Flow<MutableList<TunnelConfig>>
} }
@@ -18,66 +18,64 @@ import timber.log.Timber
import java.io.IOException import java.io.IOException
class DataStoreManager( class DataStoreManager(
private val context: Context, private val context: Context,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) { ) {
companion object { companion object {
val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN") val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
val BATTERY_OPTIMIZE_DISABLE_SHOWN = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN") val BATTERY_OPTIMIZE_DISABLE_SHOWN = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN")
val TUNNEL_RUNNING_FROM_MANUAL_START = val TUNNEL_RUNNING_FROM_MANUAL_START =
booleanPreferencesKey("TUNNEL_RUNNING_FROM_MANUAL_START") booleanPreferencesKey("TUNNEL_RUNNING_FROM_MANUAL_START")
val ACTIVE_TUNNEL = intPreferencesKey("ACTIVE_TUNNEL") val ACTIVE_TUNNEL = intPreferencesKey("ACTIVE_TUNNEL")
val CURRENT_SSID = stringPreferencesKey("CURRENT_SSID") val CURRENT_SSID = stringPreferencesKey("CURRENT_SSID")
val IS_PIN_LOCK_ENABLED = booleanPreferencesKey("PIN_LOCK_ENABLED") val IS_PIN_LOCK_ENABLED = booleanPreferencesKey("PIN_LOCK_ENABLED")
} }
// preferences // preferences
private val preferencesKey = "preferences" private val preferencesKey = "preferences"
private val Context.dataStore by private val Context.dataStore by
preferencesDataStore( preferencesDataStore(
name = preferencesKey, name = preferencesKey,
) )
suspend fun init() { suspend fun init() {
withContext(ioDispatcher) { withContext(ioDispatcher) {
try { try {
context.dataStore.data.first() context.dataStore.data.first()
} catch (e: IOException) { } catch (e: IOException) {
Timber.e(e) Timber.e(e)
} }
} }
} }
suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) { suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) {
withContext(ioDispatcher) { withContext(ioDispatcher) {
try { try {
context.dataStore.edit { it[key] = value } context.dataStore.edit { it[key] = value }
} catch (e: IOException) { } catch (e: IOException) {
Timber.e(e) Timber.e(e)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
} }
} }
} }
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map { it[key] }
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map { it[key] } suspend fun <T> getFromStore(key: Preferences.Key<T>): T? {
return withContext(ioDispatcher) {
try {
context.dataStore.data.map { it[key] }.first()
} catch (e: IOException) {
Timber.e(e)
null
}
}
}
suspend fun <T> getFromStore(key: Preferences.Key<T>): T? { fun <T> getFromStoreBlocking(key: Preferences.Key<T>) = runBlocking {
return withContext(ioDispatcher) { context.dataStore.data.map { it[key] }.first()
try { }
context.dataStore.data.map { it[key] }.first()
} catch (e: IOException) {
Timber.e(e)
null
}
}
}
val preferencesFlow: Flow<Preferences?> = context.dataStore.data
fun <T> getFromStoreBlocking(key: Preferences.Key<T>) = runBlocking {
context.dataStore.data.map { it[key] }.first()
}
val preferencesFlow: Flow<Preferences?> = context.dataStore.data
} }
@@ -1,16 +1,16 @@
package com.zaneschepke.wireguardautotunnel.data.domain package com.zaneschepke.wireguardautotunnel.data.domain
data class GeneralState( data class GeneralState(
val isLocationDisclosureShown: Boolean = LOCATION_DISCLOSURE_SHOWN_DEFAULT, val isLocationDisclosureShown: Boolean = LOCATION_DISCLOSURE_SHOWN_DEFAULT,
val isBatteryOptimizationDisableShown: Boolean = BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT, val isBatteryOptimizationDisableShown: Boolean = BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT,
val isTunnelRunningFromManualStart: Boolean = TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT, val isTunnelRunningFromManualStart: Boolean = TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT,
val isPinLockEnabled: Boolean = PIN_LOCK_ENABLED_DEFAULT, val isPinLockEnabled: Boolean = PIN_LOCK_ENABLED_DEFAULT,
val activeTunnelId: Int? = null val activeTunnelId: Int? = null,
) { ) {
companion object { companion object {
const val LOCATION_DISCLOSURE_SHOWN_DEFAULT = false const val LOCATION_DISCLOSURE_SHOWN_DEFAULT = false
const val BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT = false const val BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT = false
const val TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT = false const val TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT = false
const val PIN_LOCK_ENABLED_DEFAULT = false const val PIN_LOCK_ENABLED_DEFAULT = false
} }
} }
@@ -6,53 +6,53 @@ import androidx.room.PrimaryKey
@Entity @Entity
data class Settings( data class Settings(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "is_tunnel_enabled") val isAutoTunnelEnabled: Boolean = false, @ColumnInfo(name = "is_tunnel_enabled") val isAutoTunnelEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") @ColumnInfo(name = "is_tunnel_on_mobile_data_enabled")
val isTunnelOnMobileDataEnabled: Boolean = false, val isTunnelOnMobileDataEnabled: Boolean = false,
@ColumnInfo(name = "trusted_network_ssids") @ColumnInfo(name = "trusted_network_ssids")
val trustedNetworkSSIDs: MutableList<String> = mutableListOf(), val trustedNetworkSSIDs: MutableList<String> = mutableListOf(),
@ColumnInfo(name = "is_always_on_vpn_enabled") val isAlwaysOnVpnEnabled: Boolean = false, @ColumnInfo(name = "is_always_on_vpn_enabled") val isAlwaysOnVpnEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") @ColumnInfo(name = "is_tunnel_on_ethernet_enabled")
val isTunnelOnEthernetEnabled: Boolean = false, val isTunnelOnEthernetEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_shortcuts_enabled", name = "is_shortcuts_enabled",
defaultValue = "false", defaultValue = "false",
) )
val isShortcutsEnabled: Boolean = false, val isShortcutsEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_tunnel_on_wifi_enabled", name = "is_tunnel_on_wifi_enabled",
defaultValue = "false", defaultValue = "false",
) )
val isTunnelOnWifiEnabled: Boolean = false, val isTunnelOnWifiEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_kernel_enabled", name = "is_kernel_enabled",
defaultValue = "false", defaultValue = "false",
) )
val isKernelEnabled: Boolean = false, val isKernelEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_restore_on_boot_enabled", name = "is_restore_on_boot_enabled",
defaultValue = "false", defaultValue = "false",
) )
val isRestoreOnBootEnabled: Boolean = false, val isRestoreOnBootEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_multi_tunnel_enabled", name = "is_multi_tunnel_enabled",
defaultValue = "false", defaultValue = "false",
) )
val isMultiTunnelEnabled: Boolean = false, val isMultiTunnelEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_auto_tunnel_paused", name = "is_auto_tunnel_paused",
defaultValue = "false", defaultValue = "false",
) )
val isAutoTunnelPaused: Boolean = false, val isAutoTunnelPaused: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_ping_enabled", name = "is_ping_enabled",
defaultValue = "false", defaultValue = "false",
) )
val isPingEnabled: Boolean = false, val isPingEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_amnezia_enabled", name = "is_amnezia_enabled",
defaultValue = "false", defaultValue = "false",
) )
val isAmneziaEnabled: Boolean = false, val isAmneziaEnabled: Boolean = false,
) )
@@ -9,45 +9,45 @@ import java.io.InputStream
@Entity(indices = [Index(value = ["name"], unique = true)]) @Entity(indices = [Index(value = ["name"], unique = true)])
data class TunnelConfig( data class TunnelConfig(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "wg_quick") val wgQuick: String, @ColumnInfo(name = "wg_quick") val wgQuick: String,
@ColumnInfo( @ColumnInfo(
name = "tunnel_networks", name = "tunnel_networks",
defaultValue = "", defaultValue = "",
) )
val tunnelNetworks: MutableList<String> = mutableListOf(), val tunnelNetworks: MutableList<String> = mutableListOf(),
@ColumnInfo( @ColumnInfo(
name = "is_mobile_data_tunnel", name = "is_mobile_data_tunnel",
defaultValue = "false", defaultValue = "false",
) )
val isMobileDataTunnel: Boolean = false, val isMobileDataTunnel: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_primary_tunnel", name = "is_primary_tunnel",
defaultValue = "false", defaultValue = "false",
) )
val isPrimaryTunnel: Boolean = false, val isPrimaryTunnel: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "am_quick", name = "am_quick",
defaultValue = "", defaultValue = "",
) )
val amQuick: String = AM_QUICK_DEFAULT, val amQuick: String = AM_QUICK_DEFAULT,
) { ) {
companion object { companion object {
fun configFromWgQuick(wgQuick: String): Config { fun configFromWgQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream() val inputStream: InputStream = wgQuick.byteInputStream()
return inputStream.bufferedReader(Charsets.UTF_8).use { return inputStream.bufferedReader(Charsets.UTF_8).use {
Config.parse(it) Config.parse(it)
} }
} }
fun configFromAmQuick(amQuick: String): org.amnezia.awg.config.Config { fun configFromAmQuick(amQuick: String): org.amnezia.awg.config.Config {
val inputStream: InputStream = amQuick.byteInputStream() val inputStream: InputStream = amQuick.byteInputStream()
return inputStream.bufferedReader(Charsets.UTF_8).use { return inputStream.bufferedReader(Charsets.UTF_8).use {
org.amnezia.awg.config.Config.parse(it) org.amnezia.awg.config.Config.parse(it)
} }
} }
const val AM_QUICK_DEFAULT = "" const val AM_QUICK_DEFAULT = ""
} }
} }
@@ -3,12 +3,13 @@ package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
interface AppDataRepository { interface AppDataRepository {
suspend fun getPrimaryOrFirstTunnel(): TunnelConfig? suspend fun getPrimaryOrFirstTunnel(): TunnelConfig?
suspend fun getStartTunnelConfig(): TunnelConfig?
suspend fun toggleWatcherServicePause() suspend fun getStartTunnelConfig(): TunnelConfig?
val settings: SettingsRepository suspend fun toggleWatcherServicePause()
val tunnels: TunnelConfigRepository
val appState: AppStateRepository val settings: SettingsRepository
val tunnels: TunnelConfigRepository
val appState: AppStateRepository
} }
@@ -3,32 +3,36 @@ package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import javax.inject.Inject import javax.inject.Inject
class AppDataRoomRepository @Inject constructor( class AppDataRoomRepository
override val settings: SettingsRepository, @Inject
override val tunnels: TunnelConfigRepository, constructor(
override val appState: AppStateRepository override val settings: SettingsRepository,
override val tunnels: TunnelConfigRepository,
override val appState: AppStateRepository,
) : AppDataRepository { ) : AppDataRepository {
override suspend fun getPrimaryOrFirstTunnel(): TunnelConfig? { override suspend fun getPrimaryOrFirstTunnel(): TunnelConfig? {
return tunnels.findPrimary().firstOrNull() ?: tunnels.getAll().firstOrNull() return tunnels.findPrimary().firstOrNull() ?: tunnels.getAll().firstOrNull()
} }
override suspend fun getStartTunnelConfig(): TunnelConfig? { override suspend fun getStartTunnelConfig(): TunnelConfig? {
return if (appState.isTunnelRunningFromManualStart()) { return if (appState.isTunnelRunningFromManualStart()) {
appState.getActiveTunnelId()?.let { appState.getActiveTunnelId()?.let {
tunnels.getById(it) tunnels.getById(it)
} }
} else null } else {
} null
}
}
override suspend fun toggleWatcherServicePause() { override suspend fun toggleWatcherServicePause() {
val settings = settings.getSettings() val settings = settings.getSettings()
if (settings.isAutoTunnelEnabled) { if (settings.isAutoTunnelEnabled) {
val pauseAutoTunnel = !settings.isAutoTunnelPaused val pauseAutoTunnel = !settings.isAutoTunnelPaused
this.settings.save( this.settings.save(
settings.copy( settings.copy(
isAutoTunnelPaused = pauseAutoTunnel, isAutoTunnelPaused = pauseAutoTunnel,
), ),
) )
} }
} }
} }
@@ -4,26 +4,29 @@ import com.zaneschepke.wireguardautotunnel.data.domain.GeneralState
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AppStateRepository { interface AppStateRepository {
suspend fun isLocationDisclosureShown(): Boolean suspend fun isLocationDisclosureShown(): Boolean
suspend fun setLocationDisclosureShown(shown: Boolean)
suspend fun isPinLockEnabled(): Boolean suspend fun setLocationDisclosureShown(shown: Boolean)
suspend fun setPinLockEnabled(enabled: Boolean)
suspend fun isBatteryOptimizationDisableShown(): Boolean suspend fun isPinLockEnabled(): Boolean
suspend fun setBatteryOptimizationDisableShown(shown: Boolean)
suspend fun isTunnelRunningFromManualStart(): Boolean suspend fun setPinLockEnabled(enabled: Boolean)
suspend fun setTunnelRunningFromManualStart(id: Int)
suspend fun setManualStop() suspend fun isBatteryOptimizationDisableShown(): Boolean
suspend fun getActiveTunnelId(): Int? suspend fun setBatteryOptimizationDisableShown(shown: Boolean)
suspend fun getCurrentSsid(): String? suspend fun isTunnelRunningFromManualStart(): Boolean
suspend fun setCurrentSsid(ssid: String) suspend fun setTunnelRunningFromManualStart(id: Int)
val generalStateFlow: Flow<GeneralState> suspend fun setManualStop()
suspend fun getActiveTunnelId(): Int?
suspend fun getCurrentSsid(): String?
suspend fun setCurrentSsid(ssid: String)
val generalStateFlow: Flow<GeneralState>
} }
@@ -7,86 +7,90 @@ import kotlinx.coroutines.flow.map
import timber.log.Timber import timber.log.Timber
class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager) : class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager) :
AppStateRepository { AppStateRepository {
override suspend fun isLocationDisclosureShown(): Boolean { override suspend fun isLocationDisclosureShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN) return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN)
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT ?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT
} }
override suspend fun setLocationDisclosureShown(shown: Boolean) { override suspend fun setLocationDisclosureShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, shown) dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, shown)
} }
override suspend fun isPinLockEnabled(): Boolean { override suspend fun isPinLockEnabled(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.IS_PIN_LOCK_ENABLED) return dataStoreManager.getFromStore(DataStoreManager.IS_PIN_LOCK_ENABLED)
?: GeneralState.PIN_LOCK_ENABLED_DEFAULT ?: GeneralState.PIN_LOCK_ENABLED_DEFAULT
} }
override suspend fun setPinLockEnabled(enabled: Boolean) { override suspend fun setPinLockEnabled(enabled: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.IS_PIN_LOCK_ENABLED, enabled) dataStoreManager.saveToDataStore(DataStoreManager.IS_PIN_LOCK_ENABLED, enabled)
} }
override suspend fun isBatteryOptimizationDisableShown(): Boolean { override suspend fun isBatteryOptimizationDisableShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN) return dataStoreManager.getFromStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN)
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT ?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT
} }
override suspend fun setBatteryOptimizationDisableShown(shown: Boolean) { override suspend fun setBatteryOptimizationDisableShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown) dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown)
} }
override suspend fun isTunnelRunningFromManualStart(): Boolean { override suspend fun isTunnelRunningFromManualStart(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.TUNNEL_RUNNING_FROM_MANUAL_START) return dataStoreManager.getFromStore(DataStoreManager.TUNNEL_RUNNING_FROM_MANUAL_START)
?: GeneralState.TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT ?: GeneralState.TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT
} }
override suspend fun setTunnelRunningFromManualStart(id: Int) { override suspend fun setTunnelRunningFromManualStart(id: Int) {
setTunnelRunningFromManualStart(true) setTunnelRunningFromManualStart(true)
setActiveTunnelId(id) setActiveTunnelId(id)
} }
override suspend fun setManualStop() { override suspend fun setManualStop() {
setTunnelRunningFromManualStart(false) setTunnelRunningFromManualStart(false)
} }
private suspend fun setTunnelRunningFromManualStart(running: Boolean) { private suspend fun setTunnelRunningFromManualStart(running: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.TUNNEL_RUNNING_FROM_MANUAL_START, running) dataStoreManager.saveToDataStore(DataStoreManager.TUNNEL_RUNNING_FROM_MANUAL_START, running)
} }
override suspend fun getActiveTunnelId(): Int? { override suspend fun getActiveTunnelId(): Int? {
return dataStoreManager.getFromStore(DataStoreManager.ACTIVE_TUNNEL) return dataStoreManager.getFromStore(DataStoreManager.ACTIVE_TUNNEL)
} }
private suspend fun setActiveTunnelId(id: Int) { private suspend fun setActiveTunnelId(id: Int) {
dataStoreManager.saveToDataStore(DataStoreManager.ACTIVE_TUNNEL, id) dataStoreManager.saveToDataStore(DataStoreManager.ACTIVE_TUNNEL, id)
} }
override suspend fun getCurrentSsid(): String? { override suspend fun getCurrentSsid(): String? {
return dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID) return dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID)
} }
override suspend fun setCurrentSsid(ssid: String) { override suspend fun setCurrentSsid(ssid: String) {
dataStoreManager.saveToDataStore(DataStoreManager.CURRENT_SSID, ssid) dataStoreManager.saveToDataStore(DataStoreManager.CURRENT_SSID, ssid)
} }
override val generalStateFlow: Flow<GeneralState> = override val generalStateFlow: Flow<GeneralState> =
dataStoreManager.preferencesFlow.map { prefs -> dataStoreManager.preferencesFlow.map { prefs ->
prefs?.let { pref -> prefs?.let { pref ->
try { try {
GeneralState( GeneralState(
isLocationDisclosureShown = pref[DataStoreManager.LOCATION_DISCLOSURE_SHOWN] isLocationDisclosureShown =
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT, pref[DataStoreManager.LOCATION_DISCLOSURE_SHOWN]
isBatteryOptimizationDisableShown = pref[DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN] ?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT,
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT, isBatteryOptimizationDisableShown =
isTunnelRunningFromManualStart = pref[DataStoreManager.TUNNEL_RUNNING_FROM_MANUAL_START] pref[DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN]
?: GeneralState.TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT, ?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT,
isPinLockEnabled = pref[DataStoreManager.IS_PIN_LOCK_ENABLED] isTunnelRunningFromManualStart =
?: GeneralState.TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT, pref[DataStoreManager.TUNNEL_RUNNING_FROM_MANUAL_START]
) ?: GeneralState.TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT,
} catch (e: IllegalArgumentException) { isPinLockEnabled =
Timber.e(e) pref[DataStoreManager.IS_PIN_LOCK_ENABLED]
GeneralState() ?: GeneralState.TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT,
} )
} ?: GeneralState() } catch (e: IllegalArgumentException) {
} Timber.e(e)
GeneralState()
}
} ?: GeneralState()
}
} }
@@ -5,20 +5,19 @@ import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class RoomSettingsRepository(private val settingsDoa: SettingsDao) : SettingsRepository { class RoomSettingsRepository(private val settingsDoa: SettingsDao) : SettingsRepository {
override suspend fun save(settings: Settings) {
settingsDoa.save(settings)
}
override suspend fun save(settings: Settings) { override fun getSettingsFlow(): Flow<Settings> {
settingsDoa.save(settings) return settingsDoa.getSettingsFlow()
} }
override fun getSettingsFlow(): Flow<Settings> { override suspend fun getSettings(): Settings {
return settingsDoa.getSettingsFlow() return settingsDoa.getAll().firstOrNull() ?: Settings()
} }
override suspend fun getSettings(): Settings { override suspend fun getAll(): List<Settings> {
return settingsDoa.getAll().firstOrNull() ?: Settings() return settingsDoa.getAll()
} }
override suspend fun getAll(): List<Settings> {
return settingsDoa.getAll()
}
} }
@@ -6,67 +6,66 @@ import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class RoomTunnelConfigRepository(private val tunnelConfigDao: TunnelConfigDao) : class RoomTunnelConfigRepository(private val tunnelConfigDao: TunnelConfigDao) :
TunnelConfigRepository { TunnelConfigRepository {
override fun getTunnelConfigsFlow(): Flow<TunnelConfigs> { override fun getTunnelConfigsFlow(): Flow<TunnelConfigs> {
return tunnelConfigDao.getAllFlow() return tunnelConfigDao.getAllFlow()
} }
override suspend fun getAll(): TunnelConfigs { override suspend fun getAll(): TunnelConfigs {
return tunnelConfigDao.getAll() return tunnelConfigDao.getAll()
} }
override suspend fun save(tunnelConfig: TunnelConfig) { override suspend fun save(tunnelConfig: TunnelConfig) {
tunnelConfigDao.save(tunnelConfig) tunnelConfigDao.save(tunnelConfig)
} }
override suspend fun updatePrimaryTunnel(tunnelConfig: TunnelConfig?) { override suspend fun updatePrimaryTunnel(tunnelConfig: TunnelConfig?) {
tunnelConfigDao.resetPrimaryTunnel() tunnelConfigDao.resetPrimaryTunnel()
tunnelConfig?.let { tunnelConfig?.let {
save( save(
it.copy( it.copy(
isPrimaryTunnel = true, isPrimaryTunnel = true,
), ),
) )
} }
}
} override suspend fun updateMobileDataTunnel(tunnelConfig: TunnelConfig?) {
tunnelConfigDao.resetMobileDataTunnel()
tunnelConfig?.let {
save(
it.copy(
isMobileDataTunnel = true,
),
)
}
}
override suspend fun updateMobileDataTunnel(tunnelConfig: TunnelConfig?) { override suspend fun delete(tunnelConfig: TunnelConfig) {
tunnelConfigDao.resetMobileDataTunnel() tunnelConfigDao.delete(tunnelConfig)
tunnelConfig?.let { }
save(
it.copy(
isMobileDataTunnel = true,
),
)
}
}
override suspend fun delete(tunnelConfig: TunnelConfig) { override suspend fun getById(id: Int): TunnelConfig? {
tunnelConfigDao.delete(tunnelConfig) return tunnelConfigDao.getById(id.toLong())
} }
override suspend fun getById(id: Int): TunnelConfig? { override suspend fun count(): Int {
return tunnelConfigDao.getById(id.toLong()) return tunnelConfigDao.count().toInt()
} }
override suspend fun count(): Int { override suspend fun findByTunnelName(name: String): TunnelConfig? {
return tunnelConfigDao.count().toInt() return tunnelConfigDao.getByName(name)
} }
override suspend fun findByTunnelName(name: String): TunnelConfig? { override suspend fun findByTunnelNetworksName(name: String): TunnelConfigs {
return tunnelConfigDao.getByName(name) return tunnelConfigDao.findByTunnelNetworkName(name)
} }
override suspend fun findByTunnelNetworksName(name: String): TunnelConfigs { override suspend fun findByMobileDataTunnel(): TunnelConfigs {
return tunnelConfigDao.findByTunnelNetworkName(name) return tunnelConfigDao.findByMobileDataTunnel()
} }
override suspend fun findByMobileDataTunnel(): TunnelConfigs { override suspend fun findPrimary(): TunnelConfigs {
return tunnelConfigDao.findByMobileDataTunnel() return tunnelConfigDao.findByPrimary()
} }
override suspend fun findPrimary(): TunnelConfigs {
return tunnelConfigDao.findByPrimary()
}
} }
@@ -4,11 +4,11 @@ import com.zaneschepke.wireguardautotunnel.data.domain.Settings
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface SettingsRepository { interface SettingsRepository {
suspend fun save(settings: Settings) suspend fun save(settings: Settings)
fun getSettingsFlow(): Flow<Settings> fun getSettingsFlow(): Flow<Settings>
suspend fun getSettings(): Settings suspend fun getSettings(): Settings
suspend fun getAll(): List<Settings> suspend fun getAll(): List<Settings>
} }
@@ -5,28 +5,27 @@ import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface TunnelConfigRepository { interface TunnelConfigRepository {
fun getTunnelConfigsFlow(): Flow<TunnelConfigs>
fun getTunnelConfigsFlow(): Flow<TunnelConfigs> suspend fun getAll(): TunnelConfigs
suspend fun getAll(): TunnelConfigs suspend fun save(tunnelConfig: TunnelConfig)
suspend fun save(tunnelConfig: TunnelConfig) suspend fun updatePrimaryTunnel(tunnelConfig: TunnelConfig?)
suspend fun updatePrimaryTunnel(tunnelConfig: TunnelConfig?) suspend fun updateMobileDataTunnel(tunnelConfig: TunnelConfig?)
suspend fun updateMobileDataTunnel(tunnelConfig: TunnelConfig?) suspend fun delete(tunnelConfig: TunnelConfig)
suspend fun delete(tunnelConfig: TunnelConfig) suspend fun getById(id: Int): TunnelConfig?
suspend fun getById(id: Int): TunnelConfig? suspend fun count(): Int
suspend fun count(): Int suspend fun findByTunnelName(name: String): TunnelConfig?
suspend fun findByTunnelName(name: String): TunnelConfig? suspend fun findByTunnelNetworksName(name: String): TunnelConfigs
suspend fun findByTunnelNetworksName(name: String): TunnelConfigs suspend fun findByMobileDataTunnel(): TunnelConfigs
suspend fun findByMobileDataTunnel(): TunnelConfigs suspend fun findPrimary(): TunnelConfigs
suspend fun findPrimary(): TunnelConfigs
} }
@@ -2,7 +2,7 @@ package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import android.content.Context
import com.zaneschepke.logcatter.LocalLogCollector import com.zaneschepke.logcatter.LocalLogCollector
import com.zaneschepke.logcatter.LogcatHelper import com.zaneschepke.logcatter.LogcatUtil
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -16,15 +16,15 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class AppModule { class AppModule {
@Singleton @Singleton
@ApplicationScope @ApplicationScope
@Provides @Provides
fun providesApplicationScope(@DefaultDispatcher defaultDispatcher: CoroutineDispatcher): CoroutineScope = fun providesApplicationScope(@DefaultDispatcher defaultDispatcher: CoroutineDispatcher): CoroutineScope =
CoroutineScope(SupervisorJob() + defaultDispatcher) CoroutineScope(SupervisorJob() + defaultDispatcher)
@Singleton @Singleton
@Provides @Provides
fun provideLogCollect(@ApplicationContext context: Context): LocalLogCollector { fun provideLogCollect(@ApplicationContext context: Context): LocalLogCollector {
return LogcatHelper.init(context = context) return LogcatUtil.init(context = context)
} }
} }
@@ -10,19 +10,19 @@ import kotlinx.coroutines.Dispatchers
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object CoroutinesDispatchersModule { object CoroutinesDispatchersModule {
@DefaultDispatcher @DefaultDispatcher
@Provides @Provides
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@IoDispatcher @IoDispatcher
@Provides @Provides
fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@MainDispatcher @MainDispatcher
@Provides @Provides
fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
@MainImmediateDispatcher @MainImmediateDispatcher
@Provides @Provides
fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate fun providesMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate
} }
@@ -27,67 +27,62 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class RepositoryModule { class RepositoryModule {
@Provides @Provides
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase { fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder( return Room.databaseBuilder(
context, context,
AppDatabase::class.java, AppDatabase::class.java,
context.getString(R.string.db_name), context.getString(R.string.db_name),
) )
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.addCallback(DatabaseCallback()) .addCallback(DatabaseCallback())
.build() .build()
} }
@Singleton @Singleton
@Provides @Provides
fun provideSettingsDoa(appDatabase: AppDatabase): SettingsDao { fun provideSettingsDoa(appDatabase: AppDatabase): SettingsDao {
return appDatabase.settingDao() return appDatabase.settingDao()
} }
@Singleton @Singleton
@Provides @Provides
fun provideTunnelConfigDoa(appDatabase: AppDatabase): TunnelConfigDao { fun provideTunnelConfigDoa(appDatabase: AppDatabase): TunnelConfigDao {
return appDatabase.tunnelConfigDoa() return appDatabase.tunnelConfigDoa()
} }
@Singleton @Singleton
@Provides @Provides
fun provideTunnelConfigRepository(tunnelConfigDao: TunnelConfigDao): TunnelConfigRepository { fun provideTunnelConfigRepository(tunnelConfigDao: TunnelConfigDao): TunnelConfigRepository {
return RoomTunnelConfigRepository(tunnelConfigDao) return RoomTunnelConfigRepository(tunnelConfigDao)
} }
@Singleton @Singleton
@Provides @Provides
fun provideSettingsRepository(settingsDao: SettingsDao): SettingsRepository { fun provideSettingsRepository(settingsDao: SettingsDao): SettingsRepository {
return RoomSettingsRepository(settingsDao) return RoomSettingsRepository(settingsDao)
} }
@Singleton @Singleton
@Provides @Provides
fun providePreferencesDataStore( fun providePreferencesDataStore(@ApplicationContext context: Context, @IoDispatcher ioDispatcher: CoroutineDispatcher): DataStoreManager {
@ApplicationContext context: Context, return DataStoreManager(context, ioDispatcher)
@IoDispatcher ioDispatcher: CoroutineDispatcher }
): DataStoreManager {
return DataStoreManager(context, ioDispatcher)
}
@Provides
@Singleton
fun provideGeneralStateRepository(dataStoreManager: DataStoreManager): AppStateRepository {
return DataStoreAppStateRepository(dataStoreManager)
}
@Provides
@Singleton
fun provideAppDataRepository(
settingsRepository: SettingsRepository,
tunnelConfigRepository: TunnelConfigRepository,
appStateRepository: AppStateRepository
): AppDataRepository {
return AppDataRoomRepository(settingsRepository, tunnelConfigRepository, appStateRepository)
}
@Provides
@Singleton
fun provideGeneralStateRepository(dataStoreManager: DataStoreManager): AppStateRepository {
return DataStoreAppStateRepository(dataStoreManager)
}
@Provides
@Singleton
fun provideAppDataRepository(
settingsRepository: SettingsRepository,
tunnelConfigRepository: TunnelConfigRepository,
appStateRepository: AppStateRepository,
): AppDataRepository {
return AppDataRoomRepository(settingsRepository, tunnelConfigRepository, appStateRepository)
}
} }
@@ -15,25 +15,19 @@ import dagger.hilt.android.scopes.ServiceScoped
@Module @Module
@InstallIn(ServiceComponent::class) @InstallIn(ServiceComponent::class)
abstract class ServiceModule { abstract class ServiceModule {
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideNotificationService( abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification): NotificationService
wireGuardNotification: WireGuardNotification
): NotificationService
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideWifiService(wifiService: WifiService): NetworkService<WifiService> abstract fun provideWifiService(wifiService: WifiService): NetworkService<WifiService>
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideMobileDataService( abstract fun provideMobileDataService(mobileDataService: MobileDataService): NetworkService<MobileDataService>
mobileDataService: MobileDataService
): NetworkService<MobileDataService>
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideEthernetService( abstract fun provideEthernetService(ethernetService: EthernetService): NetworkService<EthernetService>
ethernetService: EthernetService
): NetworkService<EthernetService>
} }
@@ -23,58 +23,55 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class TunnelModule { class TunnelModule {
@Provides @Provides
@Singleton @Singleton
fun provideRootShell(@ApplicationContext context: Context): RootShell { fun provideRootShell(@ApplicationContext context: Context): RootShell {
return RootShell(context) return RootShell(context)
} }
@Provides @Provides
@Singleton @Singleton
@Userspace @Userspace
fun provideUserspaceBackend(@ApplicationContext context: Context): Backend { fun provideUserspaceBackend(@ApplicationContext context: Context): Backend {
return GoBackend(context) return GoBackend(context)
} }
@Provides @Provides
@Singleton @Singleton
@Kernel @Kernel
fun provideKernelBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend { fun provideKernelBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell)) return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell))
} }
@Provides @Provides
@Singleton @Singleton
fun provideAmneziaBackend(@ApplicationContext context: Context): org.amnezia.awg.backend.Backend { fun provideAmneziaBackend(@ApplicationContext context: Context): org.amnezia.awg.backend.Backend {
return org.amnezia.awg.backend.GoBackend(context) return org.amnezia.awg.backend.GoBackend(context)
} }
@Provides @Provides
@Singleton @Singleton
fun provideVpnService( fun provideVpnService(
amneziaBackend: Provider<org.amnezia.awg.backend.Backend>, amneziaBackend: Provider<org.amnezia.awg.backend.Backend>,
@Userspace userspaceBackend: Provider<Backend>, @Userspace userspaceBackend: Provider<Backend>,
@Kernel kernelBackend: Provider<Backend>, @Kernel kernelBackend: Provider<Backend>,
appDataRepository: AppDataRepository, appDataRepository: AppDataRepository,
@ApplicationScope applicationScope: CoroutineScope, @ApplicationScope applicationScope: CoroutineScope,
@IoDispatcher ioDispatcher: CoroutineDispatcher @IoDispatcher ioDispatcher: CoroutineDispatcher,
): VpnService { ): VpnService {
return WireGuardTunnel( return WireGuardTunnel(
amneziaBackend, amneziaBackend,
userspaceBackend, userspaceBackend,
kernelBackend, kernelBackend,
appDataRepository, appDataRepository,
applicationScope, applicationScope,
ioDispatcher, ioDispatcher,
) )
} }
@Provides @Provides
@Singleton @Singleton
fun provideServiceManager( fun provideServiceManager(appDataRepository: AppDataRepository, @IoDispatcher ioDispatcher: CoroutineDispatcher): ServiceManager {
appDataRepository: AppDataRepository, return ServiceManager(appDataRepository, ioDispatcher)
@IoDispatcher ioDispatcher: CoroutineDispatcher }
): ServiceManager {
return ServiceManager(appDataRepository, ioDispatcher)
}
} }
@@ -13,13 +13,9 @@ import kotlinx.coroutines.CoroutineDispatcher
@Module @Module
@InstallIn(ViewModelComponent::class) @InstallIn(ViewModelComponent::class)
class ViewModelModule { class ViewModelModule {
@ViewModelScoped
@ViewModelScoped @Provides
@Provides fun provideFileUtils(@ApplicationContext context: Context, @IoDispatcher ioDispatcher: CoroutineDispatcher): FileUtils {
fun provideFileUtils( return FileUtils(context, ioDispatcher)
@ApplicationContext context: Context, }
@IoDispatcher ioDispatcher: CoroutineDispatcher
): FileUtils {
return FileUtils(context, ioDispatcher)
}
} }
@@ -14,43 +14,42 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject @Inject
lateinit var appDataRepository: AppDataRepository lateinit var serviceManager: ServiceManager
@Inject @Inject
lateinit var serviceManager: ServiceManager @ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject override fun onReceive(context: Context?, intent: Intent?) {
@ApplicationScope if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return
lateinit var applicationScope: CoroutineScope context?.run {
applicationScope.launch {
override fun onReceive(context: Context?, intent: Intent?) { val settings = appDataRepository.settings.getSettings()
if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return if (settings.isRestoreOnBootEnabled) {
context?.run { if (settings.isAutoTunnelEnabled) {
applicationScope.launch { Timber.i("Starting watcher service from boot")
val settings = appDataRepository.settings.getSettings() serviceManager.startWatcherServiceForeground(context)
if(settings.isRestoreOnBootEnabled) { }
if (settings.isAutoTunnelEnabled) { if (appDataRepository.appState.isTunnelRunningFromManualStart()) {
Timber.i("Starting watcher service from boot") appDataRepository.appState.getActiveTunnelId()?.let {
serviceManager.startWatcherServiceForeground(context) Timber.i("Starting tunnel that was active before reboot")
} serviceManager.startVpnServiceForeground(
if (appDataRepository.appState.isTunnelRunningFromManualStart()) { context,
appDataRepository.appState.getActiveTunnelId()?.let { appDataRepository.tunnels.getById(it)?.id,
Timber.i("Starting tunnel that was active before reboot") )
serviceManager.startVpnServiceForeground( return@launch
context, }
appDataRepository.tunnels.getById(it)?.id, }
) if (settings.isAlwaysOnVpnEnabled) {
return@launch Timber.i("Starting vpn service from boot AOVPN")
} serviceManager.startVpnServiceForeground(context)
} }
if (settings.isAlwaysOnVpnEnabled) { }
Timber.i("Starting vpn service from boot AOVPN") }
serviceManager.startVpnServiceForeground(context) }
} }
}
}
}
}
} }
@@ -17,28 +17,28 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() { class NotificationActionReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepository: SettingsRepository lateinit var settingsRepository: SettingsRepository
@Inject @Inject
lateinit var serviceManager: ServiceManager lateinit var serviceManager: ServiceManager
@Inject @Inject
@ApplicationScope @ApplicationScope
lateinit var applicationScope: CoroutineScope lateinit var applicationScope: CoroutineScope
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
applicationScope.launch { applicationScope.launch {
try { try {
//TODO fix for manual start changes when enabled // TODO fix for manual start changes when enabled
serviceManager.stopVpnServiceForeground(context) serviceManager.stopVpnServiceForeground(context)
delay(Constants.TOGGLE_TUNNEL_DELAY) delay(Constants.TOGGLE_TUNNEL_DELAY)
serviceManager.startVpnServiceForeground(context) serviceManager.startVpnServiceForeground(context)
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e) Timber.e(e)
} finally { } finally {
cancel() cancel()
} }
} }
} }
} }
@@ -1,8 +1,8 @@
package com.zaneschepke.wireguardautotunnel.service.foreground package com.zaneschepke.wireguardautotunnel.service.foreground
enum class Action { enum class Action {
START, START,
START_FOREGROUND, START_FOREGROUND,
STOP, STOP,
STOP_FOREGROUND STOP_FOREGROUND,
} }
@@ -8,49 +8,50 @@ import com.zaneschepke.wireguardautotunnel.util.Constants
import timber.log.Timber import timber.log.Timber
open class ForegroundService : LifecycleService() { open class ForegroundService : LifecycleService() {
private var isServiceStarted = false private var isServiceStarted = false
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
super.onBind(intent) super.onBind(intent)
// We don't provide binding, so return null // We don't provide binding, so return null
return null return null
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId") Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) { if (intent != null) {
val action = intent.action val action = intent.action
when (action) { when (action) {
Action.START.name, Action.START.name,
Action.START_FOREGROUND.name -> startService(intent.extras) Action.START_FOREGROUND.name,
-> startService(intent.extras)
Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService() Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService()
Constants.ALWAYS_ON_VPN_ACTION -> { Constants.ALWAYS_ON_VPN_ACTION -> {
Timber.i("Always-on VPN starting service") Timber.i("Always-on VPN starting service")
startService(intent.extras) startService(intent.extras)
} }
else -> Timber.d("This should never happen. No action in the received intent") else -> Timber.d("This should never happen. No action in the received intent")
} }
} else { } else {
Timber.d( Timber.d(
"with a null intent. It has been probably restarted by the system.", "with a null intent. It has been probably restarted by the system.",
) )
} }
return START_STICKY return START_STICKY
} }
protected open fun startService(extras: Bundle?) { protected open fun startService(extras: Bundle?) {
if (isServiceStarted) return if (isServiceStarted) return
Timber.d("Starting ${this.javaClass.simpleName}") Timber.d("Starting ${this.javaClass.simpleName}")
isServiceStarted = true isServiceStarted = true
} }
protected open fun stopService() { protected open fun stopService() {
Timber.d("Stopping ${this.javaClass.simpleName}") Timber.d("Stopping ${this.javaClass.simpleName}")
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf() stopSelf()
isServiceStarted = false isServiceStarted = false
} }
} }
@@ -11,122 +11,107 @@ import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
class ServiceManager( class ServiceManager(
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) { ) {
private fun <T : Service> actionOnService(action: Action, context: Context, cls: Class<T>, extras: Map<String, Int>? = null) {
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, Action.STOP_FOREGROUND ->
context.startForegroundService(
intent,
)
private fun <T : Service> actionOnService( Action.START, Action.STOP -> context.startService(intent)
action: Action, }
context: Context, } catch (e: Exception) {
cls: Class<T>, Timber.e(e.message)
extras: Map<String, Int>? = null }
) { }
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, Action.STOP_FOREGROUND -> context.startForegroundService(
intent,
)
Action.START, Action.STOP -> context.startService(intent) suspend fun startVpnService(context: Context, tunnelId: Int? = null, isManualStart: Boolean = false) {
} if (isManualStart) onManualStart(tunnelId)
} catch (e: Exception) { actionOnService(
Timber.e(e.message) Action.START,
} context,
} WireGuardTunnelService::class.java,
tunnelId?.let { mapOf(Constants.TUNNEL_EXTRA_KEY to it) },
)
}
suspend fun startVpnService( suspend fun stopVpnServiceForeground(context: Context, isManualStop: Boolean = false) {
context: Context, withContext(ioDispatcher) {
tunnelId: Int? = null, if (isManualStop) onManualStop()
isManualStart: Boolean = false Timber.i("Stopping vpn service")
) { actionOnService(
if (isManualStart) onManualStart(tunnelId) Action.STOP_FOREGROUND,
actionOnService( context,
Action.START, WireGuardTunnelService::class.java,
context, )
WireGuardTunnelService::class.java, }
tunnelId?.let { mapOf(Constants.TUNNEL_EXTRA_KEY to it) }, }
)
}
suspend fun stopVpnServiceForeground(context: Context, isManualStop: Boolean = false) { suspend fun stopVpnService(context: Context, isManualStop: Boolean = false) {
withContext(ioDispatcher) { withContext(ioDispatcher) {
if (isManualStop) onManualStop() if (isManualStop) onManualStop()
Timber.i("Stopping vpn service") Timber.i("Stopping vpn service")
actionOnService( actionOnService(
Action.STOP_FOREGROUND, Action.STOP,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
) )
} }
} }
suspend fun stopVpnService(context: Context, isManualStop: Boolean = false) { private suspend fun onManualStop() {
withContext(ioDispatcher) { appDataRepository.appState.setManualStop()
if (isManualStop) onManualStop() }
Timber.i("Stopping vpn service")
actionOnService(
Action.STOP,
context,
WireGuardTunnelService::class.java,
)
}
}
private suspend fun onManualStop() { private suspend fun onManualStart(tunnelId: Int?) {
appDataRepository.appState.setManualStop() tunnelId?.let {
} appDataRepository.appState.setTunnelRunningFromManualStart(it)
}
}
private suspend fun onManualStart(tunnelId: Int?) { suspend fun startVpnServiceForeground(context: Context, tunnelId: Int? = null, isManualStart: Boolean = false) {
tunnelId?.let { withContext(ioDispatcher) {
appDataRepository.appState.setTunnelRunningFromManualStart(it) if (isManualStart) onManualStart(tunnelId)
} actionOnService(
} Action.START_FOREGROUND,
context,
WireGuardTunnelService::class.java,
tunnelId?.let { mapOf(Constants.TUNNEL_EXTRA_KEY to it) },
)
}
}
suspend fun startVpnServiceForeground( fun startWatcherServiceForeground(context: Context) {
context: Context, actionOnService(
tunnelId: Int? = null, Action.START_FOREGROUND,
isManualStart: Boolean = false context,
) { WireGuardConnectivityWatcherService::class.java,
withContext(ioDispatcher) { )
if (isManualStart) onManualStart(tunnelId) }
actionOnService(
Action.START_FOREGROUND,
context,
WireGuardTunnelService::class.java,
tunnelId?.let { mapOf(Constants.TUNNEL_EXTRA_KEY to it) },
)
}
}
fun startWatcherServiceForeground( fun startWatcherService(context: Context) {
context: Context, actionOnService(
) { Action.START,
actionOnService( context,
Action.START_FOREGROUND, WireGuardConnectivityWatcherService::class.java,
context, )
WireGuardConnectivityWatcherService::class.java, }
)
}
fun startWatcherService(context: Context) { fun stopWatcherService(context: Context) {
actionOnService( actionOnService(
Action.START, Action.STOP,
context, context,
WireGuardConnectivityWatcherService::class.java, WireGuardConnectivityWatcherService::class.java,
) )
} }
fun stopWatcherService(context: Context) {
actionOnService(
Action.STOP,
context,
WireGuardConnectivityWatcherService::class.java,
)
}
} }
@@ -3,54 +3,71 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import com.zaneschepke.wireguardautotunnel.data.domain.Settings import com.zaneschepke.wireguardautotunnel.data.domain.Settings
data class WatcherState( data class WatcherState(
val isWifiConnected: Boolean = false, val isWifiConnected: Boolean = false,
val isEthernetConnected: Boolean = false, val isEthernetConnected: Boolean = false,
val isMobileDataConnected: Boolean = false, val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "", val currentNetworkSSID: String = "",
val settings: Settings = Settings() val settings: Settings = Settings(),
) { ) {
fun isEthernetConditionMet(): Boolean { fun isEthernetConditionMet(): Boolean {
return (isEthernetConnected && return (
settings.isTunnelOnEthernetEnabled) isEthernetConnected &&
} settings.isTunnelOnEthernetEnabled
)
}
fun isMobileDataConditionMet(): Boolean { fun isMobileDataConditionMet(): Boolean {
return (!isEthernetConnected && return (
settings.isTunnelOnMobileDataEnabled && !isEthernetConnected &&
!isWifiConnected && settings.isTunnelOnMobileDataEnabled &&
isMobileDataConnected) !isWifiConnected &&
} isMobileDataConnected
)
}
fun isTunnelOffOnMobileDataConditionMet(): Boolean { fun isTunnelOffOnMobileDataConditionMet(): Boolean {
return (!isEthernetConnected && return (
!settings.isTunnelOnMobileDataEnabled && !isEthernetConnected &&
isMobileDataConnected && !settings.isTunnelOnMobileDataEnabled &&
!isWifiConnected) isMobileDataConnected &&
} !isWifiConnected
)
}
fun isUntrustedWifiConditionMet(): Boolean { fun isUntrustedWifiConditionMet(): Boolean {
return (!isEthernetConnected && return (
isWifiConnected && !isEthernetConnected &&
!settings.trustedNetworkSSIDs.contains(currentNetworkSSID) && isWifiConnected &&
settings.isTunnelOnWifiEnabled) !settings.trustedNetworkSSIDs.contains(currentNetworkSSID) &&
} settings.isTunnelOnWifiEnabled
)
}
fun isTrustedWifiConditionMet(): Boolean { fun isTrustedWifiConditionMet(): Boolean {
return (!isEthernetConnected && return (
(isWifiConnected && !isEthernetConnected &&
settings.trustedNetworkSSIDs.contains(currentNetworkSSID))) (
} isWifiConnected &&
settings.trustedNetworkSSIDs.contains(currentNetworkSSID)
)
)
}
fun isTunnelOffOnWifiConditionMet(): Boolean { fun isTunnelOffOnWifiConditionMet(): Boolean {
return (!isEthernetConnected && return (
(isWifiConnected && !isEthernetConnected &&
!settings.isTunnelOnWifiEnabled)) (
} isWifiConnected &&
!settings.isTunnelOnWifiEnabled
)
)
}
fun isTunnelOffOnNoConnectivityMet(): Boolean { fun isTunnelOffOnNoConnectivityMet(): Boolean {
return (!isEthernetConnected && return (
!isWifiConnected && !isEthernetConnected &&
!isMobileDataConnected) !isWifiConnected &&
} !isMobileDataConnected
)
}
} }
@@ -33,424 +33,442 @@ import timber.log.Timber
import java.net.InetAddress import java.net.InetAddress
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardConnectivityWatcherService : ForegroundService() { class WireGuardConnectivityWatcherService : ForegroundService() {
private val foregroundId = 122 private val foregroundId = 122
@Inject @Inject
lateinit var wifiService: NetworkService<WifiService> lateinit var wifiService: NetworkService<WifiService>
@Inject @Inject
lateinit var mobileDataService: NetworkService<MobileDataService> lateinit var mobileDataService: NetworkService<MobileDataService>
@Inject @Inject
lateinit var ethernetService: NetworkService<EthernetService> lateinit var ethernetService: NetworkService<EthernetService>
@Inject @Inject
lateinit var appDataRepository: AppDataRepository lateinit var appDataRepository: AppDataRepository
@Inject @Inject
lateinit var notificationService: NotificationService lateinit var notificationService: NotificationService
@Inject @Inject
lateinit var vpnService: VpnService lateinit var vpnService: VpnService
@Inject @Inject
lateinit var serviceManager: ServiceManager lateinit var serviceManager: ServiceManager
@Inject @Inject
@IoDispatcher @IoDispatcher
lateinit var ioDispatcher: CoroutineDispatcher lateinit var ioDispatcher: CoroutineDispatcher
@Inject @Inject
@MainImmediateDispatcher @MainImmediateDispatcher
lateinit var mainImmediateDispatcher: CoroutineDispatcher lateinit var mainImmediateDispatcher: CoroutineDispatcher
private val networkEventsFlow = MutableStateFlow(WatcherState()) private val networkEventsFlow = MutableStateFlow(WatcherState())
private var watcherJob: Job? = null private var watcherJob: Job? = null
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private val tag = this.javaClass.name private val tag = this.javaClass.name
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
lifecycleScope.launch(mainImmediateDispatcher) { lifecycleScope.launch(mainImmediateDispatcher) {
try { try {
if (appDataRepository.settings.getSettings().isAutoTunnelPaused) { if (appDataRepository.settings.getSettings().isAutoTunnelPaused) {
launchWatcherPausedNotification() launchWatcherPausedNotification()
} else launchWatcherNotification() } else {
} catch (e: Exception) { launchWatcherNotification()
Timber.e("Failed to start watcher service, not enough permissions") }
} } catch (e: Exception) {
} Timber.e("Failed to start watcher service, not enough permissions")
} }
}
}
override fun startService(extras: Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
try { try {
// we need this lock so our service gets not affected by Doze Mode // we need this lock so our service gets not affected by Doze Mode
lifecycleScope.launch { initWakeLock() } lifecycleScope.launch { initWakeLock() }
cancelWatcherJob() cancelWatcherJob()
startWatcherJob() startWatcherJob()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Failed to launch watcher service, no permissions") Timber.e("Failed to launch watcher service, no permissions")
} }
} }
override fun stopService() { override fun stopService() {
super.stopService() super.stopService()
wakeLock?.let { wakeLock?.let {
if (it.isHeld) { if (it.isHeld) {
it.release() it.release()
} }
} }
cancelWatcherJob() cancelWatcherJob()
stopSelf() stopSelf()
} }
private fun launchWatcherNotification( private fun launchWatcherNotification(description: String = getString(R.string.watcher_notification_text_active)) {
description: String = getString(R.string.watcher_notification_text_active) val notification =
) { notificationService.createNotification(
val notification = channelId = getString(R.string.watcher_channel_id),
notificationService.createNotification( channelName = getString(R.string.watcher_channel_name),
channelId = getString(R.string.watcher_channel_id), title = getString(R.string.auto_tunnel_title),
channelName = getString(R.string.watcher_channel_name), description = description,
title = getString(R.string.auto_tunnel_title), )
description = description, ServiceCompat.startForeground(
) this,
ServiceCompat.startForeground( foregroundId,
this, notification,
foregroundId, Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
notification, )
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID, }
)
}
private fun launchWatcherPausedNotification() { private fun launchWatcherPausedNotification() {
launchWatcherNotification(getString(R.string.watcher_notification_text_paused)) launchWatcherNotification(getString(R.string.watcher_notification_text_paused))
} }
private fun initWakeLock() { private fun initWakeLock() {
wakeLock = wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run { (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
try { try {
Timber.i("Initiating wakelock with 10 min timeout") Timber.i("Initiating wakelock with 10 min timeout")
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT) acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} finally { } finally {
release() release()
} }
} }
} }
} }
private fun cancelWatcherJob() { private fun cancelWatcherJob() {
try { try {
watcherJob?.cancel() watcherJob?.cancel()
} catch (e: CancellationException) { } catch (e: CancellationException) {
Timber.i("Watcher job cancelled") Timber.i("Watcher job cancelled")
} }
} }
private fun startWatcherJob() { private fun startWatcherJob() {
watcherJob = watcherJob =
lifecycleScope.launch { lifecycleScope.launch {
val setting = appDataRepository.settings.getSettings() val setting = appDataRepository.settings.getSettings()
launch { launch {
Timber.i("Starting wifi watcher") Timber.i("Starting wifi watcher")
watchForWifiConnectivityChanges() watchForWifiConnectivityChanges()
} }
if (setting.isTunnelOnMobileDataEnabled) { if (setting.isTunnelOnMobileDataEnabled) {
launch { launch {
Timber.i("Starting mobile data watcher") Timber.i("Starting mobile data watcher")
watchForMobileDataConnectivityChanges() watchForMobileDataConnectivityChanges()
} }
} }
if (setting.isTunnelOnEthernetEnabled) { if (setting.isTunnelOnEthernetEnabled) {
launch { launch {
Timber.i("Starting ethernet data watcher") Timber.i("Starting ethernet data watcher")
watchForEthernetConnectivityChanges() watchForEthernetConnectivityChanges()
} }
} }
launch { launch {
Timber.i("Starting settings watcher") Timber.i("Starting settings watcher")
watchForSettingsChanges() watchForSettingsChanges()
} }
if (setting.isPingEnabled) { if (setting.isPingEnabled) {
launch { launch {
Timber.i("Starting ping watcher") Timber.i("Starting ping watcher")
watchForPingFailure() watchForPingFailure()
} }
} }
launch { launch {
Timber.i("Starting management watcher") Timber.i("Starting management watcher")
manageVpn() manageVpn()
} }
}
}
} private suspend fun watchForMobileDataConnectivityChanges() {
} withContext(ioDispatcher) {
mobileDataService.networkStatus.collect { status ->
when (status) {
is NetworkStatus.Available -> {
Timber.i("Gained Mobile data connection")
networkEventsFlow.update {
it.copy(
isMobileDataConnected = true,
)
}
}
private suspend fun watchForMobileDataConnectivityChanges() { is NetworkStatus.CapabilitiesChanged -> {
withContext(ioDispatcher) { networkEventsFlow.update {
mobileDataService.networkStatus.collect { status -> it.copy(
when (status) { isMobileDataConnected = true,
is NetworkStatus.Available -> { )
Timber.i("Gained Mobile data connection") }
networkEventsFlow.update { Timber.i("Mobile data capabilities changed")
it.copy( }
isMobileDataConnected = true,
)
}
}
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.Unavailable -> {
networkEventsFlow.update { networkEventsFlow.update {
it.copy( it.copy(
isMobileDataConnected = true, isMobileDataConnected = false,
) )
} }
Timber.i("Mobile data capabilities changed") Timber.i("Lost mobile data connection")
} }
}
}
}
}
is NetworkStatus.Unavailable -> { private suspend fun watchForPingFailure() {
networkEventsFlow.update { val context = this
it.copy( withContext(ioDispatcher) {
isMobileDataConnected = false, try {
) do {
} if (vpnService.vpnState.value.status == TunnelState.UP) {
Timber.i("Lost mobile data connection") val tunnelConfig = vpnService.vpnState.value.tunnelConfig
} tunnelConfig?.let {
} val config = TunnelConfig.configFromWgQuick(it.wgQuick)
} val results =
} config.peers.map { peer ->
} val host =
if (peer.endpoint.isPresent &&
peer.endpoint.get().resolved.isPresent
) {
peer.endpoint.get().resolved.get().host
} else {
Constants.DEFAULT_PING_IP
}
Timber.i("Checking reachability of: $host")
val reachable =
InetAddress.getByName(host)
.isReachable(Constants.PING_TIMEOUT.toInt())
Timber.i("Result: reachable - $reachable")
reachable
}
if (results.contains(false)) {
Timber.i("Restarting VPN for ping failure")
serviceManager.stopVpnServiceForeground(context)
delay(Constants.VPN_RESTART_DELAY)
serviceManager.startVpnServiceForeground(context, it.id)
delay(Constants.PING_COOLDOWN)
}
}
}
delay(Constants.PING_INTERVAL)
} while (true)
} catch (e: Exception) {
Timber.e(e)
}
}
}
private suspend fun watchForPingFailure() { private suspend fun watchForSettingsChanges() {
val context = this appDataRepository.settings.getSettingsFlow().collect { settings ->
withContext(ioDispatcher) { if (networkEventsFlow.value.settings.isAutoTunnelPaused
try { != settings.isAutoTunnelPaused
do { ) {
if (vpnService.vpnState.value.status == TunnelState.UP) { when (settings.isAutoTunnelPaused) {
val tunnelConfig = vpnService.vpnState.value.tunnelConfig true -> launchWatcherPausedNotification()
tunnelConfig?.let { false -> launchWatcherNotification()
val config = TunnelConfig.configFromWgQuick(it.wgQuick) }
val results = config.peers.map { peer -> }
val host = if (peer.endpoint.isPresent && networkEventsFlow.update {
peer.endpoint.get().resolved.isPresent) it.copy(
peer.endpoint.get().resolved.get().host settings = settings,
else Constants.DEFAULT_PING_IP )
Timber.i("Checking reachability of: $host") }
val reachable = InetAddress.getByName(host) }
.isReachable(Constants.PING_TIMEOUT.toInt()) }
Timber.i("Result: reachable - $reachable")
reachable
}
if (results.contains(false)) {
Timber.i("Restarting VPN for ping failure")
serviceManager.stopVpnServiceForeground(context)
delay(Constants.VPN_RESTART_DELAY)
serviceManager.startVpnServiceForeground(context, it.id)
delay(Constants.PING_COOLDOWN)
}
}
}
delay(Constants.PING_INTERVAL)
} while (true)
} catch (e: Exception) {
Timber.e(e)
}
}
}
private suspend fun watchForSettingsChanges() { private suspend fun watchForEthernetConnectivityChanges() {
appDataRepository.settings.getSettingsFlow().collect { settings -> withContext(ioDispatcher) {
if (networkEventsFlow.value.settings.isAutoTunnelPaused != settings.isAutoTunnelPaused) { ethernetService.networkStatus.collect { status ->
when (settings.isAutoTunnelPaused) { when (status) {
true -> launchWatcherPausedNotification() is NetworkStatus.Available -> {
false -> launchWatcherNotification() Timber.i("Gained Ethernet connection")
} networkEventsFlow.update {
} it.copy(
networkEventsFlow.update { isEthernetConnected = true,
it.copy( )
settings = settings, }
) }
}
}
}
private suspend fun watchForEthernetConnectivityChanges() { is NetworkStatus.CapabilitiesChanged -> {
withContext(ioDispatcher) { Timber.i("Ethernet capabilities changed")
ethernetService.networkStatus.collect { status -> networkEventsFlow.update {
when (status) { it.copy(
is NetworkStatus.Available -> { isEthernetConnected = true,
Timber.i("Gained Ethernet connection") )
networkEventsFlow.update { }
it.copy( }
isEthernetConnected = true,
)
}
}
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.Unavailable -> {
Timber.i("Ethernet capabilities changed") networkEventsFlow.update {
networkEventsFlow.update { it.copy(
it.copy( isEthernetConnected = false,
isEthernetConnected = true, )
) }
} Timber.i("Lost Ethernet connection")
} }
}
}
}
}
is NetworkStatus.Unavailable -> { private suspend fun watchForWifiConnectivityChanges() {
networkEventsFlow.update { withContext(ioDispatcher) {
it.copy( wifiService.networkStatus.collect { status ->
isEthernetConnected = false, when (status) {
) is NetworkStatus.Available -> {
} Timber.i("Gained Wi-Fi connection")
Timber.i("Lost Ethernet connection") networkEventsFlow.update {
} it.copy(
} isWifiConnected = true,
} )
} }
} }
private suspend fun watchForWifiConnectivityChanges() { is NetworkStatus.CapabilitiesChanged -> {
withContext(ioDispatcher) { Timber.i("Wifi capabilities changed")
wifiService.networkStatus.collect { status -> networkEventsFlow.update {
when (status) { it.copy(
is NetworkStatus.Available -> { isWifiConnected = true,
Timber.i("Gained Wi-Fi connection") )
networkEventsFlow.update { }
it.copy( val ssid = wifiService.getNetworkName(status.networkCapabilities)
isWifiConnected = true, ssid?.let { name ->
) if (name.contains(Constants.UNREADABLE_SSID)) {
} Timber.w("SSID unreadable: missing permissions")
} } else {
Timber.i("Detected valid SSID")
}
appDataRepository.appState.setCurrentSsid(name)
networkEventsFlow.update {
it.copy(
currentNetworkSSID = name,
)
}
} ?: Timber.w("Failed to read ssid")
}
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.Unavailable -> {
Timber.i("Wifi capabilities changed") networkEventsFlow.update {
networkEventsFlow.update { it.copy(
it.copy( isWifiConnected = false,
isWifiConnected = true, )
) }
} Timber.i("Lost Wi-Fi connection")
val ssid = wifiService.getNetworkName(status.networkCapabilities) }
ssid?.let { name -> }
if (name.contains(Constants.UNREADABLE_SSID)) { }
Timber.w("SSID unreadable: missing permissions") }
} else Timber.i("Detected valid SSID") }
appDataRepository.appState.setCurrentSsid(name)
networkEventsFlow.update {
it.copy(
currentNetworkSSID = name,
)
}
} ?: Timber.w("Failed to read ssid")
}
is NetworkStatus.Unavailable -> { private suspend fun getMobileDataTunnel(): TunnelConfig? {
networkEventsFlow.update { return appDataRepository.tunnels.findByMobileDataTunnel().firstOrNull()
it.copy( }
isWifiConnected = false,
)
}
Timber.i("Lost Wi-Fi connection")
}
}
}
}
}
private suspend fun getMobileDataTunnel(): TunnelConfig? { private suspend fun getSsidTunnel(ssid: String): TunnelConfig? {
return appDataRepository.tunnels.findByMobileDataTunnel().firstOrNull() return appDataRepository.tunnels.findByTunnelNetworksName(ssid).firstOrNull()
} }
private suspend fun getSsidTunnel(ssid: String): TunnelConfig? { private fun isTunnelDown(): Boolean {
return appDataRepository.tunnels.findByTunnelNetworksName(ssid).firstOrNull() return vpnService.vpnState.value.status == TunnelState.DOWN
} }
private fun isTunnelDown(): Boolean { private suspend fun manageVpn() {
return vpnService.vpnState.value.status == TunnelState.DOWN val context = this
} withContext(ioDispatcher) {
networkEventsFlow.collectLatest { watcherState ->
val autoTunnel = "Auto-tunnel watcher"
if (!watcherState.settings.isAutoTunnelPaused) {
// delay for rapid network state changes and then collect latest
delay(Constants.WATCHER_COLLECTION_DELAY)
val tunnelConfig = vpnService.vpnState.value.tunnelConfig
when {
watcherState.isEthernetConditionMet() -> {
Timber.i("$autoTunnel - tunnel on on ethernet condition met")
if (isTunnelDown()) serviceManager.startVpnServiceForeground(context)
}
private suspend fun manageVpn() { watcherState.isMobileDataConditionMet() -> {
val context = this Timber.i("$autoTunnel - tunnel on mobile data condition met")
withContext(ioDispatcher) { val mobileDataTunnel = getMobileDataTunnel()
networkEventsFlow.collectLatest { watcherState -> val tunnel =
val autoTunnel = "Auto-tunnel watcher" mobileDataTunnel ?: appDataRepository.getPrimaryOrFirstTunnel()
if (!watcherState.settings.isAutoTunnelPaused) { if (isTunnelDown() || tunnelConfig?.isMobileDataTunnel == false) {
//delay for rapid network state changes and then collect latest serviceManager.startVpnServiceForeground(
delay(Constants.WATCHER_COLLECTION_DELAY) context,
val tunnelConfig = vpnService.vpnState.value.tunnelConfig tunnel?.id,
when { )
watcherState.isEthernetConditionMet() -> { }
Timber.i("$autoTunnel - tunnel on on ethernet condition met") }
if (isTunnelDown()) serviceManager.startVpnServiceForeground(context)
}
watcherState.isMobileDataConditionMet() -> { watcherState.isTunnelOffOnMobileDataConditionMet() -> {
Timber.i("$autoTunnel - tunnel on mobile data condition met") Timber.i("$autoTunnel - tunnel off on mobile data met, turning vpn off")
val mobileDataTunnel = getMobileDataTunnel() if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context)
val tunnel = }
mobileDataTunnel ?: appDataRepository.getPrimaryOrFirstTunnel()
if (isTunnelDown() || tunnelConfig?.isMobileDataTunnel == false) {
serviceManager.startVpnServiceForeground(
context,
tunnel?.id,
)
}
}
watcherState.isTunnelOffOnMobileDataConditionMet() -> { watcherState.isUntrustedWifiConditionMet() -> {
Timber.i("$autoTunnel - tunnel off on mobile data met, turning vpn off") if (tunnelConfig?.tunnelNetworks?.contains(watcherState.currentNetworkSSID) == false ||
if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context) tunnelConfig == null
} ) {
Timber.i(
"$autoTunnel - tunnel on ssid not associated with current tunnel condition met",
)
getSsidTunnel(watcherState.currentNetworkSSID)?.let {
Timber.i("Found tunnel associated with this SSID, bringing tunnel up: ${it.name}")
if (isTunnelDown() || tunnelConfig?.id != it.id) {
serviceManager.startVpnServiceForeground(
context,
it.id,
)
}
} ?: suspend {
Timber.i("No tunnel associated with this SSID, using defaults")
val default = appDataRepository.getPrimaryOrFirstTunnel()
if (default?.name != vpnService.name) {
default?.let {
serviceManager.startVpnServiceForeground(context, it.id)
}
}
}.invoke()
}
}
watcherState.isUntrustedWifiConditionMet() -> { watcherState.isTrustedWifiConditionMet() -> {
if (tunnelConfig?.tunnelNetworks?.contains(watcherState.currentNetworkSSID) == false || Timber.i(
tunnelConfig == null) { "$autoTunnel - tunnel off on trusted wifi condition met, turning vpn off",
Timber.i("$autoTunnel - tunnel on ssid not associated with current tunnel condition met") )
getSsidTunnel(watcherState.currentNetworkSSID)?.let { if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context)
Timber.i("Found tunnel associated with this SSID, bringing tunnel up: ${it.name}") }
if (isTunnelDown() || tunnelConfig?.id != it.id) serviceManager.startVpnServiceForeground(
context,
it.id,
)
} ?: suspend {
Timber.i("No tunnel associated with this SSID, using defaults")
val default = appDataRepository.getPrimaryOrFirstTunnel()
if (default?.name != vpnService.name) {
default?.let {
serviceManager.startVpnServiceForeground(context, it.id)
}
} watcherState.isTunnelOffOnWifiConditionMet() -> {
}.invoke() Timber.i(
} "$autoTunnel - tunnel off on wifi condition met, turning vpn off",
} )
if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context)
}
watcherState.isTrustedWifiConditionMet() -> { watcherState.isTunnelOffOnNoConnectivityMet() -> {
Timber.i("$autoTunnel - tunnel off on trusted wifi condition met, turning vpn off") Timber.i(
if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context) "$autoTunnel - tunnel off on no connectivity met, turning vpn off",
} )
if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context)
}
watcherState.isTunnelOffOnWifiConditionMet() -> { else -> {
Timber.i("$autoTunnel - tunnel off on wifi condition met, turning vpn off") Timber.i("$autoTunnel - no condition met")
if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context) }
} }
}
watcherState.isTunnelOffOnNoConnectivityMet() -> { }
Timber.i("$autoTunnel - tunnel off on no connectivity met, turning vpn off") }
if (!isTunnelDown()) serviceManager.stopVpnServiceForeground(context) }
}
else -> {
Timber.i("$autoTunnel - no condition met")
}
}
}
}
}
}
} }
@@ -29,173 +29,170 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardTunnelService : ForegroundService() { class WireGuardTunnelService : ForegroundService() {
private val foregroundId = 123 private val foregroundId = 123
@Inject @Inject
lateinit var vpnService: VpnService lateinit var vpnService: VpnService
@Inject @Inject
lateinit var appDataRepository: AppDataRepository lateinit var appDataRepository: AppDataRepository
@Inject @Inject
lateinit var notificationService: NotificationService lateinit var notificationService: NotificationService
@Inject @Inject
@MainImmediateDispatcher @MainImmediateDispatcher
lateinit var mainImmediateDispatcher: CoroutineDispatcher lateinit var mainImmediateDispatcher: CoroutineDispatcher
@Inject @Inject
@IoDispatcher @IoDispatcher
lateinit var ioDispatcher: CoroutineDispatcher lateinit var ioDispatcher: CoroutineDispatcher
private var job: Job? = null private var job: Job? = null
private var didShowConnected = false private var didShowConnected = false
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
lifecycleScope.launch(mainImmediateDispatcher) { lifecycleScope.launch(mainImmediateDispatcher) {
//TODO fix this to not launch if AOVPN // TODO fix this to not launch if AOVPN
if (appDataRepository.tunnels.count() != 0) { if (appDataRepository.tunnels.count() != 0) {
launchVpnNotification() launchVpnNotification()
} }
} }
} }
override fun startService(extras: Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
cancelJob() cancelJob()
job = job =
lifecycleScope.launch { lifecycleScope.launch {
launch { launch {
val tunnelId = extras?.getInt(Constants.TUNNEL_EXTRA_KEY) val tunnelId = extras?.getInt(Constants.TUNNEL_EXTRA_KEY)
if (vpnService.getState() == TunnelState.UP) { if (vpnService.getState() == TunnelState.UP) {
vpnService.stopTunnel() vpnService.stopTunnel()
} }
vpnService.startTunnel( vpnService.startTunnel(
tunnelId?.let { tunnelId?.let {
appDataRepository.tunnels.getById(it) appDataRepository.tunnels.getById(it)
}, },
) )
} }
launch { launch {
handshakeNotifications() handshakeNotifications()
} }
} }
} }
//TODO improve tunnel notifications // TODO improve tunnel notifications
private suspend fun handshakeNotifications() { private suspend fun handshakeNotifications() {
withContext(ioDispatcher) { withContext(ioDispatcher) {
var tunnelName: String? = null var tunnelName: String? = null
vpnService.vpnState.collect { state -> vpnService.vpnState.collect { state ->
state.statistics state.statistics
?.mapPeerStats() ?.mapPeerStats()
?.map { it.value?.handshakeStatus() } ?.map { it.value?.handshakeStatus() }
.let { statuses -> .let { statuses ->
when { when {
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> { statuses?.all { it == HandshakeStatus.HEALTHY } == true -> {
if (!didShowConnected) { if (!didShowConnected) {
delay(Constants.VPN_CONNECTED_NOTIFICATION_DELAY) delay(Constants.VPN_CONNECTED_NOTIFICATION_DELAY)
tunnelName = state.tunnelConfig?.name tunnelName = state.tunnelConfig?.name
launchVpnNotification( launchVpnNotification(
getString(R.string.tunnel_start_title), getString(R.string.tunnel_start_title),
"${getString(R.string.tunnel_start_text)} - $tunnelName", "${getString(R.string.tunnel_start_text)} - $tunnelName",
) )
didShowConnected = true didShowConnected = true
} }
} }
statuses?.any { it == HandshakeStatus.STALE } == true -> {} statuses?.any { it == HandshakeStatus.STALE } == true -> {}
statuses?.all { it == HandshakeStatus.NOT_STARTED } == statuses?.all { it == HandshakeStatus.NOT_STARTED } ==
true -> { true -> {
} }
else -> {} else -> {}
} }
} }
if (state.status == TunnelState.UP && state.tunnelConfig?.name != tunnelName) { if (state.status == TunnelState.UP && state.tunnelConfig?.name != tunnelName) {
tunnelName = state.tunnelConfig?.name tunnelName = state.tunnelConfig?.name
launchVpnNotification( launchVpnNotification(
getString(R.string.tunnel_start_title), getString(R.string.tunnel_start_title),
"${getString(R.string.tunnel_start_text)} - $tunnelName", "${getString(R.string.tunnel_start_text)} - $tunnelName",
) )
} }
} }
} }
} }
private fun launchAlwaysOnDisabledNotification() { private fun launchAlwaysOnDisabledNotification() {
launchVpnNotification( launchVpnNotification(
title = this.getString(R.string.vpn_connection_failed), title = this.getString(R.string.vpn_connection_failed),
description = this.getString(R.string.always_on_disabled), description = this.getString(R.string.always_on_disabled),
) )
} }
override fun stopService() { override fun stopService() {
super.stopService() super.stopService()
lifecycleScope.launch { lifecycleScope.launch {
vpnService.stopTunnel() vpnService.stopTunnel()
didShowConnected = false didShowConnected = false
} }
cancelJob() cancelJob()
stopSelf() stopSelf()
} }
private fun launchVpnNotification( private fun launchVpnNotification(title: String = getString(R.string.vpn_starting), description: String = getString(R.string.attempt_connection)) {
title: String = getString(R.string.vpn_starting), val notification =
description: String = getString(R.string.attempt_connection) notificationService.createNotification(
) { channelId = getString(R.string.vpn_channel_id),
val notification = channelName = getString(R.string.vpn_channel_name),
notificationService.createNotification( title = title,
channelId = getString(R.string.vpn_channel_id), onGoing = false,
channelName = getString(R.string.vpn_channel_name), vibration = false,
title = title, showTimestamp = true,
onGoing = false, description = description,
vibration = false, )
showTimestamp = true, ServiceCompat.startForeground(
description = description, this,
) foregroundId,
ServiceCompat.startForeground( notification,
this, Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
foregroundId, )
notification, }
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
)
}
private fun launchVpnConnectionFailedNotification(message: String) { private fun launchVpnConnectionFailedNotification(message: String) {
val notification = val notification =
notificationService.createNotification( notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
action = action =
PendingIntent.getBroadcast( PendingIntent.getBroadcast(
this, this,
0, 0,
Intent(this, NotificationActionReceiver::class.java), Intent(this, NotificationActionReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_IMMUTABLE,
), ),
actionText = getString(R.string.restart), actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed), title = getString(R.string.vpn_connection_failed),
onGoing = false, onGoing = false,
vibration = true, vibration = true,
showTimestamp = true, showTimestamp = true,
description = message, description = message,
) )
ServiceCompat.startForeground( ServiceCompat.startForeground(
this, this,
foregroundId, foregroundId,
notification, notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID, Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
) )
} }
private fun cancelJob() { private fun cancelJob() {
try { try {
job?.cancel() job?.cancel()
} catch (e: CancellationException) { } catch (e: CancellationException) {
Timber.i("Tunnel job cancelled") Timber.i("Tunnel job cancelled")
} }
} }
} }
@@ -15,118 +15,113 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
abstract class BaseNetworkService<T : BaseNetworkService<T>>( abstract class BaseNetworkService<T : BaseNetworkService<T>>(
val context: Context, val context: Context,
networkCapability: Int networkCapability: Int,
) : NetworkService<T> { ) : NetworkService<T> {
private val connectivityManager = private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val wifiManager = private val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
override val networkStatus = callbackFlow { override val networkStatus =
val networkStatusCallback = callbackFlow {
when (Build.VERSION.SDK_INT) { val networkStatusCallback =
in Build.VERSION_CODES.S..Int.MAX_VALUE -> { when (Build.VERSION.SDK_INT) {
object : in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
ConnectivityManager.NetworkCallback( object :
FLAG_INCLUDE_LOCATION_INFO, ConnectivityManager.NetworkCallback(
) { FLAG_INCLUDE_LOCATION_INFO,
override fun onAvailable(network: Network) { ) {
trySend(NetworkStatus.Available(network)) override fun onAvailable(network: Network) {
} trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) { override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network)) trySend(NetworkStatus.Unavailable(network))
} }
override fun onCapabilitiesChanged( override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
network: Network, trySend(
networkCapabilities: NetworkCapabilities NetworkStatus.CapabilitiesChanged(
) { network,
trySend( networkCapabilities,
NetworkStatus.CapabilitiesChanged( ),
network, )
networkCapabilities, }
), }
) }
}
}
}
else -> { else -> {
object : ConnectivityManager.NetworkCallback() { object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network)) trySend(NetworkStatus.Available(network))
} }
override fun onLost(network: Network) { override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network)) trySend(NetworkStatus.Unavailable(network))
} }
override fun onCapabilitiesChanged( override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
network: Network, trySend(
networkCapabilities: NetworkCapabilities NetworkStatus.CapabilitiesChanged(
) { network,
trySend( networkCapabilities,
NetworkStatus.CapabilitiesChanged( ),
network, )
networkCapabilities, }
), }
) }
} }
} val request =
} NetworkRequest.Builder()
} .addTransportType(networkCapability)
val request = .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
NetworkRequest.Builder() .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.addTransportType(networkCapability) .build()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) connectivityManager.registerNetworkCallback(request, networkStatusCallback)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) } awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) }
} }
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? { override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid: String? = getWifiNameFromCapabilities(networkCapabilities) var ssid: String? = getWifiNameFromCapabilities(networkCapabilities)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
val info = wifiManager.connectionInfo val info = wifiManager.connectionInfo
if (info.supplicantState === SupplicantState.COMPLETED) { if (info.supplicantState === SupplicantState.COMPLETED) {
ssid = info.ssid ssid = info.ssid
} }
} }
return ssid?.trim('"') return ssid?.trim('"')
} }
companion object { companion object {
private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? { private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val info: WifiInfo val info: WifiInfo
if (networkCapabilities.transportInfo is WifiInfo) { if (networkCapabilities.transportInfo is WifiInfo) {
info = networkCapabilities.transportInfo as WifiInfo info = networkCapabilities.transportInfo as WifiInfo
return info.ssid return info.ssid
} }
} }
return null return null
} }
} }
} }
inline fun <Result> Flow<NetworkStatus>.map( inline fun <Result> Flow<NetworkStatus>.map(
crossinline onUnavailable: suspend (network: Network) -> Result, crossinline onUnavailable: suspend (network: Network) -> Result,
crossinline onAvailable: suspend (network: Network) -> Result, crossinline onAvailable: suspend (network: Network) -> Result,
crossinline onCapabilitiesChanged: crossinline onCapabilitiesChanged:
suspend (network: Network, networkCapabilities: NetworkCapabilities) -> Result suspend (network: Network, networkCapabilities: NetworkCapabilities) -> Result,
): Flow<Result> = map { status -> ): Flow<Result> = map { status ->
when (status) { when (status) {
is NetworkStatus.Unavailable -> onUnavailable(status.network) is NetworkStatus.Unavailable -> onUnavailable(status.network)
is NetworkStatus.Available -> onAvailable(status.network) is NetworkStatus.Available -> onAvailable(status.network)
is NetworkStatus.CapabilitiesChanged -> is NetworkStatus.CapabilitiesChanged ->
onCapabilitiesChanged( onCapabilitiesChanged(
status.network, status.network,
status.networkCapabilities, status.networkCapabilities,
) )
} }
} }
@@ -5,5 +5,9 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class EthernetService @Inject constructor(@ApplicationContext context: Context) : class EthernetService
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET) @Inject
constructor(
@ApplicationContext context: Context,
) :
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET)
@@ -5,5 +5,9 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class MobileDataService @Inject constructor(@ApplicationContext context: Context) : class MobileDataService
BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR) @Inject
constructor(
@ApplicationContext context: Context,
) :
BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR)
@@ -4,7 +4,7 @@ import android.net.NetworkCapabilities
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface NetworkService<T> { interface NetworkService<T> {
fun getNetworkName(networkCapabilities: NetworkCapabilities): String? fun getNetworkName(networkCapabilities: NetworkCapabilities): String?
val networkStatus: Flow<NetworkStatus> val networkStatus: Flow<NetworkStatus>
} }
@@ -4,10 +4,10 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
sealed class NetworkStatus { sealed class NetworkStatus {
class Available(val network: Network) : NetworkStatus() class Available(val network: Network) : NetworkStatus()
class Unavailable(val network: Network) : NetworkStatus() class Unavailable(val network: Network) : NetworkStatus()
class CapabilitiesChanged(val network: Network, val networkCapabilities: NetworkCapabilities) : class CapabilitiesChanged(val network: Network, val networkCapabilities: NetworkCapabilities) :
NetworkStatus() NetworkStatus()
} }
@@ -5,5 +5,9 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WifiService @Inject constructor(@ApplicationContext context: Context) : class WifiService
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI) @Inject
constructor(
@ApplicationContext context: Context,
) :
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI)
@@ -5,18 +5,18 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
interface NotificationService { interface NotificationService {
fun createNotification( fun createNotification(
channelId: String, channelId: String,
channelName: String, channelName: String,
title: String = "", title: String = "",
action: PendingIntent? = null, action: PendingIntent? = null,
actionText: String? = null, actionText: String? = null,
description: String, description: String,
showTimestamp: Boolean = false, showTimestamp: Boolean = false,
importance: Int = NotificationManager.IMPORTANCE_HIGH, importance: Int = NotificationManager.IMPORTANCE_HIGH,
vibration: Boolean = false, vibration: Boolean = false,
onGoing: Boolean = true, onGoing: Boolean = true,
lights: Boolean = true, lights: Boolean = true,
onlyAlertOnce: Boolean = true, onlyAlertOnce: Boolean = true,
): Notification ): Notification
} }
@@ -9,94 +9,97 @@ import android.content.Intent
import android.graphics.Color import android.graphics.Color
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.MainActivity
import com.zaneschepke.wireguardautotunnel.ui.SplashActivity import com.zaneschepke.wireguardautotunnel.ui.SplashActivity
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) : class WireGuardNotification
NotificationService { @Inject
private val notificationManager = constructor(
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @ApplicationContext private val context: Context,
) :
NotificationService {
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val watcherBuilder: NotificationCompat.Builder = private val watcherBuilder: NotificationCompat.Builder =
NotificationCompat.Builder( NotificationCompat.Builder(
context, context,
context.getString(R.string.watcher_channel_id), context.getString(R.string.watcher_channel_id),
) )
private val tunnelBuilder: NotificationCompat.Builder = private val tunnelBuilder: NotificationCompat.Builder =
NotificationCompat.Builder( NotificationCompat.Builder(
context, context,
context.getString(R.string.vpn_channel_id), context.getString(R.string.vpn_channel_id),
) )
override fun createNotification( override fun createNotification(
channelId: String, channelId: String,
channelName: String, channelName: String,
title: String, title: String,
action: PendingIntent?, action: PendingIntent?,
actionText: String?, actionText: String?,
description: String, description: String,
showTimestamp: Boolean, showTimestamp: Boolean,
importance: Int, importance: Int,
vibration: Boolean, vibration: Boolean,
onGoing: Boolean, onGoing: Boolean,
lights: Boolean, lights: Boolean,
onlyAlertOnce: Boolean, onlyAlertOnce: Boolean,
): Notification { ): Notification {
val channel = val channel =
NotificationChannel( NotificationChannel(
channelId, channelId,
channelName, channelName,
importance, importance,
) )
.let { .let {
it.description = title it.description = title
it.enableLights(lights) it.enableLights(lights)
it.lightColor = Color.RED it.lightColor = Color.RED
it.enableVibration(vibration) it.enableVibration(vibration)
it.vibrationPattern = longArrayOf(100, 200, 300) it.vibrationPattern = longArrayOf(100, 200, 300)
it it
} }
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
val pendingIntent: PendingIntent = val pendingIntent: PendingIntent =
Intent(context, SplashActivity::class.java).let { notificationIntent -> Intent(context, SplashActivity::class.java).let { notificationIntent ->
PendingIntent.getActivity( PendingIntent.getActivity(
context, context,
0, 0,
notificationIntent, notificationIntent,
PendingIntent.FLAG_IMMUTABLE, PendingIntent.FLAG_IMMUTABLE,
) )
} }
val builder = val builder =
when (channelId) { when (channelId) {
context.getString(R.string.watcher_channel_id) -> watcherBuilder context.getString(R.string.watcher_channel_id) -> watcherBuilder
context.getString(R.string.vpn_channel_id) -> tunnelBuilder context.getString(R.string.vpn_channel_id) -> tunnelBuilder
else -> { else -> {
NotificationCompat.Builder( NotificationCompat.Builder(
context, context,
channelId, channelId,
) )
} }
} }
return builder.let { return builder.let {
if (action != null && actionText != null) { if (action != null && actionText != null) {
it.addAction( it.addAction(
NotificationCompat.Action.Builder(0, actionText, action).build(), NotificationCompat.Action.Builder(0, actionText, action).build(),
) )
it.setAutoCancel(true) it.setAutoCancel(true)
} }
it.setContentTitle(title) it.setContentTitle(title)
.setContentText(description) .setContentText(description)
.setOnlyAlertOnce(onlyAlertOnce) .setOnlyAlertOnce(onlyAlertOnce)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setOngoing(onGoing) .setOngoing(onGoing)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setShowWhen(showTimestamp) .setShowWhen(showTimestamp)
.setSmallIcon(R.drawable.ic_launcher) .setSmallIcon(R.drawable.ic_launcher)
.build() .build()
} }
} }
} }
@@ -15,65 +15,71 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ShortcutsActivity : ComponentActivity() { class ShortcutsActivity : ComponentActivity() {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject @Inject
lateinit var appDataRepository: AppDataRepository lateinit var serviceManager: ServiceManager
@Inject @Inject
lateinit var serviceManager: ServiceManager @ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject override fun onCreate(savedInstanceState: Bundle?) {
@ApplicationScope super.onCreate(savedInstanceState)
lateinit var applicationScope: CoroutineScope applicationScope.launch {
val settings = appDataRepository.settings.getSettings()
if (settings.isShortcutsEnabled) {
when (intent.getStringExtra(CLASS_NAME_EXTRA_KEY)) {
WireGuardTunnelService::class.java.simpleName -> {
val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
val tunnelConfig =
tunnelName?.let {
appDataRepository.tunnels.getAll().firstOrNull {
it.name == tunnelName
}
}
when (intent.action) {
Action.START.name ->
serviceManager.startVpnServiceForeground(
this@ShortcutsActivity,
tunnelConfig?.id,
isManualStart = true,
)
override fun onCreate(savedInstanceState: Bundle?) { Action.STOP.name ->
super.onCreate(savedInstanceState) serviceManager.stopVpnServiceForeground(
applicationScope.launch { this@ShortcutsActivity,
val settings = appDataRepository.settings.getSettings() isManualStop = true,
if (settings.isShortcutsEnabled) { )
when (intent.getStringExtra(CLASS_NAME_EXTRA_KEY)) { }
WireGuardTunnelService::class.java.simpleName -> { }
val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
val tunnelConfig = tunnelName?.let {
appDataRepository.tunnels.getAll().firstOrNull {
it.name == tunnelName
}
}
when (intent.action) {
Action.START.name -> serviceManager.startVpnServiceForeground(
this@ShortcutsActivity, tunnelConfig?.id, isManualStart = true,
)
Action.STOP.name -> serviceManager.stopVpnServiceForeground( WireGuardConnectivityWatcherService::class.java.simpleName -> {
this@ShortcutsActivity, when (intent.action) {
isManualStop = true, Action.START.name ->
) appDataRepository.settings.save(
} settings.copy(
} isAutoTunnelPaused = false,
),
)
WireGuardConnectivityWatcherService::class.java.simpleName -> { Action.STOP.name ->
when (intent.action) { appDataRepository.settings.save(
Action.START.name -> appDataRepository.settings.save( settings.copy(
settings.copy( isAutoTunnelPaused = true,
isAutoTunnelPaused = false, ),
), )
) }
}
}
}
}
finish()
}
Action.STOP.name -> appDataRepository.settings.save( companion object {
settings.copy( const val TUNNEL_NAME_EXTRA_KEY = "tunnelName"
isAutoTunnelPaused = true, const val CLASS_NAME_EXTRA_KEY = "className"
), }
)
}
}
}
}
}
finish()
}
companion object {
const val TUNNEL_NAME_EXTRA_KEY = "tunnelName"
const val CLASS_NAME_EXTRA_KEY = "className"
}
} }
@@ -10,11 +10,8 @@ import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.module.ServiceScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -22,86 +19,86 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class AutoTunnelControlTile : TileService(), LifecycleOwner { class AutoTunnelControlTile : TileService(), LifecycleOwner {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject @Inject
lateinit var appDataRepository: AppDataRepository lateinit var serviceManager: ServiceManager
@Inject private val dispatcher = ServiceLifecycleDispatcher(this)
lateinit var serviceManager: ServiceManager
private val dispatcher = ServiceLifecycleDispatcher(this) private var manualStartConfig: TunnelConfig? = null
private var manualStartConfig: TunnelConfig? = null override fun onStartListening() {
super.onStartListening()
lifecycleScope.launch {
val settings = appDataRepository.settings.getSettings()
when (settings.isAutoTunnelEnabled) {
true -> {
if (settings.isAutoTunnelPaused) {
setInactive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.paused))
} else {
setActive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.active))
}
}
override fun onStartListening() { false -> {
super.onStartListening() setTileDescription(this@AutoTunnelControlTile.getString(R.string.disabled))
lifecycleScope.launch { setUnavailable()
val settings = appDataRepository.settings.getSettings() }
when (settings.isAutoTunnelEnabled) { }
true -> { }
if (settings.isAutoTunnelPaused) { }
setInactive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.paused))
} else {
setActive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.active))
}
}
false -> {
setTileDescription(this@AutoTunnelControlTile.getString(R.string.disabled))
setUnavailable()
}
}
}
}
override fun onTileAdded() { override fun onTileAdded() {
super.onTileAdded() super.onTileAdded()
onStartListening() onStartListening()
} }
override fun onClick() { override fun onClick() {
super.onClick() super.onClick()
unlockAndRun { unlockAndRun {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
appDataRepository.toggleWatcherServicePause() appDataRepository.toggleWatcherServicePause()
onStartListening() onStartListening()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
} finally { } finally {
cancel() cancel()
} }
} }
} }
} }
private fun setActive() { private fun setActive() {
qsTile.state = Tile.STATE_ACTIVE qsTile.state = Tile.STATE_ACTIVE
qsTile.updateTile() qsTile.updateTile()
} }
private fun setInactive() { private fun setInactive() {
qsTile.state = Tile.STATE_INACTIVE qsTile.state = Tile.STATE_INACTIVE
qsTile.updateTile() qsTile.updateTile()
} }
private fun setUnavailable() { private fun setUnavailable() {
manualStartConfig = null manualStartConfig = null
qsTile.state = Tile.STATE_UNAVAILABLE qsTile.state = Tile.STATE_UNAVAILABLE
qsTile.updateTile() qsTile.updateTile()
} }
private fun setTileDescription(description: String) { private fun setTileDescription(description: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
qsTile.subtitle = description qsTile.subtitle = description
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = description qsTile.stateDescription = description
} }
qsTile.updateTile() qsTile.updateTile()
} }
override val lifecycle: Lifecycle override val lifecycle: Lifecycle
get() = dispatcher.lifecycle get() = dispatcher.lifecycle
} }
@@ -5,17 +5,14 @@ import android.service.quicksettings.Tile
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.ServiceLifecycleDispatcher import androidx.lifecycle.ServiceLifecycleDispatcher
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.module.ApplicationScope
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState import com.zaneschepke.wireguardautotunnel.service.tunnel.TunnelState
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
@@ -23,100 +20,102 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TunnelControlTile : TileService(), LifecycleOwner { class TunnelControlTile : TileService(), LifecycleOwner {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject @Inject
lateinit var appDataRepository: AppDataRepository lateinit var vpnService: VpnService
@Inject @Inject
lateinit var vpnService: VpnService lateinit var serviceManager: ServiceManager
@Inject private val dispatcher = ServiceLifecycleDispatcher(this)
lateinit var serviceManager: ServiceManager
private val dispatcher = ServiceLifecycleDispatcher(this) private var manualStartConfig: TunnelConfig? = null
private var manualStartConfig: TunnelConfig? = null override fun onStartListening() {
super.onStartListening()
Timber.d("On start listening called")
lifecycleScope.launch {
when (vpnService.getState()) {
TunnelState.UP -> {
setActive()
setTileDescription(vpnService.name)
}
override fun onStartListening() { TunnelState.DOWN -> {
super.onStartListening() setInactive()
Timber.d("On start listening called") val config =
lifecycleScope.launch { appDataRepository.getStartTunnelConfig()?.also { config ->
when (vpnService.getState()) { manualStartConfig = config
TunnelState.UP -> { } ?: appDataRepository.getPrimaryOrFirstTunnel()
setActive() config?.let {
setTileDescription(vpnService.name) setTileDescription(it.name)
} } ?: setUnavailable()
}
TunnelState.DOWN -> { else -> setInactive()
setInactive() }
val config = appDataRepository.getStartTunnelConfig()?.also { config -> }
manualStartConfig = config }
} ?: appDataRepository.getPrimaryOrFirstTunnel()
config?.let {
setTileDescription(it.name)
} ?: setUnavailable()
}
else -> setInactive() override fun onTileAdded() {
} super.onTileAdded()
} onStartListening()
} }
override fun onTileAdded() { override fun onClick() {
super.onTileAdded() super.onClick()
onStartListening() unlockAndRun {
} lifecycleScope.launch {
try {
if (vpnService.getState() == TunnelState.UP) {
serviceManager.stopVpnServiceForeground(
this@TunnelControlTile,
isManualStop = true,
)
} else {
serviceManager.startVpnServiceForeground(
this@TunnelControlTile,
manualStartConfig?.id,
isManualStart = true,
)
}
} catch (e: Exception) {
Timber.e(e.message)
} finally {
cancel()
}
}
}
}
override fun onClick() { private fun setActive() {
super.onClick() qsTile.state = Tile.STATE_ACTIVE
unlockAndRun { qsTile.updateTile()
lifecycleScope.launch { }
try {
if (vpnService.getState() == TunnelState.UP) {
serviceManager.stopVpnServiceForeground(
this@TunnelControlTile,
isManualStop = true,
)
} else {
serviceManager.startVpnServiceForeground(
this@TunnelControlTile, manualStartConfig?.id, isManualStart = true,
)
}
} catch (e: Exception) {
Timber.e(e.message)
} finally {
cancel()
}
}
}
}
private fun setActive() { private fun setInactive() {
qsTile.state = Tile.STATE_ACTIVE qsTile.state = Tile.STATE_INACTIVE
qsTile.updateTile() qsTile.updateTile()
} }
private fun setInactive() { private fun setUnavailable() {
qsTile.state = Tile.STATE_INACTIVE manualStartConfig = null
qsTile.updateTile() qsTile.state = Tile.STATE_UNAVAILABLE
} qsTile.updateTile()
}
private fun setUnavailable() { private fun setTileDescription(description: String) {
manualStartConfig = null if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
qsTile.state = Tile.STATE_UNAVAILABLE qsTile.subtitle = description
qsTile.updateTile() }
} if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = description
}
qsTile.updateTile()
}
private fun setTileDescription(description: String) { override val lifecycle: Lifecycle
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { get() = dispatcher.lifecycle
qsTile.subtitle = description
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = description
}
qsTile.updateTile()
}
override val lifecycle: Lifecycle
get() = dispatcher.lifecycle
} }
@@ -1,16 +1,17 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel package com.zaneschepke.wireguardautotunnel.service.tunnel
enum class HandshakeStatus { enum class HandshakeStatus {
HEALTHY, HEALTHY,
STALE, STALE,
UNKNOWN, UNKNOWN,
NOT_STARTED; NOT_STARTED,
;
companion object { companion object {
private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 180 private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 180
const val STATUS_CHANGE_TIME_BUFFER = 30 const val STATUS_CHANGE_TIME_BUFFER = 30
const val STALE_TIME_LIMIT_SEC = const val STALE_TIME_LIMIT_SEC =
WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + STATUS_CHANGE_TIME_BUFFER WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + STATUS_CHANGE_TIME_BUFFER
const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30 const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30
} }
} }
@@ -3,41 +3,42 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
enum class TunnelState { enum class TunnelState {
UP, UP,
DOWN, DOWN,
TOGGLE; TOGGLE,
;
fun toWgState(): Tunnel.State { fun toWgState(): Tunnel.State {
return when (this) { return when (this) {
UP -> Tunnel.State.UP UP -> Tunnel.State.UP
DOWN -> Tunnel.State.DOWN DOWN -> Tunnel.State.DOWN
TOGGLE -> Tunnel.State.TOGGLE TOGGLE -> Tunnel.State.TOGGLE
} }
} }
fun toAmState(): org.amnezia.awg.backend.Tunnel.State { fun toAmState(): org.amnezia.awg.backend.Tunnel.State {
return when (this) { return when (this) {
UP -> org.amnezia.awg.backend.Tunnel.State.UP UP -> org.amnezia.awg.backend.Tunnel.State.UP
DOWN -> org.amnezia.awg.backend.Tunnel.State.DOWN DOWN -> org.amnezia.awg.backend.Tunnel.State.DOWN
TOGGLE -> org.amnezia.awg.backend.Tunnel.State.TOGGLE TOGGLE -> org.amnezia.awg.backend.Tunnel.State.TOGGLE
} }
} }
companion object { companion object {
fun from(state: Tunnel.State): TunnelState { fun from(state: Tunnel.State): TunnelState {
return when (state) { return when (state) {
Tunnel.State.DOWN -> DOWN Tunnel.State.DOWN -> DOWN
Tunnel.State.TOGGLE -> TOGGLE Tunnel.State.TOGGLE -> TOGGLE
Tunnel.State.UP -> UP Tunnel.State.UP -> UP
} }
} }
fun from(state: org.amnezia.awg.backend.Tunnel.State): TunnelState { fun from(state: org.amnezia.awg.backend.Tunnel.State): TunnelState {
return when (state) { return when (state) {
org.amnezia.awg.backend.Tunnel.State.DOWN -> DOWN org.amnezia.awg.backend.Tunnel.State.DOWN -> DOWN
org.amnezia.awg.backend.Tunnel.State.TOGGLE -> TOGGLE org.amnezia.awg.backend.Tunnel.State.TOGGLE -> TOGGLE
org.amnezia.awg.backend.Tunnel.State.UP -> UP org.amnezia.awg.backend.Tunnel.State.UP -> UP
} }
} }
} }
} }
@@ -5,11 +5,11 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
interface VpnService : Tunnel, org.amnezia.awg.backend.Tunnel { interface VpnService : Tunnel, org.amnezia.awg.backend.Tunnel {
suspend fun startTunnel(tunnelConfig: TunnelConfig? = null): TunnelState suspend fun startTunnel(tunnelConfig: TunnelConfig? = null): TunnelState
suspend fun stopTunnel() suspend fun stopTunnel()
val vpnState: StateFlow<VpnState> val vpnState: StateFlow<VpnState>
fun getState(): TunnelState fun getState(): TunnelState
} }
@@ -4,7 +4,7 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics import com.zaneschepke.wireguardautotunnel.service.tunnel.statistics.TunnelStatistics
data class VpnState( data class VpnState(
val status: TunnelState = TunnelState.DOWN, val status: TunnelState = TunnelState.DOWN,
val tunnelConfig: TunnelConfig? = null, val tunnelConfig: TunnelConfig? = null,
val statistics: TunnelStatistics? = null val statistics: TunnelStatistics? = null,
) )
@@ -32,191 +32,202 @@ import javax.inject.Provider
class WireGuardTunnel class WireGuardTunnel
@Inject @Inject
constructor( constructor(
private val userspaceAmneziaBackend: Provider<org.amnezia.awg.backend.Backend>, private val userspaceAmneziaBackend: Provider<org.amnezia.awg.backend.Backend>,
@Userspace private val userspaceBackend: Provider<Backend>, @Userspace private val userspaceBackend: Provider<Backend>,
@Kernel private val kernelBackend: Provider<Backend>, @Kernel private val kernelBackend: Provider<Backend>,
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
@ApplicationScope private val applicationScope: CoroutineScope, @ApplicationScope private val applicationScope: CoroutineScope,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : VpnService { ) : VpnService {
private val _vpnState = MutableStateFlow(VpnState()) private val _vpnState = MutableStateFlow(VpnState())
override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow() override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow()
private var statsJob: Job? = null
private var statsJob: Job? = null private var backendIsWgUserspace = true
private var backendIsWgUserspace = true private var backendIsAmneziaUserspace = false
private var backendIsAmneziaUserspace = false init {
applicationScope.launch(ioDispatcher) {
appDataRepository.settings.getSettingsFlow().collect {
if (it.isKernelEnabled && (backendIsWgUserspace || backendIsAmneziaUserspace)) {
Timber.i("Setting kernel backend")
backendIsWgUserspace = false
backendIsAmneziaUserspace = false
} else if (!it.isKernelEnabled && !it.isAmneziaEnabled && !backendIsWgUserspace) {
Timber.i("Setting WireGuard userspace backend")
backendIsWgUserspace = true
backendIsAmneziaUserspace = false
} else if (it.isAmneziaEnabled && !backendIsAmneziaUserspace) {
Timber.i("Setting Amnezia userspace backend")
backendIsAmneziaUserspace = true
backendIsWgUserspace = false
}
}
}
}
init { private fun setState(tunnelConfig: TunnelConfig?, tunnelState: TunnelState): TunnelState {
applicationScope.launch(ioDispatcher) { return if (backendIsAmneziaUserspace) {
appDataRepository.settings.getSettingsFlow().collect { Timber.i("Using Amnezia backend")
if (it.isKernelEnabled && (backendIsWgUserspace || backendIsAmneziaUserspace)) { val config =
Timber.i("Setting kernel backend") tunnelConfig?.let {
backendIsWgUserspace = false if (it.amQuick != "") {
backendIsAmneziaUserspace = false TunnelConfig.configFromAmQuick(it.amQuick)
} else if (!it.isKernelEnabled && !it.isAmneziaEnabled && !backendIsWgUserspace) { } else {
Timber.i("Setting WireGuard userspace backend") Timber.w(
backendIsWgUserspace = true "Using backwards compatible wg config, amnezia specific config not found.",
backendIsAmneziaUserspace = false )
} else if (it.isAmneziaEnabled && !backendIsAmneziaUserspace) { TunnelConfig.configFromAmQuick(it.wgQuick)
Timber.i("Setting Amnezia userspace backend") }
backendIsAmneziaUserspace = true }
backendIsWgUserspace = false val state =
} userspaceAmneziaBackend.get().setState(this, tunnelState.toAmState(), config)
} TunnelState.from(state)
} } else {
} Timber.i("Using Wg backend")
val wgConfig = tunnelConfig?.let { TunnelConfig.configFromWgQuick(it.wgQuick) }
val state =
backend().setState(
this,
tunnelState.toWgState(),
wgConfig,
)
TunnelState.from(state)
}
}
private fun setState(tunnelConfig: TunnelConfig?, tunnelState: TunnelState): TunnelState { override suspend fun startTunnel(tunnelConfig: TunnelConfig?): TunnelState {
return if (backendIsAmneziaUserspace) { return withContext(ioDispatcher) {
Timber.i("Using Amnezia backend") try {
val config = tunnelConfig?.let { // TODO we need better error handling here
if (it.amQuick != "") TunnelConfig.configFromAmQuick(it.amQuick) else { // need to bubble up these errors to the UI
Timber.w("Using backwards compatible wg config, amnezia specific config not found.") val config = tunnelConfig ?: appDataRepository.getPrimaryOrFirstTunnel()
TunnelConfig.configFromAmQuick(it.wgQuick) if (config != null) {
} emitTunnelConfig(config)
} setState(config, TunnelState.UP)
val state = } else {
userspaceAmneziaBackend.get().setState(this, tunnelState.toAmState(), config) throw Exception("No tunnels")
TunnelState.from(state) }
} else { } catch (e: BackendException) {
Timber.i("Using Wg backend") Timber.e("Failed to start tunnel with error: ${e.message}")
val wgConfig = tunnelConfig?.let { TunnelConfig.configFromWgQuick(it.wgQuick) } TunnelState.from(State.DOWN)
val state = backend().setState( }
this, }
tunnelState.toWgState(), }
wgConfig,
)
TunnelState.from(state)
}
}
override suspend fun startTunnel(tunnelConfig: TunnelConfig?): TunnelState { private fun backend(): Backend {
return withContext(ioDispatcher) { return when {
try { backendIsWgUserspace -> {
//TODO we need better error handling here userspaceBackend.get()
// need to bubble up these errors to the UI }
val config = tunnelConfig ?: appDataRepository.getPrimaryOrFirstTunnel()
if (config != null) {
emitTunnelConfig(config)
setState(config, TunnelState.UP)
} else throw Exception("No tunnels")
} catch (e: BackendException) {
Timber.e("Failed to start tunnel with error: ${e.message}")
TunnelState.from(State.DOWN)
}
}
}
private fun backend(): Backend { !backendIsWgUserspace && !backendIsAmneziaUserspace -> {
return when { kernelBackend.get()
backendIsWgUserspace -> { }
userspaceBackend.get()
}
!backendIsWgUserspace && !backendIsAmneziaUserspace -> { else -> {
kernelBackend.get() userspaceBackend.get()
} }
}
}
else -> { private fun emitTunnelState(state: TunnelState) {
userspaceBackend.get() _vpnState.tryEmit(
} _vpnState.value.copy(
} status = state,
} ),
)
}
private fun emitTunnelState(state: TunnelState) { private fun emitBackendStatistics(statistics: TunnelStatistics) {
_vpnState.tryEmit( _vpnState.tryEmit(
_vpnState.value.copy( _vpnState.value.copy(
status = state, statistics = statistics,
), ),
) )
} }
private fun emitBackendStatistics(statistics: TunnelStatistics) { private suspend fun emitTunnelConfig(tunnelConfig: TunnelConfig?) {
_vpnState.tryEmit( _vpnState.emit(
_vpnState.value.copy( _vpnState.value.copy(
statistics = statistics, tunnelConfig = tunnelConfig,
), ),
) )
} }
private suspend fun emitTunnelConfig(tunnelConfig: TunnelConfig?) { private fun resetVpnState() {
_vpnState.emit( _vpnState.tryEmit(VpnState())
_vpnState.value.copy( }
tunnelConfig = tunnelConfig,
),
)
}
private fun resetVpnState() { override suspend fun stopTunnel() {
_vpnState.tryEmit(VpnState()) withContext(ioDispatcher) {
} try {
if (getState() == TunnelState.UP) {
val state = setState(null, TunnelState.DOWN)
resetVpnState()
emitTunnelState(state)
}
} catch (e: BackendException) {
Timber.e("Failed to stop wireguard tunnel with error: ${e.message}")
} catch (e: org.amnezia.awg.backend.BackendException) {
Timber.e("Failed to stop amnezia tunnel with error: ${e.message}")
}
}
}
override suspend fun stopTunnel() { override fun getState(): TunnelState {
withContext(ioDispatcher) { return if (backendIsAmneziaUserspace) {
try { TunnelState.from(
if (getState() == TunnelState.UP) { userspaceAmneziaBackend.get().getState(this),
val state = setState(null, TunnelState.DOWN) )
resetVpnState() } else {
emitTunnelState(state) TunnelState.from(backend().getState(this))
} }
} catch (e: BackendException) { }
Timber.e("Failed to stop wireguard tunnel with error: ${e.message}")
} catch (e: org.amnezia.awg.backend.BackendException) {
Timber.e("Failed to stop amnezia tunnel with error: ${e.message}")
}
}
}
override fun getState(): TunnelState { override fun getName(): String {
return if (backendIsAmneziaUserspace) TunnelState.from( return _vpnState.value.tunnelConfig?.name ?: ""
userspaceAmneziaBackend.get().getState(this), }
)
else TunnelState.from(backend().getState(this))
}
override fun getName(): String { override fun onStateChange(newState: Tunnel.State) {
return _vpnState.value.tunnelConfig?.name ?: "" handleStateChange(TunnelState.from(newState))
} }
private fun handleStateChange(state: TunnelState) {
emitTunnelState(state)
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate()
if (state == TunnelState.UP) {
statsJob = startTunnelStatisticsJob()
}
if (state == TunnelState.DOWN) {
try {
statsJob?.cancel()
} catch (e: CancellationException) {
Timber.i("Stats job cancelled")
}
}
}
override fun onStateChange(newState: Tunnel.State) { private fun startTunnelStatisticsJob() = applicationScope.launch(ioDispatcher) {
handleStateChange(TunnelState.from(newState)) while (true) {
} if (backendIsAmneziaUserspace) {
emitBackendStatistics(
AmneziaStatistics(
userspaceAmneziaBackend.get().getStatistics(this@WireGuardTunnel),
),
)
} else {
emitBackendStatistics(
WireGuardStatistics(backend().getStatistics(this@WireGuardTunnel)),
)
}
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
}
}
private fun handleStateChange(state: TunnelState) { override fun onStateChange(state: State) {
emitTunnelState(state) handleStateChange(TunnelState.from(state))
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate() }
if (state == TunnelState.UP) {
statsJob = startTunnelStatisticsJob()
}
if (state == TunnelState.DOWN) {
try {
statsJob?.cancel()
} catch (e: CancellationException) {
Timber.i("Stats job cancelled")
}
}
}
private fun startTunnelStatisticsJob() = applicationScope.launch(ioDispatcher) {
while (true) {
if (backendIsAmneziaUserspace) {
emitBackendStatistics(
AmneziaStatistics(
userspaceAmneziaBackend.get().getStatistics(this@WireGuardTunnel),
),
)
} else {
emitBackendStatistics(WireGuardStatistics(backend().getStatistics(this@WireGuardTunnel)))
}
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
}
}
override fun onStateChange(state: State) {
handleStateChange(TunnelState.from(state))
}
} }
@@ -4,31 +4,31 @@ import org.amnezia.awg.backend.Statistics
import org.amnezia.awg.crypto.Key import org.amnezia.awg.crypto.Key
class AmneziaStatistics(private val statistics: Statistics) : TunnelStatistics() { class AmneziaStatistics(private val statistics: Statistics) : TunnelStatistics() {
override fun peerStats(peer: Key): PeerStats? { override fun peerStats(peer: Key): PeerStats? {
val key = Key.fromBase64(peer.toBase64()) val key = Key.fromBase64(peer.toBase64())
val stats = statistics.peer(key) val stats = statistics.peer(key)
return stats?.let { return stats?.let {
PeerStats( PeerStats(
rxBytes = stats.rxBytes, rxBytes = stats.rxBytes,
txBytes = stats.txBytes, txBytes = stats.txBytes,
latestHandshakeEpochMillis = stats.latestHandshakeEpochMillis, latestHandshakeEpochMillis = stats.latestHandshakeEpochMillis,
) )
} }
} }
override fun isTunnelStale(): Boolean { override fun isTunnelStale(): Boolean {
return statistics.isStale return statistics.isStale
} }
override fun getPeers(): Array<Key> { override fun getPeers(): Array<Key> {
return statistics.peers() return statistics.peers()
} }
override fun rx(): Long { override fun rx(): Long {
return statistics.totalRx() return statistics.totalRx()
} }
override fun tx(): Long { override fun tx(): Long {
return statistics.totalTx() return statistics.totalTx()
} }
} }
@@ -3,16 +3,16 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel.statistics
import org.amnezia.awg.crypto.Key import org.amnezia.awg.crypto.Key
abstract class TunnelStatistics { abstract class TunnelStatistics {
@JvmRecord @JvmRecord
data class PeerStats(val rxBytes: Long, val txBytes: Long, val latestHandshakeEpochMillis: Long) data class PeerStats(val rxBytes: Long, val txBytes: Long, val latestHandshakeEpochMillis: Long)
abstract fun peerStats(peer: Key): PeerStats? abstract fun peerStats(peer: Key): PeerStats?
abstract fun isTunnelStale(): Boolean abstract fun isTunnelStale(): Boolean
abstract fun getPeers(): Array<Key> abstract fun getPeers(): Array<Key>
abstract fun rx(): Long abstract fun rx(): Long
abstract fun tx(): Long abstract fun tx(): Long
} }
@@ -4,33 +4,33 @@ import com.wireguard.android.backend.Statistics
import org.amnezia.awg.crypto.Key import org.amnezia.awg.crypto.Key
class WireGuardStatistics(private val statistics: Statistics) : TunnelStatistics() { class WireGuardStatistics(private val statistics: Statistics) : TunnelStatistics() {
override fun peerStats(peer: Key): PeerStats? { override fun peerStats(peer: Key): PeerStats? {
val key = com.wireguard.crypto.Key.fromBase64(peer.toBase64()) val key = com.wireguard.crypto.Key.fromBase64(peer.toBase64())
val peerStats = statistics.peer(key) val peerStats = statistics.peer(key)
return peerStats?.let { return peerStats?.let {
PeerStats( PeerStats(
txBytes = peerStats.txBytes, txBytes = peerStats.txBytes,
rxBytes = peerStats.rxBytes, rxBytes = peerStats.rxBytes,
latestHandshakeEpochMillis = peerStats.latestHandshakeEpochMillis, latestHandshakeEpochMillis = peerStats.latestHandshakeEpochMillis,
) )
} }
} }
override fun isTunnelStale(): Boolean { override fun isTunnelStale(): Boolean {
return statistics.isStale return statistics.isStale
} }
override fun getPeers(): Array<Key> { override fun getPeers(): Array<Key> {
return statistics.peers().map { return statistics.peers().map {
Key.fromBase64(it.toBase64()) Key.fromBase64(it.toBase64())
}.toTypedArray() }.toTypedArray()
} }
override fun rx(): Long { override fun rx(): Long {
return statistics.totalRx() return statistics.totalRx()
} }
override fun tx(): Long { override fun tx(): Long {
return statistics.totalTx() return statistics.totalTx()
} }
} }
@@ -1,9 +1,9 @@
package com.zaneschepke.wireguardautotunnel.ui package com.zaneschepke.wireguardautotunnel.ui
data class AppUiState( data class AppUiState(
val snackbarMessage: String = "", val snackbarMessage: String = "",
val snackbarMessageConsumed: Boolean = true, val snackbarMessageConsumed: Boolean = true,
val vpnPermissionAccepted: Boolean = false, val vpnPermissionAccepted: Boolean = false,
val notificationPermissionAccepted: Boolean = false, val notificationPermissionAccepted: Boolean = false,
val requestPermissions: Boolean = false val requestPermissions: Boolean = false,
) )
@@ -20,104 +20,104 @@ import javax.inject.Inject
class AppViewModel class AppViewModel
@Inject @Inject
constructor() : ViewModel() { constructor() : ViewModel() {
val vpnIntent: Intent? = GoBackend.VpnService.prepare(WireGuardAutoTunnel.instance)
val vpnIntent: Intent? = GoBackend.VpnService.prepare(WireGuardAutoTunnel.instance) private val _appUiState =
MutableStateFlow(
AppUiState(
vpnPermissionAccepted = vpnIntent == null,
),
)
val appUiState = _appUiState.asStateFlow()
private val _appUiState = MutableStateFlow( fun isRequiredPermissionGranted(): Boolean {
AppUiState( val allAccepted =
vpnPermissionAccepted = vpnIntent == null, (_appUiState.value.vpnPermissionAccepted && _appUiState.value.vpnPermissionAccepted)
), if (!allAccepted) requestPermissions()
) return allAccepted
val appUiState = _appUiState.asStateFlow() }
private fun requestPermissions() {
_appUiState.update {
it.copy(
requestPermissions = true,
)
}
}
fun isRequiredPermissionGranted(): Boolean { fun permissionsRequested() {
val allAccepted = _appUiState.update {
(_appUiState.value.vpnPermissionAccepted && _appUiState.value.vpnPermissionAccepted) it.copy(
if (!allAccepted) requestPermissions() requestPermissions = false,
return allAccepted )
} }
}
private fun requestPermissions() { fun openWebPage(url: String, context: Context) {
_appUiState.update { try {
it.copy( val webpage: Uri = Uri.parse(url)
requestPermissions = true, val intent =
) Intent(Intent.ACTION_VIEW, webpage).apply {
} addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Timber.e(e)
showSnackbarMessage(context.getString(R.string.no_browser_detected))
}
}
fun permissionsRequested() { fun onVpnPermissionAccepted() {
_appUiState.update { _appUiState.update {
it.copy( it.copy(
requestPermissions = false, vpnPermissionAccepted = true,
) )
} }
} }
fun openWebPage(url: String, context: Context) { fun launchEmail(context: Context) {
try { try {
val webpage: Uri = Uri.parse(url) val intent =
val intent = Intent(Intent.ACTION_VIEW, webpage).apply { Intent(Intent.ACTION_SENDTO).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) type = Constants.EMAIL_MIME_TYPE
} putExtra(Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.my_email)))
context.startActivity(intent) putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject))
} catch (e: ActivityNotFoundException) { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
Timber.e(e) }
showSnackbarMessage(context.getString(R.string.no_browser_detected)) context.startActivity(
} Intent.createChooser(intent, context.getString(R.string.email_chooser)).apply {
} addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
} catch (e: ActivityNotFoundException) {
Timber.e(e)
showSnackbarMessage(context.getString(R.string.no_email_detected))
}
}
fun onVpnPermissionAccepted() { fun showSnackbarMessage(message: String) {
_appUiState.update { _appUiState.update {
it.copy( it.copy(
vpnPermissionAccepted = true, snackbarMessage = message,
) snackbarMessageConsumed = false,
} )
} }
}
fun launchEmail(context: Context) { fun snackbarMessageConsumed() {
try { _appUiState.update {
val intent = it.copy(
Intent(Intent.ACTION_SENDTO).apply { snackbarMessage = "",
type = Constants.EMAIL_MIME_TYPE snackbarMessageConsumed = true,
putExtra(Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.my_email))) )
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject)) }
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }
}
context.startActivity(
Intent.createChooser(intent, context.getString(R.string.email_chooser)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
} catch (e: ActivityNotFoundException) {
Timber.e(e)
showSnackbarMessage(context.getString(R.string.no_email_detected))
}
}
fun showSnackbarMessage(message: String) { fun setNotificationPermissionAccepted(accepted: Boolean) {
_appUiState.update { _appUiState.update {
it.copy( it.copy(
snackbarMessage = message, notificationPermissionAccepted = accepted,
snackbarMessageConsumed = false, )
) }
} }
}
fun snackbarMessageConsumed() {
_appUiState.update {
it.copy(
snackbarMessage = "",
snackbarMessageConsumed = true,
)
}
}
fun setNotificationPermissionAccepted(accepted: Boolean) {
_appUiState.update {
it.copy(
notificationPermissionAccepted = accepted,
)
}
}
} }
@@ -10,6 +10,8 @@ import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -19,15 +21,19 @@ import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -41,11 +47,13 @@ import androidx.navigation.navArgument
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
@@ -65,225 +73,255 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@Inject
lateinit var appStateRepository: AppStateRepository
@Inject @Inject
lateinit var appStateRepository: AppStateRepository lateinit var settingsRepository: SettingsRepository
@Inject @Inject
lateinit var settingsRepository: SettingsRepository lateinit var serviceManager: ServiceManager
@Inject @OptIn(
lateinit var serviceManager: ServiceManager ExperimentalPermissionsApi::class,
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@OptIn( val isPinLockEnabled = intent.extras?.getBoolean(SplashActivity.IS_PIN_LOCK_ENABLED_KEY)
ExperimentalPermissionsApi::class,
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val isPinLockEnabled = intent.extras?.getBoolean(SplashActivity.IS_PIN_LOCK_ENABLED_KEY) enableEdgeToEdge(navigationBarStyle = SystemBarStyle.dark(Color.Transparent.toArgb()))
enableEdgeToEdge(navigationBarStyle = SystemBarStyle.dark(Color.Transparent.toArgb())) lifecycleScope.launch {
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate()
val settings = settingsRepository.getSettings()
if (settings.isAutoTunnelEnabled) {
serviceManager.startWatcherService(application.applicationContext)
}
}
lifecycleScope.launch { setContent {
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate() val appViewModel = hiltViewModel<AppViewModel>()
val settings = settingsRepository.getSettings() val appUiState by appViewModel.appUiState.collectAsStateWithLifecycle()
if (settings.isAutoTunnelEnabled) { val navController = rememberNavController()
serviceManager.startWatcherService(application.applicationContext) val navBackStackEntry by navController.currentBackStackEntryAsState()
} var showVpnPermissionDialog by remember { mutableStateOf(false) }
}
setContent { val notificationPermissionState =
val appViewModel = hiltViewModel<AppViewModel>() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val appUiState by appViewModel.appUiState.collectAsStateWithLifecycle() rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
val navController = rememberNavController() } else {
val navBackStackEntry by navController.currentBackStackEntryAsState() null
}
val notificationPermissionState = val snackbarHostState = remember { SnackbarHostState() }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) else null
val snackbarHostState = remember { SnackbarHostState() } val vpnActivityResultState =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
val accepted = (it.resultCode == RESULT_OK)
if (accepted) {
appViewModel.onVpnPermissionAccepted()
} else {
showVpnPermissionDialog = true
}
},
)
val vpnActivityResultState = fun showSnackBarMessage(message: StringValue) {
rememberLauncherForActivityResult( lifecycleScope.launch(Dispatchers.Main) {
ActivityResultContracts.StartActivityForResult(), val result =
onResult = { snackbarHostState.showSnackbar(
val accepted = (it.resultCode == RESULT_OK) message = message.asString(this@MainActivity),
if (accepted) { duration = SnackbarDuration.Short,
appViewModel.onVpnPermissionAccepted() )
} when (result) {
}, SnackbarResult.ActionPerformed,
) SnackbarResult.Dismissed,
-> {
snackbarHostState.currentSnackbarData?.dismiss()
}
}
}
}
fun showSnackBarMessage(message: StringValue) { LaunchedEffect(appUiState.requestPermissions) {
lifecycleScope.launch(Dispatchers.Main) { if (appUiState.requestPermissions) {
val result = appViewModel.permissionsRequested()
snackbarHostState.showSnackbar( if (notificationPermissionState != null && !notificationPermissionState.status.isGranted
message = message.asString(this@MainActivity), ) {
duration = SnackbarDuration.Short, notificationPermissionState.launchPermissionRequest()
) return@LaunchedEffect if (notificationPermissionState.status.shouldShowRationale || !notificationPermissionState.status.isGranted) {
when (result) { showSnackBarMessage(
SnackbarResult.ActionPerformed, StringValue.StringResource(R.string.notification_permission_required),
SnackbarResult.Dismissed -> { )
snackbarHostState.currentSnackbarData?.dismiss() } else {
} Unit
} }
} }
} if (!appUiState.vpnPermissionAccepted) {
return@LaunchedEffect appViewModel.vpnIntent?.let {
vpnActivityResultState.launch(
it,
)
} ?: Unit
}
}
}
LaunchedEffect(appUiState.requestPermissions) { WireguardAutoTunnelTheme {
if (appUiState.requestPermissions) { LaunchedEffect(Unit) {
appViewModel.permissionsRequested() appViewModel.setNotificationPermissionAccepted(
if (notificationPermissionState != null && !notificationPermissionState.status.isGranted notificationPermissionState?.status?.isGranted ?: true,
) { )
showSnackBarMessage(StringValue.StringResource(R.string.notification_permission_required)) }
return@LaunchedEffect notificationPermissionState.launchPermissionRequest()
}
if (!appUiState.vpnPermissionAccepted) {
return@LaunchedEffect appViewModel.vpnIntent?.let {
vpnActivityResultState.launch(
it,
)
}!!
}
}
}
WireguardAutoTunnelTheme { LaunchedEffect(appUiState.snackbarMessageConsumed) {
LaunchedEffect(Unit) { if (!appUiState.snackbarMessageConsumed) {
appViewModel.setNotificationPermissionAccepted( showSnackBarMessage(StringValue.DynamicString(appUiState.snackbarMessage))
notificationPermissionState?.status?.isGranted ?: true, appViewModel.snackbarMessageConsumed()
) }
} }
LaunchedEffect(appUiState.snackbarMessageConsumed) { val focusRequester = remember { FocusRequester() }
if (!appUiState.snackbarMessageConsumed) {
showSnackBarMessage(StringValue.DynamicString(appUiState.snackbarMessage))
appViewModel.snackbarMessageConsumed()
}
}
val focusRequester = remember { FocusRequester() } if (showVpnPermissionDialog) {
InfoDialog(
onDismiss = { showVpnPermissionDialog = false },
onAttest = { showVpnPermissionDialog = false },
title = { Text(text = stringResource(R.string.vpn_denied_dialog_title)) },
body = {
Column(verticalArrangement = Arrangement.spacedBy(15.dp)) {
Text(text = stringResource(R.string.vpn_denied_dialog_message))
Text(text = stringResource(R.string.vpn_denied_dialog_message2))
}
},
confirmText = { Text(text = stringResource(R.string.okay)) },
)
}
Scaffold( Scaffold(
snackbarHost = { snackbarHost = {
SnackbarHost(snackbarHostState) { snackbarData: SnackbarData -> SnackbarHost(snackbarHostState) { snackbarData: SnackbarData ->
CustomSnackBar( CustomSnackBar(
snackbarData.visuals.message, snackbarData.visuals.message,
isRtl = false, isRtl = false,
containerColor = containerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation( MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp, 2.dp,
), ),
) )
} }
}, },
//TODO refactor // TODO refactor
modifier = Modifier modifier =
.focusable() Modifier
.focusProperties { .focusable()
when (navBackStackEntry?.destination?.route) { .focusProperties {
Screen.Lock.route -> Unit when (navBackStackEntry?.destination?.route) {
else -> up = focusRequester Screen.Lock.route -> Unit
} else -> up = focusRequester
}, }
bottomBar = { },
BottomNavBar( bottomBar = {
navController, BottomNavBar(
listOf( navController,
Screen.Main.navItem, listOf(
Screen.Settings.navItem, Screen.Main.navItem,
Screen.Support.navItem, Screen.Settings.navItem,
), Screen.Support.navItem,
) ),
}, )
) { padding -> },
NavHost( ) { padding ->
navController, NavHost(
startDestination = (if (isPinLockEnabled == true) Screen.Lock.route else Screen.Main.route), navController,
modifier = Modifier startDestination = (if (isPinLockEnabled == true) Screen.Lock.route else Screen.Main.route),
.padding(padding) modifier =
.fillMaxSize(), Modifier
) { .padding(padding)
composable( .fillMaxSize(),
Screen.Main.route, ) {
) { composable(
MainScreen( Screen.Main.route,
focusRequester = focusRequester, ) {
appViewModel = appViewModel, MainScreen(
navController = navController, focusRequester = focusRequester,
) appViewModel = appViewModel,
} navController = navController,
composable( )
Screen.Settings.route, }
) { composable(
SettingsScreen( Screen.Settings.route,
appViewModel = appViewModel, ) {
navController = navController, SettingsScreen(
focusRequester = focusRequester, appViewModel = appViewModel,
) navController = navController,
} focusRequester = focusRequester,
composable( )
Screen.Support.route, }
) { composable(
SupportScreen( Screen.Support.route,
focusRequester = focusRequester, ) {
appViewModel = appViewModel, SupportScreen(
navController = navController, focusRequester = focusRequester,
) appViewModel = appViewModel,
} navController = navController,
composable(Screen.Support.Logs.route) { )
LogsScreen() }
} composable(Screen.Support.Logs.route) {
composable( LogsScreen()
"${Screen.Config.route}/{id}?configType={configType}", }
arguments = composable(
listOf( "${Screen.Config.route}/{id}?configType={configType}",
navArgument("id") { arguments =
type = NavType.StringType listOf(
defaultValue = "0" navArgument("id") {
}, type = NavType.StringType
navArgument("configType") { defaultValue = "0"
type = NavType.StringType },
defaultValue = ConfigType.WIREGUARD.name navArgument("configType") {
}, type = NavType.StringType
), defaultValue = ConfigType.WIREGUARD.name
) { },
val id = it.arguments?.getString("id") ),
val configType = ConfigType.valueOf( ) {
it.arguments?.getString("configType") ?: ConfigType.WIREGUARD.name, val id = it.arguments?.getString("id")
) val configType =
if (!id.isNullOrBlank()) { ConfigType.valueOf(
ConfigScreen( it.arguments?.getString("configType") ?: ConfigType.WIREGUARD.name,
navController = navController, )
tunnelId = id, if (!id.isNullOrBlank()) {
appViewModel = appViewModel, ConfigScreen(
focusRequester = focusRequester, navController = navController,
configType = configType, tunnelId = id,
) appViewModel = appViewModel,
} focusRequester = focusRequester,
} configType = configType,
composable("${Screen.Option.route}/{id}") { )
val id = it.arguments?.getString("id") }
if (!id.isNullOrBlank()) { }
OptionsScreen( composable("${Screen.Option.route}/{id}") {
navController = navController, val id = it.arguments?.getString("id")
tunnelId = id, if (!id.isNullOrBlank()) {
appViewModel = appViewModel, OptionsScreen(
focusRequester = focusRequester, navController = navController,
) tunnelId = id,
} appViewModel = appViewModel,
} focusRequester = focusRequester,
composable(Screen.Lock.route) { )
PinLockScreen( }
navController = navController, }
appViewModel = appViewModel, composable(Screen.Lock.route) {
) PinLockScreen(
} navController = navController,
} appViewModel = appViewModel,
} )
} }
} }
} }
}
}
}
} }
@@ -9,37 +9,38 @@ import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
sealed class Screen(val route: String) { sealed class Screen(val route: String) {
data object Main : Screen("main") { data object Main : Screen("main") {
val navItem = val navItem =
BottomNavItem( BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.tunnels), name = WireGuardAutoTunnel.instance.getString(R.string.tunnels),
route = route, route = route,
icon = Icons.Rounded.Home, icon = Icons.Rounded.Home,
) )
} }
data object Settings : Screen("settings") { data object Settings : Screen("settings") {
val navItem = val navItem =
BottomNavItem( BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.settings), name = WireGuardAutoTunnel.instance.getString(R.string.settings),
route = route, route = route,
icon = Icons.Rounded.Settings, icon = Icons.Rounded.Settings,
) )
} }
data object Support : Screen("support") { data object Support : Screen("support") {
val navItem = val navItem =
BottomNavItem( BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.support), name = WireGuardAutoTunnel.instance.getString(R.string.support),
route = route, route = route,
icon = Icons.Rounded.QuestionMark, icon = Icons.Rounded.QuestionMark,
) )
data object Logs : Screen("support/logs") data object Logs : Screen("support/logs")
} }
data object Config : Screen("config") data object Config : Screen("config")
data object Lock : Screen("lock")
data object Option : Screen("option") data object Lock : Screen("lock")
data object Option : Screen("option")
} }
@@ -23,46 +23,45 @@ import javax.inject.Inject
@SuppressLint("CustomSplashScreen") @SuppressLint("CustomSplashScreen")
@AndroidEntryPoint @AndroidEntryPoint
class SplashActivity : ComponentActivity() { class SplashActivity : ComponentActivity() {
@Inject
lateinit var appStateRepository: AppStateRepository
@Inject @Inject
lateinit var appStateRepository: AppStateRepository lateinit var localLogCollector: LocalLogCollector
@Inject @Inject
lateinit var localLogCollector: LocalLogCollector @ApplicationScope
lateinit var applicationScope: CoroutineScope
@Inject override fun onCreate(savedInstanceState: Bundle?) {
@ApplicationScope if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
lateinit var applicationScope: CoroutineScope val splashScreen = installSplashScreen()
splashScreen.setKeepOnScreenCondition { true }
}
super.onCreate(savedInstanceState)
override fun onCreate(savedInstanceState: Bundle?) { applicationScope.launch {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (!isRunningOnAndroidTv()) localLogCollector.start()
val splashScreen = installSplashScreen() }
splashScreen.setKeepOnScreenCondition { true }
}
super.onCreate(savedInstanceState)
applicationScope.launch { lifecycleScope.launch {
if (!isRunningOnAndroidTv()) localLogCollector.start() repeatOnLifecycle(Lifecycle.State.CREATED) {
} val pinLockEnabled = appStateRepository.isPinLockEnabled()
if (pinLockEnabled) {
PinManager.initialize(WireGuardAutoTunnel.instance)
}
lifecycleScope.launch { val intent =
repeatOnLifecycle(Lifecycle.State.CREATED) { Intent(this@SplashActivity, MainActivity::class.java).apply {
val pinLockEnabled = appStateRepository.isPinLockEnabled() putExtra(IS_PIN_LOCK_ENABLED_KEY, pinLockEnabled)
if (pinLockEnabled) { }
PinManager.initialize(WireGuardAutoTunnel.instance) startActivity(intent)
} finish()
}
}
}
val intent = Intent(this@SplashActivity, MainActivity::class.java).apply { companion object {
putExtra(IS_PIN_LOCK_ENABLED_KEY, pinLockEnabled) const val IS_PIN_LOCK_ENABLED_KEY = "is_pin_lock_enabled"
} }
startActivity(intent)
finish()
}
}
}
companion object {
const val IS_PIN_LOCK_ENABLED_KEY = "is_pin_lock_enabled"
}
} }
@@ -12,31 +12,25 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
@Composable @Composable
fun ClickableIconButton( fun ClickableIconButton(onClick: () -> Unit, onIconClick: () -> Unit, text: String, icon: ImageVector, enabled: Boolean) {
onClick: () -> Unit, TextButton(
onIconClick: () -> Unit, onClick = onClick,
text: String, enabled = enabled,
icon: ImageVector, ) {
enabled: Boolean Text(text, Modifier.weight(1f, false))
) { Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
TextButton( Icon(
onClick = onClick, imageVector = icon,
enabled = enabled, contentDescription = icon.name,
) { modifier =
Text(text, Modifier.weight(1f, false)) Modifier
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) .size(ButtonDefaults.IconSize)
Icon( .weight(1f, false)
imageVector = icon, .clickable {
contentDescription = icon.name, if (enabled) {
modifier = onIconClick()
Modifier }
.size(ButtonDefaults.IconSize) },
.weight(1f, false) )
.clickable { }
if (enabled) {
onIconClick()
}
},
)
}
} }
@@ -27,71 +27,73 @@ import com.zaneschepke.wireguardautotunnel.util.toThreeDecimalPlaceString
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun RowListItem( fun RowListItem(
icon: @Composable () -> Unit, icon: @Composable () -> Unit,
text: String, text: String,
onHold: () -> Unit, onHold: () -> Unit,
onClick: () -> Unit, onClick: () -> Unit,
rowButton: @Composable () -> Unit, rowButton: @Composable () -> Unit,
expanded: Boolean, expanded: Boolean,
statistics: TunnelStatistics?, statistics: TunnelStatistics?,
focusRequester: FocusRequester, focusRequester: FocusRequester,
) { ) {
Box( Box(
modifier = modifier =
Modifier.focusRequester(focusRequester) Modifier
.animateContentSize() .focusRequester(focusRequester)
.clip(RoundedCornerShape(30.dp)) .animateContentSize()
.combinedClickable( .clip(RoundedCornerShape(30.dp))
onClick = { onClick() }, .combinedClickable(
onLongClick = { onHold() }, onClick = { onClick() },
), onLongClick = { onHold() },
) { ),
Column { ) {
Row( Column {
modifier = Modifier Row(
.fillMaxWidth() modifier =
.padding(horizontal = 15.dp, vertical = 5.dp), Modifier
verticalAlignment = Alignment.CenterVertically, .fillMaxWidth()
horizontalArrangement = Arrangement.SpaceBetween, .padding(horizontal = 15.dp, vertical = 5.dp),
) { verticalAlignment = Alignment.CenterVertically,
Row( horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, ) {
modifier = Modifier.fillMaxWidth(13 / 20f), Row(
) { verticalAlignment = Alignment.CenterVertically,
icon() modifier = Modifier.fillMaxWidth(13 / 20f),
Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis) ) {
} icon()
rowButton() Text(text, maxLines = 1, overflow = TextOverflow.Ellipsis)
} }
if (expanded) { rowButton()
statistics?.getPeers()?.forEach { }
Row( if (expanded) {
modifier = statistics?.getPeers()?.forEach {
Modifier Row(
.fillMaxWidth() modifier =
.padding(end = 10.dp, bottom = 10.dp, start = 10.dp), Modifier
verticalAlignment = Alignment.CenterVertically, .fillMaxWidth()
horizontalArrangement = Arrangement.SpaceEvenly, .padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
) { verticalAlignment = Alignment.CenterVertically,
//TODO change these to string resources horizontalArrangement = Arrangement.SpaceEvenly,
val handshakeEpoch = statistics.peerStats(it)!!.latestHandshakeEpochMillis ) {
val peerTx = statistics.peerStats(it)!!.txBytes // TODO change these to string resources
val peerRx = statistics.peerStats(it)!!.rxBytes val handshakeEpoch = statistics.peerStats(it)!!.latestHandshakeEpochMillis
val peerId = it.toBase64().subSequence(0, 3).toString() + "***" val peerTx = statistics.peerStats(it)!!.txBytes
val handshakeSec = val peerRx = statistics.peerStats(it)!!.rxBytes
NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) val peerId = it.toBase64().subSequence(0, 3).toString() + "***"
val handshake = val handshakeSec =
if (handshakeSec == null) "never" else "$handshakeSec secs ago" NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch)
val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString() val handshake =
val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString() if (handshakeSec == null) "never" else "$handshakeSec secs ago"
val fontSize = 9.sp val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString()
Text("peer: $peerId", fontSize = fontSize) val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString()
Text("handshake: $handshake", fontSize = fontSize) val fontSize = 9.sp
Text("tx: $peerTxMB MB", fontSize = fontSize) Text("peer: $peerId", fontSize = fontSize)
Text("rx: $peerRxMB MB", fontSize = fontSize) Text("handshake: $handshake", fontSize = fontSize)
} Text("tx: $peerTxMB MB", fontSize = fontSize)
} Text("rx: $peerRxMB MB", fontSize = fontSize)
} }
} }
} }
}
}
} }
@@ -26,57 +26,57 @@ import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun SearchBar(onQuery: (queryString: String) -> Unit) { fun SearchBar(onQuery: (queryString: String) -> Unit) {
// Immediately update and keep track of query from text field changes. // Immediately update and keep track of query from text field changes.
var query: String by rememberSaveable { mutableStateOf("") } var query: String by rememberSaveable { mutableStateOf("") }
var showClearIcon by rememberSaveable { mutableStateOf(false) } var showClearIcon by rememberSaveable { mutableStateOf(false) }
if (query.isEmpty()) { if (query.isEmpty()) {
showClearIcon = false showClearIcon = false
} else if (query.isNotEmpty()) { } else if (query.isNotEmpty()) {
showClearIcon = true showClearIcon = true
} }
TextField( TextField(
value = query, value = query,
onValueChange = { onQueryChanged -> onValueChange = { onQueryChanged ->
// If user makes changes to text, immediately updated it. // If user makes changes to text, immediately updated it.
query = onQueryChanged query = onQueryChanged
onQuery(onQueryChanged) onQuery(onQueryChanged)
}, },
leadingIcon = { leadingIcon = {
val icon = Icons.Rounded.Search val icon = Icons.Rounded.Search
Icon( Icon(
imageVector = icon, imageVector = icon,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
contentDescription = icon.name, contentDescription = icon.name,
) )
}, },
trailingIcon = { trailingIcon = {
if (showClearIcon) { if (showClearIcon) {
IconButton(onClick = { query = "" }) { IconButton(onClick = { query = "" }) {
val icon = Icons.Rounded.Clear val icon = Icons.Rounded.Clear
Icon( Icon(
imageVector = icon, imageVector = icon,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
contentDescription = icon.name, contentDescription = icon.name,
) )
} }
} }
}, },
maxLines = 1, maxLines = 1,
colors = colors =
TextFieldDefaults.colors( TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent,
), ),
placeholder = { Text(text = stringResource(R.string.hint_search_packages)) }, placeholder = { Text(text = stringResource(R.string.hint_search_packages)) },
textStyle = MaterialTheme.typography.bodySmall, textStyle = MaterialTheme.typography.bodySmall,
singleLine = true, singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text),
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background, shape = RectangleShape), .background(color = MaterialTheme.colorScheme.background, shape = RectangleShape),
) )
} }
@@ -11,26 +11,26 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
@Composable @Composable
fun ConfigurationTextBox( fun ConfigurationTextBox(
value: String, value: String,
hint: String, hint: String,
onValueChange: (String) -> Unit, onValueChange: (String) -> Unit,
keyboardActions: KeyboardActions, keyboardActions: KeyboardActions,
label: String, label: String,
modifier: Modifier modifier: Modifier,
) { ) {
OutlinedTextField( OutlinedTextField(
modifier = modifier, modifier = modifier,
value = value, value = value,
singleLine = true, singleLine = true,
onValueChange = { onValueChange(it) }, onValueChange = { onValueChange(it) },
label = { Text(label) }, label = { Text(label) },
maxLines = 1, maxLines = 1,
placeholder = { Text(hint) }, placeholder = { Text(hint) },
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done, imeAction = ImeAction.Done,
), ),
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
) )
} }
@@ -13,35 +13,31 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@Composable @Composable
fun ConfigurationToggle( fun ConfigurationToggle(label: String, enabled: Boolean, checked: Boolean, padding: Dp, onCheckChanged: () -> Unit, modifier: Modifier = Modifier) {
label: String, Row(
enabled: Boolean, modifier =
checked: Boolean, Modifier
padding: Dp, .fillMaxWidth()
onCheckChanged: () -> Unit, .padding(padding),
modifier: Modifier = Modifier verticalAlignment = Alignment.CenterVertically,
) { horizontalArrangement = Arrangement.SpaceBetween,
Row( ) {
modifier = Modifier Text(
.fillMaxWidth() label,
.padding(padding), textAlign = TextAlign.Start,
verticalAlignment = Alignment.CenterVertically, modifier =
horizontalArrangement = Arrangement.SpaceBetween, Modifier
) { .weight(
Text( weight = 1.0f,
label, textAlign = TextAlign.Start, fill = false,
modifier = Modifier ),
.weight( softWrap = true,
weight = 1.0f, )
fill = false, Switch(
), modifier = modifier,
softWrap = true, enabled = enabled,
) checked = checked,
Switch( onCheckedChange = { onCheckChanged() },
modifier = modifier, )
enabled = enabled, }
checked = checked,
onCheckedChange = { onCheckChanged() },
)
}
} }
@@ -0,0 +1,37 @@
package com.zaneschepke.wireguardautotunnel.ui.common.dialog
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
@Composable
fun InfoDialog(
onAttest: () -> Unit,
onDismiss: () -> Unit,
title: @Composable () -> Unit,
body: @Composable () -> Unit,
confirmText: @Composable () -> Unit,
) {
AlertDialog(
onDismissRequest = { onDismiss() },
confirmButton = {
TextButton(
onClick = {
onAttest()
},
) {
confirmText()
}
},
dismissButton = {
TextButton(onClick = { onDismiss() }) {
Text(text = stringResource(R.string.cancel))
}
},
title = { title() },
text = { body() },
)
}
@@ -18,39 +18,42 @@ import com.zaneschepke.wireguardautotunnel.ui.Screen
@Composable @Composable
fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavItem>) { fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavItem>) {
val backStackEntry = navController.currentBackStackEntryAsState() val backStackEntry = navController.currentBackStackEntryAsState()
var showBottomBar by rememberSaveable { mutableStateOf(true) } var showBottomBar by rememberSaveable { mutableStateOf(true) }
val navBackStackEntry by navController.currentBackStackEntryAsState() val navBackStackEntry by navController.currentBackStackEntryAsState()
//TODO find a better way to hide nav bar // TODO find a better way to hide nav bar
showBottomBar = when (navBackStackEntry?.destination?.route) { showBottomBar =
Screen.Lock.route -> false when (navBackStackEntry?.destination?.route) {
else -> true Screen.Lock.route -> false
} else -> true
}
NavigationBar( NavigationBar(
containerColor = if (!showBottomBar) Color.Transparent else MaterialTheme.colorScheme.background, containerColor = if (!showBottomBar) Color.Transparent else MaterialTheme.colorScheme.background,
) { ) {
if (showBottomBar) bottomNavItems.forEach { item -> if (showBottomBar) {
val selected = item.route == backStackEntry.value?.destination?.route bottomNavItems.forEach { item ->
val selected = item.route == backStackEntry.value?.destination?.route
NavigationBarItem( NavigationBarItem(
selected = selected, selected = selected,
onClick = { navController.navigate(item.route) }, onClick = { navController.navigate(item.route) },
label = { label = {
Text( Text(
text = item.name, text = item.name,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
}, },
icon = { icon = {
Icon( Icon(
imageVector = item.icon, imageVector = item.icon,
contentDescription = "${item.name} Icon", contentDescription = "${item.name} Icon",
) )
}, },
) )
} }
} }
}
} }
@@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.ui.common.navigation
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
data class BottomNavItem( data class BottomNavItem(
val name: String, val name: String,
val route: String, val route: String,
val icon: ImageVector, val icon: ImageVector,
) )
@@ -12,78 +12,77 @@ import androidx.fragment.app.FragmentActivity
@Composable @Composable
fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (String) -> Unit) { fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (String) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val biometricManager = BiometricManager.from(context) val biometricManager = BiometricManager.from(context)
val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
val isBiometricAvailable = remember { val isBiometricAvailable =
when (bio) { remember {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> { when (bio) {
onError("Biometrics not available") BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
false onError("Biometrics not available")
} false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> { BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created") onError("Biometrics not created")
false false
} }
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> { BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found") onError("Biometric hardware not found")
false false
} }
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> { BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required") onError("Biometric security update required")
false false
} }
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> { BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported") onError("Biometrics not supported")
false false
} }
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> { BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown") onError("Biometrics status unknown")
false false
} }
BiometricManager.BIOMETRIC_SUCCESS -> true BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false else -> false
} }
} }
if (isBiometricAvailable) { if (isBiometricAvailable) {
val executor = remember { ContextCompat.getMainExecutor(context) } val executor = remember { ContextCompat.getMainExecutor(context) }
val promptInfo = val promptInfo =
BiometricPrompt.PromptInfo.Builder() BiometricPrompt.PromptInfo.Builder()
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) .setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.setTitle("Biometric Authentication") .setTitle("Biometric Authentication")
.setSubtitle("Log in using your biometric credential") .setSubtitle("Log in using your biometric credential")
.build() .build()
val biometricPrompt = val biometricPrompt =
BiometricPrompt( BiometricPrompt(
context as FragmentActivity, context as FragmentActivity,
executor, executor,
object : BiometricPrompt.AuthenticationCallback() { object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
super.onAuthenticationError(errorCode, errString) super.onAuthenticationError(errorCode, errString)
onFailure() onFailure()
} }
override fun onAuthenticationSucceeded( override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
result: BiometricPrompt.AuthenticationResult super.onAuthenticationSucceeded(result)
) { onSuccess()
super.onAuthenticationSucceeded(result) }
onSuccess()
}
override fun onAuthenticationFailed() { override fun onAuthenticationFailed() {
super.onAuthenticationFailed() super.onAuthenticationFailed()
onFailure() onFailure()
} }
}, },
) )
biometricPrompt.authenticate(promptInfo) biometricPrompt.authenticate(promptInfo)
} }
} }
@@ -25,40 +25,37 @@ import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
@Composable @Composable
fun CustomSnackBar( fun CustomSnackBar(message: String, isRtl: Boolean = true, containerColor: Color = MaterialTheme.colorScheme.surface) {
message: String, Snackbar(
isRtl: Boolean = true, containerColor = containerColor,
containerColor: Color = MaterialTheme.colorScheme.surface modifier =
) { Modifier
Snackbar( .fillMaxWidth(
containerColor = containerColor, if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f,
modifier = )
Modifier .padding(bottom = 100.dp),
.fillMaxWidth( shape = RoundedCornerShape(16.dp),
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f, ) {
) CompositionLocalProvider(
.padding(bottom = 100.dp), LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr,
shape = RoundedCornerShape(16.dp), ) {
) { Row(
CompositionLocalProvider( modifier =
LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr, Modifier
) { .width(IntrinsicSize.Max)
Row( .height(IntrinsicSize.Min),
modifier = Modifier verticalAlignment = Alignment.CenterVertically,
.width(IntrinsicSize.Max) horizontalArrangement = Arrangement.Start,
.height(IntrinsicSize.Min), ) {
verticalAlignment = Alignment.CenterVertically, val icon = Icons.Rounded.Info
horizontalArrangement = Arrangement.Start, Icon(
) { icon,
val icon = Icons.Rounded.Info contentDescription = icon.name,
Icon( tint = Color.White,
icon, modifier = Modifier.padding(end = 10.dp),
contentDescription = icon.name, )
tint = Color.White, Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp))
modifier = Modifier.padding(end = 10.dp), }
) }
Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp)) }
}
}
}
} }
@@ -13,14 +13,15 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun LoadingScreen() { fun LoadingScreen() {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
.fillMaxSize() Modifier
.focusable() .fillMaxSize()
.padding(), .focusable()
) { .padding(),
Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() } ) {
} Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() }
}
} }
@@ -13,13 +13,14 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun LogTypeLabel(color: Color, content: @Composable () -> Unit) { fun LogTypeLabel(color: Color, content: @Composable () -> Unit) {
Box( Box(
modifier = Modifier modifier =
.size(20.dp) Modifier
.clip(RoundedCornerShape(2.dp)) .size(20.dp)
.background(color), .clip(RoundedCornerShape(2.dp))
contentAlignment = Alignment.Center, .background(color),
) { contentAlignment = Alignment.Center,
content() ) {
} content()
}
} }
@@ -13,10 +13,10 @@ import androidx.compose.ui.unit.sp
@Composable @Composable
fun SectionTitle(title: String, padding: Dp) { fun SectionTitle(title: String, padding: Dp) {
Text( Text(
title, title,
textAlign = TextAlign.Start, textAlign = TextAlign.Start,
style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.ExtraBold), style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.ExtraBold),
modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp), modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp),
) )
} }
@@ -7,67 +7,67 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.config.model.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.Packages import com.zaneschepke.wireguardautotunnel.util.Packages
data class ConfigUiState( data class ConfigUiState(
val proxyPeers: List<PeerProxy> = arrayListOf(PeerProxy()), val proxyPeers: List<PeerProxy> = arrayListOf(PeerProxy()),
val interfaceProxy: InterfaceProxy = InterfaceProxy(), val interfaceProxy: InterfaceProxy = InterfaceProxy(),
val packages: Packages = emptyList(), val packages: Packages = emptyList(),
val checkedPackageNames: List<String> = emptyList(), val checkedPackageNames: List<String> = emptyList(),
val include: Boolean = true, val include: Boolean = true,
val isAllApplicationsEnabled: Boolean = false, val isAllApplicationsEnabled: Boolean = false,
val loading: Boolean = true, val loading: Boolean = true,
val tunnel: TunnelConfig? = null, val tunnel: TunnelConfig? = null,
val tunnelName: String = "", val tunnelName: String = "",
val isAmneziaEnabled: Boolean = false val isAmneziaEnabled: Boolean = false,
) { ) {
companion object { companion object {
fun from(config: Config): ConfigUiState { fun from(config: Config): ConfigUiState {
val proxyPeers = config.peers.map { PeerProxy.from(it) } val proxyPeers = config.peers.map { PeerProxy.from(it) }
val proxyInterface = InterfaceProxy.from(config.`interface`) val proxyInterface = InterfaceProxy.from(config.`interface`)
var include = true var include = true
var isAllApplicationsEnabled = false var isAllApplicationsEnabled = false
val checkedPackages = val checkedPackages =
if (config.`interface`.includedApplications.isNotEmpty()) { if (config.`interface`.includedApplications.isNotEmpty()) {
config.`interface`.includedApplications config.`interface`.includedApplications
} else if (config.`interface`.excludedApplications.isNotEmpty()) { } else if (config.`interface`.excludedApplications.isNotEmpty()) {
include = false include = false
config.`interface`.excludedApplications config.`interface`.excludedApplications
} else { } else {
isAllApplicationsEnabled = true isAllApplicationsEnabled = true
emptySet() emptySet()
} }
return ConfigUiState( return ConfigUiState(
proxyPeers, proxyPeers,
proxyInterface, proxyInterface,
emptyList(), emptyList(),
checkedPackages.toList(), checkedPackages.toList(),
include, include,
isAllApplicationsEnabled, isAllApplicationsEnabled,
) )
} }
fun from(config: org.amnezia.awg.config.Config): ConfigUiState { fun from(config: org.amnezia.awg.config.Config): ConfigUiState {
//TODO update with new values // TODO update with new values
val proxyPeers = config.peers.map { PeerProxy.from(it) } val proxyPeers = config.peers.map { PeerProxy.from(it) }
val proxyInterface = InterfaceProxy.from(config.`interface`) val proxyInterface = InterfaceProxy.from(config.`interface`)
var include = true var include = true
var isAllApplicationsEnabled = false var isAllApplicationsEnabled = false
val checkedPackages = val checkedPackages =
if (config.`interface`.includedApplications.isNotEmpty()) { if (config.`interface`.includedApplications.isNotEmpty()) {
config.`interface`.includedApplications config.`interface`.includedApplications
} else if (config.`interface`.excludedApplications.isNotEmpty()) { } else if (config.`interface`.excludedApplications.isNotEmpty()) {
include = false include = false
config.`interface`.excludedApplications config.`interface`.excludedApplications
} else { } else {
isAllApplicationsEnabled = true isAllApplicationsEnabled = true
emptySet() emptySet()
} }
return ConfigUiState( return ConfigUiState(
proxyPeers, proxyPeers,
proxyInterface, proxyInterface,
emptyList(), emptyList(),
checkedPackages.toList(), checkedPackages.toList(),
include, include,
isAllApplicationsEnabled, isAllApplicationsEnabled,
) )
} }
} }
} }
@@ -38,485 +38,529 @@ import javax.inject.Inject
class ConfigViewModel class ConfigViewModel
@Inject @Inject
constructor( constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
private val packageManager = WireGuardAutoTunnel.instance.packageManager
private val packageManager = WireGuardAutoTunnel.instance.packageManager private val _uiState = MutableStateFlow(ConfigUiState())
val uiState = _uiState.asStateFlow()
private val _uiState = MutableStateFlow(ConfigUiState()) fun init(tunnelId: String) = viewModelScope.launch(ioDispatcher) {
val uiState = _uiState.asStateFlow() val packages = getQueriedPackages("")
val state =
if (tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
val tunnelConfig =
appDataRepository.tunnels.getAll()
.firstOrNull { it.id.toString() == tunnelId }
val isAmneziaEnabled = settingsRepository.getSettings().isAmneziaEnabled
if (tunnelConfig != null) {
(
if (isAmneziaEnabled) {
val amConfig =
if (tunnelConfig.amQuick == "") tunnelConfig.wgQuick else tunnelConfig.amQuick
ConfigUiState.from(TunnelConfig.configFromAmQuick(amConfig))
} else {
ConfigUiState.from(
TunnelConfig.configFromWgQuick(tunnelConfig.wgQuick),
)
}
).copy(
packages = packages,
loading = false,
tunnel = tunnelConfig,
tunnelName = tunnelConfig.name,
isAmneziaEnabled = isAmneziaEnabled,
)
} else {
ConfigUiState(loading = false, packages = packages)
}
} else {
ConfigUiState(loading = false, packages = packages)
}
_uiState.value = state
}
fun init(tunnelId: String) = fun onTunnelNameChange(name: String) {
viewModelScope.launch(ioDispatcher) { _uiState.value = _uiState.value.copy(tunnelName = name)
val packages = getQueriedPackages("") }
val state =
if (tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
val tunnelConfig =
appDataRepository.tunnels.getAll()
.firstOrNull { it.id.toString() == tunnelId }
val isAmneziaEnabled = settingsRepository.getSettings().isAmneziaEnabled
if (tunnelConfig != null) {
(if (isAmneziaEnabled) {
val amConfig =
if (tunnelConfig.amQuick == "") tunnelConfig.wgQuick else tunnelConfig.amQuick
ConfigUiState.from(TunnelConfig.configFromAmQuick(amConfig))
} else ConfigUiState.from(TunnelConfig.configFromWgQuick(tunnelConfig.wgQuick))).copy(
packages = packages,
loading = false,
tunnel = tunnelConfig,
tunnelName = tunnelConfig.name,
isAmneziaEnabled = isAmneziaEnabled,
)
} else {
ConfigUiState(loading = false, packages = packages)
}
} else {
ConfigUiState(loading = false, packages = packages)
}
_uiState.value = state
}
fun onTunnelNameChange(name: String) { fun onIncludeChange(include: Boolean) {
_uiState.value = _uiState.value.copy(tunnelName = name) _uiState.value = _uiState.value.copy(include = include)
} }
fun onIncludeChange(include: Boolean) { fun onAddCheckedPackage(packageName: String) {
_uiState.value = _uiState.value.copy(include = include) _uiState.value =
} _uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames + packageName,
)
}
fun onAddCheckedPackage(packageName: String) { fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_uiState.value = _uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
_uiState.value.copy( }
checkedPackageNames = _uiState.value.checkedPackageNames + packageName,
)
}
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) { fun onRemoveCheckedPackage(packageName: String) {
_uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled) _uiState.value =
} _uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames - packageName,
)
}
fun onRemoveCheckedPackage(packageName: String) { private fun getQueriedPackages(query: String): List<PackageInfo> {
_uiState.value = return getAllInternetCapablePackages().filter {
_uiState.value.copy( getPackageLabel(it).lowercase().contains(query.lowercase())
checkedPackageNames = _uiState.value.checkedPackageNames - packageName, }
) }
}
private fun getQueriedPackages(query: String): List<PackageInfo> { fun getPackageLabel(packageInfo: PackageInfo): String {
return getAllInternetCapablePackages().filter { return packageInfo.applicationInfo?.loadLabel(packageManager).toString()
getPackageLabel(it).lowercase().contains(query.lowercase()) }
}
}
fun getPackageLabel(packageInfo: PackageInfo): String { private fun getAllInternetCapablePackages(): List<PackageInfo> {
return packageInfo.applicationInfo.loadLabel(packageManager).toString() return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
} }
private fun getAllInternetCapablePackages(): List<PackageInfo> { private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET)) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
} packageManager.getPackagesHoldingPermissions(
permissions,
PackageManager.PackageInfoFlags.of(0L),
)
} else {
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> { private fun isAllApplicationsEnabled(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return _uiState.value.isAllApplicationsEnabled
packageManager.getPackagesHoldingPermissions( }
permissions,
PackageManager.PackageInfoFlags.of(0L),
)
} else {
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
private fun isAllApplicationsEnabled(): Boolean { private fun saveConfig(tunnelConfig: TunnelConfig) = viewModelScope.launch { appDataRepository.tunnels.save(tunnelConfig) }
return _uiState.value.isAllApplicationsEnabled
}
private fun saveConfig(tunnelConfig: TunnelConfig) = private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) = viewModelScope.launch {
viewModelScope.launch { appDataRepository.tunnels.save(tunnelConfig) } if (tunnelConfig != null) {
saveConfig(tunnelConfig).join()
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate()
}
}
private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) = private fun buildPeerListFromProxyPeers(): List<Peer> {
viewModelScope.launch { return _uiState.value.proxyPeers.map {
if (tunnelConfig != null) { val builder = Peer.Builder()
saveConfig(tunnelConfig).join() if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate() if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
} if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
} if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
}
builder.build()
}
}
private fun buildPeerListFromProxyPeers(): List<Peer> { private fun buildAmPeerListFromProxyPeers(): List<org.amnezia.awg.config.Peer> {
return _uiState.value.proxyPeers.map { return _uiState.value.proxyPeers.map {
val builder = Peer.Builder() val builder = org.amnezia.awg.config.Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim()) if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim()) if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim()) if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim()) if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) { if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(it.persistentKeepalive.trim()) builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
} }
builder.build() builder.build()
} }
} }
private fun buildAmPeerListFromProxyPeers(): List<org.amnezia.awg.config.Peer> { private fun emptyCheckedPackagesList() {
return _uiState.value.proxyPeers.map { _uiState.value = _uiState.value.copy(checkedPackageNames = emptyList())
val builder = org.amnezia.awg.config.Peer.Builder() }
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
}
builder.build()
}
}
private fun emptyCheckedPackagesList() { private fun buildInterfaceListFromProxyInterface(): Interface {
_uiState.value = _uiState.value.copy(checkedPackageNames = emptyList()) val builder = Interface.Builder()
} builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
if (_uiState.value.interfaceProxy.dnsServers.isNotEmpty()) {
builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim())
}
if (_uiState.value.interfaceProxy.mtu.isNotEmpty()) {
builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim())
}
if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim())
}
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) {
builder.includeApplications(
_uiState.value.checkedPackageNames,
)
}
if (!_uiState.value.include) {
builder.excludeApplications(
_uiState.value.checkedPackageNames,
)
}
return builder.build()
}
private fun buildInterfaceListFromProxyInterface(): Interface { private fun buildAmInterfaceListFromProxyInterface(): org.amnezia.awg.config.Interface {
val builder = Interface.Builder() val builder = org.amnezia.awg.config.Interface.Builder()
builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim()) builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim()) builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
if (_uiState.value.interfaceProxy.dnsServers.isNotEmpty()) { if (_uiState.value.interfaceProxy.dnsServers.isNotEmpty()) {
builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim()) builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim())
} }
if (_uiState.value.interfaceProxy.mtu.isNotEmpty()) if (_uiState.value.interfaceProxy.mtu.isNotEmpty()) {
builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim()) builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim())
if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) { }
builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim()) if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
} builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim())
if (isAllApplicationsEnabled()) emptyCheckedPackagesList() }
if (_uiState.value.include) builder.includeApplications(_uiState.value.checkedPackageNames) if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (!_uiState.value.include) builder.excludeApplications(_uiState.value.checkedPackageNames) if (_uiState.value.include) {
return builder.build() builder.includeApplications(
} _uiState.value.checkedPackageNames,
)
}
if (!_uiState.value.include) {
builder.excludeApplications(
_uiState.value.checkedPackageNames,
)
}
if (_uiState.value.interfaceProxy.junkPacketCount.isNotEmpty()) {
builder.setJunkPacketCount(
_uiState.value.interfaceProxy.junkPacketCount.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.junkPacketMinSize.isNotEmpty()) {
builder.setJunkPacketMinSize(
_uiState.value.interfaceProxy.junkPacketMinSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.junkPacketMaxSize.isNotEmpty()) {
builder.setJunkPacketMaxSize(
_uiState.value.interfaceProxy.junkPacketMaxSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.initPacketJunkSize.isNotEmpty()) {
builder.setInitPacketJunkSize(
_uiState.value.interfaceProxy.initPacketJunkSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.responsePacketJunkSize.isNotEmpty()) {
builder.setResponsePacketJunkSize(
_uiState.value.interfaceProxy.responsePacketJunkSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.initPacketMagicHeader.isNotEmpty()) {
builder.setInitPacketMagicHeader(
_uiState.value.interfaceProxy.initPacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.responsePacketMagicHeader.isNotEmpty()) {
builder.setResponsePacketMagicHeader(
_uiState.value.interfaceProxy.responsePacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.transportPacketMagicHeader.isNotEmpty()) {
builder.setTransportPacketMagicHeader(
_uiState.value.interfaceProxy.transportPacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.underloadPacketMagicHeader.isNotEmpty()) {
builder.setUnderloadPacketMagicHeader(
_uiState.value.interfaceProxy.underloadPacketMagicHeader.trim().toLong(),
)
}
return builder.build()
}
private fun buildAmInterfaceListFromProxyInterface(): org.amnezia.awg.config.Interface { private fun buildConfig(): Config {
val builder = org.amnezia.awg.config.Interface.Builder() val peerList = buildPeerListFromProxyPeers()
builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim()) val wgInterface = buildInterfaceListFromProxyInterface()
builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim()) return Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
if (_uiState.value.interfaceProxy.dnsServers.isNotEmpty()) { }
builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim())
}
if (_uiState.value.interfaceProxy.mtu.isNotEmpty())
builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim())
if (_uiState.value.interfaceProxy.listenPort.isNotEmpty()) {
builder.parseListenPort(_uiState.value.interfaceProxy.listenPort.trim())
}
if (isAllApplicationsEnabled()) emptyCheckedPackagesList()
if (_uiState.value.include) builder.includeApplications(_uiState.value.checkedPackageNames)
if (!_uiState.value.include) builder.excludeApplications(_uiState.value.checkedPackageNames)
if (_uiState.value.interfaceProxy.junkPacketCount.isNotEmpty()) {
builder.setJunkPacketCount(_uiState.value.interfaceProxy.junkPacketCount.trim().toInt())
}
if (_uiState.value.interfaceProxy.junkPacketMinSize.isNotEmpty()) {
builder.setJunkPacketMinSize(
_uiState.value.interfaceProxy.junkPacketMinSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.junkPacketMaxSize.isNotEmpty()) {
builder.setJunkPacketMaxSize(
_uiState.value.interfaceProxy.junkPacketMaxSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.initPacketJunkSize.isNotEmpty()) {
builder.setInitPacketJunkSize(
_uiState.value.interfaceProxy.initPacketJunkSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.responsePacketJunkSize.isNotEmpty()) {
builder.setResponsePacketJunkSize(
_uiState.value.interfaceProxy.responsePacketJunkSize.trim().toInt(),
)
}
if (_uiState.value.interfaceProxy.initPacketMagicHeader.isNotEmpty()) {
builder.setInitPacketMagicHeader(
_uiState.value.interfaceProxy.initPacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.responsePacketMagicHeader.isNotEmpty()) {
builder.setResponsePacketMagicHeader(
_uiState.value.interfaceProxy.responsePacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.transportPacketMagicHeader.isNotEmpty()) {
builder.setTransportPacketMagicHeader(
_uiState.value.interfaceProxy.transportPacketMagicHeader.trim().toLong(),
)
}
if (_uiState.value.interfaceProxy.underloadPacketMagicHeader.isNotEmpty()) {
builder.setUnderloadPacketMagicHeader(
_uiState.value.interfaceProxy.underloadPacketMagicHeader.trim().toLong(),
)
}
return builder.build()
}
private fun buildConfig(): Config { private fun buildAmConfig(): org.amnezia.awg.config.Config {
val peerList = buildPeerListFromProxyPeers() val peerList = buildAmPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface() val amInterface = buildAmInterfaceListFromProxyInterface()
return Config.Builder().addPeers(peerList).setInterface(wgInterface).build() return org.amnezia.awg.config.Config.Builder().addPeers(
} peerList,
).setInterface(amInterface)
.build()
}
private fun buildAmConfig(): org.amnezia.awg.config.Config { fun onSaveAllChanges(configType: ConfigType): Result<Unit> {
val peerList = buildAmPeerListFromProxyPeers() return try {
val amInterface = buildAmInterfaceListFromProxyInterface() val wgQuick = buildConfig().toWgQuickString()
return org.amnezia.awg.config.Config.Builder().addPeers(peerList).setInterface(amInterface) val amQuick =
.build() if (configType == ConfigType.AMNEZIA) {
} buildAmConfig().toAwgQuickString()
} else {
TunnelConfig.AM_QUICK_DEFAULT
}
val tunnelConfig =
when (uiState.value.tunnel) {
null ->
TunnelConfig(
name = _uiState.value.tunnelName,
wgQuick = wgQuick,
amQuick = amQuick,
)
fun onSaveAllChanges(configType: ConfigType): Result<Unit> { else ->
return try { uiState.value.tunnel!!.copy(
val wgQuick = buildConfig().toWgQuickString() name = _uiState.value.tunnelName,
val amQuick = if (configType == ConfigType.AMNEZIA) { wgQuick = wgQuick,
buildAmConfig().toAwgQuickString() amQuick = amQuick,
} else TunnelConfig.AM_QUICK_DEFAULT )
val tunnelConfig = when (uiState.value.tunnel) { }
null -> TunnelConfig( updateTunnelConfig(tunnelConfig)
name = _uiState.value.tunnelName, Result.success(Unit)
wgQuick = wgQuick, } catch (e: Exception) {
amQuick = amQuick, Timber.e(e)
) val message = e.message?.substringAfter(":", missingDelimiterValue = "")
val stringValue =
message?.let {
StringValue.DynamicString(message)
} ?: StringValue.StringResource(R.string.unknown_error)
Result.failure(WgTunnelExceptions.ConfigParseError(stringValue))
}
}
else -> uiState.value.tunnel!!.copy( fun onPeerPublicKeyChange(index: Int, value: String) {
name = _uiState.value.tunnelName, _uiState.update {
wgQuick = wgQuick, it.copy(
amQuick = amQuick, proxyPeers =
) _uiState.value.proxyPeers.update(
} index,
updateTunnelConfig(tunnelConfig) _uiState.value.proxyPeers[index].copy(publicKey = value),
Result.success(Unit) ),
} catch (e: Exception) { )
Timber.e(e) }
val message = e.message?.substringAfter(":", missingDelimiterValue = "") }
val stringValue = message?.let {
StringValue.DynamicString(message)
} ?: StringValue.StringResource(R.string.unknown_error)
Result.failure(WgTunnelExceptions.ConfigParseError(stringValue))
}
}
fun onPeerPublicKeyChange(index: Int, value: String) { fun onPreSharedKeyChange(index: Int, value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
proxyPeers = proxyPeers =
_uiState.value.proxyPeers.update( _uiState.value.proxyPeers.update(
index, index,
_uiState.value.proxyPeers[index].copy(publicKey = value), _uiState.value.proxyPeers[index].copy(preSharedKey = value),
), ),
) )
} }
} }
fun onPreSharedKeyChange(index: Int, value: String) { fun onEndpointChange(index: Int, value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
proxyPeers = proxyPeers =
_uiState.value.proxyPeers.update( _uiState.value.proxyPeers.update(
index, index,
_uiState.value.proxyPeers[index].copy(preSharedKey = value), _uiState.value.proxyPeers[index].copy(endpoint = value),
), ),
) )
} }
} }
fun onEndpointChange(index: Int, value: String) { fun onAllowedIpsChange(index: Int, value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
proxyPeers = proxyPeers =
_uiState.value.proxyPeers.update( _uiState.value.proxyPeers.update(
index, index,
_uiState.value.proxyPeers[index].copy(endpoint = value), _uiState.value.proxyPeers[index].copy(allowedIps = value),
), ),
) )
} }
} }
fun onAllowedIpsChange(index: Int, value: String) { fun onPersistentKeepaliveChanged(index: Int, value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
proxyPeers = proxyPeers =
_uiState.value.proxyPeers.update( _uiState.value.proxyPeers.update(
index, index,
_uiState.value.proxyPeers[index].copy(allowedIps = value), _uiState.value.proxyPeers[index].copy(persistentKeepalive = value),
), ),
) )
} }
} }
fun onPersistentKeepaliveChanged(index: Int, value: String) { fun onDeletePeer(index: Int) {
_uiState.update { _uiState.update {
it.copy( it.copy(
proxyPeers = proxyPeers = _uiState.value.proxyPeers.removeAt(index),
_uiState.value.proxyPeers.update( )
index, }
_uiState.value.proxyPeers[index].copy(persistentKeepalive = value), }
),
)
}
}
fun onDeletePeer(index: Int) { fun addEmptyPeer() {
_uiState.update { _uiState.update {
it.copy( it.copy(proxyPeers = _uiState.value.proxyPeers + PeerProxy())
proxyPeers = _uiState.value.proxyPeers.removeAt(index), }
) }
}
}
fun addEmptyPeer() { fun generateKeyPair() {
_uiState.update { val keyPair = KeyPair()
it.copy(proxyPeers = _uiState.value.proxyPeers + PeerProxy()) _uiState.update {
} it.copy(
} interfaceProxy =
_uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64(),
),
)
}
}
fun generateKeyPair() { fun onAddressesChanged(value: String) {
val keyPair = KeyPair() _uiState.update {
_uiState.update { it.copy(
it.copy( interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value),
interfaceProxy = )
_uiState.value.interfaceProxy.copy( }
privateKey = keyPair.privateKey.toBase64(), }
publicKey = keyPair.publicKey.toBase64(),
),
)
}
}
fun onAddressesChanged(value: String) { fun onListenPortChanged(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value), interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value),
) )
} }
}
} fun onDnsServersChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value),
)
}
}
fun onListenPortChanged(value: String) { fun onMtuChanged(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value), }
) }
}
}
fun onDnsServersChanged(value: String) { private fun onInterfacePublicKeyChange(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value), interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value),
) )
} }
} }
fun onMtuChanged(value: String) { fun onPrivateKeyChange(value: String) {
_uiState.update { _uiState.update {
it.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value)) it.copy(
} interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value),
} )
}
if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else {
onInterfacePublicKeyChange("")
}
}
private fun onInterfacePublicKeyChange(value: String) { fun emitQueriedPackages(query: String) {
_uiState.update { val packages =
it.copy( getAllInternetCapablePackages().filter {
interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value), getPackageLabel(it).lowercase().contains(query.lowercase())
) }
} _uiState.update { it.copy(packages = packages) }
}
} fun onJunkPacketCountChanged(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketCount = value),
)
}
}
fun onPrivateKeyChange(value: String) { fun onJunkPacketMinSizeChanged(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value), interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMinSize = value),
) )
} }
if (NumberUtils.isValidKey(value)) { }
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else {
onInterfacePublicKeyChange("")
}
}
fun emitQueriedPackages(query: String) { fun onJunkPacketMaxSizeChanged(value: String) {
val packages = _uiState.update {
getAllInternetCapablePackages().filter { it.copy(
getPackageLabel(it).lowercase().contains(query.lowercase()) interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMaxSize = value),
} )
_uiState.update { it.copy(packages = packages) } }
} }
fun onJunkPacketCountChanged(value: String) { fun onInitPacketJunkSizeChanged(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketCount = value), interfaceProxy = _uiState.value.interfaceProxy.copy(initPacketJunkSize = value),
) )
} }
} }
fun onJunkPacketMinSizeChanged(value: String) { fun onResponsePacketJunkSize(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMinSize = value), interfaceProxy =
) _uiState.value.interfaceProxy.copy(
} responsePacketJunkSize = value,
} ),
)
}
}
fun onJunkPacketMaxSizeChanged(value: String) { fun onInitPacketMagicHeader(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(junkPacketMaxSize = value), interfaceProxy =
) _uiState.value.interfaceProxy.copy(
} initPacketMagicHeader = value,
} ),
)
}
}
fun onInitPacketJunkSizeChanged(value: String) { fun onResponsePacketMagicHeader(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(initPacketJunkSize = value), interfaceProxy =
) _uiState.value.interfaceProxy.copy(
} responsePacketMagicHeader = value,
} ),
)
}
}
fun onResponsePacketJunkSize(value: String) { fun onTransportPacketMagicHeader(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(responsePacketJunkSize = value), interfaceProxy =
) _uiState.value.interfaceProxy.copy(
} transportPacketMagicHeader = value,
} ),
)
}
}
fun onInitPacketMagicHeader(value: String) { fun onUnderloadPacketMagicHeader(value: String) {
_uiState.update { _uiState.update {
it.copy( it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(initPacketMagicHeader = value), interfaceProxy =
) _uiState.value.interfaceProxy.copy(
} underloadPacketMagicHeader = value,
} ),
)
fun onResponsePacketMagicHeader(value: String) { }
_uiState.update { }
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(responsePacketMagicHeader = value),
)
}
}
fun onTransportPacketMagicHeader(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(transportPacketMagicHeader = value),
)
}
}
fun onUnderloadPacketMagicHeader(value: String) {
_uiState.update {
it.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(underloadPacketMagicHeader = value),
)
}
}
} }
@@ -3,71 +3,116 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.config.model
import com.wireguard.config.Interface import com.wireguard.config.Interface
data class InterfaceProxy( data class InterfaceProxy(
val privateKey: String = "", val privateKey: String = "",
val publicKey: String = "", val publicKey: String = "",
val addresses: String = "", val addresses: String = "",
val dnsServers: String = "", val dnsServers: String = "",
val listenPort: String = "", val listenPort: String = "",
val mtu: String = "", val mtu: String = "",
val junkPacketCount: String = "", val junkPacketCount: String = "",
val junkPacketMinSize: String = "", val junkPacketMinSize: String = "",
val junkPacketMaxSize: String = "", val junkPacketMaxSize: String = "",
val initPacketJunkSize: String = "", val initPacketJunkSize: String = "",
val responsePacketJunkSize: String = "", val responsePacketJunkSize: String = "",
val initPacketMagicHeader: String = "", val initPacketMagicHeader: String = "",
val responsePacketMagicHeader: String = "", val responsePacketMagicHeader: String = "",
val underloadPacketMagicHeader: String = "", val underloadPacketMagicHeader: String = "",
val transportPacketMagicHeader: String = "", val transportPacketMagicHeader: String = "",
) { ) {
companion object { companion object {
fun from(i: Interface): InterfaceProxy { fun from(i: Interface): InterfaceProxy {
return InterfaceProxy( return InterfaceProxy(
publicKey = i.keyPair.publicKey.toBase64().trim(), publicKey = i.keyPair.publicKey.toBase64().trim(),
privateKey = i.keyPair.privateKey.toBase64().trim(), privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(), addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(), dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = listenPort =
if (i.listenPort.isPresent) { if (i.listenPort.isPresent) {
i.listenPort.get().toString().trim() i.listenPort.get().toString().trim()
} else { } else {
"" ""
}, },
mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "", mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "",
) )
} }
fun from(i: org.amnezia.awg.config.Interface): InterfaceProxy { fun from(i: org.amnezia.awg.config.Interface): InterfaceProxy {
return InterfaceProxy( return InterfaceProxy(
publicKey = i.keyPair.publicKey.toBase64().trim(), publicKey = i.keyPair.publicKey.toBase64().trim(),
privateKey = i.keyPair.privateKey.toBase64().trim(), privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(), addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(), dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = listenPort =
if (i.listenPort.isPresent) { if (i.listenPort.isPresent) {
i.listenPort.get().toString().trim() i.listenPort.get().toString().trim()
} else { } else {
"" ""
}, },
mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "", mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "",
junkPacketCount = if (i.junkPacketCount.isPresent) i.junkPacketCount.get() junkPacketCount =
.toString() else "", if (i.junkPacketCount.isPresent) {
junkPacketMinSize = if (i.junkPacketMinSize.isPresent) i.junkPacketMinSize.get() i.junkPacketCount.get()
.toString() else "", .toString()
junkPacketMaxSize = if (i.junkPacketMaxSize.isPresent) i.junkPacketMaxSize.get() } else {
.toString() else "", ""
initPacketJunkSize = if (i.initPacketJunkSize.isPresent) i.initPacketJunkSize.get() },
.toString() else "", junkPacketMinSize =
responsePacketJunkSize = if (i.responsePacketJunkSize.isPresent) i.responsePacketJunkSize.get() if (i.junkPacketMinSize.isPresent) {
.toString() else "", i.junkPacketMinSize.get()
initPacketMagicHeader = if (i.initPacketMagicHeader.isPresent) i.initPacketMagicHeader.get() .toString()
.toString() else "", } else {
responsePacketMagicHeader = if (i.responsePacketMagicHeader.isPresent) i.responsePacketMagicHeader.get() ""
.toString() else "", },
transportPacketMagicHeader = if (i.transportPacketMagicHeader.isPresent) i.transportPacketMagicHeader.get() junkPacketMaxSize =
.toString() else "", if (i.junkPacketMaxSize.isPresent) {
underloadPacketMagicHeader = if (i.underloadPacketMagicHeader.isPresent) i.underloadPacketMagicHeader.get() i.junkPacketMaxSize.get()
.toString() else "", .toString()
) } else {
} ""
} },
initPacketJunkSize =
if (i.initPacketJunkSize.isPresent) {
i.initPacketJunkSize.get()
.toString()
} else {
""
},
responsePacketJunkSize =
if (i.responsePacketJunkSize.isPresent) {
i.responsePacketJunkSize.get()
.toString()
} else {
""
},
initPacketMagicHeader =
if (i.initPacketMagicHeader.isPresent) {
i.initPacketMagicHeader.get()
.toString()
} else {
""
},
responsePacketMagicHeader =
if (i.responsePacketMagicHeader.isPresent) {
i.responsePacketMagicHeader.get()
.toString()
} else {
""
},
transportPacketMagicHeader =
if (i.transportPacketMagicHeader.isPresent) {
i.transportPacketMagicHeader.get()
.toString()
} else {
""
},
underloadPacketMagicHeader =
if (i.underloadPacketMagicHeader.isPresent) {
i.underloadPacketMagicHeader.get()
.toString()
} else {
""
},
)
}
}
} }
@@ -3,96 +3,96 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.config.model
import com.wireguard.config.Peer import com.wireguard.config.Peer
data class PeerProxy( data class PeerProxy(
val publicKey: String = "", val publicKey: String = "",
val preSharedKey: String = "", val preSharedKey: String = "",
val persistentKeepalive: String = "", val persistentKeepalive: String = "",
val endpoint: String = "", val endpoint: String = "",
val allowedIps: String = IPV4_WILDCARD.joinToString(", ").trim() val allowedIps: String = IPV4_WILDCARD.joinToString(", ").trim(),
) { ) {
companion object { companion object {
fun from(peer: Peer): PeerProxy { fun from(peer: Peer): PeerProxy {
return PeerProxy( return PeerProxy(
publicKey = peer.publicKey.toBase64(), publicKey = peer.publicKey.toBase64(),
preSharedKey = preSharedKey =
if (peer.preSharedKey.isPresent) { if (peer.preSharedKey.isPresent) {
peer.preSharedKey.get().toBase64().trim() peer.preSharedKey.get().toBase64().trim()
} else { } else {
"" ""
}, },
persistentKeepalive = persistentKeepalive =
if (peer.persistentKeepalive.isPresent) { if (peer.persistentKeepalive.isPresent) {
peer.persistentKeepalive.get().toString().trim() peer.persistentKeepalive.get().toString().trim()
} else { } else {
"" ""
}, },
endpoint = endpoint =
if (peer.endpoint.isPresent) { if (peer.endpoint.isPresent) {
peer.endpoint.get().toString().trim() peer.endpoint.get().toString().trim()
} else { } else {
"" ""
}, },
allowedIps = peer.allowedIps.joinToString(", ").trim(), allowedIps = peer.allowedIps.joinToString(", ").trim(),
) )
} }
fun from(peer: org.amnezia.awg.config.Peer): PeerProxy { fun from(peer: org.amnezia.awg.config.Peer): PeerProxy {
return PeerProxy( return PeerProxy(
publicKey = peer.publicKey.toBase64(), publicKey = peer.publicKey.toBase64(),
preSharedKey = preSharedKey =
if (peer.preSharedKey.isPresent) { if (peer.preSharedKey.isPresent) {
peer.preSharedKey.get().toBase64().trim() peer.preSharedKey.get().toBase64().trim()
} else { } else {
"" ""
}, },
persistentKeepalive = persistentKeepalive =
if (peer.persistentKeepalive.isPresent) { if (peer.persistentKeepalive.isPresent) {
peer.persistentKeepalive.get().toString().trim() peer.persistentKeepalive.get().toString().trim()
} else { } else {
"" ""
}, },
endpoint = endpoint =
if (peer.endpoint.isPresent) { if (peer.endpoint.isPresent) {
peer.endpoint.get().toString().trim() peer.endpoint.get().toString().trim()
} else { } else {
"" ""
}, },
allowedIps = peer.allowedIps.joinToString(", ").trim(), allowedIps = peer.allowedIps.joinToString(", ").trim(),
) )
} }
val IPV4_PUBLIC_NETWORKS = val IPV4_PUBLIC_NETWORKS =
setOf( setOf(
"0.0.0.0/5", "0.0.0.0/5",
"8.0.0.0/7", "8.0.0.0/7",
"11.0.0.0/8", "11.0.0.0/8",
"12.0.0.0/6", "12.0.0.0/6",
"16.0.0.0/4", "16.0.0.0/4",
"32.0.0.0/3", "32.0.0.0/3",
"64.0.0.0/2", "64.0.0.0/2",
"128.0.0.0/3", "128.0.0.0/3",
"160.0.0.0/5", "160.0.0.0/5",
"168.0.0.0/6", "168.0.0.0/6",
"172.0.0.0/12", "172.0.0.0/12",
"172.32.0.0/11", "172.32.0.0/11",
"172.64.0.0/10", "172.64.0.0/10",
"172.128.0.0/9", "172.128.0.0/9",
"173.0.0.0/8", "173.0.0.0/8",
"174.0.0.0/7", "174.0.0.0/7",
"176.0.0.0/4", "176.0.0.0/4",
"192.0.0.0/9", "192.0.0.0/9",
"192.128.0.0/11", "192.128.0.0/11",
"192.160.0.0/13", "192.160.0.0/13",
"192.169.0.0/16", "192.169.0.0/16",
"192.170.0.0/15", "192.170.0.0/15",
"192.172.0.0/14", "192.172.0.0/14",
"192.176.0.0/12", "192.176.0.0/12",
"192.192.0.0/10", "192.192.0.0/10",
"193.0.0.0/8", "193.0.0.0/8",
"194.0.0.0/7", "194.0.0.0/7",
"196.0.0.0/6", "196.0.0.0/6",
"200.0.0.0/5", "200.0.0.0/5",
"208.0.0.0/4", "208.0.0.0/4",
) )
val IPV4_WILDCARD = setOf("0.0.0.0/0") val IPV4_WILDCARD = setOf("0.0.0.0/0")
} }
} }
@@ -1,6 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main package com.zaneschepke.wireguardautotunnel.ui.screens.main
enum class ConfigType { enum class ConfigType {
AMNEZIA, AMNEZIA,
WIREGUARD WIREGUARD,
} }
File diff suppressed because it is too large Load Diff
@@ -5,8 +5,8 @@ import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
data class MainUiState( data class MainUiState(
val settings: Settings = Settings(), val settings: Settings = Settings(),
val tunnels: TunnelConfigs = emptyList(), val tunnels: TunnelConfigs = emptyList(),
val vpnState: VpnState = VpnState(), val vpnState: VpnState = VpnState(),
val loading: Boolean = true val loading: Boolean = true,
) )
@@ -34,358 +34,356 @@ import javax.inject.Inject
class MainViewModel class MainViewModel
@Inject @Inject
constructor( constructor(
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
private val serviceManager: ServiceManager, private val serviceManager: ServiceManager,
val vpnService: VpnService, val vpnService: VpnService,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
val uiState =
combine(
appDataRepository.settings.getSettingsFlow(),
appDataRepository.tunnels.getTunnelConfigsFlow(),
vpnService.vpnState,
) { settings, tunnels, vpnState ->
MainUiState(settings, tunnels, vpnState, false)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
MainUiState(),
)
val uiState = private fun stopWatcherService(context: Context) {
combine( serviceManager.stopWatcherService(context)
appDataRepository.settings.getSettingsFlow(), }
appDataRepository.tunnels.getTunnelConfigsFlow(),
vpnService.vpnState,
) { settings, tunnels, vpnState ->
MainUiState(settings, tunnels, vpnState, false)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
MainUiState(),
)
private fun stopWatcherService(context: Context) { fun onDelete(tunnel: TunnelConfig, context: Context) {
serviceManager.stopWatcherService(context) viewModelScope.launch {
} val settings = appDataRepository.settings.getSettings()
val isPrimary = tunnel.isPrimaryTunnel
if (appDataRepository.tunnels.count() == 1 || isPrimary) {
stopWatcherService(context)
resetTunnelSetting(settings)
}
appDataRepository.tunnels.delete(tunnel)
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate()
}
}
fun onDelete(tunnel: TunnelConfig, context: Context) { private fun resetTunnelSetting(settings: Settings) {
viewModelScope.launch { saveSettings(
val settings = appDataRepository.settings.getSettings() settings.copy(
val isPrimary = tunnel.isPrimaryTunnel isAutoTunnelEnabled = false,
if (appDataRepository.tunnels.count() == 1 || isPrimary) { isAlwaysOnVpnEnabled = false,
stopWatcherService(context) ),
resetTunnelSetting(settings) )
} }
appDataRepository.tunnels.delete(tunnel)
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate()
}
}
private fun resetTunnelSetting(settings: Settings) { fun onTunnelStart(tunnelConfig: TunnelConfig, context: Context) = viewModelScope.launch {
saveSettings( Timber.d("On start called!")
settings.copy( serviceManager.startVpnService(
isAutoTunnelEnabled = false, context,
isAlwaysOnVpnEnabled = false, tunnelConfig.id,
), isManualStart = true,
) )
} }
fun onTunnelStart(tunnelConfig: TunnelConfig, context: Context) = fun onTunnelStop(context: Context) = viewModelScope.launch {
viewModelScope.launch { Timber.i("Stopping active tunnel")
Timber.d("On start called!") serviceManager.stopVpnService(context, isManualStop = true)
serviceManager.startVpnService( }
context,
tunnelConfig.id,
isManualStart = true,
)
}
private fun validateConfigString(config: String, configType: ConfigType) {
when (configType) {
ConfigType.AMNEZIA -> TunnelConfig.configFromAmQuick(config)
ConfigType.WIREGUARD -> TunnelConfig.configFromWgQuick(config)
}
}
fun onTunnelStop(context: Context) = private fun generateQrCodeDefaultName(config: String, configType: ConfigType): String {
viewModelScope.launch { return try {
Timber.i("Stopping active tunnel") when (configType) {
serviceManager.stopVpnService(context, isManualStop = true) ConfigType.AMNEZIA -> {
} TunnelConfig.configFromAmQuick(config).peers[0].endpoint.get().host
}
private fun validateConfigString(config: String, configType: ConfigType) { ConfigType.WIREGUARD -> {
when (configType) { TunnelConfig.configFromWgQuick(config).peers[0].endpoint.get().host
ConfigType.AMNEZIA -> TunnelConfig.configFromAmQuick(config) }
ConfigType.WIREGUARD -> TunnelConfig.configFromWgQuick(config) }
} } catch (e: Exception) {
} Timber.e(e)
NumberUtils.generateRandomTunnelName()
}
}
private fun generateQrCodeDefaultName(config: String, configType: ConfigType): String { private fun generateQrCodeTunnelName(config: String, configType: ConfigType): String {
return try { var defaultName = generateQrCodeDefaultName(config, configType)
when (configType) { val lines = config.lines().toMutableList()
ConfigType.AMNEZIA -> { val linesIterator = lines.iterator()
TunnelConfig.configFromAmQuick(config).peers[0].endpoint.get().host while (linesIterator.hasNext()) {
} val next = linesIterator.next()
if (next.contains(Constants.QR_CODE_NAME_PROPERTY)) {
defaultName = next.substringAfter(Constants.QR_CODE_NAME_PROPERTY).trim()
break
}
}
return defaultName
}
ConfigType.WIREGUARD -> { suspend fun onTunnelQrResult(result: String, configType: ConfigType): Result<Unit> {
TunnelConfig.configFromWgQuick(config).peers[0].endpoint.get().host return withContext(ioDispatcher) {
} try {
} validateConfigString(result, configType)
} catch (e: Exception) { val tunnelName =
Timber.e(e) makeTunnelNameUnique(generateQrCodeTunnelName(result, configType))
NumberUtils.generateRandomTunnelName() val tunnelConfig =
} when (configType) {
} ConfigType.AMNEZIA -> {
TunnelConfig(
name = tunnelName,
amQuick = result,
wgQuick =
TunnelConfig.configFromAmQuick(
result,
).toWgQuickString(),
)
}
private fun generateQrCodeTunnelName(config: String, configType: ConfigType): String { ConfigType.WIREGUARD ->
var defaultName = generateQrCodeDefaultName(config, configType) TunnelConfig(
val lines = config.lines().toMutableList() name = tunnelName,
val linesIterator = lines.iterator() wgQuick = result,
while (linesIterator.hasNext()) { )
val next = linesIterator.next() }
if (next.contains(Constants.QR_CODE_NAME_PROPERTY)) { addTunnel(tunnelConfig)
defaultName = next.substringAfter(Constants.QR_CODE_NAME_PROPERTY).trim() Result.success(Unit)
break } catch (e: Exception) {
} Timber.e(e)
} Result.failure(WgTunnelExceptions.InvalidQrCode())
return defaultName }
} }
}
suspend fun onTunnelQrResult(result: String, configType: ConfigType): Result<Unit> { private suspend fun makeTunnelNameUnique(name: String): String {
return withContext(ioDispatcher) { return withContext(ioDispatcher) {
try { val tunnels = appDataRepository.tunnels.getAll()
validateConfigString(result, configType) var tunnelName = name
val tunnelName = makeTunnelNameUnique(generateQrCodeTunnelName(result, configType)) var num = 1
val tunnelConfig = when (configType) { while (tunnels.any { it.name == tunnelName }) {
ConfigType.AMNEZIA -> { tunnelName = name + "($num)"
TunnelConfig( num++
name = tunnelName, amQuick = result, }
wgQuick = TunnelConfig.configFromAmQuick(result).toWgQuickString(), tunnelName
) }
} }
ConfigType.WIREGUARD -> TunnelConfig(name = tunnelName, wgQuick = result) private fun saveTunnelConfigFromStream(stream: InputStream, fileName: String, type: ConfigType) {
} var amQuick: String? = null
addTunnel(tunnelConfig) val wgQuick =
Result.success(Unit) stream.use {
} catch (e: Exception) { when (type) {
Timber.e(e) ConfigType.AMNEZIA -> {
Result.failure(WgTunnelExceptions.InvalidQrCode()) val config = org.amnezia.awg.config.Config.parse(it)
} amQuick = config.toAwgQuickString()
} config.toWgQuickString()
} }
private suspend fun makeTunnelNameUnique(name: String): String { ConfigType.WIREGUARD -> {
return withContext(ioDispatcher) { Config.parse(it).toWgQuickString()
val tunnels = appDataRepository.tunnels.getAll() }
var tunnelName = name }
var num = 1 }
while (tunnels.any { it.name == tunnelName }) { viewModelScope.launch {
tunnelName = name + "(${num})" val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName))
num++ addTunnel(
} TunnelConfig(
tunnelName name = tunnelName,
} wgQuick = wgQuick,
} amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
),
)
}
}
private fun saveTunnelConfigFromStream( private fun getInputStreamFromUri(uri: Uri, context: Context): InputStream? {
stream: InputStream, return context.applicationContext.contentResolver.openInputStream(uri)
fileName: String, }
type: ConfigType
) {
var amQuick: String? = null
val wgQuick = stream.use {
when (type) {
ConfigType.AMNEZIA -> {
val config = org.amnezia.awg.config.Config.parse(it)
amQuick = config.toAwgQuickString()
config.toWgQuickString()
}
ConfigType.WIREGUARD -> { suspend fun onTunnelFileSelected(uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
Config.parse(it).toWgQuickString() return withContext(ioDispatcher) {
} try {
} if (isValidUriContentScheme(uri)) {
} val fileName = getFileName(context, uri)
viewModelScope.launch { return@withContext when (getFileExtensionFromFileName(fileName)) {
val tunnelName = makeTunnelNameUnique(getNameFromFileName(fileName)) Constants.CONF_FILE_EXTENSION ->
addTunnel( saveTunnelFromConfUri(fileName, uri, configType, context)
TunnelConfig(
name = tunnelName,
wgQuick = wgQuick,
amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
),
)
}
}
private fun getInputStreamFromUri(uri: Uri, context: Context): InputStream? { Constants.ZIP_FILE_EXTENSION ->
return context.applicationContext.contentResolver.openInputStream(uri) saveTunnelsFromZipUri(
} uri,
configType,
context,
)
suspend fun onTunnelFileSelected( else -> Result.failure(WgTunnelExceptions.InvalidFileExtension())
uri: Uri, }
configType: ConfigType, } else {
context: Context Result.failure(WgTunnelExceptions.InvalidFileExtension())
): Result<Unit> { }
return withContext(ioDispatcher) { } catch (e: Exception) {
try { Timber.e(e)
if (isValidUriContentScheme(uri)) { Result.failure(WgTunnelExceptions.FileReadFailed())
val fileName = getFileName(context, uri) }
return@withContext when (getFileExtensionFromFileName(fileName)) { }
Constants.CONF_FILE_EXTENSION -> }
saveTunnelFromConfUri(fileName, uri, configType, context)
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri( private suspend fun saveTunnelsFromZipUri(uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
uri, return withContext(ioDispatcher) {
configType, ZipInputStream(getInputStreamFromUri(uri, context)).use { zip ->
context, generateSequence { zip.nextEntry }
) .filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
withContext(viewModelScope.coroutineContext) {
try {
var amQuick: String? = null
val wgQuick =
when (configType) {
ConfigType.AMNEZIA -> {
val config =
org.amnezia.awg.config.Config.parse(
zip,
)
amQuick = config.toAwgQuickString()
config.toWgQuickString()
}
else -> Result.failure(WgTunnelExceptions.InvalidFileExtension()) ConfigType.WIREGUARD -> {
} Config.parse(zip).toWgQuickString()
} else { }
Result.failure(WgTunnelExceptions.InvalidFileExtension()) }
} addTunnel(
} catch (e: Exception) { TunnelConfig(
Timber.e(e) name = makeTunnelNameUnique(name),
Result.failure(WgTunnelExceptions.FileReadFailed()) wgQuick = wgQuick,
} amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
} ),
} )
Result.success(Unit)
} catch (e: Exception) {
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}
}
Result.success(Unit)
}
}
}
private suspend fun saveTunnelsFromZipUri( private suspend fun saveTunnelFromConfUri(name: String, uri: Uri, configType: ConfigType, context: Context): Result<Unit> {
uri: Uri, return withContext(ioDispatcher) {
configType: ConfigType, val stream = getInputStreamFromUri(uri, context)
context: Context return@withContext if (stream != null) {
): Result<Unit> { try {
return withContext(ioDispatcher) { saveTunnelConfigFromStream(stream, name, configType)
ZipInputStream(getInputStreamFromUri(uri, context)).use { zip -> } catch (e: Exception) {
generateSequence { zip.nextEntry } return@withContext Result.failure(WgTunnelExceptions.ConfigParseError())
.filterNot { }
it.isDirectory || Result.success(Unit)
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION } else {
} Result.failure(WgTunnelExceptions.FileReadFailed())
.forEach { }
val name = getNameFromFileName(it.name) }
withContext(viewModelScope.coroutineContext) { }
try {
var amQuick: String? = null
val wgQuick =
when (configType) {
ConfigType.AMNEZIA -> {
val config = org.amnezia.awg.config.Config.parse(zip)
amQuick = config.toAwgQuickString()
config.toWgQuickString()
}
ConfigType.WIREGUARD -> { private fun addTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
Config.parse(zip).toWgQuickString() val firstTunnel = appDataRepository.tunnels.count() == 0
} saveTunnel(tunnelConfig)
} if (firstTunnel) WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate()
addTunnel( }
TunnelConfig(
name = makeTunnelNameUnique(name),
wgQuick = wgQuick,
amQuick = amQuick ?: TunnelConfig.AM_QUICK_DEFAULT,
),
)
Result.success(Unit)
} catch (e: Exception) {
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}
}
Result.success(Unit)
}
}
}
private suspend fun saveTunnelFromConfUri( fun pauseAutoTunneling() = viewModelScope.launch {
name: String, appDataRepository.settings.save(
uri: Uri, uiState.value.settings.copy(isAutoTunnelPaused = true),
configType: ConfigType, )
context: Context WireGuardAutoTunnel.requestAutoTunnelTileServiceUpdate()
): Result<Unit> { }
return withContext(ioDispatcher) {
val stream = getInputStreamFromUri(uri, context)
return@withContext if (stream != null) {
try {
saveTunnelConfigFromStream(stream, name, configType)
} catch (e: Exception) {
return@withContext Result.failure(WgTunnelExceptions.ConfigParseError())
}
Result.success(Unit)
} else {
Result.failure(WgTunnelExceptions.FileReadFailed())
}
}
}
private fun addTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch { fun resumeAutoTunneling() = viewModelScope.launch {
val firstTunnel = appDataRepository.tunnels.count() == 0 appDataRepository.settings.save(
saveTunnel(tunnelConfig) uiState.value.settings.copy(isAutoTunnelPaused = false),
if (firstTunnel) WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate() )
} WireGuardAutoTunnel.requestAutoTunnelTileServiceUpdate()
}
fun pauseAutoTunneling() = private fun saveTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch {
viewModelScope.launch { appDataRepository.tunnels.save(tunnelConfig)
appDataRepository.settings.save(uiState.value.settings.copy(isAutoTunnelPaused = true)) }
WireGuardAutoTunnel.requestAutoTunnelTileServiceUpdate()
}
fun resumeAutoTunneling() = private fun getFileNameByCursor(context: Context, uri: Uri): String? {
viewModelScope.launch { context.contentResolver.query(uri, null, null, null, null)?.use {
appDataRepository.settings.save(uiState.value.settings.copy(isAutoTunnelPaused = false)) return getDisplayNameByCursor(it)
WireGuardAutoTunnel.requestAutoTunnelTileServiceUpdate() }
} return null
}
private fun saveTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch { private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
appDataRepository.tunnels.save(tunnelConfig) val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
} return if (columnIndex != -1) {
return columnIndex
} else {
null
}
}
private fun getFileNameByCursor(context: Context, uri: Uri): String? { private fun getDisplayNameByCursor(cursor: Cursor): String? {
context.contentResolver.query(uri, null, null, null, null)?.use { return if (cursor.moveToFirst()) {
return getDisplayNameByCursor(it) val index = getDisplayNameColumnIndex(cursor)
} if (index != null) {
return null cursor.getString(index)
} } else {
null
}
} else {
null
}
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? { private fun isValidUriContentScheme(uri: Uri): Boolean {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) return uri.scheme == Constants.URI_CONTENT_SCHEME
return if (columnIndex != -1) { }
return columnIndex
} else {
null
}
}
private fun getDisplayNameByCursor(cursor: Cursor): String? { private fun getFileName(context: Context, uri: Uri): String {
return if (cursor.moveToFirst()) { return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName()
val index = getDisplayNameColumnIndex(cursor) }
if (index != null) {
cursor.getString(index)
} else null
} else null
}
private fun isValidUriContentScheme(uri: Uri): Boolean { private fun getNameFromFileName(fileName: String): String {
return uri.scheme == Constants.URI_CONTENT_SCHEME return fileName.substring(0, fileName.lastIndexOf('.'))
} }
private fun getFileName(context: Context, uri: Uri): String { private fun getFileExtensionFromFileName(fileName: String): String? {
return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName() return try {
} fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) {
Timber.e(e)
null
}
}
private fun getNameFromFileName(fileName: String): String { private fun saveSettings(settings: Settings) = viewModelScope.launch { appDataRepository.settings.save(settings) }
return fileName.substring(0, fileName.lastIndexOf('.'))
}
private fun getFileExtensionFromFileName(fileName: String): String? { fun onCopyTunnel(tunnel: TunnelConfig?) = viewModelScope.launch {
return try { tunnel?.let {
fileName.substring(fileName.lastIndexOf('.')) saveTunnel(
} catch (e: Exception) { TunnelConfig(
Timber.e(e) name = it.name.plus(NumberUtils.randomThree()),
null wgQuick = it.wgQuick,
} ),
} )
}
private fun saveSettings(settings: Settings) = }
viewModelScope.launch { appDataRepository.settings.save(settings) }
fun onCopyTunnel(tunnel: TunnelConfig?) = viewModelScope.launch {
tunnel?.let {
saveTunnel(
TunnelConfig(
name = it.name.plus(NumberUtils.randomThree()),
wgQuick = it.wgQuick,
),
)
}
}
} }
@@ -77,283 +77,295 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalLayoutApi::class) @OptIn(ExperimentalLayoutApi::class)
@Composable @Composable
fun OptionsScreen( fun OptionsScreen(
optionsViewModel: OptionsViewModel = hiltViewModel(), optionsViewModel: OptionsViewModel = hiltViewModel(),
navController: NavController, navController: NavController,
appViewModel: AppViewModel, appViewModel: AppViewModel,
focusRequester: FocusRequester, focusRequester: FocusRequester,
tunnelId: String tunnelId: String,
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val uiState by optionsViewModel.uiState.collectAsStateWithLifecycle() val uiState by optionsViewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val screenPadding = 5.dp val screenPadding = 5.dp
val fillMaxWidth = .85f val fillMaxWidth = .85f
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
optionsViewModel.init(tunnelId) optionsViewModel.init(tunnelId)
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
delay(Constants.FOCUS_REQUEST_DELAY) delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus() focusRequester.requestFocus()
} }
} }
fun saveTrustedSSID() { fun saveTrustedSSID() {
if (currentText.isNotEmpty()) { if (currentText.isNotEmpty()) {
scope.launch { scope.launch {
optionsViewModel.onSaveRunSSID(currentText).onSuccess { optionsViewModel.onSaveRunSSID(currentText).onSuccess {
currentText = "" currentText = ""
}.onFailure { }.onFailure {
appViewModel.showSnackbarMessage(it.getMessage(context)) appViewModel.showSnackbarMessage(it.getMessage(context))
} }
} }
} }
} }
Scaffold( Scaffold(
floatingActionButton = { floatingActionButton = {
val secondaryColor = MaterialTheme.colorScheme.secondary val secondaryColor = MaterialTheme.colorScheme.secondary
val tvFobColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) val tvFobColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
val fobColor = val fobColor =
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) tvFobColor else secondaryColor if (WireGuardAutoTunnel.isRunningOnAndroidTv()) tvFobColor else secondaryColor
val fobIconColor = val fobIconColor =
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) Color.White else MaterialTheme.colorScheme.background if (WireGuardAutoTunnel.isRunningOnAndroidTv()) Color.White else MaterialTheme.colorScheme.background
AnimatedVisibility( AnimatedVisibility(
visible = true, visible = true,
enter = slideInVertically(initialOffsetY = { it * 2 }), enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 }), exit = slideOutVertically(targetOffsetY = { it * 2 }),
modifier = Modifier modifier =
.focusRequester(focusRequester) Modifier
.focusGroup(), .focusRequester(focusRequester)
) { .focusGroup(),
MultiFloatingActionButton( ) {
fabIcon = FabIcon( MultiFloatingActionButton(
iconRes = R.drawable.edit, fabIcon =
iconResAfterRotate = R.drawable.close, FabIcon(
iconRotate = 180f, iconRes = R.drawable.edit,
), iconResAfterRotate = R.drawable.close,
fabOption = FabOption( iconRotate = 180f,
iconTint = fobIconColor, ),
backgroundTint = fobColor, fabOption =
), FabOption(
itemsMultiFab = listOf( iconTint = fobIconColor,
MultiFabItem( backgroundTint = fobColor,
label = { ),
Text( itemsMultiFab =
stringResource(id = R.string.amnezia), listOf(
color = Color.White, MultiFabItem(
textAlign = TextAlign.Center, label = {
modifier = Modifier.padding(end = 10.dp), Text(
) stringResource(id = R.string.amnezia),
}, color = Color.White,
modifier = Modifier textAlign = TextAlign.Center,
.size(40.dp), modifier = Modifier.padding(end = 10.dp),
icon = R.drawable.edit, )
value = ConfigType.AMNEZIA.name, },
miniFabOption = FabOption( modifier =
backgroundTint = fobColor, Modifier
fobIconColor, .size(40.dp),
), icon = R.drawable.edit,
), value = ConfigType.AMNEZIA.name,
MultiFabItem( miniFabOption =
label = { FabOption(
Text( backgroundTint = fobColor,
stringResource(id = R.string.wireguard), fobIconColor,
color = Color.White, ),
textAlign = TextAlign.Center, ),
modifier = Modifier.padding(end = 10.dp), MultiFabItem(
) label = {
}, Text(
icon = R.drawable.edit, stringResource(id = R.string.wireguard),
value = ConfigType.WIREGUARD.name, color = Color.White,
miniFabOption = FabOption( textAlign = TextAlign.Center,
backgroundTint = fobColor, modifier = Modifier.padding(end = 10.dp),
fobIconColor, )
), },
), icon = R.drawable.edit,
), value = ConfigType.WIREGUARD.name,
onFabItemClicked = { miniFabOption =
val configType = ConfigType.valueOf(it.value) FabOption(
navController.navigate( backgroundTint = fobColor,
"${Screen.Config.route}/${tunnelId}?configType=${configType.name}", fobIconColor,
) ),
}, ),
shape = RoundedCornerShape(16.dp), ),
) onFabItemClicked = {
} val configType = ConfigType.valueOf(it.value)
}, navController.navigate(
) { "${Screen.Config.route}/$tunnelId?configType=${configType.name}",
Column( )
horizontalAlignment = Alignment.CenterHorizontally, },
verticalArrangement = Arrangement.Top, shape = RoundedCornerShape(16.dp),
modifier = )
Modifier }
.fillMaxSize() },
.verticalScroll(scrollState) ) {
.clickable( Column(
indication = null, horizontalAlignment = Alignment.CenterHorizontally,
interactionSource = interactionSource, verticalArrangement = Arrangement.Top,
) { modifier =
focusManager.clearFocus() Modifier
}, .fillMaxSize()
) { .verticalScroll(scrollState)
Surface( .clickable(
tonalElevation = 2.dp, indication = null,
shadowElevation = 2.dp, interactionSource = interactionSource,
shape = RoundedCornerShape(12.dp), ) {
color = MaterialTheme.colorScheme.surface, focusManager.clearFocus()
modifier = },
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { ) {
Modifier Surface(
.height(IntrinsicSize.Min) tonalElevation = 2.dp,
.fillMaxWidth(fillMaxWidth) shadowElevation = 2.dp,
.padding(top = 10.dp) shape = RoundedCornerShape(12.dp),
} else { color = MaterialTheme.colorScheme.surface,
Modifier modifier =
.fillMaxWidth(fillMaxWidth) (
.padding(top = 20.dp) if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
}) Modifier
.padding(bottom = 10.dp), .height(IntrinsicSize.Min)
) { .fillMaxWidth(fillMaxWidth)
Column( .padding(top = 10.dp)
horizontalAlignment = Alignment.Start, } else {
verticalArrangement = Arrangement.Top, Modifier
modifier = Modifier.padding(15.dp), .fillMaxWidth(fillMaxWidth)
) { .padding(top = 20.dp)
SectionTitle( }
title = stringResource(id = R.string.general), )
padding = screenPadding, .padding(bottom = 10.dp),
) ) {
ConfigurationToggle( Column(
stringResource(R.string.set_primary_tunnel), horizontalAlignment = Alignment.Start,
enabled = true, verticalArrangement = Arrangement.Top,
checked = uiState.isDefaultTunnel, modifier = Modifier.padding(15.dp),
modifier = Modifier ) {
.focusRequester(focusRequester), SectionTitle(
padding = screenPadding, title = stringResource(id = R.string.general),
onCheckChanged = { optionsViewModel.onTogglePrimaryTunnel() }, padding = screenPadding,
) )
} ConfigurationToggle(
} stringResource(R.string.set_primary_tunnel),
Surface( enabled = true,
tonalElevation = 2.dp, checked = uiState.isDefaultTunnel,
shadowElevation = 2.dp, modifier =
shape = RoundedCornerShape(12.dp), Modifier
color = MaterialTheme.colorScheme.surface, .focusRequester(focusRequester),
modifier = padding = screenPadding,
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { onCheckChanged = { optionsViewModel.onTogglePrimaryTunnel() },
Modifier )
.height(IntrinsicSize.Min) }
.fillMaxWidth(fillMaxWidth) }
.padding(top = 10.dp) Surface(
} else { tonalElevation = 2.dp,
Modifier shadowElevation = 2.dp,
.fillMaxWidth(fillMaxWidth) shape = RoundedCornerShape(12.dp),
.padding(top = 20.dp) color = MaterialTheme.colorScheme.surface,
}) modifier =
.padding(bottom = 10.dp), (
) { if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Column( Modifier
horizontalAlignment = Alignment.Start, .height(IntrinsicSize.Min)
verticalArrangement = Arrangement.Top, .fillMaxWidth(fillMaxWidth)
modifier = Modifier.padding(15.dp), .padding(top = 10.dp)
) { } else {
SectionTitle( Modifier
title = stringResource(id = R.string.auto_tunneling), .fillMaxWidth(fillMaxWidth)
padding = screenPadding, .padding(top = 20.dp)
) }
ConfigurationToggle( )
stringResource(R.string.mobile_data_tunnel), .padding(bottom = 10.dp),
enabled = true, ) {
checked = uiState.tunnel?.isMobileDataTunnel == true, Column(
padding = screenPadding, horizontalAlignment = Alignment.Start,
onCheckChanged = { optionsViewModel.onToggleIsMobileDataTunnel() }, verticalArrangement = Arrangement.Top,
) modifier = Modifier.padding(15.dp),
Column { ) {
FlowRow( SectionTitle(
modifier = Modifier title = stringResource(id = R.string.auto_tunneling),
.padding(screenPadding) padding = screenPadding,
.fillMaxWidth(), )
horizontalArrangement = Arrangement.spacedBy(5.dp), ConfigurationToggle(
) { stringResource(R.string.mobile_data_tunnel),
uiState.tunnel?.tunnelNetworks?.forEach { ssid -> enabled = true,
ClickableIconButton( checked = uiState.tunnel?.isMobileDataTunnel == true,
onClick = { padding = screenPadding,
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { onCheckChanged = { optionsViewModel.onToggleIsMobileDataTunnel() },
focusRequester.requestFocus() )
optionsViewModel.onDeleteRunSSID(ssid) Column {
} FlowRow(
}, modifier =
onIconClick = { Modifier
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) focusRequester.requestFocus() .padding(screenPadding)
optionsViewModel.onDeleteRunSSID(ssid) .fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp),
}, ) {
text = ssid, uiState.tunnel?.tunnelNetworks?.forEach { ssid ->
icon = Icons.Filled.Close, ClickableIconButton(
enabled = true, onClick = {
) if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
} focusRequester.requestFocus()
if (uiState.tunnel == null || uiState.tunnel?.tunnelNetworks?.isEmpty() == true) { optionsViewModel.onDeleteRunSSID(ssid)
Text( }
stringResource(R.string.no_wifi_names_configured), },
fontStyle = FontStyle.Italic, onIconClick = {
color = Color.Gray, if (WireGuardAutoTunnel.isRunningOnAndroidTv()) focusRequester.requestFocus()
) optionsViewModel.onDeleteRunSSID(ssid)
} },
} text = ssid,
OutlinedTextField( icon = Icons.Filled.Close,
enabled = true, enabled = true,
value = currentText, )
onValueChange = { currentText = it }, }
label = { Text(stringResource(id = R.string.use_tunnel_on_wifi_name)) }, if (uiState.tunnel == null || uiState.tunnel?.tunnelNetworks?.isEmpty() == true) {
modifier = Text(
Modifier stringResource(R.string.no_wifi_names_configured),
.padding( fontStyle = FontStyle.Italic,
start = screenPadding, color = Color.Gray,
top = 5.dp, )
bottom = 10.dp, }
), }
maxLines = 1, OutlinedTextField(
keyboardOptions = enabled = true,
KeyboardOptions( value = currentText,
capitalization = KeyboardCapitalization.None, onValueChange = { currentText = it },
imeAction = ImeAction.Done, label = { Text(stringResource(id = R.string.use_tunnel_on_wifi_name)) },
), modifier =
keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }), Modifier
trailingIcon = { .padding(
if (currentText != "") { start = screenPadding,
IconButton(onClick = { saveTrustedSSID() }) { top = 5.dp,
Icon( bottom = 10.dp,
imageVector = Icons.Outlined.Add, ),
contentDescription = maxLines = 1,
if (currentText == "") { keyboardOptions =
stringResource( KeyboardOptions(
id = capitalization = KeyboardCapitalization.None,
R.string imeAction = ImeAction.Done,
.trusted_ssid_empty_description, ),
) keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }),
} else { trailingIcon = {
stringResource( if (currentText != "") {
id = IconButton(onClick = { saveTrustedSSID() }) {
R.string Icon(
.trusted_ssid_value_description, imageVector = Icons.Outlined.Add,
) contentDescription =
}, if (currentText == "") {
tint = MaterialTheme.colorScheme.primary, stringResource(
) id =
} R.string
} .trusted_ssid_empty_description,
}, )
) } else {
} stringResource(
} id =
} R.string
} .trusted_ssid_value_description,
} )
},
tint = MaterialTheme.colorScheme.primary,
)
}
}
},
)
}
}
}
}
}
} }
@@ -3,7 +3,7 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.options
import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
data class OptionsUiState( data class OptionsUiState(
val id: String? = null, val id: String? = null,
val tunnel: TunnelConfig? = null, val tunnel: TunnelConfig? = null,
val isDefaultTunnel: Boolean = false val isDefaultTunnel: Boolean = false,
) )
@@ -19,84 +19,92 @@ import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class OptionsViewModel @Inject class OptionsViewModel
@Inject
constructor( constructor(
private val appDataRepository: AppDataRepository private val appDataRepository: AppDataRepository,
) : ViewModel() { ) : ViewModel() {
private val _optionState = MutableStateFlow(OptionsUiState())
private val _optionState = MutableStateFlow(OptionsUiState()) val uiState =
combine(
appDataRepository.tunnels.getTunnelConfigsFlow(),
_optionState,
) { tunnels, optionState ->
if (optionState.id != null) {
val tunnelConfig = tunnels.fastFirstOrNull { it.id.toString() == optionState.id }
val isPrimaryTunnel = tunnelConfig?.isPrimaryTunnel == true
OptionsUiState(optionState.id, tunnelConfig, isPrimaryTunnel)
} else {
OptionsUiState()
}
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
OptionsUiState(),
)
val uiState = combine( fun init(tunnelId: String) {
appDataRepository.tunnels.getTunnelConfigsFlow(), _optionState.update {
_optionState, it.copy(
) { tunnels, optionState -> id = tunnelId,
if (optionState.id != null) { )
val tunnelConfig = tunnels.fastFirstOrNull { it.id.toString() == optionState.id } }
val isPrimaryTunnel = tunnelConfig?.isPrimaryTunnel == true }
OptionsUiState(optionState.id, tunnelConfig, isPrimaryTunnel)
} else OptionsUiState()
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
OptionsUiState(),
)
fun init(tunnelId: String) { fun onDeleteRunSSID(ssid: String) = viewModelScope.launch {
_optionState.update { uiState.value.tunnel?.let {
it.copy( appDataRepository.tunnels.save(
id = tunnelId, tunnelConfig =
) it.copy(
} tunnelNetworks = (uiState.value.tunnel!!.tunnelNetworks - ssid).toMutableList(),
} ),
)
}
}
fun onDeleteRunSSID(ssid: String) = viewModelScope.launch { private fun saveTunnel(tunnelConfig: TunnelConfig?) = viewModelScope.launch {
uiState.value.tunnel?.let { tunnelConfig?.let {
appDataRepository.tunnels.save( appDataRepository.tunnels.save(it)
tunnelConfig = it.copy( }
tunnelNetworks = (uiState.value.tunnel!!.tunnelNetworks - ssid).toMutableList(), }
),
)
}
}
private fun saveTunnel(tunnelConfig: TunnelConfig?) = viewModelScope.launch { suspend fun onSaveRunSSID(ssid: String): Result<Unit> {
tunnelConfig?.let { val trimmed = ssid.trim()
appDataRepository.tunnels.save(it) val tunnelsWithName =
} withContext(viewModelScope.coroutineContext) {
} appDataRepository.tunnels.findByTunnelNetworksName(trimmed)
}
return if (uiState.value.tunnel?.tunnelNetworks?.contains(trimmed) != true &&
tunnelsWithName.isEmpty()
) {
uiState.value.tunnel?.tunnelNetworks?.add(trimmed)
saveTunnel(uiState.value.tunnel)
Result.success(Unit)
} else {
Result.failure(WgTunnelExceptions.SsidConflict())
}
}
suspend fun onSaveRunSSID(ssid: String): Result<Unit> { fun onToggleIsMobileDataTunnel() = viewModelScope.launch {
val trimmed = ssid.trim() uiState.value.tunnel?.let {
val tunnelsWithName = withContext(viewModelScope.coroutineContext) { if (it.isMobileDataTunnel) {
appDataRepository.tunnels.findByTunnelNetworksName(trimmed) appDataRepository.tunnels.updateMobileDataTunnel(null)
} } else {
return if (uiState.value.tunnel?.tunnelNetworks?.contains(trimmed) != true && appDataRepository.tunnels.updateMobileDataTunnel(it)
tunnelsWithName.isEmpty()) { }
uiState.value.tunnel?.tunnelNetworks?.add(trimmed) }
saveTunnel(uiState.value.tunnel) }
Result.success(Unit)
} else {
Result.failure(WgTunnelExceptions.SsidConflict())
}
}
fun onToggleIsMobileDataTunnel() = viewModelScope.launch { fun onTogglePrimaryTunnel() = viewModelScope.launch {
uiState.value.tunnel?.let { if (uiState.value.tunnel != null) {
if (it.isMobileDataTunnel) { appDataRepository.tunnels.updatePrimaryTunnel(
appDataRepository.tunnels.updateMobileDataTunnel(null) when (uiState.value.isDefaultTunnel) {
} else appDataRepository.tunnels.updateMobileDataTunnel(it) true -> null
} false -> uiState.value.tunnel
} },
)
fun onTogglePrimaryTunnel() = viewModelScope.launch { WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate()
if (uiState.value.tunnel != null) { }
appDataRepository.tunnels.updatePrimaryTunnel( }
when (uiState.value.isDefaultTunnel) {
true -> null
false -> uiState.value.tunnel
},
)
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate()
}
}
} }
@@ -15,39 +15,43 @@ import xyz.teamgravity.pin_lock_compose.PinLock
@Composable @Composable
fun PinLockScreen(navController: NavController, appViewModel: AppViewModel) { fun PinLockScreen(navController: NavController, appViewModel: AppViewModel) {
val context = LocalContext.current val context = LocalContext.current
PinLock( PinLock(
title = { pinExists -> title = { pinExists ->
Text( Text(
text = if (pinExists) stringResource(id = R.string.enter_pin) else stringResource( text =
id = R.string.create_pin, if (pinExists) {
), stringResource(id = R.string.enter_pin)
) } else {
}, stringResource(
color = MaterialTheme.colorScheme.surface, id = R.string.create_pin,
onPinCorrect = { )
// pin is correct, navigate or hide pin lock },
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { )
navController.navigate(Screen.Main.route) },
} else { color = MaterialTheme.colorScheme.surface,
val isPopped = navController.popBackStack() onPinCorrect = {
if (!isPopped) { // pin is correct, navigate or hide pin lock
navController.navigate(Screen.Main.route) if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
} navController.navigate(Screen.Main.route)
} } else {
val isPopped = navController.popBackStack()
}, if (!isPopped) {
onPinIncorrect = { navController.navigate(Screen.Main.route)
// pin is incorrect, show error }
appViewModel.showSnackbarMessage( }
StringValue.StringResource(R.string.incorrect_pin).asString(context), },
) onPinIncorrect = {
}, // pin is incorrect, show error
onPinCreated = { appViewModel.showSnackbarMessage(
// pin created for the first time, navigate or hide pin lock StringValue.StringResource(R.string.incorrect_pin).asString(context),
appViewModel.showSnackbarMessage( )
StringValue.StringResource(R.string.pin_created).asString(context), },
) onPinCreated = {
}, // pin created for the first time, navigate or hide pin lock
) appViewModel.showSnackbarMessage(
StringValue.StringResource(R.string.pin_created).asString(context),
)
},
)
} }
@@ -5,10 +5,10 @@ import com.zaneschepke.wireguardautotunnel.data.domain.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
data class SettingsUiState( data class SettingsUiState(
val settings: Settings = Settings(), val settings: Settings = Settings(),
val tunnels: List<TunnelConfig> = emptyList(), val tunnels: List<TunnelConfig> = emptyList(),
val vpnState: VpnState = VpnState(), val vpnState: VpnState = VpnState(),
val isLocationDisclosureShown: Boolean = true, val isLocationDisclosureShown: Boolean = true,
val isBatteryOptimizeDisableShown: Boolean = false, val isBatteryOptimizeDisableShown: Boolean = false,
val isPinLockEnabled: Boolean = false val isPinLockEnabled: Boolean = false,
) )
@@ -36,222 +36,219 @@ import javax.inject.Provider
class SettingsViewModel class SettingsViewModel
@Inject @Inject
constructor( constructor(
private val appDataRepository: AppDataRepository, private val appDataRepository: AppDataRepository,
private val serviceManager: ServiceManager, private val serviceManager: ServiceManager,
private val rootShell: Provider<RootShell>, private val rootShell: Provider<RootShell>,
private val fileUtils: FileUtils, private val fileUtils: FileUtils,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
vpnService: VpnService vpnService: VpnService,
) : ViewModel() { ) : ViewModel() {
private val _kernelSupport = MutableStateFlow(false)
val kernelSupport = _kernelSupport.asStateFlow()
private val _kernelSupport = MutableStateFlow(false) val uiState =
val kernelSupport = _kernelSupport.asStateFlow() combine(
appDataRepository.settings.getSettingsFlow(),
appDataRepository.tunnels.getTunnelConfigsFlow(),
vpnService.vpnState,
appDataRepository.appState.generalStateFlow,
) { settings, tunnels, tunnelState, generalState ->
SettingsUiState(
settings,
tunnels,
tunnelState,
generalState.isLocationDisclosureShown,
generalState.isBatteryOptimizationDisableShown,
generalState.isPinLockEnabled,
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
SettingsUiState(),
)
val uiState = fun onSaveTrustedSSID(ssid: String): Result<Unit> {
combine( val trimmed = ssid.trim()
appDataRepository.settings.getSettingsFlow(), return if (!uiState.value.settings.trustedNetworkSSIDs.contains(trimmed)) {
appDataRepository.tunnels.getTunnelConfigsFlow(), uiState.value.settings.trustedNetworkSSIDs.add(trimmed)
vpnService.vpnState, saveSettings(uiState.value.settings)
appDataRepository.appState.generalStateFlow, Result.success(Unit)
) { settings, tunnels, tunnelState, generalState -> } else {
SettingsUiState( Result.failure(WgTunnelExceptions.SsidConflict())
settings, }
tunnels, }
tunnelState,
generalState.isLocationDisclosureShown,
generalState.isBatteryOptimizationDisableShown,
generalState.isPinLockEnabled,
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
SettingsUiState(),
)
fun onSaveTrustedSSID(ssid: String): Result<Unit> { fun setLocationDisclosureShown() = viewModelScope.launch {
val trimmed = ssid.trim() appDataRepository.appState.setLocationDisclosureShown(true)
return if (!uiState.value.settings.trustedNetworkSSIDs.contains(trimmed)) { }
uiState.value.settings.trustedNetworkSSIDs.add(trimmed)
saveSettings(uiState.value.settings)
Result.success(Unit)
} else {
Result.failure(WgTunnelExceptions.SsidConflict())
}
}
fun setLocationDisclosureShown() = fun setBatteryOptimizeDisableShown() = viewModelScope.launch {
viewModelScope.launch { appDataRepository.appState.setBatteryOptimizationDisableShown(true)
appDataRepository.appState.setLocationDisclosureShown(true) }
}
fun setBatteryOptimizeDisableShown() = fun onToggleTunnelOnMobileData() {
viewModelScope.launch { saveSettings(
appDataRepository.appState.setBatteryOptimizationDisableShown(true) uiState.value.settings.copy(
} isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled,
),
)
}
fun onToggleTunnelOnMobileData() { fun onDeleteTrustedSSID(ssid: String) {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled, trustedNetworkSSIDs =
), (uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList(),
) ),
} )
}
fun onDeleteTrustedSSID(ssid: String) { suspend fun onExportTunnels(files: List<File>): Result<Unit> {
saveSettings( return fileUtils.saveFilesToZip(files)
uiState.value.settings.copy( }
trustedNetworkSSIDs =
(uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList(),
),
)
}
suspend fun onExportTunnels(files: List<File>): Result<Unit> { fun onToggleAutoTunnel(context: Context) = viewModelScope.launch {
return fileUtils.saveFilesToZip(files) val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled
} var isAutoTunnelPaused = uiState.value.settings.isAutoTunnelPaused
fun onToggleAutoTunnel(context: Context) = if (isAutoTunnelEnabled) {
viewModelScope.launch { serviceManager.stopWatcherService(context)
val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled } else {
var isAutoTunnelPaused = uiState.value.settings.isAutoTunnelPaused serviceManager.startWatcherService(context)
isAutoTunnelPaused = false
}
saveSettings(
uiState.value.settings.copy(
isAutoTunnelEnabled = !isAutoTunnelEnabled,
isAutoTunnelPaused = isAutoTunnelPaused,
),
)
WireGuardAutoTunnel.requestAutoTunnelTileServiceUpdate()
}
if (isAutoTunnelEnabled) { fun onToggleAlwaysOnVPN() = viewModelScope.launch {
serviceManager.stopWatcherService(context) saveSettings(
} else { uiState.value.settings.copy(
serviceManager.startWatcherService(context) isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled,
isAutoTunnelPaused = false ),
} )
saveSettings( }
uiState.value.settings.copy(
isAutoTunnelEnabled = !isAutoTunnelEnabled,
isAutoTunnelPaused = isAutoTunnelPaused,
),
)
WireGuardAutoTunnel.requestAutoTunnelTileServiceUpdate()
}
fun onToggleAlwaysOnVPN() = private fun saveSettings(settings: Settings) = viewModelScope.launch { appDataRepository.settings.save(settings) }
viewModelScope.launch {
saveSettings(
uiState.value.settings.copy(
isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled,
),
)
}
private fun saveSettings(settings: Settings) = fun onToggleTunnelOnEthernet() {
viewModelScope.launch { appDataRepository.settings.save(settings) } saveSettings(
uiState.value.settings.copy(
isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled,
),
)
}
fun onToggleTunnelOnEthernet() { fun isLocationEnabled(context: Context): Boolean {
saveSettings( val locationManager =
uiState.value.settings.copy( context.getSystemService(
isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled, Context.LOCATION_SERVICE,
), ) as LocationManager
) return LocationManagerCompat.isLocationEnabled(locationManager)
} }
fun isLocationEnabled(context: Context): Boolean { fun onToggleShortcutsEnabled() {
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager saveSettings(
return LocationManagerCompat.isLocationEnabled(locationManager) uiState.value.settings.copy(
} isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled,
),
)
}
fun onToggleShortcutsEnabled() { private fun saveKernelMode(on: Boolean) {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled, isKernelEnabled = on,
), ),
) )
} }
private fun saveKernelMode(on: Boolean) { fun onToggleTunnelOnWifi() {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isKernelEnabled = on, isTunnelOnWifiEnabled = !uiState.value.settings.isTunnelOnWifiEnabled,
), ),
) )
} }
fun onToggleTunnelOnWifi() { fun onToggleAmnezia() = viewModelScope.launch {
saveSettings( if (uiState.value.settings.isKernelEnabled) {
uiState.value.settings.copy( saveKernelMode(false)
isTunnelOnWifiEnabled = !uiState.value.settings.isTunnelOnWifiEnabled, }
), saveAmneziaMode(!uiState.value.settings.isAmneziaEnabled)
) }
}
fun onToggleAmnezia() = viewModelScope.launch { private fun saveAmneziaMode(on: Boolean) {
if (uiState.value.settings.isKernelEnabled) { saveSettings(
saveKernelMode(false) uiState.value.settings.copy(
} isAmneziaEnabled = on,
saveAmneziaMode(!uiState.value.settings.isAmneziaEnabled) ),
} )
}
private fun saveAmneziaMode(on: Boolean) { suspend fun onToggleKernelMode(): Result<Unit> {
saveSettings( return withContext(ioDispatcher) {
uiState.value.settings.copy( if (!uiState.value.settings.isKernelEnabled) {
isAmneziaEnabled = on, try {
), rootShell.get().start()
) Timber.i("Root shell accepted!")
} saveSettings(
uiState.value.settings.copy(
isKernelEnabled = true,
isAmneziaEnabled = false,
),
)
} catch (e: RootShell.RootShellException) {
Timber.e(e)
saveKernelMode(on = false)
return@withContext Result.failure(WgTunnelExceptions.RootDenied())
}
} else {
saveKernelMode(on = false)
}
Result.success(Unit)
}
}
suspend fun onToggleKernelMode(): Result<Unit> { fun onToggleRestartOnPing() = viewModelScope.launch {
return withContext(ioDispatcher) { saveSettings(
if (!uiState.value.settings.isKernelEnabled) { uiState.value.settings.copy(
try { isPingEnabled = !uiState.value.settings.isPingEnabled,
rootShell.get().start() ),
Timber.i("Root shell accepted!") )
saveSettings( }
uiState.value.settings.copy(
isKernelEnabled = true,
isAmneziaEnabled = false,
),
)
} catch (e: RootShell.RootShellException) { fun checkKernelSupport() = viewModelScope.launch {
Timber.e(e) val kernelSupport =
saveKernelMode(on = false) withContext(ioDispatcher) {
return@withContext Result.failure(WgTunnelExceptions.RootDenied()) WgQuickBackend.hasKernelSupport()
} }
} else { _kernelSupport.update {
saveKernelMode(on = false) kernelSupport
} }
Result.success(Unit) }
}
}
fun onToggleRestartOnPing() = viewModelScope.launch { fun onPinLockDisabled() = viewModelScope.launch {
saveSettings( PinManager.clearPin()
uiState.value.settings.copy( appDataRepository.appState.setPinLockEnabled(false)
isPingEnabled = !uiState.value.settings.isPingEnabled, }
),
)
}
fun checkKernelSupport() = viewModelScope.launch { fun onPinLockEnabled() = viewModelScope.launch {
val kernelSupport = withContext(ioDispatcher) { PinManager.initialize(WireGuardAutoTunnel.instance)
WgQuickBackend.hasKernelSupport() appDataRepository.appState.setPinLockEnabled(true)
} }
_kernelSupport.update {
kernelSupport
}
}
fun onPinLockDisabled() = viewModelScope.launch { fun onToggleRestartAtBoot() = viewModelScope.launch {
PinManager.clearPin() saveSettings(
appDataRepository.appState.setPinLockEnabled(false) uiState.value.settings.copy(
} isRestoreOnBootEnabled = !uiState.value.settings.isRestoreOnBootEnabled,
),
fun onPinLockEnabled() = viewModelScope.launch { )
PinManager.initialize(WireGuardAutoTunnel.instance) }
appDataRepository.appState.setPinLockEnabled(true)
}
fun onToggleRestartAtBoot() = viewModelScope.launch {
saveSettings(
uiState.value.settings.copy(
isRestoreOnBootEnabled = !uiState.value.settings.isRestoreOnBootEnabled
)
)
}
} }
@@ -54,262 +54,268 @@ import com.zaneschepke.wireguardautotunnel.ui.Screen
@Composable @Composable
fun SupportScreen( fun SupportScreen(
viewModel: SupportViewModel = hiltViewModel(), viewModel: SupportViewModel = hiltViewModel(),
appViewModel: AppViewModel, appViewModel: AppViewModel,
navController: NavController, navController: NavController,
focusRequester: FocusRequester focusRequester: FocusRequester,
) { ) {
val context = LocalContext.current val context = LocalContext.current
val fillMaxWidth = .85f val fillMaxWidth = .85f
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.focusable(), .focusable(),
) { ) {
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { (
Modifier if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
.height(IntrinsicSize.Min) Modifier
.fillMaxWidth(fillMaxWidth) .height(IntrinsicSize.Min)
.padding(top = 10.dp) .fillMaxWidth(fillMaxWidth)
} else { .padding(top = 10.dp)
Modifier } else {
.fillMaxWidth(fillMaxWidth) Modifier
.padding(top = 20.dp) .fillMaxWidth(fillMaxWidth)
}) .padding(top = 20.dp)
.padding(bottom = 25.dp), }
) { )
Column(modifier = Modifier.padding(20.dp)) { .padding(bottom = 25.dp),
val forwardIcon = Icons.AutoMirrored.Rounded.ArrowForward ) {
Text( Column(modifier = Modifier.padding(20.dp)) {
stringResource(R.string.thank_you), val forwardIcon = Icons.AutoMirrored.Rounded.ArrowForward
textAlign = TextAlign.Start, Text(
fontWeight = FontWeight.Bold, stringResource(R.string.thank_you),
modifier = Modifier.padding(bottom = 20.dp), textAlign = TextAlign.Start,
fontSize = 16.sp, fontWeight = FontWeight.Bold,
) modifier = Modifier.padding(bottom = 20.dp),
Text( fontSize = 16.sp,
stringResource(id = R.string.support_help_text), )
textAlign = TextAlign.Start, Text(
fontSize = 16.sp, stringResource(id = R.string.support_help_text),
modifier = Modifier.padding(bottom = 20.dp), textAlign = TextAlign.Start,
) fontSize = 16.sp,
TextButton( modifier = Modifier.padding(bottom = 20.dp),
onClick = { )
appViewModel.openWebPage( TextButton(
context.resources.getString(R.string.docs_url), onClick = {
context, appViewModel.openWebPage(
) context.resources.getString(R.string.docs_url),
}, context,
modifier = Modifier )
.padding(vertical = 5.dp) },
.focusRequester(focusRequester), modifier =
) { Modifier
Row( .padding(vertical = 5.dp)
horizontalArrangement = Arrangement.SpaceBetween, .focusRequester(focusRequester),
verticalAlignment = Alignment.CenterVertically, ) {
modifier = Modifier.fillMaxWidth(), Row(
) { horizontalArrangement = Arrangement.SpaceBetween,
Row { verticalAlignment = Alignment.CenterVertically,
val icon = Icons.Rounded.Book modifier = Modifier.fillMaxWidth(),
Icon(icon, icon.name) ) {
Text( Row {
stringResource(id = R.string.docs_description), val icon = Icons.Rounded.Book
textAlign = TextAlign.Justify, Icon(icon, icon.name)
modifier = Modifier Text(
.padding(start = 10.dp) stringResource(id = R.string.docs_description),
.weight( textAlign = TextAlign.Justify,
weight = 1.0f, modifier =
fill = false, Modifier
), .padding(start = 10.dp)
softWrap = true, .weight(
) weight = 1.0f,
} fill = false,
Icon( ),
forwardIcon, softWrap = true,
forwardIcon.name, )
) }
} Icon(
} forwardIcon,
HorizontalDivider( forwardIcon.name,
thickness = 0.5.dp, )
color = MaterialTheme.colorScheme.onBackground, }
) }
TextButton( HorizontalDivider(
onClick = { thickness = 0.5.dp,
appViewModel.openWebPage( color = MaterialTheme.colorScheme.onBackground,
context.resources.getString(R.string.telegram_url), )
context, TextButton(
) onClick = {
}, appViewModel.openWebPage(
modifier = Modifier.padding(vertical = 5.dp), context.resources.getString(R.string.telegram_url),
) { context,
Row( )
horizontalArrangement = Arrangement.SpaceBetween, },
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp),
modifier = Modifier.fillMaxWidth(), ) {
) { Row(
Row { horizontalArrangement = Arrangement.SpaceBetween,
val icon = ImageVector.vectorResource(R.drawable.telegram) verticalAlignment = Alignment.CenterVertically,
Icon( modifier = Modifier.fillMaxWidth(),
icon, ) {
icon.name, Row {
Modifier.size(25.dp), val icon = ImageVector.vectorResource(R.drawable.telegram)
) Icon(
Text( icon,
stringResource(id = R.string.discord_description), icon.name,
textAlign = TextAlign.Justify, Modifier.size(25.dp),
modifier = Modifier.padding(start = 10.dp), )
) Text(
} stringResource(id = R.string.discord_description),
Icon( textAlign = TextAlign.Justify,
forwardIcon, modifier = Modifier.padding(start = 10.dp),
forwardIcon.name, )
) }
} Icon(
} forwardIcon,
HorizontalDivider( forwardIcon.name,
thickness = 0.5.dp, )
color = MaterialTheme.colorScheme.onBackground, }
) }
TextButton( HorizontalDivider(
onClick = { thickness = 0.5.dp,
appViewModel.openWebPage( color = MaterialTheme.colorScheme.onBackground,
context.resources.getString(R.string.github_url), )
context, TextButton(
) onClick = {
}, appViewModel.openWebPage(
modifier = Modifier.padding(vertical = 5.dp), context.resources.getString(R.string.github_url),
) { context,
Row( )
horizontalArrangement = Arrangement.SpaceBetween, },
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp),
modifier = Modifier.fillMaxWidth(), ) {
) { Row(
Row { horizontalArrangement = Arrangement.SpaceBetween,
val icon = ImageVector.vectorResource(R.drawable.github) verticalAlignment = Alignment.CenterVertically,
Icon( modifier = Modifier.fillMaxWidth(),
imageVector = icon, ) {
icon.name, Row {
Modifier.size(25.dp), val icon = ImageVector.vectorResource(R.drawable.github)
) Icon(
Text( imageVector = icon,
stringResource(id = R.string.open_issue), icon.name,
textAlign = TextAlign.Justify, Modifier.size(25.dp),
modifier = Modifier.padding(start = 10.dp), )
) Text(
} stringResource(id = R.string.open_issue),
Icon( textAlign = TextAlign.Justify,
forwardIcon, modifier = Modifier.padding(start = 10.dp),
forwardIcon.name, )
) }
} Icon(
} forwardIcon,
HorizontalDivider( forwardIcon.name,
thickness = 0.5.dp, )
color = MaterialTheme.colorScheme.onBackground, }
) }
TextButton( HorizontalDivider(
onClick = { appViewModel.launchEmail(context) }, thickness = 0.5.dp,
modifier = Modifier.padding(vertical = 5.dp), color = MaterialTheme.colorScheme.onBackground,
) { )
Row( TextButton(
horizontalArrangement = Arrangement.SpaceBetween, onClick = { appViewModel.launchEmail(context) },
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp),
modifier = Modifier.fillMaxWidth(), ) {
) { Row(
Row { horizontalArrangement = Arrangement.SpaceBetween,
val icon = Icons.Rounded.Mail verticalAlignment = Alignment.CenterVertically,
Icon(icon, icon.name) modifier = Modifier.fillMaxWidth(),
Text( ) {
stringResource(id = R.string.email_description), Row {
textAlign = TextAlign.Justify, val icon = Icons.Rounded.Mail
modifier = Modifier.padding(start = 10.dp), Icon(icon, icon.name)
) Text(
} stringResource(id = R.string.email_description),
Icon( textAlign = TextAlign.Justify,
forwardIcon, modifier = Modifier.padding(start = 10.dp),
forwardIcon.name, )
) }
} Icon(
} forwardIcon,
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) { forwardIcon.name,
HorizontalDivider( )
thickness = 0.5.dp, }
color = MaterialTheme.colorScheme.onBackground, }
) if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
TextButton( HorizontalDivider(
onClick = { navController.navigate(Screen.Support.Logs.route) }, thickness = 0.5.dp,
modifier = Modifier.padding(vertical = 5.dp), color = MaterialTheme.colorScheme.onBackground,
) { )
Row( TextButton(
horizontalArrangement = Arrangement.SpaceBetween, onClick = { navController.navigate(Screen.Support.Logs.route) },
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp),
modifier = Modifier.fillMaxWidth(), ) {
) { Row(
Row { horizontalArrangement = Arrangement.SpaceBetween,
val icon = Icons.Rounded.FormatListNumbered verticalAlignment = Alignment.CenterVertically,
Icon(icon, icon.name) modifier = Modifier.fillMaxWidth(),
Text( ) {
stringResource(id = R.string.read_logs), Row {
textAlign = TextAlign.Justify, val icon = Icons.Rounded.FormatListNumbered
modifier = Modifier.padding(start = 10.dp), Icon(icon, icon.name)
) Text(
} stringResource(id = R.string.read_logs),
Icon( textAlign = TextAlign.Justify,
Icons.AutoMirrored.Rounded.ArrowForward, modifier = Modifier.padding(start = 10.dp),
stringResource(id = R.string.go), )
) }
} Icon(
} Icons.AutoMirrored.Rounded.ArrowForward,
} stringResource(id = R.string.go),
} )
} }
Spacer(modifier = Modifier.weight(1f)) }
Text( }
stringResource(id = R.string.privacy_policy), }
style = TextStyle(textDecoration = TextDecoration.Underline), }
fontSize = 16.sp, Spacer(modifier = Modifier.weight(1f))
modifier = Text(
Modifier.clickable { stringResource(id = R.string.privacy_policy),
appViewModel.openWebPage( style = TextStyle(textDecoration = TextDecoration.Underline),
context.resources.getString(R.string.privacy_policy_url), fontSize = 16.sp,
context, modifier =
) Modifier.clickable {
}, appViewModel.openWebPage(
) context.resources.getString(R.string.privacy_policy_url),
Row( context,
horizontalArrangement = Arrangement.spacedBy(25.dp), )
verticalAlignment = Alignment.CenterVertically, },
modifier = Modifier.padding(25.dp), )
) { Row(
val version = buildAnnotatedString { horizontalArrangement = Arrangement.spacedBy(25.dp),
append(stringResource(id = R.string.version)) verticalAlignment = Alignment.CenterVertically,
append(": ") modifier = Modifier.padding(25.dp),
append(BuildConfig.VERSION_NAME) ) {
} val version =
val mode = buildAnnotatedString { buildAnnotatedString {
append(stringResource(R.string.mode)) append(stringResource(id = R.string.version))
append(": ") append(": ")
when (uiState.settings.isKernelEnabled) { append(BuildConfig.VERSION_NAME)
true -> append(stringResource(id = R.string.kernel)) }
false -> append(stringResource(id = R.string.userspace)) val mode =
} buildAnnotatedString {
} append(stringResource(R.string.mode))
Text(version.text, modifier = Modifier.focusable()) append(": ")
Text(mode.text) when (uiState.settings.isKernelEnabled) {
} true -> append(stringResource(id = R.string.kernel))
} false -> append(stringResource(id = R.string.userspace))
}
}
Text(version.text, modifier = Modifier.focusable())
Text(mode.text)
}
}
} }
@@ -11,16 +11,17 @@ import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SupportViewModel @Inject constructor(settingsRepository: SettingsRepository) : class SupportViewModel
ViewModel() { @Inject
constructor(settingsRepository: SettingsRepository) :
val uiState = ViewModel() {
settingsRepository val uiState =
.getSettingsFlow() settingsRepository
.map { SupportUiState(it) } .getSettingsFlow()
.stateIn( .map { SupportUiState(it) }
viewModelScope, .stateIn(
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), viewModelScope,
SupportUiState(), SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
) SupportUiState(),
)
} }
@@ -42,85 +42,88 @@ import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable @Composable
fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) { fun LogsScreen(viewModel: LogsViewModel = hiltViewModel()) {
val logs = viewModel.logs
val logs = viewModel.logs val context = LocalContext.current
val context = LocalContext.current val lazyColumnListState = rememberLazyListState()
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val lazyColumnListState = rememberLazyListState() LaunchedEffect(logs.size) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current scope.launch {
val scope = rememberCoroutineScope() lazyColumnListState.animateScrollToItem(logs.size)
}
}
LaunchedEffect(logs.size) { Scaffold(
scope.launch { floatingActionButton = {
lazyColumnListState.animateScrollToItem(logs.size) FloatingActionButton(
} onClick = {
} scope.launch {
viewModel.saveLogsToFile().onSuccess {
Scaffold( Toast.makeText(
floatingActionButton = { context,
FloatingActionButton( context.getString(R.string.logs_saved),
onClick = { Toast.LENGTH_SHORT,
scope.launch { ).show()
viewModel.saveLogsToFile().onSuccess { }
Toast.makeText( }
context, },
context.getString(R.string.logs_saved), shape = RoundedCornerShape(16.dp),
Toast.LENGTH_SHORT, containerColor = MaterialTheme.colorScheme.primary,
).show() ) {
} val icon = Icons.Filled.Save
} Icon(
}, imageVector = icon,
shape = RoundedCornerShape(16.dp), contentDescription = icon.name,
containerColor = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.onPrimary,
) { )
val icon = Icons.Filled.Save }
Icon( },
imageVector = icon, ) {
contentDescription = icon.name, LazyColumn(
tint = MaterialTheme.colorScheme.onPrimary, horizontalAlignment = Alignment.CenterHorizontally,
) verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top),
} state = lazyColumnListState,
}, modifier =
) { Modifier
LazyColumn( .fillMaxSize()
horizontalAlignment = Alignment.CenterHorizontally, .padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Top), ) {
state = lazyColumnListState, itemsIndexed(
modifier = Modifier logs,
.fillMaxSize() key = { index, _ -> index },
.padding(horizontal = 24.dp), contentType = { _: Int, _: LogMessage -> null },
) { ) { _, it ->
itemsIndexed( Row(
logs, horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.Start),
key = { index, _ -> index }, verticalAlignment = Alignment.Top,
contentType = { _: Int, _: LogMessage -> null }, modifier =
) { _, it -> Modifier
Row( .fillMaxSize()
horizontalArrangement = Arrangement.spacedBy(5.dp, Alignment.Start), .clickable(
verticalAlignment = Alignment.Top, interactionSource = remember { MutableInteractionSource() },
modifier = Modifier indication = null,
.fillMaxSize() onClick = {
.clickable( clipboardManager.setText(
interactionSource = remember { MutableInteractionSource() }, annotatedString = AnnotatedString(it.toString()),
indication = null, )
onClick = { },
clipboardManager.setText(annotatedString = AnnotatedString(it.toString())) ),
}, ) {
), val fontSize = 10.sp
) { Text(text = it.tag, modifier = Modifier.fillMaxSize(0.3f), fontSize = fontSize)
val fontSize = 10.sp LogTypeLabel(color = Color(it.level.color())) {
Text(text = it.tag, modifier = Modifier.fillMaxSize(0.3f), fontSize = fontSize) Text(
LogTypeLabel(color = Color(it.level.color())) { text = it.level.signifier,
Text( textAlign = TextAlign.Center,
text = it.level.signifier, fontSize = fontSize,
textAlign = TextAlign.Center, )
fontSize = fontSize, }
) Text("${it.message} - ${it.time}", fontSize = fontSize)
} }
Text("${it.message} - ${it.time}", fontSize = fontSize) }
} }
} }
}
}
} }
@@ -20,36 +20,37 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class LogsViewModel class LogsViewModel
@Inject constructor( @Inject
private val localLogCollector: LocalLogCollector, constructor(
private val fileUtils: FileUtils, private val localLogCollector: LocalLogCollector,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val fileUtils: FileUtils,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
) : ViewModel() { ) : ViewModel() {
val logs = mutableStateListOf<LogMessage>()
val logs = mutableStateListOf<LogMessage>() init {
viewModelScope.launch(ioDispatcher) {
localLogCollector.bufferedLogs.chunked(500, Duration.ofSeconds(1)).collect {
withContext(mainDispatcher) {
logs.addAll(it)
}
if (logs.size > Constants.LOG_BUFFER_SIZE) {
withContext(mainDispatcher) {
logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt())
}
}
}
}
}
init { suspend fun saveLogsToFile(): Result<Unit> {
viewModelScope.launch(ioDispatcher) { val file =
localLogCollector.bufferedLogs.chunked(500, Duration.ofSeconds(1)).collect { localLogCollector.getLogFile().getOrElse {
withContext(mainDispatcher) { return Result.failure(it)
logs.addAll(it) }
} val fileContent = fileUtils.readBytesFromFile(file)
if (logs.size > Constants.LOG_BUFFER_SIZE) { val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
withContext(mainDispatcher) { return fileUtils.saveByteArrayToDownloads(fileContent, fileName)
logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt()) }
}
}
}
}
}
suspend fun saveLogsToFile(): Result<Unit> {
val file = localLogCollector.getLogFile().getOrElse {
return Result.failure(it)
}
val fileContent = fileUtils.readBytesFromFile(file)
val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
return fileUtils.saveByteArrayToDownloads(fileContent, fileName)
}
} }
@@ -16,20 +16,20 @@ import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
private val DarkColorScheme = private val DarkColorScheme =
darkColorScheme( darkColorScheme(
// primary = Purple80, // primary = Purple80,
primary = virdigris, primary = virdigris,
secondary = virdigris, secondary = virdigris,
// secondary = PurpleGrey80, // secondary = PurpleGrey80,
tertiary = virdigris, tertiary = virdigris,
// tertiary = Pink80 // tertiary = Pink80
) )
private val LightColorScheme = private val LightColorScheme =
lightColorScheme( lightColorScheme(
primary = Purple40, primary = Purple40,
secondary = PurpleGrey40, secondary = PurpleGrey40,
tertiary = Pink40, tertiary = Pink40,
/* Other default colors to override /* Other default colors to override
background = Color(0xFFFFFBFE), background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE), surface = Color(0xFFFFFBFE),
@@ -39,43 +39,43 @@ private val LightColorScheme =
onBackground = Color(0xFF1C1B1F), onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F), onSurface = Color(0xFF1C1B1F),
*/ */
) )
@Composable @Composable
fun WireguardAutoTunnelTheme( fun WireguardAutoTunnelTheme(
// force dark theme // force dark theme
darkTheme: Boolean = true, darkTheme: Boolean = true,
// darkTheme: Boolean = isSystemInDarkTheme(), // darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+ // Dynamic color is available on Android 12+
// turning off dynamic color for now // turning off dynamic color for now
dynamicColor: Boolean = false, dynamicColor: Boolean = false,
content: @Composable () -> Unit content: @Composable () -> Unit,
) { ) {
val colorScheme = val colorScheme =
when { when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
} }
darkTheme -> DarkColorScheme darkTheme -> DarkColorScheme
else -> LightColorScheme else -> LightColorScheme
} }
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as Activity).window
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = Color.Transparent.toArgb() window.statusBarColor = Color.Transparent.toArgb()
window.navigationBarColor = Color.Transparent.toArgb() window.navigationBarColor = Color.Transparent.toArgb()
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
!darkTheme !darkTheme
} }
} }
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,
content = content, content = content,
) )
} }
@@ -8,15 +8,15 @@ import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with // Set of Material typography styles to start with
val Typography = val Typography =
Typography( Typography(
bodyLarge = bodyLarge =
TextStyle( TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 24.sp,
letterSpacing = 0.5.sp, letterSpacing = 0.5.sp,
), ),
/* Other default text styles to override /* Other default text styles to override
titleLarge = TextStyle( titleLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
@@ -33,4 +33,4 @@ val Typography =
letterSpacing = 0.5.sp letterSpacing = 0.5.sp
) )
*/ */
) )
@@ -1,44 +1,40 @@
package com.zaneschepke.wireguardautotunnel.util package com.zaneschepke.wireguardautotunnel.util
object Constants { object Constants {
const val BASE_LOG_FILE_NAME = "wg_tunnel_logs"
const val LOG_BUFFER_SIZE = 3_000L
const val BASE_LOG_FILE_NAME = "wg_tunnel_logs" const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val LOG_BUFFER_SIZE = 3_000L const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1_000L // 10 minutes
const val VPN_STATISTIC_CHECK_INTERVAL = 1_000L
const val VPN_CONNECTED_NOTIFICATION_DELAY = 3_000L
const val TOGGLE_TUNNEL_DELAY = 300L
const val WATCHER_COLLECTION_DELAY = 1_000L
const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content"
const val ALLOWED_FILE_TYPES = "*/*"
const val TEXT_MIME_TYPE = "text/plain"
const val ZIP_FILE_MIME_TYPE = "application/zip"
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val ALWAYS_ON_VPN_ACTION = "android.net.VpnService"
const val EMAIL_MIME_TYPE = "plain/text"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
const val MANUAL_TUNNEL_CONFIG_ID = "0" const val SUBSCRIPTION_TIMEOUT = 5_000L
const val BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT = 10 * 60 * 1_000L // 10 minutes const val FOCUS_REQUEST_DELAY = 500L
const val VPN_STATISTIC_CHECK_INTERVAL = 1_000L
const val VPN_CONNECTED_NOTIFICATION_DELAY = 3_000L
const val TOGGLE_TUNNEL_DELAY = 300L
const val WATCHER_COLLECTION_DELAY = 1_000L
const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content"
const val ALLOWED_FILE_TYPES = "*/*"
const val TEXT_MIME_TYPE = "text/plain"
const val ZIP_FILE_MIME_TYPE = "application/zip"
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val ALWAYS_ON_VPN_ACTION = "android.net.VpnService"
const val EMAIL_MIME_TYPE = "plain/text"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
const val SUBSCRIPTION_TIMEOUT = 5_000L const val DEFAULT_PING_IP = "1.1.1.1"
const val FOCUS_REQUEST_DELAY = 500L const val PING_TIMEOUT = 5_000L
const val VPN_RESTART_DELAY = 1_000L
const val PING_INTERVAL = 60_000L
const val PING_COOLDOWN = PING_INTERVAL * 60 // one hour
const val DEFAULT_PING_IP = "1.1.1.1" const val TUNNEL_EXTRA_KEY = "tunnelId"
const val PING_TIMEOUT = 5_000L
const val VPN_RESTART_DELAY = 1_000L
const val PING_INTERVAL = 60_000L
const val PING_COOLDOWN = PING_INTERVAL * 60 //one hour
const val ALLOWED_DISPLAY_NAME_LENGTH = 20 const val UNREADABLE_SSID = "<unknown ssid>"
const val TUNNEL_EXTRA_KEY = "tunnelId"
const val UNREADABLE_SSID = "<unknown ssid>"
val amneziaProperties = listOf("Jc", "Jmin", "Jmax", "S1", "S2", "H1", "H2", "H3", "H4")
const val QR_CODE_NAME_PROPERTY = "# Name ="
val amneziaProperties = listOf("Jc", "Jmin", "Jmax", "S1", "S2", "H1", "H2", "H3", "H4")
const val QR_CODE_NAME_PROPERTY = "# Name ="
} }

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