Compare commits

..

37 Commits

Author SHA1 Message Date
Zane Schepke c8205c4c59 fix: ui tunnel display bug
Fixes a bug where turning on auto tunneling hides the first tunnel in the app.

Closes #116
2024-02-19 08:45:52 -05:00
Zane Schepke 3247e94358 Merge pull request #105 from zaneschepke/dependabot/github_actions/actions/upload-artifact-4.3.1
build(deps): bump actions/upload-artifact from 4.3.0 to 4.3.1
2024-02-19 06:59:32 -05:00
Zane Schepke 2690ce29e1 feat: migrate to forked lib
Migrated app to a forked version of wireguard-android to enable development work on features that require changes to the core lib, like #107 #104 #87 #52 #6

Improved first launch flow by change vpn permission to only launch on first tunnel start

Changed to proper database seeding strategy

Updated README to account for GitHub packages auth requirement

Migrated from deprecated UI components and libs

Bump versions
2024-02-18 23:28:06 -05:00
Zane Schepke 500b85f687 update gradle, vpn permission 2024-02-09 22:34:10 -05:00
dependabot[bot] 84b2b75271 build(deps): bump actions/upload-artifact from 4.3.0 to 4.3.1
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.3.0 to 4.3.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.3.0...v4.3.1)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-02-06 14:02:45 +00:00
Zane Schepke 0197198f7b Merge pull request #100 from zaneschepke/dependabot/github_actions/peter-evans/repository-dispatch-3
build(deps): bump peter-evans/repository-dispatch from 2 to 3
2024-01-27 12:59:38 -05:00
Zane Schepke 0b271778c9 Merge pull request #99 from zaneschepke/dependabot/github_actions/actions/upload-artifact-4.3.0
build(deps): bump actions/upload-artifact from 4.2.0 to 4.3.0
2024-01-27 12:59:13 -05:00
Zane Schepke 6427b2f832 fix: icon issues
Added support for auto start on reboot for Always-On VPN kernel mode

Added support for adaptive theme icons

Fixed notification icon size, tile icon, AndroidTV icons

Clean up pipelines

Bump versions

