Compare commits

..

107 Commits

Author SHA1 Message Date
Zane Schepke 1952ff1b02 Merge remote-tracking branch 'weblate/main' into localize-sync
# Conflicts:
#	app/src/main/res/values-cs/strings.xml
#	app/src/main/res/values-fr/strings.xml
#	app/src/main/res/values-hu/strings.xml
#	app/src/main/res/values-ru/strings.xml
#	app/src/main/res/values-ur/strings.xml
#	app/src/main/res/values-zh-rTW/strings.xml
#	fastlane/metadata/android/zh-TW/short_description.txt
2025-07-11 12:57:56 -04:00
Andras 99f6c0bda5 Translated using Weblate (Hungarian)
Currently translated at 5.3% (14 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/hu/
2025-07-11 18:48:04 +02:00
Priit Jõerüüt 28b77d33ca Translated using Weblate (Estonian)
Currently translated at 90.1% (237 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/et/
2025-07-06 06:05:19 +02:00
Priit Jõerüüt b0b070f6aa Translated using Weblate (Estonian)
Currently translated at 90.1% (237 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/et/
2025-07-06 06:05:19 +02:00
Priit Jõerüüt 89b1f314b1 Translated using Weblate (Estonian)
Currently translated at 90.1% (237 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/et/
2025-07-06 06:05:19 +02:00
EESF-2 020263eba6 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 88.2% (232 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hant/
2025-07-01 22:02:05 +02:00
EESF-2 d359215b20 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 25.0% (3 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/zh_Hant/
2025-07-01 22:02:03 +02:00
Priit Jõerüüt a6a0bba569 Translated using Weblate (Estonian)
Currently translated at 81.7% (215 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/et/
2025-06-29 23:04:36 +02:00
Priit Jõerüüt f729e924cd Translated using Weblate (Estonian)
Currently translated at 81.7% (215 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/et/
2025-06-29 23:04:35 +02:00
teemue 3c1d0f893a Translated using Weblate (Finnish)
Currently translated at 52.8% (139 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/fi/
2025-06-29 23:04:34 +02:00
teemue b184331258 Translated using Weblate (Finnish)
Currently translated at 52.8% (139 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/fi/
2025-06-29 23:04:33 +02:00
Priit Jõerüüt 36603ab542 Translated using Weblate (Estonian)
Currently translated at 76.8% (202 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/et/
2025-06-28 13:04:37 +00:00
Priit Jõerüüt d0e21247b5 Translated using Weblate (Estonian)
Currently translated at 76.8% (202 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/et/
2025-06-28 13:04:35 +00:00
Priit Jõerüüt 786a8b54ce Translated using Weblate (Estonian)
Currently translated at 76.8% (202 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/et/
2025-06-28 13:04:34 +00:00
Priit Jõerüüt d762fe7283 Translated using Weblate (Estonian)
Currently translated at 76.8% (202 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/et/
2025-06-28 13:04:33 +00:00
Jan-Erik Moen ed9cab3251 Translated using Weblate (Norwegian Bokmål)
Currently translated at 5.3% (14 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/nb_NO/
2025-06-28 13:04:32 +00:00
Priit Jõerüüt 8e1e754a87 Translated using Weblate (Estonian)
Currently translated at 6.0% (16 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/et/
2025-06-27 07:01:52 +02:00
Priit Jõerüüt c6eaaa1f37 Translated using Weblate (Estonian)
Currently translated at 33.3% (4 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/et/
2025-06-27 07:01:50 +02:00
Languages add-on 5077ec9a46 Added translation using Weblate (Estonian) 2025-06-26 06:22:22 +02:00
catelixor e4a808c6ce Translated using Weblate (Czech)
Currently translated at 100.0% (263 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/cs/
2025-06-26 00:04:44 +00:00
EESF-2 52efdfa288 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 86.3% (227 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hant/
2025-06-19 01:04:27 +02:00
igor a4f413e79e Translated using Weblate (French)
Currently translated at 76.4% (201 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/fr/
2025-06-10 17:01:52 +02:00
igor 71ae3864fc Translated using Weblate (French)
Currently translated at 58.3% (7 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/fr/
2025-06-10 17:01:50 +02:00
Hamed Ap 008bc476e0 Translated using Weblate (Persian)
Currently translated at 11.0% (29 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/fa/
2025-06-09 10:02:02 +02:00
Hamed Ap 3947b04e21 Translated using Weblate (Persian)
Currently translated at 100.0% (12 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/fa/
2025-06-09 10:01:59 +02:00
Languages add-on acef2637c8 Added translation using Weblate (Persian) 2025-06-08 07:10:21 +00:00
Noureddine ba8b09cdc9 Translated using Weblate (Arabic)
Currently translated at 8.3% (1 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/ar/
2025-06-07 07:01:47 +02:00
Languages add-on 79e5ba0cb0 Added translation using Weblate (Arabic) 2025-06-06 04:41:28 +00:00
catelixor 165fda0352 Translated using Weblate (Czech)
Currently translated at 98.8% (260 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/cs/
2025-05-24 09:54:40 +02:00
Faisal Gull c4dfb4d591 Translated using Weblate (Urdu)
Currently translated at 100.0% (12 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/ur/
2025-05-19 05:03:13 +02:00
Faisal Gull e80e0b7f94 Translated using Weblate (Urdu)
Currently translated at 100.0% (263 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ur/
2025-05-19 05:03:10 +02:00
François-Xavier Choinière b856fc2230 Translated using Weblate (French)
Currently translated at 75.6% (199 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/fr/
2025-05-16 04:02:07 +02:00
Deleted User 202ac52e25 Translated using Weblate (Spanish)
Currently translated at 100.0% (263 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/es/
2025-05-13 19:02:26 +02:00
Saratoga79 2b1a8af998 Translated using Weblate (Spanish)
Currently translated at 100.0% (263 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/es/
2025-05-13 19:02:25 +02:00
solokot b924845835 Translated using Weblate (Russian)
Currently translated at 99.6% (262 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ru/
2025-05-11 08:01:43 +02:00
catelixor 0f8d7fed97 Translated using Weblate (Czech)
Currently translated at 97.7% (257 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/cs/
2025-05-09 23:01:51 +00:00
angrybb 20ff172055 Translated using Weblate (Serbian)
Currently translated at 16.6% (2 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/sr/
2025-05-07 23:01:49 +02:00
Faisal Gull 42671f616f Translated using Weblate (Urdu)
Currently translated at 100.0% (12 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/ur/
2025-05-07 23:01:48 +02:00
Faisal Gull ecca99828f Translated using Weblate (Urdu)
Currently translated at 100.0% (263 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ur/
2025-05-07 23:01:46 +02:00
Languages add-on 7f40df9d36 Added translation using Weblate (Serbian) 2025-05-06 22:16:27 +02:00
dct 9a497f7892 Translated using Weblate (Vietnamese)
Currently translated at 5.7% (15 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/vi/
2025-05-06 22:16:26 +02:00
Tommaso dc5a10ebc9 Translated using Weblate (Italian)
Currently translated at 100.0% (12 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/it/
2025-05-06 14:06:03 +00:00
catelixor d1a4c7f133 Translated using Weblate (Czech)
Currently translated at 92.7% (244 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/cs/
2025-05-06 14:06:01 +00:00
catelixor 93ebf299e6 Translated using Weblate (Czech)
Currently translated at 100.0% (12 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/cs/
2025-05-06 14:06:00 +00:00
大王叫我来巡山 1d83da9d44 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (263 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hans/
2025-05-05 10:01:55 +02:00
Matthaiks d2abaad5b4 Translated using Weblate (Polish)
Currently translated at 100.0% (263 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/pl/
2025-05-05 10:01:54 +02:00
Matthaiks 8bf88a89bd Translated using Weblate (Polish)
Currently translated at 100.0% (12 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/pl/
2025-05-05 10:01:52 +02:00
Kachelkaiser 96cfd04450 Translated using Weblate (German)
Currently translated at 100.0% (263 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/de/
2025-05-05 10:01:51 +02:00
Kachelkaiser c5f39ec906 Translated using Weblate (German)
Currently translated at 100.0% (12 of 12 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/de/
2025-05-05 10:01:49 +02:00
solokot 5cd8f9c2f2 Translated using Weblate (Russian)
Currently translated at 99.6% (262 of 263 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ru/
2025-05-05 10:01:47 +02:00
Tommaso 2d11712fdc Translated using Weblate (Italian)
Currently translated at 96.5% (250 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/it/
2025-05-04 09:03:30 +02:00
Tommaso 9528850873 Translated using Weblate (Italian)
Currently translated at 100.0% (11 of 11 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/it/
2025-05-04 09:03:30 +02:00
Faisal Gull a0d5647f23 Translated using Weblate (Urdu)
Currently translated at 100.0% (259 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ur/
2025-05-04 09:03:30 +02:00
Faisal Gull 5ff810a6ef Translated using Weblate (Urdu)
Currently translated at 100.0% (11 of 11 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/ur/
2025-05-04 09:03:30 +02:00
Faisal Gull 3dc314d79a Translated using Weblate (Urdu)
Currently translated at 100.0% (259 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ur/
2025-05-04 09:03:30 +02:00
Jasper 466a5cecbe Translated using Weblate (Dutch)
Currently translated at 70.6% (183 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/nl/
2025-05-04 09:03:30 +02:00
VertekPlus a13d70227f Translated using Weblate (Russian)
Currently translated at 99.6% (258 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ru/
2025-05-04 09:03:30 +02:00
adkostatt 519981d681 Translated using Weblate (Russian)
Currently translated at 99.6% (258 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ru/
2025-05-04 09:03:30 +02:00
Valentin 0d17bf28e0 Translated using Weblate (Russian)
Currently translated at 99.6% (258 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ru/
2025-05-04 09:03:30 +02:00
ssantos fc1d4b22a6 Translated using Weblate (Portuguese)
Currently translated at 18.1% (2 of 11 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/pt/
2025-05-04 09:03:30 +02:00
翻譯得真好下次別翻了 d2f5be6e19 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 72.5% (188 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hant/
2025-05-04 09:03:30 +02:00
Kachelkaiser 27a5b6b9f2 Translated using Weblate (German)
Currently translated at 100.0% (11 of 11 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/de/
2025-05-04 09:03:30 +02:00
大王叫我来巡山 2ae048b1de Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (259 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hans/
2025-05-04 09:03:30 +02:00
Matthaiks a890c83088 Translated using Weblate (Polish)
Currently translated at 100.0% (259 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/pl/
2025-05-04 09:03:30 +02:00
Kachelkaiser 3eedbdccba Translated using Weblate (German)
Currently translated at 100.0% (259 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/de/
2025-05-04 09:03:30 +02:00
solokot 73b3d03a25 Translated using Weblate (Russian)
Currently translated at 100.0% (259 of 259 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ru/
2025-05-04 09:03:30 +02:00
catelixor b74f84abc2 Translated using Weblate (Czech)
Currently translated at 89.9% (232 of 258 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/cs/
2025-05-04 09:03:30 +02:00
Matthaiks bbb056e9d1 Translated using Weblate (Polish)
Currently translated at 100.0% (258 of 258 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/pl/
2025-05-04 09:03:30 +02:00
தமிழ்நேரம் 0d953a32be Translated using Weblate (Tamil)
Currently translated at 100.0% (256 of 256 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ta/
2025-05-04 09:03:30 +02:00
sgauthiertremblay 3383bb6b45 Translated using Weblate (French)
Currently translated at 77.3% (198 of 256 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/fr/
2025-05-04 09:03:30 +02:00
தமிழ்நேரம் afcb801806 Translated using Weblate (Tamil)
Currently translated at 100.0% (11 of 11 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/ta/
2025-05-04 09:03:30 +02:00
大王叫我来巡山 494835a5a4 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (256 of 256 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hans/
2025-05-04 09:03:30 +02:00
catelixor 2f784f563f Translated using Weblate (Czech)
Currently translated at 89.8% (230 of 256 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/cs/
2025-05-04 09:03:30 +02:00
catelixor 11d6fd2ed9 Translated using Weblate (Czech)
Currently translated at 63.6% (7 of 11 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/cs/
2025-05-04 09:03:30 +02:00
Matthaiks 0e8138ba6b Translated using Weblate (Polish)
Currently translated at 100.0% (256 of 256 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/pl/
2025-05-04 09:03:30 +02:00
Matthaiks 0eb3719972 Translated using Weblate (Polish)
Currently translated at 100.0% (11 of 11 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/pl/
2025-05-04 09:03:30 +02:00
solokot 196cd34ef7 Translated using Weblate (Russian)
Currently translated at 100.0% (256 of 256 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ru/
2025-05-04 09:03:30 +02:00
Matthaiks ed42a2f7df Translated using Weblate (Polish)
Currently translated at 99.2% (254 of 256 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/pl/
2025-05-04 09:03:30 +02:00
vm 9b944bf51e Translated using Weblate (Hungarian)
Currently translated at 20.0% (2 of 10 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/hu/
2025-05-04 09:03:30 +02:00
Faisal Gull b8c792418d Translated using Weblate (Urdu)
Currently translated at 100.0% (254 of 254 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ur/
2025-05-04 09:03:30 +02:00
vm 6d8a38c65b Translated using Weblate (Hungarian)
Currently translated at 5.1% (13 of 254 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/hu/
2025-05-04 09:03:30 +02:00
翻譯得真好下次別翻了 09c1c68aad Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 72.4% (184 of 254 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hant/
2025-05-04 09:03:30 +02:00
大王叫我来巡山 36b8353b07 Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (254 of 254 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hans/
2025-05-04 09:03:30 +02:00
catelixor f10ed1269e Translated using Weblate (Czech)
Currently translated at 87.7% (223 of 254 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/cs/
2025-05-04 09:03:30 +02:00
Matthaiks e6e35f01d7 Translated using Weblate (Polish)
Currently translated at 100.0% (254 of 254 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/pl/
2025-05-04 09:03:30 +02:00
Kachelkaiser 35737bdd84 Translated using Weblate (German)
Currently translated at 100.0% (254 of 254 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/de/
2025-05-04 09:03:30 +02:00
solokot 5a6960bffd Translated using Weblate (Russian)
Currently translated at 100.0% (254 of 254 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ru/
2025-05-04 09:03:30 +02:00
翻譯得真好下次別翻了 10cefe2ab0 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 70.8% (170 of 240 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hant/
2025-05-04 09:03:30 +02:00
翻譯得真好下次別翻了 add53834e7 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 30.0% (3 of 10 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/zh_Hant/
2025-05-04 09:03:30 +02:00
catelixor f740ec57ea Translated using Weblate (Czech)
Currently translated at 80.0% (192 of 240 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/cs/
2025-05-04 09:03:30 +02:00
catelixor 7eadfe2af2 Translated using Weblate (Czech)
Currently translated at 50.0% (5 of 10 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/cs/
2025-05-04 09:03:30 +02:00
Faisal Gull 001a4358e1 Translated using Weblate (Urdu)
Currently translated at 100.0% (10 of 10 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/ur/
2025-05-04 09:03:30 +02:00
Faisal Gull 31abe64513 Translated using Weblate (Urdu)
Currently translated at 100.0% (240 of 240 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ur/
2025-05-04 09:03:30 +02:00
翻譯得真好下次別翻了 900aa86c29 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 68.7% (165 of 240 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hant/
2025-05-04 09:03:29 +02:00
大王叫我来巡山 264e17f30b Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (240 of 240 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hans/
2025-05-04 09:03:29 +02:00
catelixor fbc3ff3276 Translated using Weblate (Czech)
Currently translated at 73.7% (177 of 240 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/cs/
2025-05-04 09:03:29 +02:00
Kachelkaiser 151cdd4e03 Translated using Weblate (German)
Currently translated at 100.0% (240 of 240 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/de/
2025-05-04 09:03:29 +02:00
Kachelkaiser f0deb1294a Translated using Weblate (German)
Currently translated at 100.0% (10 of 10 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/de/
2025-05-04 09:03:29 +02:00
solokot 19efb08cb9 Translated using Weblate (Russian)
Currently translated at 100.0% (240 of 240 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ru/
2025-05-04 09:03:29 +02:00
Matthaiks 9f4b9ca771 Translated using Weblate (Polish)
Currently translated at 100.0% (240 of 240 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/pl/
2025-05-04 09:03:29 +02:00
翻譯得真好下次別翻了 a60e480be0 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 63.8% (152 of 238 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hant/
2025-05-04 09:03:29 +02:00
kometchtech f28e21ef79 Translated using Weblate (Japanese)
Currently translated at 41.1% (98 of 238 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ja/
2025-05-04 09:03:29 +02:00
Matthaiks b4ca1e63c4 Translated using Weblate (Polish)
Currently translated at 100.0% (10 of 10 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/pl/
2025-05-04 09:03:29 +02:00
翻譯得真好下次別翻了 e35f4bbb52 Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 63.4% (151 of 238 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/zh_Hant/
2025-05-04 09:03:29 +02:00
kometchtech f0e575da69 Translated using Weblate (Japanese)
Currently translated at 39.0% (93 of 238 strings)

Translation: WG Tunnel/strings
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/strings/ja/
2025-05-04 09:03:29 +02:00
kometchtech a6b7d2091a Translated using Weblate (Japanese)
Currently translated at 100.0% (9 of 9 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/ja/
2025-05-04 09:03:29 +02:00
Matthaiks 324a1a66c5 Translated using Weblate (Polish)
Currently translated at 100.0% (9 of 9 strings)

Translation: WG Tunnel/fastlane
Translate-URL: https://hosted.weblate.org/projects/wg-tunnel/fastlane/pl/
2025-05-04 09:03:29 +02:00
187 changed files with 2450 additions and 4310 deletions
+4
View File
@@ -12,6 +12,7 @@ on:
default: debug
options:
- debug
- prerelease
- nightly
- release
flavor:
@@ -104,6 +105,9 @@ jobs:
"release")
./gradlew :app:assemble${flavor^}Release --info
;;
"prerelease")
./gradlew :app:assemble${flavor^}Prerelease --info
;;
"nightly")
./gradlew :app:assemble${flavor^}Nightly --info
;;
+9 -3
View File
@@ -25,6 +25,7 @@ on:
description: "GitHub release type"
options:
- none
- prerelease
- release
default: release
required: true
@@ -59,7 +60,7 @@ jobs:
flavor: fdroid
build-standalone:
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' || inputs.release_type == 'debug' || inputs.flavor == 'standalone' }}
if: ${{ github.event_name == 'push' || inputs.release_type == 'release' || inputs.release_type == 'prerelease' || inputs.flavor == 'standalone' }}
uses: ./.github/workflows/build.yml
secrets: inherit
with:
@@ -123,6 +124,11 @@ jobs:
echo "$RELEASE_NOTES" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: On prerelease release notes
if: ${{ github.event_name != 'push' && inputs.release_type == 'prerelease' }}
run: |
echo "RELEASE_NOTES=Testing version of app for specific feature." >> $GITHUB_ENV
- name: Get checksum
id: checksum
run: |
@@ -156,8 +162,8 @@ jobs:
tag_name: ${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag_name }}
name: ${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag_name }}
draft: false
prerelease: false
make_latest: true
prerelease: ${{ github.event_name != 'push' && inputs.release_type == 'prerelease' }}
make_latest: ${{ github.event_name == 'push' || inputs.release_type == 'release' }}
files: |
${{ github.workspace }}/temp/**/*.apk
env:
+19 -24
View File
@@ -1,5 +1,3 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -22,8 +20,6 @@ android {
includeInBundle = false
}
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
defaultConfig {
applicationId = Constants.APP_ID
minSdk = Constants.MIN_SDK
@@ -31,10 +27,15 @@ android {
versionCode = computeVersionCode()
versionName = computeVersionName()
ksp { arg("room.schemaLocation", "$projectDir/schemas") }
sourceSets { getByName("debug").assets.srcDirs(files("$projectDir/schemas")) }
val languagesArray = buildLanguagesArray(languageList())
buildConfigField("String[]", "LANGUAGES", "new String[]{ $languagesArray }")
buildConfigField(
"String[]",
"LANGUAGES",
"new String[]{ ${languageList().joinToString(separator = ", ") { "\"$it\"" }} }",
)
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { useSupportLibrary = true }
@@ -72,15 +73,22 @@ android {
debug {
applicationIdSuffix = ".debug"
resValue("string", "app_name", "WG Tunnel Debug")
resValue("string", "app_name", "WG Tunnel - Debug")
isDebuggable = true
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.debug\"")
}
create(Constants.PRERELEASE) {
initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".prerelease"
resValue("string", "app_name", "WG Tunnel - Pre")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.pre\"")
}
create(Constants.NIGHTLY) {
initWith(buildTypes.getByName(Constants.RELEASE))
applicationIdSuffix = ".nightly"
resValue("string", "app_name", "WG Tunnel Nightly")
resValue("string", "app_name", "WG Tunnel - Nightly")
resValue("string", "provider", "\"${Constants.APP_NAME}.provider.nightly\"")
}
}
@@ -106,14 +114,7 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
freeCompilerArgs = listOf("-XXLanguage:+PropertyParamAnnotationDefaultTargetMode")
}
}
kotlinOptions { jvmTarget = Constants.JVM_TARGET }
buildFeatures {
compose = true
buildConfig = true
@@ -121,8 +122,8 @@ android {
packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } }
licensee {
allowedLicenses().forEach { allow(it) }
allowedLicenseUrls().forEach { allowUrl(it) }
Constants.allowedLicenses.forEach { allow(it) }
Constants.allowedLicenseUrls.forEach { allowUrl(it) }
}
applicationVariants.all {
@@ -218,16 +219,10 @@ dependencies {
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.slf4j.android)
implementation(libs.icmp4a)
// shizuku
implementation(libs.shizuku.api)
implementation(libs.shizuku.provider)
implementation(libs.reorderable)
implementation(libs.roomdatabasebackup) {
exclude(group = "org.reactivestreams", module = "reactive-streams")
}
}
tasks.register<Copy>("copyLicenseeJsonToAssets") {
@@ -1,302 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 18,
"identityHash": "505728bad740c12bab998a066b569333",
"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_ping_enabled` INTEGER NOT NULL DEFAULT false, `is_amnezia_enabled` INTEGER NOT NULL DEFAULT false, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT false, `is_stop_on_no_internet_enabled` INTEGER NOT NULL DEFAULT false, `is_vpn_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `is_lan_on_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `debounce_delay_seconds` INTEGER NOT NULL DEFAULT 3, `is_disable_kill_switch_on_trusted_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_unsecure_enabled` INTEGER NOT NULL DEFAULT false, `split_tunnel_apps` TEXT NOT NULL DEFAULT '', `wifi_detection_method` INTEGER NOT NULL DEFAULT 0)",
"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": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isAmneziaEnabled",
"columnName": "is_amnezia_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isWildcardsEnabled",
"columnName": "is_wildcards_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isStopOnNoInternetEnabled",
"columnName": "is_stop_on_no_internet_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isVpnKillSwitchEnabled",
"columnName": "is_vpn_kill_switch_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelKillSwitchEnabled",
"columnName": "is_kernel_kill_switch_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isLanOnKillSwitchEnabled",
"columnName": "is_lan_on_kill_switch_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "debounceDelaySeconds",
"columnName": "debounce_delay_seconds",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "isDisableKillSwitchOnTrustedEnabled",
"columnName": "is_disable_kill_switch_on_trusted_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnUnsecureEnabled",
"columnName": "is_tunnel_on_unsecure_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "splitTunnelApps",
"columnName": "split_tunnel_apps",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "wifiDetectionMethod",
"columnName": "wifi_detection_method",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"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, `am_quick` TEXT NOT NULL DEFAULT '', `is_Active` INTEGER NOT NULL DEFAULT false, `is_ping_enabled` INTEGER NOT NULL DEFAULT false, `ping_interval` INTEGER DEFAULT null, `ping_cooldown` INTEGER DEFAULT null, `ping_ip` TEXT DEFAULT null, `is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false, `is_ipv4_preferred` INTEGER NOT NULL DEFAULT true, `position` INTEGER NOT NULL DEFAULT 0)",
"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"
},
{
"fieldPath": "amQuick",
"columnName": "am_quick",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isActive",
"columnName": "is_Active",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "pingInterval",
"columnName": "ping_interval",
"affinity": "INTEGER",
"defaultValue": "null"
},
{
"fieldPath": "pingCooldown",
"columnName": "ping_cooldown",
"affinity": "INTEGER",
"defaultValue": "null"
},
{
"fieldPath": "pingIp",
"columnName": "ping_ip",
"affinity": "TEXT",
"defaultValue": "null"
},
{
"fieldPath": "isEthernetTunnel",
"columnName": "is_ethernet_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isIpv4Preferred",
"columnName": "is_ipv4_preferred",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "true"
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"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`)"
}
]
}
],
"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, '505728bad740c12bab998a066b569333')"
]
}
}
@@ -1,316 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 19,
"identityHash": "82bdb96b7a9f8695a34ad1ec21d9aea8",
"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_ping_enabled` INTEGER NOT NULL DEFAULT false, `is_amnezia_enabled` INTEGER NOT NULL DEFAULT false, `is_wildcards_enabled` INTEGER NOT NULL DEFAULT false, `is_stop_on_no_internet_enabled` INTEGER NOT NULL DEFAULT false, `is_vpn_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `is_kernel_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `is_lan_on_kill_switch_enabled` INTEGER NOT NULL DEFAULT false, `debounce_delay_seconds` INTEGER NOT NULL DEFAULT 3, `is_disable_kill_switch_on_trusted_enabled` INTEGER NOT NULL DEFAULT false, `is_tunnel_on_unsecure_enabled` INTEGER NOT NULL DEFAULT false, `wifi_detection_method` INTEGER NOT NULL DEFAULT 0, `is_ping_monitoring_enabled` INTEGER NOT NULL DEFAULT true, `tunnel_ping_interval_sec` INTEGER NOT NULL DEFAULT 30, `tunnel_ping_attempts` INTEGER NOT NULL DEFAULT 3, `tunnel_ping_timeout_sec` INTEGER)",
"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": "isPingEnabled",
"columnName": "is_ping_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isAmneziaEnabled",
"columnName": "is_amnezia_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isWildcardsEnabled",
"columnName": "is_wildcards_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isStopOnNoInternetEnabled",
"columnName": "is_stop_on_no_internet_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isVpnKillSwitchEnabled",
"columnName": "is_vpn_kill_switch_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isKernelKillSwitchEnabled",
"columnName": "is_kernel_kill_switch_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isLanOnKillSwitchEnabled",
"columnName": "is_lan_on_kill_switch_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "debounceDelaySeconds",
"columnName": "debounce_delay_seconds",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "isDisableKillSwitchOnTrustedEnabled",
"columnName": "is_disable_kill_switch_on_trusted_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isTunnelOnUnsecureEnabled",
"columnName": "is_tunnel_on_unsecure_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "wifiDetectionMethod",
"columnName": "wifi_detection_method",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "isPingMonitoringEnabled",
"columnName": "is_ping_monitoring_enabled",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "true"
},
{
"fieldPath": "tunnelPingIntervalSeconds",
"columnName": "tunnel_ping_interval_sec",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "30"
},
{
"fieldPath": "tunnelPingAttempts",
"columnName": "tunnel_ping_attempts",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "3"
},
{
"fieldPath": "tunnelPingTimeoutSeconds",
"columnName": "tunnel_ping_timeout_sec",
"affinity": "INTEGER"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
}
},
{
"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, `am_quick` TEXT NOT NULL DEFAULT '', `is_Active` INTEGER NOT NULL DEFAULT false, `restart_on_ping_failure` INTEGER NOT NULL DEFAULT false, `ping_target` TEXT DEFAULT null, `is_ethernet_tunnel` INTEGER NOT NULL DEFAULT false, `is_ipv4_preferred` INTEGER NOT NULL DEFAULT true, `position` INTEGER NOT NULL DEFAULT 0, `auto_tunnel_apps` TEXT NOT NULL DEFAULT '[]')",
"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"
},
{
"fieldPath": "amQuick",
"columnName": "am_quick",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "isActive",
"columnName": "is_Active",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "restartOnPingFailure",
"columnName": "restart_on_ping_failure",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "pingTarget",
"columnName": "ping_target",
"affinity": "TEXT",
"defaultValue": "null"
},
{
"fieldPath": "isEthernetTunnel",
"columnName": "is_ethernet_tunnel",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "false"
},
{
"fieldPath": "isIpv4Preferred",
"columnName": "is_ipv4_preferred",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "true"
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "autoTunnelApps",
"columnName": "auto_tunnel_apps",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "'[]'"
}
],
"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`)"
}
]
}
],
"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, '82bdb96b7a9f8695a34ad1ec21d9aea8')"
]
}
}
-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#648DB3</color>
</resources>
+1
View File
@@ -12,6 +12,7 @@
<!--foreground service permissions-->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!--start service on boot permission-->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -1,7 +1,9 @@
package com.zaneschepke.wireguardautotunnel
import android.Manifest
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.os.Build
import android.os.Bundle
@@ -25,10 +27,10 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
@@ -36,9 +38,6 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.data.AppDatabase
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.MainDispatcher
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.VpnDeniedDialog
@@ -55,7 +54,6 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.Loca
import com.zaneschepke.wireguardautotunnel.ui.screens.main.MainScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.TunnelAutoTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.config.ConfigScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.sort.SortScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.SplitTunnelScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.main.tunneloptions.TunnelOptionsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.pin.PinLockScreen
@@ -66,22 +64,17 @@ import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.displa
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.appearance.language.LanguageScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.killswitch.KillSwitchScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.logs.LogsScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.monitoring.TunnelMonitoringScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.SupportScreen
import com.zaneschepke.wireguardautotunnel.ui.screens.support.license.LicenseScreen
import com.zaneschepke.wireguardautotunnel.ui.theme.WireguardAutoTunnelTheme
import com.zaneschepke.wireguardautotunnel.util.extensions.isRunningOnTv
import com.zaneschepke.wireguardautotunnel.util.extensions.restartApp
import com.zaneschepke.wireguardautotunnel.util.extensions.showToast
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import dagger.hilt.android.AndroidEntryPoint
import de.raphaelebner.roomdatabasebackup.core.RoomBackup
import javax.inject.Inject
import kotlin.system.exitProcess
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import org.amnezia.awg.backend.GoBackend.VpnService
import timber.log.Timber
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@@ -92,16 +85,8 @@ class MainActivity : AppCompatActivity() {
@Inject lateinit var networkMonitor: NetworkMonitor
@Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher
@Inject @MainDispatcher lateinit var mainDispatcher: CoroutineDispatcher
@Inject lateinit var appDatabase: AppDatabase
private var lastLocationPermissionState: Boolean? = null
private lateinit var roomBackup: RoomBackup
val REQUEST_CODE = 123
@SuppressLint("BatteryLife")
@@ -114,7 +99,6 @@ class MainActivity : AppCompatActivity() {
window.isNavigationBarContrastEnforced = false
}
super.onCreate(savedInstanceState)
roomBackup = RoomBackup(this)
val viewModel by viewModels<AppViewModel>()
@@ -126,6 +110,7 @@ class MainActivity : AppCompatActivity() {
val isTv = isRunningOnTv()
val appUiState by viewModel.uiState.collectAsStateWithLifecycle()
val appViewState by viewModel.appViewState.collectAsStateWithLifecycle()
val tunnelError by viewModel.tunnelManager.errorEvents.collectAsStateWithLifecycle(null)
val navController = rememberNavController()
val backStackEntry by navController.currentBackStackEntryAsState()
@@ -168,6 +153,15 @@ class MainActivity : AppCompatActivity() {
viewModel.handleEvent(AppEvent.SetBatteryOptimizeDisableShown)
}
LaunchedEffect(tunnelError) {
if (tunnelError == null) return@LaunchedEffect
val message = tunnelError!!.second.toStringRes()
val context = this@MainActivity
snackbar.showSnackbar(
context.getString(R.string.tunnel_error_template, context.getString(message))
)
}
with(appViewState) {
LaunchedEffect(isConfigChanged) {
if (isConfigChanged) {
@@ -268,13 +262,13 @@ class MainActivity : AppCompatActivity() {
MainScreen(appUiState, appViewState, viewModel)
}
composable<Route.Settings> {
SettingsScreen(appUiState, appViewState, viewModel)
SettingsScreen(appUiState, viewModel)
}
composable<Route.SettingsAdvanced> {
SettingsAdvancedScreen(appUiState, viewModel)
}
composable<Route.LocationDisclosure> {
LocationDisclosureScreen(viewModel)
LocationDisclosureScreen(appUiState, viewModel)
}
composable<Route.AutoTunnel> {
AutoTunnelScreen(appUiState, viewModel)
@@ -308,12 +302,7 @@ class MainActivity : AppCompatActivity() {
appUiState.tunnels
.firstOrNull { it.id == args.id }
?.let { config ->
TunnelOptionsScreen(
config,
viewModel,
appViewState,
appUiState.appSettings,
)
TunnelOptionsScreen(config, viewModel, appViewState)
}
}
composable<Route.Lock> { PinLockScreen(viewModel) }
@@ -333,10 +322,6 @@ class MainActivity : AppCompatActivity() {
)
}
}
composable<Route.Sort> { SortScreen(appUiState, viewModel) }
composable<Route.TunnelMonitoring> {
TunnelMonitoringScreen(appUiState, viewModel)
}
}
}
}
@@ -348,61 +333,19 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
WireGuardAutoTunnel.setUiActive(true)
networkMonitor.checkPermissionsAndUpdateState()
checkPermissionAndNotify()
}
override fun onPause() {
super.onPause()
WireGuardAutoTunnel.setUiActive(false)
private fun checkPermissionAndNotify() {
val hasLocation =
ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) ==
PackageManager.PERMISSION_GRANTED
if (lastLocationPermissionState != hasLocation) {
Timber.d("Location permission changed to: $hasLocation")
if (hasLocation) {
networkMonitor.sendLocationPermissionsGrantedBroadcast()
}
lastLocationPermissionState = hasLocation
}
}
fun performBackup() =
lifecycleScope.launch(ioDispatcher) {
roomBackup
.database(appDatabase)
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
.enableLogDebug(true)
.maxFileCount(5)
.apply {
onCompleteListener { success, message, exitCode ->
lifecycleScope.launch(mainDispatcher) {
if (success) {
showToast(
getString(
R.string.backup_success,
getString(R.string.restarting_app),
)
)
restartApp()
} else showToast(R.string.backup_failed)
}
}
}
.backup()
}
fun performRestore() =
lifecycleScope.launch {
roomBackup
.database(appDatabase)
.enableLogDebug(true)
.backupLocation(RoomBackup.BACKUP_FILE_LOCATION_CUSTOM_DIALOG)
.apply {
onCompleteListener { success, message, exitCode ->
lifecycleScope.launch(mainDispatcher) {
if (success) {
showToast(
getString(
R.string.restore_success,
getString(R.string.restarting_app),
)
)
restartApp()
} else showToast(R.string.restore_failed)
}
}
}
.restore()
}
}
@@ -4,10 +4,12 @@ import android.app.Application
import android.os.StrictMode
import android.os.StrictMode.ThreadPolicy
import androidx.hilt.work.HiltWorkerFactory
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.work.Configuration
import com.wireguard.android.backend.GoBackend
import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationMonitor
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.worker.ServiceWorker
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
@@ -19,10 +21,10 @@ import com.zaneschepke.wireguardautotunnel.util.LocaleUtil
import com.zaneschepke.wireguardautotunnel.util.ReleaseTree
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@HiltAndroidApp
@@ -43,13 +45,12 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
@Inject @MainDispatcher lateinit var mainDispatcher: CoroutineDispatcher
@Inject lateinit var notificationMonitor: NotificationMonitor
@Inject lateinit var tunnelManager: TunnelManager
override fun onCreate() {
super.onCreate()
instance = this
ProcessLifecycleOwner.get().lifecycle.addObserver(AppLifecycleObserver())
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
StrictMode.setThreadPolicy(
@@ -79,7 +80,6 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
ServiceWorker.start(this)
applicationScope.launch {
launch { notificationMonitor.handleApplicationNotifications() }
appDataRepository.appState.getLocale()?.let {
withContext(mainDispatcher) { LocaleUtil.changeLocale(it) }
}
@@ -90,20 +90,30 @@ class WireGuardAutoTunnel : Application(), Configuration.Provider {
}
override fun onTerminate() {
applicationScope.cancel()
tunnelManager.setBackendState(BackendState.INACTIVE, emptyList())
applicationScope.launch {
tunnelManager.setBackendState(BackendState.INACTIVE, emptyList())
}
super.onTerminate()
}
class AppLifecycleObserver : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
Timber.d("Application entered foreground")
foreground = true
}
override fun onPause(owner: LifecycleOwner) {
Timber.d("Application entered background")
foreground = false
}
}
companion object {
private var foreground = false
private val _uiActive = MutableStateFlow(false)
val uiActive: StateFlow<Boolean>
get() = _uiActive
fun setUiActive(active: Boolean) {
_uiActive.update { active }
fun isForeground(): Boolean {
return foreground
}
@Volatile private var lastActiveTunnels: List<Int> = emptyList()
@@ -43,14 +43,8 @@ interface NotificationManager {
fun show(notificationId: Int, notification: Notification)
companion object {
const val AUTO_TUNNEL_LOCATION_PERMISSION_ID = 123
const val AUTO_TUNNEL_LOCATION_SERVICES_ID = 124
// For auto tunnel foreground notification
const val AUTO_TUNNEL_NOTIFICATION_ID = 122
// for tunnel foreground notification
const val VPN_NOTIFICATION_ID = 100
const val TUNNEL_ERROR_NOTIFICATION_ID = 101
const val TUNNEL_MESSAGES_NOTIFICATION_ID = 102
const val EXTRA_ID = "id"
}
}
@@ -1,63 +0,0 @@
package com.zaneschepke.wireguardautotunnel.core.notification
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.WireGuardAutoTunnel
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.domain.events.BackendError
import com.zaneschepke.wireguardautotunnel.util.StringValue
import jakarta.inject.Inject
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class NotificationMonitor
@Inject
constructor(
private val tunnelManager: TunnelManager,
private val notificationManager: NotificationManager,
) {
suspend fun handleApplicationNotifications() = coroutineScope {
launch { handleTunnelErrors() }
launch { handleTunnelMessages() }
}
private suspend fun handleTunnelErrors() =
tunnelManager.errorEvents.collectLatest { (tunnelConf, error) ->
if (!WireGuardAutoTunnel.uiActive.value) {
val notification =
notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = StringValue.DynamicString(tunnelConf.name),
description =
when (error) {
is BackendError.BounceFailed -> error.toStringValue()
else ->
StringValue.StringResource(
R.string.tunnel_error_template,
error.toStringRes(),
)
},
)
notificationManager.show(
NotificationManager.TUNNEL_ERROR_NOTIFICATION_ID,
notification,
)
}
}
private suspend fun handleTunnelMessages() =
tunnelManager.messageEvents.collectLatest { (tunnelConf, message) ->
if (!WireGuardAutoTunnel.uiActive.value) {
val notification =
notificationManager.createNotification(
WireGuardNotification.NotificationChannels.VPN,
title = StringValue.DynamicString(tunnelConf.name),
description = message.toStringValue(),
)
notificationManager.show(
NotificationManager.TUNNEL_MESSAGES_NOTIFICATION_ID,
notification,
)
}
}
}
@@ -7,6 +7,9 @@ import android.content.ServiceConnection
import android.net.VpnService
import android.os.IBinder
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.AutoTunnelService
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.MainDispatcher
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.util.extensions.requestAutoTunnelTileServiceUpdate
import com.zaneschepke.wireguardautotunnel.util.extensions.requestTunnelTileServiceStateUpdate
@@ -26,9 +29,9 @@ class ServiceManager
@Inject
constructor(
private val context: Context,
private val ioDispatcher: CoroutineDispatcher,
private val applicationScope: CoroutineScope,
private val mainDispatcher: CoroutineDispatcher,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope,
@MainDispatcher private val mainDispatcher: CoroutineDispatcher,
private val appDataRepository: AppDataRepository,
) {
@@ -93,8 +96,6 @@ constructor(
service.stop()
try {
context.unbindService(autoTunnelServiceConnection)
} catch (e: Exception) {
Timber.e(e, "Failed to unbind AutoTunnelService")
} finally {
_tunnelService.value = null
}
@@ -119,8 +120,6 @@ constructor(
service.stop()
try {
context.unbindService(tunnelServiceConnection)
} catch (e: Exception) {
Timber.e(e, "Failed to stop TunnelForegroundService")
} finally {
_tunnelService.value = null
}
@@ -7,24 +7,27 @@ import android.os.IBinder
import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.networkmonitor.NetworkStatus
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelMonitor
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.distinctByKeys
import dagger.hilt.android.AndroidEntryPoint
import io.ktor.util.collections.*
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
@AndroidEntryPoint
@@ -34,18 +37,23 @@ class TunnelForegroundService : LifecycleService() {
@Inject lateinit var serviceManager: ServiceManager
@Inject lateinit var tunnelManager: TunnelManager
@Inject lateinit var tunnelMonitor: TunnelMonitor
@Inject lateinit var networkMonitor: NetworkMonitor
@Inject @IoDispatcher lateinit var ioDispatcher: CoroutineDispatcher
@Inject lateinit var appDataRepository: AppDataRepository
@Inject lateinit var tunnelRepo: TunnelRepository
@Inject lateinit var tunnelManager: TunnelManager
private val isNetworkConnected = MutableStateFlow(true)
private val tunnelJobs = ConcurrentHashMap<TunnelConf, Job>()
private val pingJobs = ConcurrentHashMap<TunnelConf, Job>()
private val jobsMutex = Mutex()
class LocalBinder(val service: TunnelForegroundService) : Binder()
private val tunnelJobs = ConcurrentMap<TunnelConf, Job>()
private val binder = LocalBinder(this)
override fun onCreate() {
@@ -78,23 +86,94 @@ class TunnelForegroundService : LifecycleService() {
fun start() =
lifecycleScope.launch(ioDispatcher) {
tunnelManager.activeTunnels.distinctByKeys().collect { activeTunnels ->
val activeTunConfigs = activeTunnels.keys
val obsoleteJobs = tunnelJobs.keys - activeTunConfigs
obsoleteJobs.forEach { tunnelConf -> tunnelJobs[tunnelConf]?.cancel() }
activeTunConfigs.forEach { tun ->
if (tunnelJobs.containsKey(tun)) return@forEach
tunnelJobs[tun] = launch { tunnelMonitor.startMonitoring(tun, true) }
}
updateServiceNotification(activeTunnels)
// No active tunnels and no jobs: nothing to do
if (activeTunnels.isEmpty() && tunnelJobs.isEmpty()) return@collect
// Synchronize jobs with active tunnels
synchronizeJobs(activeTunnels)
updateServiceNotification()
}
}
private suspend fun synchronizeJobs(activeTunnels: Map<TunnelConf, TunnelState>) {
jobsMutex.withLock {
// Stop jobs for tunnels that are no longer active
stopInactiveJobs(activeTunnels)
// Start jobs for new tunnels
startNewJobs(activeTunnels)
}
}
private fun stopInactiveJobs(activeTunnels: Map<TunnelConf, TunnelState>) {
// If no active tunnels, clear all jobs
if (activeTunnels.isEmpty()) {
clearAllJobs()
return
}
// Stop jobs for tunnels not in activeTunnels
val tunnelsToStop = tunnelJobs.keys - activeTunnels.keys
tunnelsToStop.forEach { tun -> stopTunnelJobs(tun) }
}
private fun clearAllJobs() {
tunnelJobs.forEach { (tun, job) ->
Timber.d("Stopping tunnel job for ${tun.tunName}")
job.cancel()
}
tunnelJobs.clear()
pingJobs.forEach { (tun, job) ->
if (isPingBounce(tun)) {
Timber.d("Preserving ping job for ${tun.tunName} due to PING bounce")
return@forEach
}
Timber.d("Stopping ping job for ${tun.tunName}")
job.cancel()
}
pingJobs.entries.removeIf { (tun, _) -> !isPingBounce(tun) }
}
private fun stopTunnelJobs(tun: TunnelConf) {
tunnelJobs.remove(tun)?.cancel()
Timber.d("Stopped tunnel job for ${tun.tunName}")
if (isPingBounce(tun))
return Timber.d("Preserving ${tun.tunName} ping job due to ping bounce")
pingJobs.remove(tun)?.cancel()
Timber.d("Stopped ping job for ${tun.tunName}")
}
private fun startNewJobs(activeTunnels: Map<TunnelConf, TunnelState>) {
val tunnelsToStart = activeTunnels.keys - tunnelJobs.keys
tunnelsToStart.forEach { tun ->
tunnelJobs[tun] = startTunnelJobs(tun)
Timber.d("Started tunnel job for ${tun.tunName}")
if (pingJobs[tun]?.isActive == true) {
Timber.d("Reusing active ping job for ${tun.tunName}")
} else {
pingJobs[tun]?.cancel() // Cancel any stale job
if (tun.isPingEnabled) {
if (tun.isStaticallyConfigured()) {
Timber.d("Skipping ping for statically configured tunnel")
} else {
pingJobs[tun] = startPingJob(tun)
Timber.d("Started ping job for ${tun.tunName}")
}
}
}
}
}
private fun isPingBounce(tun: TunnelConf): Boolean =
tunnelManager.bouncingTunnelIds[tun.id] == TunnelStatus.StopReason.PING
// TODO Would be cool to have this include kill switch
private fun updateServiceNotification(activeTunnels: Map<TunnelConf, TunnelState>) {
// TODO also we need to include errors
private fun updateServiceNotification() {
val notification =
when (activeTunnels.size) {
when (tunnelJobs.size) {
0 -> onCreateNotification()
1 -> createTunnelNotification(activeTunnels.keys.first())
1 -> createTunnelNotification(tunnelJobs.keys.first())
else -> createTunnelsNotification()
}
ServiceCompat.startForeground(
@@ -105,18 +184,91 @@ class TunnelForegroundService : LifecycleService() {
)
}
// use same scope so we can cancel all of these
private fun startTunnelJobs(tunnelConf: TunnelConf) =
lifecycleScope.launch(ioDispatcher) {
// monitor if we have internet connectivity
launch { startNetworkMonitorJob() }
// job to trigger stats emit on interval
launch { startTunnelStatsJob(tunnelConf) }
// monitor changes to the tunnel config
launch { startTunnelConfChangesJob(tunnelConf) }
}
private suspend fun startTunnelConfChangesJob(tunnelConf: TunnelConf) {
tunnelRepo.flow
.flowOn(ioDispatcher)
.map { storedTunnels -> storedTunnels.firstOrNull { it.id == tunnelConf.id } }
.filterNotNull()
// only emit when one of these 3 values change
.distinctUntilChanged { old, new -> old == new }
.collect { storedTunnel ->
if (tunnelConf != storedTunnel) {
Timber.d("Config changed for ${storedTunnel.tunName}, bouncing")
// let this complete, even after cancel
withContext(NonCancellable) {
tunnelManager.bounceTunnel(
storedTunnel,
TunnelStatus.StopReason.CONFIG_CHANGED,
)
}
}
}
}
private suspend fun startNetworkMonitorJob() {
networkMonitor.networkStatusFlow.flowOn(ioDispatcher).collectLatest { status ->
val isAvailable = status !is NetworkStatus.Disconnected
isNetworkConnected.value = isAvailable
Timber.d("Network available: $status")
}
}
private suspend fun startTunnelStatsJob(tunnel: TunnelConf) = coroutineScope {
while (isActive) {
tunnelManager.updateTunnelStatistics(tunnel)
delay(STATS_DELAY)
}
}
private fun startPingJob(tunnel: TunnelConf) =
lifecycleScope.launch(ioDispatcher) {
// delay for initial duration
delay(tunnel.pingInterval ?: Constants.PING_INTERVAL)
while (isActive) {
val shouldBounce = shouldBounceTunnel(tunnel)
val delayMs =
if (shouldBounce) {
// let this complete, even after cancel
withContext(NonCancellable) {
tunnelManager.bounceTunnel(tunnel, TunnelStatus.StopReason.PING)
}
tunnel.pingCooldown ?: Constants.PING_COOLDOWN
} else {
tunnel.pingInterval ?: Constants.PING_INTERVAL
}
delay(delayMs)
}
}
private suspend fun shouldBounceTunnel(tunnel: TunnelConf): Boolean {
if (!isNetworkConnected.value) {
Timber.d("Network disconnected, skipping ping for ${tunnel.tunName}")
return false
}
return runCatching { !tunnel.isTunnelPingable(ioDispatcher) }
.onFailure { e -> Timber.e(e, "Ping check failed for ${tunnel.tunName}") }
.getOrDefault(true)
}
fun stop() {
Timber.d("Stop called")
tunnelJobs.forEach { it.value.cancel() }
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
tunnelJobs.forEach { it.value.cancel() }
serviceManager.handleTunnelServiceDestroy()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
Timber.d("onDestroy")
super.onDestroy()
}
@@ -151,4 +303,14 @@ class TunnelForegroundService : LifecycleService() {
title = getString(R.string.tunnel_starting),
)
}
// TODO add notification handling and optional log reading for restart on handshake failures
companion object {
const val STATS_DELAY = 1_000L
// ipv6 disabled or block on network
// Failed to send handshake initiation: write udp [::]"
// Failed to send data packets: write udp [::]
// Failed to send data packets: write udp 0.0.0.0:51820
// Handshake did not complete after 5 seconds, retrying
}
}
@@ -3,23 +3,22 @@ package com.zaneschepke.wireguardautotunnel.core.service.autotunnel
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.ServiceCompat
import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor
import com.zaneschepke.networkmonitor.ConnectivityState
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.networkmonitor.NetworkStatus
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelMonitor
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.NotificationAction
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus.StopReason.Ping
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
@@ -27,15 +26,11 @@ import com.zaneschepke.wireguardautotunnel.domain.state.AutoTunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.Tunnels
import com.zaneschepke.wireguardautotunnel.util.extensions.toMillis
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import javax.inject.Provider
import kotlin.math.pow
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import timber.log.Timber
@AndroidEntryPoint
@@ -53,24 +48,20 @@ class AutoTunnelService : LifecycleService() {
@Inject lateinit var tunnelManager: TunnelManager
@Inject lateinit var tunnelMonitor: TunnelMonitor
private val defaultState = AutoTunnelState()
private val autoTunMutex = Mutex()
private val autoTunnelStateFlow = MutableStateFlow(defaultState)
private val bounceCounts = MutableStateFlow<Map<Int, Int>>(emptyMap())
private var wakeLock: PowerManager.WakeLock? = null
private var eventHandlerJob: Job? = null
private val lastBounceTimes = mutableMapOf<Int, Long>()
private var killSwitchJob: Job? = null
class LocalBinder(val service: AutoTunnelService) : Binder()
private val binder = LocalBinder(this)
private var isServiceRunning = false
override fun onCreate() {
super.onCreate()
launchWatcherNotification()
@@ -89,19 +80,28 @@ class AutoTunnelService : LifecycleService() {
}
fun start() {
launchWatcherNotification()
startAutoTunnelStateJob()
startLocationPermissionsNotificationJob()
if (isServiceRunning) return
isServiceRunning = true
kotlin
.runCatching {
launchWatcherNotification()
initWakeLock()
startAutoTunnelJob()
startAutoTunnelStateJob()
killSwitchJob = startKillSwitchJob()
}
.onFailure { Timber.e(it) }
}
fun stop() {
isServiceRunning = false
wakeLock?.let { if (it.isHeld) it.release() }
stopSelf()
}
override fun onDestroy() {
serviceManager.handleAutoTunnelServiceDestroy()
restoreVpnKillSwitch()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
super.onDestroy()
}
@@ -111,7 +111,7 @@ class AutoTunnelService : LifecycleService() {
settings.isVpnKillSwitchEnabled &&
tunnelManager.getBackendState() != BackendState.KILL_SWITCH_ACTIVE
) {
eventHandlerJob?.cancel()
killSwitchJob?.cancel()
val allowedIps =
if (settings.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS
else emptyList()
@@ -143,141 +143,80 @@ class AutoTunnelService : LifecycleService() {
)
}
private fun startAutoTunnelStateJob() =
lifecycleScope.launch(ioDispatcher) {
val networkFlow =
debouncedConnectivityStateFlow
.flowOn(ioDispatcher)
.map(NetworkState::from)
.map { StateChange.NetworkChange(it) }
.distinctUntilChanged()
val settingsFlow =
combineSettings().map { StateChange.SettingsChange(it.first, it.second) }
val tunnelsFlow =
tunnelManager.activeTunnels.map { StateChange.ActiveTunnelsChange(it) }
val monitoringFlow =
tunnelManager.activeTunnels
.map { map -> map.mapValues { (_, state) -> state.pingStates } }
.distinctUntilChanged()
.map { StateChange.MonitoringChange(it) }
var reevaluationJob: Job? = null
// get everything in sync before we use merge
combine(networkFlow, settingsFlow, tunnelsFlow, monitoringFlow) {
network,
settings,
tunnels,
monitoring ->
autoTunnelStateFlow.update {
it.copy(
activeTunnels = tunnels.activeTunnels,
networkState = network.networkState,
settings = settings.settings,
tunnels = settings.tunnels,
)
}
}
.first()
// use merge to limit the noise of a combine and also increase the scalability of auto
// tunnel handling new states
merge(networkFlow, settingsFlow, tunnelsFlow, monitoringFlow).collect { change ->
if (change !is StateChange.ActiveTunnelsChange) {
Timber.d("New state changed to ${change.javaClass.simpleName}")
}
when (change) {
is StateChange.NetworkChange -> {
reevaluationJob?.cancel()
val previousState = autoTunnelStateFlow.value
autoTunnelStateFlow.update { it.copy(networkState = change.networkState) }
// Android late mobile data state change, we can ignore handling this
if (
isAndroidLateCellularActiveChange(
previousState.networkState,
change.networkState,
)
) {
Timber.d("Android late cellular active state change")
return@collect
}
}
is StateChange.SettingsChange -> {
reevaluationJob?.cancel()
autoTunnelStateFlow.update {
it.copy(settings = change.settings, tunnels = change.tunnels)
}
}
is StateChange.ActiveTunnelsChange -> {
autoTunnelStateFlow.update { it.copy(activeTunnels = change.activeTunnels) }
return@collect
}
is StateChange.MonitoringChange -> {
change.pingStates.forEach { (config, pingState) ->
Timber.d("Ping state $pingState")
if (pingState?.all { it.value.isReachable } == true) {
Timber.d("Clearing bounce count on success")
bounceCounts.update { current ->
current.toMutableMap().apply { remove(config.id) }
}
}
}
return@collect handleAutoTunnelEvent(
autoTunnelStateFlow.value.determineAutoTunnelEvent(
StateChange.MonitoringChange(change.pingStates)
)
)
}
}
handleAutoTunnelEvent(autoTunnelStateFlow.value.determineAutoTunnelEvent(change))
reevaluationJob = launch {
delay(REEVALUATE_CHECK_DELAY)
val currentState = autoTunnelStateFlow.value
if (currentState != defaultState) {
Timber.d("Re-evaluating auto-tunnel state..")
handleAutoTunnelEvent(currentState.determineAutoTunnelEvent(change))
private fun initWakeLock() {
wakeLock =
(getSystemService(POWER_SERVICE) as PowerManager).run {
val tag = this.javaClass.name
newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "$tag::lock").apply {
try {
Timber.i("Initiating wakelock with 10 min timeout")
acquire(Constants.BATTERY_SAVER_WATCHER_WAKE_LOCK_TIMEOUT)
} finally {
release()
}
}
}
}
private fun buildNetworkState(networkStatus: NetworkStatus): NetworkState {
return with(autoTunnelStateFlow.value.networkState) {
val wifiName =
when (networkStatus) {
is NetworkStatus.Connected -> {
networkStatus.wifiSsid
}
else -> null
}
copy(
isWifiConnected = networkStatus.wifiConnected,
isMobileDataConnected = networkStatus.cellularConnected,
isEthernetConnected = networkStatus.ethernetConnected,
wifiName = wifiName,
)
}
private fun isAndroidLateCellularActiveChange(
previous: NetworkState,
new: NetworkState,
): Boolean {
return (previous.isWifiConnected != new.isWifiConnected &&
previous.wifiName == new.wifiName &&
previous.isMobileDataConnected != new.isMobileDataConnected)
}
// all relevant settings to auto tunnel
private fun areAutoTunnelSettingsTheSame(old: AppSettings, new: AppSettings): Boolean {
return (old.isTunnelOnWifiEnabled == new.isTunnelOnWifiEnabled &&
old.isTunnelOnMobileDataEnabled == new.isTunnelOnMobileDataEnabled &&
old.isTunnelOnEthernetEnabled == new.isTunnelOnEthernetEnabled &&
old.trustedNetworkSSIDs == new.trustedNetworkSSIDs &&
old.isPingEnabled == new.isPingEnabled &&
old.debounceDelaySeconds == new.debounceDelaySeconds &&
old.wifiDetectionMethod == new.wifiDetectionMethod &&
old.isVpnKillSwitchEnabled == new.isVpnKillSwitchEnabled &&
old.isLanOnKillSwitchEnabled == new.isLanOnKillSwitchEnabled &&
old.isDisableKillSwitchOnTrustedEnabled == new.isDisableKillSwitchOnTrustedEnabled &&
old.isStopOnNoInternetEnabled == new.isStopOnNoInternetEnabled)
}
@OptIn(ExperimentalCoroutinesApi::class)
private fun startAutoTunnelStateJob() =
lifecycleScope.launch(ioDispatcher) {
combine(
combineSettings(),
appDataRepository
.get()
.settings
.flow
.distinctUntilChanged { old, new ->
old.isKernelEnabled == new.isKernelEnabled
} // Only emit when isKernelEnabled changes
.flatMapLatest {
networkMonitor.networkStatusFlow.flowOn(ioDispatcher).map {
buildNetworkState(it)
}
}
.distinctUntilChanged(),
) { double, networkState ->
AutoTunnelState(
tunnelManager.activeTunnels.value,
networkState,
double.first,
double.second,
)
}
.collect { state ->
autoTunnelStateFlow.update {
it.copy(
activeTunnels = state.activeTunnels,
networkState = state.networkState,
settings = state.settings,
tunnels = state.tunnels,
)
}
}
}
private fun combineSettings(): Flow<Pair<AppSettings, Tunnels>> {
return combine(
appDataRepository
.get()
.settings
.flow
.distinctUntilChanged(::areAutoTunnelSettingsTheSame),
appDataRepository.get().settings.flow,
appDataRepository.get().tunnels.flow.map { tunnels ->
// isActive is ignored for equality checks so user can manually toggle off
// tunnel with auto-tunnel
@@ -289,174 +228,70 @@ class AutoTunnelService : LifecycleService() {
.distinctUntilChanged()
}
private fun areAutoTunnelPermissionsRequiredTheSame(
old: AutoTunnelState,
new: AutoTunnelState,
): Boolean {
return (old.settings.wifiDetectionMethod == new.settings.wifiDetectionMethod &&
old.networkState.locationPermissionGranted ==
new.networkState.locationPermissionGranted &&
old.networkState.locationServicesEnabled == new.networkState.locationServicesEnabled &&
old.tunnels == new.tunnels &&
old.settings.trustedNetworkSSIDs == new.settings.trustedNetworkSSIDs)
}
// watch for changes to location permission and notify user it will impact auto-tunneling
// TODO or a recheck button for location permission so we dont have to poll it
private fun startLocationPermissionsNotificationJob(): Job =
private fun startKillSwitchJob() =
lifecycleScope.launch(ioDispatcher) {
var locationServicesShown = false
var locationPermissionsShown = false
data class NetworkPermissionState(
val detectionMethod: AndroidNetworkMonitor.WifiDetectionMethod,
val locationServicesEnabled: Boolean,
val locationPermissionsEnabled: Boolean,
val ssidReadRequired: Boolean,
)
autoTunnelStateFlow
.distinctUntilChanged(::areAutoTunnelPermissionsRequiredTheSame)
.map {
NetworkPermissionState(
it.settings.wifiDetectionMethod,
it.networkState.locationServicesEnabled == true,
it.networkState.locationPermissionGranted == true,
(it.tunnels.any { tunnel -> tunnel.tunnelNetworks.isNotEmpty() } ||
it.settings.trustedNetworkSSIDs.isNotEmpty()),
)
}
.collect { state ->
when (state.detectionMethod) {
AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT,
AndroidNetworkMonitor.WifiDetectionMethod.LEGACY -> {
if (
!state.locationPermissionsEnabled &&
!locationPermissionsShown &&
state.ssidReadRequired
) {
locationPermissionsShown = true
val notification =
notificationManager.createNotification(
WireGuardNotification.NotificationChannels.AUTO_TUNNEL,
title = getString(R.string.warning),
description =
getString(R.string.location_permissions_missing),
)
notificationManager.show(
NotificationManager.AUTO_TUNNEL_LOCATION_PERMISSION_ID,
notification,
)
}
if (
!state.locationServicesEnabled &&
!locationServicesShown &&
state.ssidReadRequired
) {
locationServicesShown = true
val notification =
notificationManager.createNotification(
WireGuardNotification.NotificationChannels.AUTO_TUNNEL,
title = getString(R.string.warning),
description =
getString(R.string.location_services_not_detected),
)
notificationManager.show(
NotificationManager.AUTO_TUNNEL_LOCATION_SERVICES_ID,
notification,
)
}
if (state.locationServicesEnabled || !state.ssidReadRequired) {
notificationManager.remove(
NotificationManager.AUTO_TUNNEL_LOCATION_SERVICES_ID
)
locationServicesShown = false
}
if (state.locationPermissionsEnabled || !state.ssidReadRequired) {
notificationManager.remove(
NotificationManager.AUTO_TUNNEL_LOCATION_PERMISSION_ID
)
locationPermissionsShown = false
}
}
else -> Unit
autoTunnelStateFlow.collect {
if (it == defaultState) return@collect
when (val event = it.asKillSwitchEvent()) {
KillSwitchEvent.DoNothing -> Unit
is KillSwitchEvent.Start -> {
Timber.d("Starting kill switch")
tunnelManager.setBackendState(
BackendState.KILL_SWITCH_ACTIVE,
event.allowedIps,
)
}
}
}
private suspend fun handleAutoTunnelEvent(autoTunnelEvent: AutoTunnelEvent) {
autoTunMutex.withLock {
when (
val event =
autoTunnelEvent.also {
Timber.i("Auto tunnel event: ${it.javaClass.simpleName}")
KillSwitchEvent.Stop -> {
Timber.d("Stopping kill switch")
tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptySet())
}
) {
is AutoTunnelEvent.Start ->
(event.tunnelConf ?: appDataRepository.get().getPrimaryOrFirstTunnel())?.let {
tunnelManager.startTunnel(it)
}
is AutoTunnelEvent.Stop -> tunnelManager.stopTunnel()
AutoTunnelEvent.DoNothing -> Timber.i("Auto-tunneling: nothing to do")
is AutoTunnelEvent.Bounce ->
handleBounceWithBackoff(event.configsPeerKeyResolvedMap)
is AutoTunnelEvent.StartKillSwitch -> {
Timber.d("Starting kill switch")
tunnelManager.setBackendState(BackendState.KILL_SWITCH_ACTIVE, event.allowedIps)
}
AutoTunnelEvent.StopKillSwitch -> {
Timber.d("Stopping kill switch")
tunnelManager.setBackendState(BackendState.SERVICE_ACTIVE, emptySet())
}
}
}
}
private suspend fun handleBounceWithBackoff(
configsPeerKeyResolvedMap: List<Pair<TunnelConf, Map<String, String?>>>
) { // Simplified param: no failureCount
val settings = appDataRepository.get().settings.get()
val pingIntervalMillis = settings.tunnelPingIntervalSeconds.toMillis()
configsPeerKeyResolvedMap.forEach { (config, peerMap) ->
val bounceCount = bounceCounts.value.getOrDefault(config.id, 0)
val exponent = bounceCount.toDouble()
val backoffDelay =
(pingIntervalMillis * 2.0.pow(exponent)).toLong().coerceAtMost(MAX_BACKOFF_MS)
val currentTime = System.currentTimeMillis()
val lastTime = lastBounceTimes.getOrDefault(config.id, 0L)
if (currentTime - lastTime >= backoffDelay) {
Timber.d(
"Bouncing tunnel ${config.name} after detecting failure, with bounce count $bounceCount and calculated backoff delay $backoffDelay ms"
)
tunnelManager.bounceTunnel(config, Ping(peerMap))
lastBounceTimes[config.id] = currentTime
bounceCounts.update { current ->
current.toMutableMap().apply { this[config.id] = (this[config.id] ?: 0) + 1 }
@OptIn(FlowPreview::class)
private fun startAutoTunnelJob() =
lifecycleScope.launch(ioDispatcher) {
Timber.i("Starting auto-tunnel network event watcher")
val settings = appDataRepository.get().settings.get()
var reevaluationJob: Job? = null
autoTunnelStateFlow.debounce(settings.debounceDelayMillis()).collect { watcherState ->
if (watcherState == defaultState) return@collect
reevaluationJob?.cancel()
handleAutoTunnelEvent(watcherState)
// schedule one-time re-evaluation
reevaluationJob = launch {
delay(REEVALUATE_CHECK_DELAY)
if (watcherState != defaultState) {
Timber.d("Re-evaluating auto-tunnel state..")
handleAutoTunnelEvent(watcherState)
}
}
} else {
Timber.d(
"Backoff in progress for tunnel ${config.name}, skipping bounce (required delay: $backoffDelay ms)"
)
}
}
}
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
private val debouncedConnectivityStateFlow: Flow<ConnectivityState> by lazy {
appDataRepository
.get()
.settings
.flow
.map { it.debounceDelaySeconds.toMillis() }
.distinctUntilChanged()
.flatMapLatest { debounceMillis ->
networkMonitor.connectivityStateFlow.debounce(debounceMillis)
}
private suspend fun handleAutoTunnelEvent(watcherState: AutoTunnelState) {
Timber.i("Auto-tunnel settings: ${watcherState.settings.toAutoTunnelStateString()}")
Timber.i("Auto-tunnel network state: ${watcherState.networkState}")
when (
val event =
watcherState.asAutoTunnelEvent().also {
Timber.i("Auto-tunnel event: ${it.javaClass.simpleName}")
}
) {
is AutoTunnelEvent.Start ->
(event.tunnelConf ?: appDataRepository.get().getPrimaryOrFirstTunnel())?.let {
tunnelManager.startTunnel(it)
}
is AutoTunnelEvent.Stop -> tunnelManager.stopTunnel()
AutoTunnelEvent.DoNothing -> Timber.i("Auto-tunneling: nothing to do")
}
}
companion object {
// try to keep this window short as it will interrupt manual overrides
const val REEVALUATE_CHECK_DELAY = 2_000L
const val MAX_BACKOFF_MS = 300_000L // 5 minutes
const val REEVALUATE_CHECK_DELAY = 5_000L
}
}
@@ -1,20 +0,0 @@
package com.zaneschepke.wireguardautotunnel.core.service.autotunnel
import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.NetworkState
import com.zaneschepke.wireguardautotunnel.domain.state.PingState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.util.extensions.Tunnels
import org.amnezia.awg.crypto.Key
sealed class StateChange {
data class NetworkChange(val networkState: NetworkState) : StateChange()
data class SettingsChange(val settings: AppSettings, val tunnels: Tunnels) : StateChange()
data class ActiveTunnelsChange(val activeTunnels: Map<TunnelConf, TunnelState>) : StateChange()
data class MonitoringChange(val pingStates: Map<TunnelConf, Map<Key, PingState>?>) :
StateChange()
}
@@ -2,42 +2,37 @@ package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.wireguard.android.backend.Tunnel
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.events.BackendError
import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.PingState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.ui.state.ConfigProxy
import com.zaneschepke.wireguardautotunnel.util.extensions.asTunnelState
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException
import kotlin.concurrent.thread
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.amnezia.awg.crypto.Key
import timber.log.Timber
abstract class BaseTunnel(
private val applicationScope: CoroutineScope,
@ApplicationScope private val applicationScope: CoroutineScope,
private val appDataRepository: AppDataRepository,
private val serviceManager: ServiceManager,
) : TunnelProvider {
private val _errorEvents = MutableSharedFlow<Pair<TunnelConf, BackendError>>()
private val _errorEvents =
MutableSharedFlow<Pair<TunnelConf, BackendError>>(replay = 0, extraBufferCapacity = 1)
override val errorEvents = _errorEvents.asSharedFlow()
private val _messageEvents = MutableSharedFlow<Pair<TunnelConf, BackendMessage>>()
override val messageEvents = _messageEvents.asSharedFlow()
private val activeTuns = MutableStateFlow<Map<TunnelConf, TunnelState>>(emptyMap())
private val tunJobs = ConcurrentHashMap<Int, Job>()
private val tunThreads = ConcurrentHashMap<Int, Thread>()
override val activeTunnels = activeTuns.asStateFlow()
private val tunMutex = Mutex()
@@ -54,43 +49,32 @@ abstract class BaseTunnel(
return serviceManager.hasVpnPermission()
}
override suspend fun updateTunnelStatus(
protected suspend fun updateTunnelStatus(
tunnelConf: TunnelConf,
status: TunnelStatus?,
stats: TunnelStatistics?,
pingStates: Map<Key, PingState>?,
handshakeSuccessLogs: Boolean?,
status: TunnelStatus? = null,
stats: TunnelStatistics? = null,
) {
tunStatusMutex.withLock {
activeTuns.update { currentTuns ->
val originalConf = currentTuns.getKeyById(tunnelConf.id) ?: tunnelConf
val existingState = currentTuns.getValueById(tunnelConf.id) ?: TunnelState()
val newStatus = status ?: existingState.status
if (newStatus == TunnelStatus.Down) {
val newState = status ?: existingState.status
if (newState == TunnelStatus.Down) {
Timber.d("Removing tunnel ${tunnelConf.id} from activeTunnels as state is DOWN")
cleanUpTunJob(tunnelConf)
cleanUpTunThread(tunnelConf)
currentTuns - originalConf
} else if (
existingState.status == newStatus &&
stats == null &&
pingStates == null &&
handshakeSuccessLogs == null
) {
Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newStatus")
} else if (existingState.status == newState && stats == null) {
Timber.d("Skipping redundant state update for ${tunnelConf.id}: $newState")
currentTuns
} else {
val updated =
existingState.copy(
status = newStatus,
status = newState,
statistics = stats ?: existingState.statistics,
pingStates = pingStates ?: existingState.pingStates,
handshakeSuccessLogs =
handshakeSuccessLogs ?: existingState.handshakeSuccessLogs,
)
currentTuns + (originalConf to updated)
}
}
handleServiceStateOnChange()
}
}
@@ -120,100 +104,44 @@ abstract class BaseTunnel(
}
}
override suspend fun updateTunnelStatistics(tunnel: TunnelConf) {
val stats = getStatistics(tunnel)
updateTunnelStatus(tunnel, null, stats)
}
override suspend fun startTunnel(tunnelConf: TunnelConf) {
if (activeTuns.exists(tunnelConf.id) || tunJobs.containsKey(tunnelConf.id))
return Timber.w("Tunnel is already running ${tunnelConf.name}")
// For userspace, we need to make sure all previous tunnels are down
if (activeTuns.exists(tunnelConf.id) || tunThreads.containsKey(tunnelConf.id)) return
if (this@BaseTunnel is UserspaceTunnel) stopActiveTunnels()
tunMutex.withLock {
val job =
applicationScope.launch {
try {
tunThreads[tunnelConf.id] = thread {
try {
runBlocking {
Timber.d("Starting tunnel ${tunnelConf.id}...")
startTunnelInner(tunnelConf)
Timber.d("Started complete for tunnel ${tunnelConf.name}...")
// catch cancellation that could occur before and during startTunnelInner
// and trigger at that suspend point
} catch (e: CancellationException) {
Timber.w(
"Tunnel start has been cancelled as ${tunnelConf.name} failed to start"
)
}
} catch (e: InterruptedException) {
Timber.w(
"Tunnel start has been interrupted as ${tunnelConf.name} failed to start"
)
}
tunJobs[tunnelConf.id] = job
job.invokeOnCompletion {
tunJobs.remove(tunnelConf.id)
Timber.d("Start job completed for tunnel ${tunnelConf.id}")
}
}
}
private suspend fun startTunnelInner(tunnelConf: TunnelConf) {
configureTunnelCallbacks(tunnelConf)
Timber.d("Starting backend for tunnel ${tunnelConf.id}...")
var currentConf = tunnelConf
var restoreAttempted = false
var originalError: BackendError? = null
while (true) {
try {
startBackend(currentConf)
updateTunnelStatus(currentConf, TunnelStatus.Up)
Timber.d("Started for tun ${currentConf.id}...")
saveTunnelActiveState(currentConf, true)
serviceManager.startTunnelForegroundService()
if (restoreAttempted)
_messageEvents.emit(tunnelConf to BackendMessage.BounceRecovery)
if (bouncingTunnelIds[currentConf.id] is TunnelStatus.StopReason.Ping) {
_messageEvents.emit(tunnelConf to BackendMessage.BounceSuccess)
}
return // Success, return
} catch (e: BackendError) {
originalError = originalError ?: e
val bounceReason = bouncingTunnelIds[currentConf.id]
if (!restoreAttempted && bounceReason is TunnelStatus.StopReason.Ping) {
Timber.i(
"Attempting to recover bounce failure with previously resolved endpoints for ${currentConf.name}"
)
try {
val previouslyResolved = bounceReason.previouslyResolvedEndpoints
val configProxy = ConfigProxy.from(currentConf.toAmConfig())
val updatedConfigProxy =
configProxy.copy(
peers =
configProxy.peers.map {
it.copy(
endpoint =
previouslyResolved[it.publicKey] ?: it.endpoint
)
}
)
val (wg, amnezia) = updatedConfigProxy.buildConfigs()
currentConf =
currentConf.copyWithCallback(
amQuick = amnezia.toAwgQuickString(true),
wgQuick = wg.toWgQuickString(true),
)
bouncingTunnelIds.remove(currentConf.id)
restoreAttempted = true
continue // Retry
} catch (e: Exception) {
Timber.e(
e,
"Failed to update config with resolved endpoints for ${currentConf.name}",
)
// Fall through to failure (will emit BounceFailed since
// retryAttempted=true)
}
}
Timber.e(e, "Failed to start backend for ${currentConf.name}")
val emitError =
if (restoreAttempted) BackendError.BounceFailed(originalError) else e
_errorEvents.emit(currentConf to emitError)
updateTunnelStatus(currentConf, TunnelStatus.Down)
return
}
Timber.d("Starting backend for tunnel ${tunnelConf.id}...")
try {
startBackend(tunnelConf)
updateTunnelStatus(tunnelConf, TunnelStatus.Up)
Timber.d("Started for tun ${tunnelConf.id}...")
saveTunnelActiveState(tunnelConf, true)
serviceManager.startTunnelForegroundService()
} catch (e: BackendError) {
Timber.e(e, "Failed to start backend for ${tunnelConf.name}")
_errorEvents.emit(tunnelConf to e)
updateTunnelStatus(tunnelConf, TunnelStatus.Down)
}
}
@@ -246,23 +174,30 @@ abstract class BaseTunnel(
}
private fun handleServiceStateOnChange() {
if (activeTuns.value.isEmpty()) serviceManager.stopTunnelForegroundService()
if (activeTuns.value.isEmpty() && bouncingTunnelIds.isEmpty())
serviceManager.stopTunnelForegroundService()
}
private suspend fun handleStuckStartingTunnelShutdown(tunnel: TunnelConf) {
Timber.d("Stuck in starting state so cancelling job for tunnel ${tunnel.name}")
Timber.d("Stuck in starting state so shutting down tunnel thread for tunnel ${tunnel.name}")
try {
tunJobs[tunnel.id]?.cancel() ?: Timber.d("No job found for ${tunnel.name}")
tunThreads[tunnel.id]?.let {
if (it.state != Thread.State.TERMINATED) {
it.interrupt()
} else {
Timber.d("Thread already terminated")
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to cancel job for ${tunnel.name}")
Timber.e(e, "Failed to stop tunnel thread for ${tunnel.name}")
} finally {
updateTunnelStatus(tunnel, TunnelStatus.Down)
}
}
private fun cleanUpTunJob(tunnel: TunnelConf) {
Timber.d("Removing job for ${tunnel.name}")
tunJobs -= tunnel.id
private fun cleanUpTunThread(tunnel: TunnelConf) {
Timber.d("Removing thread for ${tunnel.name}")
tunThreads -= tunnel.id
}
private fun removeActiveTunnel(tunnelConf: TunnelConf) {
@@ -275,10 +210,16 @@ abstract class BaseTunnel(
"Bounce tunnel ${tunnelConf.name} for reason: $reason, current bouncing: ${bouncingTunnelIds.size}"
)
bouncingTunnelIds[tunnelConf.id] = reason
runCatching {
try {
stopTunnel(tunnelConf, reason)
delay(BOUNCE_DELAY)
startTunnel(tunnelConf)
} finally {
bouncingTunnelIds.remove(tunnelConf.id)
handleServiceStateOnChange()
Timber.d(
"Cleared bounce state for ${tunnelConf.name}, remaining: ${bouncingTunnelIds.size}"
)
}
}
}
@@ -7,7 +7,6 @@ import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.events.BackendError
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
@@ -36,17 +35,11 @@ constructor(
}
override suspend fun startBackend(tunnel: TunnelConf) {
// name too long for kernel mode
if (!tunnel.isNameKernelCompatible) throw BackendError.TunnelNameTooLong
try {
updateTunnelStatus(tunnel, TunnelStatus.Starting)
backend.setState(tunnel, Tunnel.State.UP, tunnel.toWgConfig())
} catch (e: BackendException) {
Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}")
throw e.toBackendError()
} catch (e: IllegalArgumentException) {
Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}")
throw BackendError.Config
}
}
@@ -1,32 +1,37 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
import com.zaneschepke.wireguardautotunnel.di.Kernel
import com.zaneschepke.wireguardautotunnel.di.Userspace
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.events.BackendError
import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.PingState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.amnezia.awg.crypto.Key
@OptIn(ExperimentalCoroutinesApi::class)
class TunnelManager
@Inject
constructor(
private val kernelTunnel: TunnelProvider,
private val userspaceTunnel: TunnelProvider,
@Kernel private val kernelTunnel: TunnelProvider,
@Userspace private val userspaceTunnel: TunnelProvider,
private val appDataRepository: AppDataRepository,
applicationScope: CoroutineScope,
ioDispatcher: CoroutineDispatcher,
@ApplicationScope private val applicationScope: CoroutineScope,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : TunnelProvider {
@OptIn(ExperimentalCoroutinesApi::class)
@@ -42,30 +47,26 @@ constructor(
initialValue = userspaceTunnel,
)
override val activeTunnels: StateFlow<Map<TunnelConf, TunnelState>> =
tunnelProviderFlow.value.activeTunnels
@OptIn(ExperimentalCoroutinesApi::class)
override val errorEvents: SharedFlow<Pair<TunnelConf, BackendError>> =
tunnelProviderFlow
.flatMapLatest { it.errorEvents }
.shareIn(
scope = applicationScope.plus(ioDispatcher),
started = SharingStarted.Eagerly,
replay = 0,
)
@OptIn(ExperimentalCoroutinesApi::class)
override val messageEvents: SharedFlow<Pair<TunnelConf, BackendMessage>> =
tunnelProviderFlow
.flatMapLatest { it.messageEvents }
override val activeTunnels =
appDataRepository.settings.flow
.filterNotNull()
.shareIn(
.flatMapLatest { settings ->
if (settings.isKernelEnabled) {
kernelTunnel.activeTunnels
} else {
userspaceTunnel.activeTunnels
}
}
.stateIn(
scope = applicationScope.plus(ioDispatcher),
started = SharingStarted.Eagerly,
replay = 0,
initialValue = emptyMap(),
)
override val errorEvents: SharedFlow<Pair<TunnelConf, BackendError>>
get() = tunnelProviderFlow.value.errorEvents
override val bouncingTunnelIds: ConcurrentHashMap<Int, TunnelStatus.StopReason> =
tunnelProviderFlow.value.bouncingTunnelIds
@@ -73,8 +74,8 @@ constructor(
return userspaceTunnel.hasVpnPermission()
}
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return tunnelProviderFlow.value.getStatistics(tunnelConf)
override suspend fun updateTunnelStatistics(tunnel: TunnelConf) {
tunnelProviderFlow.value.updateTunnelStatistics(tunnel)
}
override suspend fun startTunnel(tunnelConf: TunnelConf) {
@@ -101,35 +102,24 @@ constructor(
return tunnelProviderFlow.value.runningTunnelNames()
}
override suspend fun updateTunnelStatus(
tunnelConf: TunnelConf,
status: TunnelStatus?,
stats: TunnelStatistics?,
pingStates: Map<Key, PingState>?,
handshakeSuccessLogs: Boolean?,
) {
tunnelProviderFlow.value.updateTunnelStatus(
tunnelConf,
status,
stats,
pingStates,
handshakeSuccessLogs,
)
override fun getStatistics(tunnelConf: TunnelConf): TunnelStatistics? {
return tunnelProviderFlow.value.getStatistics(tunnelConf)
}
suspend fun restorePreviousState() {
val settings = appDataRepository.settings.get()
if (settings.isRestoreOnBootEnabled) {
val previouslyActiveTuns = appDataRepository.tunnels.getActive()
val tunsToStart =
previouslyActiveTuns.filterNot { tun ->
activeTunnels.value.any { tun.id == it.key.id }
fun restorePreviousState() =
applicationScope.launch(ioDispatcher) {
val settings = appDataRepository.settings.get()
if (settings.isRestoreOnBootEnabled) {
val previouslyActiveTuns = appDataRepository.tunnels.getActive()
val tunsToStart =
previouslyActiveTuns.filterNot { tun ->
activeTunnels.value.any { tun.id == it.key.id }
}
if (settings.isKernelEnabled) {
return@launch tunsToStart.forEach { startTunnel(it) }
} else {
tunsToStart.firstOrNull()?.let { startTunnel(it) }
}
if (settings.isKernelEnabled) {
return tunsToStart.forEach { startTunnel(it) }
} else {
tunsToStart.firstOrNull()?.let { startTunnel(it) }
}
}
}
}
@@ -1,266 +0,0 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.FailureReason
import com.zaneschepke.wireguardautotunnel.domain.state.PingState
import com.zaneschepke.wireguardautotunnel.util.extensions.toMillis
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
import dagger.hilt.android.scopes.ServiceScoped
import io.ktor.util.collections.*
import javax.inject.Inject
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.amnezia.awg.crypto.Key
import timber.log.Timber
@ServiceScoped
class TunnelMonitor
@Inject
constructor(
private val appDataRepository: AppDataRepository,
private val tunnelManager: TunnelManager,
private val networkMonitor: NetworkMonitor,
private val networkUtils: NetworkUtils,
private val logReader: LogReader,
) {
@OptIn(FlowPreview::class)
suspend fun startMonitoring(tunnelConf: TunnelConf, withLogs: Boolean): Job = coroutineScope {
launch {
launch { startTunnelConfChangesJob(tunnelConf) }
launch { startPingMonitor(tunnelConf) }
launch { startWgStatsPoll(tunnelConf) }
if (withLogs) launch { startLogsMonitor(tunnelConf) }
}
}
private suspend fun startTunnelConfChangesJob(tunnelConf: TunnelConf) {
appDataRepository.tunnels.flow
.map { storedTunnels -> storedTunnels.firstOrNull { it.id == tunnelConf.id } }
.filterNotNull()
.distinctUntilChanged { old, new -> old == new }
.collect { storedTunnel ->
if (tunnelConf != storedTunnel) {
Timber.d("Config changed for ${storedTunnel.tunName}, bouncing")
withContext(NonCancellable) {
tunnelManager.bounceTunnel(
storedTunnel,
TunnelStatus.StopReason.ConfigChanged,
)
}
}
}
}
private suspend fun startLogsMonitor(tunnelConf: TunnelConf) {
logReader.liveLogs.collect { log ->
val healthLogs =
when {
log.message.contains(HANDSHAKE_RESPONSE_TEXT, true) ||
log.message.contains(KEEPALIVE_RESPONSE_TEXT, true) -> true
log.message.contains(HANDSHAKE_INIT_FAILED_TEXT, true) ||
log.message.contains(HANDSHAKE_NOT_COMPLETED_TEXT) ||
log.message.contains(DATA_PACKET_FAILED_TEXT) -> false
else -> null
}
healthLogs?.let { healthy ->
tunnelManager.updateTunnelStatus(tunnelConf, null, null, null, healthy)
}
}
}
private suspend fun startPingMonitor(tunnelConf: TunnelConf) = coroutineScope {
val pingStatsFlow = MutableStateFlow<Map<Key, PingState>>(emptyMap())
val tunStateFlow =
tunnelManager.activeTunnels.mapNotNull { it.getValueById(tunnelConf.id) }.stateIn(this)
val connectivityStateFlow = networkMonitor.connectivityStateFlow.stateIn(this)
val isNetworkConnected = connectivityStateFlow.map { it.hasConnectivity() }.stateIn(this)
data class NetworkChangeKey(
val ethernetConnected: Boolean,
val wifiConnected: Boolean,
val cellularConnected: Boolean,
val wifiSsid: String?,
)
connectivityStateFlow
.map {
NetworkChangeKey(
ethernetConnected = it.ethernetConnected,
wifiConnected = it.wifiState.connected,
cellularConnected = it.cellularConnected,
wifiSsid = if (it.wifiState.connected) it.wifiState.ssid else null,
)
}
.distinctUntilChanged()
.stateIn(this)
appDataRepository.settings.flow
.distinctUntilChanged { old, new ->
old.isPingEnabled == new.isPingEnabled &&
old.tunnelPingIntervalSeconds == new.tunnelPingIntervalSeconds &&
old.tunnelPingAttempts == new.tunnelPingAttempts &&
old.tunnelPingTimeoutSeconds == new.tunnelPingTimeoutSeconds
}
.collectLatest { settings ->
if (!settings.isPingEnabled) return@collectLatest
Timber.d("Starting pinger for ${tunnelConf.tunName} with settings")
val config = tunnelConf.toAmConfig()
val pingablePeers = config.peers.filter { it.allowedIps.isNotEmpty() }
if (pingablePeers.isEmpty()) return@collectLatest
suspend fun performPing() {
val updates = ConcurrentMap<Key, PingState>()
pingablePeers.forEach { peer ->
val previousState = pingStatsFlow.value[peer.publicKey] ?: PingState()
val allowedIpStr = peer.allowedIps.firstOrNull()?.toString()
if (allowedIpStr == null) {
updates[peer.publicKey] =
previousState.copy(
isReachable = false,
failureReason = FailureReason.NoResolvedEndpoint,
lastPingAttemptMillis = System.currentTimeMillis(),
)
return@forEach
}
val host =
{
val parts = allowedIpStr.split("/")
val internalIp = if (parts.size == 2) parts[0] else allowedIpStr
val prefix =
if (parts.size == 2) parts[1].toIntOrNull() ?: 32 else 32
if (prefix <= 1) {
tunnelConf.pingTarget ?: CLOUDFLARE_IPV4_IP
} else {
internalIp.removeSurrounding("[", "]")
}
}
.invoke()
val attemptTime = System.currentTimeMillis()
runCatching {
val pingStats =
settings.tunnelPingTimeoutSeconds?.let {
networkUtils.pingWithStats(
host,
settings.tunnelPingAttempts,
it.toMillis(),
)
}
?: networkUtils.pingWithStats(
host,
settings.tunnelPingAttempts,
)
updates[peer.publicKey] =
previousState.copy(
transmitted = pingStats.transmitted,
received = pingStats.received,
packetLoss = pingStats.packetLoss,
rttMin = pingStats.rttMin,
rttMax = pingStats.rttMax,
rttAvg = pingStats.rttAvg,
rttStddev = pingStats.rttStddev,
isReachable = pingStats.isReachable,
failureReason =
if (pingStats.isReachable) null
else FailureReason.PingFailed,
lastSuccessfulPingMillis =
pingStats.lastSuccessfulPingMillis
?: previousState.lastSuccessfulPingMillis,
pingTarget = host,
lastPingAttemptMillis = attemptTime,
)
Timber.d(
"Ping completed for peer ${peer.publicKey.toBase64().substring(0, 5)}.. to host $host with stats: $pingStats"
)
}
.onFailure {
Timber.e(
it,
"Ping failed for peer ${peer.publicKey} in ${tunnelConf.tunName} to host $host",
)
updates[peer.publicKey] =
previousState.copy(
isReachable = false,
failureReason = FailureReason.PingFailed,
pingTarget = host,
lastPingAttemptMillis = attemptTime,
)
}
}
if (updates.isNotEmpty()) {
pingStatsFlow.update { updates }
tunnelManager.updateTunnelStatus(tunnelConf, null, null, updates)
}
}
// Wait for the tunnel to be fully active
tunStateFlow.filter { state -> state.status == TunnelStatus.Up }.first()
// small delay to make sure tunnel is fully up before we actively monitor
delay(3_000L)
while (isActive) {
if (isNetworkConnected.value) {
performPing()
} else {
pingStatsFlow.update { current ->
current.mapValues { entry ->
entry.value.copy(
isReachable = false,
failureReason = FailureReason.NoConnectivity,
lastPingAttemptMillis = System.currentTimeMillis(),
)
}
}
tunnelManager.updateTunnelStatus(
tunnelConf,
null,
null,
pingStatsFlow.value,
)
}
delay(settings.tunnelPingIntervalSeconds.toMillis())
}
}
}
private suspend fun startWgStatsPoll(tunnelConf: TunnelConf) = coroutineScope {
while (isActive) {
val stats = tunnelManager.getStatistics(tunnelConf)
tunnelManager.updateTunnelStatus(tunnelConf, null, stats, null)
delay(STATS_DELAY)
}
}
companion object {
const val CLOUDFLARE_IPV6_IP = "2606:4700:4700::1111"
const val CLOUDFLARE_IPV4_IP = "1.1.1.1"
const val STATS_DELAY = 1_000L
const val KEEPALIVE_RESPONSE_TEXT = "Receiving keepalive packet"
const val HANDSHAKE_RESPONSE_TEXT = "Received handshake response"
const val HANDSHAKE_INIT_FAILED_TEXT = "Failed to send handshake initiation: write udp"
const val DATA_PACKET_FAILED_TEXT = "Failed to send data packets"
const val HANDSHAKE_NOT_COMPLETED_TEXT =
"Handshake did not complete after 5 seconds, retrying"
}
}
@@ -1,17 +1,14 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendError
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.events.BackendError
import com.zaneschepke.wireguardautotunnel.domain.events.BackendMessage
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.PingState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import org.amnezia.awg.crypto.Key
interface TunnelProvider {
/** Starts the specified tunnel configuration. */
@@ -26,19 +23,19 @@ interface TunnelProvider {
*/
suspend fun stopTunnel(
tunnelConf: TunnelConf? = null,
reason: TunnelStatus.StopReason = TunnelStatus.StopReason.User,
reason: TunnelStatus.StopReason = TunnelStatus.StopReason.USER,
)
/**
* Bounces (stops and restarts) the specified tunnel.
*
* @param tunnelConf The tunnel to bounce.
* @param reason The reason for bouncing, defaults to User for manual actions. Callers should
* override with specific reasons (e.g., Ping, ConfigChanged) when applicable.
* @param reason The reason for bouncing, defaults to USER for manual actions. Callers should
* override with specific reasons (e.g., PING, CONFIG_CHANGED) when applicable.
*/
suspend fun bounceTunnel(
tunnelConf: TunnelConf,
reason: TunnelStatus.StopReason = TunnelStatus.StopReason.User,
reason: TunnelStatus.StopReason = TunnelStatus.StopReason.USER,
)
fun setBackendState(backendState: BackendState, allowedIps: Collection<String>)
@@ -53,17 +50,9 @@ interface TunnelProvider {
val errorEvents: SharedFlow<Pair<TunnelConf, BackendError>>
val messageEvents: SharedFlow<Pair<TunnelConf, BackendMessage>>
val bouncingTunnelIds: ConcurrentHashMap<Int, TunnelStatus.StopReason>
fun hasVpnPermission(): Boolean
suspend fun updateTunnelStatus(
tunnelConf: TunnelConf,
status: TunnelStatus? = null,
stats: TunnelStatistics? = null,
pingStates: Map<Key, PingState>? = null,
handshakeSuccessLogs: Boolean? = null,
)
suspend fun updateTunnelStatistics(tunnel: TunnelConf)
}
@@ -1,9 +1,9 @@
package com.zaneschepke.wireguardautotunnel.core.tunnel
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.di.ApplicationScope
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.events.BackendError
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.state.AmneziaStatistics
@@ -23,7 +23,7 @@ import timber.log.Timber
class UserspaceTunnel
@Inject
constructor(
applicationScope: CoroutineScope,
@ApplicationScope private val applicationScope: CoroutineScope,
val serviceManager: ServiceManager,
val appDataRepository: AppDataRepository,
private val backend: Backend,
@@ -40,9 +40,6 @@ constructor(
} catch (e: BackendException) {
Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}")
throw e.toBackendError()
} catch (e: IllegalArgumentException) {
Timber.e(e, "Failed to start up backend for tunnel ${tunnel.name}")
throw BackendError.Config
}
}
@@ -2,7 +2,11 @@ package com.zaneschepke.wireguardautotunnel.core.worker
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.*
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.di.IoDispatcher
@@ -1,6 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data
import androidx.room.*
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.dao.SettingsDao
import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
@@ -9,7 +13,7 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig
@Database(
entities = [Settings::class, TunnelConfig::class],
version = 19,
version = 17,
autoMigrations =
[
AutoMigration(from = 1, to = 2),
@@ -28,8 +32,6 @@ import com.zaneschepke.wireguardautotunnel.data.entity.TunnelConfig
AutoMigration(from = 14, to = 15),
AutoMigration(from = 15, to = 16),
AutoMigration(from = 16, to = 17, spec = WifiDetectionMigration::class),
AutoMigration(from = 17, to = 18),
AutoMigration(from = 18, to = 19, spec = PingMigration::class),
],
exportSchema = true,
)
@@ -49,22 +51,3 @@ class RemoveTunnelPauseMigration : AutoMigrationSpec
@DeleteColumn(tableName = "Settings", columnName = "is_wifi_by_shell_enabled")
class WifiDetectionMigration : AutoMigrationSpec
@DeleteColumn.Entries(
DeleteColumn(tableName = "TunnelConfig", columnName = "ping_interval"),
DeleteColumn(tableName = "TunnelConfig", columnName = "ping_cooldown"),
DeleteColumn(tableName = "Settings", columnName = "split_tunnel_apps"),
)
@RenameColumn.Entries(
RenameColumn(
tableName = "TunnelConfig",
fromColumnName = "is_ping_enabled",
toColumnName = "restart_on_ping_failure",
),
RenameColumn(
tableName = "TunnelConfig",
fromColumnName = "ping_ip",
toColumnName = "ping_target",
),
)
class PingMigration : AutoMigrationSpec
@@ -31,7 +31,6 @@ class DataStoreManager(
val theme = stringPreferencesKey("THEME")
val isRemoteControlEnabled = booleanPreferencesKey("IS_REMOTE_CONTROL_ENABLED")
val remoteKey = stringPreferencesKey("REMOTE_KEY")
val showDetailedPingStats = booleanPreferencesKey("SHOW_DETAILED_PING_STATS")
}
// preferences
@@ -6,19 +6,19 @@ import kotlinx.serialization.json.Json
class DatabaseConverters {
@TypeConverter
fun listToString(value: List<String>): String {
fun listToString(value: MutableList<String>): String {
return Json.encodeToString(value)
}
@TypeConverter
fun stringToList(value: String): List<String> {
fun stringToList(value: String): MutableList<String> {
if (value.isBlank() || value.isEmpty()) return mutableListOf()
return try {
Json.decodeFromString<List<String>>(value)
Json.decodeFromString<MutableList<String>>(value)
} catch (e: Exception) {
val list = value.split(",").toMutableList()
val json = listToString(list)
Json.decodeFromString<List<String>>(json)
Json.decodeFromString<MutableList<String>>(json)
}
}
@@ -1,6 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.dao
import androidx.room.*
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.entity.Settings
import kotlinx.coroutines.flow.Flow
@@ -1,6 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.dao
import androidx.room.*
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.entity.TunnelConfig
import com.zaneschepke.wireguardautotunnel.util.extensions.TunnelConfigs
import kotlinx.coroutines.flow.Flow
@@ -42,6 +46,5 @@ interface TunnelConfigDao {
@Query("SELECT * FROM TUNNELCONFIG WHERE is_mobile_data_tunnel=1")
suspend fun findByMobileDataTunnel(): TunnelConfigs
@Query("SELECT * FROM tunnelconfig ORDER BY position")
fun getAllFlow(): Flow<List<TunnelConfig>>
@Query("SELECT * FROM tunnelconfig") fun getAllFlow(): Flow<MutableList<TunnelConfig>>
}
@@ -9,7 +9,6 @@ data class GeneralState(
val expandedTunnelIds: List<Int> = emptyList(),
val isLocalLogsEnabled: Boolean = IS_LOGS_ENABLED_DEFAULT,
val isRemoteControlEnabled: Boolean = IS_REMOTE_CONTROL_ENABLED,
val showDetailedPingStats: Boolean = SHOW_DETAILED_PING_STATS_DEFAULT,
val remoteKey: String? = null,
val locale: String? = null,
val theme: Theme = Theme.AUTOMATIC,
@@ -21,6 +20,5 @@ data class GeneralState(
const val PIN_LOCK_ENABLED_DEFAULT = false
const val IS_LOGS_ENABLED_DEFAULT = false
const val IS_REMOTE_CONTROL_ENABLED = false
const val SHOW_DETAILED_PING_STATS_DEFAULT = false
}
}
@@ -10,7 +10,8 @@ data class Settings(
@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: List<String> = emptyList(),
@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,
@@ -44,15 +45,12 @@ data class Settings(
val isDisableKillSwitchOnTrustedEnabled: Boolean = false,
@ColumnInfo(name = "is_tunnel_on_unsecure_enabled", defaultValue = "false")
val isTunnelOnUnsecureEnabled: Boolean = false,
@ColumnInfo(name = "split_tunnel_apps", defaultValue = "")
val splitTunnelApps: MutableList<String> = mutableListOf(),
@ColumnInfo(name = "wifi_detection_method", defaultValue = "0")
val wifiDetectionMethod: WifiDetectionMethod = WifiDetectionMethod.fromValue(0),
@ColumnInfo(name = "is_ping_monitoring_enabled", defaultValue = "true")
val isPingMonitoringEnabled: Boolean = true,
@ColumnInfo(name = "tunnel_ping_interval_sec", defaultValue = "30")
val tunnelPingIntervalSeconds: Int = 30,
@ColumnInfo(name = "tunnel_ping_attempts", defaultValue = "3") val tunnelPingAttempts: Int = 3,
@ColumnInfo(name = "tunnel_ping_timeout_sec") val tunnelPingTimeoutSeconds: Int? = null,
) {
enum class WifiDetectionMethod(val value: Int) {
DEFAULT(0),
LEGACY(1),
@@ -11,23 +11,22 @@ data class TunnelConfig(
@ColumnInfo(name = "name") val name: String,
@ColumnInfo(name = "wg_quick") val wgQuick: String,
@ColumnInfo(name = "tunnel_networks", defaultValue = "")
val tunnelNetworks: List<String> = listOf(),
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,
@ColumnInfo(name = "am_quick", defaultValue = "") val amQuick: String = AM_QUICK_DEFAULT,
@ColumnInfo(name = "is_Active", defaultValue = "false") val isActive: Boolean = false,
@ColumnInfo(name = "restart_on_ping_failure", defaultValue = "false")
val restartOnPingFailure: Boolean = false,
@ColumnInfo(name = "ping_target", defaultValue = "null") var pingTarget: String? = null,
@ColumnInfo(name = "is_ping_enabled", defaultValue = "false")
val isPingEnabled: Boolean = false,
@ColumnInfo(name = "ping_interval", defaultValue = "null") val pingInterval: Long? = null,
@ColumnInfo(name = "ping_cooldown", defaultValue = "null") val pingCooldown: Long? = null,
@ColumnInfo(name = "ping_ip", defaultValue = "null") var pingIp: String? = null,
@ColumnInfo(name = "is_ethernet_tunnel", defaultValue = "false")
val isEthernetTunnel: Boolean = false,
var isEthernetTunnel: Boolean = false,
@ColumnInfo(name = "is_ipv4_preferred", defaultValue = "true")
val isIpv4Preferred: Boolean = true,
@ColumnInfo(name = "position", defaultValue = "0") val position: Int = 0,
@ColumnInfo(name = "auto_tunnel_apps", defaultValue = "[]")
val autoTunnelApps: List<String> = listOf(),
var isIpv4Preferred: Boolean = true,
) {
companion object {
@@ -13,7 +13,6 @@ object GeneralStateMapper {
expandedTunnelIds,
isLocalLogsEnabled,
isRemoteControlEnabled,
showDetailedPingStats,
remoteKey,
locale,
theme,
@@ -29,7 +28,6 @@ object GeneralStateMapper {
expandedTunnelIds,
isLocalLogsEnabled,
isRemoteControlEnabled,
showDetailedPingStats,
remoteKey,
locale,
theme,
@@ -2,6 +2,7 @@ package com.zaneschepke.wireguardautotunnel.data.mapper
import com.zaneschepke.wireguardautotunnel.data.entity.GitHubRelease
import com.zaneschepke.wireguardautotunnel.domain.model.AppUpdate
import kotlin.collections.firstOrNull
object GitHubReleaseMapper {
fun toAppUpdate(gitHubRelease: GitHubRelease, newVersion: String): AppUpdate {
@@ -28,13 +28,11 @@ object SettingsMapper {
debounceDelaySeconds = settings.debounceDelaySeconds,
isDisableKillSwitchOnTrustedEnabled = settings.isDisableKillSwitchOnTrustedEnabled,
isTunnelOnUnsecureEnabled = settings.isTunnelOnUnsecureEnabled,
splitTunnelApps = settings.splitTunnelApps,
wifiDetectionMethod =
AndroidNetworkMonitor.WifiDetectionMethod.fromValue(
settings.wifiDetectionMethod.value
),
tunnelPingIntervalSeconds = settings.tunnelPingIntervalSeconds,
tunnelPingAttempts = settings.tunnelPingAttempts,
tunnelPingTimeoutSeconds = settings.tunnelPingTimeoutSeconds,
)
}
@@ -43,7 +41,7 @@ object SettingsMapper {
id = appSettings.id,
isAutoTunnelEnabled = appSettings.isAutoTunnelEnabled,
isTunnelOnMobileDataEnabled = appSettings.isTunnelOnMobileDataEnabled,
trustedNetworkSSIDs = appSettings.trustedNetworkSSIDs,
trustedNetworkSSIDs = appSettings.trustedNetworkSSIDs.toMutableList(),
isAlwaysOnVpnEnabled = appSettings.isAlwaysOnVpnEnabled,
isTunnelOnEthernetEnabled = appSettings.isTunnelOnEthernetEnabled,
isShortcutsEnabled = appSettings.isShortcutsEnabled,
@@ -61,11 +59,9 @@ object SettingsMapper {
debounceDelaySeconds = appSettings.debounceDelaySeconds,
isDisableKillSwitchOnTrustedEnabled = appSettings.isDisableKillSwitchOnTrustedEnabled,
isTunnelOnUnsecureEnabled = appSettings.isTunnelOnUnsecureEnabled,
splitTunnelApps = appSettings.splitTunnelApps.toMutableList(),
wifiDetectionMethod =
Settings.WifiDetectionMethod.fromValue(appSettings.wifiDetectionMethod.value),
tunnelPingIntervalSeconds = appSettings.tunnelPingIntervalSeconds,
tunnelPingAttempts = appSettings.tunnelPingAttempts,
tunnelPingTimeoutSeconds = appSettings.tunnelPingTimeoutSeconds,
)
}
}
@@ -15,11 +15,12 @@ object TunnelConfigMapper {
isPrimaryTunnel,
amQuick,
isActive,
pingTarget,
restartOnPingFailure,
isPingEnabled,
pingInterval,
pingCooldown,
pingIp,
isEthernetTunnel,
isIpv4Preferred,
position,
)
}
}
@@ -30,16 +31,17 @@ object TunnelConfigMapper {
id,
tunName,
wgQuick,
tunnelNetworks,
tunnelNetworks.toMutableList(),
isMobileDataTunnel,
isPrimaryTunnel,
amQuick,
isActive,
restartOnPingFailure,
pingTarget,
isPingEnabled,
pingInterval,
pingCooldown,
pingIp,
isEthernetTunnel,
isIpv4Preferred,
position,
)
}
}
@@ -1,10 +1,10 @@
package com.zaneschepke.wireguardautotunnel.data.network
import io.ktor.client.*
import io.ktor.client.engine.okhttp.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
object KtorClient {
@@ -1,11 +1,11 @@
package com.zaneschepke.wireguardautotunnel.data.network
import com.zaneschepke.wireguardautotunnel.data.entity.GitHubRelease
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.*
import io.ktor.client.request.*
import io.ktor.http.*
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.request.get
import io.ktor.http.HttpStatusCode
class KtorGitHubApi(private val client: HttpClient) : GitHubApi {
override suspend fun getLatestRelease(owner: String, repo: String): Result<GitHubRelease> {
@@ -119,15 +119,6 @@ class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager
return dataStoreManager.getFromStore(DataStoreManager.remoteKey)
}
override suspend fun setShowDetailedPingStats(showDetailedPing: Boolean) {
dataStoreManager.saveToDataStore(DataStoreManager.showDetailedPingStats, showDetailedPing)
}
override suspend fun getShowDetailedPing(): Boolean {
return dataStoreManager.getFromStore(DataStoreManager.showDetailedPingStats)
?: GeneralState.SHOW_DETAILED_PING_STATS_DEFAULT
}
override val flow: Flow<AppState> =
dataStoreManager.preferencesFlow
.map { prefs ->
@@ -153,9 +144,6 @@ class DataStoreAppStateRepository(private val dataStoreManager: DataStoreManager
isRemoteControlEnabled =
pref[DataStoreManager.isRemoteControlEnabled]
?: GeneralState.IS_REMOTE_CONTROL_ENABLED,
showDetailedPingStats =
pref[DataStoreManager.showDetailedPingStats]
?: GeneralState.SHOW_DETAILED_PING_STATS_DEFAULT,
remoteKey = pref[DataStoreManager.remoteKey],
locale = pref[DataStoreManager.locale],
theme = getTheme(),
@@ -9,11 +9,13 @@ import com.zaneschepke.wireguardautotunnel.domain.model.AppUpdate
import com.zaneschepke.wireguardautotunnel.domain.repository.UpdateRepository
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.utils.io.*
import io.ktor.client.HttpClient
import io.ktor.client.request.get
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.contentLength
import io.ktor.utils.io.ByteReadChannel
import io.ktor.utils.io.readAvailable
import java.io.File
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
@@ -4,12 +4,9 @@ import android.content.Context
import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.logcatter.LogcatReader
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.WireGuardNotification
import com.zaneschepke.wireguardautotunnel.core.shortcut.DynamicShortcutManager
import com.zaneschepke.wireguardautotunnel.core.shortcut.ShortcutManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -50,19 +47,4 @@ class AppModule {
): ShortcutManager {
return DynamicShortcutManager(context, ioDispatcher)
}
@Singleton
@Provides
fun provideNetworkUtils(@IoDispatcher ioDispatcher: CoroutineDispatcher): NetworkUtils {
return NetworkUtils(ioDispatcher)
}
@Singleton
@Provides
fun provideNotificationMonitor(
tunnelManager: TunnelManager,
notificationManager: NotificationManager,
): NotificationMonitor {
return NotificationMonitor(tunnelManager, notificationManager)
}
}
@@ -11,14 +11,22 @@ import com.zaneschepke.wireguardautotunnel.data.dao.TunnelConfigDao
import com.zaneschepke.wireguardautotunnel.data.network.GitHubApi
import com.zaneschepke.wireguardautotunnel.data.network.KtorClient
import com.zaneschepke.wireguardautotunnel.data.network.KtorGitHubApi
import com.zaneschepke.wireguardautotunnel.data.repository.*
import com.zaneschepke.wireguardautotunnel.domain.repository.*
import com.zaneschepke.wireguardautotunnel.data.repository.AppDataRoomRepository
import com.zaneschepke.wireguardautotunnel.data.repository.DataStoreAppStateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.GitHubUpdateRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomSettingsRepository
import com.zaneschepke.wireguardautotunnel.data.repository.RoomTunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppStateRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.TunnelRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.UpdateRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import io.ktor.client.*
import io.ktor.client.HttpClient
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
@@ -4,15 +4,15 @@ import android.content.Context
import com.wireguard.android.backend.WgQuickBackend
import com.wireguard.android.util.RootShell
import com.wireguard.android.util.ToolsInstaller
import com.zaneschepke.logcatter.LogReader
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor
import com.zaneschepke.networkmonitor.NetworkMonitor
import com.zaneschepke.wireguardautotunnel.core.notification.NotificationManager
import com.zaneschepke.wireguardautotunnel.core.service.ServiceManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.*
import com.zaneschepke.wireguardautotunnel.core.tunnel.KernelTunnel
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelManager
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelProvider
import com.zaneschepke.wireguardautotunnel.core.tunnel.UserspaceTunnel
import com.zaneschepke.wireguardautotunnel.domain.repository.AppDataRepository
import com.zaneschepke.wireguardautotunnel.domain.repository.AppSettingRepository
import com.zaneschepke.wireguardautotunnel.util.network.NetworkUtils
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -99,7 +99,6 @@ class TunnelModule {
appDataRepository: AppDataRepository,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
@ApplicationScope applicationScope: CoroutineScope,
notificationManager: NotificationManager,
): TunnelManager {
return TunnelManager(
kernelTunnel,
@@ -151,23 +150,4 @@ class TunnelModule {
appDataRepository,
)
}
@Singleton
@Provides
fun provideTunnelMonitor(
@ApplicationContext context: Context,
tunnelManager: TunnelManager,
networkMonitor: NetworkMonitor,
networkUtils: NetworkUtils,
logReader: LogReader,
appDataRepository: AppDataRepository,
): TunnelMonitor {
return TunnelMonitor(
appDataRepository,
tunnelManager,
networkMonitor,
networkUtils,
logReader,
)
}
}
@@ -1,7 +1,6 @@
package com.zaneschepke.wireguardautotunnel.domain.events
package com.zaneschepke.wireguardautotunnel.domain.enums
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.StringValue
sealed class BackendError : Exception() {
data object DNS : BackendError()
@@ -12,37 +11,23 @@ sealed class BackendError : Exception() {
data object KernelModuleName : BackendError()
data object InvalidConfig : BackendError()
data object NotAuthorized : BackendError()
data object ServiceNotRunning : BackendError()
data object Unknown : BackendError()
data object TunnelNameTooLong : BackendError()
data class BounceFailed(val error: BackendError) : BackendError()
fun toStringRes() =
when (this) {
Config -> R.string.config_error
DNS -> R.string.dns_resolve_error
InvalidConfig -> R.string.invalid_config_error
KernelModuleName -> R.string.kernel_name_error
NotAuthorized,
Unauthorized -> R.string.auth_error
ServiceNotRunning -> R.string.service_running_error
Unknown -> R.string.unknown_error
TunnelNameTooLong -> R.string.error_tunnel_name
is BounceFailed -> R.string.bounce_failed_template
}
fun toStringValue(): StringValue {
return when (val backendError = this) {
is BounceFailed ->
StringValue.StringResource(
backendError.toStringRes(),
backendError.error.toStringRes(),
)
else -> StringValue.StringResource(backendError.toStringRes())
}
}
}
@@ -10,12 +10,10 @@ sealed class TunnelStatus {
data object Starting : TunnelStatus()
sealed class StopReason {
data object User : StopReason()
data class Ping(val previouslyResolvedEndpoints: Map<String, String?>) : StopReason()
data object ConfigChanged : StopReason()
enum class StopReason {
USER,
PING,
CONFIG_CHANGED,
}
fun isDown(): Boolean {
@@ -5,14 +5,7 @@ import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
sealed class AutoTunnelEvent {
data class Start(val tunnelConf: TunnelConf? = null) : AutoTunnelEvent()
data class Bounce(val configsPeerKeyResolvedMap: List<Pair<TunnelConf, Map<String, String?>>>) :
AutoTunnelEvent()
data object Stop : AutoTunnelEvent()
data object DoNothing : AutoTunnelEvent()
data class StartKillSwitch(val allowedIps: List<String>) : AutoTunnelEvent()
data object StopKillSwitch : AutoTunnelEvent()
}
@@ -1,19 +0,0 @@
package com.zaneschepke.wireguardautotunnel.domain.events
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.util.StringValue
sealed class BackendMessage {
data object BounceSuccess : BackendMessage()
data object BounceRecovery : BackendMessage()
fun toStringRes() =
when (this) {
BounceRecovery -> R.string.pinger_bounce_recovery
BounceSuccess -> R.string.pinger_bounce_successful
}
fun toStringValue() = StringValue.StringResource(this.toStringRes())
}
@@ -24,12 +24,14 @@ data class AppSettings(
val debounceDelaySeconds: Int = 3,
val isDisableKillSwitchOnTrustedEnabled: Boolean = false,
val isTunnelOnUnsecureEnabled: Boolean = false,
val splitTunnelApps: List<String> = emptyList(),
val wifiDetectionMethod: AndroidNetworkMonitor.WifiDetectionMethod =
AndroidNetworkMonitor.WifiDetectionMethod.DEFAULT,
val tunnelPingIntervalSeconds: Int = 30,
val tunnelPingAttempts: Int = 3,
val tunnelPingTimeoutSeconds: Int? = null,
) {
fun debounceDelayMillis(): Long {
return debounceDelaySeconds * 1000L
}
fun toAutoTunnelStateString(): String {
return """
TunnelOnWifi: $isTunnelOnWifiEnabled
@@ -9,7 +9,6 @@ data class AppState(
val expandedTunnelIds: List<Int>,
val isLocalLogsEnabled: Boolean,
val isRemoteControlEnabled: Boolean,
val showDetailedPingStats: Boolean,
val remoteKey: String?,
val locale: String?,
val theme: Theme,
@@ -2,9 +2,14 @@ package com.zaneschepke.wireguardautotunnel.domain.model
import com.wireguard.android.backend.Tunnel
import com.wireguard.config.Config
import com.zaneschepke.wireguardautotunnel.util.Constants
import com.zaneschepke.wireguardautotunnel.util.extensions.*
import java.io.InputStream
import java.net.InetAddress
import java.nio.charset.StandardCharsets
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.withContext
import timber.log.Timber
data class TunnelConf(
val id: Int = 0,
@@ -15,16 +20,15 @@ data class TunnelConf(
val isPrimaryTunnel: Boolean = false,
val amQuick: String,
val isActive: Boolean = false,
val pingTarget: String? = null,
val restartOnPingFailure: Boolean = false,
val isPingEnabled: Boolean = false,
val pingInterval: Long? = null,
val pingCooldown: Long? = null,
val pingIp: String? = null,
val isEthernetTunnel: Boolean = false,
val isIpv4Preferred: Boolean = true,
val position: Int = 0,
@Transient private var stateChangeCallback: ((Any) -> Unit)? = null,
) : Tunnel, org.amnezia.awg.backend.Tunnel {
val isNameKernelCompatible: Boolean = (name.length <= 15)
fun setStateChangeCallback(callback: (Any) -> Unit) {
stateChangeCallback = callback
}
@@ -39,8 +43,10 @@ data class TunnelConf(
isPrimaryTunnel == other.isPrimaryTunnel &&
isMobileDataTunnel == other.isMobileDataTunnel &&
isEthernetTunnel == other.isEthernetTunnel &&
pingTarget == other.pingTarget &&
restartOnPingFailure == other.restartOnPingFailure &&
isPingEnabled == other.isPingEnabled &&
pingIp == other.pingIp &&
pingCooldown == other.pingCooldown &&
pingInterval == other.pingInterval &&
tunnelNetworks == other.tunnelNetworks &&
isIpv4Preferred == other.isIpv4Preferred
}
@@ -66,8 +72,10 @@ data class TunnelConf(
isPrimaryTunnel: Boolean = this.isPrimaryTunnel,
amQuick: String = this.amQuick,
isActive: Boolean = this.isActive,
restartOnPingFailure: Boolean = this.restartOnPingFailure,
pingIp: String? = this.pingTarget,
isPingEnabled: Boolean = this.isPingEnabled,
pingInterval: Long? = this.pingInterval,
pingCooldown: Long? = this.pingCooldown,
pingIp: String? = this.pingIp,
isEthernetTunnel: Boolean = this.isEthernetTunnel,
isIpv4Preferred: Boolean = this.isIpv4Preferred,
): TunnelConf {
@@ -80,11 +88,12 @@ data class TunnelConf(
isPrimaryTunnel,
amQuick,
isActive,
isPingEnabled,
pingInterval,
pingCooldown,
pingIp,
restartOnPingFailure,
isEthernetTunnel,
isIpv4Preferred,
position,
)
.apply { stateChangeCallback = this@TunnelConf.stateChangeCallback }
}
@@ -125,6 +134,21 @@ data class TunnelConf(
return tunnelName
}
suspend fun isTunnelPingable(context: CoroutineContext): Boolean {
return withContext(context) {
val config = toWgConfig()
if (pingIp != null) {
return@withContext InetAddress.getByName(pingIp)
.isReachable(Constants.PING_TIMEOUT.toInt())
.also { Timber.i("Ping reachable $pingIp: $it") }
}
config.peers
.map { peer -> peer.isReachable() }
.all { true }
.also { Timber.i("Ping of all peers reachable: $it") }
}
}
companion object {
fun configFromWgQuick(wgQuick: String): Config {
val inputStream: InputStream = wgQuick.byteInputStream()
@@ -41,9 +41,5 @@ interface AppStateRepository {
suspend fun getRemoteKey(): String?
suspend fun setShowDetailedPingStats(showDetailedPing: Boolean)
suspend fun getShowDetailedPing(): Boolean
val flow: Flow<AppState>
}
@@ -1,8 +1,10 @@
package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.wireguardautotunnel.core.service.autotunnel.StateChange
import com.zaneschepke.wireguardautotunnel.core.tunnel.allDown
import com.zaneschepke.wireguardautotunnel.core.tunnel.hasActive
import com.zaneschepke.wireguardautotunnel.core.tunnel.isUp
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent
import com.zaneschepke.wireguardautotunnel.domain.events.AutoTunnelEvent.*
import com.zaneschepke.wireguardautotunnel.domain.events.KillSwitchEvent
import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.util.extensions.isMatchingToWildcardList
@@ -14,68 +16,6 @@ data class AutoTunnelState(
val tunnels: List<TunnelConf> = emptyList(),
) {
fun determineAutoTunnelEvent(stateChange: StateChange): AutoTunnelEvent {
when (val change = stateChange) {
is StateChange.NetworkChange,
is StateChange.SettingsChange -> {
// Compute desired tunnel based on network conditions
var desiredTunnel: TunnelConf? = null
if (networkState.isEthernetConnected && settings.isTunnelOnEthernetEnabled) {
desiredTunnel = preferredEthernetTunnel()
} else if (isMobileDataActive() && settings.isTunnelOnMobileDataEnabled) {
desiredTunnel = preferredMobileDataTunnel()
} else if (
isWifiActive() && settings.isTunnelOnWifiEnabled && !isCurrentSSIDTrusted()
) {
desiredTunnel = preferredWifiTunnel()
}
// Override for no connectivity if enabled
if (isNoConnectivity() && settings.isStopOnNoInternetEnabled) {
desiredTunnel = null
}
// Determine current active tunnel (assuming only one can be active)
val currentTunnel = activeTunnels.entries.firstOrNull()?.key
// Handle tunnel start/stop/change
if (desiredTunnel != null) {
if (currentTunnel != desiredTunnel) {
// Start or switch to the desired tunnel (overrides any kill switch)
return Start(desiredTunnel)
}
// If already active and matching, fall through to kill switch check (though
// unlikely needed)
} else {
if (currentTunnel != null) {
// Stop the active tunnel (then next emission can handle kill switch if
// needed)
return AutoTunnelEvent.Stop
}
}
// Handle kill switch only if no user tunnel is or will be active
if (stopKillSwitchOnTrusted()) {
return AutoTunnelEvent.StopKillSwitch
}
if (startKillSwitch()) {
val allowedIps =
if (settings.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS
else emptyList()
return StartKillSwitch(allowedIps)
}
}
is StateChange.MonitoringChange -> {
val bounceTunnels = bounceOnPingFailed()
if (bounceTunnels.isNotEmpty()) {
return Bounce(bounceTunnels)
}
}
is StateChange.ActiveTunnelsChange -> Unit
}
return DoNothing
}
// also need to check for Wi-Fi state as there is some overlap when they are both connected
private fun isMobileDataActive(): Boolean {
return !networkState.isEthernetConnected &&
@@ -83,22 +23,32 @@ data class AutoTunnelState(
networkState.isMobileDataConnected
}
private fun isMobileTunnelDataChangeNeeded(): Boolean {
val preferredTunnel = preferredMobileDataTunnel()
return preferredTunnel != null &&
activeTunnels.isNotEmpty() &&
!activeTunnels.isUp(preferredTunnel)
}
private fun isEthernetTunnelChangeNeeded(): Boolean {
val preferredTunnel = preferredEthernetTunnel()
return preferredTunnel != null &&
activeTunnels.isNotEmpty() &&
!activeTunnels.isUp(preferredTunnel)
}
private fun preferredMobileDataTunnel(): TunnelConf? {
return tunnels.firstOrNull { it.isMobileDataTunnel }
?: tunnels.firstOrNull { it.isPrimaryTunnel }
?: tunnels.firstOrNull()
}
private fun preferredEthernetTunnel(): TunnelConf? {
return tunnels.firstOrNull { it.isEthernetTunnel }
?: tunnels.firstOrNull { it.isPrimaryTunnel }
?: tunnels.firstOrNull()
}
private fun preferredWifiTunnel(): TunnelConf? {
return getTunnelWithMatchingTunnelNetwork()
?: tunnels.firstOrNull { it.isPrimaryTunnel }
?: tunnels.firstOrNull()
return getTunnelWithMatchingTunnelNetwork() ?: tunnels.firstOrNull { it.isPrimaryTunnel }
}
// ignore cellular state as there is overlap where it may still be active, but not prioritized
@@ -106,6 +56,19 @@ data class AutoTunnelState(
return !networkState.isEthernetConnected && networkState.isWifiConnected
}
private fun startOnEthernet(): Boolean {
return networkState.isEthernetConnected &&
settings.isTunnelOnEthernetEnabled &&
activeTunnels.allDown()
}
private fun stopOnEthernet(): Boolean {
return networkState.isEthernetConnected &&
!settings.isTunnelOnEthernetEnabled &&
activeTunnels.hasActive()
}
// TODO test removed kill switch state check
private fun stopKillSwitchOnTrusted(): Boolean {
return networkState.isWifiConnected &&
settings.isVpnKillSwitchEnabled &&
@@ -113,6 +76,7 @@ data class AutoTunnelState(
isCurrentSSIDTrusted()
}
// TODO test, removed kill switch state check
private fun startKillSwitch(): Boolean {
return settings.isVpnKillSwitchEnabled &&
(!settings.isDisableKillSwitchOnTrustedEnabled || !isCurrentSSIDTrusted())
@@ -124,21 +88,93 @@ data class AutoTunnelState(
!networkState.isMobileDataConnected
}
private fun bounceOnPingFailed(): List<Pair<TunnelConf, Map<String, String?>>> {
return activeTunnels.entries
.filter { (tunnel, state) ->
tunnel.restartOnPingFailure &&
(state.pingStates?.any { (key, pingState) ->
pingState.failureReason == FailureReason.PingFailed
} ?: false)
}
.map { (tunnel, state) ->
val peerMap =
(state.statistics?.getPeers()?.associate { peerKey ->
peerKey.toBase64() to state.statistics.peerStats(peerKey)?.resolvedEndpoint
} ?: emptyMap())
Pair(tunnel, peerMap)
private fun stopOnMobileData(): Boolean {
return isMobileDataActive() &&
!settings.isTunnelOnMobileDataEnabled &&
activeTunnels.hasActive()
}
private fun startOnMobileData(): Boolean {
return isMobileDataActive() &&
settings.isTunnelOnMobileDataEnabled &&
activeTunnels.allDown()
}
private fun changeOnMobileData(): Boolean {
return isMobileDataActive() &&
settings.isTunnelOnMobileDataEnabled &&
isMobileTunnelDataChangeNeeded()
}
private fun changeOnEthernet(): Boolean {
return networkState.isEthernetConnected &&
settings.isTunnelOnEthernetEnabled &&
isEthernetTunnelChangeNeeded()
}
private fun stopOnWifi(): Boolean {
return isWifiActive() && !settings.isTunnelOnWifiEnabled && activeTunnels.hasActive()
}
private fun stopOnTrustedWifi(): Boolean {
return isWifiActive() &&
settings.isTunnelOnWifiEnabled &&
activeTunnels.hasActive() &&
isCurrentSSIDTrusted()
}
private fun startOnUntrustedWifi(): Boolean {
return isWifiActive() &&
settings.isTunnelOnWifiEnabled &&
activeTunnels.allDown() &&
!isCurrentSSIDTrusted()
}
private fun changeOnUntrustedWifi(): Boolean {
return isWifiActive() &&
settings.isTunnelOnWifiEnabled &&
activeTunnels.hasActive() &&
!isCurrentSSIDTrusted() &&
!isWifiTunnelPreferred()
}
private fun isWifiTunnelPreferred(): Boolean {
val preferred = preferredWifiTunnel()
return preferred?.let { activeTunnels.isUp(it) } ?: true
}
fun asAutoTunnelEvent(): AutoTunnelEvent {
return when {
// ethernet scenarios
stopOnEthernet() -> AutoTunnelEvent.Stop
startOnEthernet() || changeOnEthernet() ->
AutoTunnelEvent.Start(preferredEthernetTunnel())
// mobile data scenarios
stopOnMobileData() -> AutoTunnelEvent.Stop
startOnMobileData() || changeOnMobileData() ->
AutoTunnelEvent.Start(preferredMobileDataTunnel())
// wifi scenarios
stopOnWifi() -> AutoTunnelEvent.Stop
stopOnTrustedWifi() -> AutoTunnelEvent.Stop
startOnUntrustedWifi() || changeOnUntrustedWifi() ->
AutoTunnelEvent.Start(preferredWifiTunnel())
// no connectivity
isNoConnectivity() && settings.isStopOnNoInternetEnabled -> AutoTunnelEvent.Stop
else -> AutoTunnelEvent.DoNothing
}
}
fun asKillSwitchEvent(): KillSwitchEvent {
return when {
stopKillSwitchOnTrusted() -> KillSwitchEvent.Stop
startKillSwitch() -> {
val allowedIps =
if (settings.isLanOnKillSwitchEnabled) TunnelConf.LAN_BYPASS_ALLOWED_IPS
else emptyList()
KillSwitchEvent.Start(allowedIps)
}
else -> KillSwitchEvent.DoNothing
}
}
private fun isCurrentSSIDTrusted(): Boolean {
@@ -1,38 +1,12 @@
package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.networkmonitor.ConnectivityState
import com.zaneschepke.networkmonitor.util.WifiSecurityType
data class NetworkState(
val isWifiConnected: Boolean = false,
val isMobileDataConnected: Boolean = false,
val isEthernetConnected: Boolean = false,
val wifiName: String? = null,
val isWifiSecure: Boolean? = null,
val locationServicesEnabled: Boolean? = null,
val locationPermissionGranted: Boolean? = null,
) {
fun hasNoCapabilities(): Boolean {
return !isWifiConnected && !isMobileDataConnected && !isEthernetConnected
}
companion object {
fun from(connectivityState: ConnectivityState): NetworkState {
return NetworkState(
isWifiSecure =
when (connectivityState.wifiState.securityType) {
WifiSecurityType.OPEN,
WifiSecurityType.UNKNOWN -> false
null -> null
else -> true
},
isWifiConnected = connectivityState.wifiState.connected,
isMobileDataConnected = connectivityState.cellularConnected,
isEthernetConnected = connectivityState.ethernetConnected,
wifiName = connectivityState.wifiState.ssid,
locationPermissionGranted = connectivityState.wifiState.locationPermissionsGranted,
locationServicesEnabled = connectivityState.wifiState.locationServicesEnabled,
)
}
}
}
@@ -1,26 +0,0 @@
package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.wireguardautotunnel.core.tunnel.TunnelMonitor.Companion.CLOUDFLARE_IPV4_IP
enum class FailureReason {
NoConnectivity,
PingFailed,
NoResolvedEndpoint,
Timeout,
Unknown,
}
data class PingState(
val transmitted: Int = 0,
val received: Int = 0,
val packetLoss: Double = 0.0,
val rttMin: Double = 0.0,
val rttMax: Double = 0.0,
val rttAvg: Double = 0.0,
val rttStddev: Double = 0.0,
val isReachable: Boolean = false,
val lastSuccessfulPingMillis: Long? = null,
val lastPingAttemptMillis: Long? = null,
val failureReason: FailureReason? = null,
val pingTarget: String = CLOUDFLARE_IPV4_IP,
)
@@ -2,12 +2,9 @@ package com.zaneschepke.wireguardautotunnel.domain.state
import com.zaneschepke.wireguardautotunnel.domain.enums.BackendState
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import org.amnezia.awg.crypto.Key
data class TunnelState(
val status: TunnelStatus = TunnelStatus.Down,
val backendState: BackendState = BackendState.INACTIVE,
val statistics: TunnelStatistics? = null,
val pingStates: Map<Key, PingState>? = null,
val handshakeSuccessLogs: Boolean? = null,
)
@@ -3,24 +3,13 @@ package com.zaneschepke.wireguardautotunnel.domain.state
import org.amnezia.awg.crypto.Key
abstract class TunnelStatistics {
open class PeerStats(
@JvmRecord
data class PeerStats(
val rxBytes: Long,
val txBytes: Long,
val latestHandshakeEpochMillis: Long,
val resolvedEndpoint: String,
) {
// mimic data class copy
open fun copy(
rxBytes: Long = this.rxBytes,
txBytes: Long = this.txBytes,
latestHandshakeEpochMillis: Long = this.latestHandshakeEpochMillis,
resolvedEndpoint: String = this.resolvedEndpoint,
): PeerStats = PeerStats(rxBytes, txBytes, latestHandshakeEpochMillis, resolvedEndpoint)
// Manual toString: Format like data class
override fun toString(): String =
"PeerStats(rxBytes=$rxBytes, txBytes=$txBytes, latestHandshakeEpochMillis=$latestHandshakeEpochMillis, resolvedEndpoint=$resolvedEndpoint)"
}
)
abstract fun peerStats(peer: Key): PeerStats?
@@ -45,8 +45,4 @@ sealed class Route {
@Serializable data class TunnelAutoTunnel(val id: Int) : Route()
@Serializable data object Logs : Route()
@Serializable data object Sort : Route()
@Serializable data object TunnelMonitoring : Route()
}
@@ -1,4 +1,4 @@
package com.zaneschepke.wireguardautotunnel.ui.common.dropdown
package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
@@ -6,7 +6,11 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.*
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -16,9 +20,9 @@ import com.zaneschepke.wireguardautotunnel.R
@Composable
fun <T> DropdownSelector(
currentValue: T?,
options: List<T?>,
onValueSelected: (T?) -> Unit,
currentValue: T,
options: List<T>,
onValueSelected: (T) -> Unit,
modifier: Modifier = Modifier,
label: @Composable (() -> Unit)? = null,
isExpanded: Boolean = false,
@@ -29,10 +33,7 @@ fun <T> DropdownSelector(
verticalAlignment = Alignment.CenterVertically,
) {
if (label != null) label()
Text(
text = currentValue?.toString() ?: stringResource(R.string._default),
style = MaterialTheme.typography.bodyMedium,
)
Text(text = currentValue.toString(), style = MaterialTheme.typography.bodyMedium)
Icon(Icons.Default.ArrowDropDown, contentDescription = stringResource(R.string.dropdown))
}
DropdownMenu(
@@ -43,20 +44,11 @@ fun <T> DropdownSelector(
onDismissRequest = onDismiss,
) {
options.forEach { option ->
if (option == null) {
return@forEach DropdownMenuItem(
text = { Text(text = stringResource(R.string._default)) },
onClick = {
onValueSelected(null)
onDismiss()
},
)
}
DropdownMenuItem(
text = { Text(text = option.toString()) },
onClick = {
onValueSelected(option)
onDismiss()
onDismiss() // Close dropdown after selection
},
)
}
@@ -3,41 +3,68 @@ package com.zaneschepke.wireguardautotunnel.ui.common
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.indication
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ExpandingRowListItem(
leading: @Composable () -> Unit,
text: String,
onHold: () -> Unit,
onClick: () -> Unit,
onDoubleClick: () -> Unit,
trailing: @Composable () -> Unit,
isSelected: Boolean,
expanded: @Composable () -> Unit,
modifier: Modifier = Modifier,
expanded: (@Composable () -> Unit)?,
) {
val isTv = LocalIsAndroidTV.current
val haptic = LocalHapticFeedback.current
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier =
modifier
.animateContentSize()
Modifier.animateContentSize()
.clip(RoundedCornerShape(8.dp))
.background(
if (isSelected) MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
else Color.Transparent
)
.then(
if (!isTv) {
Modifier.combinedClickable(
interactionSource = interactionSource,
indication = ripple(),
onClick = onClick,
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onHold()
},
onDoubleClick = onDoubleClick,
)
} else Modifier
)
) {
Column {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).height(48.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
@@ -57,7 +84,7 @@ fun ExpandingRowListItem(
}
trailing()
}
expanded()
expanded?.invoke()
}
}
}
@@ -1,6 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.common.animation
import androidx.compose.animation.core.*
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
@@ -1,74 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.banner
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.theme.Straw
@Composable
fun WarningBanner(
title: String,
visible: Boolean,
modifier: Modifier = Modifier,
trailing: (@Composable () -> Unit)? = null,
) {
AnimatedVisibility(visible = visible, enter = expandVertically(), exit = shrinkVertically()) {
Surface(
color = MaterialTheme.colorScheme.secondary,
modifier = modifier.fillMaxWidth().clip(RoundedCornerShape(8.dp)),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(start = 2.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.Start),
modifier = Modifier.weight(4f, false).fillMaxWidth(),
) {
Icon(
Icons.Outlined.Warning,
stringResource(R.string.warning),
Modifier.size(18.dp),
tint = Straw,
)
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement =
Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier = Modifier.fillMaxWidth().weight(1f).padding(start = 6.dp),
) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
trailing?.let {
Box(
contentAlignment = Alignment.CenterEnd,
modifier = Modifier.padding(start = 16.dp),
) {
it()
}
}
}
}
}
}
@@ -3,7 +3,11 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.*
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
@@ -2,23 +2,34 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
@androidx.compose.runtime.Composable
fun IconSurfaceButton(
title: String,
onClick: () -> Unit,
selected: Boolean,
leading: (@Composable () -> Unit)? = null,
leadingIcon: ImageVector? = null,
description: String? = null,
) {
val border: BorderStroke? =
@@ -53,7 +64,15 @@ fun IconSurfaceButton(
modifier =
Modifier.padding(vertical = if (description == null) 10.dp else 0.dp),
) {
leading?.invoke()
leadingIcon?.let {
Icon(
leadingIcon,
leadingIcon.name,
Modifier.size(iconSize),
if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
)
}
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
description?.let {
@@ -2,9 +2,18 @@ package com.zaneschepke.wireguardautotunnel.ui.common.button
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -1,15 +1,13 @@
package com.zaneschepke.wireguardautotunnel.ui.common.button.surface
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.vector.ImageVector
data class SelectionItem(
val leading: (@Composable () -> Unit)? = null,
val leadingIcon: ImageVector? = null,
val trailing: (@Composable () -> Unit)? = null,
val title: (@Composable () -> Unit),
val description: (@Composable () -> Unit)? = null,
val onClick: (() -> Unit)? = null,
val modifier: Modifier = Modifier.height(64.dp),
val height: Int = 64,
)
@@ -5,18 +5,19 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
@Composable
fun SurfaceSelectionGroupButton(items: List<SelectionItem>, modifier: Modifier = Modifier) {
fun SurfaceSelectionGroupButton(items: List<SelectionItem>) {
Card(
modifier = modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
) {
@@ -24,10 +25,9 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>, modifier: Modifier =
Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.fillMaxWidth()
Modifier.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.then(item.onClick?.let { modifier.clickable { it() } } ?: modifier),
.then(item.onClick?.let { Modifier.clickable { it() } } ?: Modifier),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -37,14 +37,21 @@ fun SurfaceSelectionGroupButton(items: List<SelectionItem>, modifier: Modifier =
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(4f, false).fillMaxWidth(),
) {
item.leading?.invoke()
item.leadingIcon?.let { icon ->
Icon(
icon,
icon.name,
modifier = Modifier.size(iconSize),
tint = MaterialTheme.colorScheme.onSurface,
)
}
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement =
Arrangement.spacedBy(2.dp, Alignment.CenterVertically),
modifier =
Modifier.fillMaxWidth()
.padding(start = if (item.leading != null) 16.dp else 0.dp)
.padding(start = if (item.leadingIcon != null) 16.dp else 0.dp)
.weight(1f)
.padding(
vertical = if (item.description == null) 16.dp else 6.dp
@@ -12,7 +12,11 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -28,7 +32,6 @@ fun SubmitConfigurationTextBox(
hint: String,
isErrorValue: (value: String?) -> Boolean,
onSubmit: (value: String) -> Unit,
supportingText: @Composable (() -> Unit)? = null,
keyboardOptions: KeyboardOptions =
KeyboardOptions(capitalization = KeyboardCapitalization.None, imeAction = ImeAction.Done),
) {
@@ -46,7 +49,6 @@ fun SubmitConfigurationTextBox(
value = stateValue,
onValueChange = { stateValue = it },
interactionSource = interactionSource,
supportingText = supportingText,
label = {
Text(
label,
@@ -1,6 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.common.dialog
import androidx.compose.material3.*
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -1,36 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.common.dropdown
import androidx.compose.runtime.*
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
@Composable
fun LabelledNumberDropdown(
title: @Composable () -> Unit,
description: (@Composable () -> Unit)? = null,
leading: @Composable () -> Unit,
onSelected: (Int?) -> Unit,
options: List<Int?>,
currentValue: Int?,
) {
var isDropDownExpanded by remember { mutableStateOf(false) }
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
leading = leading,
title = title,
description = description,
onClick = { isDropDownExpanded = true },
trailing = {
DropdownSelector(
currentValue = currentValue,
options = options,
onValueSelected = { num -> onSelected(num) },
isExpanded = isDropDownExpanded,
onDismiss = { isDropDownExpanded = false },
)
},
)
)
)
}
@@ -1,6 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.common.snackbar
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Info
@@ -10,6 +10,7 @@ import androidx.compose.material.icons.rounded.QuestionMark
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
@@ -20,6 +21,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.navigation.BottomNavItem
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.navigation.isCurrentRoute
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
@@ -30,6 +32,7 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
@Composable
fun BottomNavbar(appUiState: AppUiState) {
val navController = LocalNavController.current
val isTv = LocalIsAndroidTV.current
val navBackStackEntry by navController.currentBackStackEntryAsState()
val items =
@@ -46,9 +49,8 @@ fun BottomNavbar(appUiState: AppUiState) {
icon = Icons.Rounded.Bolt,
onClick = {
val route =
if (appUiState.appState.isLocationDisclosureShown) {
Route.AutoTunnel
} else Route.LocationDisclosure
if (appUiState.appState.isLocationDisclosureShown) Route.AutoTunnel
else Route.LocationDisclosure
navController.goFromRoot(route)
},
active = appUiState.isAutoTunnelActive,
@@ -66,42 +68,53 @@ fun BottomNavbar(appUiState: AppUiState) {
onClick = { navController.goFromRoot(Route.Support) },
),
)
// Define ripple configuration based on platform
val rippleConfiguration =
if (isTv) {
RippleConfiguration()
} else {
null
}
NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {
items.forEach { item ->
val isSelected = navBackStackEntry.isCurrentRoute(item.route::class)
val interactionSource = remember { MutableInteractionSource() }
// Apply ripple configuration only if needed
CompositionLocalProvider(LocalRippleConfiguration provides rippleConfiguration) {
NavigationBar(containerColor = MaterialTheme.colorScheme.surface) {
items.forEach { item ->
val isSelected = navBackStackEntry.isCurrentRoute(item.route::class)
val interactionSource = remember { MutableInteractionSource() }
NavigationBarItem(
icon = {
if (item.active) {
BadgedBox(
badge = {
Badge(
modifier = Modifier.offset(x = 8.dp, y = (-8).dp).size(6.dp),
containerColor = SilverTree,
)
NavigationBarItem(
icon = {
if (item.active) {
BadgedBox(
badge = {
Badge(
modifier =
Modifier.offset(x = 8.dp, y = (-8).dp).size(6.dp),
containerColor = SilverTree,
)
}
) {
Icon(imageVector = item.icon, contentDescription = item.name)
}
) {
} else {
Icon(imageVector = item.icon, contentDescription = item.name)
}
} else {
Icon(imageVector = item.icon, contentDescription = item.name)
}
},
onClick = item.onClick,
selected = isSelected,
enabled = true,
label = null,
alwaysShowLabel = false,
colors =
NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onBackground,
indicatorColor = Color.Transparent,
),
interactionSource = interactionSource,
)
},
onClick = { navController.goFromRoot(item.route) },
selected = isSelected,
enabled = true,
label = null,
alwaysShowLabel = false,
colors =
NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onBackground,
indicatorColor = Color.Transparent,
),
interactionSource = interactionSource,
)
}
}
}
}
@@ -4,7 +4,6 @@ import android.os.Build
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.Sort
import androidx.compose.material.icons.rounded.*
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -12,7 +11,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
@@ -25,10 +23,11 @@ import com.zaneschepke.wireguardautotunnel.ui.navigation.isCurrentRoute
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.ui.state.AppViewState
import com.zaneschepke.wireguardautotunnel.ui.state.NavBarState
import com.zaneschepke.wireguardautotunnel.ui.theme.Brick
import com.zaneschepke.wireguardautotunnel.ui.theme.SilverTree
import com.zaneschepke.wireguardautotunnel.ui.theme.iconSize
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import com.zaneschepke.wireguardautotunnel.viewmodel.event.UiEvent
@Composable
fun currentNavBackStackEntryAsNavBarState(
@@ -61,40 +60,35 @@ fun currentNavBackStackEntryAsNavBarState(
Row {
if (selectedCount == 0) {
val showSort = remember(uiState.tunnels) { uiState.tunnels.size > 1 }
if (showSort)
ActionIconButton(Icons.AutoMirrored.Rounded.Sort, R.string.sort) {
navController.navigate(Route.Sort)
}
ActionIconButton(Icons.Rounded.Add, R.string.add_tunnel) {
viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.IMPORT_TUNNELS)
)
}
return@Row
}
ActionIconButton(Icons.Rounded.SelectAll, R.string.select_all) {
viewModel.handleEvent(AppEvent.ToggleSelectAllTunnels)
}
// due to permissions, and SAF issues on TV, not support less than Android 10 on
// Android TV for file exports
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ActionIconButton(Icons.Rounded.Download, R.string.download) {
viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.EXPORT_TUNNELS)
)
} else {
ActionIconButton(Icons.Rounded.SelectAll, R.string.select_all) {
viewModel.handleEvent(AppEvent.ToggleSelectAllTunnels)
}
}
if (selectedCount == 1) {
ActionIconButton(Icons.Rounded.CopyAll, R.string.copy) {
viewModel.handleEvent(AppEvent.CopySelectedTunnel)
// due to permissions, and SAF issues on TV, not support less than Android 10 on
// Android TV for file exports
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ActionIconButton(Icons.Rounded.Download, R.string.download) {
viewModel.handleEvent(
AppEvent.SetBottomSheet(AppViewState.BottomSheet.EXPORT_TUNNELS)
)
}
}
}
if (showDelete) {
ActionIconButton(Icons.Rounded.Delete, R.string.delete_tunnel) {
viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.DELETE))
if (selectedCount == 1) {
ActionIconButton(Icons.Rounded.CopyAll, R.string.copy) {
viewModel.handleEvent(AppEvent.CopySelectedTunnel)
}
}
if (showDelete) {
ActionIconButton(Icons.Rounded.Delete, R.string.delete_tunnel) {
viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.DELETE))
}
}
}
}
@@ -110,6 +104,8 @@ fun currentNavBackStackEntryAsNavBarState(
when {
backStackEntry.isCurrentRoute(Route.Main::class) -> {
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.tunnels)) },
topTrailing = { TunnelActionBar() },
route = Route.Main,
@@ -117,15 +113,36 @@ fun currentNavBackStackEntryAsNavBarState(
}
backStackEntry.isCurrentRoute(Route.AutoTunnel::class) -> {
val (icon, label, tint) =
if (uiState.appSettings.isAutoTunnelEnabled) {
Triple(Icons.Rounded.Stop, R.string.stop_auto, Brick)
} else {
Triple(Icons.Rounded.PlayArrow, R.string.start_auto, SilverTree)
}
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.auto_tunnel)) },
topTrailing = {
IconButton(
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnel) }
) {
Icon(
icon,
stringResource(label),
tint = tint,
modifier = Modifier.size(iconSize),
)
}
},
route = Route.AutoTunnel,
)
}
backStackEntry.isCurrentRoute(Route.Logs::class) -> {
NavBarState(
showTop = true,
showBottom = false,
topTitle = { Text(stringResource(R.string.logs)) },
topTrailing = {
@@ -141,80 +158,60 @@ fun currentNavBackStackEntryAsNavBarState(
backStackEntry.isCurrentRoute(Route.Settings::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.settings)) },
route = Route.Settings,
topTrailing = {
ActionIconButton(Icons.Rounded.Menu, R.string.quick_actions) {
viewModel.handleEvent(
AppEvent.SetBottomSheet(
AppViewState.BottomSheet.BACKUP_AND_RESTORE
)
)
}
},
)
backStackEntry.isCurrentRoute(Route.Appearance::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.appearance)) },
route = Route.Appearance,
)
backStackEntry.isCurrentRoute(Route.Language::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.language)) },
route = Route.Language,
)
backStackEntry.isCurrentRoute(Route.Display::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.display_theme)) },
route = Route.Display,
)
backStackEntry.isCurrentRoute(Route.TunnelMonitoring::class) ->
NavBarState(
topTitle = { Text(stringResource(R.string.tunnel_monitoring)) },
route = Route.TunnelMonitoring,
)
backStackEntry.isCurrentRoute(Route.WifiDetectionMethod::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.wifi_detection_method)) },
route = Route.WifiDetectionMethod,
)
backStackEntry.isCurrentRoute(Route.KillSwitch::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.kill_switch)) },
route = Route.KillSwitch,
)
backStackEntry.isCurrentRoute(Route.Support::class) ->
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.support)) },
route = Route.Support,
)
backStackEntry.isCurrentRoute(Route.Sort::class) -> {
NavBarState(
showTop = true,
showBottom = true,
topTitle = { Text(stringResource(R.string.sort)) },
topTrailing = {
Row {
ActionIconButton(Icons.Rounded.SortByAlpha, R.string.sort) {
viewModel.handleUiEvent(UiEvent.SortTunnels)
}
ActionIconButton(Icons.Rounded.Save, R.string.save) {
viewModel.handleEvent(AppEvent.InvokeScreenAction)
}
}
},
route = Route.Sort,
)
}
backStackEntry.isCurrentRoute(Route.License::class) -> {
NavBarState(
showTop = true,
@@ -1,89 +1,93 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel
import android.Manifest
import android.os.Build
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.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.zaneschepke.wireguardautotunnel.R
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider
import com.zaneschepke.wireguardautotunnel.ui.common.banner.WarningBanner
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.common.dialog.InfoDialog
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.AdvancedSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.NetworkTunnelingItems
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components.WifiTunnelingItems
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.BackgroundLocationDialog
import com.zaneschepke.wireguardautotunnel.ui.screens.settings.components.LocationServicesDialog
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.launchLocationServicesSettings
import com.zaneschepke.wireguardautotunnel.util.extensions.isLocationServicesEnabled
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) {
val navController = LocalNavController.current
val context = LocalContext.current
val navController = LocalNavController.current
val isTv = LocalIsAndroidTV.current
val fineLocationState = rememberPermissionState(Manifest.permission.ACCESS_FINE_LOCATION)
var currentText by remember { mutableStateOf("") }
var isBackgroundLocationGranted by remember { mutableStateOf(true) }
var showLocationServicesAlertDialog by remember { mutableStateOf(false) }
var showLocationDialog by remember { mutableStateOf(false) }
val showLocationServicesWarning by
remember(
uiState.connectivityState?.wifiState,
uiState.appSettings.trustedNetworkSSIDs,
uiState.appSettings.wifiDetectionMethod,
) {
derivedStateOf {
uiState.connectivityState?.wifiState?.locationServicesEnabled == false &&
uiState.appSettings.wifiDetectionMethod.needsLocationPermissions() &&
uiState.appSettings.trustedNetworkSSIDs.isNotEmpty()
}
}
fun checkFineLocationGranted() {
isBackgroundLocationGranted = fineLocationState.status.isGranted
}
val showLocationPermissionsWarning by
remember(
uiState.connectivityState?.wifiState,
uiState.appSettings.trustedNetworkSSIDs,
uiState.appSettings.wifiDetectionMethod,
) {
derivedStateOf {
uiState.connectivityState?.wifiState?.locationPermissionsGranted == false &&
uiState.appSettings.wifiDetectionMethod.needsLocationPermissions() &&
uiState.appSettings.trustedNetworkSSIDs.isNotEmpty()
fun isWifiNameReadable(): Boolean {
return when {
!isBackgroundLocationGranted || !fineLocationState.status.isGranted -> {
showLocationDialog = true
false
}
!context.isLocationServicesEnabled() -> {
showLocationServicesAlertDialog = true
false
}
else -> true
}
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) checkFineLocationGranted()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
if (isTv && Build.VERSION.SDK_INT == Build.VERSION_CODES.Q) {
checkFineLocationGranted()
} else {
val backgroundLocationState =
rememberPermissionState(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
LaunchedEffect(uiState.appSettings.trustedNetworkSSIDs) { currentText = "" }
if (showLocationDialog) {
InfoDialog(
onAttest = {
context.launchAppSettings()
showLocationDialog = false
},
onDismiss = { showLocationDialog = false },
title = { Text(stringResource(R.string.location_permissions)) },
body = { Text(stringResource(R.string.location_justification)) },
confirmText = { Text(stringResource(R.string.open_settings)) },
)
}
LocationServicesDialog(
showLocationServicesAlertDialog,
onDismiss = { showLocationServicesAlertDialog = false },
onAttest = { showLocationServicesAlertDialog = false },
)
BackgroundLocationDialog(
showLocationDialog,
onDismiss = { showLocationDialog = false },
onAttest = { showLocationDialog = false },
)
Column(
horizontalAlignment = Alignment.Start,
@@ -94,66 +98,16 @@ fun AutoTunnelScreen(uiState: AppUiState, viewModel: AppViewModel) {
.padding(vertical = 24.dp)
.padding(horizontal = 12.dp),
) {
WarningBanner(
stringResource(R.string.location_services_not_detected),
showLocationServicesWarning,
trailing = {
TextButton({ context.launchLocationServicesSettings() }) {
Text(
stringResource(R.string.fix),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
},
)
WarningBanner(
stringResource(R.string.location_permissions_missing),
showLocationPermissionsWarning,
trailing = {
TextButton({ showLocationDialog = true }) {
Text(
stringResource(R.string.fix),
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodyMedium,
)
}
},
)
val (title, buttonText, icon) =
remember(uiState.isAutoTunnelActive) {
when (uiState.isAutoTunnelActive) {
true ->
Triple(
context.getString(R.string.auto_tunnel_running),
context.getString(R.string.stop),
Icons.Outlined.CheckCircle,
)
false ->
Triple(
context.getString(R.string.auto_tunnel_not_running),
context.getString(R.string.start),
Icons.Outlined.Info,
)
}
}
SurfaceSelectionGroupButton(
items =
listOf(
SelectionItem(
leading = { Icon(icon, null) },
title = { Text(title) },
trailing = {
Button({ viewModel.handleEvent(AppEvent.ToggleAutoTunnel) }) {
Text(buttonText, fontWeight = FontWeight.Bold)
}
},
)
WifiTunnelingItems(
uiState,
viewModel,
currentText,
{ currentText = it },
{ isWifiNameReadable() },
)
)
SurfaceSelectionGroupButton(
items = WifiTunnelingItems(uiState, viewModel, currentText) { currentText = it }
)
SectionDivider()
SurfaceSelectionGroupButton(items = NetworkTunnelingItems(uiState, viewModel))
SectionDivider()
@@ -6,21 +6,13 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PauseCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.dropdown.LabelledNumberDropdown
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.components.DebounceDelaySelector
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun AutoTunnelAdvancedScreen(appUiState: AppUiState, viewModel: AppViewModel) {
@@ -33,22 +25,9 @@ fun AutoTunnelAdvancedScreen(appUiState: AppUiState, viewModel: AppViewModel) {
.padding(vertical = 24.dp)
.padding(horizontal = 12.dp),
) {
LabelledNumberDropdown(
title = {
Text(
stringResource(R.string.debounce_delay),
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface
),
)
},
leading = { Icon(Icons.Outlined.PauseCircle, null) },
onSelected = { selected ->
viewModel.handleEvent(AppEvent.SetDebounceDelay(selected!!))
},
options = (0..10).toList(),
currentValue = appUiState.appSettings.debounceDelaySeconds,
DebounceDelaySelector(
currentDelay = appUiState.appSettings.debounceDelaySeconds,
onEvent = viewModel::handleEvent,
)
}
}
@@ -0,0 +1,49 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.advanced.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PauseCircle
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.DropdownSelector
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun DebounceDelaySelector(currentDelay: Int, onEvent: (AppEvent) -> Unit) {
var isDropDownExpanded by remember { mutableStateOf(false) }
SurfaceSelectionGroupButton(
listOf(
SelectionItem(
leadingIcon = Icons.Outlined.PauseCircle,
title = {
Text(
stringResource(R.string.debounce_delay),
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface
),
)
},
onClick = { isDropDownExpanded = true },
trailing = {
DropdownSelector(
currentValue = currentDelay,
options = (0..10).toList(),
onValueSelected = { num -> onEvent(AppEvent.SetDebounceDelay(num)) },
isExpanded = isDropDownExpanded,
onDismiss = { isDropDownExpanded = false },
)
},
)
)
)
}
@@ -2,7 +2,6 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -14,7 +13,7 @@ import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionIte
@Composable
fun AdvancedSettingsItem(onClick: () -> Unit): SelectionItem {
return SelectionItem(
leading = { Icon(Icons.Outlined.Settings, contentDescription = null) },
leadingIcon = Icons.Outlined.Settings,
title = {
Text(
stringResource(R.string.advanced_settings),
@@ -4,7 +4,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PublicOff
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material.icons.outlined.SignalCellular4Bar
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -22,7 +21,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List<SelectionItem> {
return listOf(
SelectionItem(
leading = { Icon(Icons.Outlined.SignalCellular4Bar, contentDescription = null) },
leadingIcon = Icons.Outlined.SignalCellular4Bar,
title = {
Text(
stringResource(R.string.tunnel_mobile_data),
@@ -41,8 +40,8 @@ fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List<Se
},
description = {
val cellularActive =
remember(uiState.connectivityState) {
uiState.connectivityState?.cellularConnected ?: false
remember(uiState.networkStatus) {
uiState.networkStatus?.cellularConnected ?: false
}
Text(
text =
@@ -59,7 +58,7 @@ fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List<Se
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnCellular) },
),
SelectionItem(
leading = { Icon(Icons.Outlined.SettingsEthernet, contentDescription = null) },
leadingIcon = Icons.Outlined.SettingsEthernet,
title = {
Text(
stringResource(R.string.tunnel_on_ethernet),
@@ -78,8 +77,8 @@ fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List<Se
},
description = {
val ethernetActive =
remember(uiState.connectivityState) {
uiState.connectivityState?.ethernetConnected ?: false
remember(uiState.networkStatus) {
uiState.networkStatus?.ethernetConnected ?: false
}
Text(
text =
@@ -96,7 +95,7 @@ fun NetworkTunnelingItems(uiState: AppUiState, viewModel: AppViewModel): List<Se
onClick = { viewModel.handleEvent(AppEvent.ToggleAutoTunnelOnEthernet) },
),
SelectionItem(
leading = { Icon(Icons.Outlined.PublicOff, contentDescription = null) },
leadingIcon = Icons.Outlined.PublicOff,
title = {
Text(
stringResource(R.string.stop_on_no_internet),
@@ -1,7 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
@@ -17,6 +17,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.zaneschepke.networkmonitor.AndroidNetworkMonitor
import com.zaneschepke.networkmonitor.NetworkStatus
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
@@ -38,6 +40,7 @@ fun WifiTunnelingItems(
viewModel: AppViewModel,
currentText: String,
onTextChange: (String) -> Unit,
isWifiNameReadable: () -> Boolean,
): List<SelectionItem> {
val context = LocalContext.current
val navController = LocalNavController.current
@@ -46,7 +49,7 @@ fun WifiTunnelingItems(
val baseItems =
listOf(
SelectionItem(
leading = { Icon(Icons.Outlined.Wifi, contentDescription = null) },
leadingIcon = Icons.Outlined.Wifi,
title = {
Text(
stringResource(R.string.tunnel_on_wifi),
@@ -65,12 +68,11 @@ fun WifiTunnelingItems(
},
description = {
val wifiInfo by
remember(uiState.connectivityState) {
remember(uiState.networkStatus) {
derivedStateOf {
uiState.connectivityState
?.wifiState
?.takeIf { it.connected }
.let { Pair(it?.ssid, it?.securityType) }
(uiState.networkStatus as? NetworkStatus.Connected)
?.takeIf { it.wifiConnected }
.let { Pair(it?.wifiSsid, it?.securityType) }
}
}
val (wifiName, securityType) = wifiInfo
@@ -109,7 +111,7 @@ fun WifiTunnelingItems(
baseItems +
listOf(
SelectionItem(
leading = { Icon(Icons.Outlined.WifiFind, contentDescription = null) },
leadingIcon = Icons.Outlined.WifiFind,
title = {
Text(
stringResource(R.string.wifi_detection_method),
@@ -137,7 +139,7 @@ fun WifiTunnelingItems(
onClick = { navController.navigate(Route.WifiDetectionMethod) },
),
SelectionItem(
leading = { Icon(Icons.Outlined.Filter1, contentDescription = null) },
leadingIcon = Icons.Outlined.Filter1,
title = {
Text(
stringResource(R.string.use_wildcards),
@@ -199,7 +201,15 @@ fun WifiTunnelingItems(
onDelete = { viewModel.handleEvent(AppEvent.DeleteTrustedSSID(it)) },
currentText = currentText,
onSave = { ssid ->
viewModel.handleEvent(AppEvent.SaveTrustedSSID(ssid))
if (
uiState.appSettings.wifiDetectionMethod ==
AndroidNetworkMonitor.WifiDetectionMethod.ROOT ||
uiState.appSettings.wifiDetectionMethod ==
AndroidNetworkMonitor.WifiDetectionMethod.SHIZUKU ||
isWifiNameReadable()
) {
viewModel.handleEvent(AppEvent.SaveTrustedSSID(ssid))
}
},
onValueChange = onTextChange,
supporting = {
@@ -209,7 +219,7 @@ fun WifiTunnelingItems(
},
),
SelectionItem(
leading = { Icon(Icons.Outlined.VpnKeyOff, contentDescription = null) },
leadingIcon = Icons.Outlined.VpnKeyOff,
title = {
Text(
stringResource(R.string.kill_switch_off),
@@ -9,17 +9,24 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.AppSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.LocationDisclosureHeader
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.appSettingsItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.skipItem
import com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.components.SkipItem
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun LocationDisclosureScreen(viewModel: AppViewModel) {
fun LocationDisclosureScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val navController = LocalNavController.current
LaunchedEffect(Unit) { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) }
LaunchedEffect(Unit, appUiState) {
if (appUiState.appState.isLocationDisclosureShown)
navController.goFromRoot(Route.AutoTunnel)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -27,7 +34,7 @@ fun LocationDisclosureScreen(viewModel: AppViewModel) {
modifier = Modifier.fillMaxSize().padding(top = 18.dp).padding(horizontal = 24.dp),
) {
LocationDisclosureHeader()
SurfaceSelectionGroupButton(items = listOf(appSettingsItem()))
SurfaceSelectionGroupButton(items = listOf(skipItem()))
SurfaceSelectionGroupButton(items = listOf(AppSettingsItem(viewModel)))
SurfaceSelectionGroupButton(items = listOf(SkipItem(viewModel)))
}
}
@@ -2,7 +2,6 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.autotunnel.disclosure.com
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.LocationOn
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -12,20 +11,31 @@ import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.util.extensions.launchAppSettings
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun appSettingsItem(): SelectionItem {
fun AppSettingsItem(viewModel: AppViewModel): SelectionItem {
val context = LocalContext.current
return SelectionItem(
leading = { Icon(Icons.Outlined.LocationOn, contentDescription = null) },
leadingIcon = Icons.Outlined.LocationOn,
title = {
Text(
text = stringResource(R.string.launch_app_settings),
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = { ForwardButton { context.launchAppSettings() } },
onClick = { context.launchAppSettings() },
trailing = {
ForwardButton {
context.launchAppSettings().also {
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
}
}
},
onClick = {
context.launchAppSettings().also {
viewModel.handleEvent(AppEvent.SetLocationDisclosureShown)
}
},
)
}
@@ -5,15 +5,13 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.Route
import com.zaneschepke.wireguardautotunnel.ui.common.button.ForwardButton
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SelectionItem
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalNavController
import com.zaneschepke.wireguardautotunnel.util.extensions.goFromRoot
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun skipItem(): SelectionItem {
val navController = LocalNavController.current
fun SkipItem(viewModel: AppViewModel): SelectionItem {
return SelectionItem(
title = {
Text(
@@ -21,7 +19,7 @@ fun skipItem(): SelectionItem {
style = MaterialTheme.typography.bodyLarge.copy(MaterialTheme.colorScheme.onSurface),
)
},
trailing = { ForwardButton { navController.goFromRoot(Route.AutoTunnel) } },
onClick = { navController.goFromRoot(Route.AutoTunnel) },
trailing = { ForwardButton { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) } },
onClick = { viewModel.handleEvent(AppEvent.SetLocationDisclosureShown) },
)
}
@@ -6,17 +6,21 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.EthernetTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.MobileDataTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.PingRestartItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.WifiTunnelItem
import com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components.ethernetTunnelItem
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
@Composable
@@ -41,11 +45,8 @@ fun TunnelAutoTunnelScreen(
SurfaceSelectionGroupButton(
items =
buildList {
if (appSettings.isPingEnabled) {
add(PingRestartItem(tunnelConf, viewModel))
}
add(MobileDataTunnelItem(tunnelConf, viewModel))
add(ethernetTunnelItem(tunnelConf, viewModel))
add(EthernetTunnelItem(tunnelConf, viewModel))
add(
WifiTunnelItem(tunnelConf, appSettings, viewModel, currentText) {
currentText = it
@@ -2,7 +2,6 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SettingsEthernet
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -15,9 +14,9 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun ethernetTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
fun EthernetTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leading = { Icon(Icons.Outlined.SettingsEthernet, contentDescription = null) },
leadingIcon = Icons.Outlined.SettingsEthernet,
title = {
Text(
text = stringResource(R.string.ethernet_tunnel),
@@ -2,7 +2,6 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.component
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PhoneAndroid
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -17,7 +16,7 @@ import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
@Composable
fun MobileDataTunnelItem(tunnelConf: TunnelConf, viewModel: AppViewModel): SelectionItem {
return SelectionItem(
leading = { Icon(Icons.Outlined.PhoneAndroid, contentDescription = null) },
leadingIcon = Icons.Outlined.PhoneAndroid,
title = {
Text(
text = stringResource(R.string.mobile_tunnel),
@@ -1,6 +1,10 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.autotunnel.components
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Security
import androidx.compose.material3.Icon
@@ -5,9 +5,19 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.FolderZip
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material.icons.filled.FolderZip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
@@ -111,7 +121,7 @@ fun ExportTunnelsBottomSheet(viewModel: AppViewModel) {
private fun ExportOptionRow(label: String, onClick: () -> Unit) {
Row(modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(10.dp)) {
Icon(
imageVector = Icons.Outlined.FolderZip,
imageVector = Icons.Filled.FolderZip,
contentDescription = label,
modifier = Modifier.padding(10.dp),
)
@@ -5,8 +5,18 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.material.icons.filled.ContentPasteGo
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.FileOpen
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -1,9 +1,7 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -27,6 +25,8 @@ import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.openWebUrl
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import java.text.Collator
import java.util.*
@OptIn(ExperimentalFoundationApi::class)
@Composable
@@ -40,8 +40,17 @@ fun TunnelList(
val isTv = LocalIsAndroidTV.current
val context = LocalContext.current
val navController = LocalNavController.current
val lazyListState = rememberLazyListState()
val collator = Collator.getInstance(Locale.getDefault())
val sortedTunnels =
remember(appUiState.tunnels) {
appUiState.tunnels.sortedWith(
compareBy(
// primary tunnel first
{ !it.isPrimaryTunnel },
{ collator.compare(it.tunName, "") },
)
)
}
LazyColumn(
horizontalAlignment = Alignment.Start,
@@ -50,7 +59,7 @@ fun TunnelList(
modifier
.pointerInput(Unit) { if (appUiState.tunnels.isEmpty()) return@pointerInput }
.overscroll(rememberOverscrollEffect()),
state = lazyListState,
state = rememberLazyListState(0, appUiState.tunnels.count()),
userScrollEnabled = true,
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
@@ -58,7 +67,7 @@ fun TunnelList(
if (appUiState.tunnels.isEmpty()) {
item { GettingStartedLabel(onClick = { context.openWebUrl(it) }) }
}
items(appUiState.tunnels, key = { it.id }) { tunnel ->
items(sortedTunnels, key = { it.id }) { tunnel ->
val tunnelState =
remember(appUiState.activeTunnels) {
appUiState.activeTunnels.getValueById(tunnel.id) ?: TunnelState()
@@ -66,38 +75,26 @@ fun TunnelList(
val selected = remember(selectedTunnels) { selectedTunnels.any { it.id == tunnel.id } }
TunnelRowItem(
state = tunnelState,
expanded = appUiState.appState.expandedTunnelIds.contains(tunnel.id),
isSelected = selected,
tunnel = tunnel,
tunnelState = tunnelState,
appSettings = appUiState.appSettings,
onTvClick = {
navController.navigate(Route.TunnelOptions(tunnel.id))
viewModel.handleEvent(AppEvent.ClearSelectedTunnels)
onClick = {
if (selectedTunnels.isNotEmpty() && !isTv) {
viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(tunnel))
} else {
navController.navigate(Route.TunnelOptions(tunnel.id))
viewModel.handleEvent(AppEvent.ClearSelectedTunnels)
}
},
onDoubleClick = {
viewModel.handleEvent(AppEvent.ToggleTunnelStatsExpanded(tunnel.id))
},
onToggleSelectedTunnel = {
viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(it))
},
onSwitchClick = { checked -> onToggleTunnel(tunnel, checked) },
isTv = isTv,
showDetailedStats = appUiState.appState.showDetailedPingStats,
modifier =
if (!isTv)
Modifier.combinedClickable(
onClick = {
if (selectedTunnels.isNotEmpty()) {
viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(tunnel))
} else {
navController.navigate(Route.TunnelOptions(tunnel.id))
viewModel.handleEvent(AppEvent.ClearSelectedTunnels)
}
},
onLongClick = {
viewModel.handleEvent(AppEvent.ToggleSelectedTunnel(tunnel))
},
interactionSource = remember { MutableInteractionSource() },
indication = null,
)
else Modifier,
)
}
}
@@ -4,7 +4,12 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material.icons.rounded.Circle
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material.icons.rounded.SettingsEthernet
import androidx.compose.material.icons.rounded.Smartphone
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -14,13 +19,9 @@ import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.enums.TunnelStatus
import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
@@ -31,64 +32,31 @@ import com.zaneschepke.wireguardautotunnel.util.extensions.asColor
fun TunnelRowItem(
state: TunnelState,
isSelected: Boolean,
expanded: Boolean,
tunnel: TunnelConf,
tunnelState: TunnelState,
appSettings: AppSettings,
onTvClick: () -> Unit,
onClick: () -> Unit,
onDoubleClick: () -> Unit,
onToggleSelectedTunnel: (TunnelConf) -> Unit,
onSwitchClick: (Boolean) -> Unit,
isTv: Boolean,
showDetailedStats: Boolean,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val leadingIconColor =
remember(state) {
if (state.status.isUp()) tunnelState.statistics.asColor() else Color.Gray
}
val (leadingIcon, size, typeDescription) =
val (leadingIcon, size) =
remember(tunnel) {
when {
tunnel.isPrimaryTunnel ->
Triple(Icons.Rounded.Star, 16.dp, context.getString(R.string.primary_tunnel))
tunnel.isMobileDataTunnel ->
Triple(
Icons.Rounded.Smartphone,
16.dp,
context.getString(R.string.mobile_data_tunnel),
)
tunnel.isEthernetTunnel ->
Triple(
Icons.Rounded.SettingsEthernet,
16.dp,
context.getString(R.string.ethernet_tunnel),
)
else -> Triple(Icons.Rounded.Circle, 14.dp, context.getString(R.string.tunnel))
tunnel.isPrimaryTunnel -> Pair(Icons.Rounded.Star, 16.dp)
tunnel.isMobileDataTunnel -> Pair(Icons.Rounded.Smartphone, 16.dp)
tunnel.isEthernetTunnel -> Pair(Icons.Rounded.SettingsEthernet, 16.dp)
else -> Pair(Icons.Rounded.Circle, 14.dp)
}
}
// Status description based on tunnel state
val statusDescription =
remember(state) {
if (state.status.isUpOrStarting()) {
context.getString(R.string.active)
} else {
context.getString(R.string.inactive)
}
}
// Combined content description for accessibility
val combinedContentDescription =
stringResource(
R.string.tunnel_item_description,
tunnel.tunName,
typeDescription,
statusDescription,
)
ExpandingRowListItem(
modifier = modifier.semantics(mergeDescendants = true) { combinedContentDescription },
leading = {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -96,29 +64,27 @@ fun TunnelRowItem(
) {
if (isTv) {
Checkbox(
checked = isSelected,
isSelected,
onCheckedChange = { onToggleSelectedTunnel(tunnel) },
modifier = Modifier.minimumInteractiveComponentSize().size(12.dp),
)
}
Icon(
leadingIcon,
contentDescription = null,
stringResource(R.string.status),
tint = leadingIconColor,
modifier = Modifier.size(size),
)
}
},
text = tunnel.tunName,
onHold = { if (!isTv) onToggleSelectedTunnel(tunnel) },
onClick = { if (!isTv) onClick() },
onDoubleClick = { if (!isTv) onDoubleClick() },
expanded = {
if (tunnelState.status != TunnelStatus.Down) {
TunnelStatisticsRow(
tunnelState,
tunnel,
appSettings.isPingEnabled,
showDetailedStats,
)
}
if (expanded) {
TunnelStatisticsRow(tunnelState.statistics, tunnel)
} else null
},
trailing = {
Row(
@@ -126,7 +92,13 @@ fun TunnelRowItem(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
) {
if (isTv) {
IconButton(onClick = onTvClick) {
IconButton(onClick = onDoubleClick) {
Icon(
Icons.Rounded.KeyboardArrowDown,
contentDescription = stringResource(R.string.info),
)
}
IconButton(onClick = onClick) {
Icon(
Icons.Rounded.Settings,
contentDescription = stringResource(R.string.settings),
@@ -1,214 +1,112 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelState
import com.zaneschepke.wireguardautotunnel.domain.state.TunnelStatistics
import com.zaneschepke.wireguardautotunnel.util.NumberUtils
import com.zaneschepke.wireguardautotunnel.util.extensions.toThreeDecimalPlaceString
@Composable
fun TunnelStatisticsRow(
tunnelState: TunnelState,
tunnelConf: TunnelConf,
pingEnabled: Boolean,
showDetailedStats: Boolean,
) {
val config = remember(tunnelConf) { TunnelConf.configFromAmQuick(tunnelConf.wgQuick) }
val peerText = stringResource(R.string.peer)
val handshakeText = stringResource(R.string.handshake)
val endpointText = stringResource(R.string.endpoint)
val neverText = stringResource(R.string.never)
val textStyle = MaterialTheme.typography.bodySmall
val textColor = MaterialTheme.colorScheme.outline
fun TunnelStatisticsRow(statistics: TunnelStatistics?, tunnelConf: TunnelConf) {
val config = TunnelConf.configFromAmQuick(tunnelConf.wgQuick)
Column(
modifier = Modifier.fillMaxWidth().padding(start = 45.dp, bottom = 10.dp, end = 10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.Start,
) {
config.peers.forEachIndexed { index, peer ->
key(peer.publicKey.toBase64()) { // Key by peer ID to skip recomposition if unchanged
val peerStats =
remember(tunnelState.statistics, peer, tunnelConf) {
tunnelState.statistics?.peerStats(peer.publicKey)
config.peers.forEach { peer ->
val peerId = remember { peer.publicKey.toBase64().subSequence(0, 3).toString() + "***" }
val endpoint =
remember(statistics) { statistics?.peerStats(peer.publicKey)?.resolvedEndpoint }
val peerRxMB by
remember(statistics) {
derivedStateOf {
statistics
?.peerStats(peer.publicKey)
?.rxBytes
?.let { NumberUtils.bytesToMB(it) }
?.toThreeDecimalPlaceString()
}
val peerId =
remember(peer) {
peer.publicKey.toBase64().subSequence(0, 3).toString() + "***"
}
val peerTxMB by
remember(statistics) {
derivedStateOf {
statistics
?.peerStats(peer.publicKey)
?.txBytes
?.let { NumberUtils.bytesToMB(it) }
?.toThreeDecimalPlaceString()
}
val endpoint by
remember(peerStats) { derivedStateOf { peerStats?.resolvedEndpoint } }
val peerRxMB by
remember(peerStats) {
derivedStateOf {
peerStats
?.rxBytes
?.let { NumberUtils.bytesToMB(it) }
?.toThreeDecimalPlaceString() ?: "0.00"
}
}
val peerTxMB by
remember(peerStats) {
derivedStateOf {
peerStats
?.txBytes
?.let { NumberUtils.bytesToMB(it) }
?.toThreeDecimalPlaceString() ?: "0.00"
}
}
val handshake by
remember(peerStats) {
derivedStateOf {
peerStats?.latestHandshakeEpochMillis?.let {
if (it == 0L) null
else NumberUtils.getSecondsBetweenTimestampAndNow(it).toString()
}
}
}
val pingState by
remember(tunnelState.pingStates) {
derivedStateOf {
tunnelState.pingStates?.getOrDefault(peer.publicKey, null)
}
}
val lastPingedSeconds by
remember(peerStats) {
derivedStateOf {
pingState?.lastSuccessfulPingMillis?.let {
NumberUtils.getSecondsBetweenTimestampAndNow(it)
}
}
}
// Group peer stats in a column with internal spacing
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("$peerText: $peerId", style = textStyle, color = textColor)
Text(
"$handshakeText: ${handshake?.let { stringResource(R.string.sec_ago_template, it)} ?: neverText}",
style = textStyle,
color = textColor,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(R.string.rx_template, peerRxMB),
style = textStyle,
color = textColor,
)
Text(
stringResource(R.string.tx_template, peerTxMB),
style = textStyle,
color = textColor,
)
}
AnimatedVisibility(visible = endpoint != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("$endpointText: $endpoint", style = textStyle, color = textColor)
}
}
AnimatedVisibility(visible = pingState != null && pingEnabled) {
pingState?.let {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(
R.string.reachable_template,
stringResource(
if (it.isReachable) R.string._true
else R.string._false
),
),
style = textStyle,
color = textColor,
)
Text(
stringResource(
R.string.ping_target_template,
it.pingTarget,
),
style = textStyle,
color = textColor,
)
}
if (showDetailedStats) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(R.string.latency_template, it.rttAvg),
style = textStyle,
color = textColor,
)
Text(
stringResource(R.string.jitter_template, it.rttStddev),
style = textStyle,
color = textColor,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(
R.string.packets_sent_template,
it.transmitted,
),
style = textStyle,
color = textColor,
)
Text(
stringResource(
R.string.packet_loss_template,
it.packetLoss,
),
style = textStyle,
color = textColor,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
stringResource(
R.string.ping_success_template,
lastPingedSeconds?.let { sec ->
stringResource(R.string.sec_ago_template, sec)
} ?: neverText,
),
style = textStyle,
color = textColor,
)
}
}
}
val handshake by
remember(statistics) {
derivedStateOf {
statistics?.peerStats(peer.publicKey)?.latestHandshakeEpochMillis?.let {
if (it == 0L) {
null
} else {
"${NumberUtils.getSecondsBetweenTimestampAndNow(it)}"
}
}
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start),
) {
Text(
stringResource(R.string.peer).lowercase() + ": $peerId",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
Text(
stringResource(R.string.handshake) +
": ${if(handshake == null) stringResource(R.string.never) else handshake + " " + stringResource(R.string.sec)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start),
) {
Text(
"rx: ${peerRxMB ?: 0.00} MB",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
Text(
"tx: ${peerTxMB ?: 0.00} MB",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
if (endpoint != null) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.Start),
) {
Text(
"endpoint: $endpoint",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
}
}
@@ -7,7 +7,11 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -5,7 +5,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material3.*
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
@@ -9,7 +9,11 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Refresh
import androidx.compose.material3.*
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
@@ -1,11 +1,19 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -2,13 +2,28 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.config.components
import androidx.compose.foundation.background
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
@@ -1,168 +0,0 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.sort
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.overscroll
import androidx.compose.foundation.rememberOverscrollEffect
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.DragHandle
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.R
import com.zaneschepke.wireguardautotunnel.ui.common.ExpandingRowListItem
import com.zaneschepke.wireguardautotunnel.ui.navigation.LocalIsAndroidTV
import com.zaneschepke.wireguardautotunnel.ui.state.AppUiState
import com.zaneschepke.wireguardautotunnel.util.extensions.isSortedBy
import com.zaneschepke.wireguardautotunnel.viewmodel.AppViewModel
import com.zaneschepke.wireguardautotunnel.viewmodel.event.AppEvent
import com.zaneschepke.wireguardautotunnel.viewmodel.event.UiEvent
import sh.calvin.reorderable.DragGestureDetector
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@Composable
fun SortScreen(appUiState: AppUiState, viewModel: AppViewModel) {
val hapticFeedback = LocalHapticFeedback.current
val isTv = LocalIsAndroidTV.current
var sortAscending by remember { mutableStateOf<Boolean?>(null) }
var sortedTunnels by remember { mutableStateOf(appUiState.tunnels.sortedBy { it.position }) }
LaunchedEffect(Unit) {
viewModel.uiEvent.collect { uiEvent ->
when (uiEvent) {
UiEvent.SortTunnels -> {
sortAscending =
when (sortAscending) {
null -> !sortedTunnels.isSortedBy { it.name }
true -> false
false -> null
}
sortedTunnels =
when (sortAscending) {
true -> sortedTunnels.sortedBy { it.name }
false -> sortedTunnels.sortedByDescending { it.name }
null -> sortedTunnels.sortedBy { it.position }
}
}
}
}
}
LaunchedEffect(Unit) {
viewModel.handleEvent(
AppEvent.SetScreenAction {
viewModel.handleEvent(
AppEvent.SaveAllConfigs(
sortedTunnels.mapIndexed { index, conf -> conf.copy(position = index) }
)
)
viewModel.handleEvent(AppEvent.PopBackStack(true))
}
)
}
val lazyListState = rememberLazyListState()
val reorderableLazyListState =
rememberReorderableLazyListState(
lazyListState,
scrollThresholdPadding = WindowInsets.systemBars.asPaddingValues(),
) { from, to ->
sortedTunnels =
sortedTunnels.toMutableList().apply { add(to.index, removeAt(from.index)) }
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
}
LazyColumn(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(5.dp, Alignment.Top),
modifier =
Modifier.pointerInput(Unit) { if (appUiState.tunnels.isEmpty()) return@pointerInput }
.overscroll(rememberOverscrollEffect())
.padding(horizontal = 16.dp, vertical = 24.dp),
state = lazyListState,
userScrollEnabled = true,
reverseLayout = false,
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
itemsIndexed(sortedTunnels, key = { _, tunnel -> tunnel.id }) { index, tunnel ->
ReorderableItem(reorderableLazyListState, tunnel.id) { isDragging ->
ExpandingRowListItem(
leading = {},
text = tunnel.name,
trailing = {
if (!isTv)
Icon(
Icons.Default.DragHandle,
stringResource(
com.zaneschepke.wireguardautotunnel.R.string.drag_handle
),
)
else
Row {
IconButton(
onClick = {
sortedTunnels =
sortedTunnels.toMutableList().apply {
add(index - 1, removeAt(index))
}
},
enabled = index != 0,
) {
Icon(
Icons.Default.ArrowUpward,
stringResource(
com.zaneschepke.wireguardautotunnel.R.string.move_up
),
)
}
IconButton(
onClick = {
sortedTunnels =
sortedTunnels.toMutableList().apply {
add(index + 1, removeAt(index))
}
},
enabled = index != sortedTunnels.count() - 1,
) {
Icon(
Icons.Default.ArrowDownward,
stringResource(R.string.move_down),
)
}
}
},
isSelected = isDragging,
expanded = {},
modifier =
Modifier.draggableHandle(
onDragStarted = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.GestureThresholdActivate
)
},
onDragStopped = {
hapticFeedback.performHapticFeedback(HapticFeedbackType.GestureEnd)
},
dragGestureDetector = DragGestureDetector.LongPress,
),
)
}
}
}
}
@@ -18,7 +18,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import java.text.Collator
import java.util.*
import javax.inject.Inject
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@HiltViewModel
@@ -2,7 +2,11 @@ package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.componen
import android.content.pm.PackageManager
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Checkbox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
@@ -1,7 +1,12 @@
package com.zaneschepke.wireguardautotunnel.ui.screens.main.splittunnel.components
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
@@ -8,7 +8,12 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material3.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MultiChoiceSegmentedButtonRow
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -10,7 +10,6 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.zaneschepke.wireguardautotunnel.domain.model.AppSettings
import com.zaneschepke.wireguardautotunnel.domain.model.TunnelConf
import com.zaneschepke.wireguardautotunnel.ui.common.SectionDivider
import com.zaneschepke.wireguardautotunnel.ui.common.button.surface.SurfaceSelectionGroupButton
@@ -26,7 +25,6 @@ fun TunnelOptionsScreen(
tunnelConf: TunnelConf,
viewModel: AppViewModel,
appViewState: AppViewState,
appSettings: AppSettings,
) {
val isTv = LocalIsAndroidTV.current
@@ -38,10 +36,7 @@ fun TunnelOptionsScreen(
// Show authorization prompt if needed
if (showAuthPrompt) {
AuthorizationPromptWrapper(
onDismiss = {
showAuthPrompt = false
viewModel.handleEvent(AppEvent.SetShowModal(AppViewState.ModalType.NONE))
},
onDismiss = { showAuthPrompt = false },
onSuccess = {
showAuthPrompt = false
isAuthorized = true
@@ -73,13 +68,19 @@ fun TunnelOptionsScreen(
listOf(
PrimaryTunnelItem(tunnelConf, viewModel),
AutoTunnelingItem(tunnelConf),
serverIpv4Item(tunnelConf, viewModel),
ServerIpv4Item(tunnelConf, viewModel),
SplitTunnelingItem(tunnelConf),
)
)
if (appSettings.isPingEnabled) {
SectionDivider()
SurfaceSelectionGroupButton(items = listOf(pingConfigItem(tunnelConf, viewModel)))
}
SectionDivider()
SurfaceSelectionGroupButton(
items =
buildList {
add(PingRestartItem(tunnelConf, viewModel))
if (tunnelConf.isPingEnabled) {
add(PingConfigItem(tunnelConf, viewModel))
}
}
)
}
}

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