Compare commits

..

83 Commits

Author SHA1 Message Date
Zane Schepke 6f48147b3e feat: add amneziawg support 2024-04-27 21:44:41 -04:00
Zane Schepke 16979dbb2b Update ic_channel.xml 2024-04-26 01:50:08 -04:00
Hosted Weblate 5d8190628d Update translation files
Updated by "Remove blank strings" hook in Weblate.

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/
2024-04-26 01:50:08 -04:00
Anonymous 6d597a235d Translated using Weblate (Spanish)
Currently translated at 100.0% (159 of 159 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/es/
2024-04-26 01:49:49 -04:00
Anonymous 61fd2f01b9 Translated using Weblate (German)
Currently translated at 49.0% (78 of 159 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/de/
2024-04-26 01:49:08 -04:00
Anonymous 914a641977 Translated using Weblate (Russian)
Currently translated at 3.1% (5 of 159 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ru/
2024-04-26 01:49:08 -04:00
Anonymous c97a3afbc4 Translated using Weblate (Turkish)
Currently translated at 100.0% (159 of 159 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/tr/
2024-04-26 01:49:08 -04:00
gallegonovato fadc2d1562 Translated using Weblate (Spanish)
Currently translated at 100.0% (159 of 159 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/es/
2024-04-26 01:49:08 -04:00
Languages add-on 106ab76b82 Added translation using Weblate (Spanish) 2024-04-26 01:48:39 -04:00
Weblate (bot) 48b3f60b37 Translations update from Hosted Weblate (#177)
Co-authored-by: Languages add-on <noreply-addon-languages@weblate.org>
Co-authored-by: Gabriel Franz <gabrielfranz31@gmail.com>
Co-authored-by: Saratoga79 <ordizi79@gmail.com>
2024-04-24 03:49:57 -04:00
WINZORT e7f5cee6dd Add fastlane tr (#173) 2024-04-22 23:21:19 -04:00
Zane Schepke 5e01fd6c85 Merge pull request #175 from zaneschepke/i18n
Translations update from Hosted Weblate
2024-04-22 23:18:46 -04:00
Dominik Thalhammer dcbd72c6f6 Translated using Weblate (German)
Currently translated at 40.9% (9 of 22 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/de/
2024-04-22 13:18:21 +02:00
Languages add-on f27aa1452a Added translation using Weblate (German) 2024-04-22 07:52:45 +02:00
Languages add-on 82c3521c58 Added translation using Weblate (Russian) 2024-04-21 06:19:53 +02:00
Zane Schepke c6535ffe7e chore: update chats 2024-04-20 18:36:40 -04:00
Zane Schepke be5fe94ce6 chore: cleanup unused strings 2024-04-20 17:42:14 -04:00
Zane Schepke 1e905c1cfb chore: update README.md 2024-04-20 17:09:36 -04:00
Zane Schepke a549c111aa Merge pull request #170 from yurtpage/i18n
i18n
2024-04-20 16:28:15 -04:00
Zane Schepke 5ab344044e chore: add Weblate details to README 2024-04-20 14:30:21 -04:00
Zane Schepke 7030f68548 chore: update README 2024-04-20 13:57:33 -04:00
Zane Schepke ecd51b6fe5 chore: add code of conduct 2024-04-20 13:52:28 -04:00
Yurt Page 3e4f8e0791 fastlane i18n ru
Signed-off-by: Yurt Page <yurtpage@gmail.com>
2024-04-19 18:28:58 +03:00
Zane Schepke e940a0dbc5 Merge pull request #167 from mikropsoft/main
Improve tr locales
2024-04-18 22:12:21 -04:00
WINZORT 8e233475df Update strings.xml 2024-04-17 17:41:49 +03:00
WINZORT 24789826ad Update strings.xml 2024-04-17 17:38:00 +03:00
WINZORT 787e0d1a71 Update strings.xml 2024-04-17 17:37:22 +03:00
WINZORT 914ef53ef0 Improve tr locales 2024-04-17 17:23:27 +03:00
Zane Schepke a569974beb add Turkish localization 2024-04-16 16:13:41 -04:00
Zane Schepke 87bc89b6f1 Merge branch 'main' of https://github.com/zaneschepke/wgtunnel 2024-04-16 00:50:43 -04:00
Zane Schepke c343220e96 Merge pull request #156 from mikropsoft/main
Add tr locales
2024-04-16 00:41:14 -04:00
Zane Schepke 5447ec73f7 fix: stop tunnel regression
Fixes regression where tunnel is stuck in on state after x amount of toggles. Closes #163

Adds obfuscation of potentially sensitive data from logs.
Closes #160

Adds hiding of FAB on scroll to allow users to toggle tunnels when they have many tunnel configs.
Closes #161
2024-04-16 00:14:06 -04:00
Zane Schepke a2b8eb5b0b ci: update release wording 2024-04-06 11:06:19 -04:00
Zane Schepke e37777e662 fix: improve auto-tunnel reliability
Improved service manager stop service command to improve reliability
Closes #148

Added assets required by Google Play for AndroidTV to fix Google Play release

Added apk fingerprint hash to release to allow verification of apk signature via apksigner

Improve tile sync when tiles are first added

Bump versions
2024-04-06 10:43:00 -04:00
WINZORT ee3fcabcf1 Add tr locales 2024-04-06 15:39:46 +03:00
Zane Schepke 2a8895ffbc docs: update readme, add security 2024-03-30 23:16:19 -04:00
Zane Schepke b1fdb5b9b2 feat: auto-tunneling flexibility
Added tunnel settings feature where users can configure a tunnel to be used on certain SSID or with mobile data.
Closes #50

Added feature where if a tunnel was active when phone restarted, the app will start that tunnel on boot.

Removed automatic auto-tunnel toggling/override from the tunnel tile and app shortcuts as it can cause undesirable behavior.

Added second tile to control auto-tunneling pause/resume state from a tile.

Added two additional static shortcuts to be able to control auto-tunneling pause/resume state from shortcuts.

Fixed bug where crashes can happen from serializing and deserializing tunnel configs by removing the need for serialization of tunnel configs.

Refactored logic of watcher and tunnel service to make state more predictable.
#127

Fixed bug where rapidly toggling tunnels can cause crashes.
Closes #145

Improved how tunnels are manually toggled from one to another.

Improved logic/storage around primary tunnel behavior.

Fixes issue where info level logs were not populating on release builds.

Increase allowed name length displayed in UI.
Closes #143

Fixes bug where androidTV could crash in certain situations.

Bump versions.

Updated screenshots.
2024-03-30 00:00:35 -04:00
Zane Schepke 1d644748e5 Update README.md 2024-03-18 23:48:38 -04:00
Zane Schepke 61989b596f Merge pull request #135 from zaneschepke/dependabot/github_actions/softprops/action-gh-release-2
build(deps): bump softprops/action-gh-release from 1 to 2
2024-03-18 23:40:46 -04:00
Zane Schepke 5946d7c10d feat: add lock, logs, and ping
Fixes bug where control tile tunnel did not match with tunnel being controlled Closes #132
Fixes tunnel config edit screen error message #131

Revert to official lib to fix slow speeds issue Closes #137

Adds local app lock feature Closes #88

Adds restart vpn on ping fail with 1 minute interval and 60 minute cooldown Closes #6

Adds ability to easily make a copy of a tunnel.

Fixes bug on AndroidTV where tunnels were not being deleted properly.

Fixes bug where auto tunneling could be turned on before VPN permission was given.
2024-03-18 22:52:00 -04:00
Zane Schepke 4fc8ffbcbb feat: add logs screen 2024-03-13 02:52:57 -04:00
Zane Schepke c0cff297b2 remove firebase 2024-03-11 12:39:00 -04:00
dependabot[bot] 7ca0db3a40 build(deps): bump softprops/action-gh-release from 1 to 2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 13:36:55 +00:00
Zane Schepke ee8db0a859 fix: ui bug and graphene notification
Fixes a bug where save button was hidden on config screen

Adds a disclaimer notification for when GrapheneOS auto enabled Always-on VPN on first app tunnel start

Closes #121 #120
2024-02-20 16:17:34 -05:00
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
242 changed files with 7196 additions and 3956 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
+22
View File
@@ -0,0 +1,22 @@
# Contributor Code of Conduct
## Pledge
We as individuals involved in this project, pledge to participate in this
community in a respectful, constructive, and civil manner as we work towards a common goal
of delivering free, open source, and value adding software for all.
## Standard
The standard for this community is the Golden Rule.
> “Do unto others as you would have them do unto you.”
## Scope
This Code of Conduct applies to all spaces related to WG Tunnel.
## Incidents or Concerns
For any incidents or concerns, reach out to Zane at
<support@zaneschepke.com>.
+2
View File
@@ -0,0 +1,2 @@
ko_fi: zaneschepke
liberapay: zaneschepke
+5 -3
View File
@@ -11,12 +11,14 @@ assignees: zaneschepke
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
**Smartphone (please complete the following information):** **Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- Android Version: [e.g. iOS8.1] - Device: [e.g. Pixel 4a]
- App Version [e.g. 22] - Android Version: [e.g. Android 13]
- App Version [e.g. 3.3.3]
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
2. Click on '....' 2. Click on '....'
3. Scroll down to '....' 3. Scroll down to '....'
+10
View File
@@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: daily
- package-ecosystem: gradle
directory: /
schedule:
interval: daily
@@ -1,27 +1,31 @@
# name of the workflow # name of the workflow
name: Android CI Tag Deployment name: Android CI Tag Deployment (Pre-release)
on: on:
workflow_dispatch:
push: push:
tags: tags:
- '*.*.*' - '*.*.*-**'
jobs: jobs:
build: build:
name: Build Signed APK name: Build Signed APK
# change to macos because of hilt issues on ubuntu in gradle 8.3
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
KEY_STORE_PATH: ${{ secrets.KEY_STORE_PATH }}
SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }}
SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }}
SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }}
KEY_STORE_FILE: 'android_keystore.jks'
KEY_STORE_LOCATION: ${{ github.workspace }}/app/keystore/
GH_USER: ${{ secrets.GH_USER }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up JDK 17 - name: Set up JDK 17
uses: actions/setup-java@v3 uses: actions/setup-java@v4
with: with:
distribution: 'temurin' distribution: 'temurin'
java-version: '17' java-version: '17'
@@ -35,10 +39,16 @@ jobs:
id: decode_keystore id: decode_keystore
uses: timheuer/base64-to-file@v1.2 uses: timheuer/base64-to-file@v1.2
with: with:
fileName: 'android_keystore.jks' fileName: ${{ env.KEY_STORE_FILE }}
fileDir: ${{ github.workspace }}/app/keystore/ fileDir: ${{ env.KEY_STORE_LOCATION }}
encodedString: ${{ secrets.KEYSTORE }} encodedString: ${{ secrets.KEYSTORE }}
# create keystore path for gradle to read
- name: Create keystore path env var
run: |
store_path=${{ env.KEY_STORE_LOCATION }}${{ env.KEY_STORE_FILE }}
echo "KEY_STORE_PATH=$store_path" >> $GITHUB_ENV
- name: Create service_account.json - name: Create service_account.json
id: createServiceAccount id: createServiceAccount
run: echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json run: echo '${{ secrets.SERVICE_ACCOUNT_JSON }}' > service_account.json
@@ -52,36 +62,64 @@ jobs:
- name: Get apk path - name: Get apk path
id: apk-path id: apk-path
run: echo "path=$(find . -regex '^.*/build/outputs/apk/fdroid/release/.*\.apk$' -type f | head -1)" >> $GITHUB_OUTPUT run: echo "path=$(find . -regex '^.*/build/outputs/apk/fdroid/release/.*\.apk$' -type f | head -1)" >> $GITHUB_OUTPUT
- name: Get version code
run: |
version_code=$(grep "VERSION_CODE" buildSrc/src/main/kotlin/Constants.kt | awk '{print $5}' | tr -d '\n')
echo "VERSION_CODE=$version_code" >> $GITHUB_ENV
# Save the APK after the Build job is complete to publish it as a Github release in the next job # Save the APK after the Build job is complete to publish it as a Github release in the next job
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v3.1.2 uses: actions/upload-artifact@v4.3.1
with: with:
name: wgtunnel name: wgtunnel
path: ${{ steps.apk-path.outputs.path }} path: ${{ steps.apk-path.outputs.path }}
- name: Download APK from build - name: Download APK from build
uses: actions/download-artifact@v1 uses: actions/download-artifact@v4
with: with:
name: wgtunnel name: wgtunnel
- name: Create Release with Fastlane changelog notes - name: Create Release with Fastlane changelog notes
id: create_release id: create_release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v2
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
# fix hardcode changelog file name # fix hardcode changelog file name
body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/32500.txt body_path: ${{ github.workspace }}/fastlane/metadata/android/en-US/changelogs/${{ env.VERSION_CODE }}.txt
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: Release ${{ github.ref_name }} name: ${{ github.ref_name }}
draft: false draft: false
prerelease: false prerelease: true
files: ${{ github.workspace }}/${{ steps.apk-path.outputs.path }} files: ${{ github.workspace }}/${{ steps.apk-path.outputs.path }}
- name: Install apksigner
run: |
sudo apt-get update
sudo apt-get install -y apksigner
- name: Get checksum
id: checksum
run: echo "checksum=$(apksigner verify -print-certs ${{ steps.apk-path.outputs.path }} | grep -Po "(?<=SHA-256 digest:) .*" | tr -d "[:blank:]")" >> $GITHUB_OUTPUT
- name: Append checksum
id: append_checksum
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
body: |
SHA256 fingerprint:
```${{ steps.checksum.outputs.checksum }}```
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
draft: false
prerelease: true
append_body: true
- name: Deploy with fastlane - name: Deploy with fastlane
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: '3.2' # Not needed with a .ruby-version file ruby-version: '3.2' # Not needed with a .ruby-version file
bundler-cache: true bundler-cache: true
- name: Distribute app to Beta track 🚀 - name: Distribute app to Beta track 🚀
run: (cd ${{ github.workspace }} && bundle install && bundle exec fastlane beta) run: (cd ${{ github.workspace }} && bundle install && bundle exec fastlane beta)
+131
View File
@@ -0,0 +1,131 @@
# 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@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
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: Install apksigner
run: |
sudo apt-get update
sudo apt-get install -y apksigner
- name: Get checksum
id: checksum
run: echo "checksum=$(apksigner verify -print-certs ${{ steps.apk-path.outputs.path }} | grep -Po "(?<=SHA-256 digest:) .*" | tr -d "[:blank:]")" >> $GITHUB_OUTPUT
- name: Append checksum
id: append_checksum
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
body: |
SHA256 fingerprint:
```${{ steps.checksum.outputs.checksum }}```
tag_name: ${{ github.ref_name }}
name: ${{ github.ref_name }}
draft: false
prerelease: false
append_body: true
- 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)
+47 -22
View File
@@ -4,8 +4,9 @@ WG Tunnel
<div align="center"> <div align="center">
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/rbRRNh6H7V)
[![Discord Chat](https://img.shields.io/discord/1108285024631001111.svg)](https://discord.gg/rbRRNh6H7V) [![X Community](https://img.shields.io/badge/X-000000?style=for-the-badge&logo=x&logoColor=white)](https://twitter.com/i/communities/1780655267685736818)
[![Telegram](https://img.shields.io/badge/Telegram-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/wgtunnel)
</div> </div>
@@ -13,22 +14,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,31 +34,54 @@ This is an alternative Android Application for [WireGuard](https://www.wireguard
## Screenshots ## Screenshots
<p float="center"> <p float="center">
<img label="Main" style="padding-right:25px" src="asset/main_screen.png" width="200" /> <img label="Main" style="padding-right:25px" src="fastlane/metadata/android/en-US/images/phoneScreenshots/main_screen.png" width="200" />
<img label="Config" style="padding-left:25px" src="asset/config_screen.png" width="200" /> <img label="Config" style="padding-left:25px" src="fastlane/metadata/android/en-US/images/phoneScreenshots/config_screen.png" width="200" />
<img label="Settings" style="padding-left:25px" src="asset/settings_screen.png" width="200" /> <img label="Settings" style="padding-left:25px" src="fastlane/metadata/android/en-US/images/phoneScreenshots/settings_screen.png" width="200" />
<img label="Support" style="padding-left:25px" src="asset/support_screen.png" width="200" /> <img label="Support" style="padding-left:25px" src="fastlane/metadata/android/en-US/images/phoneScreenshots/support_screen.png" width="200" />
</p> </p>
<div align="left"> <div align="left">
## Inspiration ## Inspiration
The original inspiration for this app came from the inconvenience of having to manually turn VPN off and on while on different networks. This app was created to offer a free solution to this problem. The original inspiration for this app came from the inconvenience of having to manually turn VPN off
and on while on different networks. This app was created to offer a free solution to this problem.
## Features ## Features
* Add tunnels via .conf file, zip, manual entry, or QR code * Add tunnels via .conf file, zip, manual entry, or QR code
* Auto connect to VPN based on Wi-Fi SSID * Auto connect to tunnels based on Wi-Fi SSID, ethernet, or mobile data
* Split tunneling by application with search * Split tunneling by application with search
* Always-on VPN for Android support * WireGuard support for kernel and userspace modes
* Always-On VPN support
* Export tunnels to zip * Export tunnels to zip
* Quick tile support for VPN toggling * Quick tile support for tunnel toggling, auto-tunneling
* Static shortcuts support for primary tunnel for automation integration * Static shortcuts support for tunnel toggling, auto-tunneling
* Intent automation support for all tunnels * Intent automation support for all tunnels
* Optional auto connect on mobile data, ethernet * Automatic auto-tunneling service restart after reboot
* Automatic service restart after reboot * Automatic tunnel restart after reboot
* Service will stay running in background after app has been closed * Battery preservation measures
* Restart tunnel on ping failure (beta)
## Docs
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).
## Contributing
Any contributions in the form of feedback, issues, code, or translations are welcome and much appreciated!
Please read the [code of conduct](https://github.com/zaneschepke/wgtunnel?tab=coc-ov-file#contributor-code-of-conduct) before contributing.
## Translation
This app is using [Weblate](https://weblate.org) to assist with translations.
Help translate WG Tunnel into your language at [Hosted Weblate](https://hosted.weblate.org/engage/wg-tunnel/).\
[![Translation status](https://hosted.weblate.org/widgets/wg-tunnel/-/multi-auto.svg)](https://hosted.weblate.org/engage/wg-tunnel/)
## Building ## Building
@@ -69,6 +89,11 @@ The original inspiration for this app came from the inconvenience of having to m
``` ```
$ git clone https://github.com/zaneschepke/wgtunnel $ git clone https://github.com/zaneschepke/wgtunnel
$ cd wgtunnel $ cd wgtunnel
```
And then build the app:
```
$ ./gradlew assembleDebug $ ./gradlew assembleDebug
``` ```
+5
View File
@@ -0,0 +1,5 @@
# Security Policy
## Reporting a Vulnerability
Please report security issues to `support@zaneschepke.com`
+50 -54
View File
@@ -4,7 +4,7 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt.android) alias(libs.plugins.hilt.android)
id("org.jetbrains.kotlin.plugin.serialization") alias(libs.plugins.kotlinxSerialization)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
} }
@@ -12,6 +12,10 @@ android {
namespace = Constants.APP_ID namespace = Constants.APP_ID
compileSdk = Constants.TARGET_SDK compileSdk = Constants.TARGET_SDK
androidResources {
generateLocaleConfig = true
}
defaultConfig { defaultConfig {
applicationId = Constants.APP_ID applicationId = Constants.APP_ID
minSdk = Constants.MIN_SDK minSdk = Constants.MIN_SDK
@@ -19,20 +23,14 @@ android {
versionCode = Constants.VERSION_CODE versionCode = Constants.VERSION_CODE
versionName = Constants.VERSION_NAME versionName = Constants.VERSION_NAME
ksp { ksp { arg("room.schemaLocation", "$projectDir/schemas") }
arg("room.schemaLocation", "$projectDir/schemas")
}
sourceSets { sourceSets {
getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room getByName("debug").assets.srcDirs(files("$projectDir/schemas")) // Room
} }
resourceConfigurations.addAll(listOf("en"))
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables { useSupportLibrary = true }
useSupportLibrary = true
}
} }
signingConfigs { signingConfigs {
@@ -47,32 +45,41 @@ android {
} }
} }
// try to get secrets from env first for pipeline build, then properties file for local build // try to get secrets from env first for pipeline build, then properties file for local
storeFile = file( // build
System.getenv().getOrDefault( storeFile =
Constants.KEY_STORE_PATH_VAR, file(
properties.getProperty(Constants.KEY_STORE_PATH_VAR) System.getenv()
.getOrDefault(
Constants.KEY_STORE_PATH_VAR,
properties.getProperty(Constants.KEY_STORE_PATH_VAR),
),
) )
) storePassword =
storePassword = System.getenv().getOrDefault( System.getenv()
Constants.STORE_PASS_VAR, .getOrDefault(
properties.getProperty(Constants.STORE_PASS_VAR) Constants.STORE_PASS_VAR,
) properties.getProperty(Constants.STORE_PASS_VAR),
keyAlias = System.getenv().getOrDefault( )
Constants.KEY_ALIAS_VAR, keyAlias =
properties.getProperty(Constants.KEY_ALIAS_VAR) System.getenv()
) .getOrDefault(
keyPassword = System.getenv().getOrDefault( Constants.KEY_ALIAS_VAR,
Constants.KEY_PASS_VAR, properties.getProperty(Constants.KEY_ALIAS_VAR),
properties.getProperty(Constants.KEY_PASS_VAR) )
) keyPassword =
System.getenv()
.getOrDefault(
Constants.KEY_PASS_VAR,
properties.getProperty(Constants.KEY_PASS_VAR),
)
} }
} }
buildTypes { buildTypes {
// don't strip // don't strip
packaging.jniLibs.keepDebugSymbols.addAll( packaging.jniLibs.keepDebugSymbols.addAll(
listOf("libwg-go.so", "libwg-quick.so", "libwg.so") listOf("libwg-go.so", "libwg-quick.so", "libwg.so"),
) )
applicationVariants.all { applicationVariants.all {
@@ -91,13 +98,11 @@ android {
isShrinkResources = true isShrinkResources = true
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro",
) )
signingConfig = signingConfigs.getByName(Constants.RELEASE) signingConfig = signingConfigs.getByName(Constants.RELEASE)
} }
debug { debug { isDebuggable = true }
isDebuggable = true
}
} }
flavorDimensions.add(Constants.TYPE) flavorDimensions.add(Constants.TYPE)
productFlavors { productFlavors {
@@ -108,8 +113,7 @@ android {
create("general") { create("general") {
dimension = Constants.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") //any plugins general specific
apply(plugin = "com.google.firebase.crashlytics")
} }
} }
} }
@@ -118,28 +122,24 @@ android {
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions { jvmTarget = Constants.JVM_TARGET }
jvmTarget = Constants.JVM_TARGET
}
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true buildConfig = true
} }
composeOptions { composeOptions { kotlinCompilerExtensionVersion = Constants.COMPOSE_COMPILER_EXTENSION_VERSION }
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
} }
val generalImplementation by configurations val generalImplementation by configurations
dependencies { dependencies {
implementation(project(":logcatter"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
// optional - helpers for implementing LifecycleOwner in a Service // helpers for implementing LifecycleOwner in a Service
implementation(libs.androidx.lifecycle.service) implementation(libs.androidx.lifecycle.service)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom)) implementation(platform(libs.androidx.compose.bom))
@@ -160,13 +160,14 @@ dependencies {
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
debugImplementation(libs.androidx.compose.manifest) debugImplementation(libs.androidx.compose.manifest)
// wg // tunnel
implementation(libs.tunnel) implementation(libs.amneziawg.android)
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)
// logging // logging
implementation(libs.timber) implementation(libs.timber)
// compose navigation // compose navigation
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.hilt.navigation.compose)
@@ -176,7 +177,6 @@ dependencies {
ksp(libs.hilt.android.compiler) ksp(libs.hilt.android.compiler)
// accompanist // accompanist
implementation(libs.accompanist.systemuicontroller)
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.accompanist.flowlayout) implementation(libs.accompanist.flowlayout)
implementation(libs.accompanist.drawablepainter) implementation(libs.accompanist.drawablepainter)
@@ -197,17 +197,13 @@ dependencies {
// serialization // serialization
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
// firebase crashlytics
generalImplementation(platform(libs.firebase.bom))
generalImplementation(libs.google.firebase.crashlytics.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)
implementation(libs.pin.lock.compose)
// shortcuts // shortcuts
implementation(libs.androidx.core) implementation(libs.androidx.core)
-39
View File
@@ -1,39 +0,0 @@
{
"project_info": {
"project_number": "328300975830",
"project_id": "wireguard-auto-tunnel",
"storage_bucket": "wireguard-auto-tunnel.appspot.com"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:328300975830:android:82cd774598ccb7234b1b77",
"android_client_info": {
"package_name": "com.zaneschepke.wireguardautotunnel"
}
},
"oauth_client": [
{
"client_id": "328300975830-m72lc3hr69ddhdqh9ngr27rvc8o0jb2d.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyBsSMY0LlckizXDnuYBy7nXWGSdl8zZedI"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "328300975830-m72lc3hr69ddhdqh9ngr27rvc8o0jb2d.apps.googleusercontent.com",
"client_type": 3
}
]
}
}
}
],
"configuration_version": "1"
}
+2
View File
@@ -22,3 +22,5 @@
-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite { -keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite {
<fields>; <fields>;
} }
@@ -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')"
]
}
}
@@ -0,0 +1,168 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "625820076477aca948536f7bccccc7ca",
"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, `is_ping_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"
},
{
"fieldPath": "isAutoTunnelPaused",
"columnName": "is_auto_tunnel_paused",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_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, '625820076477aca948536f7bccccc7ca')"
]
}
}
@@ -0,0 +1,176 @@
{
"formatVersion": 1,
"database": {
"version": 7,
"identityHash": "e65e4e7cf01f50fb03196d47b54288b1",
"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, `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_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, `is_ping_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": "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": "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"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_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, `tunnel_networks` TEXT NOT NULL DEFAULT '', `is_mobile_data_tunnel` INTEGER NOT NULL DEFAULT false, `is_primary_tunnel` INTEGER NOT NULL DEFAULT false)",
"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
},
{
"fieldPath": "tunnelNetworks",
"columnName": "tunnel_networks",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isMobileDataTunnel",
"columnName": "is_mobile_data_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPrimaryTunnel",
"columnName": "is_primary_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
}
],
"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, 'e65e4e7cf01f50fb03196d47b54288b1')"
]
}
}
@@ -3,52 +3,33 @@ package com.zaneschepke.wireguardautotunnel
import androidx.room.testing.MigrationTestHelper import androidx.room.testing.MigrationTestHelper
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import java.io.IOException import com.zaneschepke.wireguardautotunnel.data.Queries
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.IOException
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MigrationTest { class MigrationTest {
private val dbName = "migration-test" private val dbName = "migration-test"
@get:Rule @get:Rule
val helper: MigrationTestHelper = MigrationTestHelper( val helper: MigrationTestHelper =
InstrumentationRegistry.getInstrumentation(), MigrationTestHelper(
AppDatabase::class.java InstrumentationRegistry.getInstrumentation(),
) AppDatabase::class.java,
)
@Test @Test
@Throws(IOException::class) @Throws(IOException::class)
fun migrate2To3() { fun migrate6To7() {
helper.createDatabase(dbName, 3).apply { helper.createDatabase(dbName, 6).apply {
// Database has schema version 1. Insert some data using SQL queries. // Database has schema version 1. Insert some data using SQL queries.
// You can't use DAO classes because they expect the latest schema. // You can't use DAO classes because they expect the latest schema.
execSQL(Queries.createDefaultSettings())
execSQL( execSQL(
"INSERT INTO Settings (is_tunnel_enabled, " + Queries.createTunnelConfig(),
"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)" +
" VALUES (" +
"false," +
"false," +
"'[trustedSSID1,trustedSSID2]'," +
"'defaultTunnel'," +
"false," +
"false," +
"false," +
"false," +
"false)"
)
execSQL(
"INSERT INTO TunnelConfig (name, wg_quick)" +
" VALUES ('hello', 'hello')"
) )
// Prepare for the next version. // Prepare for the next version.
close() close()
@@ -56,7 +37,7 @@ class MigrationTest {
// Re-open the database with version 2 and provide // Re-open the database with version 2 and provide
// MIGRATION_1_2 as the migration process. // MIGRATION_1_2 as the migration process.
helper.runMigrationsAndValidate(dbName, 4, true) helper.runMigrationsAndValidate(dbName, 7, true)
// MigrationTestHelper automatically verifies the schema changes, // MigrationTestHelper automatically verifies the schema changes,
// but you need to validate that the data was migrated properly. // but you need to validate that the data was migrated properly.
} }
+86 -46
View File
@@ -1,56 +1,62 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission <uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32" android:maxSdkVersion="32"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" <uses-permission
android:name="android.permission.ACCESS_WIFI_STATE"
android:maxSdkVersion="30" android:maxSdkVersion="30"
tools:ignore="LeanbackUsesWifi" /> tools:ignore="LeanbackUsesWifi" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/> <uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!--foreground service exempt android 14--> <!--foreground service exempt android 14-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/> <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<!--foreground service permissions--> <!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!--start service on boot permission--> <!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!--android tv support--> <!--android tv support-->
<uses-feature android:name="android.software.leanback" <uses-feature
android:name="android.software.leanback"
android:required="false" /> android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" <uses-feature
android:name="android.hardware.touchscreen"
android:required="false" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.hardware.location.gps" android:name="android.hardware.location.gps"
android:required="false" /> android:required="false" />
<uses-feature <uses-feature
android:name="android.hardware.screen.portrait" android:name="android.hardware.screen.portrait"
android:required="false" /> android:required="false" />
<queries> <queries>
<intent> <intent>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
</intent> </intent>
</queries> </queries>
<application <application
android:allowBackup="true"
android:enableOnBackInvokedCallback="true"
android:name=".WireGuardAutoTunnel" android:name=".WireGuardAutoTunnel"
android:allowBackup="true"
android:banner="@drawable/ic_banner"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:banner="@mipmap/ic_banner"
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
@@ -62,11 +68,14 @@
android:theme="@style/Theme.WireguardAutoTunnel"> android:theme="@style/Theme.WireguardAutoTunnel">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" /> <category android:name="android.intent.category.LEANBACK_LAUNCHER" />
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES"/>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
</intent-filter> </intent-filter>
<meta-data android:name="android.app.shortcuts" <meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" /> android:resource="@xml/shortcuts" />
</activity> </activity>
<activity <activity
@@ -74,63 +83,94 @@
android:screenOrientation="fullSensor" android:screenOrientation="fullSensor"
android:stateNotNeeded="true" android:stateNotNeeded="true"
android:theme="@style/zxing_CaptureTheme" android:theme="@style/zxing_CaptureTheme"
android:windowSoftInputMode="stateAlwaysHidden" /> android:windowSoftInputMode="stateAlwaysHidden"
tools:ignore="DiscouragedApi" />
<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="systemExempted|specialUse" android:exported="false"
tools:node="merge" android:foregroundServiceType="systemExempted"
android:exported="false"> tools:node="merge" />
<service
android:name=".service.tile.TunnelControlTile"
android:exported="true"
android:icon="@drawable/ic_launcher"
android:label="Tunnel control"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<meta-data
android:name="android.service.quicksettings.ACTIVE_TILE"
android:value="true" />
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="true" />
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service> </service>
<service <service
android:name=".service.tile.AutoTunnelControlTile"
android:exported="true" android:exported="true"
android:name=".service.tile.TunnelControlTile" android:icon="@drawable/ic_launcher"
android:icon="@drawable/shield" android:label="Auto-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"
android:permission="android.permission.BIND_VPN_SERVICE"
android:persistent="true" android:persistent="true"
android:foregroundServiceType="systemExempted|specialUse" tools:node="merge">
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"
android:persistent="true" android:persistent="true"
android:foregroundServiceType="systemExempted|specialUse" android:stopWithTask="false"
tools:node="merge" 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,21 +0,0 @@
package com.zaneschepke.wireguardautotunnel
object Constants {
const val MANUAL_TUNNEL_CONFIG_ID = "0"
const val WATCHER_SERVICE_WAKE_LOCK_TIMEOUT = 10 * 60 * 1000L // 10 minutes
const val VPN_CONNECTIVITY_CHECK_INTERVAL = 3000L
const val VPN_STATISTIC_CHECK_INTERVAL = 1000L
const val TOGGLE_TUNNEL_DELAY = 500L
const val FADE_IN_ANIMATION_DURATION = 1000
const val SLIDE_IN_ANIMATION_DURATION = 500
const val SLIDE_IN_TRANSITION_OFFSET = 1000
const val CONF_FILE_EXTENSION = ".conf"
const val ZIP_FILE_EXTENSION = ".zip"
const val URI_CONTENT_SCHEME = "content"
const val URI_PACKAGE_SCHEME = "package"
const val ALLOWED_FILE_TYPES = "*/*"
const val GOOGLE_TV_EXPLORER_STUB = "com.google.android.tv.frameworkpackagestubs"
const val ANDROID_TV_EXPLORER_STUB = "com.android.tv.frameworkpackagestubs"
const val EMAIL_MIME_TYPE = "message/rfc822"
const val SYSTEM_EXEMPT_SERVICE_TYPE_ID = 1024
}
@@ -1,31 +0,0 @@
package com.zaneschepke.wireguardautotunnel
import android.content.BroadcastReceiver
import java.math.BigDecimal
import java.text.DecimalFormat
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
fun BroadcastReceiver.goAsync(
context: CoroutineContext = EmptyCoroutineContext,
block: suspend CoroutineScope.() -> Unit
) {
val pendingResult = goAsync()
@OptIn(DelicateCoroutinesApi::class) // Must run globally; there's no teardown callback.
GlobalScope.launch(context) {
try {
block()
} finally {
pendingResult.finish()
}
}
}
fun BigDecimal.toThreeDecimalPlaceString(): String {
val df = DecimalFormat("#.###")
return df.format(this)
}
@@ -1,56 +1,46 @@
package com.zaneschepke.wireguardautotunnel package com.zaneschepke.wireguardautotunnel
import android.app.Application import android.app.Application
import android.content.ComponentName
import android.content.Context import android.content.Context
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.AutoTunnelControlTile
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.service.tile.TunnelControlTile
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import java.io.IOException
import javax.inject.Inject
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import xyz.teamgravity.pin_lock_compose.PinManager
@HiltAndroidApp @HiltAndroidApp
class WireGuardAutoTunnel : Application() { class WireGuardAutoTunnel : Application() {
@Inject
lateinit var settingsRepo: SettingsDoa
@Inject
lateinit var dataStoreManager: DataStoreManager
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) instance = this
initSettings() if (BuildConfig.DEBUG) Timber.plant(Timber.DebugTree()) else Timber.plant(ReleaseTree())
with(ProcessLifecycleOwner.get()) { PinManager.initialize(this)
lifecycleScope.launch {
try {
// load preferences into memory
dataStoreManager.init()
} catch (e: IOException) {
Timber.e("Failed to load preferences")
}
}
}
}
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 requestTunnelTileServiceStateUpdate(context: Context) {
TileService.requestListeningState(
context,
ComponentName(instance, TunnelControlTile::class.java),
)
}
fun requestAutoTunnelTileServiceUpdate(context: Context) {
TileService.requestListeningState(
context,
ComponentName(instance, AutoTunnelControlTile::class.java),
)
} }
} }
} }
@@ -0,0 +1,54 @@
package com.zaneschepke.wireguardautotunnel.data
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.DeleteColumn
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.AutoMigrationSpec
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
@Database(
entities = [Settings::class, TunnelConfig::class],
version = 7,
autoMigrations =
[
AutoMigration(from = 1, to = 2),
AutoMigration(from = 2, to = 3),
AutoMigration(
from = 3,
to = 4,
),
AutoMigration(
from = 4,
to = 5,
),
AutoMigration(
from = 5,
to = 6,
),
AutoMigration(
from = 6,
to = 7,
spec = RemoveLegacySettingColumnsMigration::class,
),
],
exportSchema = true,
)
@TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun settingDao(): SettingsDao
abstract fun tunnelConfigDoa(): TunnelConfigDao
}
@DeleteColumn(
tableName = "Settings",
columnName = "default_tunnel",
)
@DeleteColumn(
tableName = "Settings",
columnName = "is_battery_saver_enabled",
)
class RemoveLegacySettingColumnsMigration : AutoMigrationSpec
@@ -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()
}
}
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.repository package com.zaneschepke.wireguardautotunnel.data
import androidx.room.TypeConverter import androidx.room.TypeConverter
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -12,7 +12,7 @@ class DatabaseListConverters {
@TypeConverter @TypeConverter
fun stringToList(value: String): MutableList<String> { fun stringToList(value: String): MutableList<String> {
if (value.isEmpty()) return mutableListOf() if (value.isBlank() || value.isEmpty()) return mutableListOf()
return try { return try {
Json.decodeFromString<MutableList<String>>(value) Json.decodeFromString<MutableList<String>>(value)
} catch (e: Exception) { } catch (e: Exception) {
@@ -0,0 +1,35 @@
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,
is_always_on_vpn_enabled,
is_tunnel_on_ethernet_enabled,
is_shortcuts_enabled,
is_tunnel_on_wifi_enabled,
is_kernel_enabled,
is_restore_on_boot_enabled,
is_multi_tunnel_enabled)
VALUES
('false',
'false',
'sampleSSID1,sampleSSID2',
'false',
'false',
'false',
'false',
'false',
'false',
'false')
""".trimIndent()
}
fun createTunnelConfig(): String {
return """
INSERT INTO TunnelConfig (name, wg_quick) VALUES ('test', 'test')
""".trimIndent()
}
}
@@ -1,15 +1,15 @@
package com.zaneschepke.wireguardautotunnel.repository package com.zaneschepke.wireguardautotunnel.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.data.model.Settings
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface SettingsDoa { interface SettingsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(t: Settings) suspend fun save(t: Settings)
@@ -22,6 +22,9 @@ interface SettingsDoa {
@Query("SELECT * FROM settings") @Query("SELECT * FROM settings")
suspend fun getAll(): List<Settings> suspend fun getAll(): List<Settings>
@Query("SELECT * FROM settings LIMIT 1")
fun getSettingsFlow(): Flow<Settings>
@Query("SELECT * FROM settings") @Query("SELECT * FROM settings")
fun getAllFlow(): Flow<MutableList<Settings>> fun getAllFlow(): Flow<MutableList<Settings>>
@@ -0,0 +1,49 @@
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 com.zaneschepke.wireguardautotunnel.util.TunnelConfigs
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: TunnelConfigs)
@Query("SELECT * FROM TunnelConfig WHERE id=:id")
suspend fun getById(id: Long): TunnelConfig?
@Query("SELECT * FROM TunnelConfig")
suspend fun getAll(): TunnelConfigs
@Delete
suspend fun delete(t: TunnelConfig)
@Query("SELECT COUNT('id') FROM TunnelConfig")
suspend fun count(): Long
@Query("SELECT * FROM TunnelConfig WHERE tunnel_networks LIKE '%' || :name || '%'")
suspend fun findByTunnelNetworkName(name: String): TunnelConfigs
@Query("UPDATE TunnelConfig SET is_primary_tunnel = 0 WHERE is_primary_tunnel =1")
fun resetPrimaryTunnel()
@Query("UPDATE TunnelConfig SET is_mobile_data_tunnel = 0 WHERE is_mobile_data_tunnel =1")
fun resetMobileDataTunnel()
@Query("SELECT * FROM TUNNELCONFIG WHERE is_primary_tunnel=1")
suspend fun findByPrimary(): TunnelConfigs
@Query("SELECT * FROM TUNNELCONFIG WHERE is_mobile_data_tunnel=1")
suspend fun findByMobileDataTunnel(): TunnelConfigs
@Query("SELECT * FROM tunnelconfig")
fun getAllFlow(): Flow<MutableList<TunnelConfig>>
}
@@ -0,0 +1,70 @@
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.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.io.IOException
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")
val TUNNEL_RUNNING_FROM_MANUAL_START =
booleanPreferencesKey("TUNNEL_RUNNING_FROM_MANUAL_START")
val ACTIVE_TUNNEL = intPreferencesKey("ACTIVE_TUNNEL")
val CURRENT_SSID = stringPreferencesKey("CURRENT_SSID")
}
// preferences
private val preferencesKey = "preferences"
private val Context.dataStore by
preferencesDataStore(
name = preferencesKey,
)
suspend fun init() {
try {
context.dataStore.data.first()
} catch (e: IOException) {
Timber.e(e)
}
}
suspend fun <T> saveToDataStore(key: Preferences.Key<T>, value: T) {
try {
context.dataStore.edit { it[key] = value }
} catch (e: IOException) {
Timber.e(e)
} catch (e: Exception) {
Timber.e(e)
}
}
fun <T> getFromStoreFlow(key: Preferences.Key<T>) = context.dataStore.data.map { it[key] }
suspend fun <T> getFromStore(key: Preferences.Key<T>): T? {
return try {
context.dataStore.data.map { it[key] }.first()
} catch (e: IOException) {
Timber.e(e)
null
}
}
fun <T> getFromStoreBlocking(key: Preferences.Key<T>) = runBlocking {
context.dataStore.data.map { it[key] }.first()
}
val preferencesFlow: Flow<Preferences?> = context.dataStore.data
}
@@ -0,0 +1,14 @@
package com.zaneschepke.wireguardautotunnel.data.model
data class GeneralState(
val locationDisclosureShown: Boolean = LOCATION_DISCLOSURE_SHOWN_DEFAULT,
val batteryOptimizationDisableShown: Boolean = BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT,
val tunnelRunningFromManualStart: Boolean = TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT,
val activeTunnelId: Int? = null
) {
companion object {
const val LOCATION_DISCLOSURE_SHOWN_DEFAULT = false
const val BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT = false
const val TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT = false
}
}
@@ -0,0 +1,53 @@
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") val isAutoTunnelEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled")
val isTunnelOnMobileDataEnabled: Boolean = false,
@ColumnInfo(name = "trusted_network_ssids")
val trustedNetworkSSIDs: MutableList<String> = mutableListOf(),
@ColumnInfo(name = "is_always_on_vpn_enabled") val isAlwaysOnVpnEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled")
val isTunnelOnEthernetEnabled: Boolean = false,
@ColumnInfo(
name = "is_shortcuts_enabled",
defaultValue = "false",
)
val isShortcutsEnabled: Boolean = false,
@ColumnInfo(
name = "is_tunnel_on_wifi_enabled",
defaultValue = "false",
)
val isTunnelOnWifiEnabled: Boolean = false,
@ColumnInfo(
name = "is_kernel_enabled",
defaultValue = "false",
)
val isKernelEnabled: Boolean = false,
@ColumnInfo(
name = "is_restore_on_boot_enabled",
defaultValue = "false",
)
val isRestoreOnBootEnabled: Boolean = false,
@ColumnInfo(
name = "is_multi_tunnel_enabled",
defaultValue = "false",
)
val isMultiTunnelEnabled: Boolean = false,
@ColumnInfo(
name = "is_auto_tunnel_paused",
defaultValue = "false",
)
val isAutoTunnelPaused: Boolean = false,
@ColumnInfo(
name = "is_ping_enabled",
defaultValue = "false",
)
val isPingEnabled: Boolean = false,
)
@@ -0,0 +1,38 @@
package com.zaneschepke.wireguardautotunnel.data.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import org.amnezia.awg.config.Config
import java.io.InputStream
@Entity(indices = [Index(value = ["name"], unique = true)])
data class TunnelConfig(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "wg_quick") val wgQuick: String,
@ColumnInfo(
name = "tunnel_networks",
defaultValue = "",
)
val tunnelNetworks: MutableList<String> = mutableListOf(),
@ColumnInfo(
name = "is_mobile_data_tunnel",
defaultValue = "false",
)
val isMobileDataTunnel: Boolean = false,
@ColumnInfo(
name = "is_primary_tunnel",
defaultValue = "false",
)
val isPrimaryTunnel: Boolean = false,
) {
companion object {
fun configFromQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream()
val reader = inputStream.bufferedReader(Charsets.UTF_8)
return Config.parse(reader)
}
}
}
@@ -0,0 +1,14 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
interface AppDataRepository {
suspend fun getPrimaryOrFirstTunnel(): TunnelConfig?
suspend fun getStartTunnelConfig(): TunnelConfig?
suspend fun toggleWatcherServicePause()
val settings: SettingsRepository
val tunnels: TunnelConfigRepository
val appState: AppStateRepository
}
@@ -0,0 +1,34 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import javax.inject.Inject
class AppDataRoomRepository @Inject constructor(
override val settings: SettingsRepository,
override val tunnels: TunnelConfigRepository,
override val appState: AppStateRepository
) : AppDataRepository {
override suspend fun getPrimaryOrFirstTunnel(): TunnelConfig? {
return tunnels.findPrimary().firstOrNull() ?: tunnels.getAll().firstOrNull()
}
override suspend fun getStartTunnelConfig(): TunnelConfig? {
return if (appState.isTunnelRunningFromManualStart()) {
appState.getActiveTunnelId()?.let {
tunnels.getById(it)
}
} else null
}
override suspend fun toggleWatcherServicePause() {
val settings = settings.getSettings()
if (settings.isAutoTunnelEnabled) {
val pauseAutoTunnel = !settings.isAutoTunnelPaused
this.settings.save(
settings.copy(
isAutoTunnelPaused = pauseAutoTunnel,
),
)
}
}
}
@@ -0,0 +1,26 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.model.GeneralState
import kotlinx.coroutines.flow.Flow
interface AppStateRepository {
suspend fun isLocationDisclosureShown(): Boolean
suspend fun setLocationDisclosureShown(shown: Boolean)
suspend fun isBatteryOptimizationDisableShown(): Boolean
suspend fun setBatteryOptimizationDisableShown(shown: Boolean)
suspend fun isTunnelRunningFromManualStart(): Boolean
suspend fun setTunnelRunningFromManualStart(id: Int)
suspend fun setManualStop()
suspend fun getActiveTunnelId(): Int?
suspend fun getCurrentSsid(): String?
suspend fun setCurrentSsid(ssid: String)
val generalStateFlow: Flow<GeneralState>
}
@@ -0,0 +1,81 @@
package com.zaneschepke.wireguardautotunnel.data.repository
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.model.GeneralState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager) :
AppStateRepository {
override suspend fun isLocationDisclosureShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN)
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT
}
override suspend fun setLocationDisclosureShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.LOCATION_DISCLOSURE_SHOWN, shown)
}
override suspend fun isBatteryOptimizationDisableShown(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN)
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT
}
override suspend fun setBatteryOptimizationDisableShown(shown: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN, shown)
}
override suspend fun isTunnelRunningFromManualStart(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.TUNNEL_RUNNING_FROM_MANUAL_START)
?: GeneralState.TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT
}
override suspend fun setTunnelRunningFromManualStart(id: Int) {
setTunnelRunningFromManualStart(true)
setActiveTunnelId(id)
}
override suspend fun setManualStop() {
setTunnelRunningFromManualStart(false)
}
private suspend fun setTunnelRunningFromManualStart(running: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.TUNNEL_RUNNING_FROM_MANUAL_START, running)
}
override suspend fun getActiveTunnelId(): Int? {
return dataStoreManager.getFromStore(DataStoreManager.ACTIVE_TUNNEL)
}
private suspend fun setActiveTunnelId(id: Int) {
dataStoreManager.saveToDataStore(DataStoreManager.ACTIVE_TUNNEL, id)
}
override suspend fun getCurrentSsid(): String? {
return dataStoreManager.getFromStore(DataStoreManager.CURRENT_SSID)
}
override suspend fun setCurrentSsid(ssid: String) {
dataStoreManager.saveToDataStore(DataStoreManager.CURRENT_SSID, ssid)
}
override val generalStateFlow: Flow<GeneralState> =
dataStoreManager.preferencesFlow.map { prefs ->
prefs?.let { pref ->
try {
GeneralState(
locationDisclosureShown = pref[DataStoreManager.LOCATION_DISCLOSURE_SHOWN]
?: GeneralState.LOCATION_DISCLOSURE_SHOWN_DEFAULT,
batteryOptimizationDisableShown = pref[DataStoreManager.BATTERY_OPTIMIZE_DISABLE_SHOWN]
?: GeneralState.BATTERY_OPTIMIZATION_DISABLE_SHOWN_DEFAULT,
tunnelRunningFromManualStart = pref[DataStoreManager.TUNNEL_RUNNING_FROM_MANUAL_START]
?: GeneralState.TUNNELING_RUNNING_FROM_MANUAL_START_DEFAULT,
)
} catch (e: IllegalArgumentException) {
Timber.e(e)
GeneralState()
}
} ?: GeneralState()
}
}
@@ -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 RoomSettingsRepository(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,68 @@
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 RoomTunnelConfigRepository(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 updatePrimaryTunnel(tunnelConfig: TunnelConfig?) {
tunnelConfigDao.resetPrimaryTunnel()
tunnelConfig?.let {
save(
it.copy(
isPrimaryTunnel = true,
),
)
}
}
override suspend fun updateMobileDataTunnel(tunnelConfig: TunnelConfig?) {
tunnelConfigDao.resetMobileDataTunnel()
tunnelConfig?.let {
save(
it.copy(
isMobileDataTunnel = true,
),
)
}
}
override suspend fun delete(tunnelConfig: TunnelConfig) {
tunnelConfigDao.delete(tunnelConfig)
}
override suspend fun getById(id: Int): TunnelConfig? {
return tunnelConfigDao.getById(id.toLong())
}
override suspend fun count(): Int {
return tunnelConfigDao.count().toInt()
}
override suspend fun findByTunnelNetworksName(name: String): TunnelConfigs {
return tunnelConfigDao.findByTunnelNetworkName(name)
}
override suspend fun findByMobileDataTunnel(): TunnelConfigs {
return tunnelConfigDao.findByMobileDataTunnel()
}
override suspend fun findPrimary(): TunnelConfigs {
return tunnelConfigDao.findByPrimary()
}
}
@@ -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,30 @@
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 updatePrimaryTunnel(tunnelConfig: TunnelConfig?)
suspend fun updateMobileDataTunnel(tunnelConfig: TunnelConfig?)
suspend fun delete(tunnelConfig: TunnelConfig)
suspend fun getById(id: Int): TunnelConfig?
suspend fun count(): Int
suspend fun findByTunnelNetworksName(name: String): TunnelConfigs
suspend fun findByMobileDataTunnel(): TunnelConfigs
suspend fun findPrimary(): TunnelConfigs
}
@@ -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,15 +17,14 @@ import javax.inject.Singleton
class DatabaseModule { class DatabaseModule {
@Provides @Provides
@Singleton @Singleton
fun provideDatabase( fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
@ApplicationContext context: Context
): AppDatabase {
return Room.databaseBuilder( return Room.databaseBuilder(
context, context,
AppDatabase::class.java, AppDatabase::class.java,
context.getString(R.string.db_name) context.getString(R.string.db_name),
) )
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.addCallback(DatabaseCallback())
.build() .build()
} }
} }
@@ -1,10 +1,18 @@
package com.zaneschepke.wireguardautotunnel.module package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import android.content.Context
import com.zaneschepke.wireguardautotunnel.repository.AppDatabase import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.SettingsDao
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao import com.zaneschepke.wireguardautotunnel.data.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.datastore.DataStoreManager import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRoomRepository
import com.zaneschepke.wireguardautotunnel.data.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.DataStoreAppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomSettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomTunnelConfigRepository
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.TunnelConfigRepository
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -17,19 +25,49 @@ import javax.inject.Singleton
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 RoomTunnelConfigRepository(tunnelConfigDao)
}
@Singleton
@Provides
fun provideSettingsRepository(settingsDao: SettingsDao): SettingsRepository {
return RoomSettingsRepository(settingsDao)
}
@Singleton @Singleton
@Provides @Provides
fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager { fun providePreferencesDataStore(@ApplicationContext context: Context): DataStoreManager {
return DataStoreManager(context) return DataStoreManager(context)
} }
@Provides
@Singleton
fun provideGeneralStateRepository(dataStoreManager: DataStoreManager): AppStateRepository {
return DataStoreAppStateRepository(dataStoreManager)
}
@Provides
@Singleton
fun provideAppDataRepository(
settingsRepository: SettingsRepository,
tunnelConfigRepository: TunnelConfigRepository,
appStateRepository: AppStateRepository
): AppDataRepository {
return AppDataRoomRepository(settingsRepository, tunnelConfigRepository, appStateRepository)
}
} }
@@ -17,7 +17,9 @@ import dagger.hilt.android.scopes.ServiceScoped
abstract class ServiceModule { abstract class ServiceModule {
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideNotificationService(wireGuardNotification: WireGuardNotification): NotificationService abstract fun provideNotificationService(
wireGuardNotification: WireGuardNotification
): NotificationService
@Binds @Binds
@ServiceScoped @ServiceScoped
@@ -25,9 +27,13 @@ abstract class ServiceModule {
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideMobileDataService(mobileDataService: MobileDataService): NetworkService<MobileDataService> abstract fun provideMobileDataService(
mobileDataService: MobileDataService
): NetworkService<MobileDataService>
@Binds @Binds
@ServiceScoped @ServiceScoped
abstract fun provideEthernetService(ethernetService: EthernetService): NetworkService<EthernetService> abstract fun provideEthernetService(
ethernetService: EthernetService
): NetworkService<EthernetService>
} }
@@ -1,12 +1,8 @@
package com.zaneschepke.wireguardautotunnel.module package com.zaneschepke.wireguardautotunnel.module
import android.content.Context import android.content.Context
import com.wireguard.android.backend.Backend import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.wireguard.android.backend.GoBackend import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
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
@@ -14,6 +10,11 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.amnezia.awg.backend.AwgQuickBackend
import org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.GoBackend
import org.amnezia.awg.util.RootShell
import org.amnezia.awg.util.ToolsInstaller
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@@ -21,29 +22,22 @@ import javax.inject.Singleton
class TunnelModule { class TunnelModule {
@Provides @Provides
@Singleton @Singleton
fun provideRootShell( fun provideRootShell(@ApplicationContext context: Context): RootShell {
@ApplicationContext context: Context
): RootShell {
return RootShell(context) return RootShell(context)
} }
@Provides @Provides
@Singleton @Singleton
@Userspace @Userspace
fun provideUserspaceBackend( fun provideUserspaceBackend(@ApplicationContext context: Context): Backend {
@ApplicationContext context: Context
): Backend {
return GoBackend(context) return GoBackend(context)
} }
@Provides @Provides
@Singleton @Singleton
@Kernel @Kernel
fun provideKernelBackend( fun provideKernelBackend(@ApplicationContext context: Context, rootShell: RootShell): Backend {
@ApplicationContext context: Context, return AwgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell))
rootShell: RootShell
): Backend {
return WgQuickBackend(context, rootShell, ToolsInstaller(context, rootShell))
} }
@Provides @Provides
@@ -51,8 +45,14 @@ class TunnelModule {
fun provideVpnService( fun provideVpnService(
@Userspace userspaceBackend: Backend, @Userspace userspaceBackend: Backend,
@Kernel kernelBackend: Backend, @Kernel kernelBackend: Backend,
settingsDoa: SettingsDoa appDataRepository: AppDataRepository
): VpnService { ): VpnService {
return WireGuardTunnel(userspaceBackend, kernelBackend, settingsDoa) return WireGuardTunnel(userspaceBackend, kernelBackend, appDataRepository)
}
@Provides
@Singleton
fun provideServiceManager(appDataRepository: AppDataRepository): ServiceManager {
return ServiceManager(appDataRepository)
} }
} }
@@ -3,33 +3,42 @@ 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.AppDataRepository
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.goAsync
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.cancel
@AndroidEntryPoint @AndroidEntryPoint
class BootReceiver : BroadcastReceiver() { class BootReceiver : BroadcastReceiver() {
@Inject
lateinit var settingsRepo: SettingsDoa
override fun onReceive( @Inject
context: Context, lateinit var appDataRepository: AppDataRepository
intent: Intent
) = goAsync { @Inject
if (intent.action == Intent.ACTION_BOOT_COMPLETED) { lateinit var serviceManager: ServiceManager
try {
val settings = settingsRepo.getAll() override fun onReceive(context: Context?, intent: Intent?) = goAsync {
if (settings.isNotEmpty()) { if (Intent.ACTION_BOOT_COMPLETED != intent?.action) return@goAsync
val setting = settings.first() context?.run {
if (setting.isAutoTunnelEnabled && setting.defaultTunnel != null) { val settings = appDataRepository.settings.getSettings()
ServiceManager.startWatcherService(context, setting.defaultTunnel!!) if (settings.isAutoTunnelEnabled) {
} Timber.i("Starting watcher service from boot")
serviceManager.startWatcherServiceForeground(context)
}
if (appDataRepository.appState.isTunnelRunningFromManualStart()) {
appDataRepository.appState.getActiveTunnelId()?.let {
Timber.i("Starting tunnel that was active before reboot")
serviceManager.startVpnServiceForeground(
context,
appDataRepository.tunnels.getById(it)?.id,
)
} }
} finally { }
cancel() if (settings.isAlwaysOnVpnEnabled) {
Timber.i("Starting vpn service from boot AOVPN")
serviceManager.startVpnServiceForeground(context)
} }
} }
} }
@@ -3,34 +3,32 @@ 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 javax.inject.Inject
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class NotificationActionReceiver : BroadcastReceiver() { class NotificationActionReceiver : BroadcastReceiver() {
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var settingsRepository: SettingsRepository
override fun onReceive( @Inject
context: Context, lateinit var serviceManager: ServiceManager
intent: Intent?
) = goAsync { override fun onReceive(context: Context, intent: Intent?) = goAsync {
try { try {
val settings = settingsRepo.getAll() //TODO fix for manual start changes when enabled
if (settings.isNotEmpty()) { serviceManager.stopVpnServiceForeground(context)
val setting = settings.first() delay(Constants.TOGGLE_TUNNEL_DELAY)
if (setting.defaultTunnel != null) { serviceManager.startVpnServiceForeground(context)
ServiceManager.stopVpnService(context) } catch (e: Exception) {
delay(Constants.TOGGLE_TUNNEL_DELAY) Timber.e(e)
ServiceManager.startVpnService(context, setting.defaultTunnel.toString())
}
}
} finally { } finally {
cancel() cancel()
} }
@@ -1,26 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository
import androidx.room.AutoMigration
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
@Database(
entities = [Settings::class, TunnelConfig::class],
version = 4,
autoMigrations = [
AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), AutoMigration(
from = 3,
to = 4
)
],
exportSchema = true
)
@TypeConverters(DatabaseListConverters::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun settingDao(): SettingsDoa
abstract fun tunnelConfigDoa(): TunnelConfigDao
}
@@ -1,33 +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,39 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository.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")
}
// 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> getFromStore(key: Preferences.Key<T>) =
context.dataStore.data.map {
it[key]
}
val locationDisclosureFlow: Flow<Boolean?> = context.dataStore.data.map {
it[LOCATION_DISCLOSURE_SHOWN]
}
}
@@ -1,49 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Settings(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "is_tunnel_enabled") var isAutoTunnelEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_mobile_data_enabled") var isTunnelOnMobileDataEnabled: Boolean = false,
@ColumnInfo(name = "trusted_network_ssids") var trustedNetworkSSIDs: MutableList<String> = mutableListOf(),
@ColumnInfo(name = "default_tunnel") var defaultTunnel: String? = null,
@ColumnInfo(name = "is_always_on_vpn_enabled") var isAlwaysOnVpnEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_ethernet_enabled") var isTunnelOnEthernetEnabled: Boolean = false,
@ColumnInfo(
name = "is_shortcuts_enabled",
defaultValue = "false"
) var isShortcutsEnabled: Boolean = false,
@ColumnInfo(
name = "is_battery_saver_enabled",
defaultValue = "false"
) var isBatterySaverEnabled: Boolean = false,
@ColumnInfo(
name = "is_tunnel_on_wifi_enabled",
defaultValue = "false"
) var isTunnelOnWifiEnabled: Boolean = false,
@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
) {
fun isTunnelConfigDefault(tunnelConfig: TunnelConfig): Boolean {
return if (defaultTunnel != null) {
val defaultConfig = TunnelConfig.from(defaultTunnel!!)
(tunnelConfig.id == defaultConfig.id)
} else {
false
}
}
}
@@ -1,34 +0,0 @@
package com.zaneschepke.wireguardautotunnel.repository.model
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import com.wireguard.config.Config
import java.io.InputStream
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Entity(indices = [Index(value = ["name"], unique = true)])
@Serializable
data class TunnelConfig(
@PrimaryKey(autoGenerate = true) val id: Int = 0,
@ColumnInfo(name = "name") var name: String,
@ColumnInfo(name = "wg_quick") var wgQuick: String
) {
override fun toString(): String {
return Json.encodeToString(serializer(), this)
}
companion object {
fun from(string: String): TunnelConfig {
return Json.decodeFromString<TunnelConfig>(string)
}
fun configFromQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream()
val reader = inputStream.bufferedReader(Charsets.UTF_8)
return Config.parse(reader)
}
}
}
@@ -3,5 +3,6 @@ package com.zaneschepke.wireguardautotunnel.service.foreground
enum class Action { enum class Action {
START, START,
START_FOREGROUND, START_FOREGROUND,
STOP STOP,
STOP_FOREGROUND
} }
@@ -4,6 +4,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import com.zaneschepke.wireguardautotunnel.util.Constants
import timber.log.Timber import timber.log.Timber
open class ForegroundService : LifecycleService() { open class ForegroundService : LifecycleService() {
@@ -15,53 +16,40 @@ open class ForegroundService : LifecycleService() {
return null return null
} }
override fun onStartCommand( override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent: Intent?,
flags: Int,
startId: Int
): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
Timber.d("onStartCommand executed with startId: $startId") Timber.d("onStartCommand executed with startId: $startId")
if (intent != null) { if (intent != null) {
val action = intent.action val action = intent.action
Timber.d("using an intent with action $action")
when (action) { when (action) {
Action.START.name, Action.START_FOREGROUND.name -> startService(intent.extras) Action.START.name,
Action.STOP.name -> stopService(intent.extras) Action.START_FOREGROUND.name -> startService(intent.extras)
"android.net.VpnService" -> { Action.STOP.name, Action.STOP_FOREGROUND.name -> stopService()
Timber.d("Always-on VPN starting service") Constants.ALWAYS_ON_VPN_ACTION -> {
Timber.i("Always-on VPN starting service")
startService(intent.extras) startService(intent.extras)
} }
else -> Timber.d("This should never happen. No action in the received intent") else -> Timber.d("This should never happen. No action in the received intent")
} }
} else { } else {
Timber.d( Timber.d(
"with a null intent. It has been probably restarted by the system." "with a null intent. It has been probably restarted by the system.",
) )
} }
// 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() {
super.onDestroy()
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() {
Timber.d("Stopping ${this.javaClass.simpleName}") Timber.d("Stopping ${this.javaClass.simpleName}")
try { stopForeground(STOP_FOREGROUND_REMOVE)
stopForeground(STOP_FOREGROUND_REMOVE) stopSelf()
stopSelf()
} catch (e: Exception) {
Timber.d("Service stopped without being started: ${e.message}")
}
isServiceStarted = false isServiceStarted = false
} }
} }
@@ -1,123 +1,109 @@
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.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.util.Constants
import timber.log.Timber import timber.log.Timber
object ServiceManager { class ServiceManager(private val appDataRepository: AppDataRepository) {
@Suppress("DEPRECATION")
private // Deprecated for third party Services.
fun <T> Context.isServiceRunning(service: Class<T>) =
(getSystemService(ACTIVITY_SERVICE) as ActivityManager)
.getRunningServices(Integer.MAX_VALUE)
.any { it.service.className == service.name }
fun <T : Service> getServiceState(
context: Context,
cls: Class<T>
): ServiceState {
val isServiceRunning = context.isServiceRunning(cls)
return if (isServiceRunning) ServiceState.STARTED else ServiceState.STOPPED
}
private fun <T : Service> actionOnService( private fun <T : Service> actionOnService(
action: Action, action: Action,
context: Context, context: Context,
cls: Class<T>, cls: Class<T>,
extras: Map<String, String>? = null extras: Map<String, Int>? = null
) { ) {
if (getServiceState(context, cls) == ServiceState.STOPPED && action == Action.STOP) return
if (getServiceState(context, cls) == ServiceState.STARTED && action == Action.START) return
val intent = val intent =
Intent(context, cls).also { Intent(context, cls).also {
it.action = action.name it.action = action.name
extras?.forEach { (k, v) -> extras?.forEach { (k, v) -> it.putExtra(k, v) }
it.putExtra(k, v)
}
} }
intent.component?.javaClass intent.component?.javaClass
try { try {
when (action) { when (action) {
Action.START_FOREGROUND -> { Action.START_FOREGROUND, Action.STOP_FOREGROUND -> context.startForegroundService(intent)
context.startForegroundService(intent) Action.START, Action.STOP -> context.startService(intent)
}
Action.START -> {
context.startService(intent)
}
Action.STOP -> context.startService(intent)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
} }
} }
fun startVpnService( suspend fun startVpnService(
context: Context, context: Context,
tunnelConfig: String tunnelId: Int? = null,
isManualStart: Boolean = false
) { ) {
if (isManualStart) onManualStart(tunnelId)
actionOnService( actionOnService(
Action.START, Action.START,
context, context,
WireGuardTunnelService::class.java, WireGuardTunnelService::class.java,
mapOf(context.getString(R.string.tunnel_extras_key) to tunnelConfig) tunnelId?.let { mapOf(Constants.TUNNEL_EXTRA_KEY to it) },
) )
} }
fun stopVpnService(context: Context) { suspend fun stopVpnServiceForeground(context: Context, isManualStop: Boolean = false) {
if (isManualStop) onManualStop()
Timber.i("Stopping vpn service")
actionOnService(
Action.STOP_FOREGROUND,
context,
WireGuardTunnelService::class.java,
)
}
suspend fun stopVpnService(context: Context, isManualStop: Boolean = false) {
if (isManualStop) onManualStop()
Timber.i("Stopping vpn service")
actionOnService( actionOnService(
Action.STOP, Action.STOP,
context, context,
WireGuardTunnelService::class.java WireGuardTunnelService::class.java,
) )
} }
fun startVpnServiceForeground( private suspend fun onManualStop() {
appDataRepository.appState.setManualStop()
}
private suspend fun onManualStart(tunnelId: Int?) {
tunnelId?.let {
appDataRepository.appState.setTunnelRunningFromManualStart(it)
}
}
suspend fun startVpnServiceForeground(
context: Context, context: Context,
tunnelConfig: String tunnelId: Int? = null,
isManualStart: Boolean = false
) { ) {
if (isManualStart) onManualStart(tunnelId)
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) tunnelId?.let { mapOf(Constants.TUNNEL_EXTRA_KEY to it) },
) )
} }
private fun startWatcherServiceForeground( fun startWatcherServiceForeground(
context: Context, context: Context,
tunnelConfig: String
) { ) {
actionOnService(
Action.START_FOREGROUND,
context,
WireGuardConnectivityWatcherService::class.java,
)
}
fun startWatcherService(context: Context) {
actionOnService( actionOnService(
Action.START, Action.START,
context, context,
WireGuardConnectivityWatcherService::class.java, WireGuardConnectivityWatcherService::class.java,
mapOf(
context
.getString(R.string.tunnel_extras_key) to
tunnelConfig
)
)
}
fun startWatcherService(
context: Context,
tunnelConfig: String
) {
actionOnService(
Action.START,
context,
WireGuardConnectivityWatcherService::class.java,
mapOf(
context
.getString(R.string.tunnel_extras_key) to
tunnelConfig
)
) )
} }
@@ -125,22 +111,7 @@ object ServiceManager {
actionOnService( actionOnService(
Action.STOP, Action.STOP,
context, context,
WireGuardConnectivityWatcherService::class.java WireGuardConnectivityWatcherService::class.java,
) )
} }
fun toggleWatcherServiceForeground(
context: Context,
tunnelConfig: String
) {
when (
getServiceState(
context,
WireGuardConnectivityWatcherService::class.java
)
) {
ServiceState.STARTED -> stopWatcherService(context)
ServiceState.STOPPED -> startWatcherServiceForeground(context, tunnelConfig)
}
}
} }
@@ -1,6 +0,0 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
enum class ServiceState {
STARTED,
STOPPED,
}
@@ -0,0 +1,84 @@
package com.zaneschepke.wireguardautotunnel.service.foreground
import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import org.amnezia.awg.backend.Tunnel
data class WatcherState(
val isWifiConnected: Boolean = false,
val config: TunnelConfig? = null,
val vpnStatus: Tunnel.State = Tunnel.State.DOWN,
val isEthernetConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val currentNetworkSSID: String = "",
val settings: Settings = Settings()
) {
private fun isVpnConnected() = vpnStatus == Tunnel.State.UP
fun isEthernetConditionMet(): Boolean {
return (isEthernetConnected &&
settings.isTunnelOnEthernetEnabled &&
!isVpnConnected())
}
fun isMobileDataConditionMet(): Boolean {
return (!isEthernetConnected &&
settings.isTunnelOnMobileDataEnabled &&
!isWifiConnected &&
isMobileDataConnected &&
!isVpnConnected())
}
fun isTunnelNotMobileDataPreferredConditionMet(): Boolean {
return (!isEthernetConnected &&
settings.isTunnelOnMobileDataEnabled &&
!isWifiConnected &&
isMobileDataConnected &&
config?.isMobileDataTunnel == false && isVpnConnected())
}
fun isTunnelOffOnMobileDataConditionMet(): Boolean {
return (!isEthernetConnected &&
!settings.isTunnelOnMobileDataEnabled &&
isMobileDataConnected &&
!isWifiConnected &&
isVpnConnected())
}
fun isUntrustedWifiConditionMet(): Boolean {
return (!isEthernetConnected &&
isWifiConnected &&
!settings.trustedNetworkSSIDs.contains(currentNetworkSSID) &&
settings.isTunnelOnWifiEnabled
&& !isVpnConnected())
}
fun isTunnelNotWifiNamePreferredMet(ssid: String): Boolean {
return (!isEthernetConnected &&
isWifiConnected &&
!settings.trustedNetworkSSIDs.contains(currentNetworkSSID) &&
settings.isTunnelOnWifiEnabled && config?.tunnelNetworks?.contains(ssid) == false && isVpnConnected())
}
fun isTrustedWifiConditionMet(): Boolean {
return (!isEthernetConnected &&
(isWifiConnected &&
settings.trustedNetworkSSIDs.contains(currentNetworkSSID)) &&
(isVpnConnected()))
}
fun isTunnelOffOnWifiConditionMet(): Boolean {
return (!isEthernetConnected &&
(isWifiConnected &&
!settings.isTunnelOnWifiEnabled &&
(isVpnConnected())))
}
fun isTunnelOffOnNoConnectivityMet(): Boolean {
return (!isEthernetConnected &&
!isWifiConnected &&
!isMobileDataConnected &&
(isVpnConnected()))
}
}
@@ -1,19 +1,13 @@
package com.zaneschepke.wireguardautotunnel.service.foreground package com.zaneschepke.wireguardautotunnel.service.foreground
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
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 androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
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.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.model.Settings import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
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
@@ -21,14 +15,20 @@ 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 javax.inject.Inject import kotlinx.coroutines.CancellationException
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 org.amnezia.awg.backend.Tunnel
import timber.log.Timber import timber.log.Timber
import java.net.InetAddress
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardConnectivityWatcherService : ForegroundService() { class WireGuardConnectivityWatcherService : ForegroundService() {
@@ -44,7 +44,7 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
lateinit var ethernetService: NetworkService<EthernetService> lateinit var ethernetService: NetworkService<EthernetService>
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var appDataRepository: AppDataRepository
@Inject @Inject
lateinit var notificationService: NotificationService lateinit var notificationService: NotificationService
@@ -52,14 +52,12 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
@Inject @Inject
lateinit var vpnService: VpnService lateinit var vpnService: VpnService
private var isWifiConnected = false @Inject
private var isEthernetConnected = false lateinit var serviceManager: ServiceManager
private var isMobileDataConnected = false
private var currentNetworkSSID = ""
private lateinit var watcherJob: Job private val networkEventsFlow = MutableStateFlow(WatcherState())
private lateinit var setting: Settings
private lateinit var tunnelConfig: String private var watcherJob: Job? = null
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private val tag = this.javaClass.name private val tag = this.javaClass.name
@@ -68,7 +66,9 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
super.onCreate() super.onCreate()
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
try { try {
launchWatcherNotification() if (appDataRepository.settings.getSettings().isAutoTunnelPaused) {
launchWatcherPausedNotification()
} else launchWatcherNotification()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Failed to start watcher service, not enough permissions") Timber.e("Failed to start watcher service, not enough permissions")
} }
@@ -78,28 +78,17 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
override fun startService(extras: Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
try { try {
launchWatcherNotification()
val tunnelId = extras?.getString(getString(R.string.tunnel_extras_key))
if (tunnelId != null) {
this.tunnelConfig = tunnelId
}
// we need this lock so our service gets not affected by Doze Mode // we need this lock so our service gets not affected by Doze Mode
lifecycleScope.launch { lifecycleScope.launch { initWakeLock() }
initWakeLock()
}
cancelWatcherJob() cancelWatcherJob()
if (this::tunnelConfig.isInitialized) { startWatcherJob()
startWatcherJob()
} else {
stopService(extras)
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.e("Failed to launch watcher service, no permissions") Timber.e("Failed to launch watcher service, no permissions")
} }
} }
override fun stopService(extras: Bundle?) { override fun stopService() {
super.stopService(extras) super.stopService()
wakeLock?.let { wakeLock?.let {
if (it.isHeld) { if (it.isHeld) {
it.release() it.release()
@@ -109,91 +98,89 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
stopSelf() stopSelf()
} }
private fun launchWatcherNotification() { private fun launchWatcherNotification(
description: String = getString(R.string.watcher_notification_text_active)
) {
val notification = val notification =
notificationService.createNotification( notificationService.createNotification(
channelId = getString(R.string.watcher_channel_id), channelId = getString(R.string.watcher_channel_id),
channelName = getString(R.string.watcher_channel_name), channelName = getString(R.string.watcher_channel_name),
description = getString(R.string.watcher_notification_text), title = getString(R.string.auto_tunnel_title),
vibration = false description = description,
) )
ServiceCompat.startForeground( ServiceCompat.startForeground(
this, this,
foregroundId, foregroundId,
notification, notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
) )
} }
// try to start task again if killed private fun launchWatcherPausedNotification() {
override fun onTaskRemoved(rootIntent: Intent) { launchWatcherNotification(getString(R.string.watcher_notification_text_paused))
Timber.d("Task Removed called")
val restartServiceIntent = Intent(rootIntent)
val restartServicePendingIntent: PendingIntent =
PendingIntent.getService(
this,
1,
restartServiceIntent,
PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE
)
applicationContext.getSystemService(Context.ALARM_SERVICE)
val alarmService: AlarmManager =
applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmService.set(
AlarmManager.ELAPSED_REALTIME,
SystemClock.elapsedRealtime() + 1000,
restartServicePendingIntent
)
} }
private suspend fun initWakeLock() { private fun initWakeLock() {
val isBatterySaverOn =
withContext(lifecycleScope.coroutineContext) {
settingsRepo.getAll().firstOrNull()?.isBatterySaverEnabled ?: false
}
wakeLock = wakeLock =
(getSystemService(Context.POWER_SERVICE) as PowerManager).run { (getSystemService(Context.POWER_SERVICE) as PowerManager).run {
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply { newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
if (isBatterySaverOn) { try {
Timber.d("Initiating wakelock with timeout") Timber.i("Initiating wakelock with 10 min timeout")
acquire(Constants.WATCHER_SERVICE_WAKE_LOCK_TIMEOUT) acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} else { } finally {
Timber.d("Initiating wakelock with zero timeout") release()
acquire()
} }
} }
} }
} }
private fun cancelWatcherJob() { private fun cancelWatcherJob() {
if (this::watcherJob.isInitialized) { try {
watcherJob.cancel() watcherJob?.cancel()
} catch (e : CancellationException) {
Timber.i("Watcher job cancelled")
} }
} }
private fun startWatcherJob() { private fun startWatcherJob() {
watcherJob = watcherJob =
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
val settings = settingsRepo.getAll() val setting = appDataRepository.settings.getSettings()
if (settings.isNotEmpty()) {
setting = settings[0]
}
launch { launch {
Timber.i("Starting wifi watcher")
watchForWifiConnectivityChanges() watchForWifiConnectivityChanges()
} }
if (setting.isTunnelOnMobileDataEnabled) { if (setting.isTunnelOnMobileDataEnabled) {
launch { launch {
Timber.i("Starting mobile data watcher")
watchForMobileDataConnectivityChanges() watchForMobileDataConnectivityChanges()
} }
} }
if (setting.isTunnelOnEthernetEnabled) { if (setting.isTunnelOnEthernetEnabled) {
launch { launch {
Timber.i("Starting ethernet data watcher")
watchForEthernetConnectivityChanges() watchForEthernetConnectivityChanges()
} }
} }
launch { launch {
Timber.i("Starting vpn state watcher")
watchForVpnConnectivityChanges()
}
launch {
Timber.i("Starting settings watcher")
watchForSettingsChanges()
}
if (setting.isPingEnabled) {
launch {
Timber.i("Starting ping watcher")
watchForPingFailure()
}
}
launch {
Timber.i("Starting management watcher")
manageVpn() manageVpn()
} }
} }
} }
@@ -201,39 +188,116 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
mobileDataService.networkStatus.collect { mobileDataService.networkStatus.collect {
when (it) { when (it) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.d("Gained Mobile data connection") Timber.i("Gained Mobile data connection")
isMobileDataConnected = true networkEventsFlow.value =
networkEventsFlow.value.copy(
isMobileDataConnected = true,
)
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
isMobileDataConnected = true networkEventsFlow.value =
Timber.d("Mobile data capabilities changed") networkEventsFlow.value.copy(
isMobileDataConnected = true,
)
Timber.i("Mobile data capabilities changed")
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isMobileDataConnected = false networkEventsFlow.value =
Timber.d("Lost mobile data connection") networkEventsFlow.value.copy(
isMobileDataConnected = false,
)
Timber.i("Lost mobile data connection")
} }
} }
} }
} }
private suspend fun watchForPingFailure() {
try {
do {
if (vpnService.vpnState.value.status == Tunnel.State.UP) {
val tunnelConfig = vpnService.vpnState.value.tunnelConfig
tunnelConfig?.let {
val config = TunnelConfig.configFromQuick(it.wgQuick)
val results = config.peers.map { peer ->
val host = if (peer.endpoint.isPresent &&
peer.endpoint.get().resolved.isPresent)
peer.endpoint.get().resolved.get().host
else Constants.BACKUP_PING_HOST
Timber.i("Checking reachability of: $host")
val reachable = InetAddress.getByName(host)
.isReachable(Constants.PING_TIMEOUT.toInt())
Timber.i("Result: reachable - $reachable")
reachable
}
if (results.contains(false)) {
Timber.i("Restarting VPN for ping failure")
serviceManager.stopVpnServiceForeground(this)
delay(Constants.VPN_RESTART_DELAY)
serviceManager.startVpnServiceForeground(this)
delay(Constants.PING_COOLDOWN)
}
}
}
delay(Constants.PING_INTERVAL)
} while (true)
} catch (e: Exception) {
Timber.e(e)
}
}
private suspend fun watchForSettingsChanges() {
appDataRepository.settings.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 {
networkEventsFlow.value =
networkEventsFlow.value.copy(
vpnStatus = it.status,
config = it.tunnelConfig,
)
}
}
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.i("Gained Ethernet connection")
isEthernetConnected = true networkEventsFlow.value =
networkEventsFlow.value.copy(
isEthernetConnected = true,
)
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Ethernet capabilities changed") Timber.i("Ethernet capabilities changed")
isEthernetConnected = true networkEventsFlow.value =
networkEventsFlow.value.copy(
isEthernetConnected = true,
)
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isEthernetConnected = false networkEventsFlow.value =
Timber.d("Lost Ethernet connection") networkEventsFlow.value.copy(
isEthernetConnected = false,
)
Timber.i("Lost Ethernet connection")
} }
} }
} }
@@ -243,93 +307,124 @@ class WireGuardConnectivityWatcherService : ForegroundService() {
wifiService.networkStatus.collect { wifiService.networkStatus.collect {
when (it) { when (it) {
is NetworkStatus.Available -> { is NetworkStatus.Available -> {
Timber.d("Gained Wi-Fi connection") Timber.i("Gained Wi-Fi connection")
isWifiConnected = true networkEventsFlow.value =
networkEventsFlow.value.copy(
isWifiConnected = true,
)
} }
is NetworkStatus.CapabilitiesChanged -> { is NetworkStatus.CapabilitiesChanged -> {
Timber.d("Wifi capabilities changed") Timber.i("Wifi capabilities changed")
isWifiConnected = true networkEventsFlow.value =
val ssid = wifiService.getNetworkName(it.networkCapabilities) ?: "" networkEventsFlow.value.copy(
Timber.d("Detected SSID: $ssid") isWifiConnected = true,
currentNetworkSSID = ssid )
val ssid = wifiService.getNetworkName(it.networkCapabilities)
ssid?.let {
if(it.contains(Constants.UNREADABLE_SSID)) {
Timber.w("SSID unreadable: missing permissions")
} else Timber.i("Detected valid SSID")
appDataRepository.appState.setCurrentSsid(ssid)
networkEventsFlow.value =
networkEventsFlow.value.copy(
currentNetworkSSID = ssid,
)
} ?: Timber.w("Failed to read ssid")
} }
is NetworkStatus.Unavailable -> { is NetworkStatus.Unavailable -> {
isWifiConnected = false networkEventsFlow.value =
Timber.d("Lost Wi-Fi connection") networkEventsFlow.value.copy(
isWifiConnected = false,
)
Timber.i("Lost Wi-Fi connection")
} }
} }
} }
} }
private suspend fun getMobileDataTunnel(): TunnelConfig? {
return appDataRepository.tunnels.findByMobileDataTunnel().firstOrNull()
}
private suspend fun getSsidTunnel(ssid: String): TunnelConfig? {
return appDataRepository.tunnels.findByTunnelNetworksName(ssid).firstOrNull()
}
private suspend fun manageVpn() { private suspend fun manageVpn() {
while (true) { networkEventsFlow.collectLatest { watcherState ->
when { val autoTunnel = "Auto-tunnel watcher"
( if (!watcherState.settings.isAutoTunnelPaused) {
( //delay for rapid network state changes and then collect latest
isEthernetConnected && delay(Constants.WATCHER_COLLECTION_DELAY)
setting.isTunnelOnEthernetEnabled && when {
vpnService.getState() == Tunnel.State.DOWN watcherState.isEthernetConditionMet() -> {
) Timber.i("$autoTunnel - tunnel on on ethernet condition met")
) -> serviceManager.startVpnServiceForeground(this)
ServiceManager.startVpnService(this, tunnelConfig) }
( watcherState.isMobileDataConditionMet() -> {
!isEthernetConnected && Timber.i("$autoTunnel - tunnel on on mobile data condition met")
setting.isTunnelOnMobileDataEnabled && serviceManager.startVpnServiceForeground(this, getMobileDataTunnel()?.id)
!isWifiConnected && }
isMobileDataConnected &&
vpnService.getState() == Tunnel.State.DOWN
) ->
ServiceManager.startVpnService(this, tunnelConfig)
( watcherState.isTunnelNotMobileDataPreferredConditionMet() -> {
!isEthernetConnected && getMobileDataTunnel()?.let {
!setting.isTunnelOnMobileDataEnabled && Timber.i("$autoTunnel - tunnel connected on mobile data is not preferred condition met, switching to preferred")
!isWifiConnected && serviceManager.startVpnServiceForeground(
vpnService.getState() == Tunnel.State.UP this,
) -> getMobileDataTunnel()?.id,
ServiceManager.stopVpnService(this) )
}
}
( watcherState.isTunnelOffOnMobileDataConditionMet() -> {
!isEthernetConnected && isWifiConnected && Timber.i("$autoTunnel - tunnel off on mobile data met, turning vpn off")
!setting.trustedNetworkSSIDs.contains(currentNetworkSSID) && serviceManager.stopVpnServiceForeground(this)
setting.isTunnelOnWifiEnabled && }
(vpnService.getState() != Tunnel.State.UP)
) ->
ServiceManager.startVpnService(this, tunnelConfig)
( watcherState.isTunnelNotWifiNamePreferredMet(watcherState.currentNetworkSSID) -> {
!isEthernetConnected && ( Timber.i("$autoTunnel - tunnel on ssid not associated with current tunnel condition met")
isWifiConnected && getSsidTunnel(watcherState.currentNetworkSSID)?.let {
setting.trustedNetworkSSIDs.contains(currentNetworkSSID) Timber.i("Found tunnel associated with this SSID, bringing tunnel up")
) && serviceManager.startVpnServiceForeground(this, it.id)
(vpnService.getState() == Tunnel.State.UP) } ?: suspend {
) -> Timber.i("No tunnel associated with this SSID, using defaults")
ServiceManager.stopVpnService(this) if (appDataRepository.getPrimaryOrFirstTunnel()?.name != vpnService.name) {
serviceManager.startVpnServiceForeground(this)
}
}.invoke()
}
( watcherState.isUntrustedWifiConditionMet() -> {
!isEthernetConnected && ( Timber.i("$autoTunnel - tunnel on untrusted wifi condition met")
isWifiConnected && serviceManager.startVpnServiceForeground(
!setting.isTunnelOnWifiEnabled && this,
(vpnService.getState() == Tunnel.State.UP) getSsidTunnel(watcherState.currentNetworkSSID)?.id,
) )
) -> }
ServiceManager.stopVpnService(this)
( watcherState.isTrustedWifiConditionMet() -> {
!isEthernetConnected && !isWifiConnected && Timber.i("$autoTunnel - tunnel off on trusted wifi condition met, turning vpn off")
!isMobileDataConnected && serviceManager.stopVpnServiceForeground(this)
(vpnService.getState() == Tunnel.State.UP) }
) ->
ServiceManager.stopVpnService(this)
else -> { watcherState.isTunnelOffOnWifiConditionMet() -> {
// Do nothing Timber.i("$autoTunnel - tunnel off on wifi condition met, turning vpn off")
serviceManager.stopVpnServiceForeground(this)
}
watcherState.isTunnelOffOnNoConnectivityMet() -> {
Timber.i("$autoTunnel - tunnel off on no connectivity met, turning vpn off")
serviceManager.stopVpnServiceForeground(this)
}
else -> {
Timber.i("$autoTunnel - no condition met")
}
} }
} }
delay(Constants.VPN_CONNECTIVITY_CHECK_INTERVAL)
} }
} }
} }
@@ -5,20 +5,24 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver import com.zaneschepke.wireguardautotunnel.receiver.NotificationActionReceiver
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.notification.NotificationService import com.zaneschepke.wireguardautotunnel.service.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.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 javax.inject.Inject import kotlinx.coroutines.CancellationException
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 org.amnezia.awg.backend.Tunnel
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class WireGuardTunnelService : ForegroundService() { class WireGuardTunnelService : ForegroundService() {
@@ -28,136 +32,122 @@ class WireGuardTunnelService : ForegroundService() {
lateinit var vpnService: VpnService lateinit var vpnService: VpnService
@Inject @Inject
lateinit var settingsRepo: SettingsDoa lateinit var appDataRepository: AppDataRepository
@Inject @Inject
lateinit var notificationService: NotificationService lateinit var notificationService: NotificationService
private lateinit var job: Job private var job: Job? = null
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() //TODO fix this to not launch if AOVPN
if (appDataRepository.tunnels.count() != 0) {
launchVpnNotification()
}
} }
} }
override fun startService(extras: Bundle?) { override fun startService(extras: Bundle?) {
super.startService(extras) super.startService(extras)
// TODO fix grapheneOS calls always-on on install
launchVpnStartingNotification()
val tunnelConfigString = extras?.getString(getString(R.string.tunnel_extras_key))
cancelJob() cancelJob()
job = job =
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
launch { launch {
if (tunnelConfigString != null) { val tunnelId = extras?.getInt(Constants.TUNNEL_EXTRA_KEY)
try { if (vpnService.getState() == Tunnel.State.UP) {
val tunnelConfig = TunnelConfig.from(tunnelConfigString) vpnService.stopTunnel()
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
} catch (e: Exception) {
Timber.e("Problem starting tunnel: ${e.message}")
stopService(extras)
}
} else {
Timber.d("Tunnel config null, starting default tunnel")
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings[0]
if (setting.defaultTunnel != null && setting.isAlwaysOnVpnEnabled) {
val tunnelConfig = TunnelConfig.from(setting.defaultTunnel!!)
tunnelName = tunnelConfig.name
vpnService.startTunnel(tunnelConfig)
}
}
} }
vpnService.startTunnel(
tunnelId?.let {
appDataRepository.tunnels.getById(it)
},
)
} }
launch { launch {
var didShowConnected = false handshakeNotifications()
var didShowFailedHandshakeNotification = false
vpnService.handshakeStatus.collect {
when (it) {
HandshakeStatus.NOT_STARTED -> {
}
HandshakeStatus.NEVER_CONNECTED -> {
if (!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(
getString(R.string.initial_connection_failure_message)
)
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
HandshakeStatus.HEALTHY -> {
if (!didShowConnected) {
launchVpnConnectedNotification()
didShowConnected = true
}
}
HandshakeStatus.STALE -> {}
HandshakeStatus.UNHEALTHY -> {
if (!didShowFailedHandshakeNotification) {
launchVpnConnectionFailedNotification(
getString(R.string.lost_connection_failure_message)
)
didShowFailedHandshakeNotification = true
didShowConnected = false
}
}
}
}
} }
} }
} }
override fun stopService(extras: Bundle?) { //TODO improve tunnel notifications
super.stopService(extras) private suspend fun handshakeNotifications() {
var tunnelName: String? = null
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)
tunnelName = state.tunnelConfig?.name
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 -> {}
}
}
if (state.status == Tunnel.State.UP && state.tunnelConfig?.name != tunnelName) {
tunnelName = state.tunnelConfig?.name
launchVpnNotification(
getString(R.string.tunnel_start_title),
"${getString(R.string.tunnel_start_text)} - $tunnelName",
)
}
}
}
private fun launchAlwaysOnDisabledNotification() {
launchVpnNotification(
title = this.getString(R.string.vpn_connection_failed),
description = this.getString(R.string.always_on_disabled),
)
}
override fun stopService() {
super.stopService()
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
vpnService.stopTunnel() vpnService.stopTunnel()
didShowConnected = false
} }
cancelJob() cancelJob()
stopSelf() stopSelf()
} }
private fun launchVpnConnectedNotification() { private fun launchVpnNotification(
title: String = getString(R.string.vpn_starting),
description: String = getString(R.string.attempt_connection)
) {
val notification = val notification =
notificationService.createNotification( notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id), channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name), channelName = getString(R.string.vpn_channel_name),
title = getString(R.string.tunnel_start_title), title = title,
onGoing = false, onGoing = false,
vibration = false, vibration = false,
showTimestamp = true, showTimestamp = true,
description = "${getString(R.string.tunnel_start_text)} $tunnelName" description = description,
) )
ServiceCompat.startForeground( ServiceCompat.startForeground(
this, this,
foregroundId, foregroundId,
notification, notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
)
}
private fun launchVpnStartingNotification() {
val notification =
notificationService.createNotification(
channelId = getString(R.string.vpn_channel_id),
channelName = getString(R.string.vpn_channel_name),
title = getString(R.string.vpn_starting),
onGoing = false,
vibration = false,
showTimestamp = true,
description = getString(R.string.attempt_connection)
)
ServiceCompat.startForeground(
this,
foregroundId,
notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID
) )
} }
@@ -171,26 +161,28 @@ class WireGuardTunnelService : ForegroundService() {
this, this,
0, 0,
Intent(this, NotificationActionReceiver::class.java), Intent(this, NotificationActionReceiver::class.java),
PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_IMMUTABLE,
), ),
actionText = getString(R.string.restart), actionText = getString(R.string.restart),
title = getString(R.string.vpn_connection_failed), title = getString(R.string.vpn_connection_failed),
onGoing = false, onGoing = false,
vibration = true, vibration = true,
showTimestamp = true, showTimestamp = true,
description = message description = message,
) )
ServiceCompat.startForeground( ServiceCompat.startForeground(
this, this,
foregroundId, foregroundId,
notification, notification,
Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID Constants.SYSTEM_EXEMPT_SERVICE_TYPE_ID,
) )
} }
private fun cancelJob() { private fun cancelJob() {
if (this::job.isInitialized) { try {
job.cancel() job?.cancel()
} catch (e : CancellationException) {
Timber.i("Tunnel job cancelled")
} }
} }
} }
@@ -24,72 +24,70 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
private val wifiManager = private val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
override val networkStatus = override val networkStatus = callbackFlow {
callbackFlow { val networkStatusCallback =
val networkStatusCallback = when (Build.VERSION.SDK_INT) {
when (Build.VERSION.SDK_INT) { in Build.VERSION_CODES.S..Int.MAX_VALUE -> {
in Build.VERSION_CODES.S..Int.MAX_VALUE -> { object :
object : ConnectivityManager.NetworkCallback( ConnectivityManager.NetworkCallback(
FLAG_INCLUDE_LOCATION_INFO FLAG_INCLUDE_LOCATION_INFO,
) { ) {
override fun onAvailable(network: Network) { override fun onAvailable(network: Network) {
trySend(NetworkStatus.Available(network)) trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) {
trySend(NetworkStatus.Unavailable(network))
}
override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities
) {
trySend(
NetworkStatus.CapabilitiesChanged(
network,
networkCapabilities
)
)
}
} }
}
else -> { override fun onLost(network: Network) {
object : ConnectivityManager.NetworkCallback() { trySend(NetworkStatus.Unavailable(network))
override fun onAvailable(network: Network) { }
trySend(NetworkStatus.Available(network))
}
override fun onLost(network: Network) { override fun onCapabilitiesChanged(
trySend(NetworkStatus.Unavailable(network)) network: Network,
} networkCapabilities: NetworkCapabilities
) {
override fun onCapabilitiesChanged( trySend(
network: Network, NetworkStatus.CapabilitiesChanged(
networkCapabilities: NetworkCapabilities network,
) { networkCapabilities,
trySend( ),
NetworkStatus.CapabilitiesChanged( )
network,
networkCapabilities
)
)
}
} }
} }
} }
val request =
NetworkRequest.Builder()
.addTransportType(networkCapability)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { else -> {
connectivityManager.unregisterNetworkCallback(networkStatusCallback) 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 =
NetworkRequest.Builder()
.addTransportType(networkCapability)
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
.build()
connectivityManager.registerNetworkCallback(request, networkStatusCallback)
awaitClose { connectivityManager.unregisterNetworkCallback(networkStatusCallback) }
}
override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? { override fun getNetworkName(networkCapabilities: NetworkCapabilities): String? {
var ssid: String? = getWifiNameFromCapabilities(networkCapabilities) var ssid: String? = getWifiNameFromCapabilities(networkCapabilities)
@@ -119,18 +117,16 @@ abstract class BaseNetworkService<T : BaseNetworkService<T>>(
inline fun <Result> Flow<NetworkStatus>.map( inline fun <Result> Flow<NetworkStatus>.map(
crossinline onUnavailable: suspend (network: Network) -> Result, crossinline onUnavailable: suspend (network: Network) -> Result,
crossinline onAvailable: suspend (network: Network) -> Result, crossinline onAvailable: suspend (network: Network) -> Result,
crossinline onCapabilitiesChanged: suspend ( crossinline onCapabilitiesChanged:
network: Network, suspend (network: Network, networkCapabilities: NetworkCapabilities) -> Result
networkCapabilities: NetworkCapabilities ): Flow<Result> = map { status ->
) -> Result when (status) {
): Flow<Result> = is NetworkStatus.Unavailable -> onUnavailable(status.network)
map { status -> is NetworkStatus.Available -> onAvailable(status.network)
when (status) { is NetworkStatus.CapabilitiesChanged ->
is NetworkStatus.Unavailable -> onUnavailable(status.network) onCapabilitiesChanged(
is NetworkStatus.Available -> onAvailable(status.network)
is NetworkStatus.CapabilitiesChanged -> onCapabilitiesChanged(
status.network, status.network,
status.networkCapabilities status.networkCapabilities,
) )
}
} }
}
@@ -5,9 +5,5 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class EthernetService class EthernetService @Inject constructor(@ApplicationContext context: Context) :
@Inject
constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET) BaseNetworkService<EthernetService>(context, NetworkCapabilities.TRANSPORT_ETHERNET)
@@ -5,9 +5,5 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class MobileDataService class MobileDataService @Inject constructor(@ApplicationContext context: Context) :
@Inject
constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR) BaseNetworkService<MobileDataService>(context, NetworkCapabilities.TRANSPORT_CELLULAR)
@@ -5,9 +5,5 @@ import android.net.NetworkCapabilities
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
class WifiService class WifiService @Inject constructor(@ApplicationContext context: Context) :
@Inject
constructor(
@ApplicationContext context: Context
) :
BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI) BaseNetworkService<WifiService>(context, NetworkCapabilities.TRANSPORT_WIFI)
@@ -16,6 +16,7 @@ interface NotificationService {
importance: Int = NotificationManager.IMPORTANCE_HIGH, importance: Int = NotificationManager.IMPORTANCE_HIGH,
vibration: Boolean = false, vibration: Boolean = false,
onGoing: Boolean = true, onGoing: Boolean = true,
lights: Boolean = true lights: Boolean = true,
onlyAlertOnce: Boolean = true,
): Notification ): Notification
} }
@@ -7,19 +7,28 @@ 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 class WireGuardNotification @Inject constructor(@ApplicationContext private val context: Context) :
@Inject NotificationService {
constructor(
@ApplicationContext private val context: Context
) : NotificationService {
private val notificationManager = private val notificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val watcherBuilder: NotificationCompat.Builder =
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,
channelName: String, channelName: String,
@@ -31,21 +40,23 @@ constructor(
importance: Int, importance: Int,
vibration: Boolean, vibration: Boolean,
onGoing: Boolean, onGoing: Boolean,
lights: Boolean lights: Boolean,
onlyAlertOnce: Boolean,
): Notification { ): Notification {
val channel = val channel =
NotificationChannel( NotificationChannel(
channelId, channelId,
channelName, channelName,
importance importance,
).let { )
it.description = title .let {
it.enableLights(lights) it.description = title
it.lightColor = Color.RED it.enableLights(lights)
it.enableVibration(vibration) it.lightColor = Color.RED
it.vibrationPattern = longArrayOf(100, 200, 300, 400, 500, 400, 300, 200, 400) it.enableVibration(vibration)
it it.vibrationPattern = longArrayOf(100, 200, 300)
} it
}
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
val pendingIntent: PendingIntent = val pendingIntent: PendingIntent =
Intent(context, MainActivity::class.java).let { notificationIntent -> Intent(context, MainActivity::class.java).let { notificationIntent ->
@@ -53,30 +64,37 @@ constructor(
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 -> {
NotificationCompat.Builder(
context,
channelId,
)
}
}
return builder.let { return builder.let {
if (action != null && actionText != null) { if (action != null && actionText != null) {
// TODO find a not deprecated way to do this
it.addAction( it.addAction(
Notification.Action.Builder(0, actionText, action) NotificationCompat.Action.Builder(0, actionText, action).build(),
.build()
) )
it.setAutoCancel(true) it.setAutoCancel(true)
} }
it.setContentTitle(title) 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()
} }
} }
@@ -3,70 +3,64 @@ package com.zaneschepke.wireguardautotunnel.service.shortcut
import android.os.Bundle import android.os.Bundle
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.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.Action import com.zaneschepke.wireguardautotunnel.service.foreground.Action
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardConnectivityWatcherService
import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService import com.zaneschepke.wireguardautotunnel.service.foreground.WireGuardTunnelService
import com.zaneschepke.wireguardautotunnel.util.WgTunnelException
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class ShortcutsActivity : ComponentActivity() { class ShortcutsActivity : ComponentActivity() {
@Inject
lateinit var settingsRepo: SettingsDoa
@Inject @Inject
lateinit var tunnelConfigRepo: TunnelConfigDao lateinit var appDataRepository: AppDataRepository
private fun attemptWatcherServiceToggle(tunnelConfig: String) { @Inject
lifecycleScope.launch(Dispatchers.Main) { lateinit var serviceManager: ServiceManager
val settings = getSettings()
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) lifecycleScope.launch(Dispatchers.Main) {
.equals(WireGuardTunnelService::class.java.simpleName) val settings = appDataRepository.settings.getSettings()
) { if (settings.isShortcutsEnabled) {
lifecycleScope.launch(Dispatchers.Main) { when (intent.getStringExtra(CLASS_NAME_EXTRA_KEY)) {
val settings = getSettings() WireGuardTunnelService::class.java.simpleName -> {
if (settings.isShortcutsEnabled) {
try {
val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY) val tunnelName = intent.getStringExtra(TUNNEL_NAME_EXTRA_KEY)
val tunnelConfig = val tunnelConfig = tunnelName?.let {
if (tunnelName != null) { appDataRepository.tunnels.getAll().firstOrNull {
tunnelConfigRepo.getAll().firstOrNull { it.name == tunnelName } it.name == tunnelName
} else {
if (settings.defaultTunnel == null) {
tunnelConfigRepo.getAll().first()
} else {
TunnelConfig.from(settings.defaultTunnel!!)
}
} }
tunnelConfig ?: return@launch }
attemptWatcherServiceToggle(tunnelConfig.toString())
when (intent.action) { when (intent.action) {
Action.STOP.name -> ServiceManager.stopVpnService( Action.START.name -> serviceManager.startVpnServiceForeground(
this@ShortcutsActivity this@ShortcutsActivity, tunnelConfig?.id, isManualStart = true,
) )
Action.START.name -> ServiceManager.startVpnService(
Action.STOP.name -> serviceManager.stopVpnServiceForeground(
this@ShortcutsActivity, this@ShortcutsActivity,
tunnelConfig.toString() isManualStop = true,
)
}
}
WireGuardConnectivityWatcherService::class.java.simpleName -> {
when (intent.action) {
Action.START.name -> appDataRepository.settings.save(
settings.copy(
isAutoTunnelPaused = false,
),
)
Action.STOP.name -> appDataRepository.settings.save(
settings.copy(
isAutoTunnelPaused = true,
),
) )
} }
} catch (e: Exception) {
Timber.e(e.message)
} }
} }
} }
@@ -74,15 +68,6 @@ class ShortcutsActivity : ComponentActivity() {
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 TUNNEL_NAME_EXTRA_KEY = "tunnelName"
const val CLASS_NAME_EXTRA_KEY = "className" const val CLASS_NAME_EXTRA_KEY = "className"
@@ -0,0 +1,111 @@
package com.zaneschepke.wireguardautotunnel.service.tile
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class AutoTunnelControlTile : TileService() {
@Inject
lateinit var appDataRepository: AppDataRepository
@Inject
lateinit var serviceManager: ServiceManager
private val scope = CoroutineScope(Dispatchers.IO)
private var manualStartConfig: TunnelConfig? = null
override fun onStartListening() {
super.onStartListening()
scope.launch {
appDataRepository.settings.getSettingsFlow().collectLatest {
when (it.isAutoTunnelEnabled) {
true -> {
if (it.isAutoTunnelPaused) {
setInactive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.paused))
} else {
setActive()
setTileDescription(this@AutoTunnelControlTile.getString(R.string.active))
}
}
false -> {
setTileDescription(this@AutoTunnelControlTile.getString(R.string.disabled))
setUnavailable()
}
}
}
}
}
override fun onTileAdded() {
super.onTileAdded()
onStartListening()
}
override fun onDestroy() {
super.onDestroy()
scope.cancel()
}
override fun onTileRemoved() {
super.onTileRemoved()
scope.cancel()
}
override fun onClick() {
super.onClick()
unlockAndRun {
scope.launch {
try {
appDataRepository.toggleWatcherServicePause()
} catch (e: Exception) {
Timber.e(e.message)
} finally {
cancel()
}
}
}
}
private fun setActive() {
qsTile.state = Tile.STATE_ACTIVE
qsTile.updateTile()
}
private fun setInactive() {
qsTile.state = Tile.STATE_INACTIVE
qsTile.updateTile()
}
private fun setUnavailable() {
manualStartConfig = null
qsTile.state = Tile.STATE_UNAVAILABLE
qsTile.updateTile()
}
private fun setTileDescription(description: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
qsTile.subtitle = description
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
qsTile.stateDescription = description
}
qsTile.updateTile()
}
}
@@ -3,48 +3,60 @@ package com.zaneschepke.wireguardautotunnel.service.tile
import android.os.Build 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.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.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 javax.inject.Inject
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 org.amnezia.awg.backend.Tunnel
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class TunnelControlTile : TileService() { class TunnelControlTile : TileService() {
@Inject
lateinit var settingsRepo: SettingsDoa
@Inject @Inject
lateinit var configRepo: TunnelConfigDao lateinit var appDataRepository: AppDataRepository
@Inject @Inject
lateinit var vpnService: VpnService lateinit var vpnService: VpnService
private val scope = CoroutineScope(Dispatchers.Main) @Inject
lateinit var serviceManager: ServiceManager
private lateinit var job: Job private val scope = CoroutineScope(Dispatchers.IO)
private var manualStartConfig: TunnelConfig? = null
override fun onStartListening() { override fun onStartListening() {
job =
scope.launch {
updateTileState()
}
super.onStartListening() super.onStartListening()
} Timber.d("On start listening called")
scope.launch {
vpnService.vpnState.collect { it ->
when (it.status) {
Tunnel.State.UP -> {
setActive()
it.tunnelConfig?.name?.let { name -> setTileDescription(name) }
}
override fun onTileRemoved() { Tunnel.State.DOWN -> {
super.onTileRemoved() setInactive()
cancelJob() val config = appDataRepository.getStartTunnelConfig()?.also { config ->
manualStartConfig = config
} ?: appDataRepository.getPrimaryOrFirstTunnel()
config?.let {
setTileDescription(it.name)
} ?: setUnavailable()
}
else -> setInactive()
}
}
}
} }
override fun onDestroy() { override fun onDestroy() {
@@ -52,22 +64,30 @@ class TunnelControlTile : TileService() {
scope.cancel() scope.cancel()
} }
override fun onTileRemoved() {
super.onTileRemoved()
scope.cancel()
}
override fun onTileAdded() {
super.onTileAdded()
onStartListening()
}
override fun onClick() { override fun onClick() {
super.onClick() super.onClick()
unlockAndRun { unlockAndRun {
scope.launch { scope.launch {
try { try {
val tunnel = determineTileTunnel() if (vpnService.getState() == Tunnel.State.UP) {
if (tunnel != null) { serviceManager.stopVpnServiceForeground(
attemptWatcherServiceToggle(tunnel.toString()) this@TunnelControlTile,
if (vpnService.getState() == Tunnel.State.UP) { isManualStop = true,
ServiceManager.stopVpnService(this@TunnelControlTile) )
} else { } else {
ServiceManager.startVpnServiceForeground( serviceManager.startVpnServiceForeground(
this@TunnelControlTile, this@TunnelControlTile, manualStartConfig?.id, isManualStart = true,
tunnel.toString() )
)
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e.message) Timber.e(e.message)
@@ -78,68 +98,20 @@ class TunnelControlTile : TileService() {
} }
} }
private suspend fun determineTileTunnel(): TunnelConfig? { private fun setActive() {
var tunnelConfig: TunnelConfig? = null qsTile.state = Tile.STATE_ACTIVE
val settings = settingsRepo.getAll() qsTile.updateTile()
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) { private fun setInactive() {
scope.launch { qsTile.state = Tile.STATE_INACTIVE
val settings = settingsRepo.getAll() qsTile.updateTile()
if (settings.isNotEmpty()) {
val setting = settings.first()
if (setting.isAutoTunnelEnabled) {
ServiceManager.toggleWatcherServiceForeground(
this@TunnelControlTile,
tunnelConfig
)
}
}
}
} }
private suspend fun updateTileState() { private fun setUnavailable() {
vpnService.state.collect { manualStartConfig = null
try { qsTile.state = Tile.STATE_UNAVAILABLE
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()
} catch (e: Exception) {
Timber.e("Unable to update tile state")
}
}
} }
private fun setTileDescription(description: String) { private fun setTileDescription(description: String) {
@@ -149,11 +121,6 @@ class TunnelControlTile : TileService() {
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()
}
} }
} }
@@ -3,15 +3,14 @@ package com.zaneschepke.wireguardautotunnel.service.tunnel
enum class HandshakeStatus { enum class HandshakeStatus {
HEALTHY, HEALTHY,
STALE, STALE,
UNHEALTHY, UNKNOWN,
NEVER_CONNECTED, NOT_STARTED;
NOT_STARTED
;
companion object { companion object {
private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 180 private const val WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC = 180
const val STATUS_CHANGE_TIME_BUFFER = 30 const val STATUS_CHANGE_TIME_BUFFER = 30
const val STALE_TIME_LIMIT_SEC = WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + STATUS_CHANGE_TIME_BUFFER const val STALE_TIME_LIMIT_SEC =
WG_TYPICAL_HANDSHAKE_INTERVAL_WHEN_HEALTHY_SEC + STATUS_CHANGE_TIME_BUFFER
const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30 const val NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC = 30
} }
} }
@@ -1,21 +1,15 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Statistics import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.wireguard.android.backend.Tunnel import kotlinx.coroutines.flow.StateFlow
import com.wireguard.crypto.Key import org.amnezia.awg.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import kotlinx.coroutines.flow.SharedFlow
interface VpnService : Tunnel { interface VpnService : Tunnel {
suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State suspend fun startTunnel(tunnelConfig: TunnelConfig? = null): Tunnel.State
suspend fun stopTunnel() suspend fun stopTunnel()
val state: SharedFlow<Tunnel.State> val vpnState: StateFlow<VpnState>
val tunnelName: SharedFlow<String>
val statistics: SharedFlow<Statistics>
val lastHandshake: SharedFlow<Map<Key, Long>>
val handshakeStatus: SharedFlow<HandshakeStatus>
fun getState(): Tunnel.State fun getState(): Tunnel.State
} }
@@ -0,0 +1,11 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import org.amnezia.awg.backend.Statistics
import org.amnezia.awg.backend.Tunnel
data class VpnState(
val status: Tunnel.State = Tunnel.State.DOWN,
val tunnelConfig: TunnelConfig? = null,
val statistics: Statistics? = null
)
@@ -1,68 +1,40 @@
package com.zaneschepke.wireguardautotunnel.service.tunnel package com.zaneschepke.wireguardautotunnel.service.tunnel
import com.wireguard.android.backend.Backend import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.wireguard.android.backend.BackendException import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.wireguard.android.backend.Statistics import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import com.wireguard.crypto.Key
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.module.Kernel import com.zaneschepke.wireguardautotunnel.module.Kernel
import com.zaneschepke.wireguardautotunnel.module.Userspace import com.zaneschepke.wireguardautotunnel.module.Userspace
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig import kotlinx.coroutines.CancellationException
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import javax.inject.Inject
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.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 org.amnezia.awg.backend.Backend
import org.amnezia.awg.backend.BackendException
import org.amnezia.awg.backend.Statistics
import org.amnezia.awg.backend.Tunnel
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
class WireGuardTunnel class WireGuardTunnel
@Inject @Inject
constructor( constructor(
@Userspace private val userspaceBackend: Backend, @Userspace private val userspaceBackend: Backend,
@Kernel private val kernelBackend: Backend, @Kernel private val kernelBackend: Backend,
private val settingsRepo: SettingsDoa private val appDataRepository: AppDataRepository,
) : VpnService { ) : VpnService {
private val _tunnelName = MutableStateFlow("") private val _vpnState = MutableStateFlow(VpnState())
override val tunnelName get() = _tunnelName.asStateFlow() override val vpnState: StateFlow<VpnState> = _vpnState.asStateFlow()
private val _state =
MutableSharedFlow<Tunnel.State>(
onBufferOverflow = BufferOverflow.DROP_OLDEST,
replay = 1
)
private val _handshakeStatus =
MutableSharedFlow<HandshakeStatus>(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val state get() = _state.asSharedFlow()
private val _statistics = MutableSharedFlow<Statistics>(replay = 1)
override val statistics get() = _statistics.asSharedFlow()
private val _lastHandshake = MutableSharedFlow<Map<Key, Long>>(replay = 1)
override val lastHandshake get() = _lastHandshake.asSharedFlow()
override val handshakeStatus: SharedFlow<HandshakeStatus>
get() = _handshakeStatus.asSharedFlow()
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private lateinit var statsJob: Job private var statsJob: Job? = null
private var config: Config? = null
private var backend: Backend = userspaceBackend private var backend: Backend = userspaceBackend
@@ -70,13 +42,12 @@ constructor(
init { init {
scope.launch { scope.launch {
settingsRepo.getAllFlow().collect { appDataRepository.settings.getSettingsFlow().collect {
val settings = it.first() if (it.isKernelEnabled && backendIsUserspace) {
if (settings.isKernelEnabled && backendIsUserspace) {
Timber.d("Setting kernel backend") Timber.d("Setting kernel backend")
backend = kernelBackend backend = kernelBackend
backendIsUserspace = false backendIsUserspace = false
} else if (!settings.isKernelEnabled && !backendIsUserspace) { } else if (!it.isKernelEnabled && !backendIsUserspace) {
Timber.d("Setting userspace backend") Timber.d("Setting userspace backend")
backend = userspaceBackend backend = userspaceBackend
backendIsUserspace = true backendIsUserspace = true
@@ -85,44 +56,56 @@ constructor(
} }
} }
override suspend fun startTunnel(tunnelConfig: TunnelConfig): Tunnel.State { override suspend fun startTunnel(tunnelConfig: TunnelConfig?): Tunnel.State {
return try { return try {
stopTunnelOnConfigChange(tunnelConfig) //TODO we need better error handling here
emitTunnelName(tunnelConfig.name) val config = tunnelConfig ?: appDataRepository.getPrimaryOrFirstTunnel()
config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick) if (config != null) {
val state = emitTunnelConfig(config)
backend.setState( val wgConfig = TunnelConfig.configFromQuick(config.wgQuick)
this, val state =
Tunnel.State.UP, backend.setState(
config this,
) Tunnel.State.UP,
_state.emit(state) wgConfig,
state )
} catch (e: Exception) { state
} else throw Exception("No tunnels")
} catch (e: BackendException) {
Timber.e("Failed to start tunnel with error: ${e.message}") Timber.e("Failed to start tunnel with error: ${e.message}")
Tunnel.State.DOWN Tunnel.State.DOWN
} }
} }
private suspend fun emitTunnelName(name: String) { private fun emitTunnelState(state: Tunnel.State) {
_tunnelName.emit(name) _vpnState.tryEmit(
_vpnState.value.copy(
status = state,
),
)
} }
private suspend fun stopTunnelOnConfigChange(tunnelConfig: TunnelConfig) { private fun emitBackendStatistics(statistics: Statistics) {
if (getState() == Tunnel.State.UP && _tunnelName.value != tunnelConfig.name) { _vpnState.tryEmit(
stopTunnel() _vpnState.value.copy(
} statistics = statistics,
),
)
} }
override fun getName(): String { private suspend fun emitTunnelConfig(tunnelConfig: TunnelConfig?) {
return _tunnelName.value _vpnState.emit(
_vpnState.value.copy(
tunnelConfig = tunnelConfig,
),
)
} }
override suspend fun stopTunnel() { override suspend fun stopTunnel() {
try { try {
if (getState() == Tunnel.State.UP) { if (getState() == Tunnel.State.UP) {
val state = backend.setState(this, Tunnel.State.DOWN, null) val state = backend.setState(this, Tunnel.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}")
@@ -133,55 +116,30 @@ constructor(
return backend.getState(this) return backend.getState(this)
} }
override fun getName(): String {
return _vpnState.value.tunnelConfig?.name ?: ""
}
override fun onStateChange(state: Tunnel.State) { override fun onStateChange(state: Tunnel.State) {
val tunnel = this val tunnel = this
_state.tryEmit(state) emitTunnelState(state)
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate(WireGuardAutoTunnel.instance)
if (state == Tunnel.State.UP) { if (state == Tunnel.State.UP) {
statsJob = statsJob =
scope.launch { scope.launch {
val handshakeMap = HashMap<Key, Long>()
var neverHadHandshakeCounter = 0
while (true) { while (true) {
val statistics = backend.getStatistics(tunnel) val statistics = backend.getStatistics(tunnel)
_statistics.emit(statistics) emitBackendStatistics(statistics)
statistics.peers().forEach { key ->
val handshakeEpoch =
statistics.peer(key)?.latestHandshakeEpochMillis ?: 0L
handshakeMap[key] = handshakeEpoch
if (handshakeEpoch == 0L) {
if (neverHadHandshakeCounter >= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.NEVER_CONNECTED)
} else {
_handshakeStatus.emit(HandshakeStatus.NOT_STARTED)
}
if (neverHadHandshakeCounter <= HandshakeStatus.NEVER_CONNECTED_TO_UNHEALTHY_TIME_LIMIT_SEC) {
neverHadHandshakeCounter += (1 * Constants.VPN_STATISTIC_CHECK_INTERVAL / 1000).toInt()
}
return@forEach
}
// TODO one day make each peer have their own dedicated status
val lastHandshake = NumberUtils.getSecondsBetweenTimestampAndNow(
handshakeEpoch
)
if (lastHandshake != null) {
if (lastHandshake >= HandshakeStatus.STALE_TIME_LIMIT_SEC) {
_handshakeStatus.emit(HandshakeStatus.STALE)
} else {
_handshakeStatus.emit(HandshakeStatus.HEALTHY)
}
}
}
_lastHandshake.emit(handshakeMap)
delay(Constants.VPN_STATISTIC_CHECK_INTERVAL) delay(Constants.VPN_STATISTIC_CHECK_INTERVAL)
} }
} }
} }
if (state == Tunnel.State.DOWN) { if (state == Tunnel.State.DOWN) {
if (this::statsJob.isInitialized) { try {
statsJob.cancel() statsJob?.cancel()
} catch (e : CancellationException) {
Timber.i("Stats job cancelled")
} }
_handshakeStatus.tryEmit(HandshakeStatus.NOT_STARTED)
_lastHandshake.tryEmit(emptyMap())
} }
} }
} }
@@ -1,8 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.lifecycle.ViewModel
import javax.inject.Inject
class ActivityViewModel @Inject constructor() : ViewModel() {
// TODO move shared logic to shared viewmodel
}
@@ -0,0 +1,9 @@
package com.zaneschepke.wireguardautotunnel.ui
data class AppUiState(
val snackbarMessage: String = "",
val snackbarMessageConsumed: Boolean = true,
val vpnPermissionAccepted: Boolean = false,
val notificationPermissionAccepted: Boolean = false,
val requestPermissions: Boolean = false
)
@@ -0,0 +1,148 @@
package com.zaneschepke.wireguardautotunnel.ui
import android.app.Application
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.runtime.mutableStateListOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.zaneschepke.logcatter.Logcatter
import com.zaneschepke.logcatter.model.LogMessage
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.FileUtils
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 org.amnezia.awg.backend.GoBackend
import timber.log.Timber
import java.time.Instant
import javax.inject.Inject
@HiltViewModel
class AppViewModel
@Inject
constructor(
private val application: Application,
) : ViewModel() {
val vpnIntent: Intent? = GoBackend.VpnService.prepare(WireGuardAutoTunnel.instance)
private val _appUiState = MutableStateFlow(
AppUiState(
vpnPermissionAccepted = vpnIntent == null,
),
)
val appUiState = _appUiState.asStateFlow()
fun isRequiredPermissionGranted(): Boolean {
val allAccepted =
(_appUiState.value.vpnPermissionAccepted && _appUiState.value.vpnPermissionAccepted)
if (!allAccepted) requestPermissions()
return allAccepted
}
private fun requestPermissions() {
_appUiState.value = _appUiState.value.copy(
requestPermissions = true,
)
}
fun permissionsRequested() {
_appUiState.value = _appUiState.value.copy(
requestPermissions = false,
)
}
fun openWebPage(url: String) {
try {
val webpage: Uri = Uri.parse(url)
val intent = Intent(Intent.ACTION_VIEW, webpage).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
application.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Timber.e(e)
showSnackbarMessage(application.getString(R.string.no_browser_detected))
}
}
fun onVpnPermissionAccepted() {
_appUiState.value = _appUiState.value.copy(
vpnPermissionAccepted = true,
)
}
fun launchEmail() {
try {
val intent =
Intent(Intent.ACTION_SENDTO).apply {
type = Constants.EMAIL_MIME_TYPE
putExtra(Intent.EXTRA_EMAIL, arrayOf(application.getString(R.string.my_email)))
putExtra(Intent.EXTRA_SUBJECT, application.getString(R.string.email_subject))
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
application.startActivity(
Intent.createChooser(intent, application.getString(R.string.email_chooser)).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
},
)
} catch (e: ActivityNotFoundException) {
Timber.e(e)
showSnackbarMessage(application.getString(R.string.no_email_detected))
}
}
fun showSnackbarMessage(message: String) {
_appUiState.value = _appUiState.value.copy(
snackbarMessage = message,
snackbarMessageConsumed = false,
)
}
fun snackbarMessageConsumed() {
_appUiState.value = _appUiState.value.copy(
snackbarMessage = "",
snackbarMessageConsumed = true,
)
}
val logs = mutableStateListOf<LogMessage>()
fun readLogCatOutput() =
viewModelScope.launch(viewModelScope.coroutineContext + Dispatchers.IO) {
launch {
Logcatter.logs(callback = {
logs.add(it)
if (logs.size > Constants.LOG_BUFFER_SIZE) {
logs.removeRange(0, (logs.size - Constants.LOG_BUFFER_SIZE).toInt())
}
})
}
}
fun clearLogs() {
logs.clear()
Logcatter.clear()
}
fun saveLogsToFile() {
val fileName = "${Constants.BASE_LOG_FILE_NAME}-${Instant.now().epochSecond}.txt"
val content = logs.joinToString(separator = "\n")
FileUtils.saveFileToDownloads(application.applicationContext, content, fileName)
Toast.makeText(application, application.getString(R.string.logs_saved), Toast.LENGTH_SHORT)
.show()
}
fun setNotificationPermissionAccepted(accepted: Boolean) {
_appUiState.value = _appUiState.value.copy(
notificationPermissionAccepted = accepted,
)
}
}
@@ -1,20 +1,17 @@
package com.zaneschepke.wireguardautotunnel.ui package com.zaneschepke.wireguardautotunnel.ui
import android.Manifest import android.Manifest
import android.content.Intent
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 androidx.activity.SystemBarStyle
import android.view.KeyEvent
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.compose.animation.ExitTransition import androidx.compose.foundation.focusable
import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.animation.fadeIn import androidx.compose.foundation.layout.padding
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,102 +22,143 @@ 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.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.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController 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.ui.common.PermissionRequestFailedScreen import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.data.datastore.DataStoreManager
import com.zaneschepke.wireguardautotunnel.data.repository.SettingsRepository
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager
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.main.MainScreen import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.options.OptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pinlock.PinLockScreen
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.screens.support.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.StringValue
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 xyz.teamgravity.pin_lock_compose.PinManager
import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@Inject
lateinit var dataStoreManager: DataStoreManager
@Inject
lateinit var settingsRepository: SettingsRepository
@Inject
lateinit var serviceManager: ServiceManager
@OptIn( @OptIn(
ExperimentalPermissionsApi::class ExperimentalPermissionsApi::class,
) )
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge(navigationBarStyle = SystemBarStyle.dark(Color.Transparent.toArgb()))
// load preferences into memory and init data
lifecycleScope.launch {
dataStoreManager.init()
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate(this@MainActivity)
val settings = settingsRepository.getSettings()
if (settings.isAutoTunnelEnabled) {
serviceManager.startWatcherService(application.applicationContext)
}
}
setContent { setContent {
// TODO move shared logic to shared viewmodel val appViewModel = hiltViewModel<AppViewModel>()
// val sharedViewModel = hiltViewModel<ActivityViewModel>() val appUiState by appViewModel.appUiState.collectAsStateWithLifecycle()
val navController = rememberNavController() val navController = rememberNavController()
val focusRequester = remember { FocusRequester() } val navBackStackEntry by navController.currentBackStackEntryAsState()
val notificationPermissionState =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS) else null
val snackbarHostState = remember { SnackbarHostState() }
val vpnActivityResultState =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
val accepted = (it.resultCode == RESULT_OK)
if (accepted) {
appViewModel.onVpnPermissionAccepted()
}
},
)
fun showSnackBarMessage(message: StringValue) {
lifecycleScope.launch(Dispatchers.Main) {
val result =
snackbarHostState.showSnackbar(
message = message.asString(this@MainActivity),
duration = SnackbarDuration.Short,
)
when (result) {
SnackbarResult.ActionPerformed,
SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
}
}
}
LaunchedEffect(appUiState.requestPermissions) {
if (appUiState.requestPermissions) {
appViewModel.permissionsRequested()
if (notificationPermissionState != null && !notificationPermissionState.status.isGranted
) {
showSnackBarMessage(StringValue.StringResource(R.string.notification_permission_required))
return@LaunchedEffect notificationPermissionState.launchPermissionRequest()
}
if (!appUiState.vpnPermissionAccepted) {
return@LaunchedEffect vpnActivityResultState.launch(appViewModel.vpnIntent)
}
}
}
WireguardAutoTunnelTheme { WireguardAutoTunnelTheme {
TransparentSystemBars() LaunchedEffect(Unit) {
appViewModel.setNotificationPermissionAccepted(
val snackbarHostState = remember { SnackbarHostState() } notificationPermissionState?.status?.isGranted ?: true,
val notificationPermissionState =
rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
fun requestNotificationPermission() {
if (!notificationPermissionState.status.isGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionState.launchPermissionRequest()
}
}
var vpnIntent by remember { mutableStateOf(GoBackend.VpnService.prepare(this)) }
val vpnActivityResultState =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult(),
onResult = {
val accepted = (it.resultCode == RESULT_OK)
if (accepted) {
vpnIntent = null
}
}
) )
LaunchedEffect(vpnIntent) { if (!WireGuardAutoTunnel.isRunningOnAndroidTv()) appViewModel.readLogCatOutput()
if (vpnIntent != null) { }
vpnActivityResultState.launch(vpnIntent)
} else { LaunchedEffect(appUiState.snackbarMessageConsumed) {
requestNotificationPermission() if (!appUiState.snackbarMessageConsumed) {
showSnackBarMessage(StringValue.DynamicString(appUiState.snackbarMessage))
appViewModel.snackbarMessageConsumed()
} }
} }
fun showSnackBarMessage(message: String) { val focusRequester = remember { FocusRequester() }
lifecycleScope.launch(Dispatchers.Main) {
val result =
snackbarHostState.showSnackbar(
message = message,
actionLabel = applicationContext.getString(R.string.okay),
duration = SnackbarDuration.Short
)
when (result) {
SnackbarResult.ActionPerformed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
SnackbarResult.Dismissed -> {
snackbarHostState.currentSnackbarData?.dismiss()
}
}
}
}
Scaffold( Scaffold(
snackbarHost = { snackbarHost = {
@@ -128,159 +166,100 @@ class MainActivity : AppCompatActivity() {
CustomSnackBar( CustomSnackBar(
snackbarData.visuals.message, snackbarData.visuals.message,
isRtl = false, isRtl = false,
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation( containerColor =
2.dp MaterialTheme.colorScheme.surfaceColorAtElevation(
) 2.dp,
),
) )
} }
}, },
modifier = //TODO refactor
Modifier.onKeyEvent { modifier = Modifier
if (it.nativeKeyEvent.action == KeyEvent.ACTION_UP) { .focusable()
when (it.nativeKeyEvent.keyCode) { .focusProperties {
KeyEvent.KEYCODE_DPAD_UP -> { when (navBackStackEntry?.destination?.route) {
try { Screen.Lock.route -> Unit
focusRequester.requestFocus() else -> up = focusRequester
} catch (e: IllegalStateException) {
Timber.e(
"No D-Pad focus request modifier added to element on screen"
)
}
false
}
else -> {
false
}
} }
} else { },
false bottomBar = {
} BottomNavBar(
navController,
listOf(
Screen.Main.navItem,
Screen.Settings.navItem,
Screen.Support.navItem,
),
)
}, },
bottomBar =
if (vpnIntent == null && notificationPermissionState.status.isGranted) {
{ BottomNavBar(navController, Routes.navItems) }
} else {
{}
}
) { padding -> ) { padding ->
if (vpnIntent != null) { NavHost(
PermissionRequestFailedScreen( navController,
padding = padding, startDestination =
onRequestAgain = { vpnActivityResultState.launch(vpnIntent) }, (if (PinManager.pinExists()) Screen.Lock.route else Screen.Main.route),
message = getString(R.string.vpn_permission_required), modifier =
getString(R.string.retry) Modifier
) .padding(padding)
return@Scaffold .fillMaxSize(),
} ) {
if (!notificationPermissionState.status.isGranted) {
PermissionRequestFailedScreen(
padding = padding,
onRequestAgain = {
val intentSettings =
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intentSettings.data =
Uri.fromParts(
Constants.URI_PACKAGE_SCHEME,
this.packageName,
null
)
startActivity(intentSettings)
},
message = getString(R.string.notification_permission_required),
getString(R.string.open_settings)
)
return@Scaffold
}
NavHost(navController, startDestination = Routes.Main.name) {
composable( composable(
Routes.Main.name, Screen.Main.route,
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
}
) { ) {
MainScreen(padding = padding, showSnackbarMessage = { message -> MainScreen(
showSnackBarMessage(message) focusRequester = focusRequester,
}, navController = navController) appViewModel = appViewModel,
navController = navController,
)
} }
composable(Routes.Settings.name, enterTransition = { composable(
when (initialState.destination.route) { Screen.Settings.route,
Routes.Main.name -> ) {
slideInHorizontally( SettingsScreen(
initialOffsetX = { Constants.SLIDE_IN_TRANSITION_OFFSET }, appViewModel = appViewModel,
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) navController = navController,
) focusRequester = focusRequester,
)
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)
)
}
}
}) {
SettingsScreen(padding = padding, showSnackbarMessage = { message ->
showSnackBarMessage(message)
}, focusRequester = focusRequester)
} }
composable(Routes.Support.name, enterTransition = { composable(
when (initialState.destination.route) { Screen.Support.route,
Routes.Settings.name, Routes.Main.name -> ) {
slideInHorizontally( SupportScreen(
initialOffsetX = { Constants.SLIDE_IN_ANIMATION_DURATION }, focusRequester = focusRequester,
animationSpec = tween(Constants.SLIDE_IN_ANIMATION_DURATION) appViewModel = appViewModel,
) navController = navController,
)
else -> { }
fadeIn( composable(Screen.Support.Logs.route) {
animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION) LogsScreen(appViewModel)
) }
} composable("${Screen.Config.route}/{id}") {
}
}) { SupportScreen(padding = padding, focusRequester = focusRequester) }
composable("${Routes.Config.name}/{id}", enterTransition = {
fadeIn(animationSpec = tween(Constants.FADE_IN_ANIMATION_DURATION))
}) {
val id = it.arguments?.getString("id") val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) { if (!id.isNullOrBlank()) {
ConfigScreen( ConfigScreen(
navController = navController, navController = navController,
id = id, tunnelId = id,
showSnackbarMessage = { message -> appViewModel = appViewModel,
showSnackBarMessage(message) focusRequester = focusRequester,
},
focusRequester = focusRequester
) )
} }
} }
composable("${Screen.Option.route}/{id}") {
val id = it.arguments?.getString("id")
if (!id.isNullOrBlank()) {
OptionsScreen(
navController = navController,
tunnelId = id,
appViewModel = appViewModel,
focusRequester = focusRequester,
)
}
}
composable(Screen.Lock.route) {
PinLockScreen(
navController = navController,
appViewModel = appViewModel,
)
}
} }
} }
} }
@@ -1,36 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
enum class Routes {
Main,
Settings,
Support,
Config
;
companion object {
val navItems =
listOf(
BottomNavItem(
name = "Tunnels",
route = Main.name,
icon = Icons.Rounded.Home
),
BottomNavItem(
name = "Settings",
route = Settings.name,
icon = Icons.Rounded.Settings
),
BottomNavItem(
name = "Support",
route = Support.name,
icon = Icons.Rounded.QuestionMark
)
)
}
}
@@ -0,0 +1,47 @@
package com.zaneschepke.wireguardautotunnel.ui
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.ui.common.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.util.StringValue
sealed class Screen(val route: String) {
data object Main : Screen("main") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.tunnels),
route = route,
icon = Icons.Rounded.Home,
)
}
data object Settings : Screen("settings") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.settings),
route = route,
icon = Icons.Rounded.Settings,
)
}
data object Support : Screen("support") {
val navItem =
BottomNavItem(
name = WireGuardAutoTunnel.instance.getString(R.string.support),
route = route,
icon = Icons.Rounded.QuestionMark,
)
data object Logs : Screen("support/logs")
}
data object Config : Screen("config")
data object Lock : Screen("lock")
data object Option : Screen("option")
}
@@ -1,10 +1,8 @@
package com.zaneschepke.wireguardautotunnel.ui.common package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -12,32 +10,33 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.zaneschepke.wireguardautotunnel.R
@Composable @Composable
fun ClickableIconButton( fun ClickableIconButton(
onClick: () -> Unit,
onIconClick: () -> Unit, onIconClick: () -> Unit,
text: String, text: String,
icon: ImageVector, icon: ImageVector,
enabled: Boolean enabled: Boolean
) { ) {
TextButton( TextButton(
onClick = {}, onClick = onClick,
enabled = enabled enabled = enabled,
) { ) {
Text(text, Modifier.weight(1f, false)) Text(text, Modifier.weight(1f, false))
Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing)) Spacer(modifier = Modifier.size(ButtonDefaults.IconSpacing))
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = stringResource(R.string.delete), contentDescription = icon.name,
modifier = modifier =
Modifier.size(ButtonDefaults.IconSize).weight(1f, false).clickable { Modifier
if (enabled) { .size(ButtonDefaults.IconSize)
onIconClick() .weight(1f, false)
} .clickable {
} if (enabled) {
onIconClick()
}
},
) )
} }
} }
@@ -1,43 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
fun PermissionRequestFailedScreen(
padding: PaddingValues,
onRequestAgain: () -> Unit,
message: String,
buttonText: String
) {
val scope = rememberCoroutineScope()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier =
Modifier
.fillMaxSize()
.padding(padding)
) {
Text(message, textAlign = TextAlign.Center, modifier = Modifier.padding(15.dp))
Button(onClick = {
scope.launch {
onRequestAgain()
}
}) {
Text(buttonText)
}
}
}
@@ -17,9 +17,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.wireguard.android.backend.Statistics
import com.zaneschepke.wireguardautotunnel.toThreeDecimalPlaceString
import com.zaneschepke.wireguardautotunnel.util.NumberUtils import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.toThreeDecimalPlaceString
import org.amnezia.awg.backend.Statistics
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
@@ -38,26 +38,20 @@ fun RowListItem(
.animateContentSize() .animateContentSize()
.clip(RoundedCornerShape(30.dp)) .clip(RoundedCornerShape(30.dp))
.combinedClickable( .combinedClickable(
onClick = { onClick = { onClick() },
onClick() onLongClick = { onHold() },
}, ),
onLongClick = {
onHold()
}
)
) { ) {
Column { Column {
Row( Row(
modifier = modifier = Modifier
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 5.dp), .padding(horizontal = 15.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(.60f)
) { ) {
icon() icon()
Text(text) Text(text)
@@ -72,8 +66,9 @@ fun RowListItem(
.fillMaxWidth() .fillMaxWidth()
.padding(end = 10.dp, bottom = 10.dp, start = 10.dp), .padding(end = 10.dp, bottom = 10.dp, start = 10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly horizontalArrangement = Arrangement.SpaceEvenly,
) { ) {
//TODO change these to string resources
val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis val handshakeEpoch = statistics.peer(it)!!.latestHandshakeEpochMillis
val peerTx = statistics.peer(it)!!.txBytes val peerTx = statistics.peer(it)!!.txBytes
val peerRx = statistics.peer(it)!!.rxBytes val peerRx = statistics.peer(it)!!.rxBytes
@@ -47,7 +47,7 @@ fun SearchBar(onQuery: (queryString: String) -> Unit) {
Icon( Icon(
imageVector = Icons.Rounded.Search, imageVector = Icons.Rounded.Search,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
contentDescription = stringResource(id = R.string.search_icon) contentDescription = stringResource(id = R.string.search_icon),
) )
}, },
trailingIcon = { trailingIcon = {
@@ -56,7 +56,7 @@ fun SearchBar(onQuery: (queryString: String) -> Unit) {
Icon( Icon(
imageVector = Icons.Rounded.Clear, imageVector = Icons.Rounded.Clear,
tint = MaterialTheme.colorScheme.onBackground, tint = MaterialTheme.colorScheme.onBackground,
contentDescription = stringResource(id = R.string.clear_icon) contentDescription = stringResource(id = R.string.clear_icon),
) )
} }
} }
@@ -66,7 +66,7 @@ fun SearchBar(onQuery: (queryString: String) -> Unit) {
TextFieldDefaults.colors( TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent, focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent disabledContainerColor = Color.Transparent,
), ),
placeholder = { Text(text = stringResource(R.string.hint_search_packages)) }, placeholder = { Text(text = stringResource(R.string.hint_search_packages)) },
textStyle = MaterialTheme.typography.bodySmall, textStyle = MaterialTheme.typography.bodySmall,
@@ -75,6 +75,6 @@ fun SearchBar(onQuery: (queryString: String) -> Unit) {
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.background(color = MaterialTheme.colorScheme.background, shape = RectangleShape) .background(color = MaterialTheme.colorScheme.background, shape = RectangleShape),
) )
} }
@@ -22,19 +22,15 @@ fun ConfigurationTextBox(
modifier = modifier, modifier = modifier,
value = value, value = value,
singleLine = true, singleLine = true,
onValueChange = { onValueChange = { onValueChange(it) },
onValueChange(it)
},
label = { Text(label) }, label = { Text(label) },
maxLines = 1, maxLines = 1,
placeholder = { placeholder = { Text(hint) },
Text(hint)
},
keyboardOptions = keyboardOptions =
KeyboardOptions( KeyboardOptions(
capitalization = KeyboardCapitalization.None, capitalization = KeyboardCapitalization.None,
imeAction = ImeAction.Done imeAction = ImeAction.Done,
), ),
keyboardActions = keyboardActions keyboardActions = keyboardActions,
) )
} }
@@ -9,6 +9,7 @@ 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.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
@Composable @Composable
@@ -21,21 +22,23 @@ fun ConfigurationToggle(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Row( Row(
modifier = modifier = Modifier
Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(padding), .padding(padding),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
Text(label) Text(label, textAlign = TextAlign.Start, modifier = Modifier
.weight(
weight = 1.0f,
fill = false,
),
softWrap = true)
Switch( Switch(
modifier = modifier, modifier = modifier,
enabled = enabled, enabled = enabled,
checked = checked, checked = checked,
onCheckedChange = { onCheckedChange = { onCheckChanged() },
onCheckChanged()
}
) )
} }
} }
@@ -6,21 +6,33 @@ import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import com.zaneschepke.wireguardautotunnel.ui.Screen
@Composable @Composable
fun BottomNavBar( fun BottomNavBar(navController: NavController, bottomNavItems: List<BottomNavItem>) {
navController: NavController,
bottomNavItems: List<BottomNavItem>
) {
val backStackEntry = navController.currentBackStackEntryAsState() val backStackEntry = navController.currentBackStackEntryAsState()
var showBottomBar by rememberSaveable { mutableStateOf(true) }
val navBackStackEntry by navController.currentBackStackEntryAsState()
//TODO find a better way to hide nav bar
showBottomBar = when (navBackStackEntry?.destination?.route) {
Screen.Lock.route -> false
else -> true
}
NavigationBar( NavigationBar(
containerColor = MaterialTheme.colorScheme.background containerColor = if (!showBottomBar) Color.Transparent else MaterialTheme.colorScheme.background,
) { ) {
bottomNavItems.forEach { item -> if (showBottomBar) bottomNavItems.forEach { item ->
val selected = item.route == backStackEntry.value?.destination?.route val selected = item.route == backStackEntry.value?.destination?.route
NavigationBarItem( NavigationBarItem(
@@ -29,15 +41,15 @@ fun BottomNavBar(
label = { label = {
Text( Text(
text = item.name, text = item.name,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold,
) )
}, },
icon = { icon = {
Icon( Icon(
imageVector = item.icon, imageVector = item.icon,
contentDescription = "${item.name} Icon" contentDescription = "${item.name} Icon",
) )
} },
) )
} }
} }
@@ -11,51 +11,46 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
@Composable @Composable
fun AuthorizationPrompt( fun AuthorizationPrompt(onSuccess: () -> Unit, onFailure: () -> Unit, onError: (String) -> Unit) {
onSuccess: () -> Unit,
onFailure: () -> Unit,
onError: (String) -> Unit
) {
val context = LocalContext.current val context = LocalContext.current
val biometricManager = BiometricManager.from(context) val biometricManager = BiometricManager.from(context)
val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL) val bio = biometricManager.canAuthenticate(BIOMETRIC_WEAK or DEVICE_CREDENTIAL)
val isBiometricAvailable = val isBiometricAvailable = remember {
remember { when (bio) {
when (bio) { BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> {
BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> { onError("Biometrics not available")
onError("Biometrics not available") false
false
}
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
} }
BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> {
onError("Biometrics not created")
false
}
BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> {
onError("Biometric hardware not found")
false
}
BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> {
onError("Biometric security update required")
false
}
BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> {
onError("Biometrics not supported")
false
}
BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> {
onError("Biometrics status unknown")
false
}
BiometricManager.BIOMETRIC_SUCCESS -> true
else -> false
} }
}
if (isBiometricAvailable) { if (isBiometricAvailable) {
val executor = remember { ContextCompat.getMainExecutor(context) } val executor = remember { ContextCompat.getMainExecutor(context) }
@@ -71,10 +66,7 @@ fun AuthorizationPrompt(
context as FragmentActivity, context as FragmentActivity,
executor, executor,
object : BiometricPrompt.AuthenticationCallback() { object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationError( override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
errorCode: Int,
errString: CharSequence
) {
super.onAuthenticationError(errorCode, errString) super.onAuthenticationError(errorCode, errString)
onFailure() onFailure()
} }
@@ -90,7 +82,7 @@ fun AuthorizationPrompt(
super.onAuthenticationFailed() super.onAuthenticationFailed()
onFailure() onFailure()
} }
} },
) )
biometricPrompt.authenticate(promptInfo) biometricPrompt.authenticate(promptInfo)
} }
@@ -19,12 +19,9 @@ 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.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
@Composable @Composable
@@ -33,29 +30,32 @@ fun CustomSnackBar(
isRtl: Boolean = true, isRtl: Boolean = true,
containerColor: Color = MaterialTheme.colorScheme.surface containerColor: Color = MaterialTheme.colorScheme.surface
) { ) {
val context = LocalContext.current
Snackbar( Snackbar(
containerColor = containerColor, containerColor = containerColor,
modifier = modifier =
Modifier.fillMaxWidth( Modifier
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) 1 / 3f else 2 / 3f .fillMaxWidth(
).padding(bottom = 100.dp), if (WireGuardAutoTunnel.isRunningOnAndroidTv()) 1 / 3f else 2 / 3f,
shape = RoundedCornerShape(16.dp) )
.padding(bottom = 100.dp),
shape = RoundedCornerShape(16.dp),
) { ) {
CompositionLocalProvider( CompositionLocalProvider(
LocalLayoutDirection provides LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr,
if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr
) { ) {
Row( Row(
modifier = Modifier.width(IntrinsicSize.Max).height(IntrinsicSize.Min), modifier = Modifier
.width(IntrinsicSize.Max)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start horizontalArrangement = Arrangement.Start,
) { ) {
val icon = Icons.Rounded.Info
Icon( Icon(
Icons.Rounded.Info, icon,
contentDescription = stringResource(R.string.info), contentDescription = icon.name,
tint = Color.White, tint = Color.White,
modifier = Modifier.padding(end = 10.dp) modifier = Modifier.padding(end = 10.dp),
) )
Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp)) Text(message, color = Color.White, modifier = Modifier.padding(end = 5.dp))
} }
@@ -0,0 +1,26 @@
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() }
}
}
@@ -0,0 +1,25 @@
package com.zaneschepke.wireguardautotunnel.ui.common.text
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
@Composable
fun LogTypeLabel(color: Color, content: @Composable () -> Unit) {
Box(
modifier = Modifier
.size(20.dp)
.clip(RoundedCornerShape(2.dp))
.background(color),
contentAlignment = Alignment.Center,
) {
content()
}
}
@@ -12,14 +12,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
@Composable @Composable
fun SectionTitle( fun SectionTitle(title: String, padding: Dp) {
title: String,
padding: Dp
) {
Text( Text(
title, title,
textAlign = TextAlign.Center, textAlign = TextAlign.Start,
style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.ExtraBold), style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.ExtraBold),
modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp) modifier = Modifier.padding(padding, bottom = 5.dp, top = 5.dp),
) )
} }
@@ -1,6 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.models package com.zaneschepke.wireguardautotunnel.ui.models
import com.wireguard.config.Interface import org.amnezia.awg.config.Interface
data class InterfaceProxy( data class InterfaceProxy(
var privateKey: String = "", var privateKey: String = "",
@@ -17,13 +17,13 @@ data class InterfaceProxy(
privateKey = i.keyPair.privateKey.toBase64().trim(), privateKey = i.keyPair.privateKey.toBase64().trim(),
addresses = i.addresses.joinToString(", ").trim(), addresses = i.addresses.joinToString(", ").trim(),
dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(), dnsServers = i.dnsServers.joinToString(", ").replace("/", "").trim(),
listenPort = if (i.listenPort.isPresent) { listenPort =
i.listenPort.get().toString() if (i.listenPort.isPresent) {
.trim() i.listenPort.get().toString().trim()
} else { } else {
"" ""
}, },
mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "" mtu = if (i.mtu.isPresent) i.mtu.get().toString().trim() else "",
) )
} }
} }
@@ -1,6 +1,6 @@
package com.zaneschepke.wireguardautotunnel.ui.models package com.zaneschepke.wireguardautotunnel.ui.models
import com.wireguard.config.Peer import org.amnezia.awg.config.Peer
data class PeerProxy( data class PeerProxy(
var publicKey: String = "", var publicKey: String = "",
@@ -13,36 +13,60 @@ data class PeerProxy(
fun from(peer: Peer): PeerProxy { fun from(peer: Peer): PeerProxy {
return PeerProxy( return PeerProxy(
publicKey = peer.publicKey.toBase64(), publicKey = peer.publicKey.toBase64(),
preSharedKey = if (peer.preSharedKey.isPresent) { preSharedKey =
peer.preSharedKey.get().toBase64() if (peer.preSharedKey.isPresent) {
.trim() peer.preSharedKey.get().toBase64().trim()
} else { } else {
"" ""
}, },
persistentKeepalive = if (peer.persistentKeepalive.isPresent) { persistentKeepalive =
peer.persistentKeepalive.get() if (peer.persistentKeepalive.isPresent) {
.toString().trim() peer.persistentKeepalive.get().toString().trim()
} else { } else {
"" ""
}, },
endpoint = if (peer.endpoint.isPresent) { endpoint =
peer.endpoint.get().toString() if (peer.endpoint.isPresent) {
.trim() peer.endpoint.get().toString().trim()
} else { } else {
"" ""
}, },
allowedIps = peer.allowedIps.joinToString(", ").trim() allowedIps = peer.allowedIps.joinToString(", ").trim(),
) )
} }
val IPV4_PUBLIC_NETWORKS = val IPV4_PUBLIC_NETWORKS =
setOf( setOf(
"0.0.0.0/5", "8.0.0.0/7", "11.0.0.0/8", "12.0.0.0/6", "16.0.0.0/4", "32.0.0.0/3", "0.0.0.0/5",
"64.0.0.0/2", "128.0.0.0/3", "160.0.0.0/5", "168.0.0.0/6", "172.0.0.0/12", "8.0.0.0/7",
"172.32.0.0/11", "172.64.0.0/10", "172.128.0.0/9", "173.0.0.0/8", "174.0.0.0/7", "11.0.0.0/8",
"176.0.0.0/4", "192.0.0.0/9", "192.128.0.0/11", "192.160.0.0/13", "192.169.0.0/16", "12.0.0.0/6",
"192.170.0.0/15", "192.172.0.0/14", "192.176.0.0/12", "192.192.0.0/10", "16.0.0.0/4",
"193.0.0.0/8", "194.0.0.0/7", "196.0.0.0/6", "200.0.0.0/5", "208.0.0.0/4" "32.0.0.0/3",
"64.0.0.0/2",
"128.0.0.0/3",
"160.0.0.0/5",
"168.0.0.0/6",
"172.0.0.0/12",
"172.32.0.0/11",
"172.64.0.0/10",
"172.128.0.0/9",
"173.0.0.0/8",
"174.0.0.0/7",
"176.0.0.0/4",
"192.0.0.0/9",
"192.128.0.0/11",
"192.160.0.0/13",
"192.169.0.0/16",
"192.170.0.0/15",
"192.172.0.0/14",
"192.176.0.0/12",
"192.192.0.0/10",
"193.0.0.0/8",
"194.0.0.0/7",
"196.0.0.0/6",
"200.0.0.0/5",
"208.0.0.0/4",
) )
val IPV4_WILDCARD = setOf("0.0.0.0/0") val IPV4_WILDCARD = setOf("0.0.0.0/0")
} }
@@ -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,219 +5,118 @@ 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.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.wireguard.config.Interface import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.wireguard.config.Peer import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.wireguard.crypto.Key
import com.wireguard.crypto.KeyPair
import com.zaneschepke.wireguardautotunnel.Constants
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.ui.models.InterfaceProxy import com.zaneschepke.wireguardautotunnel.ui.models.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 javax.inject.Inject
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.launch import kotlinx.coroutines.launch
import org.amnezia.awg.config.Config
import org.amnezia.awg.config.Interface
import org.amnezia.awg.config.Peer
import org.amnezia.awg.crypto.Key
import org.amnezia.awg.crypto.KeyPair
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel @HiltViewModel
class ConfigViewModel class ConfigViewModel
@Inject @Inject
constructor( constructor(
private val application: Application, private val application: Application,
private val tunnelRepo: TunnelConfigDao, private val appDataRepository: AppDataRepository
private val settingsRepo: SettingsDoa
) : 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 state =
private lateinit var tunnelConfig: TunnelConfig if (tunnelId != Constants.MANUAL_TUNNEL_CONFIG_ID) {
val tunnelConfig =
suspend fun onScreenLoad(id: String) { appDataRepository.tunnels.getAll()
if (id != Constants.MANUAL_TUNNEL_CONFIG_ID) { .firstOrNull { it.id.toString() == tunnelId }
tunnelConfig = getTunnelConfigById(id) ?: throw WgTunnelException( if (tunnelConfig != null) {
"Config not found" val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
) val proxyPeers = config.peers.map { PeerProxy.from(it) }
emitScreenData() val proxyInterface = InterfaceProxy.from(config.`interface`)
} else { var include = true
emitEmptyScreenData() 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)
}
_uiState.value = state
} }
}
private fun emitEmptyScreenData() {
tunnelConfig = TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = "")
viewModelScope.launch {
emitTunnelConfig()
emitPeerProxy(PeerProxy())
emitInterfaceProxy(InterfaceProxy())
emitTunnelConfigName()
emitDefaultTunnelStatus()
emitQueriedPackages("")
emitTunnelAllApplicationsEnabled()
}
}
private suspend fun emitScreenData() {
emitTunnelConfig()
emitPeersFromConfig()
emitInterfaceFromConfig()
emitTunnelConfigName()
emitDefaultTunnelStatus()
emitQueriedPackages("")
emitCurrentPackageConfigurations()
}
private suspend fun emitDefaultTunnelStatus() {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
_isDefaultTunnel.value = settings.first().isTunnelConfigDefault(tunnelConfig)
}
}
private fun emitInterfaceFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
_interface.value = InterfaceProxy.from(config.`interface`)
}
private fun emitPeersFromConfig() {
val config = TunnelConfig.configFromQuick(tunnelConfig.wgQuick)
config.peers.forEach {
_proxyPeers.value.add(PeerProxy.from(it))
}
}
private fun emitPeerProxy(peerProxy: PeerProxy) {
_proxyPeers.value.add(peerProxy)
}
private fun emitInterfaceProxy(interfaceProxy: InterfaceProxy) {
_interface.value = interfaceProxy
}
private suspend fun getTunnelConfigById(id: String): TunnelConfig? {
return try {
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) { fun onTunnelNameChange(name: String) {
_tunnelName.value = name _uiState.value = _uiState.value.copy(tunnelName = name)
} }
fun onIncludeChange(include: Boolean) { fun onIncludeChange(include: Boolean) {
_include.value = include _uiState.value = _uiState.value.copy(include = include)
} }
fun onAddCheckedPackage(packageName: String) { fun onAddCheckedPackage(packageName: String) {
_checkedPackages.value.add(packageName) _uiState.value =
_uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames + packageName,
)
} }
fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) { fun onAllApplicationsChange(isAllApplicationsEnabled: Boolean) {
_isAllApplicationsEnabled.value = isAllApplicationsEnabled _uiState.value = _uiState.value.copy(isAllApplicationsEnabled = isAllApplicationsEnabled)
} }
fun onRemoveCheckedPackage(packageName: String) { fun onRemoveCheckedPackage(packageName: String) {
_checkedPackages.value.remove(packageName) _uiState.value =
_uiState.value.copy(
checkedPackageNames = _uiState.value.checkedPackageNames - packageName,
)
} }
private suspend fun emitSplitTunnelConfiguration(config: Config) { private fun getQueriedPackages(query: String): List<PackageInfo> {
val excludedApps = config.`interface`.excludedApplications return getAllInternetCapablePackages().filter {
val includedApps = config.`interface`.includedApplications getPackageLabel(it).lowercase().contains(query.lowercase())
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)
} }
} }
@@ -233,7 +132,7 @@ constructor(
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackagesHoldingPermissions( packageManager.getPackagesHoldingPermissions(
permissions, permissions,
PackageManager.PackageInfoFlags.of(0L) PackageManager.PackageInfoFlags.of(0L),
) )
} else { } else {
packageManager.getPackagesHoldingPermissions(permissions, 0) packageManager.getPackagesHoldingPermissions(permissions, 0)
@@ -241,193 +140,196 @@ constructor(
} }
private fun isAllApplicationsEnabled(): Boolean { private fun isAllApplicationsEnabled(): Boolean {
return _isAllApplicationsEnabled.value return _uiState.value.isAllApplicationsEnabled
} }
private suspend fun saveConfig(tunnelConfig: TunnelConfig) { private fun saveConfig(tunnelConfig: TunnelConfig) =
tunnelRepo.save(tunnelConfig) viewModelScope.launch { appDataRepository.tunnels.save(tunnelConfig) }
}
private suspend fun updateTunnelConfig(tunnelConfig: TunnelConfig?) { private fun updateTunnelConfig(tunnelConfig: TunnelConfig?) =
if (tunnelConfig != null) { viewModelScope.launch {
saveConfig(tunnelConfig) if (tunnelConfig != null) {
updateSettingsDefaultTunnel(tunnelConfig) saveConfig(tunnelConfig).join()
} WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate(application)
}
private suspend fun updateSettingsDefaultTunnel(tunnelConfig: TunnelConfig) {
val settings = settingsRepo.getAll()
if (settings.isNotEmpty()) {
val setting = settings[0]
if (setting.defaultTunnel != null) {
if (tunnelConfig.id == TunnelConfig.from(setting.defaultTunnel!!).id) {
settingsRepo.save(
setting.copy(
defaultTunnel = tunnelConfig.toString()
)
)
}
} }
} }
}
private 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()) { if (it.persistentKeepalive.isNotEmpty()) {
builder.parsePersistentKeepalive( builder.parsePersistentKeepalive(it.persistentKeepalive.trim())
it.persistentKeepalive.trim()
)
} }
builder.build() builder.build()
} }
} }
private fun emptyCheckedPackagesList() {
_uiState.value = _uiState.value.copy(checkedPackageNames = emptyList())
}
private fun buildInterfaceListFromProxyInterface(): Interface { 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()
} }
suspend fun onSaveAllChanges() { fun onSaveAllChanges(): Result<Event> {
try { return 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 = val tunnelConfig = when (uiState.value.tunnel) {
_tunnel.value?.copy( null -> TunnelConfig(
name = _tunnelName.value, name = _uiState.value.tunnelName,
wgQuick = config.toWgQuickString() wgQuick = config.toAwgQuickString(),
) )
else -> uiState.value.tunnel!!.copy(
name = _uiState.value.tunnelName,
wgQuick = config.toAwgQuickString(),
)
}
updateTunnelConfig(tunnelConfig) updateTunnelConfig(tunnelConfig)
Result.Success(Event.Message.ConfigSaved)
} catch (e: Exception) { } catch (e: Exception) {
throw WgTunnelException( Timber.e(e)
"Error: ${e.cause?.message?.lowercase() ?: "unknown error occurred"}" val message = e.message?.substringAfter(":", missingDelimiterValue = "")
) Result.Error(Event.Error.ConfigParseError(message ?: ""))
} }
} }
fun onPeerPublicKeyChange( fun onPeerPublicKeyChange(index: Int, value: String) {
index: Int, _uiState.value =
publicKey: String _uiState.value.copy(
) { proxyPeers =
_proxyPeers.value[index] = _uiState.value.proxyPeers.update(
_proxyPeers.value[index].copy( index,
publicKey = publicKey _uiState.value.proxyPeers[index].copy(publicKey = value),
),
) )
} }
fun onPreSharedKeyChange( fun onPreSharedKeyChange(index: Int, value: String) {
index: Int, _uiState.value =
value: String _uiState.value.copy(
) { proxyPeers =
_proxyPeers.value[index] = _uiState.value.proxyPeers.update(
_proxyPeers.value[index].copy( index,
preSharedKey = value _uiState.value.proxyPeers[index].copy(preSharedKey = value),
),
) )
} }
fun onEndpointChange( fun onEndpointChange(index: Int, value: String) {
index: Int, _uiState.value =
value: String _uiState.value.copy(
) { proxyPeers =
_proxyPeers.value[index] = _uiState.value.proxyPeers.update(
_proxyPeers.value[index].copy( index,
endpoint = value _uiState.value.proxyPeers[index].copy(endpoint = value),
),
) )
} }
fun onAllowedIpsChange( fun onAllowedIpsChange(index: Int, value: String) {
index: Int, _uiState.value =
value: String _uiState.value.copy(
) { proxyPeers =
_proxyPeers.value[index] = _uiState.value.proxyPeers.update(
_proxyPeers.value[index].copy( index,
allowedIps = value _uiState.value.proxyPeers[index].copy(allowedIps = value),
),
) )
} }
fun onPersistentKeepaliveChanged( fun onPersistentKeepaliveChanged(index: Int, value: String) {
index: Int, _uiState.value =
value: String _uiState.value.copy(
) { proxyPeers =
_proxyPeers.value[index] = _uiState.value.proxyPeers.update(
_proxyPeers.value[index].copy( index,
persistentKeepalive = value _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 = _uiState.value =
_interface.value.copy( _uiState.value.copy(
privateKey = keyPair.privateKey.toBase64(), interfaceProxy =
publicKey = keyPair.publicKey.toBase64() _uiState.value.interfaceProxy.copy(
privateKey = keyPair.privateKey.toBase64(),
publicKey = keyPair.publicKey.toBase64(),
),
) )
} }
fun onAddressesChanged(value: String) { fun onAddressesChanged(value: String) {
_interface.value = _uiState.value =
_interface.value.copy( _uiState.value.copy(
addresses = value interfaceProxy = _uiState.value.interfaceProxy.copy(addresses = value),
) )
} }
fun onListenPortChanged(value: String) { fun onListenPortChanged(value: String) {
_interface.value = _uiState.value =
_interface.value.copy( _uiState.value.copy(
listenPort = value interfaceProxy = _uiState.value.interfaceProxy.copy(listenPort = value),
) )
} }
fun onDnsServersChanged(value: String) { fun onDnsServersChanged(value: String) {
_interface.value = _uiState.value =
_interface.value.copy( _uiState.value.copy(
dnsServers = value interfaceProxy = _uiState.value.interfaceProxy.copy(dnsServers = value),
) )
} }
fun onMtuChanged(value: String) { fun onMtuChanged(value: String) {
_interface.value = _uiState.value =
_interface.value.copy( _uiState.value.copy(interfaceProxy = _uiState.value.interfaceProxy.copy(mtu = value))
mtu = value
)
} }
private fun onInterfacePublicKeyChange(value: String) { private fun onInterfacePublicKeyChange(value: String) {
_interface.value = _uiState.value =
_interface.value.copy( _uiState.value.copy(
publicKey = value interfaceProxy = _uiState.value.interfaceProxy.copy(publicKey = value),
) )
} }
fun onPrivateKeyChange(value: String) { fun onPrivateKeyChange(value: String) {
_interface.value = _uiState.value =
_interface.value.copy( _uiState.value.copy(
privateKey = value 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))
@@ -436,4 +338,12 @@ constructor(
onInterfacePublicKeyChange("") onInterfacePublicKeyChange("")
} }
} }
fun emitQueriedPackages(query: String) {
val packages =
getAllInternetCapablePackages().filter {
getPackageLabel(it).lowercase().contains(query.lowercase())
}
_uiState.value = _uiState.value.copy(packages = packages)
}
} }
@@ -8,14 +8,17 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.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.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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -23,22 +26,27 @@ import androidx.compose.foundation.layout.padding
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.CopyAll
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.Info import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.Smartphone
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
@@ -50,6 +58,8 @@ import androidx.compose.material3.TextButton
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
@@ -71,6 +81,7 @@ import androidx.compose.ui.input.pointer.pointerInput
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,29 +89,35 @@ 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.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.AppViewModel
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.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 com.zaneschepke.wireguardautotunnel.util.truncateWithEllipsis
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.amnezia.awg.backend.Tunnel
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel = hiltViewModel(), viewModel: MainViewModel = hiltViewModel(),
padding: PaddingValues, appViewModel: AppViewModel,
showSnackbarMessage: (String) -> Unit, focusRequester: FocusRequester,
navController: NavController navController: NavController
) { ) {
val haptic = LocalHapticFeedback.current val haptic = LocalHapticFeedback.current
@@ -110,45 +127,41 @@ fun MainScreen(
val sheetState = rememberModalBottomSheetState() val sheetState = rememberModalBottomSheetState()
var showBottomSheet by remember { mutableStateOf(false) } var showBottomSheet by remember { mutableStateOf(false) }
var showPrimaryChangeAlertDialog by remember { mutableStateOf(false) }
val tunnels by viewModel.tunnels.collectAsStateWithLifecycle(mutableListOf())
val handshakeStatus by viewModel.handshakeStatus.collectAsStateWithLifecycle(
HandshakeStatus.NOT_STARTED
)
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val state by viewModel.state.collectAsStateWithLifecycle(Tunnel.State.DOWN)
val tunnelName by viewModel.tunnelName.collectAsStateWithLifecycle("")
val settings by viewModel.settings.collectAsStateWithLifecycle()
val statistics by viewModel.statistics.collectAsStateWithLifecycle(null)
// Nested scroll for control FAB // Nested scroll for control FAB
val nestedScrollConnection = val nestedScrollConnection = remember {
remember { object : NestedScrollConnection {
object : NestedScrollConnection { override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
override fun onPreScroll( // Hide FAB
available: Offset, if (available.y < -1) {
source: NestedScrollSource isVisible.value = false
): Offset {
// Hide FAB
if (available.y < -1) {
isVisible.value = false
}
// Show FAB
if (available.y > 1) {
isVisible.value = true
}
return Offset.Zero
} }
// Show FAB
if (available.y > 1) {
isVisible.value = true
}
return Offset.Zero
} }
} }
}
var showDeleteTunnelAlertDialog by remember { mutableStateOf(false) }
var selectedTunnel by remember { mutableStateOf<TunnelConfig?>(null) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
delay(Constants.FOCUS_REQUEST_DELAY)
focusRequester.requestFocus()
}
}
val tunnelFileImportResultLauncher = val tunnelFileImportResultLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
object : ActivityResultContracts.GetContent() { object : ActivityResultContracts.GetContent() {
override fun createIntent( override fun createIntent(context: Context, input: String): Intent {
context: Context,
input: String
): Intent {
val intent = super.createIntent(context, input) val intent = super.createIntent(context, input)
/* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than /* AndroidTV now comes with stubs that do nothing but display a Toast less helpful than
@@ -158,154 +171,148 @@ fun MainScreen(
context.packageManager.queryIntentActivities( context.packageManager.queryIntentActivities(
intent, intent,
PackageManager.ResolveInfoFlags.of( PackageManager.ResolveInfoFlags.of(
PackageManager.MATCH_DEFAULT_ONLY.toLong() PackageManager.MATCH_DEFAULT_ONLY.toLong(),
) ),
) )
} else { } else {
context.packageManager.queryIntentActivities( context.packageManager.queryIntentActivities(
intent, intent,
PackageManager.MATCH_DEFAULT_ONLY PackageManager.MATCH_DEFAULT_ONLY,
) )
} }
if (activitiesToResolveIntent.all { if (
activitiesToResolveIntent.all {
val name = it.activityInfo.packageName val name = it.activityInfo.packageName
name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) || name.startsWith( name.startsWith(Constants.GOOGLE_TV_EXPLORER_STUB) ||
Constants.ANDROID_TV_EXPLORER_STUB name.startsWith(Constants.ANDROID_TV_EXPLORER_STUB)
)
} }
) { ) {
throw WgTunnelException(context.getString(R.string.no_file_explorer)) appViewModel.showSnackbarMessage(Event.Error.FileExplorerRequired.message)
} }
return intent return intent
} }
} },
) { data -> ) { data ->
if (data == null) return@rememberLauncherForActivityResult if (data == null) return@rememberLauncherForActivityResult
scope.launch(Dispatchers.IO) { scope.launch {
try { viewModel.onTunnelFileSelected(data).let {
viewModel.onTunnelFileSelected(data) when (it) {
} catch (e: WgTunnelException) { is Result.Error -> appViewModel.showSnackbarMessage(it.error.message)
showSnackbarMessage(e.message) is Result.Success -> {}
}
} }
} }
} }
val scanLauncher = val scanLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
contract = ScanContract(), contract = ScanContract(),
onResult = { onResult = {
scope.launch { if (it.contents != null) {
try { scope.launch {
viewModel.onTunnelQrResult(it.contents) viewModel.onTunnelQrResult(it.contents).let { result ->
} catch (e: Exception) { when (result) {
when (e) { is Result.Success -> {}
is WgTunnelException -> { is Result.Error -> appViewModel.showSnackbarMessage(result.error.message)
showSnackbarMessage(e.message)
}
else -> {
showSnackbarMessage("No QR code scanned")
} }
} }
} }
} }
} },
) )
if (showPrimaryChangeAlertDialog) { AnimatedVisibility(showDeleteTunnelAlertDialog) {
AlertDialog( AlertDialog(
onDismissRequest = { onDismissRequest = { showDeleteTunnelAlertDialog = false },
showPrimaryChangeAlertDialog = false
},
confirmButton = { confirmButton = {
TextButton(onClick = { TextButton(
scope.launch { onClick = {
viewModel.onDefaultTunnelChange(selectedTunnel) selectedTunnel?.let { viewModel.onDelete(it) }
showPrimaryChangeAlertDialog = false showDeleteTunnelAlertDialog = false
selectedTunnel = null selectedTunnel = null
} },
}) { Text(text = stringResource(R.string.okay)) } ) {
Text(text = stringResource(R.string.yes))
}
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { TextButton(onClick = { showDeleteTunnelAlertDialog = 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.delete_tunnel)) },
text = { Text(text = stringResource(R.string.primary_tunnel_change_question)) } text = { Text(text = stringResource(R.string.delete_tunnel_message)) },
) )
} }
fun onTunnelToggle( fun onTunnelToggle(checked: Boolean, tunnel: TunnelConfig) {
checked: Boolean, if (appViewModel.isRequiredPermissionGranted()) {
tunnel: TunnelConfig
) {
try {
if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop() if (checked) viewModel.onTunnelStart(tunnel) else viewModel.onTunnelStop()
} catch (e: Exception) {
showSnackbarMessage(e.message!!)
} }
} }
if (uiState.loading) {
return LoadingScreen()
}
Scaffold( Scaffold(
modifier = modifier =
Modifier.pointerInput(Unit) { Modifier.pointerInput(Unit) {
detectTapGestures(onTap = { detectTapGestures(
selectedTunnel = null onTap = {
}) selectedTunnel = null
},
)
}, },
floatingActionButtonPosition = FabPosition.End, floatingActionButtonPosition = FabPosition.End,
floatingActionButton = { floatingActionButton = {
AnimatedVisibility( AnimatedVisibility(
visible = isVisible.value, visible = isVisible.value,
enter = slideInVertically(initialOffsetY = { it * 2 }), enter = slideInVertically(initialOffsetY = { it * 2 }),
exit = slideOutVertically(targetOffsetY = { it * 2 }) exit = slideOutVertically(targetOffsetY = { it * 2 }),
) { ) {
val secondaryColor = MaterialTheme.colorScheme.secondary val secondaryColor = MaterialTheme.colorScheme.secondary
val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) val hoverColor = MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
var fobColor by remember { mutableStateOf(secondaryColor) } var fobColor by remember { mutableStateOf(secondaryColor) }
FloatingActionButton( FloatingActionButton(
modifier = modifier =
Modifier (if (
.padding(bottom = 90.dp) WireGuardAutoTunnel.isRunningOnAndroidTv() &&
uiState.tunnels.isEmpty()
)
Modifier.focusRequester(focusRequester)
else Modifier)
.onFocusChanged { .onFocusChanged {
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) { if (WireGuardAutoTunnel.isRunningOnAndroidTv()) {
fobColor = if (it.isFocused) hoverColor else secondaryColor fobColor = if (it.isFocused) hoverColor else secondaryColor
} }
}, },
onClick = { onClick = { showBottomSheet = true },
showBottomSheet = true
},
containerColor = fobColor, containerColor = fobColor,
shape = RoundedCornerShape(16.dp) shape = RoundedCornerShape(16.dp),
) { ) {
Icon( Icon(
imageVector = Icons.Rounded.Add, imageVector = Icons.Rounded.Add,
contentDescription = stringResource(id = R.string.add_tunnel), contentDescription = stringResource(id = R.string.add_tunnel),
tint = Color.DarkGray tint = Color.DarkGray,
) )
} }
} }
} },
) { ) {
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)
) { ) {
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(
@@ -314,26 +321,22 @@ fun MainScreen(
.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( Row(
modifier = modifier =
Modifier Modifier
@@ -344,27 +347,29 @@ fun MainScreen(
val scanOptions = ScanOptions() val scanOptions = ScanOptions()
scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE) scanOptions.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
scanOptions.setOrientationLocked(true) scanOptions.setOrientationLocked(true)
scanOptions.setPrompt(context.getString(R.string.scanning_qr)) scanOptions.setPrompt(
context.getString(R.string.scanning_qr),
)
scanOptions.setBeepEnabled(false) scanOptions.setBeepEnabled(false)
scanOptions.captureActivity = scanOptions.captureActivity =
CaptureActivityPortrait::class.java CaptureActivityPortrait::class.java
scanLauncher.launch(scanOptions) 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
@@ -372,233 +377,299 @@ fun MainScreen(
.clickable { .clickable {
showBottomSheet = false showBottomSheet = false
navController.navigate( navController.navigate(
"${Routes.Config.name}/${Constants.MANUAL_TUNNEL_CONFIG_ID}" "${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 Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .overscroll(ScrollableDefaults.overscrollEffect()).nestedScroll(nestedScrollConnection),
state = rememberLazyListState(0, uiState.tunnels.count()),
userScrollEnabled = true,
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
) { ) {
LazyColumn( item {
modifier = if (uiState.settings.isAutoTunnelEnabled) {
Modifier val autoTunnelingLabel = buildAnnotatedString {
.fillMaxSize() append(stringResource(id = R.string.auto_tunneling))
.padding(top = 10.dp) append(": ")
.nestedScroll(nestedScrollConnection) if (uiState.settings.isAutoTunnelPaused) append(
) { stringResource(id = R.string.paused),
items(tunnels, key = { tunnel -> tunnel.id }) { tunnel -> ) else append(
val leadingIconColor = ( stringResource(id = R.string.active),
if (tunnelName == tunnel.name) { )
when (handshakeStatus) { }
HandshakeStatus.HEALTHY -> mint
HandshakeStatus.UNHEALTHY -> brickRed
HandshakeStatus.STALE -> corn
HandshakeStatus.NOT_STARTED -> Color.Gray
HandshakeStatus.NEVER_CONNECTED -> brickRed
}
} else {
Color.Gray
}
)
val focusRequester = remember { FocusRequester() }
val expanded =
remember {
mutableStateOf(false)
}
RowListItem( RowListItem(
icon = { icon = {
if (settings.isTunnelConfigDefault(tunnel)) { val icon = Icons.Rounded.Bolt
Icon( Icon(
Icons.Rounded.Star, icon,
stringResource(R.string.status), icon.name,
tint = leadingIconColor, modifier = Modifier
modifier = .padding(end = 8.5.dp)
Modifier .size(25.dp),
.padding(end = 10.dp) tint =
.size(20.dp) if (uiState.settings.isAutoTunnelPaused) Color.Gray
) else mint,
} else { )
Icon(
Icons.Rounded.Circle,
stringResource(R.string.status),
tint = leadingIconColor,
modifier =
Modifier
.padding(end = 15.dp)
.size(15.dp)
)
}
}, },
text = tunnel.name, text = autoTunnelingLabel.text,
onHold = {
if ((state == Tunnel.State.UP) && (tunnel.name == tunnelName)) {
showSnackbarMessage(
context.resources.getString(R.string.turn_off_tunnel)
)
return@RowListItem
}
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
selectedTunnel = tunnel
},
onClick = {
if (!WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) {
expanded.value = !expanded.value
}
} else {
selectedTunnel = tunnel
focusRequester.requestFocus()
}
},
statistics = statistics,
expanded = expanded.value,
rowButton = { rowButton = {
if (tunnel.id == selectedTunnel?.id && !WireGuardAutoTunnel.isRunningOnAndroidTv( if (uiState.settings.isAutoTunnelPaused) {
context TextButton(
) onClick = { viewModel.resumeAutoTunneling() },
) { ) {
Row { Text(stringResource(id = R.string.resume))
if (!settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = {
if (settings.isAutoTunnelEnabled) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_auto
)
)
} else {
showPrimaryChangeAlertDialog = true
}
}) {
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary)
)
}
}
IconButton(onClick = {
navController.navigate(
"${Routes.Config.name}/${selectedTunnel?.id}"
)
}) {
Icon(Icons.Rounded.Edit, stringResource(id = R.string.edit))
}
IconButton(
modifier = Modifier.focusable(),
onClick = { viewModel.onDelete(tunnel) }
) {
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete)
)
}
} }
} else { } else {
val checked = state == Tunnel.State.UP && tunnel.name == tunnelName TextButton(
if (!checked) expanded.value = false onClick = { viewModel.pauseAutoTunneling() },
) {
@Composable Text(stringResource(id = R.string.pause))
fun TunnelSwitch() =
Switch(
modifier = Modifier.focusRequester(focusRequester),
checked = checked,
onCheckedChange = { checked ->
if (!checked) expanded.value = false
onTunnelToggle(checked, tunnel)
}
)
if (WireGuardAutoTunnel.isRunningOnAndroidTv(context)) {
Row {
if (!settings.isTunnelConfigDefault(tunnel)) {
IconButton(onClick = {
if (settings.isAutoTunnelEnabled) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_auto
)
)
} else {
showPrimaryChangeAlertDialog = true
}
}) {
Icon(
Icons.Rounded.Star,
stringResource(id = R.string.set_primary)
)
}
}
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
if (state == Tunnel.State.UP && (tunnelName == tunnel.name)) {
expanded.value = !expanded.value
}
}
) {
Icon(Icons.Rounded.Info, stringResource(R.string.info))
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
} else {
navController.navigate(
"${Routes.Config.name}/${tunnel.id}"
)
}
}) {
Icon(
Icons.Rounded.Edit,
stringResource(id = R.string.edit)
)
}
IconButton(onClick = {
if (state == Tunnel.State.UP && tunnel.name == tunnelName) {
showSnackbarMessage(
context.resources.getString(
R.string.turn_off_tunnel
)
)
} else {
viewModel.onDelete(tunnel)
}
}) {
Icon(
Icons.Rounded.Delete,
stringResource(id = R.string.delete)
)
}
TunnelSwitch()
}
} else {
TunnelSwitch()
} }
} }
} },
onClick = {},
onHold = {},
expanded = false,
statistics = null,
) )
} }
} }
items(
uiState.tunnels,
key = { tunnel -> tunnel.id },
) { tunnel ->
val leadingIconColor =
(if (
uiState.vpnState.tunnelConfig?.name == tunnel.name &&
uiState.vpnState.status == Tunnel.State.UP
) {
uiState.vpnState.statistics
?.mapPeerStats()
?.map { it.value?.handshakeStatus() }
.let { statuses ->
when {
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 = {
val circleIcon = Icons.Rounded.Circle
val icon = if (tunnel.isPrimaryTunnel) {
Icons.Rounded.Star
} else if (tunnel.isMobileDataTunnel) {
Icons.Rounded.Smartphone
} else {
circleIcon
}
Icon(
icon,
icon.name,
tint = leadingIconColor,
modifier = Modifier
.padding(
end = if (icon == circleIcon) 12.5.dp else 10.dp,
start = if (icon == circleIcon) 2.5.dp else 0.dp,
)
.size(if (icon == circleIcon) 15.dp else 20.dp),
)
},
text = tunnel.name.truncateWithEllipsis(Constants.ALLOWED_DISPLAY_NAME_LENGTH),
onHold = {
if (
(uiState.vpnState.status == Tunnel.State.UP) &&
(tunnel.name == uiState.vpnState.tunnelConfig?.name)
) {
appViewModel.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.tunnelConfig?.name == tunnel.name)
) {
expanded.value = !expanded.value
}
} else {
selectedTunnel = tunnel
focusRequester.requestFocus()
}
},
statistics = uiState.vpnState.statistics,
expanded = expanded.value,
rowButton = {
if (
tunnel.id == selectedTunnel?.id &&
!WireGuardAutoTunnel.isRunningOnAndroidTv()
) {
Row {
IconButton(
onClick = {
if (
uiState.settings.isAutoTunnelEnabled &&
!uiState.settings.isAutoTunnelPaused
) {
appViewModel.showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message,
)
} else {
navController.navigate(
"${Screen.Option.route}/${selectedTunnel?.id}",
)
}
},
) {
val icon = Icons.Rounded.Settings
Icon(
icon,
icon.name,
)
}
IconButton(
modifier = Modifier.focusable(),
onClick = { viewModel.onCopyTunnel(selectedTunnel) },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
modifier = Modifier.focusable(),
onClick = { showDeleteTunnelAlertDialog = true },
) {
val icon = Icons.Rounded.Delete
Icon(icon, icon.name)
}
}
} else {
val checked by remember {
derivedStateOf {
(uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.tunnelConfig?.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 {
IconButton(
onClick = {
if (uiState.settings.isAutoTunnelEnabled) {
appViewModel.showSnackbarMessage(
Event.Message.AutoTunnelOffAction.message,
)
} else {
selectedTunnel = tunnel
navController.navigate(
"${Screen.Option.route}/${selectedTunnel?.id}",
)
}
},
) {
val icon = Icons.Rounded.Settings
Icon(
icon,
icon.name,
)
}
IconButton(
modifier = Modifier.focusRequester(focusRequester),
onClick = {
if (
uiState.vpnState.status == Tunnel.State.UP &&
(uiState.vpnState.tunnelConfig?.name == tunnel.name)
) {
expanded.value = !expanded.value
} else {
appViewModel.showSnackbarMessage(
Event.Message.TunnelOnAction.message,
)
}
},
) {
val icon = Icons.Rounded.Info
Icon(icon, icon.name)
}
IconButton(
onClick = { viewModel.onCopyTunnel(tunnel) },
) {
val icon = Icons.Rounded.CopyAll
Icon(icon, icon.name)
}
IconButton(
onClick = {
if (
uiState.vpnState.status == Tunnel.State.UP &&
tunnel.name == uiState.vpnState.tunnelConfig?.name
) {
appViewModel.showSnackbarMessage(
Event.Message.TunnelOffAction.message,
)
} else {
selectedTunnel = tunnel
showDeleteTunnelAlertDialog = true
}
},
) {
val icon = Icons.Rounded.Delete
Icon(
icon,
icon.name,
)
}
TunnelSwitch()
}
} else {
TunnelSwitch()
}
}
},
)
}
} }
} }
} }
@@ -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
)
@@ -7,163 +7,149 @@ import android.net.Uri
import android.provider.OpenableColumns import android.provider.OpenableColumns
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.wireguard.config.Config import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.Constants import com.zaneschepke.wireguardautotunnel.data.model.Settings
import com.zaneschepke.wireguardautotunnel.R import com.zaneschepke.wireguardautotunnel.data.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.repository.SettingsDoa import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.repository.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.repository.model.Settings
import com.zaneschepke.wireguardautotunnel.repository.model.TunnelConfig
import com.zaneschepke.wireguardautotunnel.service.foreground.ServiceManager import com.zaneschepke.wireguardautotunnel.service.foreground.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.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.amnezia.awg.config.Config
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
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@HiltViewModel @HiltViewModel
class MainViewModel class MainViewModel
@Inject @Inject
constructor( constructor(
private val application: Application, private val application: Application,
private val tunnelRepo: TunnelConfigDao, private val appDataRepository: AppDataRepository,
private val settingsRepo: SettingsDoa, private val serviceManager: ServiceManager,
private val vpnService: VpnService val vpnService: VpnService
) : ViewModel() { ) : ViewModel() {
val tunnels get() = tunnelRepo.getAllFlow()
val state get() = vpnService.state
val handshakeStatus get() = vpnService.handshakeStatus val uiState =
val tunnelName get() = vpnService.tunnelName combine(
private val _settings = MutableStateFlow(Settings()) appDataRepository.settings.getSettingsFlow(),
val settings get() = _settings.asStateFlow() appDataRepository.tunnels.getTunnelConfigsFlow(),
val statistics get() = vpnService.statistics vpnService.vpnState,
) { settings, tunnels, vpnState ->
MainUiState(settings, tunnels, vpnState, false)
}
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(Constants.SUBSCRIPTION_TIMEOUT),
MainUiState(),
)
init { private fun stopWatcherService() =
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
settingsRepo.getAllFlow().filter { it.isNotEmpty() }.collect { serviceManager.stopWatcherService(application.applicationContext)
val settings = it.first()
validateWatcherServiceState(settings)
_settings.emit(settings)
}
} }
}
private fun validateWatcherServiceState(settings: Settings) {
val watcherState =
ServiceManager.getServiceState(
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) { val settings = appDataRepository.settings.getSettings()
ServiceManager.stopWatcherService(application.applicationContext) val isPrimary = tunnel.isPrimaryTunnel
val settings = settingsRepo.getAll() if (appDataRepository.tunnels.count() == 1 || isPrimary) {
if (settings.isNotEmpty()) { stopWatcherService()
val setting = settings[0] resetTunnelSetting(settings)
setting.defaultTunnel = null
setting.isAutoTunnelEnabled = false
setting.isAlwaysOnVpnEnabled = false
settingsRepo.save(setting)
}
} }
tunnelRepo.delete(tunnel) appDataRepository.tunnels.delete(tunnel)
WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate(application)
} }
} }
fun onTunnelStart(tunnelConfig: TunnelConfig) { private fun resetTunnelSetting(settings: Settings) {
viewModelScope.launch { saveSettings(
stopActiveTunnel() settings.copy(
startTunnel(tunnelConfig) isAutoTunnelEnabled = false,
} isAlwaysOnVpnEnabled = false,
),
)
} }
private fun startTunnel(tunnelConfig: TunnelConfig) { fun onTunnelStart(tunnelConfig: TunnelConfig) =
ServiceManager.startVpnService(application.applicationContext, tunnelConfig.toString()) viewModelScope.launch(Dispatchers.IO) {
} Timber.d("On start called!")
serviceManager.startVpnService(
private suspend fun stopActiveTunnel() {
if (ServiceManager.getServiceState(
application.applicationContext, application.applicationContext,
WireGuardTunnelService::class.java tunnelConfig.id,
) == ServiceState.STARTED isManualStart = true,
) { )
onTunnelStop()
delay(Constants.TOGGLE_TUNNEL_DELAY)
} }
}
fun onTunnelStop() {
ServiceManager.stopVpnService(application.applicationContext) fun onTunnelStop() =
} viewModelScope.launch(Dispatchers.IO) {
Timber.i("Stopping active tunnel")
serviceManager.stopVpnService(application.applicationContext, isManualStop = true)
}
private fun validateConfigString(config: String) { private fun validateConfigString(config: String) {
TunnelConfig.configFromQuick(config) TunnelConfig.configFromQuick(config)
} }
suspend fun onTunnelQrResult(result: String) { suspend fun onTunnelQrResult(result: String): Result<Unit> {
try { return try {
validateConfigString(result) validateConfigString(result)
val tunnelConfig = val tunnelConfig =
TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result) TunnelConfig(name = NumberUtils.generateRandomTunnelName(), wgQuick = result)
addTunnel(tunnelConfig) addTunnel(tunnelConfig)
Result.Success(Unit)
} catch (e: Exception) { } catch (e: Exception) {
throw WgTunnelException(e) Timber.e(e)
Result.Error(Event.Error.InvalidQrCode)
} }
} }
private suspend fun saveTunnelConfigFromStream( private suspend fun saveTunnelConfigFromStream(stream: InputStream, fileName: String) {
stream: InputStream,
fileName: String
) {
val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8) val bufferReader = stream.bufferedReader(charset = Charsets.UTF_8)
val config = Config.parse(bufferReader) val config = Config.parse(bufferReader)
val tunnelName = getNameFromFileName(fileName) val tunnelName = getNameFromFileName(fileName)
addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toWgQuickString())) addTunnel(TunnelConfig(name = tunnelName, wgQuick = config.toAwgQuickString()))
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { stream.close() }
stream.close()
}
} }
private fun getInputStreamFromUri(uri: Uri): InputStream { private fun getInputStreamFromUri(uri: Uri): InputStream? {
return application.applicationContext.contentResolver.openInputStream(uri) return application.applicationContext.contentResolver.openInputStream(uri)
?: throw WgTunnelException(application.getString(R.string.stream_failed))
} }
suspend fun onTunnelFileSelected(uri: Uri) { suspend fun onTunnelFileSelected(uri: Uri): Result<Unit> {
try { try {
val fileName = getFileName(application.applicationContext, uri) if (isValidUriContentScheme(uri)) {
val fileExtension = getFileExtensionFromFileName(fileName) val fileName = getFileName(application.applicationContext, uri)
when (fileExtension) { when (getFileExtensionFromFileName(fileName)) {
Constants.CONF_FILE_EXTENSION -> saveTunnelFromConfUri(fileName, uri) Constants.CONF_FILE_EXTENSION ->
Constants.ZIP_FILE_EXTENSION -> saveTunnelsFromZipUri(uri) saveTunnelFromConfUri(fileName, uri).let {
else -> throw WgTunnelException( when (it) {
application.getString(R.string.file_extension_message) 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) { } catch (e: Exception) {
throw WgTunnelException(e) Timber.e(e)
return Result.Error(Event.Error.FileReadFailed)
} }
} }
@@ -172,81 +158,81 @@ constructor(
generateSequence { zip.nextEntry } generateSequence { zip.nextEntry }
.filterNot { .filterNot {
it.isDirectory || it.isDirectory ||
getFileExtensionFromFileName(it.name) != Constants.CONF_FILE_EXTENSION 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.toAwgQuickString()))
} }
} }
} }
} }
private suspend fun saveTunnelFromConfUri( private suspend fun saveTunnelFromConfUri(name: String, uri: Uri): Result<Unit> {
name: String,
uri: Uri
) {
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) {
val firstTunnel = appDataRepository.tunnels.count() == 0
saveTunnel(tunnelConfig) saveTunnel(tunnelConfig)
if (firstTunnel) WireGuardAutoTunnel.requestTunnelTileServiceStateUpdate(application)
} }
fun pauseAutoTunneling() =
viewModelScope.launch {
appDataRepository.settings.save(uiState.value.settings.copy(isAutoTunnelPaused = true))
WireGuardAutoTunnel.requestAutoTunnelTileServiceUpdate(application)
}
fun resumeAutoTunneling() =
viewModelScope.launch {
appDataRepository.settings.save(uiState.value.settings.copy(isAutoTunnelPaused = false))
WireGuardAutoTunnel.requestAutoTunnelTileServiceUpdate(application)
}
private suspend fun saveTunnel(tunnelConfig: TunnelConfig) { private suspend fun saveTunnel(tunnelConfig: TunnelConfig) {
tunnelRepo.save(tunnelConfig) appDataRepository.tunnels.save(tunnelConfig)
} }
private fun getFileNameByCursor( private fun getFileNameByCursor(context: Context, uri: Uri): String? {
context: Context, context.contentResolver.query(uri, null, null, null, null)?.use {
uri: Uri return getDisplayNameByCursor(it)
): String {
val cursor = context.contentResolver.query(uri, null, null, null, null)
if (cursor != null) {
cursor.use {
return getDisplayNameByCursor(it)
}
} else {
throw WgTunnelException("Failed to initialize cursor")
} }
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 getFileName( private fun isValidUriContentScheme(uri: Uri): Boolean {
context: Context, return uri.scheme == Constants.URI_CONTENT_SCHEME
uri: Uri }
): String {
validateUriContentScheme(uri) private fun getFileName(context: Context, uri: Uri): String {
return try { return getFileNameByCursor(context, uri) ?: NumberUtils.generateRandomTunnelName()
getFileNameByCursor(context, uri)
} catch (_: Exception) {
NumberUtils.generateRandomTunnelName()
}
} }
private fun getNameFromFileName(fileName: String): String { private fun getNameFromFileName(fileName: String): String {
@@ -257,18 +243,23 @@ constructor(
return try { return try {
fileName.substring(fileName.lastIndexOf('.')) fileName.substring(fileName.lastIndexOf('.'))
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e)
"" ""
} }
} }
suspend fun onDefaultTunnelChange(selectedTunnel: TunnelConfig?) { private fun saveSettings(settings: Settings) =
if (selectedTunnel != null) { viewModelScope.launch(Dispatchers.IO) { appDataRepository.settings.save(settings) }
_settings.emit(
_settings.value.copy(
defaultTunnel = selectedTunnel.toString() fun onCopyTunnel(tunnel: TunnelConfig?) = viewModelScope.launch {
) tunnel?.let {
saveTunnel(
TunnelConfig(
name = it.name.plus(NumberUtils.randomThree()),
wgQuick = it.wgQuick,
),
) )
settingsRepo.save(_settings.value)
} }
} }
} }

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