Closes #96
Closes #98
Closes #97
Closes #79
2024-01-27 12:44:38 -05:00
dependabot[bot] 097097f620 build(deps): bump peter-evans/repository-dispatch from 2 to 3
Bumps [peter-evans/repository-dispatch](https://github.com/peter-evans/repository-dispatch) from 2 to 3.
- [Release notes](https://github.com/peter-evans/repository-dispatch/releases)
- [Commits](https://github.com/peter-evans/repository-dispatch/compare/v2...v3)

---
updated-dependencies:
- dependency-name: peter-evans/repository-dispatch
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-25 13:27:12 +00:00
dependabot[bot] 20dfaed8de build(deps): bump actions/upload-artifact from 4.2.0 to 4.3.0
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.2.0 to 4.3.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.2.0...v4.3.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-24 13:47:56 +00:00
Zane Schepke 07aa37fc2a docs: remove FireTV support 2024-01-21 11:05:33 -05:00
Zane Schepke 091cd2e028 fix: ci versionCode 2024-01-20 21:16:09 -05:00
Zane Schepke 7efa6d0bf4 ci: test pre-release 2024-01-20 20:45:44 -05:00
Zane Schepke e31fb01410 ci: add pre-release pipeline
Change release pipeline to deploy to production google play
2024-01-20 20:17:56 -05:00
Zane Schepke 76674323e7 ci: dispatch update for fdroid repo 2024-01-20 18:47:30 -05:00
Zane Schepke f1fc9ca6f7 chore: add icon 2024-01-20 18:15:36 -05:00
Zane Schepke cb301e74eb Merge branch 'main' of https://github.com/zaneschepke/wgtunnel 2024-01-20 13:33:21 -05:00
Zane Schepke 8141fe19be chore: move donations to funding
Add liberapay
2024-01-20 13:33:07 -05:00
Zane Schepke 0fdb3d0b31 Merge pull request #94 from zaneschepke/dependabot/github_actions/actions/upload-artifact-4.2.0
build(deps): bump actions/upload-artifact from 4.0.0 to 4.2.0
2024-01-19 21:21:15 -05:00
Zane Schepke d9f3a21cc3 fix: create config not saving
Fixes bug where creating a config from scratch was failing to save

Closes #93
Closes #96
Closes #89
2024-01-19 20:54:18 -05:00
dependabot[bot] fec84bc6ac build(deps): bump actions/upload-artifact from 4.0.0 to 4.2.0
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.0.0 to 4.2.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.0.0...v4.2.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-19 13:58:13 +00:00
Zane Schepke d6ee36edc0 docs: add link to README 2024-01-14 12:06:29 -05:00
Zane Schepke e3fcf712d5 Merge branch 'main' of https://github.com/zaneschepke/wgtunnel 2024-01-08 19:07:10 -05:00
Zane Schepke 5a15776bb3 fix: tunnel disable frozen
Fixes a bug where after toggling a tunnel so many times it would eventually get stuck in the on position. This was also impacting auto-tunneling reliability.

Fixes a bug where clicking the email button on the support page would not populate the "to" email field.

Fixes a bug where you could not save a tunnel without having configured DNS.

Added a dialog to prompt user if they are deleting a tunnel.

Added battery optimization disable request when first launching auto-tunneling.

Format to kotlinlang standards.

Fix ci google play deploy.

Closes #63
2024-01-08 19:06:40 -05:00
Zane Schepke 3339448424 fix: tunnel disable frozen
Fixes a bug where after toggling a tunnel so many times it would eventually get stuck in the on position. This was also impacting auto-tunneling reliability.

Fixes a bug where clicking the email button on the support page would not populate the "to" email field.

Fixes a bug where you could not save a tunnel without having configured DNS.

Added a dialog to prompt user if they are deleting a tunnel.

Added battery optimization disable request when first launching auto-tunneling.

Format to kotlinlang standards.
2024-01-08 18:42:30 -05:00
Zane Schepke 7ec294b789 docs: update full_description.txt 2024-01-04 22:03:02 -05:00
Zane Schepke e59c72788d Merge pull request #82 from zaneschepke/dependabot/github_actions/actions/download-artifact-4
build(deps): bump actions/download-artifact from 1 to 4
2024-01-01 12:21:34 -05:00
Zane Schepke 62435d549c Merge pull request #84 from zaneschepke/dependabot/github_actions/actions/upload-artifact-4.0.0
build(deps): bump actions/upload-artifact from 3.1.2 to 4.0.0
2024-01-01 12:21:01 -05:00
Zane Schepke 96800037d1 Merge pull request #81 from zaneschepke/dependabot/github_actions/actions/checkout-4
build(deps): bump actions/checkout from 3 to 4
2024-01-01 12:20:19 -05:00
Zane Schepke f5b3bb1cb7 Merge pull request #80 from zaneschepke/dependabot/github_actions/actions/setup-java-4
build(deps): bump actions/setup-java from 3 to 4
2024-01-01 12:18:48 -05:00
dependabot[bot] 654b4a4719 build(deps): bump actions/upload-artifact from 3.1.2 to 4.0.0
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3.1.2 to 4.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v3.1.2...v4.0.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 17:03:45 +00:00
dependabot[bot] a19b5ce22a build(deps): bump actions/download-artifact from 1 to 4
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 1 to 4.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v1...v4)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 17:03:43 +00:00
dependabot[bot] 86f592255c build(deps): bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 17:03:40 +00:00
dependabot[bot] 4a5dd76b5b build(deps): bump actions/setup-java from 3 to 4
Bumps [actions/setup-java](https://github.com/actions/setup-java) from 3 to 4.
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 17:03:39 +00:00
Zane Schepke 1139d17f13 chore: add dependabot 2024-01-01 12:03:18 -05:00
Zane Schepke 36855319a2 docs: update README 2024-01-01 11:53:04 -05:00
Zane Schepke 61e3751321 fix: start foreground
Fixes issue where auto tunnel should be starting service foreground
2023-12-31 22:44:44 -05:00
143 changed files with 4024 additions and 3304 deletions
+85
View File
@@ -0,0 +1,85 @@
[{*.kt,*.kts}]
indent_style = space
insert_final_newline = true
max_line_length = 100
indent_size = 4
ij_continuation_indent_size = 4
ij_java_names_count_to_use_import_on_demand = 9999
ij_kotlin_align_in_columns_case_branch = false
ij_kotlin_align_multiline_binary_operation = false
ij_kotlin_align_multiline_extends_list = false
ij_kotlin_align_multiline_method_parentheses = false
ij_kotlin_align_multiline_parameters = true
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_blank_lines_after_class_header = 0
ij_kotlin_blank_lines_around_block_when_branches = 0
ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1
ij_kotlin_block_comment_at_first_column = 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_wrap = on_every_item
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_expression_bodies = true
ij_kotlin_continuation_indent_in_argument_lists = true
ij_kotlin_continuation_indent_in_elvis = false
ij_kotlin_continuation_indent_in_if_conditions = false
ij_kotlin_continuation_indent_in_parameter_lists = false
ij_kotlin_continuation_indent_in_supertype_lists = false
ij_kotlin_else_on_new_line = false
ij_kotlin_enum_constants_wrap = off
ij_kotlin_extends_list_wrap = normal
ij_kotlin_field_annotation_wrap = split_into_lines
ij_kotlin_finally_on_new_line = false
ij_kotlin_if_rparen_on_new_line = false
ij_kotlin_import_nested_classes = false
ij_kotlin_insert_whitespaces_in_simple_one_line_method = true
ij_kotlin_keep_blank_lines_before_right_brace = 2
ij_kotlin_keep_blank_lines_in_code = 2
ij_kotlin_keep_blank_lines_in_declarations = 2
ij_kotlin_keep_first_column_comment = true
ij_kotlin_keep_indents_on_empty_lines = false
ij_kotlin_keep_line_breaks = true
ij_kotlin_lbrace_on_next_line = false
ij_kotlin_line_comment_add_space = false
ij_kotlin_line_comment_at_first_column = true
ij_kotlin_method_annotation_wrap = split_into_lines
ij_kotlin_method_call_chain_wrap = normal
ij_kotlin_method_parameters_new_line_after_left_paren = 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_for_members = 9999
ij_kotlin_parameter_annotation_wrap = off
ij_kotlin_space_after_comma = true
ij_kotlin_space_after_extend_colon = true
ij_kotlin_space_after_type_colon = true
ij_kotlin_space_before_catch_parentheses = true
ij_kotlin_space_before_comma = false
ij_kotlin_space_before_extend_colon = true
ij_kotlin_space_before_for_parentheses = true
ij_kotlin_space_before_if_parentheses = true
ij_kotlin_space_before_lambda_arrow = true
ij_kotlin_space_before_type_colon = false
ij_kotlin_space_before_when_parentheses = true
ij_kotlin_space_before_while_parentheses = true
ij_kotlin_spaces_around_additive_operators = true
ij_kotlin_spaces_around_assignment_operators = true
ij_kotlin_spaces_around_equality_operators = true
ij_kotlin_spaces_around_function_type_arrow = true
ij_kotlin_spaces_around_logical_operators = true
ij_kotlin_spaces_around_multiplicative_operators = true
ij_kotlin_spaces_around_range = false
ij_kotlin_spaces_around_relational_operators = true
ij_kotlin_spaces_around_unary_operator = false
ij_kotlin_spaces_around_when_arrow = true
ij_kotlin_variable_annotation_wrap = off
ij_kotlin_while_on_new_line = false
ij_kotlin_wrap_elvis_expressions = 1
ij_kotlin_wrap_expression_body_functions = 1
ij_kotlin_wrap_first_method_in_call_chain = false
+2
View File
@@ -0,0 +1,2 @@
ko_fi: zaneschepke
liberapay: zaneschepke
+5 -3
View File
@@ -11,12 +11,14 @@ assignees: zaneschepke
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
**Smartphone (please complete the following information):** **Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- Android Version: [e.g. iOS8.1] - Device: [e.g. Pixel 4a]
- App Version [e.g. 22] - Android Version: [e.g. Android 13]
- App Version [e.g. 3.3.3]
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily
- package-ecosystem: gradle
directory: /
schedule:
interval: daily
@@ -1,27 +1,31 @@
# name of the workflow # name of the workflow
name: Android CI Tag Deployment name: Android CI Tag Deployment (Pre-release)
on: on:
workflow_dispatch:
push: push:
tags: tags:
- '*.*.*' - '*.*.*-**'
jobs: jobs:
build: build:
name: Build Signed APK name: Build Signed APK
# change to macos because of hilt issues on ubuntu in gradle 8.3
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
KEY_STORE_PATH: ${{ secrets.KEY_STORE_PATH }}
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
KEY_STORE_FILE: 'android_keystore.jks'
KEY_STORE_LOCATION: ${{ github.workspace }}/app/keystore/
GH_USER: ${{ secrets.GH_USER }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
@@ -35,10 +39,16 @@ jobs:
id: decode_keystore id: decode_keystore
uses: timheuer/base64-to-file@v1.2 uses: timheuer/base64-to-file@v1.2
with: with:
fileName: 'android_keystore.jks' fileName: ${{ env.KEY_STORE_FILE }}
fileDir: ${{ github.workspace }}/app/keystore/ fileDir: ${{ env.KEY_STORE_LOCATION }}
encodedString: ${{ secrets.KEYSTORE }} 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 - name: Create service_account.json
id: createServiceAccount id: createServiceAccount
run: echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json run: echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
@@ -52,15 +62,18 @@ jobs:
- name: Get apk path - name: Get apk path
id: apk-path id: apk-path
run: echo "path=$(find . -regex '^.*/build/outputs/apk/fdroid/release/.*\.apk$' -type f | head -1)" >> $GITHUB_OUTPUT run: echo "path=$(find . -regex '^.*/build/outputs/apk/fdroid/release/.*\.apk$' -type f | head -1)" >> $GITHUB_OUTPUT
- name: Get version code
run: |
version_code=$(grep "VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk '{print $5}' | tr -d '\n')
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@v3.1.2 uses: actions/upload-artifact@v4.3.1
with: with:
name: wgtunnel name: wgtunnel
path: ${{ steps.apk-path.outputs.path }} path: ${{ steps.apk-path.outputs.path }}
- name: Download APK from build - name: Download APK from build
uses: actions/download-artifact@v1 uses: actions/download-artifact@v4
with: with:
name: wgtunnel name: wgtunnel
- name: Create Release with Fastlane changelog notes - name: Create Release with Fastlane changelog notes
@@ -70,18 +83,17 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
# fix hardcode changelog file name # fix hardcode changelog file name
body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/33100.txt body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/${{ env.VERSION_CODE }}.txt
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }} name: ${{ github.ref_name }}
draft: false draft: false
prerelease: false prerelease: true
files: ${{ github.workspace }}/${{ steps.apk-path.outputs.path }} files: ${{ github.workspace }}/${{ steps.apk-path.outputs.path }}
- name: Deploy with fastlane - name: Deploy with fastlane
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: '3.2' # Not needed with a .ruby-version file ruby-version: '3.2' # Not needed with a .ruby-version file
bundler-cache: true bundler-cache: true
- name: Distribute app to Beta track 🚀 - name: Distribute app to Beta track 🚀
run: (cd ${{ github.workspace }} && bundle install && bundle exec fastlane beta) run: (cd ${{ github.workspace }} && bundle install && bundle exec fastlane beta)
+106
View File
@@ -0,0 +1,106 @@
# name of the workflow
name: Android CI Tag Deployment (Release)
on:
workflow_dispatch:
push:
tags:
- '*.*.*'
- '!*.*.*-**'
jobs:
build:
name: Build Signed APK
runs-on: ubuntu-latest
env:
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
KEY_STORE_FILE: 'android_keystore.jks'
KEY_STORE_LOCATION: ${{ github.workspace }}/app/keystore/
GH_USER: ${{ secrets.GH_USER }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
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
# 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
# Build and sign APK ("-x test" argument is used to skip tests)
# add fdroid flavor for apk upload
- name: Build Fdroid Release APK
run: ./gradlew :app:assembleFdroidRelease -x test
# get fdroid flavor release apk path
- name: Get apk path
id: apk-path
run: echo "path=$(find . -regex '^.*/build/outputs/apk/fdroid/release/.*\.apk$' -type f | head -1)" >> $GITHUB_OUTPUT
- name: Get version code
run: |
version_code=$(grep "VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk '{print $5}' | tr -d '\n')
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
- name: Upload APK
uses: actions/upload-artifact@v4.3.1
with:
name: wgtunnel
path: ${{ steps.apk-path.outputs.path }}
- name: Download APK from build
uses: actions/download-artifact@v4
with:
name: wgtunnel
- name: Repository Dispatch for my F-Droid repo
uses: peter-evans/repository-dispatch@v3
with:
token: ${{ secrets.PAT }}
repository: zaneschepke/fdroid
event-type: fdroid-update
- name: Create Release with Fastlane changelog notes
id: create_release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
# fix hardcode changelog file name
body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/${{ env.VERSION_CODE }}.txt
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
draft: false
prerelease: false
files: ${{ github.workspace }}/${{ steps.apk-path.outputs.path }}
- name: Deploy with fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2' # Not needed with a .ruby-version file
bundler-cache: true
- name: Distribute app to Prod track 🚀
run: (cd ${{ github.workspace }} && bundle install && bundle exec fastlane production)
+44 -17
View File
@@ -13,22 +13,18 @@ WG Tunnel
[![Google Play](https://img.shields.io/badge/Google_Play-414141?style=for-the-badge&logo=google-play&logoColor=white)](https://play.google.com/store/apps/details?id=com.zaneschepke.wireguardautotunnel) [![Google Play](https://img.shields.io/badge/Google_Play-414141?style=for-the-badge&logo=google-play&logoColor=white)](https://play.google.com/store/apps/details?id=com.zaneschepke.wireguardautotunnel)
[![Fire TV](https://img.shields.io/badge/fire%20tv-fc3b2d?style=for-the-badge&logo=amazon%20fire%20tv&logoColor=white)](https://www.amazon.com/gp/product/B0CFGGL7WK)
[![F-Droid](https://img.shields.io/static/v1?style=for-the-badge&message=F-Droid&color=1976D2&logo=F-Droid&logoColor=FFFFFF&label=)](https://f-droid.org/packages/com.zaneschepke.wireguardautotunnel/) [![F-Droid](https://img.shields.io/static/v1?style=for-the-badge&message=F-Droid&color=1976D2&logo=F-Droid&logoColor=FFFFFF&label=)](https://f-droid.org/packages/com.zaneschepke.wireguardautotunnel/)
</div>
<div align="center">
[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/N4N8NMJN2)
</div> </div>
<div align="left"> <div align="left">
This is an alternative Android Application for [WireGuard](https://www.wireguard.com/) with added features. Built using the [wireguard-android](https://github.com/WireGuard/wireguard-android) library and [Jetpack Compose](https://developer.android.com/jetpack/compose), this application was inspired by the official [WireGuard Android](https://github.com/WireGuard/wireguard-android) app. This is an alternative Android Application for [WireGuard](https://www.wireguard.com/) with added
features. Built using the [wireguard-android](https://github.com/WireGuard/wireguard-android)
library and [Jetpack Compose](https://developer.android.com/jetpack/compose), this application was
inspired by the official [WireGuard Android](https://github.com/WireGuard/wireguard-android) app.
</div> </div>
@@ -37,38 +33,69 @@ This is an alternative Android Application for [WireGuard](https://www.wireguard
## Screenshots ## Screenshots
<p float="center"> <p float="center">
<img label="Main" style="padding-right:25px" src="asset/main_screen.png" width="200" /> <img label="Main" style="padding-right:25px" src="fastlane/metadata/android/en-US/images/phoneScreenshots/main_screen.png" width="200" />
<img label="Config" style="padding-left:25px" src="asset/config_screen.png" width="200" /> <img label="Config" style="padding-left:25px" src="fastlane/metadata/android/en-US/images/phoneScreenshots/config_screen.png" width="200" />
<img label="Settings" style="padding-left:25px" src="asset/settings_screen.png" width="200" /> <img label="Settings" style="padding-left:25px" src="fastlane/metadata/android/en-US/images/phoneScreenshots/settings_screen.png" width="200" />
<img label="Support" style="padding-left:25px" src="asset/support_screen.png" width="200" /> <img label="Support" style="padding-left:25px" src="fastlane/metadata/android/en-US/images/phoneScreenshots/support_screen.png" width="200" />
</p> </p>
<div align="left"> <div align="left">
## Inspiration ## Inspiration
The original inspiration for this app came from the inconvenience of having to manually turn VPN off and on while on different networks. This app was created to offer a free solution to this problem. The original inspiration for this app came from the inconvenience of having to manually turn VPN off
and on while on different networks. This app was created to offer a free solution to this problem.
## Features ## Features
* Add tunnels via .conf file, zip, manual entry, or QR code * Add tunnels via .conf file, zip, manual entry, or QR code
* Auto connect to VPN based on Wi-Fi SSID * Auto connect to VPN based on Wi-Fi SSID, ethernet, or mobile data
* Split tunneling by application with search * Split tunneling by application with search
* Always-on VPN for Android support * WireGuard support for kernel and userspace modes
* Always-On VPN support
* Export tunnels to zip * Export tunnels to zip
* Quick tile support for VPN toggling * Quick tile support for VPN toggling
* Static shortcuts support for primary tunnel for automation integration * Static shortcuts support for primary tunnel for automation integration
* Intent automation support for all tunnels * Intent automation support for all tunnels
* Optional auto connect on mobile data, ethernet
* Automatic service restart after reboot * Automatic service restart after reboot
* Service will stay running in background after app has been closed * Battery preservation measures
## Docs (WIP)
Basic documentation of the feature and behaviors of this app can be found [here](https://zaneschepke.com/wgtunnel-docs/overview.html).
The repository for these docs can be found [here](https://github.com/zaneschepke/wgtunnel-docs).
## Building ## Building
``` ```
$ git clone https://github.com/zaneschepke/wgtunnel $ git clone https://github.com/zaneschepke/wgtunnel
$ cd wgtunnel $ cd wgtunnel
```
Create a personal access token (classic) in GitHuv to be able to pull the wireguard-android github dependencies from GitHub packages
as documented [here](https://docs.github.com/en/packages/learn-github-packages/introduction-to-github-packages#authenticating-to-github-packages).
Alternatively, you can clone [wireguard-android](https://github.com/zaneschepke/wireguard-android) and run the following command to publish the dependency to your local maven repository (requires you have maven installed). This is the ideal approach
if you intent to make changes to this lib.
```
$ git clone https://github.com/zaneschepke/wireguard-android
$ cd wireguard-android
$ brew install maven
$ ./gradlew publishToMavenLocal
```
The [wireguard-android](https://github.com/zaneschepke/wireguard-android) dependency is a fork of the official [wireguard-android](https://github.com/WireGuard/wireguard-android) library.
Add the following lines to local.properties file:
```
GH_USER=<your github username>
GH_TOKEN=<the personal access token with read package permission you just created>
```
And then build the app:
```
$ ./gradlew assembleDebug $ ./gradlew assembleDebug
``` ```
+38 -42
View File
@@ -19,9 +19,7 @@ android {
versionCode = Constants.VERSION_CODE versionCode = Constants.VERSION_CODE
versionName = Constants.VERSION_NAME versionName = Constants.VERSION_NAME
ksp { ksp { arg("room.schemaLocation", "$projectDir/schemas") }
arg("room.schemaLocation", "$projectDir/schemas")
}
sourceSets { sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room
@@ -30,9 +28,7 @@ android {
resourceConfigurations.addAll(listOf("en")) resourceConfigurations.addAll(listOf("en"))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables { useSupportLibrary = true }
useSupportLibrary = true
}
} }
signingConfigs { signingConfigs {
@@ -47,32 +43,41 @@ android {
} }
} }
// try to get secrets from env first for pipeline build, then properties file for local build // try to get secrets from env first for pipeline build, then properties file for local
storeFile = file( // build
System.getenv().getOrDefault( storeFile =
Constants.KEY_STORE_PATH_VAR, file(
properties.getProperty(Constants.KEY_STORE_PATH_VAR) System.getenv()
.getOrDefault(
Constants.KEY_STORE_PATH_VAR,
properties.getProperty(Constants.KEY_STORE_PATH_VAR),
),
) )
) storePassword =
storePassword = System.getenv().getOrDefault( System.getenv()
Constants.STORE_PASS_VAR, .getOrDefault(
properties.getProperty(Constants.STORE_PASS_VAR) Constants.STORE_PASS_VAR,
) properties.getProperty(Constants.STORE_PASS_VAR),
keyAlias = System.getenv().getOrDefault( )
Constants.KEY_ALIAS_VAR, keyAlias =
properties.getProperty(Constants.KEY_ALIAS_VAR) System.getenv()
) .getOrDefault(
keyPassword = System.getenv().getOrDefault( Constants.KEY_ALIAS_VAR,
Constants.KEY_PASS_VAR, properties.getProperty(Constants.KEY_ALIAS_VAR),
properties.getProperty(Constants.KEY_PASS_VAR) )
) keyPassword =
System.getenv()
.getOrDefault(
Constants.KEY_PASS_VAR,
properties.getProperty(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 { applicationVariants.all {
@@ -91,13 +96,11 @@ android {
isShrinkResources = true isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro",
) )
signingConfig = signingConfigs.getByName(Constants.RELEASE) signingConfig = signingConfigs.getByName(Constants.RELEASE)
} }
debug { debug { isDebuggable = true }
isDebuggable = true
}
} }
flavorDimensions.add(Constants.TYPE) flavorDimensions.add(Constants.TYPE)
productFlavors { productFlavors {
@@ -118,24 +121,17 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions { jvmTarget = Constants.JVM_TARGET }
jvmTarget = Constants.JVM_TARGET
}
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
} }
composeOptions { composeOptions { kotlinCompilerExtensionVersion = Constants.COMPOSE_COMPILER_EXTENSION_VERSION }
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
} }
val generalImplementation by configurations val generalImplementation by configurations
dependencies { dependencies {
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
@@ -160,13 +156,14 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.manifest) debugImplementation(libs.androidx.compose.manifest)
// wg // get tunnel lib from github packages or mavenLocal
implementation(libs.tunnel) implementation(libs.tunnel)
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)
// logging // logging
implementation(libs.timber) implementation(libs.timber)
// compose navigation // compose navigation
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
@@ -176,7 +173,6 @@ dependencies {
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
// accompanist // accompanist
implementation(libs.accompanist.systemuicontroller)
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.drawablepainter) implementation(libs.accompanist.drawablepainter)
@@ -4,6 +4,7 @@ import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.zaneschepke.wireguardautotunnel.data.AppDatabase import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.data.Queries
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -14,10 +15,11 @@ class MigrationTest {
private val dbName = "migration-test" private val dbName = "migration-test"
@get:Rule @get:Rule
val helper: MigrationTestHelper = MigrationTestHelper( val helper: MigrationTestHelper =
InstrumentationRegistry.getInstrumentation(), MigrationTestHelper(
AppDatabase::class.java InstrumentationRegistry.getInstrumentation(),
) AppDatabase::class.java,
)
@Test @Test
@Throws(IOException::class) @Throws(IOException::class)
@@ -25,36 +27,9 @@ class MigrationTest {
helper.createDatabase(dbName, 4).apply { helper.createDatabase(dbName, 4).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( execSQL(
"INSERT INTO Settings (is_tunnel_enabled," + "INSERT INTO TunnelConfig (name, wg_quick)" + " VALUES ('hello', 'hello')",
"is_tunnel_on_mobile_data_enabled," +
"trusted_network_ssids," +
"default_tunnel," +
"is_always_on_vpn_enabled," +
"is_tunnel_on_ethernet_enabled," +
"is_shortcuts_enabled," +
"is_battery_saver_enabled," +
"is_tunnel_on_wifi_enabled," +
"is_kernel_enabled," +
"is_restore_on_boot_enabled," +
"is_multi_tunnel_enabled)" +
" VALUES " +
"('false'," +
"'false'," +
"'[trustedSSID1,trustedSSID2]'," +
"'defaultTunnel'," +
"'false'," +
"'false'," +
"'false'," +
"'false'," +
"'false'," +
"'false'," +
"'false'," +
"'false')"
)
execSQL(
"INSERT INTO TunnelConfig (name, wg_quick)" +
" VALUES ('hello', 'hello')"
) )
// Prepare for the next version. // Prepare for the next version.
close() close()
+62 -43
View File
@@ -1,56 +1,63 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" android:maxSdkVersion="32"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" <uses-permission
android:name="android.permission.ACCESS_WIFI_STATE"
android:maxSdkVersion="30" android:maxSdkVersion="30"
tools:ignore="LeanbackUsesWifi" /> tools:ignore="LeanbackUsesWifi" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!--foreground service exempt android 14--> <!--foreground service exempt android 14-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!--foreground service permissions--> <!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!--start service on boot permission--> <!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!--android tv support--> <!--android tv support-->
<uses-feature android:name="android.software.leanback" <uses-feature
android:name="android.software.leanback"
android:required="false" /> android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" <uses-feature
android:name="android.hardware.touchscreen"
android:required="false" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.hardware.location.gps" android:name="android.hardware.location.gps"
android:required="false" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.hardware.screen.portrait" android:name="android.hardware.screen.portrait"
android:required="false" /> android:required="false" />
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
</intent> </intent>
</queries> </queries>
<application <application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:name=".WireGuardAutoTunnel" android:name=".WireGuardAutoTunnel"
android:allowBackup="true"
android:banner="@mipmap/ic_banner"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:banner="@mipmap/ic_banner"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
@@ -62,11 +69,14 @@
android:theme="@style/Theme.WireguardAutoTunnel"> android:theme="@style/Theme.WireguardAutoTunnel">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES"/>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts" <meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity> </activity>
<activity <activity
@@ -76,65 +86,74 @@
android:theme="@style/zxing_CaptureTheme" android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden" /> android:windowSoftInputMode="stateAlwaysHidden" />
<activity <activity
android:finishOnTaskLaunch="true" android:name=".service.shortcut.ShortcutsActivity"
android:enabled="true" android:enabled="true"
android:exported="true" android:exported="true"
android:theme="@android:style/Theme.NoDisplay" android:finishOnTaskLaunch="true"
android:name=".service.shortcut.ShortcutsActivity"/> android:theme="@android:style/Theme.NoDisplay" />
<service <service
android:name=".service.foreground.ForegroundService" android:name=".service.foreground.ForegroundService"
android:enabled="true" android:enabled="true"
android:exported="false"
android:foregroundServiceType="systemExempted|specialUse" android:foregroundServiceType="systemExempted|specialUse"
tools:node="merge" tools:node="merge" />
android:exported="false">
</service>
<service <service
android:exported="true"
android:name=".service.tile.TunnelControlTile" android:name=".service.tile.TunnelControlTile"
android:icon="@drawable/shield" android:exported="true"
android:icon="@drawable/ic_launcher"
android:label="WG Tunnel" android:label="WG Tunnel"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"> android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<meta-data android:name="android.service.quicksettings.ACTIVE_TILE" <meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" /> android:value="true" />
<meta-data android:name="android.service.quicksettings.TOGGLEABLE_TILE" <meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" /> android:value="true" />
<intent-filter> <intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" /> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>
</service> </service>
<service <service
android:name=".service.foreground.WireGuardTunnelService" android:name=".service.foreground.WireGuardTunnelService"
android:permission="android.permission.BIND_VPN_SERVICE"
android:enabled="true" android:enabled="true"
android:persistent="true" android:exported="false"
android:foregroundServiceType="systemExempted|specialUse" android:foregroundServiceType="systemExempted|specialUse"
tools:node="merge" android:permission="android.permission.BIND_VPN_SERVICE"
android:exported="false"> android:persistent="true"
tools:node="merge">
<intent-filter> <intent-filter>
<action android:name="android.net.VpnService"/> <action android:name="android.net.VpnService" />
</intent-filter> </intent-filter>
<meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON" <meta-data
android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="true" /> android:value="true" />
</service> </service>
<service <service
android:name=".service.foreground.WireGuardConnectivityWatcherService" android:name=".service.foreground.WireGuardConnectivityWatcherService"
android:enabled="true" android:enabled="true"
android:stopWithTask="false" android:exported="false"
android:persistent="true"
android:foregroundServiceType="systemExempted|specialUse" android:foregroundServiceType="systemExempted|specialUse"
tools:node="merge" android:persistent="true"
android:exported="false"> android:stopWithTask="false"
</service> tools:node="merge" />
<receiver android:enabled="true" android:name=".receiver.BootReceiver"
<receiver
android:name=".receiver.BootReceiver"
android:enabled="true"
android:exported="false"> android:exported="false">
<intent-filter> <intent-filter>
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<action android:name="android.intent.action.BOOT_COMPLETED" /> <action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.ACTION_BOOT_COMPLETED" /> <action android:name="android.intent.action.ACTION_BOOT_COMPLETED" />
<action android:name="android.intent.action.QUICKBOOT_POWERON" /> <action android:name="android.intent.action.QUICKBOOT_POWERON" />
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON" /> <action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
</intent-filter> </intent-filter>
</receiver> </receiver>
<receiver android:exported="false" android:name=".receiver.NotificationActionReceiver"/> <receiver
android:name=".receiver.NotificationActionReceiver"
android:exported="false" />
</application> </application>
</manifest> </manifest>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -4,61 +4,31 @@ import android.app.Application
import android.content.ComponentName import android.content.ComponentName
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.io.IOException
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class WireGuardAutoTunnel : Application() { class WireGuardAutoTunnel : Application() {
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var dataStoreManager: DataStoreManager
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
instance = this instance = this
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
initSettings()
with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch {
try {
// load preferences into memory
dataStoreManager.init()
requestTileServiceStateUpdate()
} catch (e: IOException) {
Timber.e("Failed to load preferences")
}
}
}
}
private fun initSettings() {
with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch {
if (settingsRepository.getAll().isEmpty()) {
settingsRepository.save(Settings())
}
}
}
} }
companion object { companion object {
lateinit var instance: WireGuardAutoTunnel private set lateinit var instance: WireGuardAutoTunnel
private set
fun isRunningOnAndroidTv(): Boolean { fun isRunningOnAndroidTv(): Boolean {
return instance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) return instance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
} }
fun requestTileServiceStateUpdate() { fun requestTileServiceStateUpdate() {
TileService.requestListeningState(instance, ComponentName(instance, TunnelControlTile::class.java)) TileService.requestListeningState(
instance,
ComponentName(instance, TunnelControlTile::class.java),
)
} }
} }
} }
@@ -10,16 +10,20 @@ import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
@Database( @Database(
entities = [Settings::class, TunnelConfig::class], entities = [Settings::class, TunnelConfig::class],
version = 5, version = 5,
autoMigrations = [ autoMigrations =
AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration( [
from = 3, AutoMigration(from = 1, to = 2),
to = 4 AutoMigration(from = 2, to = 3),
),AutoMigration( AutoMigration(
from = 4, from = 3,
to = 5 to = 4,
) ),
], AutoMigration(
exportSchema = true from = 4,
to = 5,
),
],
exportSchema = true,
) )
@TypeConverters(DatabaseListConverters::class) @TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
@@ -0,0 +1,21 @@
package com.zaneschepke.wireguardautotunnel.data
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import timber.log.Timber
class DatabaseCallback : RoomDatabase.Callback() {
override fun onCreate(db: SupportSQLiteDatabase) = db.run {
// Notice non-ui thread is here
beginTransaction()
try {
execSQL(Queries.createDefaultSettings())
Timber.i("Bootstrapping settings data")
setTransactionSuccessful()
} catch (e : Exception) {
Timber.e(e)
} finally {
endTransaction()
}
}
}
@@ -0,0 +1,33 @@
package com.zaneschepke.wireguardautotunnel.data
object Queries {
fun createDefaultSettings() : String {
return """
INSERT INTO Settings (is_tunnel_enabled,
is_tunnel_on_mobile_data_enabled,
trusted_network_ssids,
default_tunnel,
is_always_on_vpn_enabled,
is_tunnel_on_ethernet_enabled,
is_shortcuts_enabled,
is_battery_saver_enabled,
is_tunnel_on_wifi_enabled,
is_kernel_enabled,
is_restore_on_boot_enabled,
is_multi_tunnel_enabled)
VALUES
('false',
'false',
'[trustedSSID1,trustedSSID2]',
NULL,
'false',
'false',
'false',
'false',
'false',
'false',
'false',
'false')
""".trimIndent()
}
}
@@ -10,27 +10,19 @@ 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
} }
@@ -10,24 +10,17 @@ 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: List<TunnelConfig>)
suspend fun saveAll(t: List<TunnelConfig>)
@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") @Query("SELECT * FROM TunnelConfig") suspend fun getAll(): List<TunnelConfig>
suspend fun getAll(): List<TunnelConfig>
@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") @Query("SELECT * FROM tunnelconfig") fun getAllFlow(): Flow<MutableList<TunnelConfig>>
fun getAllFlow(): Flow<MutableList<TunnelConfig>>
} }
@@ -1,4 +1,5 @@
package com.zaneschepke.wireguardautotunnel.data.datastore package com.zaneschepke.wireguardautotunnel.data.datastore
import android.content.Context import android.content.Context
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
@@ -11,29 +12,27 @@ import kotlinx.coroutines.flow.map
class DataStoreManager(private val context: Context) { class DataStoreManager(private val context: Context) {
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")
} }
// preferences // preferences
private val preferencesKey = "preferences" private val preferencesKey = "preferences"
private val Context.dataStore by preferencesDataStore( private val Context.dataStore by
name = preferencesKey preferencesDataStore(
) name = preferencesKey,
)
suspend fun init() { suspend fun init() {
context.dataStore.data.first() context.dataStore.data.first()
} }
suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) = suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) =
context.dataStore.edit { context.dataStore.edit { it[key] = value }
it[key] = value
}
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map {
it[key]
}
suspend fun <T> getFromStore(key: Preferences.Key<T>) = context.dataStore.data.first { it.contains(key) }[key] fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map { it[key] }
val locationDisclosureFlow: Flow<Boolean?> = context.dataStore.data.map { suspend fun <T> getFromStore(key: Preferences.Key<T>) =
it[LOCATION_DISCLOSURE_SHOWN] context.dataStore.data.first { it.contains(key) }[key]
}
val preferencesFlow: Flow<Preferences?> = context.dataStore.data
} }
@@ -8,39 +8,49 @@ import androidx.room.PrimaryKey
data class Settings( data class Settings(
@PrimaryKey(autoGenerate = true) val id: Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled: Boolean = false, @ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") var isTunnelOnMobileDataEnabled: Boolean = false, @ColumnInfo(name = "is_tunnel_on_mobile_data_enabled")
@ColumnInfo(name = "trusted_network_ssids") var trustedNetworkSSIDs: MutableList<String> = mutableListOf(), var isTunnelOnMobileDataEnabled: Boolean = false,
@ColumnInfo(name = "trusted_network_ssids")
var trustedNetworkSSIDs: MutableList<String> = mutableListOf(),
@ColumnInfo(name = "default_tunnel") var defaultTunnel: String? = null, @ColumnInfo(name = "default_tunnel") var defaultTunnel: String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled: Boolean = false, @ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled: Boolean = false, @ColumnInfo(name = "is_tunnel_on_ethernet_enabled")
var isTunnelOnEthernetEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_shortcuts_enabled", name = "is_shortcuts_enabled",
defaultValue = "false" defaultValue = "false",
) var isShortcutsEnabled: Boolean = false, )
var isShortcutsEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_battery_saver_enabled", name = "is_battery_saver_enabled",
defaultValue = "false" defaultValue = "false",
) var isBatterySaverEnabled: Boolean = false, )
var isBatterySaverEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_tunnel_on_wifi_enabled", name = "is_tunnel_on_wifi_enabled",
defaultValue = "false" defaultValue = "false",
) var isTunnelOnWifiEnabled: Boolean = false, )
var isTunnelOnWifiEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_kernel_enabled", name = "is_kernel_enabled",
defaultValue = "false" defaultValue = "false",
) var isKernelEnabled: Boolean = false, )
var isKernelEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_restore_on_boot_enabled", name = "is_restore_on_boot_enabled",
defaultValue = "false" defaultValue = "false",
) var isRestoreOnBootEnabled: Boolean = false, )
var isRestoreOnBootEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_multi_tunnel_enabled", name = "is_multi_tunnel_enabled",
defaultValue = "false" defaultValue = "false",
) var isMultiTunnelEnabled: Boolean = false, )
var isMultiTunnelEnabled: Boolean = false,
@ColumnInfo( @ColumnInfo(
name = "is_auto_tunnel_paused", name = "is_auto_tunnel_paused",
defaultValue = "false" defaultValue = "false",
) var isAutoTunnelPaused: Boolean = false, )
var isAutoTunnelPaused: Boolean = false,
) { ) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean { fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean {
return if (defaultTunnel != null) { return if (defaultTunnel != null) {
@@ -4,9 +4,11 @@ import com.zaneschepke.wireguardautotunnel.data.model.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>
suspend fun getSettings() : Settings fun getSettingsFlow(): Flow<Settings>
suspend fun getAll() : List<Settings>
suspend fun getSettings(): Settings
suspend fun getAll(): List<Settings>
} }
@@ -6,9 +6,13 @@ 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 delete(tunnelConfig: TunnelConfig) suspend fun delete(tunnelConfig: TunnelConfig)
suspend fun count() : Int
suspend fun count(): Int
} }
@@ -5,7 +5,8 @@ import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) : TunnelConfigRepository { class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) :
TunnelConfigRepository {
override fun getTunnelConfigsFlow(): Flow<TunnelConfigs> { override fun getTunnelConfigsFlow(): Flow<TunnelConfigs> {
return tunnelConfigDao.getAllFlow() return tunnelConfigDao.getAllFlow()
} }
@@ -4,6 +4,7 @@ import android.content.Context
import androidx.room.Room import androidx.room.Room
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.AppDatabase import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.data.DatabaseCallback
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -16,15 +17,14 @@ import javax.inject.Singleton
class DatabaseModule { class DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideDatabase( fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
@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())
.build() .build()
} }
} }
@@ -2,6 +2,4 @@ package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier import javax.inject.Qualifier
@Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class Kernel
@Retention(AnnotationRetention.BINARY)
annotation class Kernel
@@ -17,7 +17,9 @@ import dagger.hilt.android.scopes.ServiceScoped
abstract class ServiceModule { abstract class ServiceModule {
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification): NotificationService abstract fun provideNotificationService(
wireGuardNotification: WireGuardNotification
): NotificationService
@Binds @Binds
@ServiceScoped @ServiceScoped
@@ -25,9 +27,13 @@ abstract class ServiceModule {
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideMobileDataService(mobileDataService: MobileDataService): NetworkService<MobileDataService> abstract fun provideMobileDataService(
mobileDataService: MobileDataService
): NetworkService<MobileDataService>
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideEthernetService(ethernetService: EthernetService): NetworkService<EthernetService> abstract fun provideEthernetService(
ethernetService: EthernetService
): NetworkService<EthernetService>
} }
@@ -21,28 +21,21 @@ import javax.inject.Singleton
class TunnelModule { class TunnelModule {
@Provides @Provides
@Singleton @Singleton
fun provideRootShell( fun provideRootShell(@ApplicationContext context: Context): RootShell {
@ApplicationContext context: Context
): RootShell {
return RootShell(context) return RootShell(context)
} }
@Provides @Provides
@Singleton @Singleton
@Userspace @Userspace
fun provideUserspaceBackend( fun provideUserspaceBackend(@ApplicationContext context: Context): Backend {
@ApplicationContext context: Context
): Backend {
return GoBackend(context) return GoBackend(context)
} }
@Provides @Provides
@Singleton @Singleton
@Kernel @Kernel
fun provideKernelBackend( fun provideKernelBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend {
@ApplicationContext context: Context,
rootShell: RootShell
): Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell)) return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell))
} }
@@ -51,7 +44,7 @@ class TunnelModule {
fun provideVpnService( fun provideVpnService(
@Userspace userspaceBackend: Backend, @Userspace userspaceBackend: Backend,
@Kernel kernelBackend: Backend, @Kernel kernelBackend: Backend,
settingsRepository : SettingsRepository settingsRepository: SettingsRepository
): VpnService { ): VpnService {
return WireGuardTunnel(userspaceBackend, kernelBackend, settingsRepository) return WireGuardTunnel(userspaceBackend, kernelBackend, settingsRepository)
} }
@@ -2,6 +2,4 @@ package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier import javax.inject.Qualifier
@Qualifier @Qualifier @Retention(AnnotationRetention.BINARY) annotation class Userspace
@Retention(AnnotationRetention.BINARY)
annotation class Userspace
@@ -4,22 +4,29 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.util.goAsync import com.zaneschepke.wireguardautotunnel.util.goAsync
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var settingsRepository: SettingsRepository
@Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
override fun onReceive(context: Context?, intent: Intent?) = goAsync { override fun onReceive(context: Context?, intent: Intent?) = goAsync {
if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return@goAsync if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return@goAsync
if(settingsRepository.getSettings().isAutoTunnelEnabled) { val settings = settingsRepository.getSettings()
if (settings.isAutoTunnelEnabled) {
Timber.i("Starting watcher service from boot")
ServiceManager.startWatcherServiceForeground(context!!) ServiceManager.startWatcherServiceForeground(context!!)
} else if(settings.isAlwaysOnVpnEnabled) {
Timber.i("Starting tunnel from boot")
ServiceManager.startVpnServicePrimaryTunnel(context!!, settings, tunnelConfigRepository.getAll().firstOrNull())
} }
} }
} }
@@ -14,19 +14,15 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() { class NotificationActionReceiver : BroadcastReceiver() {
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var settingsRepository: SettingsRepository
override fun onReceive( override fun onReceive(context: Context, intent: Intent?) = goAsync {
context: Context,
intent: Intent?
) = goAsync {
try { try {
val settings = settingsRepository.getSettings() val settings = settingsRepository.getSettings()
if (settings.defaultTunnel != null) { if (settings.defaultTunnel != null) {
ServiceManager.stopVpnService(context) ServiceManager.stopVpnService(context)
delay(Constants.TOGGLE_TUNNEL_DELAY) delay(Constants.TOGGLE_TUNNEL_DELAY)
ServiceManager.startVpnService(context, settings.defaultTunnel.toString()) ServiceManager.startVpnServiceForeground(context, settings.defaultTunnel.toString())
} }
} finally { } finally {
cancel() cancel()
@@ -15,18 +15,14 @@ open class ForegroundService : LifecycleService() {
return null return null
} }
override fun onStartCommand( override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
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
Timber.d("using an intent with action $action")
when (action) { when (action) {
Action.START.name, Action.START_FOREGROUND.name -> startService(intent.extras) Action.START.name,
Action.START_FOREGROUND.name -> startService(intent.extras)
Action.STOP.name -> stopService(intent.extras) Action.STOP.name -> stopService(intent.extras)
"android.net.VpnService" -> { "android.net.VpnService" -> {
Timber.d("Always-on VPN starting service") Timber.d("Always-on VPN starting service")
@@ -36,7 +32,7 @@ open class ForegroundService : LifecycleService() {
} }
} 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.",
) )
} }
// by returning this we make sure the service is restarted if the system kills the service // by returning this we make sure the service is restarted if the system kills the service
@@ -1,28 +1,29 @@
package com.zaneschepke.wireguardautotunnel.service.foreground package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.ActivityManager
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Context.ACTIVITY_SERVICE
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import timber.log.Timber import timber.log.Timber
object ServiceManager { object ServiceManager {
@Suppress("DEPRECATION")
private // Deprecated for third party Services.
fun <T> Context.isServiceRunning(service: Class<T>) =
(getSystemService(ACTIVITY_SERVICE) as ActivityManager)
.getRunningServices(Integer.MAX_VALUE)
.any { it.service.className == service.name }
fun <T : Service> getServiceState( // private
context: Context, // fun <T> Context.isServiceRunning(service: Class<T>) =
cls: Class<T> // (getSystemService(ACTIVITY_SERVICE) as ActivityManager)
): ServiceState { // .runningAppProcesses.any {
val isServiceRunning = context.isServiceRunning(cls) // it.processName == service.name
return if (isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED // }
} //
// fun <T : Service> getServiceState(
// context: Context,
// cls: Class<T>
// ): ServiceState {
// val isServiceRunning = context.isServiceRunning(cls)
// return if (isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED
// }
private fun <T : Service> actionOnService( private fun <T : Service> actionOnService(
action: Action, action: Action,
@@ -30,14 +31,10 @@ object ServiceManager {
cls: Class<T>, cls: Class<T>,
extras: Map<String, String>? = null extras: Map<String, String>? = null
) { ) {
if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return
if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return
val intent = val intent =
Intent(context, cls).also { Intent(context, cls).also {
it.action = action.name it.action = action.name
extras?.forEach { (k, v) -> extras?.forEach { (k, v) -> it.putExtra(k, v) }
it.putExtra(k, v)
}
} }
intent.component?.javaClass intent.component?.javaClass
try { try {
@@ -45,11 +42,9 @@ object ServiceManager {
Action.START_FOREGROUND -> { Action.START_FOREGROUND -> {
context.startForegroundService(intent) context.startForegroundService(intent)
} }
Action.START -> { Action.START -> {
context.startService(intent) context.startService(intent)
} }
Action.STOP -> context.startService(intent) Action.STOP -> context.startService(intent)
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -57,55 +52,57 @@ object ServiceManager {
} }
} }
fun startVpnService( fun startVpnService(context: Context, tunnelConfig: String) {
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START, Action.START,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig),
) )
} }
fun stopVpnService(context: Context) { fun stopVpnService(context: Context) {
Timber.d("Stopping vpn service action")
actionOnService( actionOnService(
Action.STOP, Action.STOP,
context, context,
WireGuardTunnelService::class.java WireGuardTunnelService::class.java,
) )
} }
fun startVpnServiceForeground( fun startVpnServiceForeground(context: Context, tunnelConfig: String) {
context: Context,
tunnelConfig: String
) {
actionOnService( actionOnService(
Action.START_FOREGROUND, Action.START_FOREGROUND,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig),
) )
} }
fun startVpnServicePrimaryTunnel(context: Context, settings: Settings, fallbackTunnel: TunnelConfig? = null) {
if(settings.defaultTunnel != null) {
return startVpnServiceForeground(context, settings.defaultTunnel!!)
}
if(fallbackTunnel != null) {
startVpnServiceForeground(context, fallbackTunnel.toString())
}
}
fun startWatcherServiceForeground( fun startWatcherServiceForeground(
context: Context, context: Context,
) { ) {
actionOnService( actionOnService(
Action.START_FOREGROUND, Action.START_FOREGROUND,
context, context,
WireGuardConnectivityWatcherService::class.java WireGuardConnectivityWatcherService::class.java,
) )
} }
fun startWatcherService( fun startWatcherService(context: Context) {
context: Context
) {
actionOnService( actionOnService(
Action.START, Action.START,
context, context,
WireGuardConnectivityWatcherService::class.java WireGuardConnectivityWatcherService::class.java,
) )
} }
@@ -113,7 +110,7 @@ object ServiceManager {
actionOnService( actionOnService(
Action.STOP, Action.STOP,
context, context,
WireGuardConnectivityWatcherService::class.java WireGuardConnectivityWatcherService::class.java,
) )
} }
} }
@@ -34,334 +34,365 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardConnectivityWatcherService : ForegroundService() { class WireGuardConnectivityWatcherService : ForegroundService() {
private val foregroundId = 122 private val foregroundId = 122
@Inject lateinit var wifiService: NetworkService<WifiService> @Inject lateinit var wifiService: NetworkService<WifiService>
@Inject lateinit var mobileDataService: NetworkService<MobileDataService> @Inject lateinit var mobileDataService: NetworkService<MobileDataService>
@Inject lateinit var ethernetService: NetworkService<EthernetService> @Inject lateinit var ethernetService: NetworkService<EthernetService>
@Inject lateinit var settingsRepository: SettingsRepository @Inject lateinit var settingsRepository: SettingsRepository
@Inject lateinit var notificationService: NotificationService @Inject lateinit var notificationService: NotificationService
@Inject lateinit var vpnService: VpnService @Inject lateinit var vpnService: VpnService
private val networkEventsFlow = MutableStateFlow(WatcherState()) private val networkEventsFlow = MutableStateFlow(WatcherState())
data class WatcherState(
val isWifiConnected: Boolean = false,
val isVpnConnected : Boolean = false,
val isEthernetConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "",
val settings: Settings = Settings()
)
private lateinit var watcherJob: Job data class WatcherState(
val isWifiConnected: Boolean = false,
val isVpnConnected: Boolean = false,
val isEthernetConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "",
val settings: Settings = Settings()
)
private var wakeLock: PowerManager.WakeLock? = null private lateinit var watcherJob: Job
private val tag = this.javaClass.name
override fun onCreate() { private var wakeLock: PowerManager.WakeLock? = null
super.onCreate() private val tag = this.javaClass.name
lifecycleScope.launch(Dispatchers.Main) {
try { override fun onCreate() {
if(settingsRepository.getSettings().isAutoTunnelPaused) { super.onCreate()
launchWatcherPausedNotification() lifecycleScope.launch(Dispatchers.Main) {
} else launchWatcherNotification() try {
} catch (e: Exception) { if (settingsRepository.getSettings().isAutoTunnelPaused) {
Timber.e("Failed to start watcher service, not enough permissions") launchWatcherPausedNotification()
} } else launchWatcherNotification()
} catch (e: Exception) {
Timber.e("Failed to start watcher service, not enough permissions")
}
}
} }
}
override fun startService(extras: Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
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(extras: Bundle?) { override fun stopService(extras: Bundle?) {
super.stopService(extras) super.stopService(extras)
wakeLock?.let { wakeLock?.let {
if (it.isHeld) { if (it.isHeld) {
it.release() it.release()
} }
}
cancelWatcherJob()
stopSelf()
} }
cancelWatcherJob()
stopSelf()
}
private fun launchWatcherNotification(description: String = getString(R.string.watcher_notification_text_active)) { private fun launchWatcherNotification(
val notification = description: String = getString(R.string.watcher_notification_text_active)
notificationService.createNotification( ) {
channelId = getString(R.string.watcher_channel_id), val notification =
channelName = getString(R.string.watcher_channel_name), notificationService.createNotification(
title = getString(R.string.auto_tunnel_title), channelId = getString(R.string.watcher_channel_id),
description = description) channelName = getString(R.string.watcher_channel_name),
ServiceCompat.startForeground( title = getString(R.string.auto_tunnel_title),
this, foregroundId, notification, Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID) description = description,
} )
ServiceCompat.startForeground(
this,
foregroundId,
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))
} }
// TODO could this be restarting service in a bad state? // TODO could this be restarting service in a bad state?
// try to start task again if killed // try to start task again if killed
override fun onTaskRemoved(rootIntent: Intent) { override fun onTaskRemoved(rootIntent: Intent) {
Timber.d("Task Removed called") Timber.d("Task Removed called")
val restartServiceIntent = Intent(rootIntent) val restartServiceIntent = Intent(rootIntent)
val restartServicePendingIntent: PendingIntent = val restartServicePendingIntent: PendingIntent =
PendingIntent.getService( PendingIntent.getService(
this, this,
1, 1,
restartServiceIntent, restartServiceIntent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE) PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
applicationContext.getSystemService(Context.ALARM_SERVICE) )
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE)
applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager val alarmService: AlarmManager =
alarmService.set( applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
AlarmManager.ELAPSED_REALTIME, alarmService.set(
SystemClock.elapsedRealtime() + 1000, AlarmManager.ELAPSED_REALTIME,
restartServicePendingIntent) SystemClock.elapsedRealtime() + 1000,
} restartServicePendingIntent,
)
private suspend fun initWakeLock() {
val isBatterySaverOn =
withContext(lifecycleScope.coroutineContext) {
settingsRepository.getSettings().isBatterySaverEnabled
}
wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
if (isBatterySaverOn) {
Timber.d("Initiating wakelock with timeout")
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} else {
Timber.d("Initiating wakelock with zero timeout")
acquire(Constants.DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT)
}
}
}
}
private fun cancelWatcherJob() {
if (this::watcherJob.isInitialized) {
watcherJob.cancel()
} }
}
private fun startWatcherJob() { private suspend fun initWakeLock() {
watcherJob = val isBatterySaverOn =
lifecycleScope.launch(Dispatchers.IO) { withContext(lifecycleScope.coroutineContext) {
val setting = settingsRepository.getSettings() settingsRepository.getSettings().isBatterySaverEnabled
launch {
Timber.d("Starting wifi watcher")
watchForWifiConnectivityChanges()
}
if (setting.isTunnelOnMobileDataEnabled) {
launch {
Timber.d("Starting mobile data watcher")
watchForMobileDataConnectivityChanges()
} }
} wakeLock =
if (setting.isTunnelOnEthernetEnabled) { (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
launch { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
Timber.d("Starting ethernet data watcher") try {
watchForEthernetConnectivityChanges() if (isBatterySaverOn) {
Timber.d("Initiating wakelock with timeout")
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} else {
Timber.d("Initiating wakelock with zero timeout")
acquire(Constants.DEFAULT_WATCHER_WAKE_LOCK_TIMEOUT)
}
} finally {
release()
}
}
} }
}
launch {
Timber.d("Starting vpn state watcher")
watchForVpnConnectivityChanges()
}
launch {
Timber.d("Starting settings watcher")
watchForSettingsChanges()
}
launch {
Timber.d("Starting management watcher")
manageVpn()
}
}
}
private suspend fun watchForMobileDataConnectivityChanges() {
mobileDataService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Mobile data connection")
networkEventsFlow.value = networkEventsFlow.value.copy(
isMobileDataConnected = true
)
}
is NetworkStatus.CapabilitiesChanged -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isMobileDataConnected = true
)
Timber.d("Mobile data capabilities changed")
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isMobileDataConnected = false
)
Timber.d("Lost mobile data connection")
}
}
} }
}
private fun cancelWatcherJob() {
if (this::watcherJob.isInitialized) {
watcherJob.cancel()
}
}
private fun startWatcherJob() {
watcherJob =
lifecycleScope.launch(Dispatchers.IO) {
val setting = settingsRepository.getSettings()
launch {
Timber.d("Starting wifi watcher")
watchForWifiConnectivityChanges()
}
if (setting.isTunnelOnMobileDataEnabled) {
launch {
Timber.d("Starting mobile data watcher")
watchForMobileDataConnectivityChanges()
}
}
if (setting.isTunnelOnEthernetEnabled) {
launch {
Timber.d("Starting ethernet data watcher")
watchForEthernetConnectivityChanges()
}
}
launch {
Timber.d("Starting vpn state watcher")
watchForVpnConnectivityChanges()
}
launch {
Timber.d("Starting settings watcher")
watchForSettingsChanges()
}
launch {
Timber.d("Starting management watcher")
manageVpn()
}
}
}
private suspend fun watchForMobileDataConnectivityChanges() {
mobileDataService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Mobile data connection")
networkEventsFlow.value =
networkEventsFlow.value.copy(
isMobileDataConnected = true,
)
}
is NetworkStatus.CapabilitiesChanged -> {
networkEventsFlow.value =
networkEventsFlow.value.copy(
isMobileDataConnected = true,
)
Timber.d("Mobile data capabilities changed")
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.value =
networkEventsFlow.value.copy(
isMobileDataConnected = false,
)
Timber.d("Lost mobile data connection")
}
}
}
}
private suspend fun watchForSettingsChanges() { private suspend fun watchForSettingsChanges() {
settingsRepository.getSettingsFlow().collect { settingsRepository.getSettingsFlow().collect {
if(networkEventsFlow.value.settings.isAutoTunnelPaused != it.isAutoTunnelPaused) { if (networkEventsFlow.value.settings.isAutoTunnelPaused != it.isAutoTunnelPaused) {
when(it.isAutoTunnelPaused) { when (it.isAutoTunnelPaused) {
true -> launchWatcherPausedNotification() true -> launchWatcherPausedNotification()
false -> launchWatcherNotification() false -> launchWatcherNotification()
} }
} }
networkEventsFlow.value = networkEventsFlow.value.copy( networkEventsFlow.value =
settings = it networkEventsFlow.value.copy(
) settings = it,
)
} }
} }
private suspend fun watchForVpnConnectivityChanges() { private suspend fun watchForVpnConnectivityChanges() {
vpnService.vpnState.collect { vpnService.vpnState.collect {
when(it.status) { when (it.status) {
Tunnel.State.DOWN -> networkEventsFlow.value = networkEventsFlow.value.copy( Tunnel.State.DOWN ->
isVpnConnected = false networkEventsFlow.value =
) networkEventsFlow.value.copy(
Tunnel.State.UP -> networkEventsFlow.value = networkEventsFlow.value.copy( isVpnConnected = false,
isVpnConnected = true )
) Tunnel.State.UP ->
networkEventsFlow.value =
networkEventsFlow.value.copy(
isVpnConnected = true,
)
else -> {} else -> {}
} }
} }
} }
private suspend fun watchForEthernetConnectivityChanges() { private suspend fun watchForEthernetConnectivityChanges() {
ethernetService.networkStatus.collect { ethernetService.networkStatus.collect {
when (it) { when (it) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.d("Gained Ethernet connection") Timber.d("Gained Ethernet connection")
networkEventsFlow.value = networkEventsFlow.value.copy( networkEventsFlow.value =
isEthernetConnected = true networkEventsFlow.value.copy(
) isEthernetConnected = true,
} )
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Ethernet capabilities changed")
networkEventsFlow.value = networkEventsFlow.value.copy(
isEthernetConnected = true
)
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isEthernetConnected = false
)
Timber.d("Lost Ethernet connection")
}
}
}
}
private suspend fun watchForWifiConnectivityChanges() {
wifiService.networkStatus.collect {
when (it) {
is NetworkStatus.Available -> {
Timber.d("Gained Wi-Fi connection")
networkEventsFlow.value = networkEventsFlow.value.copy(
isWifiConnected = true
)
}
is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Wifi capabilities changed")
networkEventsFlow.value = networkEventsFlow.value.copy(
isWifiConnected = true
)
val ssid = wifiService.getNetworkName(it.networkCapabilities) ?: ""
Timber.d("Detected SSID: $ssid")
networkEventsFlow.value = networkEventsFlow.value.copy(
currentNetworkSSID = ssid
)
}
is NetworkStatus.Unavailable -> {
networkEventsFlow.value = networkEventsFlow.value.copy(
isWifiConnected = false
)
Timber.d("Lost Wi-Fi connection")
}
}
}
}
//TODO clean this up
private suspend fun manageVpn() {
networkEventsFlow.collectLatest {
Timber.i("New watcher state: $it")
if (!it.settings.isAutoTunnelPaused && it.settings.defaultTunnel != null) {
delay(Constants.TOGGLE_TUNNEL_DELAY)
when {
((it.isEthernetConnected &&
it.settings.isTunnelOnEthernetEnabled &&
!it.isVpnConnected)) -> {
ServiceManager.startVpnService(this, it.settings.defaultTunnel!!)
Timber.i("Condition 1 met")
} }
(!it.isEthernetConnected && is NetworkStatus.CapabilitiesChanged -> {
it.settings.isTunnelOnMobileDataEnabled && Timber.d("Ethernet capabilities changed")
!it.isWifiConnected && networkEventsFlow.value =
it.isMobileDataConnected && networkEventsFlow.value.copy(
!it.isVpnConnected) -> { isEthernetConnected = true,
ServiceManager.startVpnService(this, it.settings.defaultTunnel!!) )
Timber.i("Condition 2 met")
} }
(!it.isEthernetConnected && is NetworkStatus.Unavailable -> {
!it.settings.isTunnelOnMobileDataEnabled && networkEventsFlow.value =
!it.isWifiConnected && networkEventsFlow.value.copy(
it.isVpnConnected) -> { isEthernetConnected = false,
ServiceManager.stopVpnService(this) )
Timber.i("Condition 3 met") Timber.d("Lost Ethernet connection")
} }
(!it.isEthernetConnected && }
it.isWifiConnected && }
!it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID) && }
it.settings.isTunnelOnWifiEnabled &&
(!it.isVpnConnected)) -> { private suspend fun watchForWifiConnectivityChanges() {
ServiceManager.startVpnService(this, it.settings.defaultTunnel!!) wifiService.networkStatus.collect {
Timber.i("Condition 4 met") when (it) {
} is NetworkStatus.Available -> {
(!it.isEthernetConnected && Timber.d("Gained Wi-Fi connection")
(it.isWifiConnected && it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID)) && networkEventsFlow.value =
(it.isVpnConnected)) -> { networkEventsFlow.value.copy(
ServiceManager.stopVpnService(this) isWifiConnected = true,
Timber.i("Condition 5 met") )
} }
(!it.isEthernetConnected && is NetworkStatus.CapabilitiesChanged -> {
(it.isWifiConnected && Timber.d("Wifi capabilities changed")
!it.settings.isTunnelOnWifiEnabled && networkEventsFlow.value =
(it.isVpnConnected))) -> { networkEventsFlow.value.copy(
ServiceManager.stopVpnService(this) isWifiConnected = true,
Timber.i("Condition 6 met") )
} val ssid = wifiService.getNetworkName(it.networkCapabilities) ?: ""
(!it.isEthernetConnected && Timber.d("Detected SSID: $ssid")
!it.isWifiConnected && networkEventsFlow.value =
!it.isMobileDataConnected && networkEventsFlow.value.copy(
(it.isVpnConnected)) -> { currentNetworkSSID = ssid,
ServiceManager.stopVpnService(this) )
Timber.i("Condition 7 met") }
} is NetworkStatus.Unavailable -> {
else -> { networkEventsFlow.value =
Timber.i("No condition met") networkEventsFlow.value.copy(
isWifiConnected = false,
)
Timber.d("Lost Wi-Fi connection")
}
}
}
}
// TODO clean this up
private suspend fun manageVpn() {
networkEventsFlow.collectLatest {
Timber.i("New watcher state: $it")
if (!it.settings.isAutoTunnelPaused && it.settings.defaultTunnel != null) {
delay(Constants.TOGGLE_TUNNEL_DELAY)
when {
((it.isEthernetConnected &&
it.settings.isTunnelOnEthernetEnabled &&
!it.isVpnConnected)) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 1 met")
}
(!it.isEthernetConnected &&
it.settings.isTunnelOnMobileDataEnabled &&
!it.isWifiConnected &&
it.isMobileDataConnected &&
!it.isVpnConnected) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 2 met")
}
(!it.isEthernetConnected &&
!it.settings.isTunnelOnMobileDataEnabled &&
!it.isWifiConnected &&
it.isVpnConnected) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 3 met")
}
(!it.isEthernetConnected &&
it.isWifiConnected &&
!it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID) &&
it.settings.isTunnelOnWifiEnabled &&
(!it.isVpnConnected)) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 4 met")
}
(!it.isEthernetConnected &&
(it.isWifiConnected &&
it.settings.trustedNetworkSSIDs.contains(it.currentNetworkSSID)) &&
(it.isVpnConnected)) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 5 met")
}
(!it.isEthernetConnected &&
(it.isWifiConnected &&
!it.settings.isTunnelOnWifiEnabled &&
(it.isVpnConnected))) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 6 met")
}
(!it.isEthernetConnected &&
!it.isWifiConnected &&
!it.isMobileDataConnected &&
(it.isVpnConnected)) -> {
ServiceManager.stopVpnService(this)
Timber.i("Condition 7 met")
}
else -> {
Timber.i("No condition met")
}
} }
} }
} }
} }
}
} }
@@ -28,17 +28,13 @@ import javax.inject.Inject
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 settingsRepository: SettingsRepository
lateinit var settingsRepository: SettingsRepository
@Inject @Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
lateinit var tunnelConfigRepository: TunnelConfigRepository
@Inject @Inject lateinit var notificationService: NotificationService
lateinit var notificationService: NotificationService
private lateinit var job: Job private lateinit var job: Job
@@ -48,7 +44,7 @@ class WireGuardTunnelService : ForegroundService() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
if(tunnelConfigRepository.getAll().isNotEmpty()) { if (tunnelConfigRepository.getAll().isNotEmpty()) {
launchVpnNotification() launchVpnNotification()
} }
} }
@@ -58,11 +54,10 @@ class WireGuardTunnelService : ForegroundService() {
super.startService(extras) super.startService(extras)
cancelJob() cancelJob()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key)) val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
val tunnelConfig = tunnelConfigString?.let { val tunnelConfig = tunnelConfigString?.let { TunnelConfig.from(it) }
TunnelConfig.from(it)
}
tunnelName = tunnelConfig?.name ?: "" tunnelName = tunnelConfig?.name ?: ""
job = lifecycleScope.launch(Dispatchers.IO) { job =
lifecycleScope.launch(Dispatchers.IO) {
launch { launch {
if (tunnelConfig != null) { if (tunnelConfig != null) {
try { try {
@@ -77,22 +72,22 @@ class WireGuardTunnelService : ForegroundService() {
val settings = settingsRepository.getSettings() val settings = settingsRepository.getSettings()
val tunnels = tunnelConfigRepository.getAll() val tunnels = tunnelConfigRepository.getAll()
if (settings.isAlwaysOnVpnEnabled) { if (settings.isAlwaysOnVpnEnabled) {
val tunnel = if(settings.defaultTunnel != null) { val tunnel =
TunnelConfig.from(settings.defaultTunnel!!) if (settings.defaultTunnel != null) {
} else if(tunnels.isNotEmpty()) { TunnelConfig.from(settings.defaultTunnel!!)
tunnels.first() } else if (tunnels.isNotEmpty()) {
} else { tunnels.first()
null } else {
} null
if(tunnel != null) { }
if (tunnel != null) {
tunnelName = tunnel.name tunnelName = tunnel.name
vpnService.startTunnel(tunnel) vpnService.startTunnel(tunnel)
} }
} }
} }
} }
//TODO add failed to connect notification // TODO add failed to connect notification
launch { launch {
vpnService.vpnState.collect { state -> vpnService.vpnState.collect { state ->
state.statistics state.statistics
@@ -101,14 +96,18 @@ class WireGuardTunnelService : ForegroundService() {
.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)
launchVpnNotification(getString(R.string.tunnel_start_title),"${getString(R.string.tunnel_start_text)} $tunnelName") launchVpnNotification(
getString(R.string.tunnel_start_title),
"${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 } == true -> {} statuses?.all { it == HandshakeStatus.NOT_STARTED } ==
true -> {}
else -> {} else -> {}
} }
} }
@@ -127,7 +126,10 @@ class WireGuardTunnelService : ForegroundService() {
stopSelf() stopSelf()
} }
private fun launchVpnNotification(title : String = getString(R.string.vpn_starting),description : String = getString(R.string.attempt_connection)) { private fun launchVpnNotification(
title: String = getString(R.string.vpn_starting),
description: String = getString(R.string.attempt_connection)
) {
val notification = val notification =
notificationService.createNotification( notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
@@ -136,13 +138,13 @@ class WireGuardTunnelService : ForegroundService() {
onGoing = false, onGoing = false,
vibration = false, vibration = false,
showTimestamp = true, showTimestamp = true,
description = description description = description,
) )
ServiceCompat.startForeground( ServiceCompat.startForeground(
this, this,
foregroundId, foregroundId,
notification, notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
) )
} }
@@ -152,24 +154,24 @@ class WireGuardTunnelService : ForegroundService() {
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,
) )
} }
@@ -24,72 +24,69 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
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 = override val networkStatus = callbackFlow {
callbackFlow { val networkStatusCallback =
val networkStatusCallback = when (Build.VERSION.SDK_INT) {
when (Build.VERSION.SDK_INT) { in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
in Build.VERSION_CODES.S..Int.MAX_VALUE -> { object :
object : ConnectivityManager.NetworkCallback( ConnectivityManager.NetworkCallback(
FLAG_INCLUDE_LOCATION_INFO FLAG_INCLUDE_LOCATION_INFO,
) { ) {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network)) trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network))
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities
)
)
}
} }
}
else -> { override fun onLost(network: Network) {
object : ConnectivityManager.NetworkCallback() { trySend(NetworkStatus.Unavailable(network))
override fun onAvailable(network: Network) { }
trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) { override fun onCapabilitiesChanged(
trySend(NetworkStatus.Unavailable(network)) network: Network,
} networkCapabilities: NetworkCapabilities
) {
override fun onCapabilitiesChanged( trySend(
network: Network, NetworkStatus.CapabilitiesChanged(
networkCapabilities: NetworkCapabilities network,
) { networkCapabilities,
trySend( ),
NetworkStatus.CapabilitiesChanged( )
network,
networkCapabilities
)
)
}
} }
} }
} }
val request = else -> {
NetworkRequest.Builder() object : ConnectivityManager.NetworkCallback() {
.addTransportType(networkCapability) override fun onAvailable(network: Network) {
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) trySend(NetworkStatus.Available(network))
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) }
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { override fun onLost(network: Network) {
connectivityManager.unregisterNetworkCallback(networkStatusCallback) trySend(NetworkStatus.Unavailable(network))
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities,
),
)
}
}
}
} }
} val request =
NetworkRequest.Builder()
.addTransportType(networkCapability)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) }
}
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? { override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid: String? = getWifiNameFromCapabilities(networkCapabilities) var ssid: String? = getWifiNameFromCapabilities(networkCapabilities)
@@ -119,18 +116,16 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
inline fun <Result> Flow<NetworkStatus>.map( inline fun <Result> Flow<NetworkStatus>.map(
crossinline onUnavailable: suspend (network: Network) -> Result, crossinline onUnavailable: suspend (network: Network) -> Result,
crossinline onAvailable: suspend (network: Network) -> Result, crossinline onAvailable: suspend (network: Network) -> Result,
crossinline onCapabilitiesChanged: suspend ( crossinline onCapabilitiesChanged:
network: Network, suspend (network: Network, networkCapabilities: NetworkCapabilities) -> Result
networkCapabilities: NetworkCapabilities ): Flow<Result> = map { status ->
) -> Result when (status) {
): Flow<Result> = is NetworkStatus.Unavailable -> onUnavailable(status.network)
map { status -> is NetworkStatus.Available -> onAvailable(status.network)
when (status) { is NetworkStatus.CapabilitiesChanged ->
is NetworkStatus.Unavailable -> onUnavailable(status.network) onCapabilitiesChanged(
is NetworkStatus.Available -> onAvailable(status.network)
is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(
status.network, status.network,
status.networkCapabilities status.networkCapabilities,
) )
}
} }
}
@@ -5,9 +5,5 @@ 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 class EthernetService @Inject constructor(@ApplicationContext context: Context) :
@Inject
constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET) BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET)
@@ -5,9 +5,5 @@ 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 class MobileDataService @Inject constructor(@ApplicationContext context: Context) :
@Inject
constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR) BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR)
@@ -5,9 +5,5 @@ 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 class WifiService @Inject constructor(@ApplicationContext context: Context) :
@Inject
constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI) BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI)
@@ -3,7 +3,6 @@ package com.zaneschepke.wireguardautotunnel.service.notification
import android.app.Notification import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import androidx.core.app.NotificationCompat
interface NotificationService { interface NotificationService {
fun createNotification( fun createNotification(
@@ -13,23 +13,21 @@ import com.zaneschepke.wireguardautotunnel.ui.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WireGuardNotification class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) :
@Inject NotificationService {
constructor(
@ApplicationContext private val context: Context
) : NotificationService {
private val notificationManager = private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as 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 =
NotificationCompat.Builder(
context,
context.getString(R.string.vpn_channel_id),
) )
private val tunnelBuilder: NotificationCompat.Builder = NotificationCompat.Builder(
context,
context.getString(R.string.vpn_channel_id)
)
override fun createNotification( override fun createNotification(
channelId: String, channelId: String,
@@ -47,17 +45,18 @@ constructor(
): Notification { ): Notification {
val channel = val channel =
NotificationChannel( NotificationChannel(
channelId, channelId,
channelName, channelName,
importance importance,
).let { )
it.description = title .let {
it.enableLights(lights) it.description = title
it.lightColor = Color.RED it.enableLights(lights)
it.enableVibration(vibration) it.lightColor = Color.RED
it.vibrationPattern = longArrayOf(100,200,300) it.enableVibration(vibration)
it it.vibrationPattern = longArrayOf(100, 200, 300)
} it
}
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
val pendingIntent: PendingIntent = val pendingIntent: PendingIntent =
Intent(context, MainActivity::class.java).let { notificationIntent -> Intent(context, MainActivity::class.java).let { notificationIntent ->
@@ -65,26 +64,26 @@ constructor(
context, context,
0, 0,
notificationIntent, notificationIntent,
PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_IMMUTABLE,
) )
} }
val builder = when(channelId) { val builder =
context.getString(R.string.watcher_channel_id) -> watcherBuilder when (channelId) {
context.getString(R.string.vpn_channel_id) -> tunnelBuilder context.getString(R.string.watcher_channel_id) -> watcherBuilder
else -> { context.getString(R.string.vpn_channel_id) -> tunnelBuilder
NotificationCompat.Builder( else -> {
context, NotificationCompat.Builder(
channelId context,
) 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) NotificationCompat.Action.Builder(0, actionText, action).build(),
.build()
) )
it.setAutoCancel(true) it.setAutoCancel(true)
} }
@@ -95,7 +94,7 @@ constructor(
.setOngoing(onGoing) .setOngoing(onGoing)
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
.setShowWhen(showTimestamp) .setShowWhen(showTimestamp)
.setSmallIcon(R.mipmap.ic_launcher_foreground) .setSmallIcon(R.drawable.ic_launcher)
.build() .build()
} }
} }
@@ -12,33 +12,34 @@ import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ShortcutsActivity : ComponentActivity() { class ShortcutsActivity : ComponentActivity() {
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var settingsRepository: SettingsRepository
@Inject @Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
lateinit var tunnelConfigRepository: TunnelConfigRepository
private suspend fun toggleWatcherServicePause() { private suspend fun toggleWatcherServicePause() {
val settings = settingsRepository.getSettings() val settings = settingsRepository.getSettings()
if (settings.isAutoTunnelEnabled) { if (settings.isAutoTunnelEnabled) {
val pauseAutoTunnel = !settings.isAutoTunnelPaused val pauseAutoTunnel = !settings.isAutoTunnelPaused
settingsRepository.save(settings.copy( settingsRepository.save(
isAutoTunnelPaused = pauseAutoTunnel settings.copy(
)) isAutoTunnelPaused = pauseAutoTunnel,
} ),
)
} }
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(View(this)) setContentView(View(this))
if (intent.getStringExtra(CLASS_NAME_EXTRA_KEY) if (
intent
.getStringExtra(CLASS_NAME_EXTRA_KEY)
.equals(WireGuardTunnelService::class.java.simpleName) .equals(WireGuardTunnelService::class.java.simpleName)
) { ) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
@@ -48,7 +49,9 @@ class ShortcutsActivity : ComponentActivity() {
val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY) val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
val tunnelConfig = val tunnelConfig =
if (tunnelName != null) { if (tunnelName != null) {
tunnelConfigRepository.getAll().firstOrNull { it.name == tunnelName } tunnelConfigRepository.getAll().firstOrNull {
it.name == tunnelName
}
} else { } else {
if (settings.defaultTunnel == null) { if (settings.defaultTunnel == null) {
tunnelConfigRepository.getAll().first() tunnelConfigRepository.getAll().first()
@@ -59,13 +62,15 @@ class ShortcutsActivity : ComponentActivity() {
tunnelConfig ?: return@launch tunnelConfig ?: return@launch
toggleWatcherServicePause() toggleWatcherServicePause()
when (intent.action) { when (intent.action) {
Action.STOP.name -> ServiceManager.stopVpnService( Action.STOP.name ->
this@ShortcutsActivity ServiceManager.stopVpnService(
) this@ShortcutsActivity,
Action.START.name -> ServiceManager.startVpnService( )
this@ShortcutsActivity, Action.START.name ->
tunnelConfig.toString() ServiceManager.startVpnServiceForeground(
) this@ShortcutsActivity,
tunnelConfig.toString(),
)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
@@ -20,44 +20,43 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TunnelControlTile() : TileService() { class TunnelControlTile() : TileService() {
@Inject @Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
lateinit var tunnelConfigRepository: TunnelConfigRepository
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var settingsRepository: SettingsRepository
@Inject @Inject lateinit var vpnService: VpnService
lateinit var vpnService: VpnService
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private var tunnelName : String? = null private var tunnelName: String? = null
override fun onStartListening() { override fun onStartListening() {
super.onStartListening() super.onStartListening()
Timber.d("On start listening called") Timber.d("On start listening called")
scope.launch { scope.launch {
vpnService.vpnState.collect { vpnService.vpnState.collect {
when(it.status) { when (it.status) {
Tunnel.State.UP -> setActive() Tunnel.State.UP -> setActive()
Tunnel.State.DOWN -> setInactive() Tunnel.State.DOWN -> setInactive()
else -> setInactive() else -> setInactive()
} }
val tunnels = tunnelConfigRepository.getAll() val tunnels = tunnelConfigRepository.getAll()
if(tunnels.isEmpty()) { if (tunnels.isEmpty()) {
setUnavailable() setUnavailable()
return@collect return@collect
} }
tunnelName = it.name.ifBlank { tunnelName =
val settings = settingsRepository.getSettings() it.name.ifBlank {
if (settings.defaultTunnel != null) { val settings = settingsRepository.getSettings()
TunnelConfig.from(settings.defaultTunnel!!).name if (settings.defaultTunnel != null) {
} else tunnels.firstOrNull()?.name TunnelConfig.from(settings.defaultTunnel!!).name
} } else tunnels.firstOrNull()?.name
}
setTileDescription(tunnelName ?: "") setTileDescription(tunnelName ?: "")
} }
} }
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
scope.cancel() scope.cancel()
@@ -73,14 +72,15 @@ class TunnelControlTile() : TileService() {
unlockAndRun { unlockAndRun {
scope.launch { scope.launch {
try { try {
val tunnelConfig = tunnelConfigRepository.getAll().first { it.name == tunnelName } val tunnelConfig =
tunnelConfigRepository.getAll().first { it.name == tunnelName }
toggleWatcherServicePause() toggleWatcherServicePause()
if (vpnService.getState() == Tunnel.State.UP) { if (vpnService.getState() == Tunnel.State.UP) {
ServiceManager.stopVpnService(this@TunnelControlTile) ServiceManager.stopVpnService(this@TunnelControlTile)
} else { } else {
ServiceManager.startVpnServiceForeground( ServiceManager.startVpnServiceForeground(
this@TunnelControlTile, this@TunnelControlTile,
tunnelConfig.toString() tunnelConfig.toString(),
) )
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -97,9 +97,11 @@ class TunnelControlTile() : TileService() {
val settings = settingsRepository.getSettings() val settings = settingsRepository.getSettings()
if (settings.isAutoTunnelEnabled) { if (settings.isAutoTunnelEnabled) {
val pauseAutoTunnel = !settings.isAutoTunnelPaused val pauseAutoTunnel = !settings.isAutoTunnelPaused
settingsRepository.save(settings.copy( settingsRepository.save(
isAutoTunnelPaused = pauseAutoTunnel settings.copy(
)) isAutoTunnelPaused = pauseAutoTunnel,
),
)
} }
} }
} }
@@ -4,13 +4,13 @@ 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 = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + STATUS_CHANGE_TIME_BUFFER const val STALE_TIME_LIMIT_SEC =
WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + STATUS_CHANGE_TIME_BUFFER
const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30 const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30
} }
} }
@@ -4,7 +4,7 @@ import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
data class VpnState( data class VpnState(
val status : Tunnel.State = Tunnel.State.DOWN, val status: Tunnel.State = Tunnel.State.DOWN,
val name : String = "", val name: String = "",
val statistics : Statistics? = null val statistics: Statistics? = null
) )
@@ -25,7 +25,7 @@ import javax.inject.Inject
class WireGuardTunnel class WireGuardTunnel
@Inject @Inject
constructor( constructor(
@Userspace private val userspaceBackend: Backend, @Userspace private val userspaceBackend : Backend,
@Kernel private val kernelBackend: Backend, @Kernel private val kernelBackend: Backend,
private val settingsRepository: SettingsRepository private val settingsRepository: SettingsRepository
) : VpnService { ) : VpnService {
@@ -67,7 +67,7 @@ constructor(
backend.setState( backend.setState(
this, this,
State.UP, State.UP,
config config,
) )
emitTunnelState(state) emitTunnelState(state)
state state
@@ -80,24 +80,24 @@ constructor(
private fun emitTunnelState(state: State) { private fun emitTunnelState(state: State) {
_vpnState.tryEmit( _vpnState.tryEmit(
_vpnState.value.copy( _vpnState.value.copy(
status = state status = state,
) ),
) )
} }
private fun emitBackendStatistics(statistics: Statistics) { private fun emitBackendStatistics(statistics: Statistics) {
_vpnState.tryEmit( _vpnState.tryEmit(
_vpnState.value.copy( _vpnState.value.copy(
statistics = statistics statistics = statistics,
) ),
) )
} }
private suspend fun emitTunnelName(name: String) { private suspend fun emitTunnelName(name: String) {
_vpnState.emit( _vpnState.emit(
_vpnState.value.copy( _vpnState.value.copy(
name = name name = name,
) ),
) )
} }
@@ -1,13 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui package com.zaneschepke.wireguardautotunnel.ui
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ActivityViewModel @Inject constructor( class ActivityViewModel
private val settingsRepo: SettingsDao, @Inject
) : ViewModel() { constructor() : ViewModel() {
} }
@@ -6,11 +6,13 @@ import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.enableEdgeToEdge
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.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarData import androidx.compose.material3.SnackbarData
@@ -20,13 +22,12 @@ import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
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.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.toArgb
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
@@ -35,8 +36,10 @@ import androidx.navigation.compose.rememberNavController
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.wireguard.android.backend.GoBackend
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen import com.zaneschepke.wireguardautotunnel.ui.common.PermissionRequestFailedScreen
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavBar
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar import com.zaneschepke.wireguardautotunnel.ui.common.prompt.CustomSnackBar
@@ -44,68 +47,74 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.IOException
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@Inject
lateinit var dataStoreManager: DataStoreManager
@Inject lateinit var settingsRepository: SettingsRepository
@OptIn( @OptIn(
ExperimentalPermissionsApi::class ExperimentalPermissionsApi::class,
) )
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge(navigationBarStyle = SystemBarStyle.dark(Color.Transparent.toArgb()))
// load preferences into memory and init data
lifecycleScope.launch {
try {
dataStoreManager.init()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
} catch (e: IOException) {
Timber.e("Failed to load preferences")
}
}
setContent { setContent {
// val activityViewModel = hiltViewModel<ActivityViewModel>() //val activityViewModel = hiltViewModel<ActivityViewModel>()
val navController = rememberNavController() val navController = rememberNavController()
val focusRequester = remember { FocusRequester()} val focusRequester = remember { FocusRequester() }
WireguardAutoTunnelTheme { WireguardAutoTunnelTheme {
TransparentSystemBars()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val notificationPermissionState = val notificationPermissionState = if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) else null
fun requestNotificationPermission() { fun requestNotificationPermission() {
if (!notificationPermissionState.status.isGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (notificationPermissionState != null && !notificationPermissionState.status.isGranted
) {
notificationPermissionState.launchPermissionRequest() notificationPermissionState.launchPermissionRequest()
} }
} }
var vpnIntent by remember { mutableStateOf(GoBackend.VpnService.prepare(this)) } LaunchedEffect(Unit) {
val vpnActivityResultState = requestNotificationPermission()
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
val accepted = (it.resultCode == RESULT_OK)
if (accepted) {
vpnIntent = null
}
}
)
LaunchedEffect(vpnIntent) {
if (vpnIntent != null) {
vpnActivityResultState.launch(vpnIntent)
} else {
requestNotificationPermission()
}
} }
fun showSnackBarMessage(message: String) { fun showSnackBarMessage(message: String) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
val result = snackbarHostState.showSnackbar( val result =
snackbarHostState.showSnackbar(
message = message, message = message,
actionLabel = applicationContext.getString(R.string.okay), actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short duration = SnackbarDuration.Short,
) )
when (result) { when (result) {
SnackbarResult.ActionPerformed, SnackbarResult.Dismissed -> { SnackbarResult.ActionPerformed,
SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss() snackbarHostState.currentSnackbarData?.dismiss()
} }
} }
@@ -118,87 +127,96 @@ class MainActivity : AppCompatActivity() {
CustomSnackBar( CustomSnackBar(
snackbarData.visuals.message, snackbarData.visuals.message,
isRtl = false, isRtl = false,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation( containerColor =
2.dp MaterialTheme.colorScheme.surfaceColorAtElevation(
) 2.dp,
),
) )
} }
}, },
modifier = Modifier.focusable().focusProperties { up = focusRequester }, modifier = Modifier
.focusable()
.focusProperties { up = focusRequester },
bottomBar = bottomBar =
if (vpnIntent == null && notificationPermissionState.status.isGranted) { if (notificationPermissionState == null || notificationPermissionState.status.isGranted) {
{ BottomNavBar(navController, listOf( {
Screen.Main.navItem, BottomNavBar(
Screen.Settings.navItem, navController,
Screen.Support.navItem)) } listOf(
} else { Screen.Main.navItem,
{} Screen.Settings.navItem,
} Screen.Support.navItem,
) { padding -> ),
if (vpnIntent != null) {
PermissionRequestFailedScreen(
padding = padding,
onRequestAgain = { vpnActivityResultState.launch(vpnIntent) },
message = getString(R.string.vpn_permission_required),
getString(R.string.retry)
)
return@Scaffold
}
if (!notificationPermissionState.status.isGranted) {
PermissionRequestFailedScreen(
padding = padding,
onRequestAgain = {
val intentSettings =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data =
Uri.fromParts(
Constants.URI_PACKAGE_SCHEME,
this.packageName,
null
)
startActivity(intentSettings)
},
message = getString(R.string.notification_permission_required),
getString(R.string.open_settings)
)
return@Scaffold
}
NavHost(navController, startDestination = Screen.Main.route) {
composable(
Screen.Main.route,
) {
MainScreen(padding = padding, focusRequester = focusRequester, showSnackbarMessage = { message ->
showSnackBarMessage(message)
}, navController = navController)
}
composable(Screen.Settings.route,
) {
SettingsScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message)
}, focusRequester = focusRequester)
}
composable(Screen.Support.route,
) {
SupportScreen(padding = padding, focusRequester = focusRequester,
showSnackbarMessage = { message ->
showSnackBarMessage(message)
})
}
composable("${Screen.Config.route}/{id}") {
val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) {
//https://dagger.dev/hilt/view-model#assisted-injection
ConfigScreen(
navController = navController,
id = id,
showSnackbarMessage = { message ->
showSnackBarMessage(message)
},
focusRequester = focusRequester
) )
} }
} else {
{}
},
) { padding ->
if (notificationPermissionState != null && !notificationPermissionState.status.isGranted) {
Column(modifier = Modifier.padding(padding)) {
PermissionRequestFailedScreen(
onRequestAgain = {
val intentSettings =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data =
Uri.fromParts(
Constants.URI_PACKAGE_SCHEME,
this@MainActivity.packageName,
null,
)
startActivity(intentSettings)
},
message = getString(R.string.notification_permission_required),
getString(R.string.open_settings),
)
return@Scaffold
}
}
NavHost(navController, startDestination = Screen.Main.route) {
composable(
Screen.Main.route,
) {
MainScreen(
focusRequester = focusRequester,
showSnackbarMessage = { message -> showSnackBarMessage(message) },
navController = navController,
)
}
composable(
Screen.Settings.route,
) {
SettingsScreen(
padding = padding,
showSnackbarMessage = { message -> showSnackBarMessage(message) },
focusRequester = focusRequester,
)
//
}
composable(
Screen.Support.route,
) {
SupportScreen(
padding = padding,
focusRequester = focusRequester,
showSnackbarMessage = { message -> showSnackBarMessage(message) },
)
}
composable("${Screen.Config.route}/{id}") {
val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) {
ConfigScreen(
padding = padding,
navController = navController,
id = id,
showSnackbarMessage = { message ->
showSnackBarMessage(message)
},
focusRequester = focusRequester,
)
}
}
} }
}
} }
} }
} }
@@ -6,28 +6,33 @@ import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Settings
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 = BottomNavItem( val navItem =
name = "Tunnels", BottomNavItem(
route = route, name = "Tunnels",
icon = Icons.Rounded.Home route = route,
) icon = Icons.Rounded.Home,
)
} }
data object Settings: Screen("settings") {
val navItem = BottomNavItem(
name = "Settings",
route = route,
icon = Icons.Rounded.Settings
)
}
data object Support: Screen("support") {
val navItem = BottomNavItem(
name = "Support",
route = route,
icon = Icons.Rounded.QuestionMark
)
}
data object Config : Screen("config")
data object Settings : Screen("settings") {
val navItem =
BottomNavItem(
name = "Settings",
route = route,
icon = Icons.Rounded.Settings,
)
}
data object Support : Screen("support") {
val navItem =
BottomNavItem(
name = "Support",
route = route,
icon = Icons.Rounded.QuestionMark,
)
}
data object Config : Screen("config")
} }
@@ -23,7 +23,7 @@ fun ClickableIconButton(
) { ) {
TextButton( TextButton(
onClick = onClick, onClick = onClick,
enabled = enabled enabled = enabled,
) { ) {
Text(text, Modifier.weight(1f, false)) Text(text, Modifier.weight(1f, false))
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
@@ -31,11 +31,11 @@ fun ClickableIconButton(
imageVector = icon, imageVector = icon,
contentDescription = stringResource(R.string.delete), contentDescription = stringResource(R.string.delete),
modifier = modifier =
Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable { Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable {
if (enabled) { if (enabled) {
onIconClick() onIconClick()
} }
} },
) )
} }
} }
@@ -2,7 +2,6 @@ package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.Button import androidx.compose.material3.Button
@@ -17,7 +16,6 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun PermissionRequestFailedScreen( fun PermissionRequestFailedScreen(
padding: PaddingValues,
onRequestAgain: () -> Unit, onRequestAgain: () -> Unit,
message: String, message: String,
buttonText: String buttonText: String
@@ -26,17 +24,12 @@ fun PermissionRequestFailedScreen(
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = modifier = Modifier.fillMaxSize(),
Modifier
.fillMaxSize()
.padding(padding)
) { ) {
Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp)) Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp))
Button(onClick = { Button(
scope.launch { onClick = { scope.launch { onRequestAgain() } },
onRequestAgain() ) {
}
}) {
Text(buttonText) Text(buttonText)
} }
} }
@@ -34,30 +34,22 @@ fun RowListItem(
) { ) {
Box( Box(
modifier = modifier =
Modifier Modifier.animateContentSize()
.animateContentSize() .clip(RoundedCornerShape(30.dp))
.clip(RoundedCornerShape(30.dp)) .combinedClickable(
.combinedClickable( onClick = { onClick() },
onClick = { onLongClick = { onHold() },
onClick() ),
},
onLongClick = {
onHold()
}
)
) { ) {
Column { Column {
Row( Row(
modifier = modifier = Modifier.fillMaxWidth().padding(horizontal = 15.dp, vertical = 5.dp),
Modifier
.fillMaxWidth()
.padding(horizontal = 15.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(.60f) modifier = Modifier.fillMaxWidth(.60f),
) { ) {
icon() icon()
Text(text) Text(text)
@@ -68,11 +60,10 @@ fun RowListItem(
statistics?.peers()?.forEach { statistics?.peers()?.forEach {
Row( Row(
modifier = modifier =
Modifier Modifier.fillMaxWidth()
.fillMaxWidth() .padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
.padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly,
) { ) {
val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis
val peerTx = statistics.peer(it)!!.txBytes val peerTx = statistics.peer(it)!!.txBytes
@@ -47,7 +47,7 @@ fun SearchBar(onQuery: (queryString: String) -> Unit) {
Icon( Icon(
imageVector = Icons.Rounded.Search, imageVector = Icons.Rounded.Search,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
contentDescription = stringResource(id = R.string.search_icon) contentDescription = stringResource(id = R.string.search_icon),
) )
}, },
trailingIcon = { trailingIcon = {
@@ -56,25 +56,24 @@ fun SearchBar(onQuery: (queryString: String) -> Unit) {
Icon( Icon(
imageVector = Icons.Rounded.Clear, imageVector = Icons.Rounded.Clear,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
contentDescription = stringResource(id = R.string.clear_icon) contentDescription = stringResource(id = R.string.clear_icon),
) )
} }
} }
}, },
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)
) )
} }
@@ -22,19 +22,15 @@ fun ConfigurationTextBox(
modifier = modifier, modifier = modifier,
value = value, value = value,
singleLine = true, singleLine = true,
onValueChange = { onValueChange = { onValueChange(it) },
onValueChange(it)
},
label = { Text(label) }, label = { Text(label) },
maxLines = 1, maxLines = 1,
placeholder = { placeholder = { Text(hint) },
Text(hint)
},
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done imeAction = ImeAction.Done,
), ),
keyboardActions = keyboardActions keyboardActions = keyboardActions,
) )
} }
@@ -21,21 +21,16 @@ fun ConfigurationToggle(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
modifier = modifier = Modifier.fillMaxWidth().padding(padding),
Modifier
.fillMaxWidth()
.padding(padding),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text(label) Text(label)
Switch( Switch(
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
checked = checked, checked = checked,
onCheckedChange = { onCheckedChange = { onCheckChanged() },
onCheckChanged()
}
) )
} }
} }
@@ -11,14 +11,11 @@ import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@Composable @Composable
fun BottomNavBar( fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavItem>) {
navController: NavController,
bottomNavItems: List<BottomNavItem>
) {
val backStackEntry = navController.currentBackStackEntryAsState() val backStackEntry = navController.currentBackStackEntryAsState()
NavigationBar( NavigationBar(
containerColor = MaterialTheme.colorScheme.background containerColor = MaterialTheme.colorScheme.background,
) { ) {
bottomNavItems.forEach { item -> bottomNavItems.forEach { item ->
val selected = item.route == backStackEntry.value?.destination?.route val selected = item.route == backStackEntry.value?.destination?.route
@@ -29,15 +26,15 @@ fun BottomNavBar(
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",
) )
} },
) )
} }
} }
@@ -11,51 +11,40 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
@Composable @Composable
fun AuthorizationPrompt( fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (String) -> Unit) {
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 = val isBiometricAvailable = remember {
remember { when (bio) {
when (bio) { BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> { onError("Biometrics not available")
onError("Biometrics not available") false
false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
} }
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
} }
}
if (isBiometricAvailable) { if (isBiometricAvailable) {
val executor = remember { ContextCompat.getMainExecutor(context) } val executor = remember { ContextCompat.getMainExecutor(context) }
@@ -71,10 +60,7 @@ fun AuthorizationPrompt(
context as FragmentActivity, context as FragmentActivity,
executor, executor,
object : BiometricPrompt.AuthenticationCallback() { object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError( override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
errorCode: Int,
errString: CharSequence
) {
super.onAuthenticationError(errorCode, errString) super.onAuthenticationError(errorCode, errString)
onFailure() onFailure()
} }
@@ -90,7 +76,7 @@ fun AuthorizationPrompt(
super.onAuthenticationFailed() super.onAuthenticationFailed()
onFailure() onFailure()
} }
} },
) )
biometricPrompt.authenticate(promptInfo) biometricPrompt.authenticate(promptInfo)
} }
@@ -19,7 +19,6 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
@@ -33,29 +32,28 @@ fun CustomSnackBar(
isRtl: Boolean = true, isRtl: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.surface containerColor: Color = MaterialTheme.colorScheme.surface
) { ) {
val context = LocalContext.current
Snackbar( Snackbar(
containerColor = containerColor, containerColor = containerColor,
modifier = modifier =
Modifier.fillMaxWidth( Modifier.fillMaxWidth(
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f,
).padding(bottom = 100.dp), )
shape = RoundedCornerShape(16.dp) .padding(bottom = 100.dp),
shape = RoundedCornerShape(16.dp),
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalLayoutDirection provides LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr,
if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
) { ) {
Row( Row(
modifier = Modifier.width(IntrinsicSize.Max).height(IntrinsicSize.Min), modifier = Modifier.width(IntrinsicSize.Max).height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start horizontalArrangement = Arrangement.Start,
) { ) {
Icon( Icon(
Icons.Rounded.Info, Icons.Rounded.Info,
contentDescription = stringResource(R.string.info), contentDescription = stringResource(R.string.info),
tint = Color.White, tint = Color.White,
modifier = Modifier.padding(end = 10.dp) modifier = Modifier.padding(end = 10.dp),
) )
Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp)) Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp))
} }
@@ -13,10 +13,11 @@ 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.fillMaxSize().focusable().padding()) { modifier = Modifier.fillMaxSize().focusable().padding(),
Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() } ) {
Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() }
} }
} }
@@ -12,14 +12,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@Composable @Composable
fun SectionTitle( fun SectionTitle(title: String, padding: Dp) {
title: String,
padding: Dp
) {
Text( Text(
title, title,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
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),
) )
} }
@@ -17,13 +17,13 @@ data class InterfaceProxy(
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 = if (i.listenPort.isPresent) { listenPort =
i.listenPort.get().toString() if (i.listenPort.isPresent) {
.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 "",
) )
} }
} }
@@ -13,36 +13,60 @@ data class PeerProxy(
fun from(peer: Peer): PeerProxy { fun from(peer: Peer): PeerProxy {
return PeerProxy( return PeerProxy(
publicKey = peer.publicKey.toBase64(), publicKey = peer.publicKey.toBase64(),
preSharedKey = if (peer.preSharedKey.isPresent) { preSharedKey =
peer.preSharedKey.get().toBase64() if (peer.preSharedKey.isPresent) {
.trim() peer.preSharedKey.get().toBase64().trim()
} else { } else {
"" ""
}, },
persistentKeepalive = if (peer.persistentKeepalive.isPresent) { persistentKeepalive =
peer.persistentKeepalive.get() if (peer.persistentKeepalive.isPresent) {
.toString().trim() peer.persistentKeepalive.get().toString().trim()
} else { } else {
"" ""
}, },
endpoint = if (peer.endpoint.isPresent) { endpoint =
peer.endpoint.get().toString() if (peer.endpoint.isPresent) {
.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", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3", "0.0.0.0/5",
"64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12", "8.0.0.0/7",
"172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7", "11.0.0.0/8",
"176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16", "12.0.0.0/6",
"192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10", "16.0.0.0/4",
"193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4" "32.0.0.0/3",
"64.0.0.0/2",
"128.0.0.0/3",
"160.0.0.0/5",
"168.0.0.0/6",
"172.0.0.0/12",
"172.32.0.0/11",
"172.64.0.0/10",
"172.128.0.0/9",
"173.0.0.0/8",
"174.0.0.0/7",
"176.0.0.0/4",
"192.0.0.0/9",
"192.128.0.0/11",
"192.160.0.0/13",
"192.169.0.0/16",
"192.170.0.0/15",
"192.172.0.0/14",
"192.176.0.0/12",
"192.192.0.0/10",
"193.0.0.0/8",
"194.0.0.0/7",
"196.0.0.0/6",
"200.0.0.0/5",
"208.0.0.0/4",
) )
val IPV4_WILDCARD = setOf("0.0.0.0/0") val IPV4_WILDCARD = setOf("0.0.0.0/0")
} }
@@ -1,13 +1,13 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config package com.zaneschepke.wireguardautotunnel.ui.screens.config
import android.annotation.SuppressLint import android.annotation.SuppressLint
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -29,7 +29,7 @@ import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material.icons.rounded.Save import androidx.compose.material.icons.rounded.Save
import androidx.compose.material3.AlertDialog import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
@@ -51,7 +51,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
@@ -63,6 +62,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.input.VisualTransformation
@@ -86,358 +86,418 @@ import kotlinx.coroutines.delay
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn( @OptIn(
ExperimentalComposeUiApi::class,
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
ExperimentalFoundationApi::class) )
@Composable @Composable
fun ConfigScreen( fun ConfigScreen(
padding: PaddingValues,
viewModel: ConfigViewModel = hiltViewModel(), viewModel: ConfigViewModel = hiltViewModel(),
focusRequester: FocusRequester, focusRequester: FocusRequester,
navController: NavController, navController: NavController,
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
id: String id: String
) { ) {
val context = LocalContext.current val context = LocalContext.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current val clipboardManager: ClipboardManager = LocalClipboardManager.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
var showApplicationsDialog by remember { mutableStateOf(false) } var showApplicationsDialog by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) } var showAuthPrompt by remember { mutableStateOf(false) }
var isAuthenticated by remember { mutableStateOf(false) } var isAuthenticated by remember { mutableStateOf(false) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) { LaunchedEffect(Unit) { viewModel.init(id) }
viewModel.init(id)
}
LaunchedEffect(uiState.loading) { LaunchedEffect(uiState.loading) {
if(!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) {
delay(Constants.FOCUS_REQUEST_DELAY) delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus() focusRequester.requestFocus()
}
}
if (uiState.loading) {
LoadingScreen()
return
}
val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
val keyboardOptions =
KeyboardOptions(imeAction = ImeAction.Done)
val fillMaxHeight = .85f
val fillMaxWidth = .85f
val screenPadding = 5.dp
val applicationButtonText = {
"Tunneling apps: " +
if (uiState.isAllApplicationsEnabled) {
"all"
} else {
"${uiState.checkedPackageNames.size} " + (if (uiState.include) "included" else "excluded")
} }
} }
if (showAuthPrompt) { if (uiState.loading) {
AuthorizationPrompt( LoadingScreen()
onSuccess = { return
showAuthPrompt = false }
isAuthenticated = true
},
onError = { error ->
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthenticationFailed.message)
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthorizationFailed.message)
})
}
if (showApplicationsDialog) { val keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
val sortedPackages =
remember(uiState.packages) { uiState.packages.sortedBy { viewModel.getPackageLabel(it) } } val keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
AlertDialog(onDismissRequest = { showApplicationsDialog = false }) {
Surface( val fillMaxHeight = .85f
tonalElevation = 2.dp, val fillMaxWidth = .85f
shadowElevation = 2.dp, val screenPadding = 5.dp
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface, val applicationButtonText = buildAnnotatedString {
modifier = append(stringResource(id = R.string.tunneling_apps))
Modifier.fillMaxWidth() append(": ")
.fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f)) { if (uiState.isAllApplicationsEnabled) {
Column(modifier = Modifier.fillMaxWidth()) { append(stringResource(id = R.string.all))
Row( } else {
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 7.dp), append("${uiState.checkedPackageNames.size} ")
verticalAlignment = Alignment.CenterVertically, (if (uiState.include) append(stringResource(id = R.string.included)) else append(
horizontalArrangement = Arrangement.SpaceBetween) { stringResource(id = R.string.excluded),
Text(stringResource(id = R.string.tunnel_all)) ))
Switch( }
checked = uiState.isAllApplicationsEnabled, }
onCheckedChange = { viewModel.onAllApplicationsChange(it) })
} if (showAuthPrompt) {
if (!uiState.isAllApplicationsEnabled) { AuthorizationPrompt(
Row( onSuccess = {
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 7.dp), showAuthPrompt = false
verticalAlignment = Alignment.CenterVertically, isAuthenticated = true
horizontalArrangement = Arrangement.SpaceBetween) { },
Row( onError = {
verticalAlignment = Alignment.CenterVertically, showAuthPrompt = false
horizontalArrangement = Arrangement.SpaceBetween) { showSnackbarMessage(Event.Error.AuthenticationFailed.message)
Text(stringResource(id = R.string.include)) },
Checkbox( onFailure = {
checked = uiState.include, showAuthPrompt = false
onCheckedChange = { viewModel.onIncludeChange(!uiState.include) }) showSnackbarMessage(Event.Error.AuthorizationFailed.message)
} },
Row( )
verticalAlignment = Alignment.CenterVertically, }
horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(id = R.string.exclude)) if (showApplicationsDialog) {
Checkbox( val sortedPackages =
checked = !uiState.include, remember(uiState.packages) {
onCheckedChange = { viewModel.onIncludeChange(!uiState.include) }) uiState.packages.sortedBy { viewModel.getPackageLabel(it) }
} }
BasicAlertDialog(onDismissRequest = { showApplicationsDialog = false }) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier
.fillMaxWidth()
.fillMaxHeight(if (uiState.isAllApplicationsEnabled) 1 / 5f else 4 / 5f),
) {
Column(modifier = Modifier
.fillMaxWidth()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(id = R.string.tunnel_all))
Switch(
checked = uiState.isAllApplicationsEnabled,
onCheckedChange = { viewModel.onAllApplicationsChange(it) },
)
} }
Row( if (!uiState.isAllApplicationsEnabled) {
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween) {
SearchBar(viewModel::emitQueriedPackages)
}
Spacer(Modifier.padding(5.dp))
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxHeight(4 / 5f)) {
items(sortedPackages, key = { it.packageName }) { pack ->
Row( Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxSize().padding(5.dp)) { ) {
Row(modifier = Modifier.fillMaxWidth(fillMaxWidth)) { Row(
val drawable = verticalAlignment = Alignment.CenterVertically,
pack.applicationInfo?.loadIcon(context.packageManager) horizontalArrangement = Arrangement.SpaceBetween,
if (drawable != null) { ) {
Image( Text(stringResource(id = R.string.include))
painter = DrawablePainter(drawable), Checkbox(
stringResource(id = R.string.icon), checked = uiState.include,
modifier = Modifier.size(50.dp, 50.dp)) onCheckedChange = {
} else { viewModel.onIncludeChange(!uiState.include)
Icon( },
Icons.Rounded.Android, )
stringResource(id = R.string.edit),
modifier = Modifier.size(50.dp, 50.dp))
}
Text(
viewModel.getPackageLabel(pack),
modifier = Modifier.padding(5.dp))
}
Checkbox(
modifier = Modifier.fillMaxSize(),
checked =
(uiState.checkedPackageNames.contains(pack.packageName)),
onCheckedChange = {
if (it) {
viewModel.onAddCheckedPackage(pack.packageName)
} else {
viewModel.onRemoveCheckedPackage(pack.packageName)
}
})
} }
} Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(stringResource(id = R.string.exclude))
Checkbox(
checked = !uiState.include,
onCheckedChange = {
viewModel.onIncludeChange(!uiState.include)
},
)
}
}
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
SearchBar(viewModel::emitQueriedPackages)
}
Spacer(Modifier.padding(5.dp))
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxHeight(4 / 5f),
) {
items(sortedPackages, key = { it.packageName }) { pack ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxSize()
.padding(5.dp),
) {
Row(modifier = Modifier.fillMaxWidth(fillMaxWidth)) {
val drawable =
pack.applicationInfo?.loadIcon(context.packageManager)
if (drawable != null) {
Image(
painter = DrawablePainter(drawable),
stringResource(id = R.string.icon),
modifier = Modifier.size(50.dp, 50.dp),
)
} else {
Icon(
Icons.Rounded.Android,
stringResource(id = R.string.edit),
modifier = Modifier.size(50.dp, 50.dp),
)
}
Text(
viewModel.getPackageLabel(pack),
modifier = Modifier.padding(5.dp),
)
}
Checkbox(
modifier = Modifier.fillMaxSize(),
checked =
(uiState.checkedPackageNames.contains(
pack.packageName
)),
onCheckedChange = {
if (it) {
viewModel.onAddCheckedPackage(pack.packageName)
} else {
viewModel.onRemoveCheckedPackage(pack.packageName)
}
},
)
}
}
}
} }
} Row(
Row( verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically, modifier = Modifier
modifier = Modifier.fillMaxSize().padding(top = 5.dp), .fillMaxSize()
horizontalArrangement = Arrangement.Center) { .padding(top = 5.dp),
TextButton(onClick = { showApplicationsDialog = false }) { horizontalArrangement = Arrangement.Center,
Text(stringResource(R.string.done)) ) {
TextButton(onClick = { showApplicationsDialog = false }) {
Text(stringResource(R.string.done))
}
} }
} }
} }
} }
} }
}
Scaffold( Scaffold(
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { floatingActionButton = {
val secondaryColor = MaterialTheme.colorScheme.secondary val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) } var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton( FloatingActionButton(
modifier = modifier =
Modifier.padding(bottom = 90.dp).onFocusChanged { Modifier.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
onClick = {
viewModel.onSaveAllChanges().let {
when (it) {
is Result.Success -> {
showSnackbarMessage(it.data.message)
navController.navigate(Screen.Main.route)
}
is Result.Error -> showSnackbarMessage(it.error.message)
}
} }
}, },
onClick = { containerColor = fobColor,
viewModel.onSaveAllChanges().let { shape = RoundedCornerShape(16.dp),
when (it) { ) {
is Result.Success -> {
showSnackbarMessage(it.data.message)
navController.navigate(Screen.Main.route)
}
is Result.Error -> showSnackbarMessage(it.error.message)
}
}
},
containerColor = fobColor,
shape = RoundedCornerShape(16.dp)) {
Icon( Icon(
imageVector = Icons.Rounded.Save, imageVector = Icons.Rounded.Save,
contentDescription = stringResource(id = R.string.save_changes), contentDescription = stringResource(id = R.string.save_changes),
tint = Color.DarkGray) tint = Color.DarkGray,
} )
}) { }
Column { },
) {
Column {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier.verticalScroll(rememberScrollState()).weight(1f, true).fillMaxSize()) { Modifier
Surface( .verticalScroll(rememberScrollState())
tonalElevation = 2.dp, .weight(1f, true)
shadowElevation = 2.dp, .fillMaxSize()
shape = RoundedCornerShape(12.dp), .padding(padding),
color = MaterialTheme.colorScheme.surface, ) {
modifier = Surface(
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { tonalElevation = 2.dp,
Modifier.fillMaxHeight(fillMaxHeight).fillMaxWidth(fillMaxWidth) shadowElevation = 2.dp,
} else { shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier
.fillMaxHeight(fillMaxHeight)
.fillMaxWidth(fillMaxWidth)
} else {
Modifier.fillMaxWidth(fillMaxWidth) Modifier.fillMaxWidth(fillMaxWidth)
}) })
.padding(top = 50.dp, bottom = 10.dp)) { .padding(bottom = 10.dp),
Column( ) {
horizontalAlignment = Alignment.Start, Column(
verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start,
modifier = Modifier.padding(15.dp).focusGroup()) { verticalArrangement = Arrangement.Top,
SectionTitle( modifier = Modifier
stringResource(R.string.interface_), padding = screenPadding) .padding(15.dp)
ConfigurationTextBox( .focusGroup(),
value = uiState.tunnelName, ) {
onValueChange = { value -> viewModel.onTunnelNameChange(value) }, SectionTitle(
keyboardActions = keyboardActions, stringResource(R.string.interface_),
label = stringResource(R.string.name), padding = screenPadding,
hint = stringResource(R.string.tunnel_name).lowercase(), )
modifier = ConfigurationTextBox(
Modifier value = uiState.tunnelName,
.fillMaxWidth() onValueChange = { value -> viewModel.onTunnelNameChange(value) },
.focusRequester(focusRequester)) keyboardActions = keyboardActions,
OutlinedTextField( label = stringResource(R.string.name),
modifier = hint = stringResource(R.string.tunnel_name).lowercase(),
Modifier.fillMaxWidth().clickable { modifier = Modifier
showAuthPrompt = true .fillMaxWidth()
}, .focusRequester(focusRequester),
value = uiState.interfaceProxy.privateKey, )
visualTransformation = OutlinedTextField(
if ((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || modifier = Modifier
isAuthenticated) .fillMaxWidth()
VisualTransformation.None .clickable { showAuthPrompt = true },
else PasswordVisualTransformation(), value = uiState.interfaceProxy.privateKey,
enabled = visualTransformation =
(id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated, if ((id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated)
onValueChange = { value -> viewModel.onPrivateKeyChange(value) }, VisualTransformation.None
trailingIcon = { else PasswordVisualTransformation(),
IconButton( enabled = (id == Constants.MANUAL_TUNNEL_CONFIG_ID) || isAuthenticated,
modifier = Modifier.focusRequester(FocusRequester.Default), onValueChange = { value -> viewModel.onPrivateKeyChange(value) },
onClick = { viewModel.generateKeyPair() }) { trailingIcon = {
Icon( IconButton(
Icons.Rounded.Refresh, modifier = Modifier.focusRequester(FocusRequester.Default),
stringResource(R.string.rotate_keys), onClick = { viewModel.generateKeyPair() },
tint = Color.White) ) {
} Icon(
}, Icons.Rounded.Refresh,
label = { Text(stringResource(R.string.private_key)) }, stringResource(R.string.rotate_keys),
singleLine = true, tint = Color.White,
placeholder = { Text(stringResource(R.string.base64_key)) }, )
keyboardOptions = keyboardOptions, }
keyboardActions = keyboardActions) },
OutlinedTextField( label = { Text(stringResource(R.string.private_key)) },
modifier = singleLine = true,
Modifier placeholder = { Text(stringResource(R.string.base64_key)) },
.fillMaxWidth() keyboardOptions = keyboardOptions,
.focusRequester(FocusRequester.Default), keyboardActions = keyboardActions,
value = uiState.interfaceProxy.publicKey, )
enabled = false, OutlinedTextField(
onValueChange = {}, modifier =
trailingIcon = { Modifier
IconButton( .fillMaxWidth()
modifier = Modifier.focusRequester(FocusRequester.Default), .focusRequester(FocusRequester.Default),
onClick = { value = uiState.interfaceProxy.publicKey,
clipboardManager.setText( enabled = false,
AnnotatedString(uiState.interfaceProxy.publicKey)) onValueChange = {},
}) { trailingIcon = {
Icon( IconButton(
Icons.Rounded.ContentCopy, modifier = Modifier.focusRequester(FocusRequester.Default),
stringResource(R.string.copy_public_key), onClick = {
tint = Color.White) clipboardManager.setText(
} AnnotatedString(uiState.interfaceProxy.publicKey),
}, )
label = { Text(stringResource(R.string.public_key)) },
singleLine = true,
placeholder = { Text(stringResource(R.string.base64_key)) },
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions)
Row(modifier = Modifier.fillMaxWidth()) {
ConfigurationTextBox(
value = uiState.interfaceProxy.addresses,
onValueChange = { value ->
viewModel.onAddressesChanged(value)
}, },
keyboardActions = keyboardActions, ) {
label = stringResource(R.string.addresses), Icon(
hint = stringResource(R.string.comma_separated_list), Icons.Rounded.ContentCopy,
modifier = stringResource(R.string.copy_public_key),
Modifier tint = Color.White,
.fillMaxWidth(3 / 5f) )
.padding(end = 5.dp)) }
ConfigurationTextBox( },
value = uiState.interfaceProxy.listenPort, label = { Text(stringResource(R.string.public_key)) },
onValueChange = { value -> singleLine = true,
viewModel.onListenPortChanged(value) placeholder = { Text(stringResource(R.string.base64_key)) },
}, keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions, keyboardActions = keyboardActions,
label = stringResource(R.string.listen_port), )
hint = stringResource(R.string.random), Row(modifier = Modifier.fillMaxWidth()) {
modifier = Modifier.width(IntrinsicSize.Min)) ConfigurationTextBox(
} value = uiState.interfaceProxy.addresses,
Row(modifier = Modifier.fillMaxWidth()) { onValueChange = { value -> viewModel.onAddressesChanged(value) },
ConfigurationTextBox( keyboardActions = keyboardActions,
value = uiState.interfaceProxy.dnsServers, label = stringResource(R.string.addresses),
onValueChange = { value -> hint = stringResource(R.string.comma_separated_list),
viewModel.onDnsServersChanged(value) modifier = Modifier
}, .fillMaxWidth(3 / 5f)
keyboardActions = keyboardActions, .padding(end = 5.dp),
label = stringResource(R.string.dns_servers), )
hint = stringResource(R.string.comma_separated_list), ConfigurationTextBox(
modifier = value = uiState.interfaceProxy.listenPort,
Modifier onValueChange = { value -> viewModel.onListenPortChanged(value) },
.fillMaxWidth(3 / 5f) keyboardActions = keyboardActions,
.padding(end = 5.dp)) label = stringResource(R.string.listen_port),
ConfigurationTextBox( hint = stringResource(R.string.random),
value = uiState.interfaceProxy.mtu, modifier = Modifier.width(IntrinsicSize.Min),
onValueChange = { value -> viewModel.onMtuChanged(value) }, )
keyboardActions = keyboardActions, }
label = stringResource(R.string.mtu), Row(modifier = Modifier.fillMaxWidth()) {
hint = stringResource(R.string.auto), ConfigurationTextBox(
modifier = Modifier.width(IntrinsicSize.Min)) value = uiState.interfaceProxy.dnsServers,
} onValueChange = { value -> viewModel.onDnsServersChanged(value) },
Row( keyboardActions = keyboardActions,
verticalAlignment = Alignment.CenterVertically, label = stringResource(R.string.dns_servers),
modifier = Modifier.fillMaxSize().padding(top = 5.dp), hint = stringResource(R.string.comma_separated_list),
horizontalArrangement = Arrangement.Center) { modifier = Modifier
TextButton(onClick = { showApplicationsDialog = true }) { .fillMaxWidth(3 / 5f)
Text(applicationButtonText()) .padding(end = 5.dp),
} )
} ConfigurationTextBox(
value = uiState.interfaceProxy.mtu,
onValueChange = { value -> viewModel.onMtuChanged(value) },
keyboardActions = keyboardActions,
label = stringResource(R.string.mtu),
hint = stringResource(R.string.auto),
modifier = Modifier.width(IntrinsicSize.Min),
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = { showApplicationsDialog = true }) {
Text(applicationButtonText.text)
} }
} }
uiState.proxyPeers.forEachIndexed { index, peer -> }
}
uiState.proxyPeers.forEachIndexed { index, peer ->
Surface( Surface(
tonalElevation = 2.dp, tonalElevation = 2.dp,
shadowElevation = 2.dp, shadowElevation = 2.dp,
@@ -445,106 +505,126 @@ fun ConfigScreen(
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
modifier = modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { (if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.fillMaxHeight(fillMaxHeight).fillMaxWidth(fillMaxWidth) Modifier
.fillMaxHeight(fillMaxHeight)
.fillMaxWidth(fillMaxWidth)
} else { } else {
Modifier.fillMaxWidth(fillMaxWidth) Modifier.fillMaxWidth(fillMaxWidth)
}) })
.padding(top = 10.dp, bottom = 10.dp)) { .padding(top = 10.dp, bottom = 10.dp),
Column( ) {
horizontalAlignment = Alignment.Start, Column(
verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start,
modifier = verticalArrangement = Arrangement.Top,
Modifier.padding(horizontal = 15.dp).padding(bottom = 10.dp)) { modifier = Modifier
Row( .padding(horizontal = 15.dp)
horizontalArrangement = Arrangement.SpaceBetween, .padding(bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically, ) {
modifier = Modifier.fillMaxWidth().padding(horizontal = 5.dp)) { Row(
SectionTitle( horizontalArrangement = Arrangement.SpaceBetween,
stringResource(R.string.peer), padding = screenPadding) verticalAlignment = Alignment.CenterVertically,
IconButton(onClick = { viewModel.onDeletePeer(index) }) { modifier = Modifier
Icon(Icons.Rounded.Delete, stringResource(R.string.delete)) .fillMaxWidth()
} .padding(horizontal = 5.dp),
} ) {
SectionTitle(
ConfigurationTextBox( stringResource(R.string.peer),
value = peer.publicKey, padding = screenPadding,
onValueChange = { value -> )
viewModel.onPeerPublicKeyChange(index, value) IconButton(onClick = { viewModel.onDeletePeer(index) }) {
}, Icon(Icons.Rounded.Delete, stringResource(R.string.delete))
keyboardActions = keyboardActions, }
label = stringResource(R.string.public_key),
hint = stringResource(R.string.base64_key),
modifier = Modifier.fillMaxWidth())
ConfigurationTextBox(
value = peer.preSharedKey,
onValueChange = { value ->
viewModel.onPreSharedKeyChange(index, value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.preshared_key),
hint = stringResource(R.string.optional),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = peer.persistentKeepalive,
enabled = true,
onValueChange = { value ->
viewModel.onPersistentKeepaliveChanged(index, value)
},
trailingIcon = {
Text(
stringResource(R.string.seconds),
modifier = Modifier.padding(end = 10.dp))
},
label = { Text(stringResource(R.string.persistent_keepalive)) },
singleLine = true,
placeholder = {
Text(stringResource(R.string.optional_no_recommend))
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions)
ConfigurationTextBox(
value = peer.endpoint,
onValueChange = { value ->
viewModel.onEndpointChange(index, value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.endpoint),
hint = stringResource(R.string.endpoint).lowercase(),
modifier = Modifier.fillMaxWidth())
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = peer.allowedIps,
enabled = true,
onValueChange = { value ->
viewModel.onAllowedIpsChange(index, value)
},
label = { Text(stringResource(R.string.allowed_ips)) },
singleLine = true,
placeholder = {
Text(stringResource(R.string.comma_separated_list))
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions)
}
}
}
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(bottom = 140.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center) {
TextButton(onClick = { viewModel.addEmptyPeer() }) {
Text(stringResource(R.string.add_peer))
}
} }
}
ConfigurationTextBox(
value = peer.publicKey,
onValueChange = { value ->
viewModel.onPeerPublicKeyChange(index, value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.public_key),
hint = stringResource(R.string.base64_key),
modifier = Modifier.fillMaxWidth(),
)
ConfigurationTextBox(
value = peer.preSharedKey,
onValueChange = { value ->
viewModel.onPreSharedKeyChange(index, value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.preshared_key),
hint = stringResource(R.string.optional),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = peer.persistentKeepalive,
enabled = true,
onValueChange = { value ->
viewModel.onPersistentKeepaliveChanged(index, value)
},
trailingIcon = {
Text(
stringResource(R.string.seconds),
modifier = Modifier.padding(end = 10.dp),
)
},
label = { Text(stringResource(R.string.persistent_keepalive)) },
singleLine = true,
placeholder = {
Text(stringResource(R.string.optional_no_recommend))
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
ConfigurationTextBox(
value = peer.endpoint,
onValueChange = { value ->
viewModel.onEndpointChange(index, value)
},
keyboardActions = keyboardActions,
label = stringResource(R.string.endpoint),
hint = stringResource(R.string.endpoint).lowercase(),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = peer.allowedIps,
enabled = true,
onValueChange = { value ->
viewModel.onAllowedIpsChange(index, value)
},
label = { Text(stringResource(R.string.allowed_ips)) },
singleLine = true,
placeholder = {
Text(stringResource(R.string.comma_separated_list))
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
}
}
}
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(bottom = 140.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = { viewModel.addEmptyPeer() }) {
Text(stringResource(R.string.add_peer))
}
}
} }
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f))
} }
} if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f))
}
} }
}
} }
@@ -11,7 +11,7 @@ data class ConfigUiState(
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 = ""
@@ -41,272 +41,314 @@ constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
) : ViewModel() { ) : ViewModel() {
private val packageManager = application.packageManager private val packageManager = application.packageManager
private val _uiState = MutableStateFlow(ConfigUiState()) private val _uiState = MutableStateFlow(ConfigUiState())
val uiState = _uiState.asStateFlow() val uiState = _uiState.asStateFlow()
fun init(tunnelId : String) = viewModelScope.launch(Dispatchers.IO) { fun init(tunnelId: String) =
val packages = getQueriedPackages("") viewModelScope.launch(Dispatchers.IO) {
val state = if(tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) { val packages = getQueriedPackages("")
val tunnelConfig = val state =
tunnelConfigRepository.getAll().firstOrNull { it.id.toString() == tunnelId } if (tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
if (tunnelConfig != null) { val tunnelConfig =
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) tunnelConfigRepository.getAll().firstOrNull { it.id.toString() == tunnelId }
val proxyPeers = config.peers.map { PeerProxy.from(it) } if (tunnelConfig != null) {
val proxyInterface = InterfaceProxy.from(config.`interface`) val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
var include = true val proxyPeers = config.peers.map { PeerProxy.from(it) }
var isAllApplicationsEnabled = false val proxyInterface = InterfaceProxy.from(config.`interface`)
val checkedPackages = var include = true
if (config.`interface`.includedApplications.isNotEmpty()) { var isAllApplicationsEnabled = false
config.`interface`.includedApplications val checkedPackages =
} else if (config.`interface`.excludedApplications.isNotEmpty()) { if (config.`interface`.includedApplications.isNotEmpty()) {
include = false config.`interface`.includedApplications
config.`interface`.excludedApplications } else if (config.`interface`.excludedApplications.isNotEmpty()) {
include = false
config.`interface`.excludedApplications
} else {
isAllApplicationsEnabled = true
emptySet()
}
ConfigUiState(
proxyPeers,
proxyInterface,
packages,
checkedPackages.toList(),
include,
isAllApplicationsEnabled,
false,
tunnelConfig,
tunnelConfig.name,
)
} else { } else {
isAllApplicationsEnabled = true ConfigUiState(loading = false, packages = packages)
emptySet()
} }
ConfigUiState( } else {
proxyPeers, ConfigUiState(loading = false, packages = packages)
proxyInterface, }
packages, _uiState.value = state
checkedPackages.toList(), }
include,
isAllApplicationsEnabled, fun onTunnelNameChange(name: String) {
false, _uiState.value = _uiState.value.copy(tunnelName = name)
tunnelConfig, }
tunnelConfig.name)
} else { fun onIncludeChange(include: Boolean) {
ConfigUiState(loading = false, packages = packages) _uiState.value = _uiState.value.copy(include = include)
} }
fun onAddCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames + packageName
)
}
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
}
fun onRemoveCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames - packageName
)
}
private fun getQueriedPackages(query: String): List<PackageInfo> {
return getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
}
fun getPackageLabel(packageInfo: PackageInfo): String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
}
private fun getAllInternetCapablePackages(): List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
}
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(
permissions,
PackageManager.PackageInfoFlags.of(0L),
)
} else { } else {
ConfigUiState(loading = false, packages = packages) packageManager.getPackagesHoldingPermissions(permissions, 0)
} }
_uiState.value = state
} }
fun onTunnelNameChange(name: String) {
_uiState.value = _uiState.value.copy(tunnelName = name)
}
fun onIncludeChange(include: Boolean) { private fun isAllApplicationsEnabled(): Boolean {
_uiState.value = _uiState.value.copy(include = include) return _uiState.value.isAllApplicationsEnabled
}
fun onAddCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(checkedPackageNames = _uiState.value.checkedPackageNames + packageName)
}
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
}
fun onRemoveCheckedPackage(packageName: String) {
_uiState.value =
_uiState.value.copy(checkedPackageNames = _uiState.value.checkedPackageNames - packageName)
}
private fun getQueriedPackages(query: String): List<PackageInfo> {
return getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
} }
}
fun getPackageLabel(packageInfo: PackageInfo): String { private fun saveConfig(tunnelConfig: TunnelConfig) =
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString() viewModelScope.launch { tunnelConfigRepository.save(tunnelConfig) }
}
private fun getAllInternetCapablePackages(): List<PackageInfo> { private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) =
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET)) viewModelScope.launch {
} if (tunnelConfig != null) {
saveConfig(tunnelConfig).join()
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> { WireGuardAutoTunnel.requestTileServiceStateUpdate()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { updateSettingsDefaultTunnel(tunnelConfig)
packageManager.getPackagesHoldingPermissions( }
permissions, PackageManager.PackageInfoFlags.of(0L))
} else {
packageManager.getPackagesHoldingPermissions(permissions, 0)
}
}
private fun isAllApplicationsEnabled(): Boolean {
return _uiState.value.isAllApplicationsEnabled
}
private fun saveConfig(tunnelConfig: TunnelConfig) =
viewModelScope.launch {
tunnelConfigRepository.save(tunnelConfig)
}
private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) =
viewModelScope.launch {
if (tunnelConfig != null) {
saveConfig(tunnelConfig).join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
updateSettingsDefaultTunnel(tunnelConfig)
} }
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) { private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepository.getSettingsFlow().first() val settings = settingsRepository.getSettingsFlow().first()
if (settings.defaultTunnel != null) { if (settings.defaultTunnel != null) {
if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) { if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) {
settingsRepository.save(settings.copy(defaultTunnel = tunnelConfig.toString())) settingsRepository.save(settings.copy(defaultTunnel = tunnelConfig.toString()))
} }
}
}
private fun buildPeerListFromProxyPeers(): List<Peer> {
return _uiState.value.proxyPeers.map {
val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
}
builder.build()
}
}
private fun emptyCheckedPackagesList() {
_uiState.value = _uiState.value.copy(checkedPackageNames = emptyList())
}
private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder()
builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
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()
}
fun onSaveAllChanges(): Result<Event> {
return try {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig =
_uiState.value.tunnel?.copy(
name = _uiState.value.tunnelName, wgQuick = config.toWgQuickString())
updateTunnelConfig(tunnelConfig)
Result.Success(Event.Message.ConfigSaved)
} catch (e: Exception) {
Result.Error(Event.Error.Exception(e))
}
}
fun onPeerPublicKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(publicKey = value)))
}
fun onPreSharedKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(preSharedKey = value)))
}
fun onEndpointChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(endpoint = value)))
}
fun onAllowedIpsChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(allowedIps = value)))
}
fun onPersistentKeepaliveChanged(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index, _uiState.value.proxyPeers[index].copy(persistentKeepalive = value)))
}
fun onDeletePeer(index: Int) {
_uiState.value = _uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.removeAt(index)
)
}
fun addEmptyPeer() {
_uiState.value = _uiState.value.copy(proxyPeers = _uiState.value.proxyPeers + PeerProxy())
}
fun generateKeyPair() {
val keyPair = KeyPair()
_uiState.value =
_uiState.value.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64()))
}
fun onAddressesChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value))
}
fun onListenPortChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value))
}
fun onDnsServersChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value))
}
fun onMtuChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
}
private fun onInterfacePublicKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value))
}
fun onPrivateKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value))
if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else {
onInterfacePublicKeyChange("")
}
}
fun emitQueriedPackages(query: String) {
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
} }
_uiState.value = _uiState.value.copy(packages = packages) }
}
private fun buildPeerListFromProxyPeers(): List<Peer> {
return _uiState.value.proxyPeers.map {
val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
}
builder.build()
}
}
private fun emptyCheckedPackagesList() {
_uiState.value = _uiState.value.copy(checkedPackageNames = emptyList())
}
private fun buildInterfaceListFromProxyInterface(): Interface {
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()
}
fun onSaveAllChanges(): Result<Event> {
return try {
val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig = when(uiState.value.tunnel) {
null -> TunnelConfig(name = _uiState.value.tunnelName, wgQuick = config.toWgQuickString())
else -> uiState.value.tunnel!!.copy(
name = _uiState.value.tunnelName,
wgQuick = config.toWgQuickString(),
)
}
updateTunnelConfig(tunnelConfig)
Result.Success(Event.Message.ConfigSaved)
} catch (e: Exception) {
Result.Error(Event.Error.Exception(e))
}
}
fun onPeerPublicKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(publicKey = value),
),
)
}
fun onPreSharedKeyChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(preSharedKey = value),
),
)
}
fun onEndpointChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(endpoint = value),
),
)
}
fun onAllowedIpsChange(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(allowedIps = value),
),
)
}
fun onPersistentKeepaliveChanged(index: Int, value: String) {
_uiState.value =
_uiState.value.copy(
proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(persistentKeepalive = value),
),
)
}
fun onDeletePeer(index: Int) {
_uiState.value =
_uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.removeAt(index),
)
}
fun addEmptyPeer() {
_uiState.value = _uiState.value.copy(proxyPeers = _uiState.value.proxyPeers + PeerProxy())
}
fun generateKeyPair() {
val keyPair = KeyPair()
_uiState.value =
_uiState.value.copy(
interfaceProxy =
_uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64(),
),
)
}
fun onAddressesChanged(value: String) {
_uiState.value =
_uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value)
)
}
fun onListenPortChanged(value: String) {
_uiState.value =
_uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value)
)
}
fun onDnsServersChanged(value: String) {
_uiState.value =
_uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value)
)
}
fun onMtuChanged(value: String) {
_uiState.value =
_uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
}
private fun onInterfacePublicKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value)
)
}
fun onPrivateKeyChange(value: String) {
_uiState.value =
_uiState.value.copy(
interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value)
)
if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else {
onInterfacePublicKeyChange("")
}
}
fun emitQueriedPackages(query: String) {
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_uiState.value = _uiState.value.copy(packages = packages)
}
} }
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
) )
@@ -14,11 +14,7 @@ import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceState
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import com.zaneschepke.wireguardautotunnel.util.Constants import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
@@ -32,6 +28,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.InputStream import java.io.InputStream
import java.util.zip.ZipInputStream import java.util.zip.ZipInputStream
import javax.inject.Inject import javax.inject.Inject
@@ -46,216 +43,225 @@ constructor(
private val vpnService: VpnService private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val uiState = val uiState =
combine( combine(
settingsRepository.getSettingsFlow(), settingsRepository.getSettingsFlow(),
tunnelConfigRepository.getTunnelConfigsFlow(), tunnelConfigRepository.getTunnelConfigsFlow(),
vpnService.vpnState, vpnService.vpnState,
) { settings, tunnels, vpnState -> ) { settings, tunnels, vpnState ->
validateWatcherServiceState(settings) validateWatcherServiceState(settings)
MainUiState(settings, tunnels, vpnState, false) MainUiState(settings, tunnels, vpnState, false)
} }
.stateIn( .stateIn(
viewModelScope, viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
MainUiState()) MainUiState(),
)
private fun validateWatcherServiceState(settings: Settings) = viewModelScope.launch(Dispatchers.IO) { private fun validateWatcherServiceState(settings: Settings) =
val watcherState = viewModelScope.launch(Dispatchers.IO) {
ServiceManager.getServiceState( if (settings.isAutoTunnelEnabled) {
application.applicationContext, WireGuardConnectivityWatcherService::class.java) ServiceManager.startWatcherService(application.applicationContext)
if (settings.isAutoTunnelEnabled && }
watcherState == ServiceState.STOPPED) {
ServiceManager.startWatcherService(application.applicationContext)
}
}
private fun stopWatcherService() = viewModelScope.launch(Dispatchers.IO) {
ServiceManager.stopWatcherService(application.applicationContext)
}
fun onDelete(tunnel: TunnelConfig) {
viewModelScope.launch(Dispatchers.IO) {
if (tunnelConfigRepository.count() == 1) {
stopWatcherService()
val settings = settingsRepository.getSettings()
settings.defaultTunnel = null
settings.isAutoTunnelEnabled = false
settings.isAlwaysOnVpnEnabled = false
saveSettings(settings)
}
tunnelConfigRepository.delete(tunnel)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
}
fun onTunnelStart(tunnelConfig: TunnelConfig) = viewModelScope.launch(Dispatchers.IO) {
stopActiveTunnel().await()
startTunnel(tunnelConfig)
}
private fun startTunnel(tunnelConfig: TunnelConfig) = viewModelScope.launch(Dispatchers.IO) {
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
}
private fun stopActiveTunnel() =
viewModelScope.async(Dispatchers.IO) {
if (ServiceManager.getServiceState(
application.applicationContext, WireGuardTunnelService::class.java) ==
ServiceState.STARTED) {
onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY)
} }
}
fun onTunnelStop() = viewModelScope.launch(Dispatchers.IO) { private fun stopWatcherService() =
ServiceManager.stopVpnService(application.applicationContext) viewModelScope.launch(Dispatchers.IO) {
} ServiceManager.stopWatcherService(application.applicationContext)
}
private fun validateConfigString(config: String) { fun onDelete(tunnel: TunnelConfig) {
TunnelConfig.configFromQuick(config) viewModelScope.launch(Dispatchers.IO) {
} if (tunnelConfigRepository.count() == 1) {
stopWatcherService()
val settings = settingsRepository.getSettings()
settings.defaultTunnel = null
settings.isAutoTunnelEnabled = false
settings.isAlwaysOnVpnEnabled = false
saveSettings(settings)
}
tunnelConfigRepository.delete(tunnel)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
}
suspend fun onTunnelQrResult(result: String) : Result<Unit> { fun onTunnelStart(tunnelConfig: TunnelConfig) =
viewModelScope.launch(Dispatchers.IO) {
Timber.d("On start called!")
stopActiveTunnel().await()
startTunnel(tunnelConfig)
}
private fun startTunnel(tunnelConfig: TunnelConfig) =
viewModelScope.launch(Dispatchers.IO) {
Timber.d("Start tunnel via manager")
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
}
private fun stopActiveTunnel() =
viewModelScope.async(Dispatchers.IO) {
onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY)
}
fun onTunnelStop() =
viewModelScope.launch(Dispatchers.IO) {
Timber.d("Stopping active tunnel")
ServiceManager.stopVpnService(application.applicationContext)
}
private fun validateConfigString(config: String) {
TunnelConfig.configFromQuick(config)
}
suspend fun onTunnelQrResult(result: String): Result<Unit> {
return try { return try {
validateConfigString(result) validateConfigString(result)
val tunnelConfig = val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig) addTunnel(tunnelConfig)
Result.Success(Unit) Result.Success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
Result.Error(Event.Error.InvalidQrCode) Result.Error(Event.Error.InvalidQrCode)
} }
} }
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) { private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8) val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader) val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName) val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString())) addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
withContext(Dispatchers.IO) { stream.close() } withContext(Dispatchers.IO) { stream.close() }
} }
private fun getInputStreamFromUri(uri: Uri): InputStream? { private fun getInputStreamFromUri(uri: Uri): InputStream? {
return application.applicationContext.contentResolver.openInputStream(uri) return application.applicationContext.contentResolver.openInputStream(uri)
} }
suspend fun onTunnelFileSelected(uri: Uri) : Result<Unit> { suspend fun onTunnelFileSelected(uri: Uri): Result<Unit> {
try { try {
if(isValidUriContentScheme(uri)){ if (isValidUriContentScheme(uri)) {
val fileName = getFileName(application.applicationContext, uri) val fileName = getFileName(application.applicationContext, uri)
when (getFileExtensionFromFileName(fileName)) { when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri).let { Constants.CONF_FILE_EXTENSION ->
when(it) { saveTunnelFromConfUri(fileName, uri).let {
is Result.Error -> return Result.Error(Event.Error.FileReadFailed) when (it) {
is Result.Success -> return it is Result.Error -> return Result.Error(Event.Error.FileReadFailed)
is Result.Success -> return it
}
} }
}
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri) Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> return Result.Error(Event.Error.InvalidFileExtension) else -> return Result.Error(Event.Error.InvalidFileExtension)
} }
return Result.Success(Unit) return Result.Success(Unit)
} else { } else {
return Result.Error(Event.Error.InvalidFileExtension) return Result.Error(Event.Error.InvalidFileExtension)
} }
} catch (e: Exception) { } catch (e: Exception) {
return Result.Error(Event.Error.FileReadFailed) return Result.Error(Event.Error.FileReadFailed)
} }
}
private suspend fun saveTunnelsFromZipUri(uri: Uri) {
ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory || getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
}
}
}
}
private suspend fun saveTunnelFromConfUri(name: String, uri: Uri) : Result<Unit> {
val stream = getInputStreamFromUri(uri)
return if(stream != null) {
saveTunnelConfigFromStream(stream, name)
Result.Success(Unit)
} else {
Result.Error(Event.Error.FileReadFailed)
}
}
private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
fun pauseAutoTunneling() = viewModelScope.launch {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = true))
} }
fun resumeAutoTunneling() = viewModelScope.launch { private suspend fun saveTunnelsFromZipUri(uri: Uri) {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = false)) ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry }
.filterNot {
it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach {
val name = getNameFromFileName(it.name)
val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
}
}
}
} }
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) { private suspend fun saveTunnelFromConfUri(name: String, uri: Uri): Result<Unit> {
tunnelConfigRepository.save(tunnelConfig) val stream = getInputStreamFromUri(uri)
} return if (stream != null) {
saveTunnelConfigFromStream(stream, name)
private fun getFileNameByCursor(context: Context, uri: Uri): String? { Result.Success(Unit)
context.contentResolver.query(uri, null, null, null, null)?.use { } else {
return getDisplayNameByCursor(it) Result.Error(Event.Error.FileReadFailed)
}
} }
return null
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? { private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) saveTunnel(tunnelConfig)
return if (columnIndex != -1) { WireGuardAutoTunnel.requestTileServiceStateUpdate()
return columnIndex
} else {
null
} }
}
private fun getDisplayNameByCursor(cursor: Cursor): String? { fun pauseAutoTunneling() =
return if (cursor.moveToFirst()) { viewModelScope.launch {
val index = getDisplayNameColumnIndex(cursor) settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = true))
if (index != null) { }
cursor.getString(index)
} else null
} else null
}
private fun isValidUriContentScheme(uri: Uri): Boolean { fun resumeAutoTunneling() =
return uri.scheme == Constants.URI_CONTENT_SCHEME viewModelScope.launch {
} settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = false))
private fun getFileName(context: Context, uri: Uri): String { }
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
tunnelConfigRepository.save(tunnelConfig)
}
private fun getFileNameByCursor(context: Context, uri: Uri): String? {
context.contentResolver.query(uri, null, null, null, null)?.use {
return getDisplayNameByCursor(it)
}
return null
}
private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
return if (columnIndex != -1) {
return columnIndex
} else {
null
}
}
private fun getDisplayNameByCursor(cursor: Cursor): String? {
return if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
if (index != null) {
cursor.getString(index)
} else null
} else null
}
private fun isValidUriContentScheme(uri: Uri): Boolean {
return uri.scheme == Constants.URI_CONTENT_SCHEME
}
private fun getFileName(context: Context, uri: Uri): String {
return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName() return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName()
}
private fun getNameFromFileName(fileName: String): String {
return fileName.substring(0, fileName.lastIndexOf('.'))
}
private fun getFileExtensionFromFileName(fileName: String): String {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) {
""
} }
}
private fun saveSettings(settings: Settings) = private fun getNameFromFileName(fileName: String): String {
viewModelScope.launch(Dispatchers.IO) { settingsRepository.save(settings) } return fileName.substring(0, fileName.lastIndexOf('.'))
fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) = viewModelScope.launch {
if (selectedTunnel != null) {
saveSettings(uiState.value.settings.copy(defaultTunnel = selectedTunnel.toString())).join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
} }
}
private fun getFileExtensionFromFileName(fileName: String): String {
return try {
fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) {
""
}
}
private fun saveSettings(settings: Settings) =
viewModelScope.launch(Dispatchers.IO) { settingsRepository.save(settings) }
fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) =
viewModelScope.launch {
if (selectedTunnel != null) {
saveSettings(uiState.value.settings.copy(defaultTunnel = selectedTunnel.toString()))
.join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
}
} }
@@ -1,10 +1,17 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import android.Manifest import android.Manifest
import android.app.Activity
import android.content.Context.POWER_SERVICE
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS import android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -83,100 +90,136 @@ import java.io.File
@OptIn( @OptIn(
ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
ExperimentalLayoutApi::class) ExperimentalLayoutApi::class,
)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
viewModel: SettingsViewModel = hiltViewModel(),
padding: PaddingValues, padding: PaddingValues,
viewModel: SettingsViewModel = hiltViewModel(),
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
focusRequester: FocusRequester focusRequester: FocusRequester
) { ) {
val scope = rememberCoroutineScope { Dispatchers.IO } val scope = rememberCoroutineScope { Dispatchers.IO }
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) } var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) } var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var didExportFiles by remember { mutableStateOf(false) } var didExportFiles by remember { mutableStateOf(false) }
var showAuthPrompt by remember { mutableStateOf(false) } var showAuthPrompt by remember { mutableStateOf(false) }
val focusRequester2 = remember { FocusRequester() } val focusRequester2 = remember { FocusRequester() }
val screenPadding = 5.dp val screenPadding = 5.dp
val fillMaxWidth = .85f val fillMaxWidth = .85f
if (uiState.loading) { if (uiState.loading) {
LoadingScreen() LoadingScreen()
return return
}
fun exportAllConfigs() {
try {
val files = uiState.tunnels.map { File(context.cacheDir, "${it.name}.conf") }
files.forEachIndexed { index, file ->
file.outputStream().use { it.write(uiState.tunnels[index].wgQuick.toByteArray()) }
}
FileUtils.saveFilesToZip(context, files)
didExportFiles = true
showSnackbarMessage(Event.Message.ConfigsExported.message)
} catch (e: Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
} }
}
fun saveTrustedSSID() { val startForResult =
if (currentText.isNotEmpty()) { rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
viewModel.onSaveTrustedSSID(currentText).let { result: ActivityResult ->
when(it) { if (result.resultCode == Activity.RESULT_OK) {
is Result.Success -> currentText = "" result.data
is Result.Error -> showSnackbarMessage(it.error.message) // Handle the Intent
} }
} viewModel.setBatteryOptimizeDisableShown()
}
}
fun openSettings() {
scope.launch {
val intentSettings = Intent(ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intentSettings)
}
}
fun checkFineLocationGranted() {
isBackgroundLocationGranted =
if (!fineLocationState.status.isGranted) {
false
} else {
viewModel.setLocationDisclosureShown()
true
} }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { fun exportAllConfigs() {
if(WireGuardAutoTunnel.isRunningOnAndroidTv() && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q){ try {
checkFineLocationGranted() val files = uiState.tunnels.map { File(context.cacheDir, "${it.name}.conf") }
} else { files.forEachIndexed { index, file ->
val backgroundLocationState = file.outputStream().use { it.write(uiState.tunnels[index].wgQuick.toByteArray()) }
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) }
isBackgroundLocationGranted = FileUtils.saveFilesToZip(context, files)
if (!backgroundLocationState.status.isGranted) { didExportFiles = true
false showSnackbarMessage(Event.Message.ConfigsExported.message)
} else { } catch (e: Exception) {
SideEffect { viewModel.setLocationDisclosureShown() } showSnackbarMessage(Event.Error.Exception(e).message)
true }
} }
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { fun isBatteryOptimizationsDisabled(): Boolean {
checkFineLocationGranted() val pm = context.getSystemService(POWER_SERVICE) as PowerManager
} return pm.isIgnoringBatteryOptimizations(context.packageName)
}
fun requestBatteryOptimizationsDisabled() {
val intent =
Intent().apply {
this.action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS
data = Uri.fromParts("package", context.packageName, null)
}
startForResult.launch(intent)
}
fun handleAutoTunnelToggle() {
if (uiState.isBatteryOptimizeDisableShown || isBatteryOptimizationsDisabled()) {
viewModel.toggleAutoTunnel()
} else {
requestBatteryOptimizationsDisabled()
}
}
fun saveTrustedSSID() {
if (currentText.isNotEmpty()) {
viewModel.onSaveTrustedSSID(currentText).let {
when (it) {
is Result.Success -> currentText = ""
is Result.Error -> showSnackbarMessage(it.error.message)
}
}
}
}
fun openSettings() {
scope.launch {
val intentSettings = Intent(ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data = Uri.fromParts("package", context.packageName, null)
context.startActivity(intentSettings)
}
}
fun checkFineLocationGranted() {
isBackgroundLocationGranted =
if (!fineLocationState.status.isGranted) {
false
} else {
viewModel.setLocationDisclosureShown()
true
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (
WireGuardAutoTunnel.isRunningOnAndroidTv() &&
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q
) {
checkFineLocationGranted()
} else {
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
isBackgroundLocationGranted =
if (!backgroundLocationState.status.isGranted) {
false
} else {
SideEffect { viewModel.setLocationDisclosureShown() }
true
}
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
checkFineLocationGranted()
}
AnimatedVisibility(showLocationServicesAlertDialog) { AnimatedVisibility(showLocationServicesAlertDialog) {
AlertDialog( AlertDialog(
@@ -185,8 +228,9 @@ fun SettingsScreen(
TextButton( TextButton(
onClick = { onClick = {
showLocationServicesAlertDialog = false showLocationServicesAlertDialog = false
viewModel.toggleAutoTunnel() handleAutoTunnelToggle()
}) { },
) {
Text(text = stringResource(R.string.okay)) Text(text = stringResource(R.string.okay))
} }
}, },
@@ -196,253 +240,313 @@ fun SettingsScreen(
} }
}, },
title = { Text(text = stringResource(R.string.location_services_not_detected)) }, title = { Text(text = stringResource(R.string.location_services_not_detected)) },
text = { Text(text = stringResource(R.string.location_services_missing_message)) }) text = { Text(text = stringResource(R.string.location_services_missing_message)) },
)
} }
if (!uiState.isLocationDisclosureShown) { if (!uiState.isLocationDisclosureShown) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(padding)) { modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(padding),
Icon( ) {
Icons.Rounded.LocationOff, Icon(
contentDescription = stringResource(id = R.string.map), Icons.Rounded.LocationOff,
modifier = Modifier.padding(30.dp).size(128.dp)) contentDescription = stringResource(id = R.string.map),
Text( modifier = Modifier.padding(30.dp).size(128.dp),
stringResource(R.string.prominent_background_location_title), )
textAlign = TextAlign.Center, Text(
modifier = Modifier.padding(30.dp), stringResource(R.string.prominent_background_location_title),
fontSize = 20.sp) textAlign = TextAlign.Center,
Text( modifier = Modifier.padding(30.dp),
stringResource(R.string.prominent_background_location_message), fontSize = 20.sp,
textAlign = TextAlign.Center, )
modifier = Modifier.padding(30.dp), Text(
fontSize = 15.sp) stringResource(R.string.prominent_background_location_message),
Row( textAlign = TextAlign.Center,
modifier = modifier = Modifier.padding(30.dp),
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { fontSize = 15.sp,
Modifier.fillMaxWidth().padding(10.dp) )
} else { Row(
Modifier.fillMaxWidth().padding(30.dp) modifier =
}, if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
verticalAlignment = Alignment.CenterVertically, Modifier.fillMaxWidth().padding(10.dp)
horizontalArrangement = Arrangement.SpaceEvenly) { } else {
Modifier.fillMaxWidth().padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
TextButton(onClick = { viewModel.setLocationDisclosureShown() }) { TextButton(onClick = { viewModel.setLocationDisclosureShown() }) {
Text(stringResource(id = R.string.no_thanks)) Text(stringResource(id = R.string.no_thanks))
} }
TextButton( TextButton(
modifier = Modifier.focusRequester(focusRequester), modifier = Modifier.focusRequester(focusRequester),
onClick = { onClick = {
openSettings() openSettings()
viewModel.setLocationDisclosureShown() viewModel.setLocationDisclosureShown()
}) { },
Text(stringResource(id = R.string.turn_on)) ) {
} Text(stringResource(id = R.string.turn_on))
} }
}
} }
} }
if(showAuthPrompt) { if (showAuthPrompt) {
AuthorizationPrompt( AuthorizationPrompt(
onSuccess = { onSuccess = {
showAuthPrompt = false showAuthPrompt = false
exportAllConfigs() exportAllConfigs()
}, },
onError = { _ -> onError = { _ ->
showAuthPrompt = false showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthenticationFailed.message) showSnackbarMessage(Event.Error.AuthenticationFailed.message)
}, },
onFailure = { onFailure = {
showAuthPrompt = false showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthorizationFailed.message) showSnackbarMessage(Event.Error.AuthorizationFailed.message)
}) },
} )
}
if (uiState.tunnels.isEmpty() && uiState.isLocationDisclosureShown) { if (uiState.tunnels.isEmpty() && uiState.isLocationDisclosureShown) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize().padding(padding)) { modifier = Modifier.fillMaxSize().padding(padding),
Text( ) {
stringResource(R.string.one_tunnel_required), Text(
textAlign = TextAlign.Center, stringResource(R.string.one_tunnel_required),
modifier = Modifier.padding(15.dp), textAlign = TextAlign.Center,
fontStyle = FontStyle.Italic) modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic,
)
} }
} }
if (uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) { if (uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier.fillMaxSize().verticalScroll(scrollState).clickable( Modifier.fillMaxSize().padding(padding).verticalScroll(scrollState).clickable(
indication = null, interactionSource = interactionSource) { indication = null,
focusManager.clearFocus() interactionSource = interactionSource,
}) { ) {
Surface( focusManager.clearFocus()
tonalElevation = 2.dp, },
shadowElevation = 2.dp, ) {
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
(if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 60.dp)
})
.padding(bottom = 10.dp)) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)) {
SectionTitle(
title = stringResource(id = R.string.auto_tunneling),
padding = screenPadding)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_wifi),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnWifi() },
modifier = if(uiState.settings.isAutoTunnelEnabled) Modifier else Modifier.focusRequester(focusRequester).focusProperties { down = focusRequester2 })
AnimatedVisibility(visible = uiState.settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier = Modifier.padding(screenPadding).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp)) {
uiState.settings.trustedNetworkSSIDs.forEach { ssid ->
ClickableIconButton(
onClick = { if(WireGuardAutoTunnel.isRunningOnAndroidTv()) {
viewModel.onDeleteTrustedSSID(ssid)
focusRequester2.requestFocus()
}},
onIconClick = { viewModel.onDeleteTrustedSSID(ssid) },
text = ssid,
icon = Icons.Filled.Close,
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled))
}
if (uiState.settings.trustedNetworkSSIDs.isEmpty()) {
Text(
stringResource(R.string.none),
fontStyle = FontStyle.Italic,
color = Color.Gray)
}
}
OutlinedTextField(
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier =
Modifier.padding(
start = screenPadding, top = 5.dp, bottom = 10.dp)
.focusRequester(focusRequester2)
,
maxLines = 1,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }),
trailingIcon = {
if (currentText != "") {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription =
if (currentText == "") {
stringResource(
id = R.string.trusted_ssid_empty_description)
} else {
stringResource(
id = R.string.trusted_ssid_value_description)
},
tint = MaterialTheme.colorScheme.primary)
}
}
})
}
}
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnMobileData() })
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnEthernet() })
ConfigurationToggle(
stringResource(R.string.battery_saver),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isBatterySaverEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleBatterySaver() })
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = (if(!uiState.settings.isAutoTunnelEnabled) Modifier else Modifier.focusRequester(focusRequester))
.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center) {
TextButton(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
onClick = {
if (uiState.settings.isTunnelOnWifiEnabled && !uiState.settings.isAutoTunnelEnabled) {
when(false) {
isBackgroundLocationGranted ->
showSnackbarMessage(Event.Error.BackgroundLocationRequired.message)
fineLocationState.status.isGranted ->
showSnackbarMessage(Event.Error.PreciseLocationRequired.message)
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
viewModel.toggleAutoTunnel()
}
}
} else {
viewModel.toggleAutoTunnel()
}
}) {
val autoTunnelButtonText =
if (uiState.settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText)
}
}
}
}
if (WgQuickBackend.hasKernelSupport()) {
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.fillMaxWidth(fillMaxWidth).padding(vertical = 10.dp)) { modifier =
Column( (if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
horizontalAlignment = Alignment.Start, Modifier.height(IntrinsicSize.Min)
verticalArrangement = Arrangement.Top, .fillMaxWidth(fillMaxWidth)
modifier = Modifier.padding(15.dp)) { .padding(top = 10.dp)
} else {
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp)
})
.padding(bottom = 10.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
SectionTitle(
title = stringResource(id = R.string.auto_tunneling),
padding = screenPadding,
)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_wifi),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnWifiEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnWifi() },
modifier =
if (uiState.settings.isAutoTunnelEnabled) Modifier
else
Modifier.focusRequester(focusRequester).focusProperties {
down = focusRequester2
},
)
AnimatedVisibility(visible = uiState.settings.isTunnelOnWifiEnabled) {
Column {
FlowRow(
modifier = Modifier.padding(screenPadding).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
uiState.settings.trustedNetworkSSIDs.forEach { ssid ->
ClickableIconButton(
onClick = {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
viewModel.onDeleteTrustedSSID(ssid)
focusRequester2.requestFocus()
}
},
onIconClick = { viewModel.onDeleteTrustedSSID(ssid) },
text = ssid,
icon = Icons.Filled.Close,
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
)
}
if (uiState.settings.trustedNetworkSSIDs.isEmpty()) {
Text(
stringResource(R.string.none),
fontStyle = FontStyle.Italic,
color = Color.Gray,
)
}
}
OutlinedTextField(
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier =
Modifier.padding(
start = screenPadding,
top = 5.dp,
bottom = 10.dp,
)
.focusRequester(focusRequester2),
maxLines = 1,
keyboardOptions =
KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(onDone = { saveTrustedSSID() }),
trailingIcon = {
if (currentText != "") {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription =
if (currentText == "") {
stringResource(
id =
R.string
.trusted_ssid_empty_description,
)
} else {
stringResource(
id =
R.string
.trusted_ssid_value_description,
)
},
tint = MaterialTheme.colorScheme.primary,
)
}
}
},
)
}
}
ConfigurationToggle(
stringResource(R.string.tunnel_mobile_data),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnMobileData() },
)
ConfigurationToggle(
stringResource(id = R.string.tunnel_on_ethernet),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleTunnelOnEthernet() },
)
ConfigurationToggle(
stringResource(R.string.battery_saver),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled),
checked = uiState.settings.isBatterySaverEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleBatterySaver() },
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
(if (!uiState.settings.isAutoTunnelEnabled) Modifier
else
Modifier.focusRequester(
focusRequester,
))
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
enabled = !uiState.settings.isAlwaysOnVpnEnabled,
onClick = {
if (
uiState.settings.isTunnelOnWifiEnabled &&
!uiState.settings.isAutoTunnelEnabled
) {
when (false) {
isBackgroundLocationGranted ->
showSnackbarMessage(
Event.Error.BackgroundLocationRequired.message
)
fineLocationState.status.isGranted ->
showSnackbarMessage(
Event.Error.PreciseLocationRequired.message
)
viewModel.isLocationEnabled(context) ->
showLocationServicesAlertDialog = true
else -> {
handleAutoTunnelToggle()
}
}
} else {
handleAutoTunnelToggle()
}
},
) {
val autoTunnelButtonText =
if (uiState.settings.isAutoTunnelEnabled) {
stringResource(R.string.disable_auto_tunnel)
} else {
stringResource(id = R.string.enable_auto_tunnel)
}
Text(autoTunnelButtonText)
}
}
}
}
if (WgQuickBackend.hasKernelSupport()) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(fillMaxWidth).padding(vertical = 10.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
SectionTitle( SectionTitle(
title = stringResource(id = R.string.kernel), padding = screenPadding) title = stringResource(id = R.string.kernel),
padding = screenPadding,
)
ConfigurationToggle( ConfigurationToggle(
stringResource(R.string.use_kernel), stringResource(R.string.use_kernel),
enabled = enabled =
@@ -451,58 +555,70 @@ fun SettingsScreen(
(uiState.vpnState.status == Tunnel.State.UP)), (uiState.vpnState.status == Tunnel.State.UP)),
checked = uiState.settings.isKernelEnabled, checked = uiState.settings.isKernelEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { viewModel.onToggleKernelMode().let { onCheckChanged = {
when(it) { viewModel.onToggleKernelMode().let {
is Result.Error -> showSnackbarMessage(it.error.message) when (it) {
is Result.Success -> {} is Result.Error -> showSnackbarMessage(it.error.message)
is Result.Success -> {}
}
} }
} }) },
} )
}
} }
} }
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
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 =
Modifier.fillMaxWidth(fillMaxWidth) Modifier.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp) .padding(vertical = 10.dp)
.padding(bottom = 140.dp)) { .padding(bottom = 140.dp),
Column( ) {
horizontalAlignment = Alignment.Start, Column(
verticalArrangement = Arrangement.Top, horizontalAlignment = Alignment.Start,
modifier = Modifier.padding(15.dp)) { verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
SectionTitle( SectionTitle(
title = stringResource(id = R.string.other), padding = screenPadding) title = stringResource(id = R.string.other),
padding = screenPadding,
)
ConfigurationToggle( ConfigurationToggle(
stringResource(R.string.always_on_vpn_support), stringResource(R.string.always_on_vpn_support),
enabled = !uiState.settings.isAutoTunnelEnabled, enabled = !uiState.settings.isAutoTunnelEnabled,
checked = uiState.settings.isAlwaysOnVpnEnabled, checked = uiState.settings.isAlwaysOnVpnEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { viewModel.onToggleAlwaysOnVPN() }) onCheckChanged = { viewModel.onToggleAlwaysOnVPN() },
)
ConfigurationToggle( ConfigurationToggle(
stringResource(R.string.enabled_app_shortcuts), stringResource(R.string.enabled_app_shortcuts),
enabled = true, enabled = true,
checked = uiState.settings.isShortcutsEnabled, checked = uiState.settings.isShortcutsEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { viewModel.onToggleShortcutsEnabled() }) onCheckChanged = { viewModel.onToggleShortcutsEnabled() },
)
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(top = 5.dp), modifier = Modifier.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center) { horizontalArrangement = Arrangement.Center,
TextButton( ) {
enabled = !didExportFiles, onClick = { showAuthPrompt = true }) { TextButton(
Text(stringResource(R.string.export_configs)) enabled = !didExportFiles,
} onClick = { showAuthPrompt = true },
) {
Text(stringResource(R.string.export_configs))
} }
} }
}
} }
} }
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) { if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f)) Spacer(modifier = Modifier.weight(.17f))
} }
} }
} }
} }
@@ -5,9 +5,10 @@ import com.zaneschepke.wireguardautotunnel.data.model.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 loading : Boolean = true val isBatteryOptimizeDisableShown: Boolean = false,
val loading: Boolean = true
) )
@@ -36,18 +36,29 @@ constructor(
private val vpnService: VpnService private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val uiState = combine( val uiState =
settingsRepository.getSettingsFlow(), combine(
tunnelConfigRepository.getTunnelConfigsFlow(), settingsRepository.getSettingsFlow(),
vpnService.vpnState, tunnelConfigRepository.getTunnelConfigsFlow(),
dataStoreManager.locationDisclosureFlow, vpnService.vpnState,
){ settings, tunnels, tunnelState, locationDisclosure -> dataStoreManager.preferencesFlow,
SettingsUiState(settings, tunnels, tunnelState, locationDisclosure ) { settings, tunnels, tunnelState, preferences ->
?: false, false) SettingsUiState(
}.stateIn(viewModelScope, settings,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), SettingsUiState()) tunnels,
tunnelState,
preferences?.get(DataStoreManager.LOCATION_DISCLOSURE_SHOWN) ?: false,
preferences?.get(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN) ?: false,
false
)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
SettingsUiState(),
)
fun onSaveTrustedSSID(ssid: String) : Result<Unit>{ fun onSaveTrustedSSID(ssid: String): Result<Unit> {
val trimmed = ssid.trim() val trimmed = ssid.trim()
return if (!uiState.value.settings.trustedNetworkSSIDs.contains(trimmed)) { return if (!uiState.value.settings.trustedNetworkSSIDs.contains(trimmed)) {
uiState.value.settings.trustedNetworkSSIDs.add(trimmed) uiState.value.settings.trustedNetworkSSIDs.add(trimmed)
@@ -58,64 +69,77 @@ constructor(
} }
} }
fun setLocationDisclosureShown() = viewModelScope.launch { fun setLocationDisclosureShown() =
viewModelScope.launch {
dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, true) dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, true)
} }
fun setBatteryOptimizeDisableShown() =
viewModelScope.launch {
dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, true)
}
fun onToggleTunnelOnMobileData() { fun onToggleTunnelOnMobileData() {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled isTunnelOnMobileDataEnabled = !uiState.value.settings.isTunnelOnMobileDataEnabled,
) ),
) )
} }
fun onDeleteTrustedSSID(ssid: String) { fun onDeleteTrustedSSID(ssid: String) {
saveSettings(uiState.value.settings.copy(
trustedNetworkSSIDs = (uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList()
))
}
private suspend fun getDefaultTunnelOrFirst() : String {
return uiState.value.settings.defaultTunnel ?: tunnelConfigRepository.getAll().first().toString()
}
fun toggleAutoTunnel() = viewModelScope.launch {
val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled
var isAutoTunnelPaused = uiState.value.settings.isAutoTunnelPaused
if (isAutoTunnelEnabled) {
ServiceManager.stopWatcherService(application)
} else {
ServiceManager.startWatcherService(application)
isAutoTunnelPaused = false
}
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isAutoTunnelEnabled = !isAutoTunnelEnabled, trustedNetworkSSIDs =
isAutoTunnelPaused = isAutoTunnelPaused, (uiState.value.settings.trustedNetworkSSIDs - ssid).toMutableList(),
defaultTunnel = getDefaultTunnelOrFirst() ),
)
) )
} }
private suspend fun getDefaultTunnelOrFirst(): String {
return uiState.value.settings.defaultTunnel
?: tunnelConfigRepository.getAll().first().toString()
}
fun onToggleAlwaysOnVPN() = viewModelScope.launch { fun toggleAutoTunnel() =
val updatedSettings = uiState.value.settings.copy( viewModelScope.launch {
isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled, val isAutoTunnelEnabled = uiState.value.settings.isAutoTunnelEnabled
defaultTunnel = getDefaultTunnelOrFirst() var isAutoTunnelPaused = uiState.value.settings.isAutoTunnelPaused
if (isAutoTunnelEnabled) {
ServiceManager.stopWatcherService(application)
} else {
ServiceManager.startWatcherService(application)
isAutoTunnelPaused = false
}
saveSettings(
uiState.value.settings.copy(
isAutoTunnelEnabled = !isAutoTunnelEnabled,
isAutoTunnelPaused = isAutoTunnelPaused,
defaultTunnel = getDefaultTunnelOrFirst(),
),
) )
saveSettings(updatedSettings) }
}
private fun saveSettings(settings: Settings) = viewModelScope.launch { fun onToggleAlwaysOnVPN() =
settingsRepository.save(settings) viewModelScope.launch {
} val updatedSettings =
uiState.value.settings.copy(
isAlwaysOnVpnEnabled = !uiState.value.settings.isAlwaysOnVpnEnabled,
defaultTunnel = getDefaultTunnelOrFirst(),
)
saveSettings(updatedSettings)
}
private fun saveSettings(settings: Settings) =
viewModelScope.launch { settingsRepository.save(settings) }
fun onToggleTunnelOnEthernet() { fun onToggleTunnelOnEthernet() {
saveSettings(uiState.value.settings.copy( saveSettings(
isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled uiState.value.settings.copy(
)) isTunnelOnEthernetEnabled = !uiState.value.settings.isTunnelOnEthernetEnabled,
),
)
} }
fun isLocationEnabled(context: Context): Boolean { fun isLocationEnabled(context: Context): Boolean {
@@ -126,36 +150,36 @@ constructor(
fun onToggleShortcutsEnabled() { fun onToggleShortcutsEnabled() {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled isShortcutsEnabled = !uiState.value.settings.isShortcutsEnabled,
) ),
) )
} }
fun onToggleBatterySaver() { fun onToggleBatterySaver() {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isBatterySaverEnabled = !uiState.value.settings.isBatterySaverEnabled isBatterySaverEnabled = !uiState.value.settings.isBatterySaverEnabled,
) ),
) )
} }
private fun saveKernelMode(on: Boolean) { private fun saveKernelMode(on: Boolean) {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isKernelEnabled = on isKernelEnabled = on,
) ),
) )
} }
fun onToggleTunnelOnWifi() { fun onToggleTunnelOnWifi() {
saveSettings( saveSettings(
uiState.value.settings.copy( uiState.value.settings.copy(
isTunnelOnWifiEnabled = !uiState.value.settings.isTunnelOnWifiEnabled isTunnelOnWifiEnabled = !uiState.value.settings.isTunnelOnWifiEnabled,
) ),
) )
} }
fun onToggleKernelMode() : Result<Unit> { fun onToggleKernelMode(): Result<Unit> {
if (!uiState.value.settings.isKernelEnabled) { if (!uiState.value.settings.isKernelEnabled) {
try { try {
rootShell.start() rootShell.start()
@@ -20,10 +20,10 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ArrowForward import androidx.compose.material.icons.automirrored.rounded.ArrowForward
import androidx.compose.material.icons.rounded.Book import androidx.compose.material.icons.rounded.Book
import androidx.compose.material.icons.rounded.Mail import androidx.compose.material.icons.rounded.Mail
import androidx.compose.material3.Divider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
@@ -57,172 +57,217 @@ import com.zaneschepke.wireguardautotunnel.util.Event
@Composable @Composable
fun SupportScreen( fun SupportScreen(
viewModel: SupportViewModel = hiltViewModel(),
padding: PaddingValues, padding: PaddingValues,
viewModel: SupportViewModel = hiltViewModel(),
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
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()
fun openWebPage(url: String) { fun openWebPage(url: String) {
try { try {
val webpage: Uri = Uri.parse(url) val webpage: Uri = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, webpage) val intent = Intent(Intent.ACTION_VIEW, webpage)
context.startActivity(intent) context.startActivity(intent)
} catch (e : Exception) { } catch (e: Exception) {
showSnackbarMessage(Event.Error.Exception(e).message) showSnackbarMessage(Event.Error.Exception(e).message)
} }
} }
fun launchEmail() { fun launchEmail() {
try { try {
val intent = val intent =
Intent(Intent.ACTION_SEND).apply { Intent(Intent.ACTION_SENDTO).apply {
type = Constants.EMAIL_MIME_TYPE type = Constants.EMAIL_MIME_TYPE
putExtra(Intent.EXTRA_EMAIL, context.getString(R.string.my_email)) putExtra(Intent.EXTRA_EMAIL, arrayOf(context.getString(R.string.my_email)))
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject)) putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.email_subject))
} }
startActivity(context, createChooser(intent, context.getString(R.string.email_chooser)), null) startActivity(
} catch (e : Exception) { context,
showSnackbarMessage(Event.Error.Exception(e).message) createChooser(intent, context.getString(R.string.email_chooser)),
} null,
} )
} catch (e: Exception) {
showSnackbarMessage(Event.Error.Exception(e).message)
}
}
if (uiState.loading) { if (uiState.loading) {
LoadingScreen() LoadingScreen()
return return
} }
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize().padding(padding)
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.focusable() .focusable()
.padding(padding)) { ) {
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()) { (if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.height(IntrinsicSize.Min) Modifier.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth) .fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp) .padding(top = 10.dp)
} else { } else {
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp) Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp)
}) })
.padding(bottom = 25.dp)) { .padding(bottom = 25.dp),
Column(modifier = Modifier.padding(20.dp)) { ) {
Text( Column(modifier = Modifier.padding(20.dp)) {
stringResource(R.string.thank_you), Text(
textAlign = TextAlign.Start, stringResource(R.string.thank_you),
fontWeight = FontWeight.Bold, textAlign = TextAlign.Start,
modifier = Modifier.padding(bottom = 20.dp), fontWeight = FontWeight.Bold,
fontSize = 16.sp) 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,
TextButton( fontSize = 16.sp,
onClick = { openWebPage(context.resources.getString(R.string.docs_url)) }, modifier = Modifier.padding(bottom = 20.dp),
modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester)) { )
Row( TextButton(
horizontalArrangement = Arrangement.SpaceBetween, onClick = { openWebPage(context.resources.getString(R.string.docs_url)) },
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(vertical = 5.dp).focusRequester(focusRequester),
modifier = Modifier.fillMaxWidth()) { ) {
Row { Row(
Icon(Icons.Rounded.Book, stringResource(id = R.string.docs)) horizontalArrangement = Arrangement.SpaceBetween,
Text( verticalAlignment = Alignment.CenterVertically,
stringResource(id = R.string.docs_description), modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Justify, ) {
modifier = Modifier.padding(start = 10.dp)) Row {
} Icon(Icons.Rounded.Book, stringResource(id = R.string.docs))
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go)) Text(
} stringResource(id = R.string.docs_description),
} textAlign = TextAlign.Justify,
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp) modifier = Modifier.padding(start = 10.dp),
TextButton( )
onClick = { openWebPage(context.resources.getString(R.string.discord_url)) }, }
modifier = Modifier.padding(vertical = 5.dp)) { Icon(
Row( Icons.AutoMirrored.Rounded.ArrowForward,
horizontalArrangement = Arrangement.SpaceBetween, stringResource(id = R.string.go)
verticalAlignment = Alignment.CenterVertically, )
modifier = Modifier.fillMaxWidth()) { }
Row {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.discord),
stringResource(id = R.string.discord),
Modifier.size(25.dp))
Text(
stringResource(id = R.string.discord_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { openWebPage(context.resources.getString(R.string.github_url)) },
modifier = Modifier.padding(vertical = 5.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) {
Row {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.github),
stringResource(id = R.string.github),
Modifier.size(25.dp))
Text(
"Open an issue",
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
Divider(color = MaterialTheme.colorScheme.onBackground, thickness = 0.5.dp)
TextButton(
onClick = { launchEmail() }, modifier = Modifier.padding(vertical = 5.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()) {
Row {
Icon(Icons.Rounded.Mail, stringResource(id = R.string.email))
Text(
stringResource(id = R.string.email_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp))
}
Icon(Icons.Rounded.ArrowForward, stringResource(id = R.string.go))
}
}
} }
} HorizontalDivider(
Spacer(modifier = Modifier.weight(1f)) thickness = 0.5.dp,
Text( color = MaterialTheme.colorScheme.onBackground
stringResource(id = R.string.privacy_policy), )
style = TextStyle(textDecoration = TextDecoration.Underline), TextButton(
fontSize = 16.sp, onClick = { openWebPage(context.resources.getString(R.string.discord_url)) },
modifier = modifier = Modifier.padding(vertical = 5.dp),
Modifier.clickable { ) {
openWebPage(context.resources.getString(R.string.privacy_policy_url)) Row(
}) horizontalArrangement = Arrangement.SpaceBetween,
Row( verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(25.dp), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, ) {
modifier = Modifier.padding(25.dp)) { Row {
Text("Version: ${BuildConfig.VERSION_NAME}", modifier = Modifier.focusable()) Icon(
Text("Mode: ${if (uiState.settings.isKernelEnabled) "Kernel" else "Userspace" }") imageVector = ImageVector.vectorResource(R.drawable.discord),
} stringResource(id = R.string.discord),
Modifier.size(25.dp),
)
Text(
stringResource(id = R.string.discord_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
)
}
Icon(
Icons.AutoMirrored.Rounded.ArrowForward,
stringResource(id = R.string.go)
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.onBackground
)
TextButton(
onClick = { openWebPage(context.resources.getString(R.string.github_url)) },
modifier = Modifier.padding(vertical = 5.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.github),
stringResource(id = R.string.github),
Modifier.size(25.dp),
)
Text(
"Open an issue",
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
)
}
Icon(
Icons.AutoMirrored.Rounded.ArrowForward,
stringResource(id = R.string.go)
)
}
}
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.onBackground
)
TextButton(
onClick = { launchEmail() },
modifier = Modifier.padding(vertical = 5.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Row {
Icon(Icons.Rounded.Mail, stringResource(id = R.string.email))
Text(
stringResource(id = R.string.email_description),
textAlign = TextAlign.Justify,
modifier = Modifier.padding(start = 10.dp),
)
}
Icon(
Icons.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,
modifier =
Modifier.clickable {
openWebPage(context.resources.getString(R.string.privacy_policy_url))
},
)
Row(
horizontalArrangement = Arrangement.spacedBy(25.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(25.dp),
) {
Text("Version: ${BuildConfig.VERSION_NAME}", modifier = Modifier.focusable())
Text("Mode: ${if (uiState.settings.isKernelEnabled) "Kernel" else "Userspace"}")
}
}
}
@@ -2,7 +2,4 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.support
import com.zaneschepke.wireguardautotunnel.data.model.Settings import com.zaneschepke.wireguardautotunnel.data.model.Settings
data class SupportUiState( data class SupportUiState(val settings: Settings = Settings(), val loading: Boolean = true)
val settings : Settings = Settings(),
val loading : Boolean = true
)
@@ -11,15 +11,16 @@ import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class SupportViewModel @Inject constructor( class SupportViewModel @Inject constructor(private val settingsRepository: SettingsRepository) :
private val settingsRepository: SettingsRepository ViewModel() {
) : ViewModel() {
val uiState = settingsRepository.getSettingsFlow().map { val uiState =
SupportUiState(it, false) settingsRepository
}.stateIn( .getSettingsFlow()
viewModelScope, .map { SupportUiState(it, false) }
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT), .stateIn(
SupportUiState() viewModelScope,
) SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
SupportUiState(),
)
} }
@@ -21,7 +21,7 @@ private val DarkColorScheme =
primary = virdigris, primary = virdigris,
secondary = virdigris, secondary = virdigris,
// secondary = PurpleGrey80, // secondary = PurpleGrey80,
tertiary = virdigris tertiary = virdigris,
// tertiary = Pink80 // tertiary = Pink80
) )
@@ -29,7 +29,7 @@ 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),
@@ -57,7 +57,6 @@ fun WireguardAutoTunnelTheme(
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
} }
@@ -68,14 +67,14 @@ fun WireguardAutoTunnelTheme(
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 = !darkTheme WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars =
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightNavigationBars = !darkTheme !darkTheme
} }
} }
MaterialTheme( MaterialTheme(
colorScheme = colorScheme, colorScheme = colorScheme,
typography = Typography, typography = Typography,
content = content content = content,
) )
} }
@@ -1,22 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.graphics.Color
import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Composable
fun TransparentSystemBars() {
val systemUiController = rememberSystemUiController()
val useDarkIcons = !isSystemInDarkTheme()
DisposableEffect(systemUiController, useDarkIcons) {
systemUiController.setSystemBarsColor(
color = Color.Transparent,
darkIcons = useDarkIcons
)
onDispose {}
}
}
@@ -10,13 +10,13 @@ import androidx.compose.ui.unit.sp
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,
@@ -12,77 +12,98 @@ sealed class Event {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_none) get() = WireGuardAutoTunnel.instance.getString(R.string.error_none)
} }
data object SsidConflict : Error() { data object SsidConflict : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_ssid_exists) get() = WireGuardAutoTunnel.instance.getString(R.string.error_ssid_exists)
} }
data object RootDenied : Error() { data object RootDenied : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_root_denied) get() = WireGuardAutoTunnel.instance.getString(R.string.error_root_denied)
} }
data class General(val customMessage: String) : Error() { data class General(val customMessage: String) : Error() {
override val message: String override val message: String
get() = customMessage get() = customMessage
} }
data class Exception(val exception : kotlin.Exception) : Error() {
data class Exception(val exception: kotlin.Exception) : Error() {
override val message: String override val message: String
get() = exception.message ?: WireGuardAutoTunnel.instance.getString(R.string.unknown_error) get() =
exception.message
?: WireGuardAutoTunnel.instance.getString(R.string.unknown_error)
} }
data object InvalidQrCode : Error() { data object InvalidQrCode : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_invalid_code) get() = WireGuardAutoTunnel.instance.getString(R.string.error_invalid_code)
} }
data object InvalidFileExtension : Error() { data object InvalidFileExtension : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension) get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension)
} }
data object FileReadFailed : Error() { data object FileReadFailed : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension) get() = WireGuardAutoTunnel.instance.getString(R.string.error_file_extension)
} }
data object AuthenticationFailed : Error() { data object AuthenticationFailed : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_authentication_failed) get() = WireGuardAutoTunnel.instance.getString(R.string.error_authentication_failed)
} }
data object AuthorizationFailed : Error() { data object AuthorizationFailed : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_authorization_failed) get() = WireGuardAutoTunnel.instance.getString(R.string.error_authorization_failed)
} }
data object BackgroundLocationRequired : Error() { data object BackgroundLocationRequired : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.background_location_required) get() =
WireGuardAutoTunnel.instance.getString(R.string.background_location_required)
} }
data object LocationServicesRequired : Error() { data object LocationServicesRequired : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.location_services_required) get() = WireGuardAutoTunnel.instance.getString(R.string.location_services_required)
} }
data object PreciseLocationRequired : Error() { data object PreciseLocationRequired : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.precise_location_required) get() = WireGuardAutoTunnel.instance.getString(R.string.precise_location_required)
} }
data object FileExplorerRequired : Error() { data object FileExplorerRequired : Error() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.error_no_file_explorer) get() = WireGuardAutoTunnel.instance.getString(R.string.error_no_file_explorer)
} }
} }
sealed class Message : Event() { sealed class Message : Event() {
data object ConfigSaved: Message() { data object ConfigSaved : Message() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.config_changes_saved) get() = WireGuardAutoTunnel.instance.getString(R.string.config_changes_saved)
} }
data object ConfigsExported: Message() {
data object ConfigsExported : Message() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.exported_configs_message) get() = WireGuardAutoTunnel.instance.getString(R.string.exported_configs_message)
} }
data object TunnelOffAction: Message() {
data object TunnelOffAction : Message() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_tunnel) get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_tunnel)
} }
data object TunnelOnAction: Message() {
data object TunnelOnAction : Message() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.turn_on_tunnel) get() = WireGuardAutoTunnel.instance.getString(R.string.turn_on_tunnel)
} }
data object AutoTunnelOffAction: Message() {
data object AutoTunnelOffAction : Message() {
override val message: String override val message: String
get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_auto) get() = WireGuardAutoTunnel.instance.getString(R.string.turn_off_auto)
} }
@@ -37,23 +37,23 @@ fun BigDecimal.toThreeDecimalPlaceString(): String {
} }
fun <T> List<T>.update(index: Int, item: T): List<T> = toMutableList().apply { this[index] = item } fun <T> List<T>.update(index: Int, item: T): List<T> = toMutableList().apply { this[index] = item }
fun <T> List<T>.removeAt(index: Int): List<T> = toMutableList().apply { this.removeAt(index) } fun <T> List<T>.removeAt(index: Int): List<T> = toMutableList().apply { this.removeAt(index) }
typealias TunnelConfigs = List<TunnelConfig> typealias TunnelConfigs = List<TunnelConfig>
typealias Packages = List<PackageInfo> typealias Packages = List<PackageInfo>
fun Statistics.mapPeerStats(): Map<Key, PeerStats?> { fun Statistics.mapPeerStats(): Map<Key, PeerStats?> {
return this.peers().associateWith { key -> return this.peers().associateWith { key -> (this.peer(key)) }
(this.peer(key))
}
} }
fun PeerStats.latestHandshakeSeconds() : Long? { fun PeerStats.latestHandshakeSeconds(): Long? {
return NumberUtils.getSecondsBetweenTimestampAndNow(this.latestHandshakeEpochMillis) return NumberUtils.getSecondsBetweenTimestampAndNow(this.latestHandshakeEpochMillis)
} }
fun PeerStats.handshakeStatus() : HandshakeStatus { fun PeerStats.handshakeStatus(): HandshakeStatus {
//TODO add never connected status after duration // TODO add never connected status after duration
return this.latestHandshakeSeconds().let { return this.latestHandshakeSeconds().let {
when { when {
it == null -> HandshakeStatus.NOT_STARTED it == null -> HandshakeStatus.NOT_STARTED
@@ -65,4 +65,3 @@ fun PeerStats.handshakeStatus() : HandshakeStatus {
} }
} }
} }
@@ -36,22 +36,20 @@ object FileUtils {
val target = val target =
File( File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
fileName fileName,
) )
return target.outputStream() return target.outputStream()
} }
return null return null
} }
fun saveFilesToZip( fun saveFilesToZip(context: Context, files: List<File>) {
context: Context, val zipOutputStream =
files: List<File> createDownloadsFileOutputStream(
) { context,
val zipOutputStream = createDownloadsFileOutputStream( "wg-export_${Instant.now().epochSecond}.zip",
context, ZIP_FILE_MIME_TYPE,
"wg-export_${Instant.now().epochSecond}.zip", )
ZIP_FILE_MIME_TYPE
)
ZipOutputStream(zipOutputStream).use { zos -> ZipOutputStream(zipOutputStream).use { zos ->
files.forEach { file -> files.forEach { file ->
val entry = ZipEntry(file.name) val entry = ZipEntry(file.name)
@@ -2,12 +2,12 @@ package com.zaneschepke.wireguardautotunnel.util
import timber.log.Timber import timber.log.Timber
sealed class Result<T> { sealed class Result<T> {
class Success<T>(val data: T): Result<T>() class Success<T>(val data: T) : Result<T>()
class Error<T>(val error : Event.Error): Result<T>() {
class Error<T>(val error: Event.Error) : Result<T>() {
init { init {
when(this.error) { when (this.error) {
is Event.Error.Exception -> Timber.e(this.error.exception) is Event.Error.Exception -> Timber.e(this.error.exception)
else -> Timber.e(this.error.message) else -> Timber.e(this.error.message)
} }
@@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="640"
android:viewportHeight="640"
android:tint="#FFFFFF">
<group android:scaleX="1.0132159"
android:scaleY="1.0132159"
android:translateX="-4.229075"
android:translateY="-4.229075">
<path
android:pathData="M316.72,80.15C314.94,80.82 312.08,83.95 308.79,88.84C275.66,138.15 157.88,161.96 119.66,127.08C109.97,118.24 101.21,118.84 98.97,128.5C96.01,141.29 98.49,204.07 103.12,233.5C123.71,364.32 186.77,465.69 303.03,554.88C314.06,563.34 316.63,563.42 326.93,555.61C329.91,553.35 336.21,548.63 340.93,545.13C345.64,541.63 350.87,537.46 352.55,535.88C354.23,534.29 357.92,531.53 360.75,529.74C413.56,496.43 481.74,399.04 510.38,316C527.22,267.19 534.86,236.66 539.96,197.9C547.99,136.74 545.31,124.46 526,134.01C469.85,161.74 361.02,137.16 333.39,90.49C327.63,80.75 322.93,77.84 316.72,80.15M307.5,195.34C282.24,203.76 266.16,237.38 269.85,274.04C270.99,285.39 271.18,285 264.63,285C254.13,285 254.15,284.87 255,352.05C255.64,402.94 250.1,397.02 298.5,398.52C352.54,400.2 377.63,400.23 379.23,398.63C381.67,396.18 381.86,392.86 382.46,339.89C383.09,283.93 383.39,286 374.51,286C367.07,286 367.21,286.26 367.77,273.58C369.89,225.38 338.74,184.92 307.5,195.34M308.93,216.98C294.31,224.7 281.68,270.05 290.33,283.75C291.74,285.99 346.85,285.51 347.78,283.25C359.48,254.75 330.62,205.51 308.93,216.98M309.54,317.1C304.18,321.81 304.39,327.76 310.11,332.99C314.78,337.26 314.82,336.51 309.33,349.89C307.44,354.51 306.13,358.89 306.42,359.64C306.96,361.06 329,361.75 329,360.35C329,359.98 327.42,354.82 325.5,348.86C323.58,342.91 322,337.52 322,336.89C322,336.26 323.58,334 325.5,331.87C335.69,320.59 321.02,307.02 309.54,317.1"
android:fillColor="#53bdb6"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
</group>
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+4 -4
View File
@@ -3,8 +3,8 @@
android:height="800dp" android:height="800dp"
android:viewportWidth="256" android:viewportWidth="256"
android:viewportHeight="256"> android:viewportHeight="256">
<path <path
android:pathData="M216.86,45.1C200.29,37.34 182.57,31.71 164.04,28.5C161.77,32.61 159.11,38.15 157.28,42.55C137.58,39.58 118.07,39.58 98.74,42.55C96.91,38.15 94.19,32.61 91.9,28.5C73.35,31.71 55.61,37.36 39.04,45.14C5.62,95.65 -3.44,144.9 1.09,193.46C23.26,210.01 44.74,220.07 65.86,226.65C71.08,219.47 75.73,211.84 79.74,203.8C72.1,200.9 64.79,197.32 57.89,193.17C59.72,191.81 61.51,190.39 63.24,188.93C105.37,208.63 151.13,208.63 192.75,188.93C194.51,190.39 196.3,191.81 198.11,193.17C191.18,197.34 183.85,200.92 176.22,203.82C180.23,211.84 184.86,219.49 190.1,226.67C211.24,220.09 232.74,210.03 254.91,193.46C260.23,137.17 245.83,88.37 216.86,45.1ZM85.47,163.59C72.83,163.59 62.46,151.79 62.46,137.41C62.46,123.04 72.61,111.21 85.47,111.21C98.34,111.21 108.71,123.02 108.49,137.41C108.51,151.79 98.34,163.59 85.47,163.59ZM170.53,163.59C157.88,163.59 147.51,151.79 147.51,137.41C147.51,123.04 157.66,111.21 170.53,111.21C183.39,111.21 193.76,123.02 193.54,137.41C193.54,151.79 183.39,163.59 170.53,163.59Z" android:fillColor="#5865F2"
android:fillColor="#5865F2" android:fillType="nonZero"
android:fillType="nonZero"/> android:pathData="M216.86,45.1C200.29,37.34 182.57,31.71 164.04,28.5C161.77,32.61 159.11,38.15 157.28,42.55C137.58,39.58 118.07,39.58 98.74,42.55C96.91,38.15 94.19,32.61 91.9,28.5C73.35,31.71 55.61,37.36 39.04,45.14C5.62,95.65 -3.44,144.9 1.09,193.46C23.26,210.01 44.74,220.07 65.86,226.65C71.08,219.47 75.73,211.84 79.74,203.8C72.1,200.9 64.79,197.32 57.89,193.17C59.72,191.81 61.51,190.39 63.24,188.93C105.37,208.63 151.13,208.63 192.75,188.93C194.51,190.39 196.3,191.81 198.11,193.17C191.18,197.34 183.85,200.92 176.22,203.82C180.23,211.84 184.86,219.49 190.1,226.67C211.24,220.09 232.74,210.03 254.91,193.46C260.23,137.17 245.83,88.37 216.86,45.1ZM85.47,163.59C72.83,163.59 62.46,151.79 62.46,137.41C62.46,123.04 72.61,111.21 85.47,111.21C98.34,111.21 108.71,123.02 108.49,137.41C108.51,151.79 98.34,163.59 85.47,163.59ZM170.53,163.59C157.88,163.59 147.51,151.79 147.51,137.41C147.51,123.04 157.66,111.21 170.53,111.21C183.39,111.21 193.76,123.02 193.54,137.41C193.54,151.79 183.39,163.59 170.53,163.59Z" />
</vector> </vector>
+6 -6
View File
@@ -3,10 +3,10 @@
android:height="800dp" android:height="800dp"
android:viewportWidth="20" android:viewportWidth="20"
android:viewportHeight="20"> android:viewportHeight="20">
<path <path
android:pathData="M10,0C15.523,0 20,4.59 20,10.253C20,14.782 17.138,18.624 13.167,19.981C12.66,20.082 12.48,19.762 12.48,19.489C12.48,19.151 12.492,18.047 12.492,16.675C12.492,15.719 12.172,15.095 11.813,14.777C14.04,14.523 16.38,13.656 16.38,9.718C16.38,8.598 15.992,7.684 15.35,6.966C15.454,6.707 15.797,5.664 15.252,4.252C15.252,4.252 14.414,3.977 12.505,5.303C11.706,5.076 10.85,4.962 10,4.958C9.15,4.962 8.295,5.076 7.497,5.303C5.586,3.977 4.746,4.252 4.746,4.252C4.203,5.664 4.546,6.707 4.649,6.966C4.01,7.684 3.619,8.598 3.619,9.718C3.619,13.646 5.954,14.526 8.175,14.785C7.889,15.041 7.63,15.493 7.54,16.156C6.97,16.418 5.522,16.871 4.63,15.304C4.63,15.304 4.101,14.319 3.097,14.247C3.097,14.247 2.122,14.234 3.029,14.87C3.029,14.87 3.684,15.185 4.139,16.37C4.139,16.37 4.726,18.2 7.508,17.58C7.513,18.437 7.522,19.245 7.522,19.489C7.522,19.76 7.338,20.077 6.839,19.982C2.865,18.627 0,14.783 0,10.253C0,4.59 4.478,0 10,0" android:fillColor="#000000"
android:strokeWidth="1" android:fillType="evenOdd"
android:fillColor="#000000" android:pathData="M10,0C15.523,0 20,4.59 20,10.253C20,14.782 17.138,18.624 13.167,19.981C12.66,20.082 12.48,19.762 12.48,19.489C12.48,19.151 12.492,18.047 12.492,16.675C12.492,15.719 12.172,15.095 11.813,14.777C14.04,14.523 16.38,13.656 16.38,9.718C16.38,8.598 15.992,7.684 15.35,6.966C15.454,6.707 15.797,5.664 15.252,4.252C15.252,4.252 14.414,3.977 12.505,5.303C11.706,5.076 10.85,4.962 10,4.958C9.15,4.962 8.295,5.076 7.497,5.303C5.586,3.977 4.746,4.252 4.746,4.252C4.203,5.664 4.546,6.707 4.649,6.966C4.01,7.684 3.619,8.598 3.619,9.718C3.619,13.646 5.954,14.526 8.175,14.785C7.889,15.041 7.63,15.493 7.54,16.156C6.97,16.418 5.522,16.871 4.63,15.304C4.63,15.304 4.101,14.319 3.097,14.247C3.097,14.247 2.122,14.234 3.029,14.87C3.029,14.87 3.684,15.185 4.139,16.37C4.139,16.37 4.726,18.2 7.508,17.58C7.513,18.437 7.522,19.245 7.522,19.489C7.522,19.76 7.338,20.077 6.839,19.982C2.865,18.627 0,14.783 0,10.253C0,4.59 4.478,0 10,0"
android:fillType="evenOdd" android:strokeWidth="1"
android:strokeColor="#00000000"/> android:strokeColor="#00000000" />
</vector> </vector>
@@ -0,0 +1,45 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="640"
android:viewportHeight="640">
<group android:scaleX="0.6666667"
android:scaleY="0.6666667"
android:translateX="106.666664"
android:translateY="106.666664">
<group android:scaleX="0.315"
android:scaleY="0.56"
android:translateX="46.4"
android:translateY="140.8">
<path
android:pathData="M316.72,80.15C314.94,80.82 312.08,83.95 308.79,88.84C275.66,138.15 157.88,161.96 119.66,127.08C109.97,118.24 101.21,118.84 98.97,128.5C96.01,141.29 98.49,204.07 103.12,233.5C123.71,364.32 186.77,465.69 303.03,554.88C314.06,563.34 316.63,563.42 326.93,555.61C329.91,553.35 336.21,548.63 340.93,545.13C345.64,541.63 350.87,537.46 352.55,535.88C354.23,534.29 357.92,531.53 360.75,529.74C413.56,496.43 481.74,399.04 510.38,316C527.22,267.19 534.86,236.66 539.96,197.9C547.99,136.74 545.31,124.46 526,134.01C469.85,161.74 361.02,137.16 333.39,90.49C327.63,80.75 322.93,77.84 316.72,80.15M307.5,195.34C282.24,203.76 266.16,237.38 269.85,274.04C270.99,285.39 271.18,285 264.63,285C254.13,285 254.15,284.87 255,352.05C255.64,402.94 250.1,397.02 298.5,398.52C352.54,400.2 377.63,400.23 379.23,398.63C381.67,396.18 381.86,392.86 382.46,339.89C383.09,283.93 383.39,286 374.51,286C367.07,286 367.21,286.26 367.77,273.58C369.89,225.38 338.74,184.92 307.5,195.34M308.93,216.98C294.31,224.7 281.68,270.05 290.33,283.75C291.74,285.99 346.85,285.51 347.78,283.25C359.48,254.75 330.62,205.51 308.93,216.98M309.54,317.1C304.18,321.81 304.39,327.76 310.11,332.99C314.78,337.26 314.82,336.51 309.33,349.89C307.44,354.51 306.13,358.89 306.42,359.64C306.96,361.06 329,361.75 329,360.35C329,359.98 327.42,354.82 325.5,348.86C323.58,342.91 322,337.52 322,336.89C322,336.26 323.58,334 325.5,331.87C335.69,320.59 321.02,307.02 309.54,317.1"
android:fillColor="#53bdb6"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
</group>
<group android:scaleX="0.52644"
android:scaleY="0.20707752"
android:translateX="320.4"
android:translateY="70.26513">
<group android:translateY="132.92308">
<path android:pathData="M76.06154,-101.68616L87.95077,-101.68616L72.44308,0L55.606155,0L43.93846,-76.72615L32.196922,0L14.990769,0L0.6646154,-101.68616L13.513846,-101.68616L24.36923,-14.104615L36.553844,-89.870766L52.283077,-89.870766L63.876923,-14.104615Z"
android:fillColor="#53BDB6"/>
<path android:pathData="M135.30154,1.6984615Q123.11692,1.6984615,114.25539,-4.1723075Q105.393845,-10.0430765,100.55692,-21.821539Q95.72,-33.6,95.72,-50.88Q95.72,-67.643074,101.62769,-79.495384Q107.535385,-91.347694,117.246155,-97.32923Q126.956924,-103.31077,138.25539,-103.31077Q147.78154,-103.31077,154.42769,-100.61539Q161.07385,-97.92,167.35077,-92.16L159.59692,-84.11077Q154.94461,-88.46769,149.77539,-90.535385Q144.60616,-92.60307,138.32922,-92.60307Q130.42769,-92.60307,123.92923,-88.504616Q117.43077,-84.40615,113.44308,-75.10154Q109.45538,-65.79692,109.45538,-50.88Q109.45538,-29.095385,116.175385,-19.089231Q122.895386,-9.0830765,136.48308,-9.0830765Q146.7477,-9.0830765,155.83076,-13.9569235L155.83076,-44.97231L135.81847,-44.97231L134.26768,-55.606155L168.5323,-55.606155L168.5323,-7.163077Q160.70462,-2.88,152.98769,-0.59076923Q145.27077,1.6984615,135.30154,1.6984615Z"
android:fillColor="#53BDB6"/>
<path android:pathData="M348.30463,-90.683075L317.43692,-90.683075L317.43692,0L304.66153,0L304.66153,-90.683075L272.90768,-90.683075L272.90768,-101.68616L349.63385,-101.68616Z"
android:fillColor="#53BDB6"/>
<path android:pathData="M383.24924,-22.670769Q383.24924,-15.064615,386.3877,-11.630769Q389.52615,-8.196923,396.32,-8.196923Q402.44922,-8.196923,408.24615,-11.741538Q414.0431,-15.286154,417.36615,-20.52923L417.36615,-77.76L429.7723,-77.76L429.7723,0L419.2123,0L418.17847,-10.486154Q413.67386,-4.726154,406.99078,-1.5138462Q400.30768,1.6984615,393.2923,1.6984615Q382.14154,1.6984615,376.4923,-4.246154Q370.84308,-10.190769,370.84308,-21.193846L370.84308,-77.76L383.24924,-77.76Z"
android:fillColor="#53BDB6"/>
<path android:pathData="M459.84308,0L459.84308,-77.76L470.40308,-77.76L471.36307,-66.97846Q476.01538,-72.81231,483.0677,-76.098465Q490.12,-79.38461,497.06152,-79.38461Q508.2123,-79.38461,513.56616,-73.47692Q518.92,-67.56923,518.92,-56.49231L518.92,0L506.51385,0L506.51385,-47.335384Q506.51385,-56.049232,505.59076,-60.627693Q504.6677,-65.206154,501.82462,-67.42154Q498.98154,-69.636925,493.22153,-69.636925Q486.9446,-69.636925,481.36923,-65.76Q475.79385,-61.883076,472.24924,-56.64L472.24924,0Z"
android:fillColor="#53BDB6"/>
<path android:pathData="M548.8431,0L548.8431,-77.76L559.4031,-77.76L560.3631,-66.97846Q565.0154,-72.81231,572.0677,-76.098465Q579.12,-79.38461,586.0615,-79.38461Q597.2123,-79.38461,602.56616,-73.47692Q607.92,-67.56923,607.92,-56.49231L607.92,0L595.51385,0L595.51385,-47.335384Q595.51385,-56.049232,594.59076,-60.627693Q593.66766,-65.206154,590.8246,-67.42154Q587.98157,-69.636925,582.22156,-69.636925Q575.94464,-69.636925,570.3692,-65.76Q564.7938,-61.883076,561.2492,-56.64L561.2492,0Z"
android:fillColor="#53BDB6"/>
<path android:pathData="M647.59076,-34.486153Q647.88617,-25.772308,650.9877,-19.975384Q654.08923,-14.178461,659.1108,-11.409231Q664.1323,-8.64,670.3354,-8.64Q676.0954,-8.64,680.9323,-10.338462Q685.7692,-12.036923,691.0862,-15.655385L696.92,-7.4584618Q691.4554,-3.1753845,684.4031,-0.73846155Q677.35077,1.6984615,670.1877,1.6984615Q659.0369,1.6984615,650.9877,-3.323077Q642.9385,-8.344615,638.7662,-17.50154Q634.5939,-26.65846,634.5939,-38.76923Q634.5939,-50.51077,638.8031,-59.74154Q643.0123,-68.972305,650.6923,-74.17846Q658.3723,-79.38461,668.56305,-79.38461Q678.3108,-79.38461,685.43695,-74.80615Q692.56305,-70.22769,696.36615,-61.624615Q700.16925,-53.021538,700.16925,-41.28Q700.16925,-37.883076,699.87384,-34.486153ZM668.71075,-69.19385Q659.70154,-69.19385,654.0523,-62.88Q648.4031,-56.566154,647.6646,-44.086155L688.2062,-44.086155Q687.9846,-56.344616,682.81537,-62.76923Q677.6462,-69.19385,668.71075,-69.19385Z"
android:fillColor="#53BDB6"/>
<path android:pathData="M757.12,-19.2Q757.12,-13.735385,760.55383,-11.187693Q763.9877,-8.64,769.96924,-8.64Q776.24615,-8.64,783.04,-11.372308L786.3631,-2.2892308Q782.8185,-0.51692307,778.0923,0.59076923Q773.36615,1.6984615,767.90155,1.6984615Q761.0339,1.6984615,755.75385,-0.8861538Q750.4738,-3.4707692,747.5939,-8.344615Q744.71387,-13.218462,744.71387,-19.864614L744.71387,-99.24923L720.8615,-99.24923L720.8615,-109.07077L757.12,-109.07077Z"
android:fillColor="#53BDB6"/>
</group>
</group>
</group>
</vector>
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -1,30 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp" android:width="108dp"
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="640"
android:viewportHeight="108"> android:viewportHeight="640">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z"> <group android:scaleX="0.6"
<aapt:attr name="android:fillColor"> android:scaleY="0.6"
<gradient android:translateX="128"
android:endX="85.84757" android:translateY="128">
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path <path
android:fillColor="#FFFFFF" android:pathData="M316.72,80.15C314.94,80.82 312.08,83.95 308.79,88.84C275.66,138.15 157.88,161.96 119.66,127.08C109.97,118.24 101.21,118.84 98.97,128.5C96.01,141.29 98.49,204.07 103.12,233.5C123.71,364.32 186.77,465.69 303.03,554.88C314.06,563.34 316.63,563.42 326.93,555.61C329.91,553.35 336.21,548.63 340.93,545.13C345.64,541.63 350.87,537.46 352.55,535.88C354.23,534.29 357.92,531.53 360.75,529.74C413.56,496.43 481.74,399.04 510.38,316C527.22,267.19 534.86,236.66 539.96,197.9C547.99,136.74 545.31,124.46 526,134.01C469.85,161.74 361.02,137.16 333.39,90.49C327.63,80.75 322.93,77.84 316.72,80.15M307.5,195.34C282.24,203.76 266.16,237.38 269.85,274.04C270.99,285.39 271.18,285 264.63,285C254.13,285 254.15,284.87 255,352.05C255.64,402.94 250.1,397.02 298.5,398.52C352.54,400.2 377.63,400.23 379.23,398.63C381.67,396.18 381.86,392.86 382.46,339.89C383.09,283.93 383.39,286 374.51,286C367.07,286 367.21,286.26 367.77,273.58C369.89,225.38 338.74,184.92 307.5,195.34M308.93,216.98C294.31,224.7 281.68,270.05 290.33,283.75C291.74,285.99 346.85,285.51 347.78,283.25C359.48,254.75 330.62,205.51 308.93,216.98M309.54,317.1C304.18,321.81 304.39,327.76 310.11,332.99C314.78,337.26 314.82,336.51 309.33,349.89C307.44,354.51 306.13,358.89 306.42,359.64C306.96,361.06 329,361.75 329,360.35C329,359.98 327.42,354.82 325.5,348.86C323.58,342.91 322,337.52 322,336.89C322,336.26 323.58,334 325.5,331.87C335.69,320.59 321.02,307.02 309.54,317.1"
android:fillType="nonZero" android:fillColor="#53bdb6"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z" android:fillType="evenOdd"
android:strokeWidth="1" android:strokeColor="#00000000"/>
android:strokeColor="#00000000" /> </group>
</vector> </vector>
-5
View File
@@ -1,5 +0,0 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12V5l-9,-4z"/>
</vector>
+9 -4
View File
@@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#000000" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> android:height="24dp"
<path android:fillColor="@android:color/white" android:pathData="M20.83,18H21v-4h2v-4H12.83L20.83,18zM19.78,22.61l1.41,-1.41L2.81,2.81L1.39,4.22l2.59,2.59C2.2,7.85 1,9.79 1,12c0,3.31 2.69,6 6,6c2.21,0 4.15,-1.2 5.18,-2.99L19.78,22.61zM8.99,11.82C9,11.88 9,11.94 9,12c0,1.1 -0.9,2 -2,2s-2,-0.9 -2,-2s0.9,-2 2,-2c0.06,0 0.12,0 0.18,0.01L8.99,11.82z"/> android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20.83,18H21v-4h2v-4H12.83L20.83,18zM19.78,22.61l1.41,-1.41L2.81,2.81L1.39,4.22l2.59,2.59C2.2,7.85 1,9.79 1,12c0,3.31 2.69,6 6,6c2.21,0 4.15,-1.2 5.18,-2.99L19.78,22.61zM8.99,11.82C9,11.88 9,11.94 9,12c0,1.1 -0.9,2 -2,2s-2,-0.9 -2,-2s0.9,-2 2,-2c0.06,0 0.12,0 0.18,0.01L8.99,11.82z" />
</vector> </vector>
+9 -4
View File
@@ -1,5 +1,10 @@
<vector android:height="24dp" android:tint="#000000" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:viewportHeight="24" android:viewportWidth="24" android:width="24dp"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android"> android:height="24dp"
<path android:fillColor="@android:color/white" android:pathData="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"/> android:tint="#000000"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M12.65,10C11.83,7.67 9.61,6 7,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6c2.61,0 4.83,-1.67 5.65,-4H17v4h4v-4h2v-4H12.65zM7,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z" />
</vector> </vector>
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_banner_background"/> <background android:drawable="@color/ic_banner_background"/>
<foreground android:drawable="@mipmap/ic_banner_foreground"/> <foreground android:drawable="@drawable/ic_banner_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<!-- support for adaptive theme icons -->
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@drawable/ic_launcher_foreground"/>
<!-- support for adaptive theme icons -->
<monochrome android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon> </adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 796 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

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