Compare commits

..

59 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
Zane Schepke dd16bd977f fix pipeline sdk target 2023-12-31 20:35:57 -05:00
Zane Schepke aeb4a13389 feat: androidtv navigation, auto-tunneling pause
Improved AndroidTV navigation to be less clunky and more streamlined

Added auto-tunneling pause feature to UI to allow of quick auto tunneling pauses.
App shortcuts and quick tile also override auto tunneling by engaging pause for temporary override of VPN purposes.

Fixed bug where auto start on reboot was not working on older devices and AndroidTV.

Fixed bug where location services is prefenting some flavors of Android from using auto-tunneling.

Fixed bug where location permissions were not being detected correctly on AndroidTV versions.

Fixed bug where quick tile could become out of sync.

Improved notifications to show proper state of auto-tunneling and vpn.

Removed excessive vibration from notifications.

Improved error handling.

Closes #75
Closes #73
Closes #61
Closes #53
Closes #30
2023-12-31 17:59:30 -05:00
Zane Schepke f0ec661223 fix: android 14 foreground permissions
Closes #71
2023-12-21 12:22:55 -05:00
Zane Schepke ffa7a207fb feat: add basic kernel support
Added basic kernel support to allow users to switch between userspace and kernel wireguard

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

Fix airplane mode bug

Improve database migration testing

Fix auto-tunneling permission bug.

Lint

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

Improved UI on support screen and updated support resource links.

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

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

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

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

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

Remove vibrations from notifications

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

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

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

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

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

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

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

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

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

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

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

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

Database migration
2023-10-21 14:03:36 -04:00
189 changed files with 6139 additions and 3590 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
+33
View File
@@ -0,0 +1,33 @@
---
name: Bug report
about: Create a report to help us improve
title: "[BUG] - Problem with app"
labels: bug
assignees: zaneschepke
---
**Describe the bug**
A clear and concise description of what the bug is.
**Smartphone (please complete the following information):**
- Device: [e.g. Pixel 4a]
- Android Version: [e.g. Android 13]
- App Version [e.g. 3.3.3]
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots (Only if necessary)**
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[FEATURE] - New feature request"
labels: enhancement
assignees: zaneschepke
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+17
View File
@@ -0,0 +1,17 @@
# Support
If you are experiencing issues with the app, the following resources are available to help you.
<ol>
<li>
See the app docs site <a href="https://zaneschepke.com/wgtunnel-docs/overview.html">here</a> (work in progress).
</li>
<li>
Chat with me and our community on Discord <a href="https://discord.gg/rbRRNh6H7V">here</a>, or open an issue on GitHub <a href="https://github.com/zaneschepke/wgtunnel/issues/new/choose">here</a>.
</li>
<li>
Send me an email <a href="mailto:zanecschepke@gmail.com">here</a>.
</li>
</ol>
Thank you for using WG Tunnel.
+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
+99
View File
@@ -0,0 +1,99 @@
# name of the workflow
name: Android CI Tag Deployment (Pre-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: 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: true
files: ${{ github.workspace }}/${{ steps.apk-path.outputs.path }}
- name: Deploy with fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: '3.2' # Not needed with a .ruby-version file
bundler-cache: true
- name: Distribute app to Beta track 🚀
run: (cd ${{ github.workspace }} && bundle install && bundle exec fastlane beta)
+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)
+2
View File
@@ -69,3 +69,5 @@ lint/tmp/
# App Specific cases # App Specific cases
app/release/output.json app/release/output.json
.idea/codeStyles/ .idea/codeStyles/
# where we keep our signing secrets locally
app/signing.properties
+3
View File
@@ -0,0 +1,3 @@
source "https://rubygems.org"
gem "fastlane"
+51 -23
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,70 @@ 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 inspiration for this app came from the inconvenience of constantly having to turn VPN off and on while on different networks. With there being no free solution to this problem, this app was created to meet that need. The original inspiration for this app came from the inconvenience of having to manually turn VPN off
and on while on different networks. This app was created to offer a free solution to this problem.
## Features ## Features
* Add tunnels via .conf file * Add tunnels via .conf file, zip, manual entry, or QR code
* Auto connect to VPN based on Wi-Fi SSID * Auto connect to VPN based on Wi-Fi SSID, 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
* Quick tile support for vpn toggling * Always-On VPN support
* Dynamic shortcuts support for automation integration * Export tunnels to zip
* Configurable Trusted Network list * Quick tile support for VPN toggling
* Optional auto connect on mobile data * Static shortcuts support for primary tunnel for automation integration
* Intent automation support for all tunnels
* 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
$ ./gradlew assembleRelease ```
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
``` ```
</span> </span>
+105 -46
View File
@@ -1,3 +1,5 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
@@ -7,49 +9,108 @@ plugins {
} }
android { android {
namespace = "com.zaneschepke.wireguardautotunnel" namespace = Constants.APP_ID
compileSdk = 34 compileSdk = Constants.TARGET_SDK
defaultConfig { defaultConfig {
applicationId = "com.zaneschepke.wireguardautotunnel" applicationId = Constants.APP_ID
minSdk = 26 minSdk = Constants.MIN_SDK
targetSdk = 34 targetSdk = Constants.TARGET_SDK
versionCode = 31400 versionCode = Constants.VERSION_CODE
versionName = "3.1.4" versionName = Constants.VERSION_NAME
multiDexEnabled = true ksp { arg("room.schemaLocation", "$projectDir/schemas") }
sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room
}
resourceConfigurations.addAll(listOf("en")) resourceConfigurations.addAll(listOf("en"))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables { useSupportLibrary = true }
useSupportLibrary = true }
signingConfigs {
create(Constants.RELEASE) {
val properties =
Properties().apply {
// created local file for signing details
try {
load(file("signing.properties").reader())
} catch (_: Exception) {
load(file("signing_template.properties").reader())
}
}
// try to get secrets from env first for pipeline build, then properties file for local
// build
storeFile =
file(
System.getenv()
.getOrDefault(
Constants.KEY_STORE_PATH_VAR,
properties.getProperty(Constants.KEY_STORE_PATH_VAR),
),
)
storePassword =
System.getenv()
.getOrDefault(
Constants.STORE_PASS_VAR,
properties.getProperty(Constants.STORE_PASS_VAR),
)
keyAlias =
System.getenv()
.getOrDefault(
Constants.KEY_ALIAS_VAR,
properties.getProperty(Constants.KEY_ALIAS_VAR),
)
keyPassword =
System.getenv()
.getOrDefault(
Constants.KEY_PASS_VAR,
properties.getProperty(Constants.KEY_PASS_VAR),
)
} }
} }
buildTypes { buildTypes {
// don't strip
packaging.jniLibs.keepDebugSymbols.addAll(
listOf("libwg-go.so", "libwg-quick.so", "libwg.so"),
)
applicationVariants.all {
val variant = this
variant.outputs
.map { it as com.android.build.gradle.internal.api.BaseVariantOutputImpl }
.forEach { output ->
val outputFileName =
"${Constants.APP_NAME}-${variant.flavorName}-${variant.buildType.name}-${variant.versionName}.apk"
output.outputFileName = outputFileName
}
}
release { release {
isDebuggable = false isDebuggable = false
isMinifyEnabled = true isMinifyEnabled = true
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)
} }
debug { debug { isDebuggable = true }
isDebuggable = true
}
} }
flavorDimensions.add("type") flavorDimensions.add(Constants.TYPE)
productFlavors { productFlavors {
create("fdroid") { create("fdroid") {
dimension = "type" dimension = Constants.TYPE
proguardFile("fdroid-rules.pro")
} }
create("general") { create("general") {
dimension = "type" dimension = Constants.TYPE
if (BuildHelper.isReleaseBuild(gradle) && BuildHelper.isGeneralFlavor(gradle)) if (BuildHelper.isReleaseBuild(gradle) && BuildHelper.isGeneralFlavor(gradle)) {
{
apply(plugin = "com.google.gms.google-services") apply(plugin = "com.google.gms.google-services")
apply(plugin = "com.google.firebase.crashlytics") apply(plugin = "com.google.firebase.crashlytics")
} }
@@ -58,26 +119,19 @@ android {
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions { jvmTarget = Constants.JVM_TARGET }
jvmTarget = "17"
}
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
} }
composeOptions { kotlinCompilerExtensionVersion = Constants.COMPOSE_COMPILER_EXTENSION_VERSION }
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)
@@ -91,21 +145,25 @@ dependencies {
implementation(libs.androidx.material3) implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat) implementation(libs.androidx.appcompat)
//test // test
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test) androidTestImplementation(libs.androidx.compose.ui.test)
androidTestImplementation(libs.androidx.room.testing)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.manifest) debugImplementation(libs.androidx.compose.manifest)
//wg // get tunnel lib from github packages or mavenLocal
implementation(libs.tunnel) implementation(libs.tunnel)
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)
@@ -114,39 +172,40 @@ dependencies {
implementation(libs.hilt.android) implementation(libs.hilt.android)
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
//accompanist // accompanist
implementation(libs.accompanist.systemuicontroller)
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.navigation.animation)
implementation(libs.accompanist.drawablepainter) implementation(libs.accompanist.drawablepainter)
//room // storage
implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.runtime)
ksp(libs.androidx.room.compiler) ksp(libs.androidx.room.compiler)
implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.ktx)
implementation(libs.androidx.datastore.preferences)
//lifecycle // lifecycle
implementation(libs.lifecycle.runtime.compose) implementation(libs.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.lifecycle.process)
// icons
//icons
implementation(libs.material.icons.extended) implementation(libs.material.icons.extended)
//serialization // serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
//firebase crashlytics // firebase crashlytics
generalImplementation(platform(libs.firebase.bom)) generalImplementation(platform(libs.firebase.bom))
generalImplementation(libs.google.firebase.crashlytics.ktx) generalImplementation(libs.google.firebase.crashlytics.ktx)
generalImplementation(libs.google.firebase.analytics.ktx) generalImplementation(libs.google.firebase.analytics.ktx)
//barcode scanning // barcode scanning
implementation(libs.zxing.android.embedded) implementation(libs.zxing.android.embedded)
implementation(libs.zxing.core) implementation(libs.zxing.core)
//bio // bio
implementation(libs.androidx.biometric.ktx) implementation(libs.androidx.biometric.ktx)
} // shortcuts
implementation(libs.androidx.core)
implementation(libs.androidx.core.google.shortcuts)
}
+5
View File
@@ -0,0 +1,5 @@
-dontwarn com.google.errorprone.annotations.**
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
+6 -1
View File
@@ -18,4 +18,9 @@
# If you keep the line number information, uncomment this to # If you keep the line number information, uncomment this to
# hide the original source file name. # hide the original source file name.
#-renamesourcefileattribute SourceFile #-renamesourcefileattribute SourceFile
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>;
}
@@ -0,0 +1,112 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "ba86153e6fb0b823197b987239b03e64",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ba86153e6fb0b823197b987239b03e64')"
]
}
}
@@ -0,0 +1,126 @@
{
"formatVersion": 1,
"database": {
"version": 2,
"identityHash": "65b1c9efff61712231fa64d1f19f3915",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '65b1c9efff61712231fa64d1f19f3915')"
]
}
}
@@ -0,0 +1,133 @@
{
"formatVersion": 1,
"database": {
"version": 3,
"identityHash": "6b30daba29bb95f8ddc4d26206329d4f",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6b30daba29bb95f8ddc4d26206329d4f')"
]
}
}
@@ -0,0 +1,154 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "aee55639422df8dadfe74c3bad204477",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelEnabled",
"columnName": "is_kernel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aee55639422df8dadfe74c3bad204477')"
]
}
}
@@ -0,0 +1,161 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "bc15003a44746e18b9c260ec49737089",
"entities": [
{
"tableName": "Settings",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `is_tunnel_enabled` INTEGER NOT NULL, `is_tunnel_on_mobile_data_enabled` INTEGER NOT NULL, `trusted_network_ssids` TEXT NOT NULL, `default_tunnel` TEXT, `is_always_on_vpn_enabled` INTEGER NOT NULL, `is_tunnel_on_ethernet_enabled` INTEGER NOT NULL, `is_shortcuts_enabled` INTEGER NOT NULL DEFAULT false, `is_battery_saver_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_wifi_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_enabled` INTEGER NOT NULL DEFAULT false, `is_restore_on_boot_enabled` INTEGER NOT NULL DEFAULT false, `is_multi_tunnel_enabled` INTEGER NOT NULL DEFAULT false, `is_auto_tunnel_paused` INTEGER NOT NULL DEFAULT false)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isAutoTunnelEnabled",
"columnName": "is_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnMobileDataEnabled",
"columnName": "is_tunnel_on_mobile_data_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "trustedNetworkSSIDs",
"columnName": "trusted_network_ssids",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "defaultTunnel",
"columnName": "default_tunnel",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isAlwaysOnVpnEnabled",
"columnName": "is_always_on_vpn_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isTunnelOnEthernetEnabled",
"columnName": "is_tunnel_on_ethernet_enabled",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isShortcutsEnabled",
"columnName": "is_shortcuts_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isBatterySaverEnabled",
"columnName": "is_battery_saver_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnWifiEnabled",
"columnName": "is_tunnel_on_wifi_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelEnabled",
"columnName": "is_kernel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isRestoreOnBootEnabled",
"columnName": "is_restore_on_boot_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isMultiTunnelEnabled",
"columnName": "is_multi_tunnel_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isAutoTunnelPaused",
"columnName": "is_auto_tunnel_paused",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "TunnelConfig",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `wg_quick` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "wgQuick",
"columnName": "wg_quick",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_TunnelConfig_name",
"unique": true,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TunnelConfig_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bc15003a44746e18b9c260ec49737089')"
]
}
}
+4
View File
@@ -0,0 +1,4 @@
SIGNING_STORE_PASSWORD=
SIGNING_KEY_ALIAS=
SIGNING_KEY_PASSWORD=
KEY_STORE_PATH=/
@@ -1,13 +1,11 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.junit.Assert.*
/** /**
* Instrumented test, which will execute on an Android device. * Instrumented test, which will execute on an Android device.
* *
@@ -21,4 +19,4 @@ class ExampleInstrumentedTest {
val appContext = InstrumentationRegistry.getInstrumentation().targetContext val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.zaneschepke.wireguardautotunnel", appContext.packageName) assertEquals("com.zaneschepke.wireguardautotunnel", appContext.packageName)
} }
} }
@@ -0,0 +1,44 @@
package com.zaneschepke.wireguardautotunnel
import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.data.Queries
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import java.io.IOException
@RunWith(AndroidJUnit4::class)
class MigrationTest {
private val dbName = "migration-test"
@get:Rule
val helper: MigrationTestHelper =
MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java,
)
@Test
@Throws(IOException::class)
fun migrate4To5() {
helper.createDatabase(dbName, 4).apply {
// Database has schema version 1. Insert some data using SQL queries.
// You can't use DAO classes because they expect the latest schema.
execSQL(Queries.createDefaultSettings())
execSQL(
"INSERT INTO TunnelConfig (name, wg_quick)" + " VALUES ('hello', 'hello')",
)
// Prepare for the next version.
close()
}
// Re-open the database with version 2 and provide
// MIGRATION_1_2 as the migration process.
helper.runMigrationsAndValidate(dbName, 5, true)
// MigrationTestHelper automatically verifies the schema changes,
// but you need to validate that the data was migrated properly.
}
}
+71 -42
View File
@@ -1,68 +1,82 @@
<?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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/> <!--foreground service exempt android 14-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<!--foreground service permissions--> <!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<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: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"
android:theme="@style/Theme.WireguardAutoTunnel" android:theme="@style/Theme.WireguardAutoTunnel"
tools:targetApi="31"> tools:targetApi="tiramisu">
<activity <activity
android:name=".ui.MainActivity" android:name=".ui.MainActivity"
android:exported="true" android:exported="true"
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
@@ -72,59 +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:foregroundServiceType="remoteMessaging" android:exported="false"
android:exported="false"> android:foregroundServiceType="systemExempted|specialUse"
</service> tools:node="merge" />
<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:exported="false"
android:foregroundServiceType="systemExempted|specialUse"
android:permission="android.permission.BIND_VPN_SERVICE"
android:persistent="true" android:persistent="true"
android:foregroundServiceType="remoteMessaging" tools:node="merge">
android:exported="false">
<intent-filter> <intent-filter>
<action android:name="android.net.VpnService"/> <action android:name="android.net.VpnService" />
</intent-filter> </intent-filter>
<meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON" <meta-data
android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
android:value="true" /> android:value="true" />
</service> </service>
<service <service
android:name=".service.foreground.WireGuardConnectivityWatcherService" android:name=".service.foreground.WireGuardConnectivityWatcherService"
android:enabled="true" android:enabled="true"
android:stopWithTask="false" android:exported="false"
android:foregroundServiceType="systemExempted|specialUse"
android:persistent="true" android:persistent="true"
android:foregroundServiceType="location" android:stopWithTask="false"
android:permission="" tools:node="merge" />
<receiver
android:name=".receiver.BootReceiver"
android:enabled="true"
android:exported="false"> android:exported="false">
</service>
<receiver android:enabled="true" android:name=".receiver.BootReceiver"
android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/> <category android:name="android.intent.category.DEFAULT" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.ACTION_BOOT_COMPLETED" />
<action android:name="android.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

@@ -1,18 +0,0 @@
package com.zaneschepke.wireguardautotunnel
object Constants {
const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L
const val VPN_STATISTIC_CHECK_INTERVAL = 10000L
const val TOGGLE_TUNNEL_DELAY = 500L
const val FADE_IN_ANIMATION_DURATION = 1000
const val SLIDE_IN_ANIMATION_DURATION = 500
const val SLIDE_IN_TRANSITION_OFFSET = 1000
const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content"
const val URI_PACKAGE_SCHEME = "package"
const val ALLOWED_FILE_TYPES = "*/*"
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
}
@@ -1,24 +0,0 @@
package com.zaneschepke.wireguardautotunnel
import android.content.BroadcastReceiver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
fun BroadcastReceiver.goAsync(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
) {
val pendingResult = goAsync()
@OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback.
GlobalScope.launch(context) {
try {
block()
} finally {
pendingResult.finish()
}
}
}
@@ -1,48 +1,34 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import android.app.Application import android.app.Application
import android.content.Context import android.content.ComponentName
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.lifecycle.ProcessLifecycleOwner import android.service.quicksettings.TileService
import androidx.lifecycle.lifecycleScope import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class WireGuardAutoTunnel : Application() { class WireGuardAutoTunnel : Application() {
@Inject
lateinit var settingsRepo : SettingsDoa
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if(BuildConfig.DEBUG) { instance = this
Timber.plant(Timber.DebugTree()) if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree())
}
initSettings()
} }
private fun initSettings() {
with(ProcessLifecycleOwner.get()) {
lifecycleScope.launch {
if(settingsRepo.getAll().isEmpty()) {
settingsRepo.save(Settings())
}
}
}
}
companion object { companion object {
fun isRunningOnAndroidTv(context : Context) : Boolean { lateinit var instance: WireGuardAutoTunnel
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) private set
fun isRunningOnAndroidTv(): Boolean {
return instance.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)
}
fun requestTileServiceStateUpdate() {
TileService.requestListeningState(
instance,
ComponentName(instance, TunnelControlTile::class.java),
)
} }
} }
} }
@@ -0,0 +1,33 @@
package com.zaneschepke.wireguardautotunnel.data
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
@Database(
entities = [Settings::class, TunnelConfig::class],
version = 5,
autoMigrations =
[
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3),
AutoMigration(
from = 3,
to = 4,
),
AutoMigration(
from = 4,
to = 5,
),
],
exportSchema = true,
)
@TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun settingDao(): SettingsDao
abstract fun tunnelConfigDoa(): TunnelConfigDao
}
@@ -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,24 @@
package com.zaneschepke.wireguardautotunnel.data
import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
class DatabaseListConverters {
@TypeConverter
fun listToString(value: MutableList<String>): String {
return Json.encodeToString(value)
}
@TypeConverter
fun stringToList(value: String): MutableList<String> {
if (value.isEmpty()) return mutableListOf()
return try {
Json.decodeFromString<MutableList<String>>(value)
} catch (e: Exception) {
val list = value.split(",").toMutableList()
val json = listToString(list)
Json.decodeFromString<MutableList<String>>(json)
}
}
}
@@ -0,0 +1,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()
}
}
@@ -0,0 +1,28 @@
package com.zaneschepke.wireguardautotunnel.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import kotlinx.coroutines.flow.Flow
@Dao
interface SettingsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(t: Settings)
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveAll(t: List<Settings>)
@Query("SELECT * FROM settings WHERE id=:id") suspend fun getById(id: Long): Settings?
@Query("SELECT * FROM settings") suspend fun getAll(): List<Settings>
@Query("SELECT * FROM settings LIMIT 1") fun getSettingsFlow(): Flow<Settings>
@Query("SELECT * FROM settings") fun getAllFlow(): Flow<MutableList<Settings>>
@Delete suspend fun delete(t: Settings)
@Query("SELECT COUNT('id') FROM settings") suspend fun count(): Long
}
@@ -0,0 +1,26 @@
package com.zaneschepke.wireguardautotunnel.data
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import kotlinx.coroutines.flow.Flow
@Dao
interface TunnelConfigDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun save(t: TunnelConfig)
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun saveAll(t: List<TunnelConfig>)
@Query("SELECT * FROM TunnelConfig WHERE id=:id") suspend fun getById(id: Long): TunnelConfig?
@Query("SELECT * FROM TunnelConfig") suspend fun getAll(): List<TunnelConfig>
@Delete suspend fun delete(t: TunnelConfig)
@Query("SELECT COUNT('id') FROM TunnelConfig") suspend fun count(): Long
@Query("SELECT * FROM tunnelconfig") fun getAllFlow(): Flow<MutableList<TunnelConfig>>
}
@@ -0,0 +1,38 @@
package com.zaneschepke.wireguardautotunnel.data.datastore
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
class DataStoreManager(private val context: Context) {
companion object {
val LOCATION_DISCLOSURE_SHOWN = booleanPreferencesKey("LOCATION_DISCLOSURE_SHOWN")
val BATTERY_OPTIMIZE_DISABLE_SHOWN = booleanPreferencesKey("BATTERY_OPTIMIZE_DISABLE_SHOWN")
}
// preferences
private val preferencesKey = "preferences"
private val Context.dataStore by
preferencesDataStore(
name = preferencesKey,
)
suspend fun init() {
context.dataStore.data.first()
}
suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) =
context.dataStore.edit { it[key] = value }
fun <T> 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]
val preferencesFlow: Flow<Preferences?> = context.dataStore.data
}
@@ -0,0 +1,63 @@
package com.zaneschepke.wireguardautotunnel.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Settings(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled")
var isTunnelOnMobileDataEnabled: Boolean = false,
@ColumnInfo(name = "trusted_network_ssids")
var trustedNetworkSSIDs: MutableList<String> = mutableListOf(),
@ColumnInfo(name = "default_tunnel") var defaultTunnel: String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled")
var isTunnelOnEthernetEnabled: Boolean = false,
@ColumnInfo(
name = "is_shortcuts_enabled",
defaultValue = "false",
)
var isShortcutsEnabled: Boolean = false,
@ColumnInfo(
name = "is_battery_saver_enabled",
defaultValue = "false",
)
var isBatterySaverEnabled: Boolean = false,
@ColumnInfo(
name = "is_tunnel_on_wifi_enabled",
defaultValue = "false",
)
var isTunnelOnWifiEnabled: Boolean = false,
@ColumnInfo(
name = "is_kernel_enabled",
defaultValue = "false",
)
var isKernelEnabled: Boolean = false,
@ColumnInfo(
name = "is_restore_on_boot_enabled",
defaultValue = "false",
)
var isRestoreOnBootEnabled: Boolean = false,
@ColumnInfo(
name = "is_multi_tunnel_enabled",
defaultValue = "false",
)
var isMultiTunnelEnabled: Boolean = false,
@ColumnInfo(
name = "is_auto_tunnel_paused",
defaultValue = "false",
)
var isAutoTunnelPaused: Boolean = false,
) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean {
return if (defaultTunnel != null) {
val defaultConfig = TunnelConfig.from(defaultTunnel!!)
(tunnelConfig.id == defaultConfig.id)
} else {
false
}
}
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.repository.model package com.zaneschepke.wireguardautotunnel.data.model
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
@@ -12,24 +12,23 @@ import java.io.InputStream
@Entity(indices = [Index(value = ["name"], unique = true)]) @Entity(indices = [Index(value = ["name"], unique = true)])
@Serializable @Serializable
data class TunnelConfig( data class TunnelConfig(
@PrimaryKey(autoGenerate = true) val id : Int = 0, @PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "name") var name : String, @ColumnInfo(name = "name") var name: String,
@ColumnInfo(name = "wg_quick") var wgQuick : String, @ColumnInfo(name = "wg_quick") var wgQuick: String
){ ) {
override fun toString(): String { override fun toString(): String {
return Json.encodeToString(serializer(), this) return Json.encodeToString(serializer(), this)
} }
companion object { companion object {
fun from(string: String): TunnelConfig {
fun from(string : String) : TunnelConfig {
return Json.decodeFromString<TunnelConfig>(string) return Json.decodeFromString<TunnelConfig>(string)
} }
fun configFromQuick(wgQuick: String): Config { fun configFromQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream() val inputStream: InputStream = wgQuick.byteInputStream()
val reader = inputStream.bufferedReader(Charsets.UTF_8) val reader = inputStream.bufferedReader(Charsets.UTF_8)
return Config.parse(reader) return Config.parse(reader)
} }
} }
} }
@@ -0,0 +1,14 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import kotlinx.coroutines.flow.Flow
interface SettingsRepository {
suspend fun save(settings: Settings)
fun getSettingsFlow(): Flow<Settings>
suspend fun getSettings(): Settings
suspend fun getAll(): List<Settings>
}
@@ -0,0 +1,24 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import kotlinx.coroutines.flow.Flow
class SettingsRepositoryImpl(private val settingsDoa: SettingsDao) : SettingsRepository {
override suspend fun save(settings: Settings) {
settingsDoa.save(settings)
}
override fun getSettingsFlow(): Flow<Settings> {
return settingsDoa.getSettingsFlow()
}
override suspend fun getSettings(): Settings {
return settingsDoa.getAll().firstOrNull() ?: Settings()
}
override suspend fun getAll(): List<Settings> {
return settingsDoa.getAll()
}
}
@@ -0,0 +1,18 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
import kotlinx.coroutines.flow.Flow
interface TunnelConfigRepository {
fun getTunnelConfigsFlow(): Flow<TunnelConfigs>
suspend fun getAll(): TunnelConfigs
suspend fun save(tunnelConfig: TunnelConfig)
suspend fun delete(tunnelConfig: TunnelConfig)
suspend fun count(): Int
}
@@ -0,0 +1,29 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
import kotlinx.coroutines.flow.Flow
class TunnelConfigRepositoryImpl(private val tunnelConfigDao: TunnelConfigDao) :
TunnelConfigRepository {
override fun getTunnelConfigsFlow(): Flow<TunnelConfigs> {
return tunnelConfigDao.getAllFlow()
}
override suspend fun getAll(): TunnelConfigs {
return tunnelConfigDao.getAll()
}
override suspend fun save(tunnelConfig: TunnelConfig) {
tunnelConfigDao.save(tunnelConfig)
}
override suspend fun delete(tunnelConfig: TunnelConfig) {
tunnelConfigDao.delete(tunnelConfig)
}
override suspend fun count(): Int {
return tunnelConfigDao.count().toInt()
}
}
@@ -3,7 +3,8 @@ package com.zaneschepke.wireguardautotunnel.module
import android.content.Context 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.repository.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,11 +17,14 @@ import javax.inject.Singleton
class DatabaseModule { class DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideDatabase(@ApplicationContext context : Context) : AppDatabase { fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder( return Room.databaseBuilder(
context, context,
AppDatabase::class.java, context.getString(R.string.db_name)) AppDatabase::class.java,
context.getString(R.string.db_name),
)
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.addCallback(DatabaseCallback())
.build() .build()
} }
} }
@@ -0,0 +1,5 @@
package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class Kernel
@@ -1,27 +1,51 @@
package com.zaneschepke.wireguardautotunnel.module package com.zaneschepke.wireguardautotunnel.module
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase import android.content.Context
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepositoryImpl
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepositoryImpl
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class RepositoryModule { class RepositoryModule {
@Singleton @Singleton
@Provides @Provides
fun provideSettingsRepository(appDatabase: AppDatabase) : SettingsDoa { fun provideSettingsDoa(appDatabase: AppDatabase): SettingsDao {
return appDatabase.settingDao() return appDatabase.settingDao()
} }
@Singleton @Singleton
@Provides @Provides
fun provideTunnelConfigRepository(appDatabase: AppDatabase) : TunnelConfigDao { fun provideTunnelConfigDoa(appDatabase: AppDatabase): TunnelConfigDao {
return appDatabase.tunnelConfigDoa() return appDatabase.tunnelConfigDoa()
} }
}
@Singleton
@Provides
fun provideTunnelConfigRepository(tunnelConfigDao: TunnelConfigDao): TunnelConfigRepository {
return TunnelConfigRepositoryImpl(tunnelConfigDao)
}
@Singleton
@Provides
fun provideSettingsRepository(settingsDao: SettingsDao): SettingsRepository {
return SettingsRepositoryImpl(settingsDao)
}
@Singleton
@Provides
fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager {
return DataStoreManager(context)
}
}
@@ -15,20 +15,25 @@ import dagger.hilt.android.scopes.ServiceScoped
@Module @Module
@InstallIn(ServiceComponent::class) @InstallIn(ServiceComponent::class)
abstract class ServiceModule { abstract class ServiceModule {
@Binds
@ServiceScoped
abstract fun provideNotificationService(
wireGuardNotification: WireGuardNotification
): NotificationService
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification) : NotificationService abstract fun provideWifiService(wifiService: WifiService): NetworkService<WifiService>
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideWifiService(wifiService: WifiService) : NetworkService<WifiService> abstract fun provideMobileDataService(
mobileDataService: MobileDataService
): NetworkService<MobileDataService>
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideMobileDataService(mobileDataService : MobileDataService) : NetworkService<MobileDataService> abstract fun provideEthernetService(
ethernetService: EthernetService
@Binds ): NetworkService<EthernetService>
@ServiceScoped }
abstract fun provideEthernetService(ethernetService: EthernetService) : NetworkService<EthernetService>
}
@@ -3,6 +3,10 @@ package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import android.content.Context
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.GoBackend import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel import com.zaneschepke.wireguardautotunnel.service.tunnel.WireGuardTunnel
import dagger.Module import dagger.Module
@@ -15,16 +19,33 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class TunnelModule { class TunnelModule {
@Provides
@Singleton
fun provideRootShell(@ApplicationContext context: Context): RootShell {
return RootShell(context)
}
@Provides @Provides
@Singleton @Singleton
fun provideBackend(@ApplicationContext context : Context) : Backend { @Userspace
fun provideUserspaceBackend(@ApplicationContext context: Context): Backend {
return GoBackend(context) return GoBackend(context)
} }
@Provides @Provides
@Singleton @Singleton
fun provideVpnService(backend: Backend) : VpnService { @Kernel
return WireGuardTunnel(backend) fun provideKernelBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell))
} }
}
@Provides
@Singleton
fun provideVpnService(
@Userspace userspaceBackend: Backend,
@Kernel kernelBackend: Backend,
settingsRepository: SettingsRepository
): VpnService {
return WireGuardTunnel(userspaceBackend, kernelBackend, settingsRepository)
}
}
@@ -0,0 +1,5 @@
package com.zaneschepke.wireguardautotunnel.module
import javax.inject.Qualifier
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class Userspace
@@ -3,32 +3,30 @@ package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.goAsync import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa 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 dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.cancel 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 settingsRepo : SettingsDoa
override fun onReceive(context: Context, intent: Intent) = goAsync { @Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
if (intent.action == Intent.ACTION_BOOT_COMPLETED) {
try { override fun onReceive(context: Context?, intent: Intent?) = goAsync {
val settings = settingsRepo.getAll() if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return@goAsync
if (settings.isNotEmpty()) { val settings = settingsRepository.getSettings()
val setting = settings.first() if (settings.isAutoTunnelEnabled) {
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) { Timber.i("Starting watcher service from boot")
ServiceManager.startWatcherService(context, setting.defaultTunnel!!) ServiceManager.startWatcherServiceForeground(context!!)
} } else if(settings.isAlwaysOnVpnEnabled) {
} Timber.i("Starting tunnel from boot")
} finally { ServiceManager.startVpnServicePrimaryTunnel(context!!, settings, tunnelConfigRepository.getAll().firstOrNull())
cancel()
}
} }
} }
} }
@@ -3,10 +3,10 @@ package com.zaneschepke.wireguardautotunnel.receiver
import android.content.BroadcastReceiver import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.goAsync
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.goAsync
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -14,22 +14,18 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() { class NotificationActionReceiver : BroadcastReceiver() {
@Inject lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var settingsRepo : SettingsDoa
override fun onReceive(context: Context, intent: Intent?) = goAsync { override fun onReceive(context: Context, intent: Intent?) = goAsync {
try { try {
val settings = settingsRepo.getAll() val settings = settingsRepository.getSettings()
if (settings.isNotEmpty()) { if (settings.defaultTunnel != null) {
val setting = settings.first() ServiceManager.stopVpnService(context)
if (setting.defaultTunnel != null) { delay(Constants.TOGGLE_TUNNEL_DELAY)
ServiceManager.stopVpnService(context) ServiceManager.startVpnServiceForeground(context, settings.defaultTunnel.toString())
delay(Constants.TOGGLE_TUNNEL_DELAY)
ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
}
} }
} finally { } finally {
cancel() cancel()
} }
} }
} }
@@ -1,14 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
@Database(entities = [Settings::class, TunnelConfig::class], version = 1, exportSchema = false)
@TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun settingDao(): SettingsDoa
abstract fun tunnelConfigDoa() : TunnelConfigDao
}
@@ -1,15 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.TypeConverter
class DatabaseListConverters {
@TypeConverter
fun listToString(value: MutableList<String>): String {
return value.joinToString(",")
}
@TypeConverter
fun <T> stringToList(value: String): MutableList<String> {
if(value.isEmpty()) return mutableListOf()
return value.split(",").toMutableList()
}
}
@@ -1,34 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import kotlinx.coroutines.flow.Flow
@Dao
interface SettingsDoa {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: Settings)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveAll(t: List<Settings>)
@Query("SELECT * FROM settings WHERE id=:id")
suspend fun getById(id: Long): Settings?
@Query("SELECT * FROM settings")
suspend fun getAll(): List<Settings>
@Query("SELECT * FROM settings")
fun getAllFlow(): Flow<MutableList<Settings>>
@Delete
suspend fun delete(t: Settings)
@Query("SELECT COUNT('id') FROM settings")
suspend fun count(): Long
}
@@ -1,34 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import kotlinx.coroutines.flow.Flow
@Dao
interface TunnelConfigDao{
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: TunnelConfig)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun saveAll(t: List<TunnelConfig>)
@Query("SELECT * FROM TunnelConfig WHERE id=:id")
suspend fun getById(id: Long): TunnelConfig?
@Query("SELECT * FROM TunnelConfig")
suspend fun getAll(): List<TunnelConfig>
@Delete
suspend fun delete(t: TunnelConfig)
@Query("SELECT COUNT('id') FROM TunnelConfig")
suspend fun count(): Long
@Query("SELECT * FROM tunnelconfig")
fun getAllFlow(): Flow<MutableList<TunnelConfig>>
}
@@ -1,25 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Settings(
@PrimaryKey(autoGenerate = true) val id : Int = 0,
@ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled : Boolean = false,
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") var isTunnelOnMobileDataEnabled : Boolean = false,
@ColumnInfo(name = "trusted_network_ssids") var trustedNetworkSSIDs : MutableList<String> = mutableListOf(),
@ColumnInfo(name = "default_tunnel") var defaultTunnel : String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled : Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled : Boolean = false,
) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig) : Boolean {
return if (defaultTunnel != null) {
val defaultConfig = TunnelConfig.from(defaultTunnel!!)
(tunnelConfig.id == defaultConfig.id)
} else {
false
}
}
}
@@ -4,4 +4,4 @@ enum class Action {
START, START,
START_FOREGROUND, START_FOREGROUND,
STOP STOP
} }
@@ -6,9 +6,7 @@ import android.os.IBinder
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import timber.log.Timber import timber.log.Timber
open class ForegroundService : LifecycleService() { open class ForegroundService : LifecycleService() {
private var isServiceStarted = false private var isServiceStarted = false
override fun onBind(intent: Intent): IBinder? { override fun onBind(intent: Intent): IBinder? {
@@ -22,9 +20,9 @@ open class ForegroundService : LifecycleService() {
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")
@@ -34,26 +32,25 @@ 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
return START_STICKY return START_STICKY
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
Timber.d("The service has been destroyed") Timber.d("The service has been destroyed")
} }
protected open fun startService(extras : Bundle?) { protected open fun startService(extras: Bundle?) {
if (isServiceStarted) return if (isServiceStarted) return
Timber.d("Starting ${this.javaClass.simpleName}") Timber.d("Starting ${this.javaClass.simpleName}")
isServiceStarted = true isServiceStarted = true
} }
protected open fun stopService(extras : Bundle?) { protected open fun stopService(extras: Bundle?) {
Timber.d("Stopping ${this.javaClass.simpleName}") Timber.d("Stopping ${this.javaClass.simpleName}")
try { try {
stopForeground(STOP_FOREGROUND_REMOVE) stopForeground(STOP_FOREGROUND_REMOVE)
@@ -63,4 +60,4 @@ open class ForegroundService : LifecycleService() {
} }
isServiceStarted = false isServiceStarted = false
} }
} }
@@ -1,38 +1,44 @@
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(context: Context, cls : Class<T>): ServiceState { // private
val isServiceRunning = context.isServiceRunning(cls) // fun <T> Context.isServiceRunning(service: Class<T>) =
return if(isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED // (getSystemService(ACTIVITY_SERVICE) as ActivityManager)
} // .runningAppProcesses.any {
// it.processName == service.name
// }
//
// fun <T : Service> getServiceState(
// context: Context,
// cls: Class<T>
// ): ServiceState {
// val isServiceRunning = context.isServiceRunning(cls)
// return if (isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED
// }
private fun <T : Service> actionOnService(action: Action, context: Context, cls : Class<T>, extras : Map<String,String>? = null) { private fun <T : Service> actionOnService(
if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return action: Action,
if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return context: Context,
val intent = Intent(context, cls).also { cls: Class<T>,
it.action = action.name extras: Map<String, String>? = null
extras?.forEach {(k, v) -> ) {
it.putExtra(k, v) val intent =
Intent(context, cls).also {
it.action = action.name
extras?.forEach { (k, v) -> it.putExtra(k, v) }
} }
}
intent.component?.javaClass intent.component?.javaClass
try { try {
when(action) { when (action) {
Action.START_FOREGROUND -> { Action.START_FOREGROUND -> {
context.startForegroundService(intent) context.startForegroundService(intent)
} }
@@ -41,69 +47,70 @@ object ServiceManager {
} }
Action.STOP -> context.startService(intent) Action.STOP -> context.startService(intent)
} }
} catch (e : Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
} }
} }
fun startVpnService(context : Context, tunnelConfig : String) { fun startVpnService(context: Context, tunnelConfig: String) {
actionOnService( actionOnService(
Action.START, Action.START,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig),
}
fun stopVpnService(context : Context) {
actionOnService(
Action.STOP,
context,
WireGuardTunnelService::class.java
) )
} }
fun startVpnServiceForeground(context : Context, tunnelConfig : String) { fun stopVpnService(context: Context) {
Timber.d("Stopping vpn service action")
actionOnService(
Action.STOP,
context,
WireGuardTunnelService::class.java,
)
}
fun startVpnServiceForeground(context: Context, tunnelConfig: String) {
actionOnService( actionOnService(
Action.START_FOREGROUND, Action.START_FOREGROUND,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig)) mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig),
)
} }
private fun startWatcherServiceForeground(context : Context, tunnelConfig : String) { fun startVpnServicePrimaryTunnel(context: Context, settings: Settings, fallbackTunnel: TunnelConfig? = null) {
actionOnService( if(settings.defaultTunnel != null) {
Action.START, context, return startVpnServiceForeground(context, settings.defaultTunnel!!)
WireGuardConnectivityWatcherService::class.java, mapOf(context. }
getString(R.string.tunnel_extras_key) to if(fallbackTunnel != null) {
tunnelConfig)) startVpnServiceForeground(context, fallbackTunnel.toString())
}
fun startWatcherService(context : Context, tunnelConfig : String) {
actionOnService(
Action.START, context,
WireGuardConnectivityWatcherService::class.java, mapOf(context.
getString(R.string.tunnel_extras_key) to
tunnelConfig))
}
fun stopWatcherService(context : Context) {
actionOnService(
Action.STOP, context,
WireGuardConnectivityWatcherService::class.java)
}
fun toggleWatcherService(context: Context, tunnelConfig : String) {
when(getServiceState( context,
WireGuardConnectivityWatcherService::class.java,)) {
ServiceState.STARTED -> stopWatcherService(context)
ServiceState.STOPPED -> startWatcherService(context, tunnelConfig)
} }
} }
fun toggleWatcherServiceForeground(context: Context, tunnelConfig : String) { fun startWatcherServiceForeground(
when(getServiceState( context, context: Context,
WireGuardConnectivityWatcherService::class.java,)) { ) {
ServiceState.STARTED -> stopWatcherService(context) actionOnService(
ServiceState.STOPPED -> startWatcherServiceForeground(context, tunnelConfig) Action.START_FOREGROUND,
} context,
WireGuardConnectivityWatcherService::class.java,
)
} }
}
fun startWatcherService(context: Context) {
actionOnService(
Action.START,
context,
WireGuardConnectivityWatcherService::class.java,
)
}
fun stopWatcherService(context: Context) {
actionOnService(
Action.STOP,
context,
WireGuardConnectivityWatcherService::class.java,
)
}
}
@@ -3,4 +3,4 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
enum class ServiceState { enum class ServiceState {
STARTED, STARTED,
STOPPED, STOPPED,
} }
@@ -7,12 +7,12 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
import android.os.SystemClock import android.os.SystemClock
import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.service.network.EthernetService import com.zaneschepke.wireguardautotunnel.service.network.EthernetService
import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService import com.zaneschepke.wireguardautotunnel.service.network.MobileDataService
import com.zaneschepke.wireguardautotunnel.service.network.NetworkService import com.zaneschepke.wireguardautotunnel.service.network.NetworkService
@@ -20,72 +20,72 @@ import com.zaneschepke.wireguardautotunnel.service.network.NetworkStatus
import com.zaneschepke.wireguardautotunnel.service.network.WifiService import com.zaneschepke.wireguardautotunnel.service.network.WifiService
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.util.Constants
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardConnectivityWatcherService : ForegroundService() { class WireGuardConnectivityWatcherService : ForegroundService() {
private val foregroundId = 122
private val foregroundId = 122; @Inject lateinit var wifiService: NetworkService<WifiService>
@Inject @Inject lateinit var mobileDataService: NetworkService<MobileDataService>
lateinit var wifiService : NetworkService<WifiService>
@Inject @Inject lateinit var ethernetService: NetworkService<EthernetService>
lateinit var mobileDataService : NetworkService<MobileDataService>
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var ethernetService: NetworkService<EthernetService>
@Inject @Inject lateinit var notificationService: NotificationService
lateinit var settingsRepo: SettingsDoa
@Inject @Inject lateinit var vpnService: VpnService
lateinit var notificationService : NotificationService
@Inject private val networkEventsFlow = MutableStateFlow(WatcherState())
lateinit var vpnService : VpnService
private var isWifiConnected = false; data class WatcherState(
private var isEthernetConnected = false; val isWifiConnected: Boolean = false,
private var isMobileDataConnected = false; val isVpnConnected: Boolean = false,
private var currentNetworkSSID = ""; val isEthernetConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "",
val settings: Settings = Settings()
)
private lateinit var watcherJob : Job; private lateinit var watcherJob: Job
private lateinit var setting : Settings
private lateinit var tunnelConfig: String
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private val tag = this.javaClass.name; private val tag = this.javaClass.name
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
launchWatcherNotification() try {
if (settingsRepository.getSettings().isAutoTunnelPaused) {
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)
launchWatcherNotification() try {
val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key)) // we need this lock so our service gets not affected by Doze Mode
if (tunnelId != null) { lifecycleScope.launch { initWakeLock() }
this.tunnelConfig = tunnelId cancelWatcherJob()
}
// we need this lock so our service gets not affected by Doze Mode
initWakeLock()
cancelWatcherJob()
if(this::tunnelConfig.isInitialized) {
startWatcherJob() startWatcherJob()
} else { } catch (e: Exception) {
stopService(extras) Timber.e("Failed to launch watcher service, no permissions")
} }
} }
@@ -100,100 +100,197 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
stopSelf() stopSelf()
} }
private fun launchWatcherNotification() { private fun launchWatcherNotification(
val notification = notificationService.createNotification( description: String = getString(R.string.watcher_notification_text_active)
channelId = getString(R.string.watcher_channel_id), ) {
channelName = getString(R.string.watcher_channel_name), val notification =
description = getString(R.string.watcher_notification_text)) notificationService.createNotification(
super.startForeground(foregroundId, notification) channelId = getString(R.string.watcher_channel_id),
channelName = getString(R.string.watcher_channel_name),
title = getString(R.string.auto_tunnel_title),
description = description,
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
)
} }
//try to start task again if killed private fun launchWatcherPausedNotification() {
launchWatcherNotification(getString(R.string.watcher_notification_text_paused))
}
// TODO could this be restarting service in a bad state?
// try to start task again if killed
override fun onTaskRemoved(rootIntent: Intent) { override fun onTaskRemoved(rootIntent: Intent) {
Timber.d("Task Removed called") Timber.d("Task Removed called")
val restartServiceIntent = Intent(rootIntent) val restartServiceIntent = Intent(rootIntent)
val restartServicePendingIntent: PendingIntent = PendingIntent.getService(this, 1, restartServiceIntent, val restartServicePendingIntent: PendingIntent =
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE); PendingIntent.getService(
applicationContext.getSystemService(Context.ALARM_SERVICE); this,
val alarmService: AlarmManager = applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager; 1,
alarmService.set(AlarmManager.ELAPSED_REALTIME, SystemClock.elapsedRealtime() + 1000, restartServicePendingIntent); restartServiceIntent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE,
)
applicationContext.getSystemService(Context.ALARM_SERVICE)
val alarmService: AlarmManager =
applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmService.set(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + 1000,
restartServicePendingIntent,
)
} }
private fun initWakeLock() { private suspend fun initWakeLock() {
val isBatterySaverOn =
withContext(lifecycleScope.coroutineContext) {
settingsRepository.getSettings().isBatterySaverEnabled
}
wakeLock = wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run { (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
//TODO decide what to do here with the wakelock try {
//this is draining battery. Perhaps users only care for VPN to connect when their screen is on if (isBatterySaverOn) {
//and they are actively using apps Timber.d("Initiating wakelock with timeout")
acquire() 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()
}
} }
} }
} }
private fun cancelWatcherJob() { private fun cancelWatcherJob() {
if(this::watcherJob.isInitialized) { if (this::watcherJob.isInitialized) {
watcherJob.cancel() watcherJob.cancel()
} }
} }
private fun startWatcherJob() { private fun startWatcherJob() {
watcherJob = lifecycleScope.launch(Dispatchers.IO) { watcherJob =
val settings = settingsRepo.getAll(); lifecycleScope.launch(Dispatchers.IO) {
if(settings.isNotEmpty()) { val setting = settingsRepository.getSettings()
setting = settings[0]
}
launch {
watchForWifiConnectivityChanges()
}
if(setting.isTunnelOnMobileDataEnabled) {
launch { launch {
watchForMobileDataConnectivityChanges() 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()
} }
} }
if(setting.isTunnelOnEthernetEnabled) {
launch {
watchForEthernetConnectivityChanges()
}
}
launch {
manageVpn()
}
}
} }
private suspend fun watchForMobileDataConnectivityChanges() { private suspend fun watchForMobileDataConnectivityChanges() {
mobileDataService.networkStatus.collect { mobileDataService.networkStatus.collect {
when(it) { when (it) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.d("Gained Mobile data connection") Timber.d("Gained Mobile data connection")
isMobileDataConnected = true networkEventsFlow.value =
networkEventsFlow.value.copy(
isMobileDataConnected = true,
)
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
isMobileDataConnected = true networkEventsFlow.value =
networkEventsFlow.value.copy(
isMobileDataConnected = true,
)
Timber.d("Mobile data capabilities changed") Timber.d("Mobile data capabilities changed")
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isMobileDataConnected = false networkEventsFlow.value =
networkEventsFlow.value.copy(
isMobileDataConnected = false,
)
Timber.d("Lost mobile data connection") Timber.d("Lost mobile data connection")
} }
} }
} }
} }
private suspend fun watchForSettingsChanges() {
settingsRepository.getSettingsFlow().collect {
if (networkEventsFlow.value.settings.isAutoTunnelPaused != it.isAutoTunnelPaused) {
when (it.isAutoTunnelPaused) {
true -> launchWatcherPausedNotification()
false -> launchWatcherNotification()
}
}
networkEventsFlow.value =
networkEventsFlow.value.copy(
settings = it,
)
}
}
private suspend fun watchForVpnConnectivityChanges() {
vpnService.vpnState.collect {
when (it.status) {
Tunnel.State.DOWN ->
networkEventsFlow.value =
networkEventsFlow.value.copy(
isVpnConnected = false,
)
Tunnel.State.UP ->
networkEventsFlow.value =
networkEventsFlow.value.copy(
isVpnConnected = true,
)
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")
isEthernetConnected = true networkEventsFlow.value =
networkEventsFlow.value.copy(
isEthernetConnected = true,
)
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Ethernet capabilities changed") Timber.d("Ethernet capabilities changed")
isEthernetConnected = true networkEventsFlow.value =
networkEventsFlow.value.copy(
isEthernetConnected = true,
)
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isEthernetConnected = false networkEventsFlow.value =
networkEventsFlow.value.copy(
isEthernetConnected = false,
)
Timber.d("Lost Ethernet connection") Timber.d("Lost Ethernet connection")
} }
} }
@@ -202,48 +299,100 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
private suspend fun watchForWifiConnectivityChanges() { private suspend fun watchForWifiConnectivityChanges() {
wifiService.networkStatus.collect { wifiService.networkStatus.collect {
when (it) { when (it) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.d("Gained Wi-Fi connection") Timber.d("Gained Wi-Fi connection")
isWifiConnected = true 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.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 1 met")
} }
is NetworkStatus.CapabilitiesChanged -> { (!it.isEthernetConnected &&
Timber.d("Wifi capabilities changed") it.settings.isTunnelOnMobileDataEnabled &&
isWifiConnected = true !it.isWifiConnected &&
currentNetworkSSID = wifiService.getNetworkName(it.networkCapabilities) ?: ""; it.isMobileDataConnected &&
!it.isVpnConnected) -> {
ServiceManager.startVpnServiceForeground(this, it.settings.defaultTunnel!!)
Timber.i("Condition 2 met")
} }
is NetworkStatus.Unavailable -> { (!it.isEthernetConnected &&
isWifiConnected = false !it.settings.isTunnelOnMobileDataEnabled &&
Timber.d("Lost Wi-Fi connection") !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")
} }
} }
} }
} }
private suspend fun manageVpn() {
while(true) {
if(isEthernetConnected && setting.isTunnelOnEthernetEnabled && vpnService.getState() == Tunnel.State.DOWN) {
ServiceManager.startVpnService(this, tunnelConfig)
}
if(!isEthernetConnected && setting.isTunnelOnMobileDataEnabled &&
!isWifiConnected &&
isMobileDataConnected
&& vpnService.getState() == Tunnel.State.DOWN) {
ServiceManager.startVpnService(this, tunnelConfig)
} else if(!isEthernetConnected && !setting.isTunnelOnMobileDataEnabled &&
!isWifiConnected &&
vpnService.getState() == Tunnel.State.UP) {
ServiceManager.stopVpnService(this)
} else if(!isEthernetConnected && isWifiConnected &&
!setting.trustedNetworkSSIDs.contains(currentNetworkSSID) &&
(vpnService.getState() != Tunnel.State.UP)) {
ServiceManager.startVpnService(this, tunnelConfig)
} else if(!isEthernetConnected && (isWifiConnected &&
setting.trustedNetworkSSIDs.contains(currentNetworkSSID)) &&
(vpnService.getState() == Tunnel.State.UP)) {
ServiceManager.stopVpnService(this)
}
delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL)
}
} }
} }
@@ -3,162 +3,181 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.mapPeerStats
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
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 WireGuardTunnelService : ForegroundService() { class WireGuardTunnelService : ForegroundService() {
private val foregroundId = 123
private val foregroundId = 123; @Inject lateinit var vpnService: VpnService
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var vpnService : VpnService
@Inject @Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
lateinit var settingsRepo: SettingsDoa
@Inject @Inject lateinit var notificationService: NotificationService
lateinit var notificationService : NotificationService
private lateinit var job : Job private lateinit var job: Job
private var tunnelName : String = "" private var tunnelName: String = ""
private var didShowConnected = false
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
launchVpnStartingNotification() if (tunnelConfigRepository.getAll().isNotEmpty()) {
launchVpnNotification()
}
} }
} }
override fun startService(extras : Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
launchVpnStartingNotification()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
cancelJob() cancelJob()
job = lifecycleScope.launch(Dispatchers.IO) { val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
launch { val tunnelConfig = tunnelConfigString?.let { TunnelConfig.from(it) }
if(tunnelConfigString != null) { tunnelName = tunnelConfig?.name ?: ""
try { job =
val tunnelConfig = TunnelConfig.from(tunnelConfigString) lifecycleScope.launch(Dispatchers.IO) {
tunnelName = tunnelConfig.name launch {
vpnService.startTunnel(tunnelConfig) if (tunnelConfig != null) {
} catch (e : Exception) { try {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
}
} else {
Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll();
if(settings.isNotEmpty()) {
val setting = settings[0]
if(setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
tunnelName = tunnelConfig.name tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig) vpnService.startTunnel(tunnelConfig)
} catch (e: Exception) {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
} }
} } else {
} Timber.d("Tunnel config null, starting default tunnel or first")
} val settings = settingsRepository.getSettings()
launch { val tunnels = tunnelConfigRepository.getAll()
var didShowConnected = false if (settings.isAlwaysOnVpnEnabled) {
var didShowFailedHandshakeNotification = false val tunnel =
vpnService.handshakeStatus.collect { if (settings.defaultTunnel != null) {
when(it) { TunnelConfig.from(settings.defaultTunnel!!)
HandshakeStatus.NOT_STARTED -> { } else if (tunnels.isNotEmpty()) {
} tunnels.first()
HandshakeStatus.NEVER_CONNECTED -> { } else {
if(!didShowFailedHandshakeNotification) { null
launchVpnConnectionFailedNotification(getString(R.string.initial_connection_failure_message)) }
didShowFailedHandshakeNotification = true if (tunnel != null) {
didShowConnected = false tunnelName = tunnel.name
} vpnService.startTunnel(tunnel)
}
HandshakeStatus.HEALTHY -> {
if(!didShowConnected) {
launchVpnConnectedNotification()
didShowConnected = true
}
}
HandshakeStatus.UNHEALTHY -> {
if(!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(getString(R.string.lost_connection_failure_message))
didShowFailedHandshakeNotification = true
didShowConnected = false
} }
} }
} }
} }
// TODO add failed to connect notification
launch {
vpnService.vpnState.collect { state ->
state.statistics
?.mapPeerStats()
?.map { it.value?.handshakeStatus() }
.let { statuses ->
when {
statuses?.all { it == HandshakeStatus.HEALTHY } == true -> {
if (!didShowConnected) {
delay(Constants.VPN_CONNECTED_NOTIFICATION_DELAY)
launchVpnNotification(
getString(R.string.tunnel_start_title),
"${getString(R.string.tunnel_start_text)} $tunnelName",
)
didShowConnected = true
}
}
statuses?.any { it == HandshakeStatus.STALE } == true -> {}
statuses?.all { it == HandshakeStatus.NOT_STARTED } ==
true -> {}
else -> {}
}
}
}
}
} }
}
} }
override fun stopService(extras : Bundle?) { override fun stopService(extras: Bundle?) {
super.stopService(extras) super.stopService(extras)
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
vpnService.stopTunnel() vpnService.stopTunnel()
didShowConnected = false
} }
cancelJob() cancelJob()
stopSelf() stopSelf()
} }
private fun launchVpnConnectedNotification() { private fun launchVpnNotification(
val notification = notificationService.createNotification( title: String = getString(R.string.vpn_starting),
channelId = getString(R.string.vpn_channel_id), description: String = getString(R.string.attempt_connection)
channelName = getString(R.string.vpn_channel_name), ) {
title = getString(R.string.tunnel_start_title), val notification =
onGoing = false, notificationService.createNotification(
showTimestamp = true, channelId = getString(R.string.vpn_channel_id),
description = "${getString(R.string.tunnel_start_text)} $tunnelName" channelName = getString(R.string.vpn_channel_name),
title = title,
onGoing = false,
vibration = false,
showTimestamp = true,
description = description,
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
) )
super.startForeground(foregroundId, notification)
} }
private fun launchVpnStartingNotification() { private fun launchVpnConnectionFailedNotification(message: String) {
val notification = notificationService.createNotification( val notification =
channelId = getString(R.string.vpn_channel_id), notificationService.createNotification(
channelName = getString(R.string.vpn_channel_name), channelId = getString(R.string.vpn_channel_id),
title = getString(R.string.vpn_starting), channelName = getString(R.string.vpn_channel_name),
onGoing = false, action =
showTimestamp = true, PendingIntent.getBroadcast(
description = getString(R.string.attempt_connection) this,
0,
Intent(this, NotificationActionReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE,
),
actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed),
onGoing = false,
vibration = true,
showTimestamp = true,
description = message,
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
) )
super.startForeground(foregroundId, notification)
} }
private fun launchVpnConnectionFailedNotification(message : String) {
val notification = notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
action = PendingIntent.getBroadcast(this,0,
Intent(this, NotificationActionReceiver::class.java),PendingIntent.FLAG_IMMUTABLE),
actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed),
onGoing = false,
showTimestamp = true,
description = message
)
super.startForeground(foregroundId, notification)
}
private fun cancelJob() { private fun cancelJob() {
if(this::job.isInitialized) { if (this::job.isInitialized) {
job.cancel() job.cancel()
} }
} }
} }
@@ -14,8 +14,10 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
abstract class BaseNetworkService<T : BaseNetworkService<T>>(
abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Context, networkCapability : Int) : NetworkService<T> { val context: Context,
networkCapability: Int
) : NetworkService<T> {
private val connectivityManager = private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@@ -23,61 +25,69 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
override val networkStatus = callbackFlow { override val networkStatus = callbackFlow {
val networkStatusCallback = when (Build.VERSION.SDK_INT) { val networkStatusCallback =
in Build.VERSION_CODES.S..Int.MAX_VALUE -> { when (Build.VERSION.SDK_INT) {
object : ConnectivityManager.NetworkCallback( in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
FLAG_INCLUDE_LOCATION_INFO object :
) { ConnectivityManager.NetworkCallback(
override fun onAvailable(network: Network) { FLAG_INCLUDE_LOCATION_INFO,
trySend(NetworkStatus.Available(network)) ) {
} override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) { override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network)) trySend(NetworkStatus.Unavailable(network))
} }
override fun onCapabilitiesChanged( override fun onCapabilitiesChanged(
network: Network, network: Network,
networkCapabilities: NetworkCapabilities networkCapabilities: NetworkCapabilities
) { ) {
trySend(NetworkStatus.CapabilitiesChanged(network, networkCapabilities)) trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities,
),
)
}
}
}
else -> {
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network))
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities,
),
)
}
} }
} }
} }
val request =
else -> { NetworkRequest.Builder()
object : ConnectivityManager.NetworkCallback() { .addTransportType(networkCapability)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
override fun onAvailable(network: Network) { .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
trySend(NetworkStatus.Available(network)) .build()
}
override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network))
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(NetworkStatus.CapabilitiesChanged(network, networkCapabilities))
}
}
}
}
val request = NetworkRequest.Builder()
.addTransportType(networkCapability)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback) connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) }
connectivityManager.unregisterNetworkCallback(networkStatusCallback)
}
} }
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? { override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid: String? = getWifiNameFromCapabilities(networkCapabilities) var ssid: String? = getWifiNameFromCapabilities(networkCapabilities)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
@@ -89,7 +99,6 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
return ssid?.trim('"') return ssid?.trim('"')
} }
companion object { companion object {
private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? { private fun getWifiNameFromCapabilities(networkCapabilities: NetworkCapabilities): String? {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
@@ -105,13 +114,18 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(val context: Contex
} }
inline fun <Result> Flow<NetworkStatus>.map( inline fun <Result> Flow<NetworkStatus>.map(
crossinline onUnavailable: suspend (network : Network) -> Result, crossinline onUnavailable: suspend (network: Network) -> Result,
crossinline onAvailable: suspend (network : Network) -> Result, crossinline onAvailable: suspend (network: Network) -> Result,
crossinline onCapabilitiesChanged: suspend (network : Network, networkCapabilities : NetworkCapabilities) -> Result, crossinline onCapabilitiesChanged:
suspend (network: Network, networkCapabilities: NetworkCapabilities) -> Result
): Flow<Result> = map { status -> ): Flow<Result> = map { status ->
when (status) { when (status) {
is NetworkStatus.Unavailable -> onUnavailable(status.network) is NetworkStatus.Unavailable -> onUnavailable(status.network)
is NetworkStatus.Available -> onAvailable(status.network) is NetworkStatus.Available -> onAvailable(status.network)
is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(status.network, status.networkCapabilities) is NetworkStatus.CapabilitiesChanged ->
onCapabilitiesChanged(
status.network,
status.networkCapabilities,
)
} }
} }
@@ -6,5 +6,4 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class EthernetService @Inject constructor(@ApplicationContext context: Context) : class EthernetService @Inject constructor(@ApplicationContext context: Context) :
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET) { BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET)
}
@@ -6,5 +6,4 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class MobileDataService @Inject constructor(@ApplicationContext context: Context) : class MobileDataService @Inject constructor(@ApplicationContext context: Context) :
BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR) { BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR)
}
@@ -4,7 +4,7 @@ import android.net.NetworkCapabilities
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface NetworkService<T> { interface NetworkService<T> {
fun getNetworkName(networkCapabilities: NetworkCapabilities) : String? fun getNetworkName(networkCapabilities: NetworkCapabilities): String?
val networkStatus : Flow<NetworkStatus>
} val networkStatus: Flow<NetworkStatus>
}
@@ -4,7 +4,10 @@ import android.net.Network
import android.net.NetworkCapabilities import android.net.NetworkCapabilities
sealed class NetworkStatus { sealed class NetworkStatus {
class Available(val network : Network) : NetworkStatus() class Available(val network: Network) : NetworkStatus()
class Unavailable(val network : Network) : NetworkStatus()
class CapabilitiesChanged(val network : Network, val networkCapabilities : NetworkCapabilities) : NetworkStatus() class Unavailable(val network: Network) : NetworkStatus()
class CapabilitiesChanged(val network: Network, val networkCapabilities: NetworkCapabilities) :
NetworkStatus()
} }
@@ -6,5 +6,4 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WifiService @Inject constructor(@ApplicationContext context: Context) : class WifiService @Inject constructor(@ApplicationContext context: Context) :
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI) { BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI)
}
@@ -12,10 +12,11 @@ interface NotificationService {
action: PendingIntent? = null, action: PendingIntent? = null,
actionText: String? = null, actionText: String? = null,
description: String, description: String,
showTimestamp : Boolean = false, showTimestamp: Boolean = false,
importance: Int = NotificationManager.IMPORTANCE_HIGH, importance: Int = NotificationManager.IMPORTANCE_HIGH,
vibration: Boolean = true, vibration: Boolean = false,
onGoing: Boolean = true, onGoing: Boolean = true,
lights: Boolean = true lights: Boolean = true,
onlyAlertOnce: Boolean = true,
): Notification ): Notification
} }
@@ -7,14 +7,27 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import androidx.core.app.NotificationCompat
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.MainActivity import com.zaneschepke.wireguardautotunnel.ui.MainActivity
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) : NotificationService { class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) :
NotificationService {
private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager; private val watcherBuilder: NotificationCompat.Builder =
NotificationCompat.Builder(
context,
context.getString(R.string.watcher_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,
@@ -27,20 +40,23 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
importance: Int, importance: Int,
vibration: Boolean, vibration: Boolean,
onGoing: Boolean, onGoing: Boolean,
lights: Boolean lights: Boolean,
onlyAlertOnce: Boolean,
): Notification { ): Notification {
val channel = NotificationChannel( val channel =
channelId, NotificationChannel(
channelName, channelId,
importance channelName,
).let { importance,
it.description = title )
it.enableLights(lights) .let {
it.lightColor = Color.RED it.description = title
it.enableVibration(vibration) it.enableLights(lights)
it.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400) it.lightColor = Color.RED
it it.enableVibration(vibration)
} 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 ->
@@ -48,30 +64,38 @@ class WireGuardNotification @Inject constructor(@ApplicationContext private val
context, context,
0, 0,
notificationIntent, notificationIntent,
PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_IMMUTABLE,
) )
} }
val builder: Notification.Builder = val builder =
Notification.Builder( when (channelId) {
context, context.getString(R.string.watcher_channel_id) -> watcherBuilder
channelId context.getString(R.string.vpn_channel_id) -> tunnelBuilder
) else -> {
return builder.let { NotificationCompat.Builder(
if(action != null && actionText != null) { context,
//TODO find a not deprecated way to do this channelId,
it.addAction( )
Notification.Action.Builder(0, actionText, action) }
.build())
it.setAutoCancel(true)
} }
it.setContentTitle(title)
return builder.let {
if (action != null && actionText != null) {
it.addAction(
NotificationCompat.Action.Builder(0, actionText, action).build(),
)
it.setAutoCancel(true)
}
it.setContentTitle(title)
.setContentText(description) .setContentText(description)
.setOnlyAlertOnce(onlyAlertOnce)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setOngoing(onGoing) .setOngoing(onGoing)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setShowWhen(showTimestamp) .setShowWhen(showTimestamp)
.setSmallIcon(R.mipmap.ic_launcher_foreground) .setSmallIcon(R.drawable.ic_launcher)
.build() .build()
} }
} }
} }
@@ -1,77 +1,89 @@
package com.zaneschepke.wireguardautotunnel.service.shortcut package com.zaneschepke.wireguardautotunnel.service.shortcut
import android.os.Bundle import android.os.Bundle
import android.view.View
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.Action import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
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 lateinit var settingsRepository: SettingsRepository
@Inject @Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
lateinit var settingsRepo : SettingsDoa
@Inject private suspend fun toggleWatcherServicePause() {
lateinit var tunnelConfigRepo : TunnelConfigDao val settings = settingsRepository.getSettings()
if (settings.isAutoTunnelEnabled) {
val pauseAutoTunnel = !settings.isAutoTunnelPaused
private fun attemptWatcherServiceToggle(tunnelConfig : String) { settingsRepository.save(
lifecycleScope.launch(Dispatchers.Main) { settings.copy(
val settings = getSettings() isAutoTunnelPaused = pauseAutoTunnel,
if(settings.isAutoTunnelEnabled) { ),
ServiceManager.toggleWatcherServiceForeground(this@ShortcutsActivity, tunnelConfig) )
}
} }
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if(intent.getStringExtra(CLASS_NAME_EXTRA_KEY) setContentView(View(this))
.equals(WireGuardTunnelService::class.java.simpleName)) { if (
intent
.getStringExtra(CLASS_NAME_EXTRA_KEY)
.equals(WireGuardTunnelService::class.java.simpleName)
) {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
try { val settings = settingsRepository.getSettings()
val settings = getSettings() if (settings.isShortcutsEnabled) {
val tunnelConfig = if(settings.defaultTunnel == null) { try {
tunnelConfigRepo.getAll().first() val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
} else { val tunnelConfig =
TunnelConfig.from(settings.defaultTunnel!!) if (tunnelName != null) {
tunnelConfigRepository.getAll().firstOrNull {
it.name == tunnelName
}
} else {
if (settings.defaultTunnel == null) {
tunnelConfigRepository.getAll().first()
} else {
TunnelConfig.from(settings.defaultTunnel!!)
}
}
tunnelConfig ?: return@launch
toggleWatcherServicePause()
when (intent.action) {
Action.STOP.name ->
ServiceManager.stopVpnService(
this@ShortcutsActivity,
)
Action.START.name ->
ServiceManager.startVpnServiceForeground(
this@ShortcutsActivity,
tunnelConfig.toString(),
)
}
} catch (e: Exception) {
Timber.e(e.message)
finish()
} }
attemptWatcherServiceToggle(tunnelConfig.toString())
when(intent.action){
Action.STOP.name -> ServiceManager.stopVpnService(this@ShortcutsActivity)
Action.START.name -> ServiceManager.startVpnService(this@ShortcutsActivity, tunnelConfig.toString())
}
} catch (e : Exception) {
Timber.e(e.message)
} }
} }
} }
finish() finish()
} }
private suspend fun getSettings() : Settings {
val settings = settingsRepo.getAll()
return if (settings.isNotEmpty()) {
settings.first()
} else {
throw WgTunnelException("Settings empty")
}
}
companion object { companion object {
const val TUNNEL_NAME_EXTRA_KEY = "tunnelName"
const val CLASS_NAME_EXTRA_KEY = "className" const val CLASS_NAME_EXTRA_KEY = "className"
} }
} }
@@ -4,77 +4,86 @@ import android.os.Build
import android.service.quicksettings.Tile import android.service.quicksettings.Tile
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TunnelControlTile : TileService() { class TunnelControlTile() : TileService() {
@Inject @Inject lateinit var tunnelConfigRepository: TunnelConfigRepository
lateinit var settingsRepo : SettingsDoa
@Inject @Inject lateinit var settingsRepository: SettingsRepository
lateinit var configRepo : TunnelConfigDao
@Inject @Inject lateinit var vpnService: VpnService
lateinit var vpnService : VpnService
private val scope = CoroutineScope(Dispatchers.Main); private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var job : Job private var tunnelName: String? = null
override fun onStartListening() { override fun onStartListening() {
job = scope.launch {
updateTileState()
}
super.onStartListening() super.onStartListening()
} Timber.d("On start listening called")
override fun onTileAdded() {
super.onTileAdded()
qsTile.contentDescription = this.resources.getString(R.string.toggle_vpn)
scope.launch { scope.launch {
updateTileState(); vpnService.vpnState.collect {
when (it.status) {
Tunnel.State.UP -> setActive()
Tunnel.State.DOWN -> setInactive()
else -> setInactive()
}
val tunnels = tunnelConfigRepository.getAll()
if (tunnels.isEmpty()) {
setUnavailable()
return@collect
}
tunnelName =
it.name.ifBlank {
val settings = settingsRepository.getSettings()
if (settings.defaultTunnel != null) {
TunnelConfig.from(settings.defaultTunnel!!).name
} else tunnels.firstOrNull()?.name
}
setTileDescription(tunnelName ?: "")
}
} }
} }
override fun onTileRemoved() {
super.onTileRemoved()
cancelJob()
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
scope.cancel() scope.cancel()
} }
override fun onTileRemoved() {
super.onTileRemoved()
scope.cancel()
}
override fun onClick() { override fun onClick() {
super.onClick() super.onClick()
unlockAndRun { unlockAndRun {
scope.launch { scope.launch {
try { try {
val tunnel = determineTileTunnel(); val tunnelConfig =
if(tunnel != null) { tunnelConfigRepository.getAll().first { it.name == tunnelName }
attemptWatcherServiceToggle(tunnel.toString()) 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(this@TunnelControlTile, tunnel.toString()) ServiceManager.startVpnServiceForeground(
} this@TunnelControlTile,
tunnelConfig.toString(),
)
} }
} catch (e : Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
} finally { } finally {
cancel() cancel()
@@ -83,70 +92,42 @@ class TunnelControlTile : TileService() {
} }
} }
private suspend fun determineTileTunnel() : TunnelConfig? { private fun toggleWatcherServicePause() {
var tunnelConfig : TunnelConfig? = null;
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings.first()
tunnelConfig = if (setting.defaultTunnel != null) {
TunnelConfig.from(setting.defaultTunnel!!);
} else {
val configs = configRepo.getAll();
val config = if(configs.isNotEmpty()) {
configs.first();
} else {
null
}
config
}
}
return tunnelConfig;
}
private fun attemptWatcherServiceToggle(tunnelConfig : String) {
scope.launch { scope.launch {
val settings = settingsRepo.getAll() val settings = settingsRepository.getSettings()
if (settings.isNotEmpty()) { if (settings.isAutoTunnelEnabled) {
val setting = settings.first() val pauseAutoTunnel = !settings.isAutoTunnelPaused
if(setting.isAutoTunnelEnabled) { settingsRepository.save(
ServiceManager.toggleWatcherServiceForeground(this@TunnelControlTile, tunnelConfig) settings.copy(
} isAutoTunnelPaused = pauseAutoTunnel,
),
)
} }
} }
} }
private suspend fun updateTileState() { private fun setActive() {
vpnService.state.collect { qsTile.state = Tile.STATE_ACTIVE
when(it) { qsTile.updateTile()
Tunnel.State.UP -> {
qsTile.state = Tile.STATE_ACTIVE
}
Tunnel.State.DOWN -> {
qsTile.state = Tile.STATE_INACTIVE;
}
else -> {
qsTile.state = Tile.STATE_UNAVAILABLE
}
}
val config = determineTileTunnel();
setTileDescription(config?.name ?: this.resources.getString(R.string.no_tunnel_available))
qsTile.updateTile()
}
} }
private fun setTileDescription(description : String) { private fun setInactive() {
qsTile.state = Tile.STATE_INACTIVE
qsTile.updateTile()
}
private fun setUnavailable() {
qsTile.state = Tile.STATE_UNAVAILABLE
qsTile.updateTile()
}
private fun setTileDescription(description: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
qsTile.subtitle = description qsTile.subtitle = description
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = description; qsTile.stateDescription = description
} }
qsTile.updateTile()
} }
}
private fun cancelJob() {
if(this::job.isInitialized) {
job.cancel();
}
}
}
@@ -2,13 +2,15 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
enum class HandshakeStatus { enum class HandshakeStatus {
HEALTHY, HEALTHY,
UNHEALTHY, STALE,
NEVER_CONNECTED, UNKNOWN,
NOT_STARTED; NOT_STARTED;
companion object { companion object {
private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 120 private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 180
const val UNHEALTHY_TIME_LIMIT_SEC = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + 60 const val STATUS_CHANGE_TIME_BUFFER = 30
const val STALE_TIME_LIMIT_SEC =
WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + STATUS_CHANGE_TIME_BUFFER
const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30 const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30
} }
} }
@@ -1,18 +1,15 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.wireguard.crypto.Key import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.SharedFlow
interface VpnService : Tunnel { interface VpnService : Tunnel {
suspend fun startTunnel(tunnelConfig : TunnelConfig) : Tunnel.State suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State
suspend fun stopTunnel() suspend fun stopTunnel()
val state : SharedFlow<Tunnel.State>
val tunnelName : SharedFlow<String> val vpnState: StateFlow<VpnState>
val statistics : SharedFlow<Statistics>
val lastHandshake : SharedFlow<Map<Key,Long>> fun getState(): Tunnel.State
val handshakeStatus : SharedFlow<HandshakeStatus> }
fun getState() : Tunnel.State
}
@@ -0,0 +1,10 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel
data class VpnState(
val status: Tunnel.State = Tunnel.State.DOWN,
val name: String = "",
val statistics: Statistics? = null
)
@@ -3,134 +3,147 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Backend import com.wireguard.android.backend.Backend
import com.wireguard.android.backend.BackendException import com.wireguard.android.backend.BackendException
import com.wireguard.android.backend.Statistics import com.wireguard.android.backend.Statistics
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel.State
import com.wireguard.crypto.Key import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace
import com.zaneschepke.wireguardautotunnel.util.Constants
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
class WireGuardTunnel
class WireGuardTunnel @Inject constructor(private val backend : Backend, @Inject
constructor(
@Userspace private val userspaceBackend : Backend,
@Kernel private val kernelBackend: Backend,
private val settingsRepository: SettingsRepository
) : VpnService { ) : VpnService {
private val _vpnState = MutableStateFlow(VpnState())
override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow()
private val _tunnelName = MutableStateFlow("") private val scope = CoroutineScope(Dispatchers.IO)
override val tunnelName get() = _tunnelName.asStateFlow()
private val _state = MutableSharedFlow<Tunnel.State>( private lateinit var statsJob: Job
onBufferOverflow = BufferOverflow.DROP_OLDEST,
replay = 1)
private val _handshakeStatus = MutableSharedFlow<HandshakeStatus>(replay = 1, private var config: Config? = null
onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val state get() = _state.asSharedFlow()
private val _statistics = MutableSharedFlow<Statistics>(replay = 1) private var backend: Backend = userspaceBackend
override val statistics get() = _statistics.asSharedFlow()
private val _lastHandshake = MutableSharedFlow<Map<Key, Long>>(replay = 1) private var backendIsUserspace = true
override val lastHandshake get() = _lastHandshake.asSharedFlow()
override val handshakeStatus: SharedFlow<HandshakeStatus> init {
get() = _handshakeStatus.asSharedFlow() scope.launch {
settingsRepository.getSettingsFlow().collect {
private val scope = CoroutineScope(Dispatchers.IO); if (it.isKernelEnabled && backendIsUserspace) {
Timber.d("Setting kernel backend")
private lateinit var statsJob : Job backend = kernelBackend
backendIsUserspace = false
} else if (!it.isKernelEnabled && !backendIsUserspace) {
override suspend fun startTunnel(tunnelConfig: TunnelConfig) : Tunnel.State{ Timber.d("Setting userspace backend")
return try { backend = userspaceBackend
if(getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) { backendIsUserspace = true
stopTunnel() }
} }
_tunnelName.emit(tunnelConfig.name) }
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) }
val state = backend.setState(
this, Tunnel.State.UP, config) override suspend fun startTunnel(tunnelConfig: TunnelConfig): State {
_state.emit(state) return try {
state; stopTunnelOnConfigChange(tunnelConfig)
} catch (e : Exception) { emitTunnelName(tunnelConfig.name)
config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
val state =
backend.setState(
this,
State.UP,
config,
)
emitTunnelState(state)
state
} catch (e: Exception) {
Timber.e("Failed to start tunnel with error: ${e.message}") Timber.e("Failed to start tunnel with error: ${e.message}")
Tunnel.State.DOWN State.DOWN
}
}
private fun emitTunnelState(state: State) {
_vpnState.tryEmit(
_vpnState.value.copy(
status = state,
),
)
}
private fun emitBackendStatistics(statistics: Statistics) {
_vpnState.tryEmit(
_vpnState.value.copy(
statistics = statistics,
),
)
}
private suspend fun emitTunnelName(name: String) {
_vpnState.emit(
_vpnState.value.copy(
name = name,
),
)
}
private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) {
if (getState() == State.UP && _vpnState.value.name != tunnelConfig.name) {
stopTunnel()
} }
} }
override fun getName(): String { override fun getName(): String {
return _tunnelName.value return _vpnState.value.name
} }
override suspend fun stopTunnel() { override suspend fun stopTunnel() {
try { try {
if(getState() == Tunnel.State.UP) { if (getState() == State.UP) {
val state = backend.setState(this, Tunnel.State.DOWN, null) val state = backend.setState(this, State.DOWN, null)
_state.emit(state) emitTunnelState(state)
} }
} catch (e : BackendException) { } catch (e: BackendException) {
Timber.e("Failed to stop tunnel with error: ${e.message}") Timber.e("Failed to stop tunnel with error: ${e.message}")
} }
} }
override fun getState(): Tunnel.State { override fun getState(): State {
return backend.getState(this) return backend.getState(this)
} }
override fun onStateChange(state : Tunnel.State) { override fun onStateChange(state: State) {
val tunnel = this; val tunnel = this
_state.tryEmit(state) emitTunnelState(state)
if(state == Tunnel.State.UP) { WireGuardAutoTunnel.requestTileServiceStateUpdate()
statsJob = scope.launch { if (state == State.UP) {
val handshakeMap = HashMap<Key, Long>() statsJob =
var neverHadHandshakeCounter = 0 scope.launch {
while (true) { while (true) {
val statistics = backend.getStatistics(tunnel) val statistics = backend.getStatistics(tunnel)
_statistics.emit(statistics) emitBackendStatistics(statistics)
statistics.peers().forEach { delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
val handshakeEpoch = statistics.peer(it)?.latestHandshakeEpochMillis ?: 0L
handshakeMap[it] = handshakeEpoch
if(handshakeEpoch == 0L) {
if(neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED)
} else {
_handshakeStatus.emit(HandshakeStatus.NOT_STARTED)
}
if(neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
neverHadHandshakeCounter += 10
}
return@forEach
}
if(NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch) >= HandshakeStatus.UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.UNHEALTHY)
} else {
_handshakeStatus.emit(HandshakeStatus.HEALTHY)
}
} }
_lastHandshake.emit(handshakeMap)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
} }
}
} }
if(state == Tunnel.State.DOWN) { if (state == State.DOWN) {
if(this::statsJob.isInitialized) { if (this::statsJob.isInitialized) {
statsJob.cancel() statsJob.cancel()
} }
_handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED)
_lastHandshake.tryEmit(emptyMap())
} }
} }
}
}
@@ -0,0 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class ActivityViewModel
@Inject
constructor() : ViewModel() {
}
@@ -2,4 +2,4 @@ package com.zaneschepke.wireguardautotunnel.ui
import com.journeyapps.barcodescanner.CaptureActivity import com.journeyapps.barcodescanner.CaptureActivity
class CaptureActivityPortrait : CaptureActivity() class CaptureActivityPortrait : CaptureActivity()
@@ -6,16 +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 android.view.KeyEvent import androidx.activity.SystemBarStyle
import androidx.activity.compose.rememberLauncherForActivityResult
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.animation.ExitTransition import androidx.compose.foundation.focusable
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.layout.Column
import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.padding
import androidx.compose.animation.fadeIn
import androidx.compose.animation.slideInHorizontally
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
@@ -25,221 +22,201 @@ 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.input.key.onKeyEvent 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 com.google.accompanist.navigation.animation.AnimatedNavHost import androidx.navigation.compose.NavHost
import com.google.accompanist.navigation.animation.composable import androidx.navigation.compose.composable
import com.google.accompanist.navigation.animation.rememberAnimatedNavController 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.Constants
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
import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen import com.zaneschepke.wireguardautotunnel.ui.screens.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.detail.DetailScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen import com.zaneschepke.wireguardautotunnel.ui.screens.settings.SettingsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.TransparentSystemBars
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
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 timber.log.Timber
import java.io.IOException
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@OptIn(ExperimentalAnimationApi::class, @Inject
ExperimentalPermissionsApi::class lateinit var dataStoreManager: DataStoreManager
@Inject lateinit var settingsRepository: SettingsRepository
@OptIn(
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 navController = rememberAnimatedNavController() //val activityViewModel = hiltViewModel<ActivityViewModel>()
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 = rememberLauncherForActivityResult( requestNotificationPermission()
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 =
message = message, snackbarHostState.showSnackbar(
actionLabel = applicationContext.getString(R.string.okay), message = message,
duration = SnackbarDuration.Short, actionLabel = applicationContext.getString(R.string.okay),
) duration = SnackbarDuration.Short,
)
when (result) { when (result) {
SnackbarResult.ActionPerformed -> { snackbarHostState.currentSnackbarData?.dismiss() } SnackbarResult.ActionPerformed,
SnackbarResult.Dismissed -> { snackbarHostState.currentSnackbarData?.dismiss() } SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
} }
} }
} }
Scaffold(snackbarHost = { Scaffold(
snackbarHost = {
SnackbarHost(snackbarHostState) { snackbarData: SnackbarData -> SnackbarHost(snackbarHostState) { snackbarData: SnackbarData ->
CustomSnackBar( CustomSnackBar(
snackbarData.visuals.message, snackbarData.visuals.message,
isRtl = false, isRtl = false,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) containerColor =
MaterialTheme.colorScheme.surfaceColorAtElevation(
2.dp,
),
) )
} }
}, },
modifier = Modifier.onKeyEvent { modifier = Modifier
if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { .focusable()
when (it.nativeKeyEvent.keyCode) { .focusProperties { up = focusRequester },
KeyEvent.KEYCODE_DPAD_UP -> { bottomBar =
try { if (notificationPermissionState == null || notificationPermissionState.status.isGranted) {
focusRequester.requestFocus() {
} catch(e : IllegalStateException) { BottomNavBar(
Timber.e("No D-Pad focus request modifier added to element on screen") navController,
} listOf(
false Screen.Main.navItem,
} else -> { Screen.Settings.navItem,
false Screen.Support.navItem,
} ),
)
} }
} else { } else {
false {}
} },
}, ) { padding ->
bottomBar = if (vpnIntent == null && notificationPermissionState.status.isGranted) { if (notificationPermissionState != null && !notificationPermissionState.status.isGranted) {
{ BottomNavBar(navController, Routes.navItems) } Column(modifier = Modifier.padding(padding)) {
} else { PermissionRequestFailedScreen(
{} onRequestAgain = {
}, val intentSettings =
) Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
{ padding -> intentSettings.data =
if (vpnIntent != null) { Uri.fromParts(
PermissionRequestFailedScreen( Constants.URI_PACKAGE_SCHEME,
padding = padding, this@MainActivity.packageName,
onRequestAgain = { vpnActivityResultState.launch(vpnIntent) }, null,
message = getString(R.string.vpn_permission_required), )
getString(R.string.retry) startActivity(intentSettings)
) },
return@Scaffold message = getString(R.string.notification_permission_required),
} getString(R.string.open_settings),
if (!notificationPermissionState.status.isGranted) { )
PermissionRequestFailedScreen( return@Scaffold
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
}
AnimatedNavHost(navController, startDestination = Routes.Main.name) {
composable(Routes.Main.name, enterTransition = {
when (initialState.destination.route) {
Routes.Settings.name, Routes.Support.name ->
slideInHorizontally(
initialOffsetX = { -Constants.SLIDE_IN_TRANSITION_OFFSET },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
)
else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}
} }
}, exitTransition = {
ExitTransition.None
} }
NavHost(navController, startDestination = Screen.Main.route) {
composable(
Screen.Main.route,
) { ) {
MainScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, navController = navController) MainScreen(
} focusRequester = focusRequester,
composable(Routes.Settings.name, enterTransition = { showSnackbarMessage = { message -> showSnackBarMessage(message) },
when (initialState.destination.route) { navController = navController,
Routes.Main.name -> )
slideInHorizontally( }
initialOffsetX = { Constants.SLIDE_IN_TRANSITION_OFFSET }, composable(
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) Screen.Settings.route,
) ) {
SettingsScreen(
Routes.Support.name -> { padding = padding,
slideInHorizontally( showSnackbarMessage = { message -> showSnackBarMessage(message) },
initialOffsetX = { -Constants.SLIDE_IN_TRANSITION_OFFSET }, focusRequester = focusRequester,
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) )
//
}
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,
) )
} }
else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}
}
}) { SettingsScreen(padding = padding, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester) }
composable(Routes.Support.name, enterTransition = {
when (initialState.destination.route) {
Routes.Settings.name, Routes.Main.name ->
slideInHorizontally(
initialOffsetX = { Constants.SLIDE_IN_ANIMATION_DURATION },
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION)
)
else -> {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}
}
}) { SupportScreen(padding = padding, focusRequester) }
composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}) { it ->
val id = it.arguments?.getString("id")
if(!id.isNullOrBlank()) {
ConfigScreen(navController = navController, id = id, showSnackbarMessage = { message -> showSnackBarMessage(message) }, focusRequester = focusRequester)}
}
composable("${Routes.Detail.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}) {
val id = it.arguments?.getString("id")
if(!id.isNullOrBlank()) {
DetailScreen(padding = padding, focusRequester = focusRequester, id = id)
} }
} }
}
} }
} }
} }
@@ -6,31 +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
enum class Routes { sealed class Screen(val route: String) {
Main, data object Main : Screen("main") {
Settings, val navItem =
Support,
Config,
Detail;
companion object {
val navItems = listOf(
BottomNavItem( BottomNavItem(
name = "Tunnels", name = "Tunnels",
route = Main.name, route = route,
icon = Icons.Rounded.Home, icon = Icons.Rounded.Home,
), )
}
data object Settings : Screen("settings") {
val navItem =
BottomNavItem( BottomNavItem(
name = "Settings", name = "Settings",
route = Settings.name, route = route,
icon = Icons.Rounded.Settings, icon = Icons.Rounded.Settings,
), )
}
data object Support : Screen("support") {
val navItem =
BottomNavItem( BottomNavItem(
name = "Support", name = "Support",
route = Support.name, route = route,
icon = Icons.Rounded.QuestionMark, icon = Icons.Rounded.QuestionMark,
) )
)
} }
}
data object Config : Screen("config")
}
@@ -14,20 +14,28 @@ import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun ClickableIconButton(onIconClick : () -> Unit, text : String, icon : ImageVector, enabled : Boolean) { fun ClickableIconButton(
TextButton(onClick = {}, onClick: () -> Unit,
enabled = enabled onIconClick: () -> Unit,
text: String,
icon: ImageVector,
enabled: Boolean
) {
TextButton(
onClick = onClick,
enabled = enabled,
) { ) {
Text(text) Text(text, Modifier.weight(1f, false))
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = stringResource(R.string.delete), contentDescription = stringResource(R.string.delete),
modifier = Modifier.size(ButtonDefaults.IconSize).clickable { modifier =
if(enabled) { Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable {
onIconClick() if (enabled) {
} 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
@@ -16,20 +15,22 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Composable @Composable
fun PermissionRequestFailedScreen(padding : PaddingValues, onRequestAgain : () -> Unit, message : String, buttonText : String ) { fun PermissionRequestFailedScreen(
onRequestAgain: () -> Unit,
message: String,
buttonText: String
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column(horizontalAlignment = Alignment.CenterHorizontally, Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize() ) {
.padding(padding)) {
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)
} }
} }
} }
@@ -1,49 +1,88 @@
package com.zaneschepke.wireguardautotunnel.ui.common package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.wireguard.android.backend.Statistics
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.toThreeDecimalPlaceString
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun RowListItem(icon : @Composable() () -> Unit, text : String, onHold : () -> Unit, onClick: () -> Unit, rowButton : @Composable() () -> Unit ) { fun RowListItem(
icon: @Composable () -> Unit,
text: String,
onHold: () -> Unit,
onClick: () -> Unit,
rowButton: @Composable () -> Unit,
expanded: Boolean,
statistics: Statistics?
) {
Box( Box(
modifier = Modifier modifier =
.combinedClickable( Modifier.animateContentSize()
onClick = { .clip(RoundedCornerShape(30.dp))
onClick() .combinedClickable(
}, onClick = { onClick() },
onLongClick = { onLongClick = { onHold() },
onHold() ),
}
)
) { ) {
Row( Column {
modifier = Modifier Row(
.fillMaxWidth() modifier = Modifier.fillMaxWidth().padding(horizontal = 15.dp, vertical = 5.dp),
.padding(14.dp), verticalAlignment = Alignment.CenterVertically,
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.SpaceBetween ) {
) { Row(
Row(verticalAlignment = Alignment.CenterVertically,) { verticalAlignment = Alignment.CenterVertically,
icon() modifier = Modifier.fillMaxWidth(.60f),
Text(text) ) {
icon()
Text(text)
}
rowButton()
}
if (expanded) {
statistics?.peers()?.forEach {
Row(
modifier =
Modifier.fillMaxWidth()
.padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis
val peerTx = statistics.peer(it)!!.txBytes
val peerRx = statistics.peer(it)!!.rxBytes
val peerId = it.toBase64().subSequence(0, 3).toString() + "***"
val handshakeSec =
NumberUtils.getSecondsBetweenTimestampAndNow(handshakeEpoch)
val handshake =
if (handshakeSec == null) "never" else "$handshakeSec secs ago"
val peerTxMB = NumberUtils.bytesToMB(peerTx).toThreeDecimalPlaceString()
val peerRxMB = NumberUtils.bytesToMB(peerRx).toThreeDecimalPlaceString()
val fontSize = 9.sp
Text("peer: $peerId", fontSize = fontSize)
Text("handshake: $handshake", fontSize = fontSize)
Text("tx: $peerTxMB MB", fontSize = fontSize)
Text("rx: $peerRxMB MB", fontSize = fontSize)
}
}
} }
rowButton()
} }
} }
} }
@@ -25,9 +25,7 @@ import androidx.compose.ui.text.input.KeyboardType
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun SearchBar( fun SearchBar(onQuery: (queryString: String) -> Unit) {
onQuery : (queryString : String) -> Unit
) {
// Immediately update and keep track of query from text field changes. // Immediately update and keep track of query from text field changes.
var query: String by rememberSaveable { mutableStateOf("") } var query: String by rememberSaveable { mutableStateOf("") }
var showClearIcon by rememberSaveable { mutableStateOf(false) } var showClearIcon by rememberSaveable { mutableStateOf(false) }
@@ -49,7 +47,7 @@ fun SearchBar(
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 = {
@@ -58,23 +56,24 @@ fun SearchBar(
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 = TextFieldDefaults.colors( colors =
focusedContainerColor = Color.Transparent, TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent, unfocusedContainerColor = 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 =
.fillMaxWidth() Modifier.fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background, shape = RectangleShape) .background(color = MaterialTheme.colorScheme.background, shape = RectangleShape),
) )
} }
@@ -1,6 +1,5 @@
package com.zaneschepke.wireguardautotunnel.ui.common.config package com.zaneschepke.wireguardautotunnel.ui.common.config
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@@ -11,24 +10,27 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardCapitalization
@Composable @Composable
fun fun ConfigurationTextBox(
ConfigurationTextBox(value : String, hint : String, onValueChange : (String) -> Unit, keyboardActions : KeyboardActions, label : String, modifier: Modifier) { value: String,
OutlinedTextField( hint: String,
onValueChange: (String) -> Unit,
keyboardActions: KeyboardActions,
label: String,
modifier: Modifier
) {
OutlinedTextField(
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,
) )
} }
@@ -12,23 +12,25 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@Composable @Composable
fun ConfigurationToggle(label : String, enabled : Boolean, checked : Boolean, padding : Dp, fun ConfigurationToggle(
onCheckChanged : () -> Unit, modifier : Modifier = Modifier) { label: String,
enabled: Boolean,
checked: Boolean,
padding: Dp,
onCheckChanged: () -> Unit,
modifier: Modifier = Modifier
) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(padding),
.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,8 +11,7 @@ import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
@Composable @Composable
fun BottomNavBar(navController : NavController, bottomNavItems : List<BottomNavItem>) { fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavItem>) {
val backStackEntry = navController.currentBackStackEntryAsState() val backStackEntry = navController.currentBackStackEntryAsState()
NavigationBar( NavigationBar(
@@ -35,8 +34,8 @@ fun BottomNavBar(navController : NavController, bottomNavItems : List<BottomNavI
imageVector = item.icon, imageVector = item.icon,
contentDescription = "${item.name} Icon", contentDescription = "${item.name} Icon",
) )
} },
) )
} }
} }
} }
@@ -11,12 +11,12 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
@Composable @Composable
fun AuthorizationPrompt(onSuccess : () -> Unit, onFailure : () -> Unit, onError : (String) -> Unit) { fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (String) -> Unit) {
val context = LocalContext.current val context = LocalContext.current
val biometricManager = BiometricManager.from(context) val biometricManager = BiometricManager.from(context)
val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
val isBiometricAvailable = remember { val isBiometricAvailable = 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
@@ -45,35 +45,39 @@ fun AuthorizationPrompt(onSuccess : () -> Unit, onFailure : () -> Unit, onError
else -> false else -> false
} }
} }
if(isBiometricAvailable) { if (isBiometricAvailable) {
val executor = remember { ContextCompat.getMainExecutor(context) } val executor = remember { ContextCompat.getMainExecutor(context) }
val promptInfo = BiometricPrompt.PromptInfo.Builder() val promptInfo =
.setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) BiometricPrompt.PromptInfo.Builder()
.setTitle("Biometric Authentication") .setAllowedAuthenticators(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
.setSubtitle("Log in using your biometric credential") .setTitle("Biometric Authentication")
.build() .setSubtitle("Log in using your biometric credential")
.build()
val biometricPrompt = BiometricPrompt( val biometricPrompt =
context as FragmentActivity, BiometricPrompt(
executor, context as FragmentActivity,
object : BiometricPrompt.AuthenticationCallback() { executor,
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { object : BiometricPrompt.AuthenticationCallback() {
super.onAuthenticationError(errorCode, errString) override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
onFailure() super.onAuthenticationError(errorCode, errString)
} onFailure()
}
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(
super.onAuthenticationSucceeded(result) result: BiometricPrompt.AuthenticationResult
onSuccess() ) {
} super.onAuthenticationSucceeded(result)
onSuccess()
}
override fun onAuthenticationFailed() { override fun onAuthenticationFailed() {
super.onAuthenticationFailed() super.onAuthenticationFailed()
onFailure() onFailure()
} }
} },
) )
biometricPrompt.authenticate(promptInfo) biometricPrompt.authenticate(promptInfo)
} }
} }
@@ -1,10 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.common.prompt package com.zaneschepke.wireguardautotunnel.ui.common.prompt
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Info
@@ -17,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
@@ -31,28 +32,31 @@ 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.fillMaxWidth( modifier =
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1/3f else 2/3f).padding(bottom = 100.dp), Modifier.fillMaxWidth(
shape = RoundedCornerShape(16.dp) if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f,
) { )
.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.fillMaxSize(), modifier = Modifier.width(IntrinsicSize.Max).height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly 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),
) )
Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp)) Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp))
} }
} }
} }
} }
@@ -0,0 +1,23 @@
package com.zaneschepke.wireguardautotunnel.ui.common.screen
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun LoadingScreen() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().focusable().padding(),
) {
Column(modifier = Modifier.padding(120.dp)) { CircularProgressIndicator() }
}
}
@@ -12,11 +12,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@Composable @Composable
fun SectionTitle(title : String, padding : Dp) { fun SectionTitle(title: String, padding: Dp) {
Text( Text(
title, title,
textAlign = TextAlign.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),
) )
} }
@@ -1,26 +1,30 @@
package com.zaneschepke.wireguardautotunnel.ui.models package com.zaneschepke.wireguardautotunnel.ui.models
import com.wireguard.config.Interface import com.wireguard.config.Interface
import com.wireguard.config.Peer
data class InterfaceProxy( data class InterfaceProxy(
var privateKey : String = "", var privateKey: String = "",
var publicKey : String = "", var publicKey: String = "",
var addresses : String = "", var addresses: String = "",
var dnsServers : String = "", var dnsServers: String = "",
var listenPort : String = "", var listenPort: String = "",
var mtu : String = "", var mtu: String = ""
){ ) {
companion object { companion object {
fun from(i : Interface) : InterfaceProxy { fun from(i: Interface): InterfaceProxy {
return InterfaceProxy( return InterfaceProxy(
publicKey = i.keyPair.publicKey.toBase64().trim(), publicKey = i.keyPair.publicKey.toBase64().trim(),
privateKey = i.keyPair.privateKey.toBase64().trim(), privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(), addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(), dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = if(i.listenPort.isPresent) i.listenPort.get().toString().trim() else "", listenPort =
mtu = if(i.mtu.isPresent) i.mtu.get().toString().trim() else "" if (i.listenPort.isPresent) {
i.listenPort.get().toString().trim()
} else {
""
},
mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "",
) )
} }
} }
} }
@@ -3,30 +3,71 @@ package com.zaneschepke.wireguardautotunnel.ui.models
import com.wireguard.config.Peer import com.wireguard.config.Peer
data class PeerProxy( data class PeerProxy(
var publicKey : String = "", var publicKey: String = "",
var preSharedKey : String = "", var preSharedKey: String = "",
var persistentKeepalive : String = "", var persistentKeepalive: String = "",
var endpoint : String = "", var endpoint: String = "",
var allowedIps: String = IPV4_WILDCARD.joinToString(", ").trim() var allowedIps: String = IPV4_WILDCARD.joinToString(", ").trim()
){ ) {
companion object { companion object {
fun from(peer : Peer) : PeerProxy { fun from(peer: Peer): PeerProxy {
return PeerProxy( return PeerProxy(
publicKey = peer.publicKey.toBase64(), publicKey = peer.publicKey.toBase64(),
preSharedKey = if(peer.preSharedKey.isPresent) peer.preSharedKey.get().toBase64().trim() else "", preSharedKey =
persistentKeepalive = if(peer.persistentKeepalive.isPresent) peer.persistentKeepalive.get().toString().trim() else "", if (peer.preSharedKey.isPresent) {
endpoint = if(peer.endpoint.isPresent) peer.endpoint.get().toString().trim() else "", peer.preSharedKey.get().toBase64().trim()
allowedIps = peer.allowedIps.joinToString(", ").trim() } else {
""
},
persistentKeepalive =
if (peer.persistentKeepalive.isPresent) {
peer.persistentKeepalive.get().toString().trim()
} else {
""
},
endpoint =
if (peer.endpoint.isPresent) {
peer.endpoint.get().toString().trim()
} else {
""
},
allowedIps = peer.allowedIps.joinToString(", ").trim(),
) )
} }
val IPV4_PUBLIC_NETWORKS = setOf(
"0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3", val IPV4_PUBLIC_NETWORKS =
"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", setOf(
"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", "0.0.0.0/5",
"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", "8.0.0.0/7",
"192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10", "11.0.0.0/8",
"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" "12.0.0.0/6",
) "16.0.0.0/4",
"32.0.0.0/3",
"64.0.0.0/2",
"128.0.0.0/3",
"160.0.0.0/5",
"168.0.0.0/6",
"172.0.0.0/12",
"172.32.0.0/11",
"172.64.0.0/10",
"172.128.0.0/9",
"173.0.0.0/8",
"174.0.0.0/7",
"176.0.0.0/4",
"192.0.0.0/9",
"192.128.0.0/11",
"192.160.0.0/13",
"192.169.0.0/16",
"192.170.0.0/15",
"192.172.0.0/14",
"192.176.0.0/12",
"192.192.0.0/10",
"193.0.0.0/8",
"194.0.0.0/7",
"196.0.0.0/6",
"200.0.0.0/5",
"208.0.0.0/4",
)
val IPV4_WILDCARD = setOf("0.0.0.0/0") val IPV4_WILDCARD = setOf("0.0.0.0/0")
} }
} }
@@ -0,0 +1,18 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.config
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.Packages
data class ConfigUiState(
val proxyPeers: List<PeerProxy> = arrayListOf(PeerProxy()),
val interfaceProxy: InterfaceProxy = InterfaceProxy(),
val packages: Packages = emptyList(),
val checkedPackageNames: List<String> = emptyList(),
val include: Boolean = true,
val isAllApplicationsEnabled: Boolean = false,
val loading: Boolean = true,
val tunnel: TunnelConfig? = null,
val tunnelName: String = ""
)
@@ -5,8 +5,6 @@ import android.app.Application
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.toMutableStateList
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config import com.wireguard.config.Config
@@ -14,387 +12,343 @@ import com.wireguard.config.Interface
import com.wireguard.config.Peer import com.wireguard.config.Peer
import com.wireguard.crypto.Key import com.wireguard.crypto.Key
import com.wireguard.crypto.KeyPair import com.wireguard.crypto.KeyPair
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy
import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy import com.zaneschepke.wireguardautotunnel.ui.models.PeerProxy
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.Result
import com.zaneschepke.wireguardautotunnel.util.removeAt
import com.zaneschepke.wireguardautotunnel.util.update
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ConfigViewModel @Inject constructor(private val application : Application, class ConfigViewModel
private val tunnelRepo : TunnelConfigDao, @Inject
private val settingsRepo : SettingsDoa constructor(
private val application: Application,
private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepository: SettingsRepository,
) : ViewModel() { ) : ViewModel() {
private val _tunnel = MutableStateFlow<TunnelConfig?>(null)
private val _tunnelName = MutableStateFlow("")
val tunnelName get() = _tunnelName.asStateFlow()
val tunnel get() = _tunnel.asStateFlow()
private var _proxyPeers = MutableStateFlow(mutableStateListOf<PeerProxy>())
val proxyPeers get() = _proxyPeers.asStateFlow()
private var _interface = MutableStateFlow(InterfaceProxy())
val interfaceProxy = _interface.asStateFlow()
private val _packages = MutableStateFlow(emptyList<PackageInfo>())
val packages get() = _packages.asStateFlow()
private val packageManager = application.packageManager private val packageManager = application.packageManager
private val _checkedPackages = MutableStateFlow(mutableStateListOf<String>()) private val _uiState = MutableStateFlow(ConfigUiState())
val checkedPackages get() = _checkedPackages.asStateFlow() val uiState = _uiState.asStateFlow()
private val _include = MutableStateFlow(true)
val include get() = _include.asStateFlow()
private val _isAllApplicationsEnabled = MutableStateFlow(false) fun init(tunnelId: String) =
val isAllApplicationsEnabled get() = _isAllApplicationsEnabled.asStateFlow() viewModelScope.launch(Dispatchers.IO) {
private val _isDefaultTunnel = MutableStateFlow(false) val packages = getQueriedPackages("")
val isDefaultTunnel = _isDefaultTunnel.asStateFlow() val state =
if (tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
private lateinit var tunnelConfig: TunnelConfig val tunnelConfig =
tunnelConfigRepository.getAll().firstOrNull { it.id.toString() == tunnelId }
fun onScreenLoad(id : String) { if (tunnelConfig != null) {
if(id != Constants.MANUAL_TUNNEL_CONFIG_ID) { val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
viewModelScope.launch(Dispatchers.IO) { val proxyPeers = config.peers.map { PeerProxy.from(it) }
tunnelConfig = withContext(this.coroutineContext) { val proxyInterface = InterfaceProxy.from(config.`interface`)
getTunnelConfigById(id) ?: throw WgTunnelException("Config not found") var include = true
var isAllApplicationsEnabled = false
val checkedPackages =
if (config.`interface`.includedApplications.isNotEmpty()) {
config.`interface`.includedApplications
} 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 {
ConfigUiState(loading = false, packages = packages)
}
} else {
ConfigUiState(loading = false, packages = packages)
} }
emitScreenData() _uiState.value = state
} }
} else {
emitEmptyScreenData() fun onTunnelNameChange(name: String) {
_uiState.value = _uiState.value.copy(tunnelName = name)
}
fun onIncludeChange(include: Boolean) {
_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())
} }
} }
private fun emitEmptyScreenData() { fun getPackageLabel(packageInfo: PackageInfo): String {
tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = "")
viewModelScope.launch {
emitTunnelConfig()
emitPeerProxy(PeerProxy())
emitInterfaceProxy(InterfaceProxy())
emitTunnelConfigName()
emitDefaultTunnelStatus()
emitQueriedPackages("")
emitTunnelAllApplicationsEnabled()
}
}
private suspend fun emitScreenData() {
emitTunnelConfig()
emitPeersFromConfig()
emitInterfaceFromConfig()
emitTunnelConfigName()
emitDefaultTunnelStatus()
emitQueriedPackages("")
emitCurrentPackageConfigurations()
}
private suspend fun emitDefaultTunnelStatus() {
val settings = settingsRepo.getAll()
if(settings.isNotEmpty()) {
_isDefaultTunnel.value = settings.first().isTunnelConfigDefault(tunnelConfig)
}
}
private fun emitInterfaceFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
_interface.value = InterfaceProxy.from(config.`interface`)
}
private fun emitPeersFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
config.peers.forEach{
_proxyPeers.value.add(PeerProxy.from(it))
}
}
private fun emitPeerProxy(peerProxy: PeerProxy) {
_proxyPeers.value.add(peerProxy)
}
private fun emitInterfaceProxy(interfaceProxy: InterfaceProxy) {
_interface.value = interfaceProxy
}
private suspend fun getTunnelConfigById(id : String) : TunnelConfig? {
return try {
tunnelRepo.getById(id.toLong())
} catch (_ : Exception) {
null
}
}
private suspend fun emitTunnelConfig() {
_tunnel.emit(tunnelConfig)
}
private suspend fun emitTunnelConfigName() {
_tunnelName.emit(tunnelConfig.name)
}
fun onTunnelNameChange(name : String) {
_tunnelName.value = name
}
fun onIncludeChange(include : Boolean) {
_include.value = include
}
fun onAddCheckedPackage(packageName : String) {
_checkedPackages.value.add(packageName)
}
fun onAllApplicationsChange(isAllApplicationsEnabled : Boolean) {
_isAllApplicationsEnabled.value = isAllApplicationsEnabled
}
fun onRemoveCheckedPackage(packageName : String) {
_checkedPackages.value.remove(packageName)
}
private suspend fun emitSplitTunnelConfiguration(config : Config) {
val excludedApps = config.`interface`.excludedApplications
val includedApps = config.`interface`.includedApplications
if (excludedApps.isNotEmpty() || includedApps.isNotEmpty()) {
emitTunnelAllApplicationsDisabled()
determineAppInclusionState(excludedApps, includedApps)
} else {
emitTunnelAllApplicationsEnabled()
}
}
private suspend fun determineAppInclusionState(excludedApps : Set<String>, includedApps : Set<String>) {
if (excludedApps.isEmpty()) {
emitIncludedAppsExist()
emitCheckedApps(includedApps)
} else {
emitExcludedAppsExist()
emitCheckedApps(excludedApps)
}
}
private suspend fun emitIncludedAppsExist() {
_include.emit(true)
}
private suspend fun emitExcludedAppsExist() {
_include.emit(false)
}
private suspend fun emitCheckedApps(apps : Set<String>) {
_checkedPackages.emit(apps.toMutableStateList())
}
private suspend fun emitTunnelAllApplicationsEnabled() {
_isAllApplicationsEnabled.emit(true)
}
private suspend fun emitTunnelAllApplicationsDisabled() {
_isAllApplicationsEnabled.emit(false)
}
private fun emitCurrentPackageConfigurations() {
viewModelScope.launch(Dispatchers.IO) {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
emitSplitTunnelConfiguration(config)
}
}
fun emitQueriedPackages(query : String) {
viewModelScope.launch(Dispatchers.IO) {
val packages = getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_packages.emit(packages)
}
}
fun getPackageLabel(packageInfo : PackageInfo) : String {
return packageInfo.applicationInfo.loadLabel(application.packageManager).toString() return packageInfo.applicationInfo.loadLabel(application.packageManager).toString()
} }
private fun getAllInternetCapablePackages(): List<PackageInfo> {
private fun getAllInternetCapablePackages() : List<PackageInfo> {
return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET)) return getPackagesHoldingPermissions(arrayOf(Manifest.permission.INTERNET))
} }
private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> { private fun getPackagesHoldingPermissions(permissions: Array<String>): List<PackageInfo> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions(permissions, PackageManager.PackageInfoFlags.of(0L)) packageManager.getPackagesHoldingPermissions(
permissions,
PackageManager.PackageInfoFlags.of(0L),
)
} else { } else {
packageManager.getPackagesHoldingPermissions(permissions, 0) packageManager.getPackagesHoldingPermissions(permissions, 0)
} }
} }
private fun isAllApplicationsEnabled() : Boolean { private fun isAllApplicationsEnabled(): Boolean {
return _isAllApplicationsEnabled.value return _uiState.value.isAllApplicationsEnabled
} }
private fun isIncludeApplicationsEnabled() : Boolean { private fun saveConfig(tunnelConfig: TunnelConfig) =
return _include.value viewModelScope.launch { tunnelConfigRepository.save(tunnelConfig) }
}
private suspend fun saveConfig(tunnelConfig: TunnelConfig) { private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) =
tunnelRepo.save(tunnelConfig) viewModelScope.launch {
} if (tunnelConfig != null) {
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) { saveConfig(tunnelConfig).join()
if(tunnelConfig != null) { WireGuardAutoTunnel.requestTileServiceStateUpdate()
saveConfig(tunnelConfig) updateSettingsDefaultTunnel(tunnelConfig)
updateSettingsDefaultTunnel(tunnelConfig) }
} }
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) { private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepo.getAll() val settings = settingsRepository.getSettingsFlow().first()
if(settings.isNotEmpty()) { if (settings.defaultTunnel != null) {
val setting = settings[0] if (tunnelConfig.id == TunnelConfig.from(settings.defaultTunnel!!).id) {
if(setting.defaultTunnel != null) { settingsRepository.save(settings.copy(defaultTunnel = tunnelConfig.toString()))
if(tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
settingsRepo.save(setting.copy(
defaultTunnel = tunnelConfig.toString()
))
}
} }
} }
} }
fun buildPeerListFromProxyPeers() : List<Peer> { private fun buildPeerListFromProxyPeers(): List<Peer> {
return _proxyPeers.value.map { return _uiState.value.proxyPeers.map {
val builder = Peer.Builder() val builder = Peer.Builder()
if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim()) if (it.allowedIps.isNotEmpty()) builder.parseAllowedIPs(it.allowedIps.trim())
if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim()) if (it.publicKey.isNotEmpty()) builder.parsePublicKey(it.publicKey.trim())
if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim()) if (it.preSharedKey.isNotEmpty()) builder.parsePreSharedKey(it.preSharedKey.trim())
if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim()) if (it.endpoint.isNotEmpty()) builder.parseEndpoint(it.endpoint.trim())
if (it.persistentKeepalive.isNotEmpty()) builder.parsePersistentKeepalive(it.persistentKeepalive.trim()) if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
}
builder.build() builder.build()
} }
} }
fun buildInterfaceListFromProxyInterface() : Interface { private fun emptyCheckedPackagesList() {
_uiState.value = _uiState.value.copy(checkedPackageNames = emptyList())
}
private fun buildInterfaceListFromProxyInterface(): Interface {
val builder = Interface.Builder() val builder = Interface.Builder()
builder.parsePrivateKey(_interface.value.privateKey.trim()) builder.parsePrivateKey(_uiState.value.interfaceProxy.privateKey.trim())
builder.parseAddresses(_interface.value.addresses.trim()) builder.parseAddresses(_uiState.value.interfaceProxy.addresses.trim())
builder.parseDnsServers(_interface.value.dnsServers.trim()) if (_uiState.value.interfaceProxy.dnsServers.isNotEmpty()) {
if(_interface.value.mtu.isNotEmpty()) builder.parseMtu(_interface.value.mtu.trim()) builder.parseDnsServers(_uiState.value.interfaceProxy.dnsServers.trim())
if(_interface.value.listenPort.isNotEmpty()) builder.parseListenPort(_interface.value.listenPort.trim()) }
if(isAllApplicationsEnabled()) _checkedPackages.value.clear() if (_uiState.value.interfaceProxy.mtu.isNotEmpty())
if(_include.value) builder.includeApplications(_checkedPackages.value) builder.parseMtu(_uiState.value.interfaceProxy.mtu.trim())
if(!_include.value) builder.excludeApplications(_checkedPackages.value) 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() return builder.build()
} }
fun onSaveAllChanges(): Result<Event> {
return try {
suspend fun onSaveAllChanges() {
try {
val peerList = buildPeerListFromProxyPeers() val peerList = buildPeerListFromProxyPeers()
val wgInterface = buildInterfaceListFromProxyInterface() val wgInterface = buildInterfaceListFromProxyInterface()
val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build() val config = Config.Builder().addPeers(peerList).setInterface(wgInterface).build()
val tunnelConfig = _tunnel.value?.copy( val tunnelConfig = when(uiState.value.tunnel) {
name = _tunnelName.value, null -> TunnelConfig(name = _uiState.value.tunnelName, wgQuick = config.toWgQuickString())
wgQuick = config.toWgQuickString() else -> uiState.value.tunnel!!.copy(
) name = _uiState.value.tunnelName,
wgQuick = config.toWgQuickString(),
)
}
updateTunnelConfig(tunnelConfig) updateTunnelConfig(tunnelConfig)
} catch (e : Exception) { Result.Success(Event.Message.ConfigSaved)
throw WgTunnelException("Error: ${e.cause?.message?.lowercase() ?: "unknown error occurred"}") } catch (e: Exception) {
Result.Error(Event.Error.Exception(e))
} }
} }
fun onPeerPublicKeyChange(index: Int, publicKey: String) { fun onPeerPublicKeyChange(index: Int, value: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy( _uiState.value =
publicKey = publicKey _uiState.value.copy(
) proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(publicKey = value),
),
)
} }
fun onPreSharedKeyChange(index: Int, value: String) { fun onPreSharedKeyChange(index: Int, value: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy( _uiState.value =
preSharedKey = value _uiState.value.copy(
) proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(preSharedKey = value),
),
)
} }
fun onEndpointChange(index: Int, value: String) { fun onEndpointChange(index: Int, value: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy( _uiState.value =
endpoint = value _uiState.value.copy(
) proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(endpoint = value),
),
)
} }
fun onAllowedIpsChange(index: Int, value: String) { fun onAllowedIpsChange(index: Int, value: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy( _uiState.value =
allowedIps = value _uiState.value.copy(
) proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(allowedIps = value),
),
)
} }
fun onPersistentKeepaliveChanged(index : Int, value : String) { fun onPersistentKeepaliveChanged(index: Int, value: String) {
_proxyPeers.value[index] = _proxyPeers.value[index].copy( _uiState.value =
persistentKeepalive = value _uiState.value.copy(
) proxyPeers =
_uiState.value.proxyPeers.update(
index,
_uiState.value.proxyPeers[index].copy(persistentKeepalive = value),
),
)
} }
fun onDeletePeer(index: Int) { fun onDeletePeer(index: Int) {
proxyPeers.value.removeAt(index) _uiState.value =
_uiState.value.copy(
proxyPeers = _uiState.value.proxyPeers.removeAt(index),
)
} }
fun addEmptyPeer() { fun addEmptyPeer() {
_proxyPeers.value.add(PeerProxy()) _uiState.value = _uiState.value.copy(proxyPeers = _uiState.value.proxyPeers + PeerProxy())
} }
fun generateKeyPair() { fun generateKeyPair() {
val keyPair = KeyPair() val keyPair = KeyPair()
_interface.value = _interface.value.copy( _uiState.value =
privateKey = keyPair.privateKey.toBase64(), _uiState.value.copy(
publicKey = keyPair.publicKey.toBase64() interfaceProxy =
) _uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64(),
),
)
} }
fun onAddressesChanged(value: String) { fun onAddressesChanged(value: String) {
_interface.value = _interface.value.copy( _uiState.value =
addresses = value _uiState.value.copy(
) interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value)
)
} }
fun onListenPortChanged(value: String) { fun onListenPortChanged(value: String) {
_interface.value = _interface.value.copy( _uiState.value =
listenPort = value _uiState.value.copy(
) interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value)
)
} }
fun onDnsServersChanged(value: String) { fun onDnsServersChanged(value: String) {
_interface.value = _interface.value.copy( _uiState.value =
dnsServers = value _uiState.value.copy(
) interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value)
)
} }
fun onMtuChanged(value: String) { fun onMtuChanged(value: String) {
_interface.value = _interface.value.copy( _uiState.value =
mtu = value _uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
)
} }
private fun onInterfacePublicKeyChange(value : String) { private fun onInterfacePublicKeyChange(value: String) {
_interface.value = _interface.value.copy( _uiState.value =
publicKey = value _uiState.value.copy(
) interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value)
)
} }
fun onPrivateKeyChange(value: String) { fun onPrivateKeyChange(value: String) {
_interface.value = _interface.value.copy( _uiState.value =
privateKey = value _uiState.value.copy(
) interfaceProxy = _uiState.value.interfaceProxy.copy(privateKey = value)
if(NumberUtils.isValidKey(value)) { )
if (NumberUtils.isValidKey(value)) {
val pair = KeyPair(Key.fromBase64(value)) val pair = KeyPair(Key.fromBase64(value))
onInterfacePublicKeyChange(pair.publicKey.toBase64()) onInterfacePublicKeyChange(pair.publicKey.toBase64())
} else { } else {
onInterfacePublicKeyChange("") onInterfacePublicKeyChange("")
} }
} }
}
fun emitQueriedPackages(query: String) {
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_uiState.value = _uiState.value.copy(packages = packages)
}
}
@@ -1,163 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.detail
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import java.time.Duration
import java.time.Instant
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun DetailScreen(
viewModel: DetailViewModel = hiltViewModel(),
focusRequester: FocusRequester,
padding: PaddingValues,
id : String
) {
val context = LocalContext.current
val clipboardManager: ClipboardManager = LocalClipboardManager.current
val tunnelStats by viewModel.tunnelStats.collectAsStateWithLifecycle(null)
val tunnel by viewModel.tunnel.collectAsStateWithLifecycle(null)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle()
val lastHandshake by viewModel.lastHandshake.collectAsStateWithLifecycle(emptyMap())
LaunchedEffect(Unit) {
viewModel.emitConfig(id)
}
if(null != tunnel) {
val interfaceKey = tunnel?.`interface`?.keyPair?.publicKey?.toBase64().toString()
val addresses = tunnel?.`interface`?.addresses!!.joinToString()
val dnsServers = tunnel?.`interface`?.dnsServers!!.joinToString()
val optionalMtu = tunnel?.`interface`?.mtu
val mtu = if(optionalMtu?.isPresent == true) optionalMtu.get().toString() else stringResource(
id = R.string.none
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 4/5f else 1f)
.verticalScroll(rememberScrollState())
.focusRequester(focusRequester)
.padding(padding)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 20.dp, vertical = 7.dp).focusGroup(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f, true)) {
Text(stringResource(R.string.config_interface), fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(stringResource(R.string.name), fontStyle = FontStyle.Italic)
Text(text = tunnelName, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(tunnelName))
})
Text(stringResource(R.string.public_key), fontStyle = FontStyle.Italic)
Text(text = interfaceKey, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(interfaceKey))
})
Text(stringResource(R.string.addresses), fontStyle = FontStyle.Italic)
Text(text = addresses, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(addresses))
})
Text(stringResource(R.string.dns_servers), fontStyle = FontStyle.Italic)
Text(text = dnsServers, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(dnsServers))
})
Text(stringResource(R.string.mtu), fontStyle = FontStyle.Italic)
Text(text = mtu, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(mtu))
})
Box(modifier = Modifier.padding(10.dp))
tunnel?.peers?.forEach{
val peerKey = it.publicKey.toBase64().toString()
val allowedIps = it.allowedIps.joinToString()
val endpoint = if(it.endpoint.isPresent) it.endpoint.get().toString() else stringResource(
id = R.string.none
)
Text(stringResource(R.string.peer), fontWeight = FontWeight.Bold, fontSize = 20.sp)
Text(stringResource(R.string.public_key), fontStyle = FontStyle.Italic)
Text(text = peerKey, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(peerKey))
})
Text(stringResource(id = R.string.allowed_ips), fontStyle = FontStyle.Italic)
Text(text = allowedIps, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(allowedIps))
})
Text(stringResource(R.string.endpoint), fontStyle = FontStyle.Italic)
Text(text = endpoint, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(endpoint))
})
if (tunnelStats != null) {
val totalRx = tunnelStats?.totalRx() ?: 0
val totalTx = tunnelStats?.totalTx() ?: 0
if((totalRx + totalTx != 0L)) {
val rxKB = NumberUtils.bytesToKB(tunnelStats!!.totalRx())
val txKB = NumberUtils.bytesToKB(tunnelStats!!.totalTx())
Text(stringResource(R.string.transfer), fontStyle = FontStyle.Italic)
val transfer = "rx: ${NumberUtils.formatDecimalTwoPlaces(rxKB)} KB tx: ${NumberUtils.formatDecimalTwoPlaces(txKB)} KB"
Text(transfer, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(transfer))})
Text(stringResource(R.string.last_handshake), fontStyle = FontStyle.Italic)
val handshakeEpoch = lastHandshake[it.publicKey]
if(handshakeEpoch != null) {
if(handshakeEpoch == 0L) {
Text(stringResource(id = R.string.never), modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(context.getString(R.string.never)))
})
} else {
val time = Instant.ofEpochMilli(handshakeEpoch)
val duration = "${Duration.between(time, Instant.now()).seconds} seconds ago"
Text(duration, modifier = Modifier.clickable {
clipboardManager.setText(AnnotatedString(duration))
})
}
}
}
}
}
}
}
}
}
}
@@ -1,46 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.detail
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class DetailViewModel @Inject constructor(private val tunnelRepo : TunnelConfigDao, private val vpnService : VpnService
) : ViewModel() {
private val _tunnel = MutableStateFlow<Config?>(null)
val tunnel get() = _tunnel.asStateFlow()
private val _tunnelName = MutableStateFlow("")
val tunnelName = _tunnelName.asStateFlow()
val tunnelStats get() = vpnService.statistics
val lastHandshake get() = vpnService.lastHandshake
private suspend fun getTunnelConfigById(id: String): TunnelConfig? {
return try {
tunnelRepo.getById(id.toLong())
} catch (e: Exception) {
Timber.e(e.message)
null
}
}
fun emitConfig(id: String) {
viewModelScope.launch(Dispatchers.IO) {
val tunnelConfig = getTunnelConfigById(id)
if(tunnelConfig != null) {
_tunnelName.emit(tunnelConfig.name)
_tunnel.emit(TunnelConfig.configFromQuick(tunnelConfig.wgQuick))
}
}
}
}
@@ -1,55 +1,68 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main package com.zaneschepke.wireguardautotunnel.ui.screens.main
import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredWidth
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Create import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.FileOpen import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Add
import androidx.compose.material.icons.rounded.Bolt
import androidx.compose.material.icons.rounded.Circle import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FabPosition import androidx.compose.material3.FabPosition
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MaterialTheme.typography
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.material3.surfaceColorAtElevation
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -61,16 +74,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -78,145 +89,250 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavController import androidx.navigation.NavController
import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions import com.journeyapps.barcodescanner.ScanOptions
import com.wireguard.android.backend.GoBackend
import com.wireguard.android.backend.Tunnel import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus import com.zaneschepke.wireguardautotunnel.service.tunnel.HandshakeStatus
import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait import com.zaneschepke.wireguardautotunnel.ui.CaptureActivityPortrait
import com.zaneschepke.wireguardautotunnel.ui.Routes import com.zaneschepke.wireguardautotunnel.ui.Screen
import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem import com.zaneschepke.wireguardautotunnel.ui.common.RowListItem
import com.zaneschepke.wireguardautotunnel.ui.theme.brickRed import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.corn
import com.zaneschepke.wireguardautotunnel.ui.theme.mint import com.zaneschepke.wireguardautotunnel.ui.theme.mint
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.Result
import com.zaneschepke.wireguardautotunnel.util.handshakeStatus
import com.zaneschepke.wireguardautotunnel.util.mapPeerStats
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel = hiltViewModel(), viewModel: MainViewModel = hiltViewModel(),
padding: PaddingValues, focusRequester: FocusRequester,
showSnackbarMessage: (String) -> Unit, showSnackbarMessage: (String) -> Unit,
navController: NavController navController: NavController
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
val context = LocalContext.current val context = LocalContext.current
val isVisible = rememberSaveable { mutableStateOf(true) } val isVisible = rememberSaveable { mutableStateOf(true) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope { Dispatchers.IO }
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) } var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf()) var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(HandshakeStatus.NOT_STARTED)
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) } var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN) val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
val settings by viewModel.settings.collectAsStateWithLifecycle()
// Nested scroll for control FAB var vpnIntent by remember { mutableStateOf(GoBackend.VpnService.prepare(WireGuardAutoTunnel.instance)) }
val nestedScrollConnection = remember { val vpnActivityResultState =
object : NestedScrollConnection { rememberLauncherForActivityResult(
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { ActivityResultContracts.StartActivityForResult(),
// Hide FAB onResult = {
if (available.y < -1) { val accepted = (it.resultCode == AppCompatActivity.RESULT_OK)
isVisible.value = false if (accepted) {
vpnIntent = null
} }
// Show FAB
if (available.y > 1) {
isVisible.value = true
}
return Offset.Zero
}
}
}
val tunnelFileImportResultLauncher = rememberLauncherForActivityResult(object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */
val activitiesToResolveIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(intent, PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong()))
} else {
context.packageManager.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY)
}
if (activitiesToResolveIntent.all {
val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
}) {
throw WgTunnelException("No file explorer installed")
}
return intent
}
}) { data ->
if (data == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) {
try {
viewModel.onTunnelFileSelected(data)
} catch (e : Exception) {
showSnackbarMessage(e.message ?: "Unknown error occurred")
}
}
}
val scanLauncher = rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = {
try {
viewModel.onTunnelQrResult(it.contents)
} catch (e: Exception) {
showSnackbarMessage(context.getString(R.string.qr_result_failed))
}
}
)
if(showPrimaryChangeAlertDialog) {
AlertDialog(
onDismissRequest = {
showPrimaryChangeAlertDialog = false
}, },
confirmButton = { )
TextButton(onClick = { LaunchedEffect(uiState.loading) {
if (!uiState.loading && WireGuardAutoTunnel.isRunningOnAndroidTv()) {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
}
}
if (uiState.loading) {
LoadingScreen()
return
}
val tunnelFileImportResultLauncher =
rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() {
override fun createIntent(context: Context, input: String): Intent {
val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
* what we can do, so detect this and throw an exception that we can catch later. */
val activitiesToResolveIntent =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.packageManager.queryIntentActivities(
intent,
PackageManager.ResolveInfoFlags.of(
PackageManager.MATCH_DEFAULT_ONLY.toLong(),
),
)
} else {
context.packageManager.queryIntentActivities(
intent,
PackageManager.MATCH_DEFAULT_ONLY,
)
}
if (
activitiesToResolveIntent.all {
val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) ||
name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
}
) {
showSnackbarMessage(Event.Error.FileExplorerRequired.message)
}
return intent
}
},
) { data ->
if (data == null) return@rememberLauncherForActivityResult
scope.launch {
viewModel.onTunnelFileSelected(data).let {
when (it) {
is Result.Error -> showSnackbarMessage(it.error.message)
is Result.Success -> {}
}
}
}
}
val scanLauncher =
rememberLauncherForActivityResult(
contract = ScanContract(),
onResult = {
if (it.contents != null) {
scope.launch { scope.launch {
viewModel.onTunnelQrResult(it.contents).let { result ->
when (result) {
is Result.Success -> {}
is Result.Error -> showSnackbarMessage(result.error.message)
}
}
}
}
},
)
AnimatedVisibility(showPrimaryChangeAlertDialog) {
AlertDialog(
onDismissRequest = { showPrimaryChangeAlertDialog = false },
confirmButton = {
TextButton(
onClick = {
viewModel.onDefaultTunnelChange(selectedTunnel) viewModel.onDefaultTunnelChange(selectedTunnel)
showPrimaryChangeAlertDialog = false showPrimaryChangeAlertDialog = false
selectedTunnel = null selectedTunnel = null
} },
}) ) {
{ Text(text = stringResource(R.string.okay)) } Text(text = stringResource(R.string.okay))
}
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { TextButton(onClick = { showPrimaryChangeAlertDialog = false }) {
showPrimaryChangeAlertDialog = false Text(text = stringResource(R.string.cancel))
}) }
{ Text(text = stringResource(R.string.cancel)) }
}, },
title = { Text(text = stringResource(R.string.primary_tunnel_change)) }, title = { Text(text = stringResource(R.string.primary_tunnel_change)) },
text = { Text(text = stringResource(R.string.primary_tunnnel_change_question)) } text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) },
) )
} }
fun onTunnelToggle(checked : Boolean , tunnel : TunnelConfig) { AnimatedVisibility(showDeleteTunnelAlertDialog) {
try { AlertDialog(
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop() onDismissRequest = { showDeleteTunnelAlertDialog = false },
} catch (e : Exception) { confirmButton = {
showSnackbarMessage(e.message!!) TextButton(
onClick = {
selectedTunnel?.let { viewModel.onDelete(it) }
showDeleteTunnelAlertDialog = false
selectedTunnel = null
},
) {
Text(text = stringResource(R.string.yes))
}
},
dismissButton = {
TextButton(onClick = { showDeleteTunnelAlertDialog = false }) {
Text(text = stringResource(R.string.cancel))
}
},
title = { Text(text = stringResource(R.string.delete_tunnel)) },
text = { Text(text = stringResource(R.string.delete_tunnel_message)) },
)
}
fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
if (vpnIntent != null) {
return vpnActivityResultState.launch(vpnIntent)
} }
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
} }
Scaffold( Scaffold(
modifier = Modifier.pointerInput(Unit) { modifier =
detectTapGestures(onTap = { Modifier.pointerInput(Unit) {
selectedTunnel = null detectTapGestures(
}) onTap = {
}, if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) selectedTunnel = null
},
)
},
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
topBar = {
if (uiState.settings.isAutoTunnelEnabled)
TopAppBar(
title = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.requiredWidth(LocalConfiguration.current.screenWidthDp.dp)
.padding(end = 5.dp)
) {
Row {
Icon(
Icons.Rounded.Bolt,
stringResource(id = R.string.auto),
modifier = Modifier.size(25.dp),
tint =
if (uiState.settings.isAutoTunnelPaused) Color.Gray
else mint,
)
val autoTunnelingLabel = buildAnnotatedString {
append(stringResource(id = R.string.auto_tunneling))
append(": ")
if(uiState.settings.isAutoTunnelPaused) append(stringResource(id = R.string.paused)) else append(
stringResource(id = R.string.active),
)
}
Text(
autoTunnelingLabel.text,
style = typography.bodyLarge,
modifier = Modifier.padding(start = 10.dp),
)
}
if (uiState.settings.isAutoTunnelPaused)
TextButton(
onClick = { viewModel.resumeAutoTunneling() },
modifier = Modifier.padding(end = 10.dp),
) {
Text(stringResource(id = R.string.resume))
}
else
TextButton(
onClick = { viewModel.pauseAutoTunneling() },
modifier = Modifier.padding(end = 10.dp),
) {
Text(stringResource(id = R.string.pause))
}
}
},
)
},
floatingActionButton = { floatingActionButton = {
AnimatedVisibility( AnimatedVisibility(
visible = isVisible.value, visible = isVisible.value,
@@ -227,14 +343,20 @@ fun MainScreen(
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.padding(bottom = 90.dp).onFocusChanged { modifier =
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { (if (
fobColor = if (it.isFocused) hoverColor else secondaryColor } WireGuardAutoTunnel.isRunningOnAndroidTv() &&
} uiState.tunnels.isEmpty()
, )
onClick = { Modifier.focusRequester(focusRequester)
showBottomSheet = true else Modifier)
}, .padding(bottom = 90.dp)
.onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor
}
},
onClick = { showBottomSheet = true },
containerColor = fobColor, containerColor = fobColor,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(16.dp),
) { ) {
@@ -245,247 +367,351 @@ fun MainScreen(
) )
} }
} }
} },
) { ) { innerPadding ->
if (tunnels.isEmpty()) { AnimatedVisibility(uiState.tunnels.isEmpty(), exit = fadeOut(), enter = fadeIn()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(innerPadding),
) { ) {
Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic) Text(text = stringResource(R.string.no_tunnels), fontStyle = FontStyle.Italic)
} }
} }
if (showBottomSheet) { if (showBottomSheet) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { onDismissRequest = { showBottomSheet = false },
showBottomSheet = false sheetState = sheetState,
},
sheetState = sheetState
) { ) {
// Sheet content // Sheet content
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
try { tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
tunnelFileImportResultLauncher.launch(Constants.ALLOWED_FILE_TYPES)
} catch (e : Exception) {
showSnackbarMessage(e.message!!)
}
} }
.padding(10.dp) .padding(10.dp),
) { ) {
Icon( Icon(
Icons.Filled.FileOpen, Icons.Filled.FileOpen,
contentDescription = stringResource(id = R.string.open_file), contentDescription = stringResource(id = R.string.open_file),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp),
) )
Text( Text(
stringResource(id = R.string.add_tunnels_text), stringResource(id = R.string.add_tunnels_text),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp),
) )
} }
if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Divider() HorizontalDivider()
Row(modifier = Modifier Row(
.fillMaxWidth() modifier =
.clickable { Modifier
scope.launch { .fillMaxWidth()
showBottomSheet = false .clickable {
val scanOptions = ScanOptions() scope.launch {
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE) showBottomSheet = false
scanOptions.setOrientationLocked(true) val scanOptions = ScanOptions()
scanOptions.setPrompt(context.getString(R.string.scanning_qr)) scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setBeepEnabled(false) scanOptions.setOrientationLocked(true)
scanOptions.captureActivity = CaptureActivityPortrait::class.java scanOptions.setPrompt(
scanLauncher.launch(scanOptions) context.getString(R.string.scanning_qr)
)
scanOptions.setBeepEnabled(false)
scanOptions.captureActivity =
CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions)
}
} }
} .padding(10.dp),
.padding(10.dp)
) { ) {
Icon( Icon(
Icons.Filled.QrCode, Icons.Filled.QrCode,
contentDescription = stringResource(id = R.string.qr_scan), contentDescription = stringResource(id = R.string.qr_scan),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp),
) )
Text( Text(
stringResource(id = R.string.add_from_qr), stringResource(id = R.string.add_from_qr),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp),
) )
} }
} }
Divider() HorizontalDivider()
Row( Row(
modifier = Modifier modifier =
Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
navController.navigate("${Routes.Config.name}/${Constants.MANUAL_TUNNEL_CONFIG_ID}") navController.navigate(
"${Screen.Config.route}/${Constants.MANUAL_TUNNEL_CONFIG_ID}",
)
} }
.padding(10.dp) .padding(10.dp),
) { ) {
Icon( Icon(
Icons.Filled.Create, Icons.Filled.Create,
contentDescription = stringResource(id = R.string.create_import), contentDescription = stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp),
) )
Text( Text(
stringResource(id = R.string.create_import), stringResource(id = R.string.create_import),
modifier = Modifier.padding(10.dp) modifier = Modifier.padding(10.dp),
) )
} }
} }
} }
Column(
LazyColumn(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
.fillMaxSize() Modifier
.padding(padding) .fillMaxWidth()
.fillMaxHeight(.90f)
.overscroll(ScrollableDefaults.overscrollEffect())
.padding(innerPadding),
state = rememberLazyListState(0, uiState.tunnels.count()),
userScrollEnabled = true,
reverseLayout = true,
flingBehavior = ScrollableDefaults.flingBehavior(),
) { ) {
LazyColumn( items(
modifier = Modifier uiState.tunnels,
.fillMaxSize() key = { tunnel -> tunnel.id },
.nestedScroll(nestedScrollConnection), ) { tunnel ->
) { val leadingIconColor =
items(tunnels, key = { tunnel -> tunnel.id }) { tunnel -> (if (
val leadingIconColor = (if (tunnelName == tunnel.name) when (handshakeStatus) { uiState.vpnState.name == tunnel.name &&
HandshakeStatus.HEALTHY -> mint uiState.vpnState.status == Tunnel.State.UP
HandshakeStatus.UNHEALTHY -> brickRed ) {
HandshakeStatus.NOT_STARTED -> Color.Gray uiState.vpnState.statistics
HandshakeStatus.NEVER_CONNECTED -> brickRed ?.mapPeerStats()
} else {Color.Gray}) ?.map { it.value?.handshakeStatus() }
val focusRequester = remember { FocusRequester() } .let { statuses ->
RowListItem(icon = { when {
if (settings.isTunnelConfigDefault(tunnel)) statuses?.all { it == HandshakeStatus.HEALTHY } == true -> mint
statuses?.any { it == HandshakeStatus.STALE } == true -> corn
statuses?.all { it == HandshakeStatus.NOT_STARTED } == true ->
Color.Gray
else -> {
Color.Gray
}
}
}
} else {
Color.Gray
})
val expanded = remember { mutableStateOf(false) }
RowListItem(
icon = {
if (uiState.settings.isTunnelConfigDefault(tunnel)) {
Icon( Icon(
Icons.Rounded.Star, "status", Icons.Rounded.Star,
stringResource(R.string.status),
tint = leadingIconColor, tint = leadingIconColor,
modifier = Modifier.padding(end = 10.dp).size(20.dp) modifier = Modifier
.padding(end = 10.dp)
.size(20.dp),
) )
else Icon( } else {
Icons.Rounded.Circle, "status", Icon(
tint = leadingIconColor, Icons.Rounded.Circle,
modifier = Modifier.padding(end = 15.dp).size(15.dp) stringResource(R.string.status),
) tint = leadingIconColor,
modifier = Modifier
.padding(end = 15.dp)
.size(15.dp),
)
}
}, },
text = tunnel.name, text = tunnel.name,
onHold = { onHold = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) { if (
showSnackbarMessage(context.resources.getString(R.string.turn_off_tunnel)) (uiState.vpnState.status == Tunnel.State.UP) &&
return@RowListItem (tunnel.name == uiState.vpnState.name)
) {
showSnackbarMessage(Event.Message.TunnelOffAction.message)
return@RowListItem
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel
},
onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
if (
uiState.vpnState.status == Tunnel.State.UP &&
(uiState.vpnState.name == tunnel.name)
) {
expanded.value = !expanded.value
} }
haptic.performHapticFeedback(HapticFeedbackType.LongPress) } else {
selectedTunnel = tunnel selectedTunnel = tunnel
}, focusRequester.requestFocus()
onClick = { }
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { },
navController.navigate("${Routes.Detail.name}/${tunnel.id}") statistics = uiState.vpnState.statistics,
} else { expanded = expanded.value,
selectedTunnel = tunnel rowButton = {
focusRequester.requestFocus() if (
tunnel.id == selectedTunnel?.id &&
!WireGuardAutoTunnel.isRunningOnAndroidTv()
) {
Row {
if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
IconButton(
onClick = {
if (
uiState.settings.isAutoTunnelEnabled &&
!uiState.settings.isAutoTunnelPaused
) {
showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message,
)
} else {
showPrimaryChangeAlertDialog = true
}
},
) {
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary),
)
}
}
IconButton(
onClick = {
if (
uiState.settings.isAutoTunnelEnabled &&
uiState.settings.isTunnelConfigDefault(
tunnel,
) &&
!uiState.settings.isAutoTunnelPaused
) {
showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message,
)
} else
navController.navigate(
"${Screen.Config.route}/${selectedTunnel?.id}",
)
},
) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
}
IconButton(
modifier = Modifier.focusable(),
onClick = { showDeleteTunnelAlertDialog = true },
) {
Icon(Icons.Rounded.Delete, stringResource(id = R.string.delete))
}
} }
}, } else {
rowButton = { val checked by remember {
if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { derivedStateOf {
(uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name)
}
}
if (!checked) expanded.value = false
@Composable
fun TunnelSwitch() =
Switch(
modifier = Modifier.focusRequester(focusRequester),
checked = checked,
onCheckedChange = { checked ->
if (!checked) expanded.value = false
onTunnelToggle(checked, tunnel)
},
)
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Row { Row {
if(!settings.isTunnelConfigDefault(tunnel)) { if (!uiState.settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = { IconButton(
if(settings.isAutoTunnelEnabled) { onClick = {
showSnackbarMessage(context.resources.getString(R.string.turn_off_auto)) if (uiState.settings.isAutoTunnelEnabled) {
} else showPrimaryChangeAlertDialog = true showSnackbarMessage(
}) { Event.Message.AutoTunnelOffAction.message,
Icon(Icons.Rounded.Star, stringResource(id = R.string.set_primary)) )
} else {
selectedTunnel = tunnel
showPrimaryChangeAlertDialog = true
}
},
) {
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary),
)
} }
} }
IconButton(onClick = { IconButton(
navController.navigate("${Routes.Config.name}/${selectedTunnel?.id}") modifier = Modifier.focusRequester(focusRequester),
}) { onClick = {
if (
uiState.vpnState.status == Tunnel.State.UP &&
(uiState.vpnState.name == tunnel.name)
) {
expanded.value = !expanded.value
} else {
showSnackbarMessage(
Event.Message.TunnelOnAction.message
)
}
},
) {
Icon(Icons.Rounded.Info, stringResource(R.string.info))
}
IconButton(
onClick = {
if (
uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name
) {
showSnackbarMessage(
Event.Message.TunnelOffAction.message
)
} else {
navController.navigate(
"${Screen.Config.route}/${tunnel.id}",
)
}
},
) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit)) Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
} }
IconButton( IconButton(
modifier = Modifier.focusable(), onClick = {
onClick = { viewModel.onDelete(tunnel) }) { if (
uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.name
) {
showSnackbarMessage(
Event.Message.TunnelOffAction.message
)
} else {
showDeleteTunnelAlertDialog = true
}
},
) {
Icon( Icon(
Icons.Rounded.Delete, Icons.Rounded.Delete,
stringResource(id = R.string.delete) stringResource(id = R.string.delete),
) )
} }
TunnelSwitch()
} }
} else { } else {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { TunnelSwitch()
Row {
if(!settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = {
if(settings.isAutoTunnelEnabled) {
showSnackbarMessage(context.resources.getString(R.string.turn_off_auto))
} else showPrimaryChangeAlertDialog = true
}) {
Icon(Icons.Rounded.Star, stringResource(id = R.string.set_primary))
}
}
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
navController.navigate("${Routes.Detail.name}/${tunnel.id}")
}) {
Icon(Icons.Rounded.Info, "Info")
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName)
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
else {
navController.navigate("${Routes.Config.name}/${tunnel.id}")
}
}) {
Icon(
Icons.Rounded.Edit,
stringResource(id = R.string.edit)
)
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName)
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
else {
viewModel.onDelete(tunnel)
}
}) {
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete)
)
}
Switch(
modifier = Modifier.focusRequester(focusRequester),
checked = (state == Tunnel.State.UP && tunnel.name == tunnelName),
onCheckedChange = { checked ->
onTunnelToggle(checked, tunnel)
}
)
}
} else {
Switch(
checked = (state == Tunnel.State.UP && tunnel.name == tunnelName),
onCheckedChange = { checked ->
onTunnelToggle(checked, tunnel)
}
)
}
} }
}) }
} },
)
} }
} }
} }
@@ -0,0 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
import com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
data class MainUiState(
val settings: Settings = Settings(),
val tunnels: TunnelConfigs = emptyList(),
val vpnState: VpnState = VpnState(),
val loading: Boolean = true
)
@@ -8,235 +8,237 @@ import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.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.util.Constants
import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException import com.zaneschepke.wireguardautotunnel.util.Result
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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
@HiltViewModel @HiltViewModel
class MainViewModel @Inject constructor( class MainViewModel
@Inject
constructor(
private val application: Application, private val application: Application,
private val tunnelRepo: TunnelConfigDao, private val tunnelConfigRepository: TunnelConfigRepository,
private val settingsRepo: SettingsDoa, private val settingsRepository: SettingsRepository,
private val vpnService: VpnService private val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val tunnels get() = tunnelRepo.getAllFlow() val uiState =
val state get() = vpnService.state combine(
settingsRepository.getSettingsFlow(),
val handshakeStatus get() = vpnService.handshakeStatus tunnelConfigRepository.getTunnelConfigsFlow(),
val tunnelName get() = vpnService.tunnelName vpnService.vpnState,
private val _settings = MutableStateFlow(Settings()) ) { settings, tunnels, vpnState ->
val settings get() = _settings.asStateFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect {
val settings = it.first()
validateWatcherServiceState(settings) validateWatcherServiceState(settings)
_settings.emit(settings) MainUiState(settings, tunnels, vpnState, false)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
MainUiState(),
)
private fun validateWatcherServiceState(settings: Settings) =
viewModelScope.launch(Dispatchers.IO) {
if (settings.isAutoTunnelEnabled) {
ServiceManager.startWatcherService(application.applicationContext)
} }
} }
}
private fun validateWatcherServiceState(settings: Settings) { private fun stopWatcherService() =
val watcherState = ServiceManager.getServiceState( viewModelScope.launch(Dispatchers.IO) {
application.applicationContext, ServiceManager.stopWatcherService(application.applicationContext)
WireGuardConnectivityWatcherService::class.java
)
if (settings.isAutoTunnelEnabled && watcherState == ServiceState.STOPPED && settings.defaultTunnel != null) {
ServiceManager.startWatcherService(
application.applicationContext,
settings.defaultTunnel!!
)
} }
}
fun onDelete(tunnel: TunnelConfig) { fun onDelete(tunnel: TunnelConfig) {
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
if (tunnelRepo.count() == 1L) { if (tunnelConfigRepository.count() == 1) {
ServiceManager.stopWatcherService(application.applicationContext) stopWatcherService()
val settings = settingsRepo.getAll() val settings = settingsRepository.getSettings()
if (settings.isNotEmpty()) { settings.defaultTunnel = null
val setting = settings[0] settings.isAutoTunnelEnabled = false
setting.defaultTunnel = null settings.isAlwaysOnVpnEnabled = false
setting.isAutoTunnelEnabled = false saveSettings(settings)
setting.isAlwaysOnVpnEnabled = false
settingsRepo.save(setting)
}
} }
tunnelRepo.delete(tunnel) tunnelConfigRepository.delete(tunnel)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
} }
} }
fun onTunnelStart(tunnelConfig: TunnelConfig) { fun onTunnelStart(tunnelConfig: TunnelConfig) =
viewModelScope.launch { viewModelScope.launch(Dispatchers.IO) {
stopActiveTunnel() Timber.d("On start called!")
stopActiveTunnel().await()
startTunnel(tunnelConfig) startTunnel(tunnelConfig)
} }
}
private fun startTunnel(tunnelConfig: TunnelConfig) { private fun startTunnel(tunnelConfig: TunnelConfig) =
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString()) viewModelScope.launch(Dispatchers.IO) {
} Timber.d("Start tunnel via manager")
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString())
}
private suspend fun stopActiveTunnel() { private fun stopActiveTunnel() =
if (ServiceManager.getServiceState( viewModelScope.async(Dispatchers.IO) {
application.applicationContext,
WireGuardTunnelService::class.java,
) == ServiceState.STARTED
) {
onTunnelStop() onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY) delay(Constants.TOGGLE_TUNNEL_DELAY)
} }
}
fun onTunnelStop() { fun onTunnelStop() =
ServiceManager.stopVpnService(application.applicationContext) viewModelScope.launch(Dispatchers.IO) {
} Timber.d("Stopping active tunnel")
ServiceManager.stopVpnService(application.applicationContext)
}
private fun validateConfigString(config: String) { private fun validateConfigString(config: String) {
if (!config.contains(application.getString(R.string.config_validation))) { TunnelConfig.configFromQuick(config)
throw WgTunnelException(application.getString(R.string.config_validation))
}
} }
fun onTunnelQrResult(result: String) { suspend fun onTunnelQrResult(result: String): Result<Unit> {
viewModelScope.launch(Dispatchers.IO) { return try {
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)
} catch (e: WgTunnelException) {
throw WgTunnelException(
e.message ?: application.getString(R.string.unknown_error_message)
)
}
}
}
private fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
viewModelScope.launch(Dispatchers.IO) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
stream.close()
}
}
private fun getInputStreamFromUri(uri: Uri): InputStream {
return application.applicationContext.contentResolver.openInputStream(uri)
?: throw WgTunnelException(application.getString(R.string.stream_failed))
}
suspend fun onTunnelFileSelected(uri: Uri) {
try {
val fileName = getFileName(application.applicationContext, uri)
val fileExtension = getFileExtensionFromFileName(fileName)
when(fileExtension){
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri)
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> throw WgTunnelException(application.getString(R.string.file_extension_message))
}
} catch (e: Exception) { } catch (e: Exception) {
throw WgTunnelException(e.message ?: "Error importing file") Result.Error(Event.Error.InvalidQrCode)
}
}
private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString()))
withContext(Dispatchers.IO) { stream.close() }
}
private fun getInputStreamFromUri(uri: Uri): InputStream? {
return application.applicationContext.contentResolver.openInputStream(uri)
}
suspend fun onTunnelFileSelected(uri: Uri): Result<Unit> {
try {
if (isValidUriContentScheme(uri)) {
val fileName = getFileName(application.applicationContext, uri)
when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION ->
saveTunnelFromConfUri(fileName, uri).let {
when (it) {
is Result.Error -> return Result.Error(Event.Error.FileReadFailed)
is Result.Success -> return it
}
}
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri)
else -> return Result.Error(Event.Error.InvalidFileExtension)
}
return Result.Success(Unit)
} else {
return Result.Error(Event.Error.InvalidFileExtension)
}
} catch (e: Exception) {
return Result.Error(Event.Error.FileReadFailed)
} }
} }
private suspend fun saveTunnelsFromZipUri(uri: Uri) { private suspend fun saveTunnelsFromZipUri(uri: Uri) {
ZipInputStream(getInputStreamFromUri(uri)).use { zip -> ZipInputStream(getInputStreamFromUri(uri)).use { zip ->
generateSequence { zip.nextEntry } generateSequence { zip.nextEntry }
.filterNot { it.isDirectory || .filterNot {
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION } it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION
}
.forEach { .forEach {
val name = getNameFromFileName(it.name) val name = getNameFromFileName(it.name)
val config = Config.parse(zip) val config = Config.parse(zip)
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString())) addTunnel(TunnelConfig(name = name, wgQuick = config.toWgQuickString()))
}
} }
}
} }
} }
private fun saveTunnelFromConfUri(name : String, uri: Uri) { private suspend fun saveTunnelFromConfUri(name: String, uri: Uri): Result<Unit> {
val stream = getInputStreamFromUri(uri) val stream = getInputStreamFromUri(uri)
saveTunnelConfigFromStream(stream, name) return if (stream != null) {
saveTunnelConfigFromStream(stream, name)
Result.Success(Unit)
} else {
Result.Error(Event.Error.FileReadFailed)
}
} }
private suspend fun addTunnel(tunnelConfig: TunnelConfig) { private suspend fun addTunnel(tunnelConfig: TunnelConfig) {
saveTunnel(tunnelConfig) saveTunnel(tunnelConfig)
WireGuardAutoTunnel.requestTileServiceStateUpdate()
} }
fun pauseAutoTunneling() =
viewModelScope.launch {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = true))
}
fun resumeAutoTunneling() =
viewModelScope.launch {
settingsRepository.save(uiState.value.settings.copy(isAutoTunnelPaused = false))
}
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) { private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig) tunnelConfigRepository.save(tunnelConfig)
} }
private fun getFileNameByCursor(context: Context, uri: Uri): String { private fun getFileNameByCursor(context: Context, uri: Uri): String? {
val cursor = context.contentResolver.query(uri, null, null, null, null) context.contentResolver.query(uri, null, null, null, null)?.use {
if (cursor != null) { return getDisplayNameByCursor(it)
cursor.use {
return getDisplayNameByCursor(it)
}
} else {
throw WgTunnelException("Failed to initialize cursor")
} }
return null
} }
private fun getDisplayNameColumnIndex(cursor: Cursor): Int { private fun getDisplayNameColumnIndex(cursor: Cursor): Int? {
val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) val columnIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (columnIndex == -1) { return if (columnIndex != -1) {
throw WgTunnelException("Cursor out of bounds") return columnIndex
}
return columnIndex
}
private fun getDisplayNameByCursor(cursor: Cursor): String {
if (cursor.moveToFirst()) {
val index = getDisplayNameColumnIndex(cursor)
return cursor.getString(index)
} else { } else {
throw WgTunnelException("Cursor failed to move to first") null
} }
} }
private fun validateUriContentScheme(uri: Uri) { private fun getDisplayNameByCursor(cursor: Cursor): String? {
if (uri.scheme != Constants.URI_CONTENT_SCHEME) { return if (cursor.moveToFirst()) {
throw WgTunnelException(application.getString(R.string.file_extension_message)) 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 { private fun getFileName(context: Context, uri: Uri): String {
validateUriContentScheme(uri) return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName()
return try {
getFileNameByCursor(context, uri)
} catch (_: Exception) {
NumberUtils.generateRandomTunnelName()
}
} }
private fun getNameFromFileName(fileName: String): String { private fun getNameFromFileName(fileName: String): String {
@@ -251,14 +253,15 @@ class MainViewModel @Inject constructor(
} }
} }
suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) { private fun saveSettings(settings: Settings) =
if (selectedTunnel != null) { viewModelScope.launch(Dispatchers.IO) { settingsRepository.save(settings) }
_settings.emit(
_settings.value.copy( fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) =
defaultTunnel = selectedTunnel.toString() viewModelScope.launch {
) if (selectedTunnel != null) {
) saveSettings(uiState.value.settings.copy(defaultTunnel = selectedTunnel.toString()))
settingsRepo.save(_settings.value) .join()
WireGuardAutoTunnel.requestTileServiceStateUpdate()
}
} }
} }
}
@@ -1,10 +1,18 @@
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
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.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
@@ -15,7 +23,6 @@ import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -30,6 +37,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.rounded.LocationOff import androidx.compose.material.icons.rounded.LocationOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -38,21 +46,20 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
@@ -65,361 +72,553 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState import com.google.accompanist.permissions.rememberPermissionState
import com.wireguard.android.backend.Tunnel
import com.wireguard.android.backend.WgQuickBackend
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton import com.zaneschepke.wireguardautotunnel.ui.common.ClickableIconButton
import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle import com.zaneschepke.wireguardautotunnel.ui.common.config.ConfigurationToggle
import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt import com.zaneschepke.wireguardautotunnel.ui.common.prompt.AuthorizationPrompt
import com.zaneschepke.wireguardautotunnel.ui.common.screen.LoadingScreen
import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle import com.zaneschepke.wireguardautotunnel.ui.common.text.SectionTitle
import com.zaneschepke.wireguardautotunnel.util.StorageUtil import com.zaneschepke.wireguardautotunnel.util.Event
import com.zaneschepke.wireguardautotunnel.util.FileUtils
import com.zaneschepke.wireguardautotunnel.util.Result
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
import kotlin.math.exp
@OptIn( @OptIn(
ExperimentalPermissionsApi::class, ExperimentalPermissionsApi::class,
ExperimentalLayoutApi::class, ExperimentalComposeUiApi::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()
val context = LocalContext.current val context = LocalContext.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current val scrollState = rememberScrollState()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val settings by viewModel.settings.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val trustedSSIDs by viewModel.trustedSSIDs.collectAsStateWithLifecycle()
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION) val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") } var currentText by remember { mutableStateOf("") }
val scrollState = rememberScrollState()
var didShowLocationDisclaimer by remember { mutableStateOf(false) }
var isBackgroundLocationGranted by remember { mutableStateOf(true) } var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showAuthPrompt 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) }
val focusRequester2 = remember { FocusRequester() }
val screenPadding = 5.dp val screenPadding = 5.dp
val fillMaxWidth = .85f val fillMaxWidth = .85f
if (uiState.loading) {
LoadingScreen()
return
}
val startForResult =
rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
result: ActivityResult ->
if (result.resultCode == Activity.RESULT_OK) {
result.data
// Handle the Intent
}
viewModel.setBatteryOptimizeDisableShown()
}
fun exportAllConfigs() { fun exportAllConfigs() {
try { try {
val files = tunnels.map { File(context.cacheDir, "${it.name}.conf") } val files = uiState.tunnels.map { File(context.cacheDir, "${it.name}.conf") }
files.forEachIndexed() { index, file -> files.forEachIndexed { index, file ->
file.outputStream().use { file.outputStream().use { it.write(uiState.tunnels[index].wgQuick.toByteArray()) }
it.write(tunnels[index].wgQuick.toByteArray())
}
} }
StorageUtil.saveFilesToZip(context, files) FileUtils.saveFilesToZip(context, files)
didExportFiles = true didExportFiles = true
showSnackbarMessage("Exported configs to downloads") showSnackbarMessage(Event.Message.ConfigsExported.message)
} catch (e : Exception) { } catch (e: Exception) {
showSnackbarMessage(e.message!!) showSnackbarMessage(Event.Error.Exception(e).message)
} }
} }
fun isBatteryOptimizationsDisabled(): Boolean {
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() { fun saveTrustedSSID() {
if (currentText.isNotEmpty()) { if (currentText.isNotEmpty()) {
scope.launch { viewModel.onSaveTrustedSSID(currentText).let {
try { when (it) {
viewModel.onSaveTrustedSSID(currentText) is Result.Success -> currentText = ""
currentText = "" is Result.Error -> showSnackbarMessage(it.error.message)
} catch (e : Exception) {
showSnackbarMessage(e.message ?: "Unknown error")
} }
} }
} }
} }
fun isAllAutoTunnelPermissionsEnabled() : Boolean {
return(isBackgroundLocationGranted && fineLocationState.status.isGranted && !viewModel.isLocationServicesNeeded())
}
fun openSettings() { fun openSettings() {
scope.launch { scope.launch {
val intentSettings = val intentSettings = Intent(ACTION_APPLICATION_DETAILS_SETTINGS)
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intentSettings.data = Uri.fromParts("package", context.packageName, null)
intentSettings.data =
Uri.fromParts("package", context.packageName, null)
context.startActivity(intentSettings) context.startActivity(intentSettings)
} }
} }
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { fun checkFineLocationGranted() {
val backgroundLocationState = isBackgroundLocationGranted =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION) if (!fineLocationState.status.isGranted) {
if(!backgroundLocationState.status.isGranted) { false
isBackgroundLocationGranted = false } else {
if(!didShowLocationDisclaimer) { viewModel.setLocationDisclosureShown()
Column( true
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(padding)
) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier = Modifier
.padding(30.dp)
.size(128.dp)
)
Text(
stringResource(R.string.prominent_background_location_title),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 20.sp
)
Text(
stringResource(R.string.prominent_background_location_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 15.sp
)
Row(
modifier = if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) Modifier
.fillMaxWidth()
.padding(10.dp) else Modifier
.fillMaxWidth()
.padding(30.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(onClick = {
didShowLocationDisclaimer = true
}) {
Text(stringResource(id = R.string.no_thanks))
}
TextButton(modifier = Modifier.focusRequester(focusRequester), onClick = {
openSettings()
}) {
Text(stringResource(id = R.string.turn_on))
}
}
}
return
} }
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (
WireGuardAutoTunnel.isRunningOnAndroidTv() &&
Build.VERSION.SDK_INT == Build.VERSION_CODES.Q
) {
checkFineLocationGranted()
} else { } else {
isBackgroundLocationGranted = true val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
isBackgroundLocationGranted =
if (!backgroundLocationState.status.isGranted) {
false
} else {
SideEffect { viewModel.setLocationDisclosureShown() }
true
}
} }
} }
if(showAuthPrompt) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
AuthorizationPrompt(onSuccess = { checkFineLocationGranted()
showAuthPrompt = false
exportAllConfigs() },
onError = { error ->
showSnackbarMessage(error)
showAuthPrompt = false
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage("Authentication failed")
})
} }
if (tunnels.isEmpty()) { AnimatedVisibility(showLocationServicesAlertDialog) {
AlertDialog(
onDismissRequest = { showLocationServicesAlertDialog = false },
confirmButton = {
TextButton(
onClick = {
showLocationServicesAlertDialog = false
handleAutoTunnelToggle()
},
) {
Text(text = stringResource(R.string.okay))
}
},
dismissButton = {
TextButton(onClick = { showLocationServicesAlertDialog = false }) {
Text(text = stringResource(R.string.cancel))
}
},
title = { Text(text = stringResource(R.string.location_services_not_detected)) },
text = { Text(text = stringResource(R.string.location_services_missing_message)) },
)
}
if (!uiState.isLocationDisclosureShown) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = Modifier.fillMaxSize().verticalScroll(scrollState).padding(padding),
) {
Icon(
Icons.Rounded.LocationOff,
contentDescription = stringResource(id = R.string.map),
modifier = Modifier.padding(30.dp).size(128.dp),
)
Text(
stringResource(R.string.prominent_background_location_title),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 20.sp,
)
Text(
stringResource(R.string.prominent_background_location_message),
textAlign = TextAlign.Center,
modifier = Modifier.padding(30.dp),
fontSize = 15.sp,
)
Row(
modifier =
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Modifier.fillMaxWidth().padding(10.dp)
} else {
Modifier.fillMaxWidth().padding(30.dp)
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly,
) {
TextButton(onClick = { viewModel.setLocationDisclosureShown() }) {
Text(stringResource(id = R.string.no_thanks))
}
TextButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
openSettings()
viewModel.setLocationDisclosureShown()
},
) {
Text(stringResource(id = R.string.turn_on))
}
}
}
}
if (showAuthPrompt) {
AuthorizationPrompt(
onSuccess = {
showAuthPrompt = false
exportAllConfigs()
},
onError = { _ ->
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthenticationFailed.message)
},
onFailure = {
showAuthPrompt = false
showSnackbarMessage(Event.Error.AuthorizationFailed.message)
},
)
}
if (uiState.tunnels.isEmpty() && uiState.isLocationDisclosureShown) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center, verticalArrangement = Arrangement.Center,
modifier = Modifier modifier = Modifier.fillMaxSize().padding(padding),
.fillMaxSize()
.padding(padding)
) { ) {
Text( Text(
stringResource(R.string.one_tunnel_required), stringResource(R.string.one_tunnel_required),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
modifier = Modifier.padding(15.dp), modifier = Modifier.padding(15.dp),
fontStyle = FontStyle.Italic fontStyle = FontStyle.Italic,
) )
} }
return
} }
if (uiState.isLocationDisclosureShown && uiState.tunnels.isNotEmpty()) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier modifier =
.fillMaxSize() Modifier.fillMaxSize().padding(padding).verticalScroll(scrollState).clickable(
.verticalScroll(scrollState) indication = null,
.clickable(indication = null, interactionSource = interactionSource) { interactionSource = interactionSource,
focusManager.clearFocus()
}
) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier = (if (WireGuardAutoTunnel.isRunningOnAndroidTv(context))
Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
else Modifier.fillMaxWidth(fillMaxWidth)).padding(top = 60.dp, bottom = 25.dp)
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp)
) {
SectionTitle(title = stringResource(id = R.string.auto_tunneling), padding = screenPadding)
Text(
stringResource(R.string.trusted_ssid),
textAlign = TextAlign.Center,
modifier = Modifier.padding(screenPadding, bottom = 5.dp, top = 5.dp)
)
FlowRow(
modifier = Modifier.padding(screenPadding),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.SpaceEvenly
) { ) {
trustedSSIDs.forEach { ssid -> focusManager.clearFocus()
ClickableIconButton( },
onIconClick = { ) {
scope.launch {
viewModel.onDeleteTrustedSSID(ssid)
}
},
text = ssid,
icon = Icons.Filled.Close,
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled)
)
}
if(trustedSSIDs.isEmpty()) {
Text(stringResource(R.string.none), fontStyle = FontStyle.Italic, color = Color.Gray)
}
}
OutlinedTextField(
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
value = currentText,
onValueChange = { currentText = it },
label = { Text(stringResource(R.string.add_trusted_ssid)) },
modifier = Modifier.padding(start = screenPadding, top = 5.dp).focusRequester(focusRequester).onFocusChanged {
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
keyboardController?.hide()
}
},
maxLines = 1,
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
saveTrustedSSID()
}
),
trailingIcon = {
IconButton(onClick = { saveTrustedSSID() }) {
Icon(
imageVector = Icons.Outlined.Add,
contentDescription = if (currentText == "") stringResource(id = R.string.trusted_ssid_empty_description) else stringResource(
id = R.string.trusted_ssid_value_description
),
tint = if (currentText == "") Color.Transparent else MaterialTheme.colorScheme.primary
)
}
},
)
ConfigurationToggle(stringResource(R.string.tunnel_mobile_data),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnMobileDataEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleTunnelOnMobileData()
}
}
)
ConfigurationToggle(stringResource(id = R.string.tunnel_on_ethernet),
enabled = !(settings.isAutoTunnelEnabled || settings.isAlwaysOnVpnEnabled),
checked = settings.isTunnelOnEthernetEnabled,
padding = screenPadding,
onCheckChanged = {
scope.launch {
viewModel.onToggleTunnelOnEthernet()
}
}
)
ConfigurationToggle(stringResource(R.string.enable_auto_tunnel),
enabled = !settings.isAlwaysOnVpnEnabled,
checked = settings.isAutoTunnelEnabled,
padding = screenPadding,
onCheckChanged = {
if(!isAllAutoTunnelPermissionsEnabled()) {
val message = if(viewModel.isLocationServicesNeeded()){
"Location services required"
} else if(!isBackgroundLocationGranted){
"Background location required"
} else {
"Precise location required"
}
showSnackbarMessage(message)
} else scope.launch {
viewModel.toggleAutoTunnel()
}
}
)
}
}
if(!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
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) modifier =
.height(IntrinsicSize.Min) (if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
.padding(bottom = 180.dp) Modifier.height(IntrinsicSize.Min)
.fillMaxWidth(fillMaxWidth)
.padding(top = 10.dp)
} else {
Modifier.fillMaxWidth(fillMaxWidth).padding(top = 20.dp)
})
.padding(bottom = 10.dp),
) { ) {
Column( Column(
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp) modifier = Modifier.padding(15.dp),
) { ) {
SectionTitle(title = stringResource(id = R.string.other), padding = screenPadding) SectionTitle(
ConfigurationToggle(stringResource(R.string.always_on_vpn_support), title = stringResource(id = R.string.auto_tunneling),
enabled = !settings.isAutoTunnelEnabled,
checked = settings.isAlwaysOnVpnEnabled,
padding = screenPadding, padding = screenPadding,
onCheckChanged = { )
scope.launch { ConfigurationToggle(
viewModel.onToggleAlwaysOnVPN() 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier =
.fillMaxSize() (if (!uiState.settings.isAutoTunnelEnabled) Modifier
.padding(top = 5.dp), else
horizontalArrangement = Arrangement.Center Modifier.focusRequester(
focusRequester,
))
.fillMaxSize()
.padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) { ) {
TextButton( TextButton(
enabled = !didExportFiles, enabled = !uiState.settings.isAlwaysOnVpnEnabled,
onClick = { onClick = {
showAuthPrompt = true if (
}) { uiState.settings.isTunnelOnWifiEnabled &&
Text("Export configs") !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()) {
if(WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { Surface(
Spacer(modifier = Modifier.weight(.17f)) 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(
title = stringResource(id = R.string.kernel),
padding = screenPadding,
)
ConfigurationToggle(
stringResource(R.string.use_kernel),
enabled =
!(uiState.settings.isAutoTunnelEnabled ||
uiState.settings.isAlwaysOnVpnEnabled ||
(uiState.vpnState.status == Tunnel.State.UP)),
checked = uiState.settings.isKernelEnabled,
padding = screenPadding,
onCheckChanged = {
viewModel.onToggleKernelMode().let {
when (it) {
is Result.Error -> showSnackbarMessage(it.error.message)
is Result.Success -> {}
}
}
},
)
}
}
}
if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Surface(
tonalElevation = 2.dp,
shadowElevation = 2.dp,
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surface,
modifier =
Modifier.fillMaxWidth(fillMaxWidth)
.padding(vertical = 10.dp)
.padding(bottom = 140.dp),
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top,
modifier = Modifier.padding(15.dp),
) {
SectionTitle(
title = stringResource(id = R.string.other),
padding = screenPadding,
)
ConfigurationToggle(
stringResource(R.string.always_on_vpn_support),
enabled = !uiState.settings.isAutoTunnelEnabled,
checked = uiState.settings.isAlwaysOnVpnEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleAlwaysOnVPN() },
)
ConfigurationToggle(
stringResource(R.string.enabled_app_shortcuts),
enabled = true,
checked = uiState.settings.isShortcutsEnabled,
padding = screenPadding,
onCheckChanged = { viewModel.onToggleShortcutsEnabled() },
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize().padding(top = 5.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(
enabled = !didExportFiles,
onClick = { showAuthPrompt = true },
) {
Text(stringResource(R.string.export_configs))
}
}
}
}
}
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
Spacer(modifier = Modifier.weight(.17f))
}
} }
} }
} }
@@ -0,0 +1,14 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.settings
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.tunnel.VpnState
data class SettingsUiState(
val settings: Settings = Settings(),
val tunnels: List<TunnelConfig> = emptyList(),
val vpnState: VpnState = VpnState(),
val isLocationDisclosureShown: Boolean = true,
val isBatteryOptimizeDisableShown: Boolean = false,
val loading: Boolean = true
)

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