Compare commits
126
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68ee051ab2 | ||
|
|
0e35a794c9 | ||
|
|
5ffb5a2dbb | ||
|
|
ae06a58013 | ||
|
|
9109eb8460 | ||
|
|
1ef8c983c7 | ||
|
|
072674d1a8 | ||
|
|
228f9f6004 | ||
|
|
6756d2fca5 | ||
|
|
5998c0fc46 | ||
|
|
f5b15a0394 | ||
|
|
38e5fd84bb | ||
|
|
e0113e3413 | ||
|
|
637b8061ed | ||
|
|
29095e087f | ||
|
|
e3e6d51f6f | ||
|
|
df1575fe27 | ||
|
|
a39993075e | ||
|
|
9ed278b440 | ||
|
|
16d4c72920 | ||
|
|
1e32410fca | ||
|
|
8880601ef6 | ||
|
|
dcf8ace6e1 | ||
|
|
1a6fb85259 | ||
|
|
5ca60d7066 | ||
|
|
e7b801d584 | ||
|
|
638c58f0e4 | ||
|
|
92627da459 | ||
|
|
15ad5c1151 | ||
|
|
92e2f4f069 | ||
|
|
a08c193a90 | ||
|
|
8fbebed140 | ||
|
|
9c96c36fc6 | ||
|
|
ffa38d5f28 | ||
|
|
e465797b5f | ||
|
|
b4a311c84f | ||
|
|
739068ccb9 | ||
|
|
8cd704ed08 | ||
|
|
eb5a89623b | ||
|
|
f193661e68 | ||
|
|
53623ebb4a | ||
|
|
db6c806d58 | ||
|
|
6652970925 | ||
|
|
dc8f5a75e1 | ||
|
|
fecef1135c | ||
|
|
200697216d | ||
|
|
434e5bad4e | ||
|
|
4ff5ed11bb | ||
|
|
60252437ac | ||
|
|
75849beb90 | ||
|
|
8f21e16277 | ||
|
|
12a95d1e89 | ||
|
|
66688031cd | ||
|
|
7abac87140 | ||
|
|
1b4fdc83d8 | ||
|
|
ce80c36be1 | ||
|
|
07fb02790c | ||
|
|
b0f353af90 | ||
|
|
514b15b1ca | ||
|
|
5f8b813eb4 | ||
|
|
6668dcfb56 | ||
|
|
1eb1dfe317 | ||
|
|
cf89cec20e | ||
|
|
24e536b9b9 | ||
|
|
e6c893219c | ||
|
|
01c398f2f7 | ||
|
|
f2e4ffe860 | ||
|
|
358dcb94b2 | ||
|
|
2b40eebf5b | ||
|
|
793ca1b410 | ||
|
|
5e38fc2f76 | ||
|
|
c23d4dad8b | ||
|
|
e2ce8420a7 | ||
|
|
488c0aa210 | ||
|
|
48f1ed7fb8 | ||
|
|
389712bba3 | ||
|
|
584004a25c | ||
|
|
6a8528fc98 | ||
|
|
8ef7c0a53b | ||
|
|
dc6e1e32fb | ||
|
|
2681233449 | ||
|
|
7a7673119e | ||
|
|
8deafeae8e | ||
|
|
59a31d6a1d | ||
|
|
8bedf432e2 | ||
|
|
42cd361c58 | ||
|
|
87c3d73fbc | ||
|
|
564b8aaa8d | ||
|
|
7cc6343ba1 | ||
|
|
c9048143cc | ||
|
|
203a67ee86 | ||
|
|
3c7c121c7d | ||
|
|
0e5e28b4da | ||
|
|
22e4082825 | ||
|
|
0c3ea75cac | ||
|
|
a451386e0a | ||
|
|
8bd65d535e | ||
|
|
c5585df135 | ||
|
|
385819caf0 | ||
|
|
3f7bd94a69 | ||
|
|
6fd03f37f7 | ||
|
|
65989b3a5a | ||
|
|
5f6726f173 | ||
|
|
fda3ec158c | ||
|
|
c872246b8c | ||
|
|
e9a239e419 | ||
|
|
bc256ad369 | ||
|
|
7185aa4ba5 | ||
|
|
de5b093d62 | ||
|
|
2c69cbe775 | ||
|
|
285d14e996 | ||
|
|
3c73eb5a27 | ||
|
|
95d88709f9 | ||
|
|
56f5a00417 | ||
|
|
e9bdf60f82 | ||
|
|
d12fb96dc2 | ||
|
|
9dcc56c821 | ||
|
|
2265fcd1b4 | ||
|
|
3b58c67202 | ||
|
|
14fa2d45d8 | ||
|
|
ac1dde042a | ||
|
|
5011838b32 | ||
|
|
3934089415 | ||
|
|
bf438cb182 | ||
|
|
e0fd0f51ea | ||
|
|
2f9b52bd1b |
No files matched your search
@@ -56,3 +56,12 @@ coverage/
|
|||||||
test-driver
|
test-driver
|
||||||
nbproject/
|
nbproject/
|
||||||
*.[si]
|
*.[si]
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.apks
|
||||||
|
*.idsig
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
src/frontends/android/app/src/main/jni/openssl/
|
||||||
|
src/frontends/android/local.properties
|
||||||
|
*.hprof
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Память проекта
|
||||||
|
|
||||||
|
Основная работа в репозитории — Android-приложение SHX VPN (`src/frontends/android`), форк клиента strongSwan.
|
||||||
|
Описание приложения, кода, локализации и релиза: `src/frontends/android/docs/README.md`.
|
||||||
|
Дизайн: `src/frontends/android/docs/design/README.md` (макет «Финал 2026»).
|
||||||
|
|
||||||
|
## Android-приложение: дизайн и UI
|
||||||
|
|
||||||
|
- **Эталон — макет «Финал 2026»** (артборды `F_*`, тёмная тема `D_*`): живой холст https://claude.ai/artifact/UCC88C9hkU3hDU2YwMck1a,
|
||||||
|
копия исходников в `docs/design/final-2026/`. Экраны делаются строго по макету; новый экран сначала появляется на холсте.
|
||||||
|
- UI собирается только из компонентов макета: `ui/compose/design/ShxKit.kt`, `ShxIcons.kt`, цвета и шрифты `ui/compose/theme/ShxDesign.kt`
|
||||||
|
(`LocalShxColors`, `ShxType`). Сторонние цвета, размеры и стандартные M3-диалоги не использовать.
|
||||||
|
- Изменили экран в коде — обновите `F_*` и парный `D_*` на холсте и копию в `docs/design/final-2026/`.
|
||||||
|
|
||||||
|
## Android-приложение: строки и переводы
|
||||||
|
|
||||||
|
- Языки: en (база), ru, de, pl, uk, zh-rCN, zh-rTW. Новая строка добавляется сразу во все семь `values*/strings.xml`.
|
||||||
|
- Плейсхолдеры и `\n` как в английской строке; кавычки и plurals по языку — см. раздел «Локализация» в `docs/README.md`.
|
||||||
|
- После правок строк смотреть de и uk на эмуляторе (`adb shell cmd locale set-app-locales one.shx.strongswan.ext --locales de-DE`):
|
||||||
|
длинные слова не должны переноситься в навигации и заголовках. Сброс — `--locales ""`.
|
||||||
|
|
||||||
|
## Android-приложение: сборка и выкладка
|
||||||
|
|
||||||
|
- **Бакет S3 (Selectel, `shx-app`) ПРИВАТНЫЙ.** Публичного чтения нет и включать его не нужно:
|
||||||
|
`GET https://s3.ru-7.storage.selcloud.ru/shx-app/latest.json` всегда отдаёт `AccessDenied` — это норма, а не ошибка выкладки.
|
||||||
|
Проверять результат публикации только авторизованно: `aws s3 cp s3://shx-app/latest.json -` (endpoint и ключи из `.env`).
|
||||||
|
- Сборка и публикация — скриптом `build-local.sh` (Git Bash) или `build-local.ps1` в `src/frontends/android`:
|
||||||
|
он читает `.env`, собирает `assembleRelease`, подписывает keystore'ом из `.env`, генерирует `latest.json`
|
||||||
|
(sha256/size/versionCode) и заливает `shx-vpn.apk` + `latest.json` в корень бакета с `Cache-Control: no-cache`.
|
||||||
|
- **Порядок релиза:** `--skip-upload` → smoke-тест APK на эмуляторе (AVD `shx36`, все вкладки, `adb logcat -b crash`) →
|
||||||
|
выгрузка именно проверенного `build/publish/shx-vpn.apk` и `latest.json` через `aws s3 cp` → сверка `latest.json` в бакете.
|
||||||
|
Выгрузка в S3 — только с подтверждения владельца.
|
||||||
|
- **`.env` нельзя парсить через `source`/`eval`**: значения `NOTES_RU` и `AWS_CA_BUNDLE` содержат пробелы и
|
||||||
|
обратные слэши. Разбирать только построчно `KEY=...` (как в `build-local.sh`).
|
||||||
|
- aws cli установлен через pip: `%APPDATA%\Python\Python313\Scripts\aws` (скрипт сам добавляет путь в PATH).
|
||||||
|
- `versionCode` растёт вручную в `app/build.gradle`; перед сборкой сверять с `versionCode` в `latest.json` бакета.
|
||||||
|
- `NOTES_RU` в `.env` — заметки релиза (попадают в `latest.json` → диалог обновления в приложении); обновлять под релиз.
|
||||||
|
`.env` не в git: в worktree лежит своя копия, после релиза перенести `NOTES_RU` в `.env` основного checkout.
|
||||||
|
- **Сборка из worktree:** скопировать игнорируемые входы из основного checkout (`.env` с абсолютным `KEYSTORE_FILE`,
|
||||||
|
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
|
||||||
|
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
|
||||||
|
- Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись.
|
||||||
|
|
||||||
|
## Android-приложение: выкладка через панель SHX VPN
|
||||||
|
|
||||||
|
Когда владелец просит **«собери и выложи»** (или «собери и загрузи в панель»), выкладка идёт через панель
|
||||||
|
`https://apk.shnt.top`, а не через `aws s3 cp`. Сама просьба и есть подтверждение владельца на загрузку.
|
||||||
|
|
||||||
|
- Команда (из `src/frontends/android`, с `ORG_GRADLE_PROJECT_shortNdkCommands=true` при сборке из worktree):
|
||||||
|
`./gradlew uploadShx` — собирает `assembleRelease` и загружает APK **черновиком** в канал `beta`, тип `phone`.
|
||||||
|
- Опубликовать сразу — только если владелец сказал «опубликуй»: `./gradlew uploadShx -PshxPublish -PshxRollout=<процент>`.
|
||||||
|
Без явного процента спросить его. Канал релиза — `-PshxChannel=release`, другой тип — `-PshxType=<id>`.
|
||||||
|
- Заметки «Что нового» берутся из `NOTES_RU` в `.env`; другие — `-PshxNotes="..."`. Перед выкладкой проверить, что
|
||||||
|
`NOTES_RU` и `versionCode` в `app/build.gradle` относятся к этому релизу (панель отклонит уже загруженный код версии).
|
||||||
|
- Нужны `SHX_URL` и `SHX_TOKEN` (заданы у пользователя Windows; скрипт `scripts/upload-apk.mjs` читает их и из реестра,
|
||||||
|
если процесс запущен раньше). Не печатать значение токена.
|
||||||
|
- Подпись: release APK подписывается keystore из `.env` (`KEYSTORE_FILE`); неподписанный APK задача не загружает.
|
||||||
|
- После загрузки сообщить владельцу ссылку на версию из вывода скрипта и статус (черновик или текущая с процентом).
|
||||||
|
- Сборка для старых приложений 2.19.x (`latest.json` в корне бакета) по-прежнему идёт через `build-local.*` и
|
||||||
|
требует отдельного подтверждения.
|
||||||
@@ -2,5 +2,4 @@ charon.plugins.eap-tnc.max_message_count = 10
|
|||||||
Maximum number of processed EAP-TNC packets (0 = no limit).
|
Maximum number of processed EAP-TNC packets (0 = no limit).
|
||||||
|
|
||||||
charon.plugins.eap-tnc.protocol = tnccs-2.0
|
charon.plugins.eap-tnc.protocol = tnccs-2.0
|
||||||
IF-TNCCS protocol version to be used (_tnccs-1.1_, _tnccs-2.0_,
|
IF-TNCCS protocol version to be used (_tnccs-2.0_ is the only option).
|
||||||
_tnccs-dynamic_).
|
|
||||||
+9
-6
@@ -253,7 +253,6 @@ ARG_ENABL_SET([tnc-imc], [enable TNC IMC module.])
|
|||||||
ARG_ENABL_SET([tnc-imv], [enable TNC IMV module.])
|
ARG_ENABL_SET([tnc-imv], [enable TNC IMV module.])
|
||||||
ARG_ENABL_SET([tnc-pdp], [enable TNC policy decision point module.])
|
ARG_ENABL_SET([tnc-pdp], [enable TNC policy decision point module.])
|
||||||
ARG_ENABL_SET([tnccs-20], [enable TNCCS 2.0 protocol module.])
|
ARG_ENABL_SET([tnccs-20], [enable TNCCS 2.0 protocol module.])
|
||||||
ARG_ENABL_SET([tnccs-dynamic], [enable dynamic TNCCS protocol discovery module.])
|
|
||||||
# misc plugins
|
# misc plugins
|
||||||
ARG_ENABL_SET([android-log], [enable Android specific logger plugin.])
|
ARG_ENABL_SET([android-log], [enable Android specific logger plugin.])
|
||||||
ARG_ENABL_SET([bypass-lan], [enable plugin to install bypass policies for local subnets.])
|
ARG_ENABL_SET([bypass-lan], [enable plugin to install bypass policies for local subnets.])
|
||||||
@@ -289,7 +288,7 @@ ARG_ENABL_SET([cert-enroll], [enable automatic certificate enrollment via EST
|
|||||||
# optional features
|
# optional features
|
||||||
ARG_ENABL_SET([bfd-backtraces], [use binutils libbfd to resolve backtraces for memory leaks and segfaults.])
|
ARG_ENABL_SET([bfd-backtraces], [use binutils libbfd to resolve backtraces for memory leaks and segfaults.])
|
||||||
ARG_ENABL_SET([dbghelp-backtraces],[use dbghlp.dll on Windows to create and print backtraces for memory leaks and segfaults.])
|
ARG_ENABL_SET([dbghelp-backtraces],[use dbghlp.dll on Windows to create and print backtraces for memory leaks and segfaults.])
|
||||||
ARG_DISBL_SET([ikev1], [disable IKEv1 protocol support in charon.])
|
ARG_ENABL_SET([ikev1], [enable deprecated IKEv1 protocol support in charon.])
|
||||||
ARG_DISBL_SET([ikev2], [disable IKEv2 protocol support in charon.])
|
ARG_DISBL_SET([ikev2], [disable IKEv2 protocol support in charon.])
|
||||||
ARG_ENABL_SET([integrity-test], [enable integrity testing of libstrongswan and plugins.])
|
ARG_ENABL_SET([integrity-test], [enable integrity testing of libstrongswan and plugins.])
|
||||||
ARG_DISBL_SET([load-warning], [disable the charon plugin load option warning in starter.])
|
ARG_DISBL_SET([load-warning], [disable the charon plugin load option warning in starter.])
|
||||||
@@ -429,7 +428,7 @@ if test x$eap_radius = xtrue -o x$radattr = xtrue -o x$tnc_pdp = xtrue; then
|
|||||||
radius=true;
|
radius=true;
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if test x$tnc_imc = xtrue -o x$tnc_imv = xtrue -o x$tnccs_20 = xtrue -o x$tnccs_dynamic = xtrue -o x$eap_tnc = xtrue; then
|
if test x$tnc_imc = xtrue -o x$tnc_imv = xtrue -o x$tnccs_20 = xtrue -o x$eap_tnc = xtrue; then
|
||||||
tnc_tnccs=true;
|
tnc_tnccs=true;
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1557,7 +1556,6 @@ ADD_PLUGIN([tnc-imc], [t charon])
|
|||||||
ADD_PLUGIN([tnc-imv], [t charon])
|
ADD_PLUGIN([tnc-imv], [t charon])
|
||||||
ADD_PLUGIN([tnc-tnccs], [t charon])
|
ADD_PLUGIN([tnc-tnccs], [t charon])
|
||||||
ADD_PLUGIN([tnccs-20], [t charon])
|
ADD_PLUGIN([tnccs-20], [t charon])
|
||||||
ADD_PLUGIN([tnccs-dynamic], [t charon])
|
|
||||||
ADD_PLUGIN([dhcp], [c charon])
|
ADD_PLUGIN([dhcp], [c charon])
|
||||||
ADD_PLUGIN([osx-attr], [c charon cmd])
|
ADD_PLUGIN([osx-attr], [c charon cmd])
|
||||||
ADD_PLUGIN([p-cscf], [c charon cmd])
|
ADD_PLUGIN([p-cscf], [c charon cmd])
|
||||||
@@ -1709,7 +1707,6 @@ AM_CONDITIONAL(USE_TNC_IMC, test x$tnc_imc = xtrue)
|
|||||||
AM_CONDITIONAL(USE_TNC_IMV, test x$tnc_imv = xtrue)
|
AM_CONDITIONAL(USE_TNC_IMV, test x$tnc_imv = xtrue)
|
||||||
AM_CONDITIONAL(USE_TNC_TNCCS, test x$tnc_tnccs = xtrue)
|
AM_CONDITIONAL(USE_TNC_TNCCS, test x$tnc_tnccs = xtrue)
|
||||||
AM_CONDITIONAL(USE_TNCCS_20, test x$tnccs_20 = xtrue)
|
AM_CONDITIONAL(USE_TNCCS_20, test x$tnccs_20 = xtrue)
|
||||||
AM_CONDITIONAL(USE_TNCCS_DYNAMIC, test x$tnccs_dynamic = xtrue)
|
|
||||||
AM_CONDITIONAL(USE_IMC_TEST, test x$imc_test = xtrue)
|
AM_CONDITIONAL(USE_IMC_TEST, test x$imc_test = xtrue)
|
||||||
AM_CONDITIONAL(USE_IMV_TEST, test x$imv_test = xtrue)
|
AM_CONDITIONAL(USE_IMV_TEST, test x$imv_test = xtrue)
|
||||||
AM_CONDITIONAL(USE_IMC_SCANNER, test x$imc_scanner = xtrue)
|
AM_CONDITIONAL(USE_IMC_SCANNER, test x$imc_scanner = xtrue)
|
||||||
@@ -1943,7 +1940,6 @@ AC_CONFIG_FILES([
|
|||||||
src/libtnccs/plugins/tnc_imc/Makefile
|
src/libtnccs/plugins/tnc_imc/Makefile
|
||||||
src/libtnccs/plugins/tnc_imv/Makefile
|
src/libtnccs/plugins/tnc_imv/Makefile
|
||||||
src/libtnccs/plugins/tnccs_20/Makefile
|
src/libtnccs/plugins/tnccs_20/Makefile
|
||||||
src/libtnccs/plugins/tnccs_dynamic/Makefile
|
|
||||||
src/libpttls/Makefile
|
src/libpttls/Makefile
|
||||||
src/libimcv/Makefile
|
src/libimcv/Makefile
|
||||||
src/libimcv/plugins/imc_test/Makefile
|
src/libimcv/plugins/imc_test/Makefile
|
||||||
@@ -2112,3 +2108,10 @@ AC_MSG_RESULT([libcharon: $c_plugins])
|
|||||||
AC_MSG_RESULT([libtnccs: $t_plugins])
|
AC_MSG_RESULT([libtnccs: $t_plugins])
|
||||||
AC_MSG_RESULT([libtpmtss: $p_plugins])
|
AC_MSG_RESULT([libtpmtss: $p_plugins])
|
||||||
AC_MSG_RESULT([])
|
AC_MSG_RESULT([])
|
||||||
|
|
||||||
|
if test x$ikev1 = xtrue; then
|
||||||
|
AC_MSG_WARN(m4_normalize([
|
||||||
|
The IKEv1 protocol is deprecated. Support will be removed soon (disable
|
||||||
|
this warning with --disable-ikev1)]))
|
||||||
|
AC_MSG_RESULT([])
|
||||||
|
fi
|
||||||
@@ -336,6 +336,12 @@ int main(int argc, char *argv[])
|
|||||||
lib->plugins->add_static_features(lib->plugins, "tkm-backend", features,
|
lib->plugins->add_static_features(lib->plugins, "tkm-backend", features,
|
||||||
countof(features), TRUE, NULL, NULL);
|
countof(features), TRUE, NULL, NULL);
|
||||||
|
|
||||||
|
if (!register_proposal_mapping())
|
||||||
|
{
|
||||||
|
DBG1(DBG_DMN, "no proposal mapping defined - aborting %s", dmn_name);
|
||||||
|
goto deinit;
|
||||||
|
}
|
||||||
|
|
||||||
if (!register_ke_mapping())
|
if (!register_ke_mapping())
|
||||||
{
|
{
|
||||||
DBG1(DBG_DMN, "no KE group mapping defined - aborting %s", dmn_name);
|
DBG1(DBG_DMN, "no KE group mapping defined - aborting %s", dmn_name);
|
||||||
@@ -428,6 +434,7 @@ int main(int argc, char *argv[])
|
|||||||
lib->encoding->remove_encoder(lib->encoding, tkm_encoder_encode);
|
lib->encoding->remove_encoder(lib->encoding, tkm_encoder_encode);
|
||||||
|
|
||||||
deinit:
|
deinit:
|
||||||
|
destroy_proposal_mapping();
|
||||||
destroy_ke_mapping();
|
destroy_ke_mapping();
|
||||||
destroy_ca_mapping();
|
destroy_ca_mapping();
|
||||||
libcharon_deinit();
|
libcharon_deinit();
|
||||||
|
|||||||
@@ -156,8 +156,8 @@ METHOD(kernel_ipsec_t, add_sa, status_t,
|
|||||||
nonce_loc_id = tkm->chunk_map->get_id(tkm->chunk_map, nonce_loc);
|
nonce_loc_id = tkm->chunk_map->get_id(tkm->chunk_map, nonce_loc);
|
||||||
if (nonce_loc_id == 0 && esa.ke_ids.size == 0)
|
if (nonce_loc_id == 0 && esa.ke_ids.size == 0)
|
||||||
{
|
{
|
||||||
if (ike_esa_create_first(esa_id, esa.isa_id, data->reqid, 1, flags,
|
if (ike_esa_create_first(esa_id, esa.isa_id, data->reqid, esa.ea_id,
|
||||||
spi_loc, spi_rem) != TKM_OK)
|
flags, spi_loc, spi_rem) != TKM_OK)
|
||||||
{
|
{
|
||||||
DBG1(DBG_KNL, "child SA (%llu, first) creation failed", esa_id);
|
DBG1(DBG_KNL, "child SA (%llu, first) creation failed", esa_id);
|
||||||
goto failure;
|
goto failure;
|
||||||
@@ -167,7 +167,7 @@ METHOD(kernel_ipsec_t, add_sa, status_t,
|
|||||||
else if (nonce_loc_id != 0 && esa.ke_ids.size == 0)
|
else if (nonce_loc_id != 0 && esa.ke_ids.size == 0)
|
||||||
{
|
{
|
||||||
chunk_to_sequence(nonce_rem, &nc_rem, sizeof(nonce_type));
|
chunk_to_sequence(nonce_rem, &nc_rem, sizeof(nonce_type));
|
||||||
if (ike_esa_create_no_pfs(esa_id, esa.isa_id, data->reqid, 1,
|
if (ike_esa_create_no_pfs(esa_id, esa.isa_id, data->reqid, esa.ea_id,
|
||||||
nonce_loc_id, nc_rem, flags,
|
nonce_loc_id, nc_rem, flags,
|
||||||
spi_loc, spi_rem) != TKM_OK)
|
spi_loc, spi_rem) != TKM_OK)
|
||||||
{
|
{
|
||||||
@@ -181,7 +181,7 @@ METHOD(kernel_ipsec_t, add_sa, status_t,
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
chunk_to_sequence(nonce_rem, &nc_rem, sizeof(nonce_type));
|
chunk_to_sequence(nonce_rem, &nc_rem, sizeof(nonce_type));
|
||||||
if (ike_esa_create(esa_id, esa.isa_id, data->reqid, 1, esa.ke_ids,
|
if (ike_esa_create(esa_id, esa.isa_id, data->reqid, esa.ea_id, esa.ke_ids,
|
||||||
nonce_loc_id, nc_rem, flags, spi_loc,
|
nonce_loc_id, nc_rem, flags, spi_loc,
|
||||||
spi_rem) != TKM_OK)
|
spi_rem) != TKM_OK)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2015-2020 Tobias Brunner
|
* Copyright (C) 2015-2022 Tobias Brunner
|
||||||
* Copyright (C) 2012 Reto Buerki
|
* Copyright (C) 2012 Reto Buerki
|
||||||
* Copyright (C) 2012 Adrian-Ken Rueegsegger
|
* Copyright (C) 2012 Adrian-Ken Rueegsegger
|
||||||
*
|
*
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
#include <daemon.h>
|
#include <daemon.h>
|
||||||
#include <tkm/constants.h>
|
#include <tkm/constants.h>
|
||||||
#include <tkm/client.h>
|
#include <tkm/client.h>
|
||||||
|
#include <collections/array.h>
|
||||||
#include <crypto/hashers/hash_algorithm_set.h>
|
#include <crypto/hashers/hash_algorithm_set.h>
|
||||||
|
|
||||||
#include "tkm.h"
|
#include "tkm.h"
|
||||||
@@ -30,6 +31,9 @@
|
|||||||
|
|
||||||
typedef struct private_tkm_keymat_t private_tkm_keymat_t;
|
typedef struct private_tkm_keymat_t private_tkm_keymat_t;
|
||||||
|
|
||||||
|
static array_t *ike_proposal_map = NULL;
|
||||||
|
static array_t *esp_proposal_map = NULL;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Private data of a keymat_t object.
|
* Private data of a keymat_t object.
|
||||||
*/
|
*/
|
||||||
@@ -94,6 +98,48 @@ METHOD(keymat_t, create_nonce_gen, nonce_gen_t*,
|
|||||||
return lib->crypto->create_nonce_gen(lib->crypto);
|
return lib->crypto->create_nonce_gen(lib->crypto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Proposal mapping entry
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
/** Proposal */
|
||||||
|
proposal_t *proposal;
|
||||||
|
/** Id */
|
||||||
|
uint64_t id;
|
||||||
|
} entry_t;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine the id for the given proposal
|
||||||
|
*/
|
||||||
|
static uint64_t get_proposal_id(proposal_t *proposal)
|
||||||
|
{
|
||||||
|
array_t *map;
|
||||||
|
entry_t entry;
|
||||||
|
int i;
|
||||||
|
|
||||||
|
switch (proposal->get_protocol(proposal))
|
||||||
|
{
|
||||||
|
case PROTO_IKE:
|
||||||
|
map = ike_proposal_map;
|
||||||
|
break;
|
||||||
|
case PROTO_ESP:
|
||||||
|
map = esp_proposal_map;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
for (i = 0; i < array_count(map); i++)
|
||||||
|
{
|
||||||
|
array_get(map, i, &entry);
|
||||||
|
/* we never match DH groups as they are mapped and passed separately */
|
||||||
|
if (entry.proposal->matches(entry.proposal, proposal, PROPOSAL_SKIP_KE))
|
||||||
|
{
|
||||||
|
return entry.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Concatenate the TKM KE IDs of the passed key exchanges
|
* Concatenate the TKM KE IDs of the passed key exchanges
|
||||||
*/
|
*/
|
||||||
@@ -123,7 +169,7 @@ METHOD(keymat_v2_t, derive_ike_keys, bool,
|
|||||||
chunk_t nonce_i, chunk_t nonce_r, ike_sa_id_t *id,
|
chunk_t nonce_i, chunk_t nonce_r, ike_sa_id_t *id,
|
||||||
pseudo_random_function_t rekey_function, chunk_t rekey_skd)
|
pseudo_random_function_t rekey_function, chunk_t rekey_skd)
|
||||||
{
|
{
|
||||||
uint64_t nc_id = 0, spi_loc, spi_rem;
|
uint64_t nc_id = 0, ia_id, spi_loc, spi_rem;
|
||||||
chunk_t *nonce;
|
chunk_t *nonce;
|
||||||
ke_ids_type ke_ids;
|
ke_ids_type ke_ids;
|
||||||
nonce_type nonce_rem;
|
nonce_type nonce_rem;
|
||||||
@@ -152,6 +198,13 @@ METHOD(keymat_v2_t, derive_ike_keys, bool,
|
|||||||
spi_rem = id->get_initiator_spi(id);
|
spi_rem = id->get_initiator_spi(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ia_id = get_proposal_id(proposal);
|
||||||
|
if (!ia_id)
|
||||||
|
{
|
||||||
|
DBG1(DBG_IKE, "unable to determine id for proposal %P", proposal);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
if (rekey_function == PRF_UNDEFINED)
|
if (rekey_function == PRF_UNDEFINED)
|
||||||
{
|
{
|
||||||
/* Acquire nonce context id */
|
/* Acquire nonce context id */
|
||||||
@@ -168,11 +221,12 @@ METHOD(keymat_v2_t, derive_ike_keys, bool,
|
|||||||
DBG1(DBG_IKE, "unable to acquire ae context id");
|
DBG1(DBG_IKE, "unable to acquire ae context id");
|
||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
DBG1(DBG_IKE, "deriving IKE keys (nc: %llu, ke: %llu, spi_loc: %llx, "
|
DBG1(DBG_IKE, "deriving IKE keys (nc: %llu, ia: %llu, ke: %llu, "
|
||||||
"spi_rem: %llx)", nc_id, ke_ids.data[0], spi_loc, spi_rem);
|
"spi_loc: %llx, spi_rem: %llx)", nc_id, ia_id, ke_ids.data[0],
|
||||||
res = ike_isa_create(this->isa_ctx_id, this->ae_ctx_id, 1, ke_ids.data[0],
|
spi_loc, spi_rem);
|
||||||
nc_id, nonce_rem, this->initiator, spi_loc, spi_rem,
|
res = ike_isa_create(this->isa_ctx_id, this->ae_ctx_id, ia_id,
|
||||||
&block_len, &icv_len, &iv_len);
|
ke_ids.data[0], nc_id, nonce_rem, this->initiator,
|
||||||
|
spi_loc, spi_rem, &block_len, &icv_len, &iv_len);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -187,9 +241,10 @@ METHOD(keymat_v2_t, derive_ike_keys, bool,
|
|||||||
|
|
||||||
if (this->ae_ctx_id == isa_info.ae_id)
|
if (this->ae_ctx_id == isa_info.ae_id)
|
||||||
{
|
{
|
||||||
DBG1(DBG_IKE, "deriving IKE keys (parent_isa: %llu, ae: %llu, "
|
DBG1(DBG_IKE, "deriving IKE keys (parent_isa: %llu, ae: %llu, ia: "
|
||||||
"ke: %llu, spi_loc: %llx, spi_rem: %llx)", isa_info.parent_isa_id,
|
"%llu, ke: %llu, spi_loc: %llx, spi_rem: %llx)",
|
||||||
isa_info.ae_id, ke_ids.data[0], spi_loc, spi_rem);
|
isa_info.parent_isa_id, isa_info.ae_id, ia_id, ke_ids.data[0],
|
||||||
|
spi_loc, spi_rem);
|
||||||
|
|
||||||
res = ike_isa_update(this->isa_ctx_id, ke_ids.data[0]);
|
res = ike_isa_update(this->isa_ctx_id, ke_ids.data[0]);
|
||||||
}
|
}
|
||||||
@@ -206,15 +261,16 @@ METHOD(keymat_v2_t, derive_ike_keys, bool,
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
DBG1(DBG_IKE, "deriving IKE keys (parent_isa: %llu, ae: %llu, nc: %llu, "
|
DBG1(DBG_IKE, "deriving IKE keys (parent_isa: %llu, ae: %llu, nc: "
|
||||||
"ke: %llu, spi_loc: %llx, spi_rem: %llx)", isa_info.parent_isa_id,
|
"%llu, ia: %llu, ke: %llu, spi_loc: %llx, spi_rem: %llx)",
|
||||||
isa_info.ae_id, nc_id, ke_ids.data[0], spi_loc, spi_rem);
|
isa_info.parent_isa_id, isa_info.ae_id, nc_id, ia_id,
|
||||||
|
ke_ids.data[0], spi_loc, spi_rem);
|
||||||
|
|
||||||
this->ae_ctx_id = isa_info.ae_id;
|
this->ae_ctx_id = isa_info.ae_id;
|
||||||
res = ike_isa_create_child(this->isa_ctx_id, isa_info.parent_isa_id, 1,
|
res = ike_isa_create_child(this->isa_ctx_id, isa_info.parent_isa_id,
|
||||||
ke_ids, nc_id, nonce_rem, this->initiator,
|
ia_id, ke_ids, nc_id, nonce_rem,
|
||||||
spi_loc, spi_rem, &block_len, &icv_len,
|
this->initiator, spi_loc, spi_rem,
|
||||||
&iv_len);
|
&block_len, &icv_len, &iv_len);
|
||||||
}
|
}
|
||||||
|
|
||||||
chunk_free(&rekey_skd);
|
chunk_free(&rekey_skd);
|
||||||
@@ -254,18 +310,27 @@ METHOD(keymat_v2_t, derive_child_keys, bool,
|
|||||||
{
|
{
|
||||||
esa_info_t *esa_info_i, *esa_info_r;
|
esa_info_t *esa_info_i, *esa_info_r;
|
||||||
ke_ids_type ke_ids = {};
|
ke_ids_type ke_ids = {};
|
||||||
|
ea_id_type ea_id;
|
||||||
|
|
||||||
if (kes && !concat_ke_ids(kes, &ke_ids))
|
if (kes && !concat_ke_ids(kes, &ke_ids))
|
||||||
{
|
{
|
||||||
return FALSE;
|
return FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ea_id = get_proposal_id(proposal);
|
||||||
|
if (!ea_id)
|
||||||
|
{
|
||||||
|
DBG1(DBG_IKE, "unable to determine id for proposal %P", proposal);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
|
||||||
INIT(esa_info_i,
|
INIT(esa_info_i,
|
||||||
.isa_id = this->isa_ctx_id,
|
.isa_id = this->isa_ctx_id,
|
||||||
.spi_l = proposal->get_spi(proposal),
|
.spi_l = proposal->get_spi(proposal),
|
||||||
.nonce_i = chunk_clone(nonce_i),
|
.nonce_i = chunk_clone(nonce_i),
|
||||||
.nonce_r = chunk_clone(nonce_r),
|
.nonce_r = chunk_clone(nonce_r),
|
||||||
.is_encr_r = FALSE,
|
.is_encr_r = FALSE,
|
||||||
|
.ea_id = ea_id,
|
||||||
.ke_ids = ke_ids,
|
.ke_ids = ke_ids,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -275,12 +340,13 @@ METHOD(keymat_v2_t, derive_child_keys, bool,
|
|||||||
.nonce_i = chunk_clone(nonce_i),
|
.nonce_i = chunk_clone(nonce_i),
|
||||||
.nonce_r = chunk_clone(nonce_r),
|
.nonce_r = chunk_clone(nonce_r),
|
||||||
.is_encr_r = TRUE,
|
.is_encr_r = TRUE,
|
||||||
|
.ea_id = ea_id,
|
||||||
.ke_ids = ke_ids,
|
.ke_ids = ke_ids,
|
||||||
);
|
);
|
||||||
|
|
||||||
DBG1(DBG_CHD, "passing on esa info (isa: %llu, spi_l: %x, "
|
DBG1(DBG_CHD, "passing on esa info (isa: %llu, spi_l: %x, ea_id: %llu, "
|
||||||
"ke_id[%llu]: %llu)", esa_info_i->isa_id, ntohl(esa_info_i->spi_l),
|
"ke_id[%llu]: %llu)", esa_info_i->isa_id, ntohl(esa_info_i->spi_l),
|
||||||
esa_info_i->ke_ids.size, esa_info_i->ke_ids.data[0]);
|
esa_info_i->ea_id, esa_info_i->ke_ids.size, esa_info_i->ke_ids.data[0]);
|
||||||
|
|
||||||
/* store ESA info in encr_i/r, which is passed to add_sa */
|
/* store ESA info in encr_i/r, which is passed to add_sa */
|
||||||
*encr_i = chunk_create((u_char *)esa_info_i, sizeof(esa_info_t));
|
*encr_i = chunk_create((u_char *)esa_info_i, sizeof(esa_info_t));
|
||||||
@@ -445,7 +511,82 @@ METHOD(tkm_keymat_t, get_peer_init_msg, chunk_t*,
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* See header.
|
* Init proposal mappings for a specific protocol
|
||||||
|
*/
|
||||||
|
static int init_proposal_map(array_t *map, protocol_id_t proto)
|
||||||
|
{
|
||||||
|
enumerator_t *enumerator;
|
||||||
|
entry_t entry;
|
||||||
|
char *id_str, *proposal_str;
|
||||||
|
|
||||||
|
enumerator = lib->settings->create_key_value_enumerator(lib->settings,
|
||||||
|
"%s.proposal_mapping.%N", lib->ns,
|
||||||
|
protocol_id_lower_names, proto);
|
||||||
|
|
||||||
|
while (enumerator->enumerate(enumerator, &id_str, &proposal_str))
|
||||||
|
{
|
||||||
|
entry.proposal = proposal_create_from_string_unchecked(proto,
|
||||||
|
proposal_str);
|
||||||
|
if (!entry.proposal)
|
||||||
|
{
|
||||||
|
enumerator->destroy(enumerator);
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
entry.id = settings_value_as_uint64(id_str, 0);
|
||||||
|
array_insert(map, ARRAY_TAIL, &entry);
|
||||||
|
}
|
||||||
|
enumerator->destroy(enumerator);
|
||||||
|
|
||||||
|
if (!array_count(map))
|
||||||
|
{
|
||||||
|
return FALSE;
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Described in header
|
||||||
|
*/
|
||||||
|
int register_proposal_mapping()
|
||||||
|
{
|
||||||
|
ike_proposal_map = array_create(sizeof(entry_t), 4);
|
||||||
|
esp_proposal_map = array_create(sizeof(entry_t), 4);
|
||||||
|
|
||||||
|
if (!init_proposal_map(ike_proposal_map, PROTO_IKE) ||
|
||||||
|
!init_proposal_map(esp_proposal_map, PROTO_ESP))
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return array_count(ike_proposal_map) + array_count(esp_proposal_map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy given proposal mappings
|
||||||
|
*/
|
||||||
|
static void destroy_proposal_map(array_t *map)
|
||||||
|
{
|
||||||
|
entry_t entry;
|
||||||
|
|
||||||
|
while (array_remove(map, ARRAY_HEAD, &entry))
|
||||||
|
{
|
||||||
|
entry.proposal->destroy(entry.proposal);
|
||||||
|
}
|
||||||
|
array_destroy(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Described in header
|
||||||
|
*/
|
||||||
|
void destroy_proposal_mapping()
|
||||||
|
{
|
||||||
|
destroy_proposal_map(ike_proposal_map);
|
||||||
|
destroy_proposal_map(esp_proposal_map);
|
||||||
|
ike_proposal_map = NULL;
|
||||||
|
esp_proposal_map = NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Described in header
|
||||||
*/
|
*/
|
||||||
tkm_keymat_t *tkm_keymat_create(bool initiator)
|
tkm_keymat_t *tkm_keymat_create(bool initiator)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -67,6 +67,18 @@ struct tkm_keymat_t {
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads IKE/ESP proposal to TKM id mapping from config.
|
||||||
|
*
|
||||||
|
* @return number of registered mappings
|
||||||
|
*/
|
||||||
|
int register_proposal_mapping();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroy IKE/ESP proposal to TKM id mapping.
|
||||||
|
*/
|
||||||
|
void destroy_proposal_mapping();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create TKM keymat instance.
|
* Create TKM keymat instance.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ struct esa_info_t {
|
|||||||
*/
|
*/
|
||||||
bool is_encr_r;
|
bool is_encr_r;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ESP algorithm proposal id.
|
||||||
|
*/
|
||||||
|
ea_id_type ea_id;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Key Exchange context ids.
|
* Key Exchange context ids.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
#include "tkm_nonceg.h"
|
#include "tkm_nonceg.h"
|
||||||
#include "tkm_key_exchange.h"
|
#include "tkm_key_exchange.h"
|
||||||
#include "tkm_kernel_ipsec.h"
|
#include "tkm_kernel_ipsec.h"
|
||||||
|
#include "tkm_keymat.h"
|
||||||
|
|
||||||
/* declare test suite constructors */
|
/* declare test suite constructors */
|
||||||
#define TEST_SUITE(x) test_suite_t* x();
|
#define TEST_SUITE(x) test_suite_t* x();
|
||||||
@@ -79,6 +80,11 @@ static bool test_runner_init(bool init)
|
|||||||
lib->ns, MODP_3072_BIT);
|
lib->ns, MODP_3072_BIT);
|
||||||
lib->settings->set_int(lib->settings, "%s.ke_mapping.%d", 2,
|
lib->settings->set_int(lib->settings, "%s.ke_mapping.%d", 2,
|
||||||
lib->ns, MODP_4096_BIT);
|
lib->ns, MODP_4096_BIT);
|
||||||
|
lib->settings->set_str(lib->settings, "%s.proposal_mapping.ike.%d",
|
||||||
|
"aes256-sha512-prfsha512", lib->ns, 1);
|
||||||
|
lib->settings->set_str(lib->settings, "%s.proposal_mapping.esp.%d",
|
||||||
|
"aes256-sha512-esn-noesn", lib->ns, 1);
|
||||||
|
register_proposal_mapping();
|
||||||
register_ke_mapping();
|
register_ke_mapping();
|
||||||
|
|
||||||
plugin_loader_add_plugindirs(BUILDDIR "/src/libstrongswan/plugins",
|
plugin_loader_add_plugindirs(BUILDDIR "/src/libstrongswan/plugins",
|
||||||
@@ -100,6 +106,7 @@ static bool test_runner_init(bool init)
|
|||||||
result = FALSE;
|
result = FALSE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy_proposal_mapping();
|
||||||
destroy_ke_mapping();
|
destroy_ke_mapping();
|
||||||
libcharon_deinit();
|
libcharon_deinit();
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!--
|
||||||
|
Параметры сборки и публикации обновлений.
|
||||||
|
Скопируйте этот файл в .env и заполните значения:
|
||||||
|
cp .env.example .env
|
||||||
|
.env не попадает в git (добавлен в .gitignore).
|
||||||
|
Строки в формате KEY=value; комментарии - строки, начинающиеся с #.
|
||||||
|
-->
|
||||||
|
|
||||||
|
# --- S3 (Selectel Object Storage) ---
|
||||||
|
S3_ENDPOINT=https://s3.ru-7.storage.selcloud.ru
|
||||||
|
S3_REGION=ru-7
|
||||||
|
S3_BUCKET=shx-app
|
||||||
|
# Пусто = файлы кладутся в корень бакета (latest.json и shx-vpn.apk)
|
||||||
|
S3_PREFIX=
|
||||||
|
S3_ACCESS_KEY=
|
||||||
|
S3_SECRET_KEY=
|
||||||
|
|
||||||
|
# Если AWS CLI ругается на SSL (self-signed certificate in certificate chain),
|
||||||
|
# укажите CA-бандл, содержащий корень сертификата эндпоинта
|
||||||
|
# (для Selectel - GlobalSign Root CA - R6; например бандл из Git for Windows):
|
||||||
|
# AWS_CA_BUNDLE=C:\Program Files\Git\mingw64\etc\ssl\certs\ca-bundle.crt
|
||||||
|
|
||||||
|
# Публичная статичная ссылка на бакет (без завершающего слеша).
|
||||||
|
# Требуется публичный доступ на чтение бакета в панели Selectel.
|
||||||
|
# Зашивается в APK как адрес проверки обновлений.
|
||||||
|
S3_PUBLIC_BASE_URL=https://s3.ru-7.storage.selcloud.ru/shx-app
|
||||||
|
|
||||||
|
# --- Подпись APK (опционально) ---
|
||||||
|
# Если KEYSTORE_FILE не задан, release APK подписывается debug-ключом
|
||||||
|
# (~/.android/debug.keystore) - это нужно, чтобы обновление встало поверх
|
||||||
|
# сборок, установленных из Android Studio.
|
||||||
|
# Путь - относительно каталога android-проекта или абсолютный.
|
||||||
|
# KEYSTORE_FILE=release.keystore
|
||||||
|
# KEYSTORE_PASSWORD=
|
||||||
|
# KEY_ALIAS=
|
||||||
|
# KEY_PASSWORD=
|
||||||
|
|
||||||
|
# --- Текст "что нового" в latest.json (опционально, одна строка без кавычек) ---
|
||||||
|
# NOTES_EN=
|
||||||
|
# NOTES_RU=
|
||||||
@@ -4,5 +4,20 @@ build/
|
|||||||
app/build/
|
app/build/
|
||||||
app/src/main/libs
|
app/src/main/libs
|
||||||
app/src/main/obj
|
app/src/main/obj
|
||||||
|
app/src/main/jni/openssl/
|
||||||
*.iml
|
*.iml
|
||||||
local.properties
|
local.properties
|
||||||
|
.env
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.apks
|
||||||
|
*.idsig
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
|
||||||
|
# local release notes for the update manifest (kept out of the repo)
|
||||||
|
NOTES_*.txt
|
||||||
|
keystore/
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
.kotlin/
|
||||||
@@ -1,20 +1,98 @@
|
|||||||
apply plugin: 'com.android.application'
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'org.jetbrains.kotlin.plugin.compose'
|
||||||
|
|
||||||
|
/* Optional .env next to this project: S3/update settings and signing (see .env.example).
|
||||||
|
Environment variables take precedence over .env values. */
|
||||||
|
def dotenv = [:]
|
||||||
|
def envFile = rootProject.file('.env')
|
||||||
|
if (envFile.exists()) {
|
||||||
|
envFile.eachLine { line ->
|
||||||
|
def trimmed = line.trim()
|
||||||
|
if (trimmed && !trimmed.startsWith('#') && trimmed.contains('=')) {
|
||||||
|
def idx = trimmed.indexOf('=')
|
||||||
|
dotenv[trimmed.substring(0, idx).trim()] = trimmed.substring(idx + 1).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
def envOr = { String key, String defaultValue ->
|
||||||
|
(System.getenv(key) ?: dotenv[key] ?: defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
def updateBaseUrl = envOr('S3_PUBLIC_BASE_URL', '').replace('\\', '\\\\').replace('"', '\\"')
|
||||||
|
|
||||||
|
def keystoreFile = envOr('KEYSTORE_FILE', '')
|
||||||
|
def keystorePassword = envOr('KEYSTORE_PASSWORD', '')
|
||||||
|
/* note: locals must not be named keyAlias/keyPassword - those names would
|
||||||
|
shadow the signingConfig methods inside the signingConfigs block */
|
||||||
|
def keystoreKeyAlias = envOr('KEY_ALIAS', '')
|
||||||
|
def keystoreKeyPassword = envOr('KEY_PASSWORD', '')
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = 'org.strongswan.android'
|
namespace = 'org.strongswan.android'
|
||||||
|
|
||||||
defaultConfig {
|
/* minSdk >= 23 flips AGP's default to uncompressed .so (extractNativeLibs=false):
|
||||||
applicationId "org.strongswan.android"
|
* the APK grew 12 -> 23 MB. Updates are downloaded over cellular, so keep
|
||||||
compileSdk = 36
|
* the libraries compressed as before. */
|
||||||
minSdkVersion 21
|
packaging {
|
||||||
targetSdkVersion 36
|
jniLibs {
|
||||||
|
useLegacyPackaging = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
versionCode 96
|
defaultConfig {
|
||||||
versionName "2.6.2"
|
applicationId "one.shx.strongswan.ext"
|
||||||
|
compileSdk = 37
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 36
|
||||||
|
|
||||||
|
versionCode 172
|
||||||
|
versionName "2.19.4"
|
||||||
|
|
||||||
|
/* Base URL of the update storage, trailing slash included */
|
||||||
|
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
||||||
|
|
||||||
|
/* S3 credentials used by the app for authenticated update downloads
|
||||||
|
(same keys as the publish script). They are obfuscated with XOR +
|
||||||
|
Base64 at build time to hinder trivial string extraction.
|
||||||
|
Note: obfuscation is not real protection - an APK can always be
|
||||||
|
reverse engineered, so rotate these keys if they leak. */
|
||||||
|
def updateS3Access = envOr('S3_ACCESS_KEY', '')
|
||||||
|
def updateS3Secret = envOr('S3_SECRET_KEY', '')
|
||||||
|
def updateXorKey = new byte[16]
|
||||||
|
System.arraycopy(java.security.MessageDigest.getInstance('SHA-256')
|
||||||
|
.digest('shx-update-credentials'.getBytes('UTF-8')), 0, updateXorKey, 0, 16)
|
||||||
|
def xorB64 = { String value ->
|
||||||
|
if (!value) { return '' }
|
||||||
|
def bytes = value.getBytes('UTF-8')
|
||||||
|
for (int i = 0; i < bytes.length; i++) {
|
||||||
|
bytes[i] = (byte)(bytes[i] ^ updateXorKey[i % updateXorKey.length])
|
||||||
|
}
|
||||||
|
java.util.Base64.getEncoder().encodeToString(bytes)
|
||||||
|
}
|
||||||
|
buildConfigField "String", "UPDATE_S3_ACCESS_KEY", "\"${xorB64(updateS3Access)}\""
|
||||||
|
buildConfigField "String", "UPDATE_S3_SECRET_KEY", "\"${xorB64(updateS3Secret)}\""
|
||||||
|
buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\""
|
||||||
|
buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\""
|
||||||
|
|
||||||
|
/* SHX update API (web panel). When UPDATE_API_URL, UPDATE_API_PUBLIC_KEY and UPDATE_API_HMAC_KEY are all set,
|
||||||
|
the app checks for updates there; the bucket path above stays as a fallback while the API is unreachable.
|
||||||
|
UPDATE_API_PUBLIC_KEY is the raw Ed25519 public key (base64, 32 bytes) shown on the panel's "API updates" page;
|
||||||
|
the HMAC key is only a guard against casual requests (an APK can always be unpacked), integrity rests on the
|
||||||
|
manifest signature and on the SHA-256 plus APK signature checks. */
|
||||||
|
buildConfigField "String", "UPDATE_API_URL", "\"${envOr('UPDATE_API_URL', '')}\""
|
||||||
|
buildConfigField "String", "UPDATE_API_PUBLIC_KEY", "\"${envOr('UPDATE_API_PUBLIC_KEY', '')}\""
|
||||||
|
buildConfigField "String", "UPDATE_API_HMAC_KEY", "\"${xorB64(envOr('UPDATE_API_HMAC_KEY', ''))}\""
|
||||||
|
/* public download page, opened by «Download from the site» when an update fails */
|
||||||
|
buildConfigField "String", "UPDATE_DOWNLOAD_PAGE_URL", "\"${envOr('UPDATE_DOWNLOAD_PAGE_URL', 'https://get.shnt.top/get/universal')}\""
|
||||||
|
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
ndkBuild {
|
ndkBuild {
|
||||||
arguments '-j' + Runtime.runtime.availableProcessors()
|
arguments '-j' + Runtime.runtime.availableProcessors()
|
||||||
|
/* deep checkout paths (e.g. git worktrees) overflow the Windows command line; opt in with -PshortNdkCommands */
|
||||||
|
if (project.hasProperty('shortNdkCommands')) {
|
||||||
|
arguments 'APP_SHORT_COMMANDS=true', 'LOCAL_SHORT_COMMANDS=true'
|
||||||
|
}
|
||||||
|
abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86_64'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,26 +110,126 @@ android {
|
|||||||
options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
|
options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
if (keystoreFile) {
|
||||||
|
storeFile rootProject.file(keystoreFile)
|
||||||
|
storePassword keystorePassword
|
||||||
|
keyAlias keystoreKeyAlias
|
||||||
|
keyPassword keystoreKeyPassword
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
minifyEnabled false
|
/* R8 shrinking + resource shrinking: strips dead code/resources and
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.txt'
|
obfuscates. The JNI surface is kept via proguard-rules.txt.
|
||||||
|
Smoke-test a release build on a device before publishing. */
|
||||||
|
minifyEnabled true
|
||||||
|
shrinkResources true
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.txt'
|
||||||
|
if (keystoreFile) {
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
} else {
|
||||||
|
/* fall back to the debug key so that release APKs update debug-signed
|
||||||
|
installations in place (no uninstall, data is preserved) */
|
||||||
|
def debugKeystore = new File(System.getProperty('user.home'), '.android/debug.keystore')
|
||||||
|
if (debugKeystore.exists()) {
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
println "WARNING: KEYSTORE_FILE not set (see .env.example) - signing the release APK with the debug key (${debugKeystore})"
|
||||||
|
} else {
|
||||||
|
println "WARNING: no signing configuration - the release APK will be UNSIGNED (set KEYSTORE_FILE in .env or run a debug build once)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
compileOptions {
|
compileOptions {
|
||||||
targetCompatibility 1.8
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
sourceCompatibility 1.8
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose true
|
||||||
|
buildConfig true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation 'androidx.appcompat:appcompat:1.7.1'
|
implementation 'androidx.appcompat:appcompat:1.7.1'
|
||||||
implementation 'androidx.core:core:1.17.0'
|
implementation 'androidx.core:core:1.17.0'
|
||||||
|
implementation 'androidx.core:core-splashscreen:1.0.1'
|
||||||
implementation 'androidx.lifecycle:lifecycle-process:2.9.4'
|
implementation 'androidx.lifecycle:lifecycle-process:2.9.4'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.9.4'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4'
|
||||||
implementation 'androidx.preference:preference:1.2.1'
|
implementation 'androidx.preference:preference:1.2.1'
|
||||||
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
|
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
|
||||||
|
implementation 'androidx.work:work-runtime-ktx:2.10.1'
|
||||||
implementation 'com.google.android.material:material:1.13.0'
|
implementation 'com.google.android.material:material:1.13.0'
|
||||||
|
|
||||||
|
/* BOM 2026.09.00 = compose 1.12. The Expressive API (MaterialExpressiveTheme,
|
||||||
|
MotionScheme.expressive, LoadingIndicator, MaterialShapes) lives only in
|
||||||
|
material3 1.5.0-alpha/beta, so material3 is pinned explicitly (needs AGP 9.1,
|
||||||
|
compileSdk 37, minSdk 24). Unpin once 1.5.0 is stable. */
|
||||||
|
def composeBom = platform('androidx.compose:compose-bom:2026.09.00')
|
||||||
|
implementation composeBom
|
||||||
|
implementation 'androidx.compose.ui:ui'
|
||||||
|
implementation 'androidx.compose.material3:material3:1.5.0-beta01'
|
||||||
|
/* adaptive-navigation-suite (NavigationSuiteScaffold) requires minSdk 23+ (material3 1.5 requires 24) */
|
||||||
|
implementation 'androidx.compose.material3:material3-adaptive-navigation-suite:1.5.0-beta01'
|
||||||
|
/* list-detail layout for Profiles on expanded widths */
|
||||||
|
implementation 'androidx.compose.material3.adaptive:adaptive-layout:1.3.0'
|
||||||
|
implementation 'androidx.compose.material3.adaptive:adaptive-navigation:1.3.0'
|
||||||
|
implementation 'androidx.compose.material:material-icons-extended'
|
||||||
|
/* home screen widget (status + connect button) */
|
||||||
|
implementation 'androidx.glance:glance-appwidget:1.2.0'
|
||||||
|
implementation 'androidx.glance:glance-material3:1.2.0'
|
||||||
|
implementation 'androidx.activity:activity-compose:1.11.0'
|
||||||
|
/* QR: scanning profiles (camera) and showing a profile as a QR code */
|
||||||
|
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||||
|
/* Ed25519 verification of the signed update manifest (java.security Ed25519 needs API 33, minSdk is 24) */
|
||||||
|
implementation 'net.i2p.crypto:eddsa:0.3.0'
|
||||||
|
|
||||||
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.13.2'
|
testImplementation 'junit:junit:4.13.2'
|
||||||
testImplementation 'org.assertj:assertj-core:3.27.6'
|
testImplementation 'org.assertj:assertj-core:3.27.6'
|
||||||
testImplementation 'org.mockito:mockito-core:5.20.0'
|
testImplementation 'org.mockito:mockito-core:5.20.0'
|
||||||
|
/* the android.jar org.json stubs throw on the JVM; the real implementation is needed for the update API tests */
|
||||||
|
testImplementation 'org.json:json:20240303'
|
||||||
|
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||||
|
androidTestImplementation 'androidx.test:runner:1.7.0'
|
||||||
|
androidTestImplementation 'org.assertj:assertj-core:3.27.6'
|
||||||
|
androidTestImplementation composeBom
|
||||||
|
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Publish to the SHX VPN panel (https://apk.shnt.top): build the release APK and upload it in one step.
|
||||||
|
./gradlew uploadShx -> draft in the beta channel, type phone
|
||||||
|
./gradlew uploadShx -PshxPublish -PshxRollout=25
|
||||||
|
./gradlew uploadShx -PshxChannel=release -PshxType=tv -PshxNotes="..."
|
||||||
|
Needs Node 18+ and SHX_URL, SHX_TOKEN (a CI token from the panel's Settings -> Tokens).
|
||||||
|
Release notes default to NOTES_RU from .env; the version comes from the build's output-metadata.json. */
|
||||||
|
tasks.register('uploadShx', Exec) {
|
||||||
|
group = 'publishing'
|
||||||
|
description = 'Build the release APK and upload it to the SHX VPN panel'
|
||||||
|
dependsOn 'assembleRelease'
|
||||||
|
def apkFile = layout.buildDirectory.file('outputs/apk/release/app-release.apk')
|
||||||
|
def script = rootProject.file('scripts/upload-apk.mjs')
|
||||||
|
doFirst {
|
||||||
|
def apk = apkFile.get().asFile
|
||||||
|
if (!apk.exists()) {
|
||||||
|
throw new GradleException("${apk} not found: the release APK is unsigned (set KEYSTORE_FILE in .env)")
|
||||||
|
}
|
||||||
|
def args = ['node', script.absolutePath, apk.absolutePath,
|
||||||
|
'--type', (findProperty('shxType') ?: envOr('SHX_TYPE', 'phone')).toString(),
|
||||||
|
'--channel', (findProperty('shxChannel') ?: 'beta').toString()]
|
||||||
|
def notes = (findProperty('shxNotes') ?: envOr('NOTES_RU', '')).toString()
|
||||||
|
if (notes) args += ['--notes', notes]
|
||||||
|
if (project.hasProperty('shxPublish')) {
|
||||||
|
args += ['--publish', '--rollout', (findProperty('shxRollout') ?: '100').toString()]
|
||||||
|
}
|
||||||
|
commandLine args
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# SHX VPN R8 rules
|
||||||
|
#
|
||||||
|
# The native library (libandroidbridge) looks up Java classes and methods by
|
||||||
|
# name via JNI. R8 must not rename or remove any of them.
|
||||||
|
|
||||||
|
# --- JNI entry points called from native code by name ---
|
||||||
|
|
||||||
|
-keep class org.strongswan.android.logic.CharonVpnService {
|
||||||
|
*;
|
||||||
|
}
|
||||||
|
# inner class BuilderAdapter is called from vpnservice_builder.c
|
||||||
|
-keep class org.strongswan.android.logic.CharonVpnService$BuilderAdapter {
|
||||||
|
*;
|
||||||
|
}
|
||||||
|
-keep class org.strongswan.android.logic.SimpleFetcher {
|
||||||
|
*;
|
||||||
|
}
|
||||||
|
-keep class org.strongswan.android.logic.NetworkManager {
|
||||||
|
*;
|
||||||
|
}
|
||||||
|
-keep class org.strongswan.android.logic.Scheduler {
|
||||||
|
*;
|
||||||
|
}
|
||||||
|
-keep class org.strongswan.android.logic.imc.AndroidImc {
|
||||||
|
*;
|
||||||
|
}
|
||||||
|
|
||||||
|
# native methods (and classes containing them) must keep their names
|
||||||
|
-keepclasseswithmembernames class * {
|
||||||
|
native <methods>;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Content providers exported to other apps ---
|
||||||
|
|
||||||
|
-keep class org.strongswan.android.data.LogContentProvider {
|
||||||
|
*;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Parcelable CREATORs (RemediationInstruction etc.) ---
|
||||||
|
|
||||||
|
-keepclassmembers class * implements android.os.Parcelable {
|
||||||
|
public static final ** CREATOR;
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Enum values used via valueOf/values (VpnType, State, ErrorState...) ---
|
||||||
|
|
||||||
|
-keepclassmembers enum * {
|
||||||
|
public static **[] values();
|
||||||
|
public static ** valueOf(java.lang.String);
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- WebView / TextView reflection-free; keep line numbers for readable
|
||||||
|
# crash reports in released APKs ---
|
||||||
|
|
||||||
|
-keepattributes SourceFile,LineNumberTable
|
||||||
|
-renamesourcefileattribute SourceFile
|
||||||
|
|
||||||
|
# --- squash warnings for optional dependencies referenced only from XML ---
|
||||||
|
|
||||||
|
-dontwarn org.bouncycastle.jsse.util.**
|
||||||
|
|
||||||
|
# --- Room databases are instantiated reflectively (WorkManager's WorkDatabase_Impl) ---
|
||||||
|
|
||||||
|
-keep class * extends androidx.room.RoomDatabase {
|
||||||
|
<init>();
|
||||||
|
}
|
||||||
|
|
||||||
|
# i2p eddsa (manifest signature check) references sun.security.x509 only for JDK interop, absent on Android
|
||||||
|
-dontwarn sun.security.x509.**
|
||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.assertj.core.api.Assertions.assertThat
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.strongswan.android.data.ManagedVpnProfile
|
||||||
|
import org.strongswan.android.data.VpnProfile
|
||||||
|
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||||
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
|
import org.strongswan.android.data.VpnType
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class VpnProfileExporterTest
|
||||||
|
{
|
||||||
|
private fun sampleProfile(): VpnProfile
|
||||||
|
{
|
||||||
|
val profile = VpnProfile()
|
||||||
|
profile.setUUID(UUID.fromString("123e4567-e89b-12d3-a456-426614174000"))
|
||||||
|
profile.setName("Shared Profile")
|
||||||
|
profile.setVpnType(VpnType.IKEV2_EAP)
|
||||||
|
profile.setGateway("vpn.example.com")
|
||||||
|
profile.setPort(4500)
|
||||||
|
profile.setRemoteId(" CN=vpn.example.com")
|
||||||
|
profile.setLocalId("alice@example.com")
|
||||||
|
profile.setUsername("alice")
|
||||||
|
profile.setPassword("secret")
|
||||||
|
profile.setIkeProposal("aes256-sha2_384-prfsha384-modp2048")
|
||||||
|
profile.setEspProposal("aes256gcm16-prfsha384-modp2048")
|
||||||
|
profile.setDnsServers("10.0.0.1 10.0.0.2")
|
||||||
|
profile.setMTU(1400)
|
||||||
|
profile.setNATKeepAlive(20)
|
||||||
|
profile.setIncludedSubnets("10.1.0.0/16")
|
||||||
|
profile.setExcludedSubnets("10.1.2.0/24")
|
||||||
|
profile.setSplitTunneling(VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4)
|
||||||
|
profile.setProxyHost("proxy.example.com")
|
||||||
|
profile.setProxyPort(8080)
|
||||||
|
profile.setProxyExclusions("example.org example.net")
|
||||||
|
profile.setSelectedApps("com.example.app1 com.example.app2")
|
||||||
|
profile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_ONLY)
|
||||||
|
profile.setFlags(VpnProfile.FLAGS_SUPPRESS_CERT_REQS or VpnProfile.FLAGS_DISABLE_CRL or
|
||||||
|
VpnProfile.FLAGS_STRICT_REVOCATION or VpnProfile.FLAGS_IPv6_TRANSPORT)
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun exportRoundTripMatchesImporterFormat()
|
||||||
|
{
|
||||||
|
val json = JSONObject(VpnProfileExporter.serializeProfile(sampleProfile(), true).toString())
|
||||||
|
|
||||||
|
assertThat(json.getString("uuid")).isEqualTo("123e4567-e89b-12d3-a456-426614174000")
|
||||||
|
assertThat(json.getString("name")).isEqualTo("Shared Profile")
|
||||||
|
assertThat(json.getString("type")).isEqualTo("ikev2-eap")
|
||||||
|
|
||||||
|
val remote = json.getJSONObject("remote")
|
||||||
|
assertThat(remote.getString("addr")).isEqualTo("vpn.example.com")
|
||||||
|
assertThat(remote.getInt("port")).isEqualTo(4500)
|
||||||
|
assertThat(remote.getString("id")).isEqualTo(" CN=vpn.example.com")
|
||||||
|
assertThat(remote.getBoolean("certreq")).isFalse()
|
||||||
|
assertThat(remote.getJSONObject("revocation").getBoolean("crl")).isFalse()
|
||||||
|
assertThat(remote.getJSONObject("revocation").getBoolean("strict")).isTrue()
|
||||||
|
|
||||||
|
val local = json.getJSONObject("local")
|
||||||
|
assertThat(local.getString("id")).isEqualTo("alice@example.com")
|
||||||
|
assertThat(local.getString("eap_id")).isEqualTo("alice")
|
||||||
|
assertThat(local.getString("shared_secret")).isEqualTo("secret")
|
||||||
|
|
||||||
|
assertThat(json.getString("ike-proposal")).isEqualTo("aes256-sha2_384-prfsha384-modp2048")
|
||||||
|
assertThat(json.getString("esp-proposal")).isEqualTo("aes256gcm16-prfsha384-modp2048")
|
||||||
|
assertThat(json.getJSONArray("dns-servers").length()).isEqualTo(2)
|
||||||
|
assertThat(json.getInt("mtu")).isEqualTo(1400)
|
||||||
|
assertThat(json.getInt("nat-keepalive")).isEqualTo(20)
|
||||||
|
assertThat(json.getBoolean("ipv6-transport")).isTrue()
|
||||||
|
|
||||||
|
val proxy = json.getJSONObject("proxy")
|
||||||
|
assertThat(proxy.getString("host")).isEqualTo("proxy.example.com")
|
||||||
|
assertThat(proxy.getInt("port")).isEqualTo(8080)
|
||||||
|
assertThat(proxy.getJSONArray("exclusions").length()).isEqualTo(2)
|
||||||
|
|
||||||
|
val split = json.getJSONObject("split-tunneling")
|
||||||
|
assertThat(split.getJSONArray("subnets").getString(0)).isEqualTo("10.1.0.0/16")
|
||||||
|
assertThat(split.getJSONArray("excluded").getString(0)).isEqualTo("10.1.2.0/24")
|
||||||
|
assertThat(split.getBoolean("block-ipv4")).isTrue()
|
||||||
|
assertThat(split.has("block-ipv6")).isFalse()
|
||||||
|
|
||||||
|
assertThat(json.getJSONArray("apps").length()).isEqualTo(2)
|
||||||
|
assertThat(json.has("excluded-apps")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun passwordOmittedWhenNotIncluded()
|
||||||
|
{
|
||||||
|
val json = JSONObject(VpnProfileExporter.serializeProfile(sampleProfile(), false).toString())
|
||||||
|
|
||||||
|
assertThat(json.getJSONObject("local").has("shared_secret")).isFalse()
|
||||||
|
assertThat(json.getJSONObject("local").getString("eap_id")).isEqualTo("alice")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaultsAreOmitted()
|
||||||
|
{
|
||||||
|
val profile = VpnProfile()
|
||||||
|
profile.setName("Minimal")
|
||||||
|
profile.setVpnType(VpnType.IKEV2_EAP)
|
||||||
|
profile.setGateway("vpn.example.com")
|
||||||
|
|
||||||
|
val json = JSONObject(VpnProfileExporter.serializeProfile(profile, false).toString())
|
||||||
|
|
||||||
|
assertThat(json.has("local")).isFalse()
|
||||||
|
assertThat(json.has("ike-proposal")).isFalse()
|
||||||
|
assertThat(json.has("dns-servers")).isFalse()
|
||||||
|
assertThat(json.has("mtu")).isFalse()
|
||||||
|
assertThat(json.has("ipv6-transport")).isFalse()
|
||||||
|
assertThat(json.getJSONObject("remote").has("certreq")).isFalse()
|
||||||
|
assertThat(json.getJSONObject("remote").has("revocation")).isFalse()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun managedUserCertificateIsRepackagedWithoutPassword()
|
||||||
|
{
|
||||||
|
val local = Bundle()
|
||||||
|
local.putString(VpnProfileDataSource.KEY_USER_CERTIFICATE, TEST_P12)
|
||||||
|
local.putString(VpnProfileDataSource.KEY_USER_CERTIFICATE_PASSWORD, "testpassword")
|
||||||
|
val bundle = Bundle()
|
||||||
|
bundle.putString(VpnProfileDataSource.KEY_NAME, "Managed")
|
||||||
|
bundle.putString(VpnProfileDataSource.KEY_VPN_TYPE, "ikev2-cert")
|
||||||
|
bundle.putBundle("local", local)
|
||||||
|
val profile = ManagedVpnProfile(bundle, UUID.randomUUID())
|
||||||
|
|
||||||
|
val exported = VpnProfileExporter.userCertificateData(profile)
|
||||||
|
assertThat(exported).isNotNull()
|
||||||
|
|
||||||
|
/* the recipient must be able to open the container without knowing a password */
|
||||||
|
val keyStore = KeyStore.getInstance("PKCS12")
|
||||||
|
keyStore.load(ByteArrayInputStream(Base64.decode(exported, Base64.DEFAULT)), charArrayOf())
|
||||||
|
val alias = keyStore.aliases().nextElement()
|
||||||
|
assertThat(keyStore.getKey(alias, charArrayOf())).isNotNull()
|
||||||
|
assertThat(keyStore.getCertificateChain(alias)).isNotEmpty()
|
||||||
|
val cert = keyStore.getCertificate(alias) as java.security.cert.X509Certificate
|
||||||
|
assertThat(cert.getSubjectX500Principal().name).contains("Test User")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** PKCS#12 container with a private key for CN=Test User, password: testpassword */
|
||||||
|
private val TEST_P12 = """ MIIKOAIBAzCCCe4GCSqGSIb3DQEHAaCCCd8EggnbMIIJ1zCCBCoGCSqGSIb3DQEHBqCCBBswggQXAgEAMIIEEAYJKoZIhvcN
|
||||||
|
AQcBMF8GCSqGSIb3DQEFDTBSMDEGCSqGSIb3DQEFDDAkBBDba1sYbH9bqoGmf+8dPAqZAgIIADAMBggqhkiG9w0CCQUAMB0G
|
||||||
|
CWCGSAFlAwQBKgQQ6xNPeUT6JH4tibm14NRyZ4CCA6AyC5vk/wmALhgKx3M2jRlOdSXaGp6iLaIt1a6mDAlpULXik9oxmWmh
|
||||||
|
4m5Q5UTq9nfJBTJkmSv0MKhMOpP8WKijEH92UWqrI+P6VRpDDMM110tySeE3Qv4al/5+RJSVQFyJktSK+2bA4aFlMI+WxZBQ
|
||||||
|
Ki0l+mPMSEhDkUTVez/UBcx8rYPCFq9Di42VT2DbVfebcvmAfWJwjlIWMixGiWrIpS7U/m5v0RSUQyDSNYnp1b49eDePGLUF
|
||||||
|
90yEeOxiVjPEaRRPzeaEbBy569ig2BltZDBbCR0Ju+FUSWsyDD7OV/k9ZcmkRT4e2Hp7r9De2UiYS3sD+dGVPTTVVBEMewiv
|
||||||
|
ujsccRUx83PRlbhQFMQh6WscsBzhPb6K5qgeYQ0xUZLHMfM6657xws2UyhPFnrGfeokZg3r3SihB5XueZV95fVFtDHC0D8gD
|
||||||
|
tbdnc404XNx+xhB7uf33UZ5KUde7TC068VSXovUuxzgGFa/ZmbICtpumsr+na/+/I09zLttjnZXnr2islywjCwB9pbuzTG2y
|
||||||
|
N5ZhgsELiNph5C/8sjIxqLNQkzvD1M9K9u5YiaxaUU/ZHxIs9F6QJYa+JqGiafuBcUZuc9kg7oNm94vD69i6Jhg10LKc7My5
|
||||||
|
B5lU9SPJ/kFjKrmk4dhDYCiTRCI/zOuanmPERdFlLR+SMJe/ACRUFj+O0WT4Sd5eE1byHxzu7Rxtuoc5GAPdQHPzKoDOj5Bc
|
||||||
|
3h8yH5tTyZEUjy6Q2Svqrn/hxxduYx6zB0IATKiMXp2ADsuM1K35OWLK0WyyGC5C/ONcp84MM6vYMNecvcC8kPyuBsmegzbC
|
||||||
|
HiScBvVlqsWZUzFIdQ+aIw1c2IY0LHuWksd7QYIJAWDLK3eMk3oNeFO/ao0JLE4N9gUQKLTyMa+sxytc0C9OfJb7PB/kssYW
|
||||||
|
FC9KmmitkKU+JlM+i0uD9hXdHPThmyO8bPXT2EKM6zB0AWmcAPmPUMedzWREOmuldQNa1OQrIsZjyTOMDIy+l0UPQiFc6Q6e
|
||||||
|
SdhHlXmzr/BSwC3u/oDfzZRye6crC9qFlLg4SLFSisxF6NC+YzlfVnpXEftc3lTAJpf2UQrgxFPaawgbGK0xGGuesBzmtLFs
|
||||||
|
J57YD7h9vvqdD4VRCQrWn5Fy3/2rQVCHYZseLGyygWl0RR8C1TcWq/aWlycLIzNQmiGmYSrihA2u9v/2aVr1SyKCJicxBH3l
|
||||||
|
Xfz4adrs6aeAGC0I6G73zJ+skxud1Y6AMIIFpQYJKoZIhvcNAQcBoIIFlgSCBZIwggWOMIIFigYLKoZIhvcNAQwKAQKgggU5
|
||||||
|
MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQOc129Uu2Ph/kfFdWKDKL+gICCAAwDAYIKoZIhvcNAgkFADAd
|
||||||
|
BglghkgBZQMEASoEEOpkCTykee2dSnZ0xkWndckEggTQD5YCqwB/3T86lOpmpb+gsKBMp6s48JkxiPhg4FO15aIHfqyg8Get
|
||||||
|
KkxZCfeNzNszTbT5BApyRvbNedy9EPWluWvdnyYTDjKZvYDw5OvM21k1yX+pScXj58kBx2AMk3/XwFIeFOTx2WXt6mUy4GR7
|
||||||
|
3pA+Zxe9pWO2SB9Zzn3VrCgj6B8o70fEHRJbt8kYR5uX7S5GVFhwgQbcFc1bmn1o/cDGqKS8U4D+LDR3++m4iQUHblz4r6Vm
|
||||||
|
7hkknc2qIW116jtO3MYXQ9s+O8uiOlKbRnmQSuFjH4p0KzKl5i2uolj1rB5xHJwrJE4ajv6ubovEU/sUUjlnHScEF2S9qaJ3
|
||||||
|
+sMlYgQMcvduaGDFx79leD0DdPxONZa8WmxnY7pwYdO7xFHloPfL/bSXnsSTX3cxHVyFj2r1/+MNhYM/BQG82/UfM5/s/1q+
|
||||||
|
SmZHujO+vpDg1H9lKvIiFv992+oYGuiFpgjrTi3KGe2cMlY3r/LmFJnJkTjfTaTBRQ0UOOcC3+9/neN4dZ6Dhl51wM3ayRtS
|
||||||
|
puhl5eNG3c/05VbjLaz98I6j8WlpEVHYxMyYXqeWchHPaGMlPeKWEZTZYqly1dodLthvZyCW6oItEFVSCJZtDl0bqYPm/CWv
|
||||||
|
A2/1H8jwawzUN8KPiDIQScpK3qVR5c1pvol7uQ8P4yk6sIDqRoS27b2pS2vQwJNWIc93daCzk7MpJ/BJNTENhn2hxvlgksSZ
|
||||||
|
RI/pWJVKe/vtPqDiW1Exe5N3lJ/Pk9fTQ3cCzVUP2S25DCNhdtHmlKGR37y+j/JeAAWXNs0GH0oq48FkWIEWj7gwynxsBlME
|
||||||
|
xS3O2I1ZsDkzcaJh66tZ0Vyle87VpgZ6wA0NKqbwHdGQlAyVZ944O1jktQH9Do5qvfkkbcOM/yQE31fCTLIkT9hGXXVBG7Vy
|
||||||
|
071cp5efGkU1bBfAHBmhNP7E3VDogHUbciwRSpw4O8GXtPtP3RMbEUpoqHaX4KRvyK0XKN6ZNKl8lVaDzU8g6TL313H1Z3yJ
|
||||||
|
LPa0aedC2E4MJiQTVwbinSXhUdsmhVrFP0MfbtssdSLKkk0hNkzqsUExcaoxh51uR2pw5tb1w6x279C7dAV/+GibTeY2yngE
|
||||||
|
+ikKRIa1YH3KvSZtFskLmXf2kphk1IRZHzQZauTgIwJnY7XFJWdus3HKdqr/XaqrchLfmMXYj/ZG7bS5ssoO7g9JPMD/5ElB
|
||||||
|
Ur4Gz/1qE88NWBvqt6+guMAB5YvrsaOxxSLYZj6dyeO1bQfxMecuo3txI0lwJWpbkTHOu0rLOtde7OzgPo9qfRPur+rY5JqC
|
||||||
|
tYOyCzfFFcwILWC92eiGO+N2XUe6k75qVSWpROSwkT/lZFmz6AmeBena8obtOnV6yTWapEPXkGMKP9Cm6rXEVgvZeUh886Bd
|
||||||
|
5Am/mfAe0cT8hxoutrDXD649xRB4JNq1/jff8jik/kjgpEzk62rz6ix42JxB9gYljfdtekvi5/JfF7ODvij4xO6OzycrH0I/
|
||||||
|
742vk4swnVwKRQfhCQuaklNrTmpqWsefprLy54EArTjSNEHspZJD/BGNJe8q+ntECKMBqPzTgptuxb9JSWsTnJzYFP6kxYLf
|
||||||
|
NxQpWPVQz51SkmVzpjRgp/RE3FLmBtponAST69xIQuXz+xinc1asWYgxPjAXBgkqhkiG9w0BCRQxCh4IAHQAZQBzAHQwIwYJ
|
||||||
|
KoZIhvcNAQkVMRYEFNSUf6nnO3vpRNk70pJS3R58gg9NMEEwMTANBglghkgBZQMEAgEFAAQgL2BlHv8N4AAEaEOTRpzFamcY
|
||||||
|
l/5PNjlWjlgV252DMCoECGgcyjRqux6dAgIIAA=="""
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
class ConnectivityCheckScreenTest
|
||||||
|
{
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
|
private fun string(id: Int): String =
|
||||||
|
androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
|
||||||
|
|
||||||
|
private fun setContent()
|
||||||
|
{
|
||||||
|
composeRule.setContent {
|
||||||
|
ShxTheme {
|
||||||
|
ConnectivityCheckScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsSectionHeaders()
|
||||||
|
{
|
||||||
|
setContent()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.health_check_targets_title)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.health_check_server_down_title)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.health_check_tunnel_dead_title)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsMasterToggle()
|
||||||
|
{
|
||||||
|
setContent()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.health_check_title)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsAggressiveModeToggle()
|
||||||
|
{
|
||||||
|
setContent()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.health_check_aggressive_title)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsTargetToggles()
|
||||||
|
{
|
||||||
|
setContent()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.probe_target_google)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.probe_target_cloudflare)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.routing.TemplateMatch
|
||||||
|
import org.strongswan.android.ui.compose.routing.AppReason
|
||||||
|
import org.strongswan.android.ui.compose.routing.AppsFilter
|
||||||
|
import org.strongswan.android.ui.compose.routing.DirectAppRow
|
||||||
|
import org.strongswan.android.ui.compose.routing.DirectAppsContent
|
||||||
|
import org.strongswan.android.ui.compose.routing.DirectAppsGroup
|
||||||
|
import org.strongswan.android.ui.compose.routing.DirectAppsUiState
|
||||||
|
import org.strongswan.android.ui.compose.routing.QuickChipState
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
/** Smoke test of the stateless "Apps direct" screen: filter chips, quick-add chips and an app row. */
|
||||||
|
class DirectAppsScreenTest
|
||||||
|
{
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
|
private fun string(id: Int, vararg args: Any): String =
|
||||||
|
InstrumentationRegistry.getInstrumentation().targetContext.getString(id, *args)
|
||||||
|
|
||||||
|
private val state = DirectAppsUiState(
|
||||||
|
loading = false,
|
||||||
|
groups = listOf(
|
||||||
|
DirectAppsGroup(
|
||||||
|
category = "banks",
|
||||||
|
rows = listOf(
|
||||||
|
DirectAppRow("com.example.bank", "Example Bank", true, AppReason.Template(TemplateMatch(category = "banks"))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DirectAppsGroup(
|
||||||
|
category = null,
|
||||||
|
rows = listOf(DirectAppRow("com.example.chat", "Example Chat", false, AppReason.Tunnel)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
directCount = 1,
|
||||||
|
tunnelCount = 1,
|
||||||
|
filter = AppsFilter.ALL,
|
||||||
|
quickChips = listOf(QuickChipState("banks", true), QuickChipState("shop", false)),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsFiltersQuickChipsAndRows()
|
||||||
|
{
|
||||||
|
var toggled: Pair<String, Boolean>? = null
|
||||||
|
composeRule.setContent {
|
||||||
|
ShxTheme {
|
||||||
|
DirectAppsContent(
|
||||||
|
state = state,
|
||||||
|
onBack = {},
|
||||||
|
onQuery = {},
|
||||||
|
onFilter = {},
|
||||||
|
onShowSystem = {},
|
||||||
|
onToggleApp = { pkg, direct -> toggled = pkg to direct },
|
||||||
|
onToggleCategory = { _, _ -> },
|
||||||
|
onAllRf = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(string(R.string.apps_filter_direct, 1)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(string(R.string.apps_filter_vpn, 1)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(string(R.string.routing_apps_add_all_rf)).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Example Bank").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Example Chat").performClick()
|
||||||
|
assertEquals("com.example.chat" to true, toggled)
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
/** Smoke tests for the redesigned Profiles, Routing and Settings screens. */
|
||||||
|
class ScreensSmokeTest
|
||||||
|
{
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
|
private fun string(id: Int): String =
|
||||||
|
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun profilesShowsEmptyState()
|
||||||
|
{
|
||||||
|
composeRule.setContent {
|
||||||
|
ShxTheme {
|
||||||
|
ProfilesScreen(
|
||||||
|
state = HomeUiState(),
|
||||||
|
onConnect = {},
|
||||||
|
onDisconnect = {},
|
||||||
|
onEdit = {},
|
||||||
|
onDelete = {},
|
||||||
|
onRestore = {},
|
||||||
|
onShare = {},
|
||||||
|
onAdd = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.onNodeWithText(string(R.string.no_profiles)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun routingShowsHeroAndAdvancedBlock()
|
||||||
|
{
|
||||||
|
composeRule.setContent {
|
||||||
|
ShxTheme {
|
||||||
|
RoutingScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.onNodeWithText(string(R.string.routing_title)).assertIsDisplayed()
|
||||||
|
/* advanced block is collapsed until tapped */
|
||||||
|
composeRule.onNodeWithText(string(R.string.routing_advanced).uppercase()).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsOpensSecurityPageAndGoesBack()
|
||||||
|
{
|
||||||
|
composeRule.setContent {
|
||||||
|
ShxTheme {
|
||||||
|
SettingsScreen(profiles = emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.onNodeWithText(string(R.string.settings_section_security)).performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithText(string(R.string.safe_ipv6_title)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
@@ -0,0 +1,80 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
class SettingsScreenTest
|
||||||
|
{
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
|
private fun string(id: Int): String =
|
||||||
|
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
|
||||||
|
|
||||||
|
private fun setContent()
|
||||||
|
{
|
||||||
|
composeRule.setContent {
|
||||||
|
ShxTheme {
|
||||||
|
SettingsScreen(profiles = emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsSectionHeaders()
|
||||||
|
{
|
||||||
|
setContent()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.settings_section_automation)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.settings_section_security)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.settings_section_support)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsTrustedNetworksEntry()
|
||||||
|
{
|
||||||
|
setContent()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.auto_connect_title)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsKillSwitchEntry()
|
||||||
|
{
|
||||||
|
setContent()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.kill_switch_title)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
composeRule.onAllNodesWithText(
|
||||||
|
string(R.string.kill_switch_summary_off)
|
||||||
|
).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
class TrustedNetworksScreenTest
|
||||||
|
{
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createComposeRule()
|
||||||
|
|
||||||
|
private fun string(id: Int): String =
|
||||||
|
InstrumentationRegistry.getInstrumentation().targetContext.getString(id)
|
||||||
|
|
||||||
|
private fun setContent()
|
||||||
|
{
|
||||||
|
composeRule.setContent {
|
||||||
|
ShxTheme {
|
||||||
|
TrustedNetworksScreen(profiles = emptyList(), currentSsid = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsMasterToggle()
|
||||||
|
{
|
||||||
|
setContent()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.auto_connect_title)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.auto_connect_summary)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsProfilePickerRow()
|
||||||
|
{
|
||||||
|
setContent()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.auto_connect_profile)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsEmptyTrustedList()
|
||||||
|
{
|
||||||
|
setContent()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.auto_connect_no_networks)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun showsAddNetworkButton()
|
||||||
|
{
|
||||||
|
setContent()
|
||||||
|
composeRule.onNodeWithText(
|
||||||
|
string(R.string.auto_connect_add_network)
|
||||||
|
).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,12 +21,38 @@
|
|||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<!-- SSID is redacted in the background on Android 10+ without this. -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<!-- «Recent apps»: per-app traffic totals from the system network statistics;
|
||||||
|
granted by the user in Settings > Usage access, never requested silently -->
|
||||||
|
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS"
|
||||||
|
tools:ignore="ProtectedPermissions" />
|
||||||
|
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
|
||||||
|
<!-- required to install downloaded updates of the app itself -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
<!-- lets the app install its own updates in the background without the
|
||||||
|
system confirmation dialog (Android 12+, same package/signature only) -->
|
||||||
|
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
|
||||||
|
<!-- required to claim update ownership (setRequestUpdateOwnership) so our
|
||||||
|
own updates keep being applied silently on Android 14+ -->
|
||||||
|
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
<!-- Required to reliably schedule NAT keepalives/rekeyings via the AlarmManager.
|
||||||
|
On Android 13+ this special permission is denied by default, but it can be
|
||||||
|
granted by the user in system settings (Scheduler falls back to inexact
|
||||||
|
alarms while it is not granted). -->
|
||||||
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
||||||
<!-- necessary to allow users to select ex-/included apps and EAP-TNC -->
|
<!-- necessary to allow users to select ex-/included apps and EAP-TNC -->
|
||||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
|
||||||
tools:ignore="QueryAllPackagesPermission" />
|
tools:ignore="QueryAllPackagesPermission" />
|
||||||
@@ -42,6 +68,7 @@
|
|||||||
android:allowBackup="false" >
|
android:allowBackup="false" >
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.MainActivity"
|
android:name=".ui.MainActivity"
|
||||||
|
android:theme="@style/ShxSplashTheme"
|
||||||
android:label="@string/main_activity_name"
|
android:label="@string/main_activity_name"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -53,6 +80,18 @@
|
|||||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ScenariosActivity"
|
||||||
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ServerActivity"
|
||||||
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.StatsActivity"
|
||||||
|
android:exported="false" />
|
||||||
|
<activity
|
||||||
|
android:name=".ui.BatteryActivity"
|
||||||
|
android:exported="false" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.VpnProfileControlActivity"
|
android:name=".ui.VpnProfileControlActivity"
|
||||||
android:theme="@style/TransparentActivity"
|
android:theme="@style/TransparentActivity"
|
||||||
@@ -61,13 +100,24 @@
|
|||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="org.strongswan.android.action.START_PROFILE" />
|
<action android:name="one.shx.strongswan.ext.action.START_PROFILE" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="org.strongswan.android.action.DISCONNECT" />
|
<action android:name="one.shx.strongswan.ext.action.DISCONNECT" />
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="one.shx.strongswan.ext.action.TOGGLE_VPN" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- deep links for automation tools: shxvpn://connect[?profile=<uuid>], shxvpn://disconnect, shxvpn://toggle -->
|
||||||
|
<intent-filter tools:ignore="AppLinkUrlError">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="shxvpn" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.VpnProfileDetailActivity" >
|
android:name=".ui.VpnProfileDetailActivity" >
|
||||||
@@ -76,10 +126,6 @@
|
|||||||
android:name=".ui.TrustedCertificatesActivity"
|
android:name=".ui.TrustedCertificatesActivity"
|
||||||
android:label="@string/trusted_certs_title" >
|
android:label="@string/trusted_certs_title" >
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
|
||||||
android:name=".ui.SelectedApplicationsActivity"
|
|
||||||
android:label="@string/profile_select_apps" >
|
|
||||||
</activity>
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.LogActivity"
|
android:name=".ui.LogActivity"
|
||||||
android:label="@string/log_title" >
|
android:label="@string/log_title" >
|
||||||
@@ -88,6 +134,26 @@
|
|||||||
android:name=".ui.SettingsActivity"
|
android:name=".ui.SettingsActivity"
|
||||||
android:label="@string/pref_title">
|
android:label="@string/pref_title">
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ConnectivityCheckActivity"
|
||||||
|
android:label="@string/health_check_title">
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.RouteCheckActivity"
|
||||||
|
android:label="@string/route_check_title">
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.InboxActivity"
|
||||||
|
android:label="@string/events_title">
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.DiagnosticsActivity"
|
||||||
|
android:label="@string/diag_title">
|
||||||
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name=".ui.OnboardingActivity"
|
||||||
|
android:label="@string/app_name">
|
||||||
|
</activity>
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.RemediationInstructionsActivity"
|
android:name=".ui.RemediationInstructionsActivity"
|
||||||
android:label="@string/remediation_instructions_title" >
|
android:label="@string/remediation_instructions_title" >
|
||||||
@@ -105,6 +171,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".ui.VpnProfileImportActivity"
|
android:name=".ui.VpnProfileImportActivity"
|
||||||
android:label="@string/profile_import"
|
android:label="@string/profile_import"
|
||||||
|
android:theme="@style/TransparentActivity"
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
@@ -198,16 +265,92 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.service.quicksettings.TILE_CATEGORY"
|
||||||
|
android:value="android.service.quicksettings.CATEGORY_CONNECTIVITY" />
|
||||||
|
<meta-data
|
||||||
|
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||||
|
android:value="true" />
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<receiver
|
||||||
|
android:name=".ui.widget.ShxWidgetReceiver"
|
||||||
|
android:label="@string/widget_label"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/shx_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name=".data.LogContentProvider"
|
android:name=".data.LogContentProvider"
|
||||||
android:authorities="org.strongswan.android.content.log"
|
android:authorities="one.shx.strongswan.ext.content.log"
|
||||||
android:exported="true" >
|
android:exported="true" >
|
||||||
<!-- android:grantUriPermissions="true" combined with a custom permission does
|
<!-- android:grantUriPermissions="true" combined with a custom permission does
|
||||||
not work (probably too many indirections with ACTION_SEND) so we secure
|
not work (probably too many indirections with ACTION_SEND) so we secure
|
||||||
this provider with a custom ticketing system -->
|
this provider with a custom ticketing system -->
|
||||||
</provider>
|
</provider>
|
||||||
|
<provider
|
||||||
|
android:name=".data.ProfileExportProvider"
|
||||||
|
android:authorities="one.shx.strongswan.ext.content.export"
|
||||||
|
android:exported="true" >
|
||||||
|
<!-- same ticketing approach as above for shared VPN profile exports -->
|
||||||
|
</provider>
|
||||||
|
<!-- serves downloaded updates to the system package installer -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
<!-- receives the result of the self-update installation -->
|
||||||
|
<receiver
|
||||||
|
android:name=".logic.updates.UpdateInstallStatusReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
<!-- restores an expected VPN connection after the hosting process died;
|
||||||
|
only uses a non-wakeup alarm so it never wakes the device -->
|
||||||
|
<receiver
|
||||||
|
android:name=".logic.WatchdogReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
<!-- «Yes» / «No» of the suggestion notifications -->
|
||||||
|
<receiver
|
||||||
|
android:name=".logic.events.EventAnswerReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
<!-- edges of the time windows of the user scenarios -->
|
||||||
|
<receiver
|
||||||
|
android:name=".logic.scenarios.ScenarioReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
<!-- ends a "pause VPN for N minutes" and reconnects the paused profile -->
|
||||||
|
<receiver
|
||||||
|
android:name=".logic.PauseReceiver"
|
||||||
|
android:exported="false" />
|
||||||
|
<!-- runs after the app has updated itself (the system kills the
|
||||||
|
process during the install): cleans up and lets auto-connect
|
||||||
|
restore the VPN -->
|
||||||
|
<receiver
|
||||||
|
android:name=".logic.updates.UpdateReplacedReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- after a reboot or a clock change: re-arm the pause and scenario alarms, evaluate the rules again -->
|
||||||
|
<receiver
|
||||||
|
android:name=".logic.BootReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.TIME_SET" />
|
||||||
|
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
+44
-11
@@ -37,10 +37,12 @@ import android.provider.OpenableColumns;
|
|||||||
|
|
||||||
public class LogContentProvider extends ContentProvider
|
public class LogContentProvider extends ContentProvider
|
||||||
{
|
{
|
||||||
private static final String AUTHORITY = "org.strongswan.android.content.log";
|
private static final String AUTHORITY = "one.shx.strongswan.ext.content.log";
|
||||||
/* an Uri is valid for 30 minutes */
|
/* an Uri is valid for 30 minutes */
|
||||||
private static final long URI_VALIDITY = 30 * 60 * 1000;
|
private static final long URI_VALIDITY = 30 * 60 * 1000;
|
||||||
private static ConcurrentHashMap<Uri, Long> mUris = new ConcurrentHashMap<Uri, Long>();
|
private static ConcurrentHashMap<Uri, Long> mUris = new ConcurrentHashMap<Uri, Long>();
|
||||||
|
/* file served by each Uri (defaults to the charon log for legacy Uris) */
|
||||||
|
private static ConcurrentHashMap<Uri, File> mFiles = new ConcurrentHashMap<Uri, File>();
|
||||||
private File mLogFile;
|
private File mLogFile;
|
||||||
|
|
||||||
public LogContentProvider()
|
public LogContentProvider()
|
||||||
@@ -59,6 +61,16 @@ public class LogContentProvider extends ContentProvider
|
|||||||
* @return null if failed to create the Uri
|
* @return null if failed to create the Uri
|
||||||
*/
|
*/
|
||||||
public static Uri createContentUri()
|
public static Uri createContentUri()
|
||||||
|
{
|
||||||
|
return createContentUri(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The log file can only be accessed by Uris created with this method
|
||||||
|
* @param file file to serve, the charon log if null
|
||||||
|
* @return null if failed to create the Uri
|
||||||
|
*/
|
||||||
|
public static Uri createContentUri(File file)
|
||||||
{
|
{
|
||||||
SecureRandom random;
|
SecureRandom random;
|
||||||
try
|
try
|
||||||
@@ -71,6 +83,11 @@ public class LogContentProvider extends ContentProvider
|
|||||||
}
|
}
|
||||||
Uri uri = Uri.parse("content://" + AUTHORITY + "/" + random.nextLong());
|
Uri uri = Uri.parse("content://" + AUTHORITY + "/" + random.nextLong());
|
||||||
mUris.put(uri, SystemClock.uptimeMillis());
|
mUris.put(uri, SystemClock.uptimeMillis());
|
||||||
|
if (file != null)
|
||||||
|
{ /* ConcurrentHashMap does not accept null values; fileFor() falls
|
||||||
|
* back to the charon log for legacy Uris */
|
||||||
|
mFiles.put(uri, file);
|
||||||
|
}
|
||||||
return uri;
|
return uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,13 +103,13 @@ public class LogContentProvider extends ContentProvider
|
|||||||
String[] selectionArgs, String sortOrder)
|
String[] selectionArgs, String sortOrder)
|
||||||
{
|
{
|
||||||
/* this is called by apps to find out the name and size of the file.
|
/* this is called by apps to find out the name and size of the file.
|
||||||
* since we only provide a single file this is simple to implement */
|
* since we only provide a single file per Uri this is simple to implement */
|
||||||
if (projection == null)
|
if (projection == null)
|
||||||
{
|
{
|
||||||
projection = new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
|
projection = new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
|
||||||
}
|
}
|
||||||
Long timestamp = mUris.get(uri);
|
File file = fileFor(uri);
|
||||||
if (timestamp == null)
|
if (file == null)
|
||||||
{ /* don't check the validity as this information is not really private */
|
{ /* don't check the validity as this information is not really private */
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -103,12 +120,12 @@ public class LogContentProvider extends ContentProvider
|
|||||||
if (OpenableColumns.DISPLAY_NAME.equals(col))
|
if (OpenableColumns.DISPLAY_NAME.equals(col))
|
||||||
{
|
{
|
||||||
cols.add(OpenableColumns.DISPLAY_NAME);
|
cols.add(OpenableColumns.DISPLAY_NAME);
|
||||||
vals.add(CharonVpnService.LOG_FILE);
|
vals.add(file.getName());
|
||||||
}
|
}
|
||||||
else if (OpenableColumns.SIZE.equals(col))
|
else if (OpenableColumns.SIZE.equals(col))
|
||||||
{
|
{
|
||||||
cols.add(OpenableColumns.SIZE);
|
cols.add(OpenableColumns.SIZE);
|
||||||
vals.add(mLogFile.length());
|
vals.add(file.length());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,17 +137,33 @@ public class LogContentProvider extends ContentProvider
|
|||||||
@Override
|
@Override
|
||||||
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException
|
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException
|
||||||
{
|
{
|
||||||
Long timestamp = mUris.get(uri);
|
File file = fileFor(uri);
|
||||||
if (timestamp != null)
|
if (file != null)
|
||||||
{
|
{
|
||||||
|
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_CREATE | ParcelFileDescriptor.MODE_READ_ONLY);
|
||||||
|
}
|
||||||
|
return super.openFile(uri, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the file served by the given Uri, or null if the Uri is invalid or expired.
|
||||||
|
*/
|
||||||
|
private File fileFor(Uri uri)
|
||||||
|
{
|
||||||
|
Long timestamp = mUris.get(uri);
|
||||||
|
if (timestamp == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
long elapsed = SystemClock.uptimeMillis() - timestamp;
|
long elapsed = SystemClock.uptimeMillis() - timestamp;
|
||||||
if (elapsed > 0 && elapsed < URI_VALIDITY)
|
if (elapsed > 0 && elapsed < URI_VALIDITY)
|
||||||
{ /* we fail if clock wrapped, should happen rarely though */
|
{ /* we fail if clock wrapped, should happen rarely though */
|
||||||
return ParcelFileDescriptor.open(mLogFile, ParcelFileDescriptor.MODE_CREATE | ParcelFileDescriptor.MODE_READ_ONLY);
|
File file = mFiles.get(uri);
|
||||||
|
return file != null ? file : mLogFile;
|
||||||
}
|
}
|
||||||
mUris.remove(uri);
|
mUris.remove(uri);
|
||||||
}
|
mFiles.remove(uri);
|
||||||
return super.openFile(uri, mode);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+1
-1
@@ -47,7 +47,7 @@ public class ManagedVpnProfile extends VpnProfile
|
|||||||
private ManagedTrustedCertificate trustedCertificate;
|
private ManagedTrustedCertificate trustedCertificate;
|
||||||
private ManagedUserCertificate userCertificate;
|
private ManagedUserCertificate userCertificate;
|
||||||
|
|
||||||
ManagedVpnProfile(final Bundle bundle, final UUID uuid)
|
public ManagedVpnProfile(final Bundle bundle, final UUID uuid)
|
||||||
{
|
{
|
||||||
int flags = 0;
|
int flags = 0;
|
||||||
int splitFlags = 0;
|
int splitFlags = 0;
|
||||||
|
|||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.data;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileNotFoundException;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
import android.content.ContentProvider;
|
||||||
|
import android.content.ContentValues;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.database.MatrixCursor;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.ParcelFileDescriptor;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serves exported VPN profile files to other apps. Instead of grantUriPermissions
|
||||||
|
* (which does not work reliably with ACTION_SEND) this provider is exported but only
|
||||||
|
* serves files through randomly generated ticket Uris created with createContentUri().
|
||||||
|
*/
|
||||||
|
public class ProfileExportProvider extends ContentProvider
|
||||||
|
{
|
||||||
|
public static final String MIME_TYPE = "application/vnd.strongswan.profile";
|
||||||
|
|
||||||
|
private static final String AUTHORITY = "one.shx.strongswan.ext.content.export";
|
||||||
|
/* an Uri is valid for 30 minutes */
|
||||||
|
private static final long URI_VALIDITY = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
private static class Ticket
|
||||||
|
{
|
||||||
|
final File mFile;
|
||||||
|
final String mDisplayName;
|
||||||
|
final long mCreatedAt;
|
||||||
|
|
||||||
|
Ticket(File file, String displayName, long createdAt)
|
||||||
|
{
|
||||||
|
mFile = file;
|
||||||
|
mDisplayName = displayName;
|
||||||
|
mCreatedAt = createdAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final ConcurrentHashMap<Uri, Ticket> mTickets = new ConcurrentHashMap<Uri, Ticket>();
|
||||||
|
|
||||||
|
public ProfileExportProvider()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCreate()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The given file can only be accessed by Uris created with this method
|
||||||
|
* @return null if failed to create the Uri
|
||||||
|
*/
|
||||||
|
public static Uri createContentUri(Context context, File file, String displayName)
|
||||||
|
{
|
||||||
|
SecureRandom random;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
random = SecureRandom.getInstance("SHA1PRNG");
|
||||||
|
}
|
||||||
|
catch (NoSuchAlgorithmException e)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Uri uri = Uri.parse("content://" + AUTHORITY + "/" + random.nextLong());
|
||||||
|
Ticket ticket = new Ticket(exportFile(context, file.getName()), displayName, SystemClock.uptimeMillis());
|
||||||
|
mTickets.put(uri, ticket);
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Directory exported profile files are stored in (also used by the exporter).
|
||||||
|
*/
|
||||||
|
public static File exportDirectory(Context context)
|
||||||
|
{
|
||||||
|
File dir = new File(context.getCacheDir(), "export");
|
||||||
|
if (!dir.exists())
|
||||||
|
{
|
||||||
|
dir.mkdirs();
|
||||||
|
}
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The file the exported profile is stored in (resolves against the export directory).
|
||||||
|
*/
|
||||||
|
public static File exportFile(Context context, String fileName)
|
||||||
|
{
|
||||||
|
return new File(exportDirectory(context), fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getType(Uri uri)
|
||||||
|
{
|
||||||
|
return MIME_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Cursor query(Uri uri, String[] projection, String selection,
|
||||||
|
String[] selectionArgs, String sortOrder)
|
||||||
|
{
|
||||||
|
/* this is called by apps to find out the name and size of the file */
|
||||||
|
if (projection == null)
|
||||||
|
{
|
||||||
|
projection = new String[]{ OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE };
|
||||||
|
}
|
||||||
|
Ticket ticket = mTickets.get(uri);
|
||||||
|
if (ticket == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
List<String> cols = new ArrayList<>();
|
||||||
|
List<Object> vals = new ArrayList<>();
|
||||||
|
for (String col : projection)
|
||||||
|
{
|
||||||
|
if (OpenableColumns.DISPLAY_NAME.equals(col))
|
||||||
|
{
|
||||||
|
cols.add(OpenableColumns.DISPLAY_NAME);
|
||||||
|
vals.add(ticket.mDisplayName);
|
||||||
|
}
|
||||||
|
else if (OpenableColumns.SIZE.equals(col))
|
||||||
|
{
|
||||||
|
cols.add(OpenableColumns.SIZE);
|
||||||
|
vals.add(ticket.mFile.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MatrixCursor cursor = new MatrixCursor(cols.toArray(new String[0]), 1);
|
||||||
|
cursor.addRow(vals.toArray());
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException
|
||||||
|
{
|
||||||
|
Ticket ticket = mTickets.get(uri);
|
||||||
|
if (ticket != null)
|
||||||
|
{
|
||||||
|
long elapsed = SystemClock.uptimeMillis() - ticket.mCreatedAt;
|
||||||
|
if (elapsed >= 0 && elapsed < URI_VALIDITY && ticket.mFile.exists())
|
||||||
|
{
|
||||||
|
return ParcelFileDescriptor.open(ticket.mFile, ParcelFileDescriptor.MODE_READ_ONLY);
|
||||||
|
}
|
||||||
|
mTickets.remove(uri);
|
||||||
|
}
|
||||||
|
return super.openFile(uri, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Uri insert(Uri uri, ContentValues values)
|
||||||
|
{
|
||||||
|
/* not supported */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int delete(Uri uri, String selection, String[] selectionArgs)
|
||||||
|
{
|
||||||
|
/* not supported */
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int update(Uri uri, ContentValues values, String selection,
|
||||||
|
String[] selectionArgs)
|
||||||
|
{
|
||||||
|
/* not supported */
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -32,7 +32,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
public class VpnProfileManagedDataSource implements VpnProfileDataSource
|
public class VpnProfileManagedDataSource implements VpnProfileDataSource
|
||||||
{
|
{
|
||||||
private static final String NAME_MANAGED_VPN_PROFILES = "org.strongswan.android.data.VpnProfileManagedDataSource.preferences";
|
private static final String NAME_MANAGED_VPN_PROFILES = "one.shx.strongswan.ext.data.VpnProfileManagedDataSource.preferences";
|
||||||
private static final String PREFIX_USER_CERT = "usercert:";
|
private static final String PREFIX_USER_CERT = "usercert:";
|
||||||
|
|
||||||
private final ManagedConfigurationService mManagedConfigurationService;
|
private final ManagedConfigurationService mManagedConfigurationService;
|
||||||
|
|||||||
+13
-3
@@ -54,6 +54,16 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
|||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A profile keeps the data source it was loaded from, which may be closed
|
||||||
|
* by the time the profile is updated or deleted, so reopen on demand.
|
||||||
|
*/
|
||||||
|
private SQLiteDatabase database()
|
||||||
|
{
|
||||||
|
open();
|
||||||
|
return mDatabase;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void close()
|
public void close()
|
||||||
{
|
{
|
||||||
@@ -67,7 +77,7 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
|||||||
public VpnProfile insertProfile(VpnProfile profile)
|
public VpnProfile insertProfile(VpnProfile profile)
|
||||||
{
|
{
|
||||||
ContentValues values = ContentValuesFromVpnProfile(profile);
|
ContentValues values = ContentValuesFromVpnProfile(profile);
|
||||||
long insertId = mDatabase.insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
|
long insertId = database().insert(DatabaseHelper.TABLE_VPN_PROFILE.Name, null, values);
|
||||||
if (insertId == -1)
|
if (insertId == -1)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
@@ -82,14 +92,14 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
|||||||
{
|
{
|
||||||
final UUID uuid = profile.getUUID();
|
final UUID uuid = profile.getUUID();
|
||||||
ContentValues values = ContentValuesFromVpnProfile(profile);
|
ContentValues values = ContentValuesFromVpnProfile(profile);
|
||||||
return mDatabase.update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
return database().update(DatabaseHelper.TABLE_VPN_PROFILE.Name, values, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean deleteVpnProfile(VpnProfile profile)
|
public boolean deleteVpnProfile(VpnProfile profile)
|
||||||
{
|
{
|
||||||
final UUID uuid = profile.getUUID();
|
final UUID uuid = profile.getUUID();
|
||||||
return mDatabase.delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
return database().delete(DatabaseHelper.TABLE_VPN_PROFILE.Name, KEY_UUID + " = ?", new String[]{uuid.toString()}) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import org.strongswan.android.logic.autoconnect.AutoConnectManager
|
||||||
|
import org.strongswan.android.logic.scenarios.ScenarioEngine
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the automation up again after a reboot or a change of the clock.
|
||||||
|
*
|
||||||
|
* Starting the process runs the application's start-up (network rules, scenarios),
|
||||||
|
* but alarms do not survive a reboot, and a time or time zone change moves the
|
||||||
|
* edges of the scenario time windows. This receiver re-arms what the alarms
|
||||||
|
* carried and lets the rules decide again. It never starts the tunnel by itself:
|
||||||
|
* the rules and scenarios do, and a profile without a saved password stays off.
|
||||||
|
*/
|
||||||
|
class BootReceiver : BroadcastReceiver()
|
||||||
|
{
|
||||||
|
override fun onReceive(context: Context, intent: Intent?)
|
||||||
|
{
|
||||||
|
val action = intent?.action ?: return
|
||||||
|
DiagLog.i(TAG, "system event: $action")
|
||||||
|
if (action == Intent.ACTION_BOOT_COMPLETED)
|
||||||
|
{
|
||||||
|
PauseManager.rearmAfterRestart(context)
|
||||||
|
}
|
||||||
|
/* start() re-arms the time-window alarm for the current clock and zone */
|
||||||
|
ScenarioEngine.start(context)
|
||||||
|
AutoConnectManager.getInstance(context).reevaluate()
|
||||||
|
val pending = goAsync()
|
||||||
|
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object
|
||||||
|
{
|
||||||
|
const val TAG = "BootReceiver"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1491
-129
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileWriter
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight file-based lifecycle log (diag.log) for app-level events that
|
||||||
|
* must remain diagnosable after the hosting process dies: logcat is lost in
|
||||||
|
* that case and charon.log only covers the daemon. Records process starts,
|
||||||
|
* watchdog decisions, retry scheduling, network changes, auto-connect and
|
||||||
|
* foreground-service start failures.
|
||||||
|
*
|
||||||
|
* The writer is kept open across writes but flushed on every line, so
|
||||||
|
* events survive a process kill at any point while avoiding a per-line
|
||||||
|
* open/stat/close cycle. The file rotates at [MAX_SIZE] (one previous
|
||||||
|
* generation is kept), tracked by a written-byte counter instead of a stat
|
||||||
|
* per line. Never throws - logging must not be able to break the caller.
|
||||||
|
*/
|
||||||
|
object DiagLog
|
||||||
|
{
|
||||||
|
private const val FILE_NAME = "diag.log"
|
||||||
|
private const val OLD_FILE_NAME = "diag.log.1"
|
||||||
|
private const val MAX_SIZE = 256L * 1024
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var dir: File? = null
|
||||||
|
|
||||||
|
private var writer: FileWriter? = null
|
||||||
|
private var written = 0L
|
||||||
|
|
||||||
|
private val format = SimpleDateFormat("MM-dd HH:mm:ss.SSS", Locale.US)
|
||||||
|
|
||||||
|
/** Must be called once at process start (from the Application class). */
|
||||||
|
@JvmStatic
|
||||||
|
fun init(context: Context)
|
||||||
|
{
|
||||||
|
dir = context.getFilesDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write one line; never throws. */
|
||||||
|
@JvmStatic
|
||||||
|
fun i(tag: String, message: String)
|
||||||
|
{
|
||||||
|
val base = dir ?: return
|
||||||
|
try
|
||||||
|
{
|
||||||
|
synchronized(this)
|
||||||
|
{
|
||||||
|
/* SimpleDateFormat is not thread-safe: format under the lock */
|
||||||
|
val line = "${format.format(Date())} $tag: $message\n"
|
||||||
|
if (written > MAX_SIZE)
|
||||||
|
{
|
||||||
|
rotate(base)
|
||||||
|
}
|
||||||
|
var w = writer
|
||||||
|
if (w == null)
|
||||||
|
{
|
||||||
|
val file = File(base, FILE_NAME)
|
||||||
|
written = file.length()
|
||||||
|
w = FileWriter(file, true)
|
||||||
|
writer = w
|
||||||
|
}
|
||||||
|
w.write(line)
|
||||||
|
w.flush()
|
||||||
|
written += line.toByteArray(Charsets.UTF_8).size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Close the writer and move diag.log to diag.log.1; reopened lazily. */
|
||||||
|
private fun rotate(base: File)
|
||||||
|
{
|
||||||
|
writer?.let {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
it.close()
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writer = null
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File(base, OLD_FILE_NAME).delete()
|
||||||
|
File(base, FILE_NAME).renameTo(File(base, OLD_FILE_NAME))
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
written = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The log file (may not exist yet). */
|
||||||
|
@JvmStatic
|
||||||
|
fun file(context: Context): File = File(context.getFilesDir(), FILE_NAME)
|
||||||
|
}
|
||||||
+72
@@ -0,0 +1,72 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic;
|
||||||
|
|
||||||
|
import android.app.AlarmManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retries a foreground service start that the system blocked while the app
|
||||||
|
* was in the background (Android 12+). Alarms are on the allowlist for such
|
||||||
|
* starts, so the start is delivered via a one-shot alarm instead.
|
||||||
|
*
|
||||||
|
* The alarm is NON-WAKEUP and exact: it never wakes the device and simply
|
||||||
|
* fires when the device is awake anyway (deferred by Doze until then). It
|
||||||
|
* replaces any previously scheduled retry, so blocked starts cannot pile up.
|
||||||
|
*/
|
||||||
|
final class FgsStartRetry
|
||||||
|
{
|
||||||
|
private static final String TAG = FgsStartRetry.class.getSimpleName();
|
||||||
|
private static final int REQUEST_CODE = 42;
|
||||||
|
private static final long RETRY_DELAY_MS = 5000;
|
||||||
|
|
||||||
|
private FgsStartRetry()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
static void schedule(Context context, Intent intent)
|
||||||
|
{
|
||||||
|
AlarmManager am = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||||
|
if (am == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PendingIntent pi = PendingIntent.getForegroundService(context, REQUEST_CODE, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
long when = SystemClock.elapsedRealtime() + RETRY_DELAY_MS;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !am.canScheduleExactAlarms())
|
||||||
|
{
|
||||||
|
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
am.setExact(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (SecurityException e)
|
||||||
|
{ /* the exact-alarm grant was revoked in between; inexact is fine */
|
||||||
|
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Foreground service start was blocked, retrying via a non-wakeup alarm");
|
||||||
|
DiagLog.i(TAG, "FGS start blocked, retry alarm scheduled in " + RETRY_DELAY_MS + "ms");
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-4
@@ -25,6 +25,7 @@ import android.net.Network;
|
|||||||
import android.net.NetworkRequest;
|
import android.net.NetworkRequest;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
|
|
||||||
public class NetworkManager extends BroadcastReceiver implements Runnable
|
public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||||
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
private volatile boolean mRegistered;
|
private volatile boolean mRegistered;
|
||||||
private ConnectivityManager.NetworkCallback mCallback;
|
private ConnectivityManager.NetworkCallback mCallback;
|
||||||
private Thread mEventNotifier;
|
private Thread mEventNotifier;
|
||||||
private int mConnectedNetworks = 0;
|
/* a set, not a counter: a repeated onAvailable or a missed onLost cannot
|
||||||
|
* make it drift away from the networks that really exist */
|
||||||
|
private final HashSet<Network> mConnectedNetworks = new HashSet<>();
|
||||||
private LinkedList<Boolean> mEvents = new LinkedList<>();
|
private LinkedList<Boolean> mEvents = new LinkedList<>();
|
||||||
|
|
||||||
public NetworkManager(Context context)
|
public NetworkManager(Context context)
|
||||||
@@ -51,7 +54,7 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
{
|
{
|
||||||
/* we expect this to be called if connected to at least one network during
|
/* we expect this to be called if connected to at least one network during
|
||||||
* callback registration */
|
* callback registration */
|
||||||
mConnectedNetworks += 1;
|
mConnectedNetworks.add(network);
|
||||||
mEvents.addLast(true);
|
mEvents.addLast(true);
|
||||||
NetworkManager.this.notifyAll();
|
NetworkManager.this.notifyAll();
|
||||||
}
|
}
|
||||||
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
synchronized (NetworkManager.this)
|
synchronized (NetworkManager.this)
|
||||||
{
|
{
|
||||||
/* in particular mobile connections are disconnected overlapping with WiFi */
|
/* in particular mobile connections are disconnected overlapping with WiFi */
|
||||||
mConnectedNetworks -= 1;
|
mConnectedNetworks.remove(network);
|
||||||
mEvents.addLast(mConnectedNetworks > 0);
|
mEvents.addLast(!mConnectedNetworks.isEmpty());
|
||||||
NetworkManager.this.notifyAll();
|
NetworkManager.this.notifyAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,8 +76,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void Register()
|
public void Register()
|
||||||
|
{
|
||||||
|
synchronized (this)
|
||||||
{
|
{
|
||||||
mEvents.clear();
|
mEvents.clear();
|
||||||
|
mConnectedNetworks.clear();
|
||||||
|
}
|
||||||
mRegistered = true;
|
mRegistered = true;
|
||||||
mEventNotifier = new Thread(this);
|
mEventNotifier = new Thread(this);
|
||||||
mEventNotifier.start();
|
mEventNotifier.start();
|
||||||
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
connected = mEvents.removeFirst();
|
connected = mEvents.removeFirst();
|
||||||
|
/* a burst of identical states (several networks coming up at once)
|
||||||
|
* is one change for charon, not one reconnect attempt each */
|
||||||
|
while (!mEvents.isEmpty() && mEvents.getFirst() == connected)
|
||||||
|
{
|
||||||
|
mEvents.removeFirst();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
/* call the native parts without holding the lock */
|
/* call the native parts without holding the lock */
|
||||||
networkChanged(!connected);
|
networkChanged(!connected);
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
|
import org.strongswan.android.logic.events.Events
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Pause VPN for N minutes": the tunnel is disconnected like a manual
|
||||||
|
* disconnect (the watchdog stands down) and an alarm brings the same profile
|
||||||
|
* back when the pause ends. Auto-connect does not start the tunnel while a
|
||||||
|
* pause is running, and any connect request cancels the pause.
|
||||||
|
*/
|
||||||
|
object PauseManager
|
||||||
|
{
|
||||||
|
private const val TAG = "PauseManager"
|
||||||
|
private const val PREF_UNTIL = "pref_pause_until"
|
||||||
|
private const val PREF_PROFILE = "pref_pause_profile"
|
||||||
|
private const val REQUEST_CODE = 45
|
||||||
|
/** «Pause 15 min» from the VPN notification */
|
||||||
|
const val ACTION_PAUSE = "org.strongswan.android.action.PAUSE_15"
|
||||||
|
/* safety net of "until the network changes" */
|
||||||
|
private const val NETWORK_PAUSE_MAX_MS = 12L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/* wall-clock end of the running pause, 0 if none; read by the Home screen */
|
||||||
|
private val _until = MutableStateFlow(0L)
|
||||||
|
val until: StateFlow<Long> = _until
|
||||||
|
@Volatile private var loaded = false
|
||||||
|
|
||||||
|
private fun load(context: Context)
|
||||||
|
{
|
||||||
|
if (!loaded)
|
||||||
|
{
|
||||||
|
val until = PreferenceManager.getDefaultSharedPreferences(context).getLong(PREF_UNTIL, 0L)
|
||||||
|
_until.value = if (until > System.currentTimeMillis()) until else 0L
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The flow of the pause end, initialised from the stored state. */
|
||||||
|
@JvmStatic
|
||||||
|
fun until(context: Context): StateFlow<Long>
|
||||||
|
{
|
||||||
|
load(context.applicationContext)
|
||||||
|
return until
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun isPaused(context: Context): Boolean
|
||||||
|
{
|
||||||
|
load(context.applicationContext)
|
||||||
|
return _until.value > System.currentTimeMillis()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disconnects [service] and schedules the reconnect of [profileUuid] in [minutes]. */
|
||||||
|
@JvmStatic
|
||||||
|
fun pause(context: Context, service: VpnStateService, profileUuid: String, minutes: Int)
|
||||||
|
{
|
||||||
|
pauseUntil(context, service, profileUuid, System.currentTimeMillis() + minutes * 60_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disconnects [service] until the wall-clock time [until]. */
|
||||||
|
@JvmStatic
|
||||||
|
fun pauseUntil(context: Context, service: VpnStateService, profileUuid: String, until: Long)
|
||||||
|
{
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
unwatchNetwork(ctx)
|
||||||
|
service.disconnect()
|
||||||
|
store(ctx, until, profileUuid)
|
||||||
|
DiagLog.i(TAG, "VPN paused until $until")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects until the device moves to another network (e.g. leaves this
|
||||||
|
* Wi-Fi). A [NETWORK_PAUSE_MAX_MS] alarm is the safety net in case the
|
||||||
|
* process dies meanwhile and the change is never seen.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun pauseUntilNetworkChange(context: Context, service: VpnStateService, profileUuid: String)
|
||||||
|
{
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
pauseUntil(ctx, service, profileUuid, System.currentTimeMillis() + NETWORK_PAUSE_MAX_MS)
|
||||||
|
watchNetwork(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next 07:00 after [now], local time. */
|
||||||
|
@JvmStatic
|
||||||
|
fun nextMorning(now: Long): Long
|
||||||
|
{
|
||||||
|
val c = java.util.Calendar.getInstance()
|
||||||
|
c.timeInMillis = now
|
||||||
|
c.set(java.util.Calendar.HOUR_OF_DAY, 7)
|
||||||
|
c.set(java.util.Calendar.MINUTE, 0)
|
||||||
|
c.set(java.util.Calendar.SECOND, 0)
|
||||||
|
c.set(java.util.Calendar.MILLISECOND, 0)
|
||||||
|
if (c.timeInMillis <= now)
|
||||||
|
{
|
||||||
|
c.add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||||
|
}
|
||||||
|
return c.timeInMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
private var networkCallback: android.net.ConnectivityManager.NetworkCallback? = null
|
||||||
|
|
||||||
|
private fun watchNetwork(ctx: Context)
|
||||||
|
{
|
||||||
|
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager ?: return
|
||||||
|
var first: android.net.Network? = null
|
||||||
|
val callback = object : android.net.ConnectivityManager.NetworkCallback()
|
||||||
|
{
|
||||||
|
override fun onAvailable(network: android.net.Network)
|
||||||
|
{
|
||||||
|
/* right after the disconnect the default network may still be the
|
||||||
|
* tunnel itself: only real uplinks count */
|
||||||
|
val caps = try
|
||||||
|
{
|
||||||
|
cm.getNetworkCapabilities(network)
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (caps == null || caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/* the first uplink reported is the current one; any other one is a change */
|
||||||
|
if (first == null)
|
||||||
|
{
|
||||||
|
first = network
|
||||||
|
}
|
||||||
|
else if (network != first)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "network changed, ending the pause")
|
||||||
|
android.os.Handler(android.os.Looper.getMainLooper()).post { resumeNow(ctx) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cm.registerDefaultNetworkCallback(callback)
|
||||||
|
networkCallback = callback
|
||||||
|
}
|
||||||
|
catch (_: RuntimeException)
|
||||||
|
{ /* too many callbacks: the safety alarm still ends the pause */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unwatchNetwork(ctx: Context)
|
||||||
|
{
|
||||||
|
val callback = networkCallback ?: return
|
||||||
|
networkCallback = null
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager)?.unregisterNetworkCallback(callback)
|
||||||
|
}
|
||||||
|
catch (_: IllegalArgumentException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves the end of the running pause by [minutes]. */
|
||||||
|
@JvmStatic
|
||||||
|
fun extend(context: Context, minutes: Int)
|
||||||
|
{
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
val profile = prefs.getString(PREF_PROFILE, null) ?: return
|
||||||
|
if (!isPaused(ctx))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store(ctx, _until.value + minutes * 60_000L, profile)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the pause right away and reconnects the paused profile. */
|
||||||
|
@JvmStatic
|
||||||
|
fun resumeNow(context: Context)
|
||||||
|
{
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
val profile = PreferenceManager.getDefaultSharedPreferences(ctx).getString(PREF_PROFILE, null)
|
||||||
|
cancel(ctx)
|
||||||
|
if (profile != null)
|
||||||
|
{
|
||||||
|
reconnect(ctx, profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops the pause without connecting. Called for every connect request,
|
||||||
|
* so a manual connect (app, tile, widget, shortcut) wins over the pause.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun cancel(context: Context)
|
||||||
|
{
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
load(ctx)
|
||||||
|
if (_until.value == 0L)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_until.value = 0L
|
||||||
|
unwatchNetwork(ctx)
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
||||||
|
.remove(PREF_UNTIL).remove(PREF_PROFILE).apply()
|
||||||
|
alarmManager(ctx).cancel(alarmIntent(ctx))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After a reboot: alarms do not survive it, so a pause that was running would
|
||||||
|
* never end. Re-arms the alarm, or ends the pause right away if its time is up.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun rearmAfterRestart(context: Context)
|
||||||
|
{
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||||
|
val until = prefs.getLong(PREF_UNTIL, 0L)
|
||||||
|
val profile = prefs.getString(PREF_PROFILE, null)
|
||||||
|
if (profile == null || until == 0L)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (until > System.currentTimeMillis())
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "pause survives the restart, re-arming its alarm")
|
||||||
|
store(ctx, until, profile)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
onAlarm(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun store(ctx: Context, until: Long, profileUuid: String)
|
||||||
|
{
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
||||||
|
.putLong(PREF_UNTIL, until).putString(PREF_PROFILE, profileUuid).apply()
|
||||||
|
_until.value = until
|
||||||
|
loaded = true
|
||||||
|
val am = alarmManager(ctx)
|
||||||
|
val pi = alarmIntent(ctx)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||||
|
if (exact)
|
||||||
|
{
|
||||||
|
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: SecurityException)
|
||||||
|
{ /* the exact-alarm grant was revoked in between; a late resume is fine */
|
||||||
|
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun alarmManager(ctx: Context) = ctx.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||||
|
|
||||||
|
private fun alarmIntent(ctx: Context): PendingIntent =
|
||||||
|
PendingIntent.getBroadcast(
|
||||||
|
ctx, REQUEST_CODE, Intent(ctx, PauseReceiver::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Called by [PauseReceiver] when the alarm fires. */
|
||||||
|
internal fun onAlarm(context: Context)
|
||||||
|
{
|
||||||
|
val ctx = context.applicationContext
|
||||||
|
val profile = PreferenceManager.getDefaultSharedPreferences(ctx).getString(PREF_PROFILE, null)
|
||||||
|
cancel(ctx)
|
||||||
|
if (profile == null)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DiagLog.i(TAG, "pause over, reconnecting")
|
||||||
|
reconnect(ctx, profile)
|
||||||
|
Events.pauseOver(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconnect(ctx: Context, profileUuid: String)
|
||||||
|
{
|
||||||
|
val needsConsent = try
|
||||||
|
{
|
||||||
|
VpnService.prepare(ctx) != null
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
true
|
||||||
|
}
|
||||||
|
if (needsConsent)
|
||||||
|
{ /* the VPN permission went to another app meanwhile; only the UI can ask again */
|
||||||
|
DiagLog.i(TAG, "pause over, but the VPN permission is gone")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val service = VpnStateService.getLiveInstance()
|
||||||
|
if (service != null)
|
||||||
|
{ /* a live process: the regular connect path (keeps retries and state) */
|
||||||
|
if (service.state == VpnStateService.State.DISABLED)
|
||||||
|
{
|
||||||
|
val extras = Bundle()
|
||||||
|
extras.putString(VpnProfileDataSource.KEY_UUID, profileUuid)
|
||||||
|
/* a password typed for the paused session goes along with it */
|
||||||
|
service.profile?.takeIf { it.getUUID().toString() == profileUuid }?.password
|
||||||
|
?.let { extras.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||||
|
service.connect(extras, true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/* the process is gone (the alarm started a fresh one): start the
|
||||||
|
* service directly, like the watchdog does */
|
||||||
|
val start = Intent(ctx, CharonVpnService::class.java)
|
||||||
|
start.putExtra(VpnProfileDataSource.KEY_UUID, profileUuid)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
{
|
||||||
|
ctx.startForegroundService(start)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ctx.startService(start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: IllegalStateException)
|
||||||
|
{
|
||||||
|
FgsStartRetry.schedule(ctx, start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fires when a pause ends and brings the tunnel back. */
|
||||||
|
class PauseReceiver : BroadcastReceiver()
|
||||||
|
{
|
||||||
|
override fun onReceive(context: Context, intent: Intent?)
|
||||||
|
{
|
||||||
|
if (intent?.action == PauseManager.ACTION_PAUSE)
|
||||||
|
{
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(context)
|
||||||
|
val service = VpnStateService.getLiveInstance() ?: return
|
||||||
|
val profile = service.profile ?: return
|
||||||
|
PauseManager.pause(context, service, profile.getUUID().toString(), 15)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
PauseManager.onAlarm(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
+98
@@ -0,0 +1,98 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic
|
||||||
|
|
||||||
|
import java.util.PriorityQueue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The job queue behind [Scheduler]: charon's timers, ordered by due time, and the
|
||||||
|
* bookkeeping of the single alarm that wakes the device for the earliest one.
|
||||||
|
* Times are on any monotonic clock (the scheduler passes elapsedRealtime).
|
||||||
|
*
|
||||||
|
* Jobs due within [coalesceMs] of each other share one alarm: a job may run up to
|
||||||
|
* [coalesceMs] early, never late. charon only schedules jobs longer than 3 s ahead,
|
||||||
|
* so 2 s is harmless, and every alarm saved is one more slot in the Doze quota of
|
||||||
|
* allow-while-idle alarms that the keepalive needs.
|
||||||
|
*
|
||||||
|
* Not thread-safe; the caller synchronises.
|
||||||
|
*/
|
||||||
|
class ScheduledJobQueue(private val coalesceMs: Long = COALESCE_MS)
|
||||||
|
{
|
||||||
|
private class Job(val id: String, val time: Long) : Comparable<Job>
|
||||||
|
{
|
||||||
|
override fun compareTo(other: Job): Int = time.compareTo(other.time)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val jobs = PriorityQueue<Job>()
|
||||||
|
private var armed = NONE
|
||||||
|
|
||||||
|
/** Due time the alarm is currently set for, or [NONE]. */
|
||||||
|
val armedAt: Long get() = armed
|
||||||
|
|
||||||
|
val size: Int get() = jobs.size
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Queues a job.
|
||||||
|
*
|
||||||
|
* @return true if the alarm has to be (re)armed for [nextAlarm]; false if the
|
||||||
|
* armed alarm already covers the job
|
||||||
|
*/
|
||||||
|
fun add(id: String, time: Long): Boolean
|
||||||
|
{
|
||||||
|
jobs.add(Job(id, time))
|
||||||
|
if (armed == NONE || time < armed - coalesceMs)
|
||||||
|
{
|
||||||
|
armed = time
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the jobs that are due at [now] (or within the coalescing window).
|
||||||
|
* The alarm is considered fired: [nextAlarm] says whether to arm another one.
|
||||||
|
*/
|
||||||
|
fun popDue(now: Long): List<String>
|
||||||
|
{
|
||||||
|
val limit = now + coalesceMs
|
||||||
|
val due = ArrayList<String>()
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
val head = jobs.peek() ?: break
|
||||||
|
if (head.time > limit)
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
due.add(jobs.remove().id)
|
||||||
|
}
|
||||||
|
armed = jobs.peek()?.time ?: NONE
|
||||||
|
return due
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Due time of the earliest queued job, or [NONE]; the time to arm after [popDue]. */
|
||||||
|
fun nextAlarm(): Long = jobs.peek()?.time ?: NONE
|
||||||
|
|
||||||
|
fun clear()
|
||||||
|
{
|
||||||
|
jobs.clear()
|
||||||
|
armed = NONE
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
const val NONE = -1L
|
||||||
|
const val COALESCE_MS = 2000L
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-50
@@ -24,26 +24,32 @@ import android.content.Context;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.IntentFilter;
|
import android.content.IntentFilter;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.List;
|
||||||
import java.util.PriorityQueue;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
|
|
||||||
public class Scheduler extends BroadcastReceiver
|
public class Scheduler extends BroadcastReceiver
|
||||||
{
|
{
|
||||||
private final String EXECUTE_JOB = "org.strongswan.android.Scheduler.EXECUTE_JOB";
|
private final String EXECUTE_JOB = "one.shx.strongswan.ext.Scheduler.EXECUTE_JOB";
|
||||||
|
private final String TAG = Scheduler.class.getSimpleName();
|
||||||
private final Context mContext;
|
private final Context mContext;
|
||||||
private final AlarmManager mManager;
|
private final AlarmManager mManager;
|
||||||
private final PriorityQueue<ScheduledJob> mJobs;
|
/* due times are elapsedRealtime: unlike RTC they do not jump when the clock
|
||||||
|
* is set (NTP, manual change, time zone), which would delay keepalives and
|
||||||
|
* rekeyings by hours */
|
||||||
|
private final ScheduledJobQueue mJobs;
|
||||||
|
private boolean mExactLogged;
|
||||||
|
|
||||||
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
@SuppressLint("UnspecifiedRegisterReceiverFlag")
|
||||||
public Scheduler(Context context)
|
public Scheduler(Context context)
|
||||||
{
|
{
|
||||||
mContext = context;
|
mContext = context;
|
||||||
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||||
mJobs = new PriorityQueue<>();
|
mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
|
||||||
|
|
||||||
IntentFilter filter = new IntentFilter();
|
IntentFilter filter = new IntentFilter();
|
||||||
filter.addAction(EXECUTE_JOB);
|
filter.addAction(EXECUTE_JOB);
|
||||||
@@ -99,6 +105,47 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
return PendingIntent.getBroadcast(mContext, 0, intent, flags);
|
return PendingIntent.getBroadcast(mContext, 0, intent, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the next wakeup alarm. Uses exact alarms so the device is woken
|
||||||
|
* for NAT keepalives and rekeyings while it is idle, but since Android 13
|
||||||
|
* that special permission is denied by default, so fall back to inexact
|
||||||
|
* alarms instead of crashing with a SecurityException (connectivity then
|
||||||
|
* only degrades slightly while timers may be delayed in doze mode).
|
||||||
|
*
|
||||||
|
* @param time wakeup time based on elapsedRealtime
|
||||||
|
*/
|
||||||
|
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||||
|
private void scheduleAlarm(long time)
|
||||||
|
{
|
||||||
|
PendingIntent pending = createIntent();
|
||||||
|
boolean exact = true;
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
{
|
||||||
|
exact = mManager.canScheduleExactAlarms();
|
||||||
|
}
|
||||||
|
if (!mExactLogged)
|
||||||
|
{ /* once per run, for support reports */
|
||||||
|
mExactLogged = true;
|
||||||
|
DiagLog.i(TAG, "keepalive alarms are " + (exact ? "exact" : "inexact (exact alarms not allowed)"));
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (exact)
|
||||||
|
{
|
||||||
|
mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (SecurityException e)
|
||||||
|
{ /* permission might have been revoked, or OEM restrictions apply */
|
||||||
|
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
|
||||||
|
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule executing a job in the future.
|
* Schedule executing a job in the future.
|
||||||
* Called via JNI from different threads.
|
* Called via JNI from different threads.
|
||||||
@@ -111,13 +158,10 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
{
|
{
|
||||||
synchronized (this)
|
synchronized (this)
|
||||||
{
|
{
|
||||||
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms);
|
long time = SystemClock.elapsedRealtime() + ms;
|
||||||
mJobs.add(job);
|
if (mJobs.add(id, time))
|
||||||
|
{ /* the job is due before the armed alarm: move the alarm */
|
||||||
if (job == mJobs.peek())
|
scheduleAlarm(time);
|
||||||
{ /* update the alarm if the job has to be executed before all others */
|
|
||||||
PendingIntent pending = createIntent();
|
|
||||||
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, job.Time, pending);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,31 +170,21 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
@Override
|
@Override
|
||||||
public void onReceive(Context context, Intent intent)
|
public void onReceive(Context context, Intent intent)
|
||||||
{
|
{
|
||||||
ArrayList<ScheduledJob> jobs = new ArrayList<>();
|
List<String> jobs;
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
|
|
||||||
synchronized (this)
|
synchronized (this)
|
||||||
{
|
{
|
||||||
ScheduledJob job = mJobs.peek();
|
jobs = mJobs.popDue(SystemClock.elapsedRealtime());
|
||||||
while (job != null)
|
long next = mJobs.nextAlarm();
|
||||||
|
if (next != ScheduledJobQueue.NONE)
|
||||||
{
|
{
|
||||||
if (job.Time > now)
|
scheduleAlarm(next);
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
jobs.add(mJobs.remove());
|
|
||||||
job = mJobs.peek();
|
|
||||||
}
|
|
||||||
if (job != null)
|
|
||||||
{
|
|
||||||
PendingIntent pending = createIntent();
|
|
||||||
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, job.Time, pending);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ScheduledJob job : jobs)
|
for (String id : jobs)
|
||||||
{
|
{
|
||||||
executeJob(job.Id);
|
executeJob(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,25 +194,4 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
* @param id job ID
|
* @param id job ID
|
||||||
*/
|
*/
|
||||||
public native void executeJob(String id);
|
public native void executeJob(String id);
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep track of scheduled jobs.
|
|
||||||
*/
|
|
||||||
private static class ScheduledJob implements Comparable<ScheduledJob>
|
|
||||||
{
|
|
||||||
String Id;
|
|
||||||
long Time;
|
|
||||||
|
|
||||||
ScheduledJob(String id, long time)
|
|
||||||
{
|
|
||||||
Id = id;
|
|
||||||
Time = time;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int compareTo(ScheduledJob o)
|
|
||||||
{
|
|
||||||
return Long.compare(Time, o.Time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+51
@@ -28,6 +28,13 @@ import android.util.Log;
|
|||||||
|
|
||||||
import org.strongswan.android.data.DatabaseHelper;
|
import org.strongswan.android.data.DatabaseHelper;
|
||||||
import org.strongswan.android.data.ManagedConfigurationService;
|
import org.strongswan.android.data.ManagedConfigurationService;
|
||||||
|
import org.strongswan.android.logic.autoconnect.AutoConnectManager;
|
||||||
|
import org.strongswan.android.logic.connectivity.HealthCheckManager;
|
||||||
|
import org.strongswan.android.logic.connectivity.WhiteListManager;
|
||||||
|
import org.strongswan.android.logic.routing.RoutingManager;
|
||||||
|
import org.strongswan.android.logic.routing.RuIpListManager;
|
||||||
|
import org.strongswan.android.logic.shortcuts.VpnShortcutManager;
|
||||||
|
import org.strongswan.android.logic.updates.UpdateManager;
|
||||||
import org.strongswan.android.security.LocalCertificateKeyStoreProvider;
|
import org.strongswan.android.security.LocalCertificateKeyStoreProvider;
|
||||||
import org.strongswan.android.utils.Constants;
|
import org.strongswan.android.utils.Constants;
|
||||||
|
|
||||||
@@ -80,6 +87,24 @@ public class StrongSwanApplication extends Application
|
|||||||
StrongSwanApplication.mContext = getApplicationContext();
|
StrongSwanApplication.mContext = getApplicationContext();
|
||||||
StrongSwanApplication.mInstance = this;
|
StrongSwanApplication.mInstance = this;
|
||||||
|
|
||||||
|
/* file-based lifecycle log; must be initialized before anything logs to it */
|
||||||
|
DiagLog.init(this);
|
||||||
|
DiagLog.i(TAG, "process start (pid=" + android.os.Process.myPid() + ")");
|
||||||
|
|
||||||
|
/* record every uncaught Java exception in diag.log before the process
|
||||||
|
* dies: logcat is lost on a process kill, and this is the only way to
|
||||||
|
* diagnose crashes reported by users without adb */
|
||||||
|
final Thread.UncaughtExceptionHandler previousHandler =
|
||||||
|
Thread.getDefaultUncaughtExceptionHandler();
|
||||||
|
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
|
||||||
|
DiagLog.i(TAG, "UNCAUGHT EXCEPTION on " + thread.getName() + ": " +
|
||||||
|
Log.getStackTraceString(throwable));
|
||||||
|
if (previousHandler != null)
|
||||||
|
{
|
||||||
|
previousHandler.uncaughtException(thread, throwable);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
mDatabaseHelper = new DatabaseHelper(mContext);
|
mDatabaseHelper = new DatabaseHelper(mContext);
|
||||||
|
|
||||||
mManagedConfigurationService = new ManagedConfigurationService(mContext);
|
mManagedConfigurationService = new ManagedConfigurationService(mContext);
|
||||||
@@ -93,6 +118,32 @@ public class StrongSwanApplication extends Application
|
|||||||
|
|
||||||
final IntentFilter restrictionsFilter = new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED);
|
final IntentFilter restrictionsFilter = new IntentFilter(Intent.ACTION_APPLICATION_RESTRICTIONS_CHANGED);
|
||||||
registerReceiver(mRestrictionsReceiver, restrictionsFilter);
|
registerReceiver(mRestrictionsReceiver, restrictionsFilter);
|
||||||
|
|
||||||
|
/* starts monitoring networks if the trusted networks feature is enabled */
|
||||||
|
AutoConnectManager.getInstance(this).start();
|
||||||
|
|
||||||
|
/* starts tunnel health checks if the connectivity check feature is enabled */
|
||||||
|
HealthCheckManager.getInstance(this).start();
|
||||||
|
|
||||||
|
/* starts ISP whitelist detection if the feature is enabled */
|
||||||
|
WhiteListManager.getInstance(this).start();
|
||||||
|
|
||||||
|
/* applies global routing rule sets and restarts the tunnel when they change */
|
||||||
|
RoutingManager.getInstance(this).start();
|
||||||
|
|
||||||
|
/* loads the RU geoip list, refreshes it when due and restarts the tunnel on changes */
|
||||||
|
RuIpListManager.getInstance(this).start();
|
||||||
|
|
||||||
|
/* publishes shortcuts for automation tools (e.g. Samsung Modes and Routines) if the feature is enabled */
|
||||||
|
VpnShortcutManager.getInstance(this).start();
|
||||||
|
|
||||||
|
/* checks the update storage for a newer version of the app (if enabled and due) */
|
||||||
|
UpdateManager.getInstance(this).start();
|
||||||
|
/* daily re-read of the profiles that came from a link */
|
||||||
|
org.strongswan.android.logic.profiles.ProfileUpdater.schedule(this);
|
||||||
|
/* user scenarios of F_Automation */
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.start(this);
|
||||||
|
org.strongswan.android.logic.stats.WeeklySummary.schedule(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void reloadManagedConfigurationAndNotifyListeners()
|
private void reloadManagedConfigurationAndNotifyListeners()
|
||||||
|
|||||||
+423
@@ -0,0 +1,423 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.util.Base64;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
import org.strongswan.android.data.ManagedCertificate;
|
||||||
|
import org.strongswan.android.data.ManagedUserCertificate;
|
||||||
|
import org.strongswan.android.data.ManagedVpnProfile;
|
||||||
|
import org.strongswan.android.data.ProfileExportProvider;
|
||||||
|
import org.strongswan.android.data.VpnProfile;
|
||||||
|
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
|
||||||
|
import org.strongswan.android.data.VpnType;
|
||||||
|
import org.strongswan.android.data.VpnType.VpnTypeFeature;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.FileWriter;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.security.Key;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import java.security.cert.Certificate;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.Enumeration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exports a VpnProfile as a VPN profile file in the format used by the original
|
||||||
|
* strongSwan app (.sswan), with the certificates embedded, so it can be shared
|
||||||
|
* and imported by any strongSwan-compatible app.
|
||||||
|
*/
|
||||||
|
public class VpnProfileExporter
|
||||||
|
{
|
||||||
|
/** File name extension used by the original strongSwan app. */
|
||||||
|
public static final String FILE_EXTENSION = ".sswan";
|
||||||
|
|
||||||
|
private VpnProfileExporter()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the given profile to the cache directory and create a shareable
|
||||||
|
* content Uri for it.
|
||||||
|
*
|
||||||
|
* @param includePassword whether a stored password is written to the file (as cleartext)
|
||||||
|
* @return the content Uri, or null if the profile could not be exported
|
||||||
|
*/
|
||||||
|
public static Uri exportProfile(Context context, VpnProfile profile, boolean includePassword)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JSONObject obj = serializeProfile(profile, includePassword);
|
||||||
|
File file = writeFile(context, profile.getUUID().toString(), obj.toString(2));
|
||||||
|
return ProfileExportProvider.createContentUri(context, file, displayName(profile.getName()));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
e.printStackTrace();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static JSONObject serializeProfile(VpnProfile profile, boolean includePassword) throws IOException
|
||||||
|
{
|
||||||
|
if (TextUtils.isEmpty(profile.getGateway()))
|
||||||
|
{
|
||||||
|
throw new IOException("profile has no gateway");
|
||||||
|
}
|
||||||
|
JSONObject obj = new JSONObject();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
obj.put("uuid", profile.getUUID().toString());
|
||||||
|
obj.put("name", profile.getName());
|
||||||
|
obj.put("type", profile.getVpnType().getIdentifier());
|
||||||
|
|
||||||
|
obj.put("remote", buildRemote(profile));
|
||||||
|
JSONObject local = buildLocal(profile, includePassword);
|
||||||
|
if (local.length() > 0)
|
||||||
|
{
|
||||||
|
obj.put("local", local);
|
||||||
|
}
|
||||||
|
|
||||||
|
obj.putOpt("ike-proposal", profile.getIkeProposal());
|
||||||
|
obj.putOpt("esp-proposal", profile.getEspProposal());
|
||||||
|
JSONArray dns = toArray(profile.getDnsServers());
|
||||||
|
if (dns.length() > 0)
|
||||||
|
{
|
||||||
|
obj.put("dns-servers", dns);
|
||||||
|
}
|
||||||
|
obj.putOpt("mtu", profile.getMTU());
|
||||||
|
obj.putOpt("nat-keepalive", profile.getNATKeepAlive());
|
||||||
|
if (hasFlag(profile, VpnProfile.FLAGS_IPv6_TRANSPORT))
|
||||||
|
{
|
||||||
|
obj.put("ipv6-transport", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject proxy = buildProxy(profile);
|
||||||
|
if (proxy.length() > 0)
|
||||||
|
{
|
||||||
|
obj.put("proxy", proxy);
|
||||||
|
}
|
||||||
|
JSONObject split = buildSplitTunneling(profile);
|
||||||
|
if (split.length() > 0)
|
||||||
|
{
|
||||||
|
obj.put("split-tunneling", split);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.getSelectedAppsHandling() == SelectedAppsHandling.SELECTED_APPS_ONLY)
|
||||||
|
{
|
||||||
|
JSONArray apps = toArray(profile.getSelectedApps());
|
||||||
|
if (apps.length() > 0)
|
||||||
|
{
|
||||||
|
obj.put("apps", apps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (profile.getSelectedAppsHandling() == SelectedAppsHandling.SELECTED_APPS_EXCLUDE)
|
||||||
|
{
|
||||||
|
JSONArray apps = toArray(profile.getSelectedApps());
|
||||||
|
if (apps.length() > 0)
|
||||||
|
{
|
||||||
|
obj.put("excluded-apps", apps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
catch (IOException e)
|
||||||
|
{
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
IOException wrapped = new IOException("failed to serialize profile");
|
||||||
|
wrapped.initCause(e);
|
||||||
|
throw wrapped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject buildRemote(VpnProfile profile) throws Exception
|
||||||
|
{
|
||||||
|
JSONObject remote = new JSONObject();
|
||||||
|
remote.put("addr", profile.getGateway());
|
||||||
|
remote.putOpt("port", profile.getPort());
|
||||||
|
remote.putOpt("id", profile.getRemoteId());
|
||||||
|
|
||||||
|
String cert = trustedCertificateData(profile);
|
||||||
|
if (!TextUtils.isEmpty(cert))
|
||||||
|
{
|
||||||
|
remote.put("cert", cert);
|
||||||
|
}
|
||||||
|
if (hasFlag(profile, VpnProfile.FLAGS_SUPPRESS_CERT_REQS))
|
||||||
|
{
|
||||||
|
remote.put("certreq", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject revocation = new JSONObject();
|
||||||
|
if (hasFlag(profile, VpnProfile.FLAGS_DISABLE_CRL))
|
||||||
|
{
|
||||||
|
revocation.put("crl", false);
|
||||||
|
}
|
||||||
|
if (hasFlag(profile, VpnProfile.FLAGS_DISABLE_OCSP))
|
||||||
|
{
|
||||||
|
revocation.put("ocsp", false);
|
||||||
|
}
|
||||||
|
if (hasFlag(profile, VpnProfile.FLAGS_STRICT_REVOCATION))
|
||||||
|
{
|
||||||
|
revocation.put("strict", true);
|
||||||
|
}
|
||||||
|
if (revocation.length() > 0)
|
||||||
|
{
|
||||||
|
remote.put("revocation", revocation);
|
||||||
|
}
|
||||||
|
return remote;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject buildLocal(VpnProfile profile, boolean includePassword) throws Exception
|
||||||
|
{
|
||||||
|
JSONObject local = new JSONObject();
|
||||||
|
local.putOpt("id", profile.getLocalId());
|
||||||
|
|
||||||
|
VpnType type = profile.getVpnType();
|
||||||
|
if (type.has(VpnTypeFeature.USER_PASS))
|
||||||
|
{
|
||||||
|
local.putOpt("eap_id", profile.getUsername());
|
||||||
|
if (includePassword)
|
||||||
|
{
|
||||||
|
local.putOpt("shared_secret", profile.getPassword());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type.has(VpnTypeFeature.CERTIFICATE))
|
||||||
|
{
|
||||||
|
String p12 = userCertificateData(profile);
|
||||||
|
if (!TextUtils.isEmpty(p12))
|
||||||
|
{
|
||||||
|
local.put("p12", p12);
|
||||||
|
}
|
||||||
|
if (hasFlag(profile, VpnProfile.FLAGS_RSA_PSS))
|
||||||
|
{
|
||||||
|
local.put("rsa-pss", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject buildProxy(VpnProfile profile) throws Exception
|
||||||
|
{
|
||||||
|
JSONObject proxy = new JSONObject();
|
||||||
|
if (!TextUtils.isEmpty(profile.getProxyHost()))
|
||||||
|
{
|
||||||
|
proxy.put("host", profile.getProxyHost());
|
||||||
|
proxy.putOpt("port", profile.getProxyPort());
|
||||||
|
JSONArray exclusions = toArray(profile.getProxyExclusions());
|
||||||
|
if (exclusions.length() > 0)
|
||||||
|
{
|
||||||
|
proxy.put("exclusions", exclusions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return proxy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JSONObject buildSplitTunneling(VpnProfile profile) throws Exception
|
||||||
|
{
|
||||||
|
JSONObject split = new JSONObject();
|
||||||
|
JSONArray included = toArray(profile.getIncludedSubnets());
|
||||||
|
if (included.length() > 0)
|
||||||
|
{
|
||||||
|
split.put("subnets", included);
|
||||||
|
}
|
||||||
|
JSONArray excluded = toArray(profile.getExcludedSubnets());
|
||||||
|
if (excluded.length() > 0)
|
||||||
|
{
|
||||||
|
split.put("excluded", excluded);
|
||||||
|
}
|
||||||
|
Integer st = profile.getSplitTunneling();
|
||||||
|
if (st != null)
|
||||||
|
{
|
||||||
|
if ((st & VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4) != 0)
|
||||||
|
{
|
||||||
|
split.put("block-ipv4", true);
|
||||||
|
}
|
||||||
|
if ((st & VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6) != 0)
|
||||||
|
{
|
||||||
|
split.put("block-ipv6", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return split;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data of the CA/server certificate as Base64-encoded DER, or null if unavailable.
|
||||||
|
*/
|
||||||
|
private static String trustedCertificateData(VpnProfile profile)
|
||||||
|
{
|
||||||
|
if (profile instanceof ManagedVpnProfile)
|
||||||
|
{ /* the raw certificate is stored with the profile */
|
||||||
|
ManagedCertificate cert = ((ManagedVpnProfile)profile).getTrustedCertificate();
|
||||||
|
return cert != null ? cert.getData() : null;
|
||||||
|
}
|
||||||
|
String alias = profile.getCertificateAlias();
|
||||||
|
if (TextUtils.isEmpty(alias))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{ /* resolves from the app-local certificate store and all system stores */
|
||||||
|
X509Certificate certificate = TrustedCertificateManager.getInstance().getCACertificateFromAlias(alias);
|
||||||
|
if (certificate == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Base64.encodeToString(certificate.getEncoded(), Base64.NO_WRAP);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
e.printStackTrace();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data of the user certificate as Base64-encoded PKCS#12, or null if not exportable.
|
||||||
|
* Only certificates stored by the app itself (managed profiles) can be exported;
|
||||||
|
* keys installed in the system KeyChain are non-exportable by design.
|
||||||
|
*/
|
||||||
|
static String userCertificateData(VpnProfile profile)
|
||||||
|
{
|
||||||
|
if (!(profile instanceof ManagedVpnProfile))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ManagedUserCertificate cert = ((ManagedVpnProfile)profile).getUserCertificate();
|
||||||
|
if (cert == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String repacked = repackageWithoutPassword(cert);
|
||||||
|
return !TextUtils.isEmpty(repacked) ? repacked : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The profile file format has no field for the PKCS#12 password, so the container
|
||||||
|
* is rewritten with an empty password. The full certificate chain is preserved.
|
||||||
|
*/
|
||||||
|
private static String repackageWithoutPassword(ManagedUserCertificate cert)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
char[] password = cert.getPrivateKeyPassword() == null ? new char[0]
|
||||||
|
: cert.getPrivateKeyPassword().toCharArray();
|
||||||
|
KeyStore in = KeyStore.getInstance("PKCS12");
|
||||||
|
in.load(new ByteArrayInputStream(Base64.decode(cert.getData(), Base64.DEFAULT)), password);
|
||||||
|
|
||||||
|
KeyStore out = KeyStore.getInstance("PKCS12");
|
||||||
|
out.load(null, null);
|
||||||
|
for (Enumeration<String> aliases = in.aliases(); aliases.hasMoreElements(); )
|
||||||
|
{
|
||||||
|
String alias = aliases.nextElement();
|
||||||
|
Key key = in.getKey(alias, password);
|
||||||
|
Certificate[] chain = in.getCertificateChain(alias);
|
||||||
|
if (key != null)
|
||||||
|
{
|
||||||
|
out.setKeyEntry(alias, key, null, chain != null ? chain : new Certificate[0]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Certificate certificate = in.getCertificate(alias);
|
||||||
|
if (certificate != null)
|
||||||
|
{
|
||||||
|
out.setCertificateEntry(alias, certificate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
|
||||||
|
out.store(outStream, null); /* empty password */
|
||||||
|
return Base64.encodeToString(outStream.toByteArray(), Base64.NO_WRAP);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
e.printStackTrace();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasFlag(VpnProfile profile, int flag)
|
||||||
|
{
|
||||||
|
Integer flags = profile.getFlags();
|
||||||
|
return flags != null && (flags & flag) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a space-separated string to a JSON array (inverse of what the
|
||||||
|
* importer joins with spaces).
|
||||||
|
*/
|
||||||
|
private static JSONArray toArray(String value)
|
||||||
|
{
|
||||||
|
JSONArray array = new JSONArray();
|
||||||
|
if (!TextUtils.isEmpty(value))
|
||||||
|
{
|
||||||
|
for (String item : value.trim().split("\\s+"))
|
||||||
|
{
|
||||||
|
if (!TextUtils.isEmpty(item))
|
||||||
|
{
|
||||||
|
array.put(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static File writeFile(Context context, String uuid, String data) throws IOException
|
||||||
|
{
|
||||||
|
File dir = ProfileExportProvider.exportDirectory(context);
|
||||||
|
/* drop exports of other profiles so the cache does not grow indefinitely */
|
||||||
|
File[] files = dir.listFiles();
|
||||||
|
if (files != null)
|
||||||
|
{
|
||||||
|
for (File file : files)
|
||||||
|
{
|
||||||
|
file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
File file = ProfileExportProvider.exportFile(context, uuid + FILE_EXTENSION);
|
||||||
|
try (FileWriter writer = new FileWriter(file))
|
||||||
|
{
|
||||||
|
writer.write(data);
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File name offered to the receiving app, derived from the profile name.
|
||||||
|
*/
|
||||||
|
private static String displayName(String name)
|
||||||
|
{
|
||||||
|
String sanitized = name == null ? "" : name.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_").trim();
|
||||||
|
sanitized = sanitized.replaceAll("^[. ]+|[. ]+$", "");
|
||||||
|
if (TextUtils.isEmpty(sanitized))
|
||||||
|
{
|
||||||
|
sanitized = "profile";
|
||||||
|
}
|
||||||
|
if (sanitized.length() > 100)
|
||||||
|
{
|
||||||
|
sanitized = sanitized.substring(0, 100);
|
||||||
|
}
|
||||||
|
return sanitized + FILE_EXTENSION;
|
||||||
|
}
|
||||||
|
}
|
||||||
+379
-17
@@ -19,6 +19,7 @@ package org.strongswan.android.logic;
|
|||||||
import android.app.Service;
|
import android.app.Service;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
import android.os.Binder;
|
import android.os.Binder;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
@@ -26,28 +27,41 @@ import android.os.IBinder;
|
|||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.os.Message;
|
import android.os.Message;
|
||||||
import android.os.SystemClock;
|
import android.os.SystemClock;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
import org.strongswan.android.R;
|
||||||
import org.strongswan.android.data.VpnProfile;
|
import org.strongswan.android.data.VpnProfile;
|
||||||
|
import org.strongswan.android.ui.widget.ShxWidgetState;
|
||||||
import org.strongswan.android.data.VpnProfileDataSource;
|
import org.strongswan.android.data.VpnProfileDataSource;
|
||||||
import org.strongswan.android.data.VpnType;
|
import org.strongswan.android.data.VpnType;
|
||||||
|
import org.strongswan.android.logic.events.Events;
|
||||||
|
import org.strongswan.android.logic.profiles.Resilience;
|
||||||
import org.strongswan.android.logic.imc.ImcState;
|
import org.strongswan.android.logic.imc.ImcState;
|
||||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
import org.strongswan.android.logic.imc.RemediationInstruction;
|
||||||
|
import org.strongswan.android.logic.connectivity.WhiteListManager;
|
||||||
import org.strongswan.android.ui.VpnProfileControlActivity;
|
import org.strongswan.android.ui.VpnProfileControlActivity;
|
||||||
|
|
||||||
import java.lang.ref.WeakReference;
|
import java.lang.ref.WeakReference;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.LinkedList;
|
import java.util.LinkedList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.concurrent.Callable;
|
import java.util.concurrent.Callable;
|
||||||
|
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
|
import org.strongswan.android.utils.Constants;
|
||||||
|
|
||||||
public class VpnStateService extends Service
|
public class VpnStateService extends Service
|
||||||
{
|
{
|
||||||
private final HashSet<VpnStateListener> mListeners = new HashSet<VpnStateListener>();
|
private final HashSet<VpnStateListener> mListeners = new HashSet<VpnStateListener>();
|
||||||
private final IBinder mBinder = new LocalBinder();
|
private final IBinder mBinder = new LocalBinder();
|
||||||
|
/* the instance of this service in the current process, or null if it is
|
||||||
|
* not running; used by the watchdog to tell a live process from a fresh
|
||||||
|
* one (the receiver runs in the same, single process) */
|
||||||
|
private static volatile VpnStateService sInstance;
|
||||||
private long mConnectionID = 0;
|
private long mConnectionID = 0;
|
||||||
private Handler mHandler;
|
private Handler mHandler;
|
||||||
private VpnProfile mProfile;
|
private VpnProfile mProfile;
|
||||||
@@ -56,12 +70,41 @@ public class VpnStateService extends Service
|
|||||||
private ImcState mImcState = ImcState.UNKNOWN;
|
private ImcState mImcState = ImcState.UNKNOWN;
|
||||||
private final LinkedList<RemediationInstruction> mRemediationInstructions = new LinkedList<RemediationInstruction>();
|
private final LinkedList<RemediationInstruction> mRemediationInstructions = new LinkedList<RemediationInstruction>();
|
||||||
private static final long RETRY_INTERVAL = 1000;
|
private static final long RETRY_INTERVAL = 1000;
|
||||||
|
/* while a retry is still far away the countdown only ticks in coarse steps,
|
||||||
|
* saving main-thread wakeups (and notification rebuilds) while waiting */
|
||||||
|
private static final long RETRY_COARSE_INTERVAL = 5000;
|
||||||
|
private static final long RETRY_COARSE_THRESHOLD = 15000;
|
||||||
|
/* while an ISP whitelist is active the retry countdown is frozen at this interval */
|
||||||
|
private static final long RETRY_SUPPRESS_MS = 10000;
|
||||||
/* cap the retry interval at 2 minutes */
|
/* cap the retry interval at 2 minutes */
|
||||||
private static final long MAX_RETRY_INTERVAL = 120000;
|
private static final long MAX_RETRY_INTERVAL = 120000;
|
||||||
|
/* if the daemon neither completes nor reports a failure within this window
|
||||||
|
* we surface it as UNREACHABLE so the retry machinery takes over */
|
||||||
|
private static final long CONNECTING_TIMEOUT = 90000;
|
||||||
|
/* minimum interval between network-change-triggered immediate retries */
|
||||||
|
private static final long NETWORK_RETRY_DEBOUNCE_MS = 5000;
|
||||||
private static final int RETRY_MSG = 1;
|
private static final int RETRY_MSG = 1;
|
||||||
|
private static final int CONNECTING_TIMEOUT_MSG = 2;
|
||||||
private final RetryTimeoutProvider mTimeoutProvider = new RetryTimeoutProvider();
|
private final RetryTimeoutProvider mTimeoutProvider = new RetryTimeoutProvider();
|
||||||
private long mRetryTimeout;
|
private long mRetryTimeout;
|
||||||
private long mRetryIn;
|
private long mRetryIn;
|
||||||
|
/* true while the countdown is parked because the ISP whitelist blocks the server */
|
||||||
|
private boolean mRetryFrozen;
|
||||||
|
/* timestamp of the last network-change-triggered immediate retry */
|
||||||
|
private long mLastNetworkRetry;
|
||||||
|
/* double-start guard: profile and issue time of the last connect() that
|
||||||
|
* got through, cleared by disconnect() */
|
||||||
|
private static final long CONNECT_DEDUPE_MS = 10000;
|
||||||
|
private String mLastConnectUuid;
|
||||||
|
private long mLastConnectIssued;
|
||||||
|
|
||||||
|
private static final String TAG = VpnStateService.class.getSimpleName();
|
||||||
|
|
||||||
|
/** Tick size for the retry countdown: coarse steps early, seconds at the end. */
|
||||||
|
private static long retryStep(long retryIn)
|
||||||
|
{
|
||||||
|
return Math.min(retryIn > RETRY_COARSE_THRESHOLD ? RETRY_COARSE_INTERVAL : RETRY_INTERVAL, retryIn);
|
||||||
|
}
|
||||||
|
|
||||||
public enum State
|
public enum State
|
||||||
{
|
{
|
||||||
@@ -107,6 +150,7 @@ public class VpnStateService extends Service
|
|||||||
@Override
|
@Override
|
||||||
public void onCreate()
|
public void onCreate()
|
||||||
{
|
{
|
||||||
|
sInstance = this;
|
||||||
/* this handler allows us to notify listeners from the UI thread and
|
/* this handler allows us to notify listeners from the UI thread and
|
||||||
* not from the threads that actually report any state changes */
|
* not from the threads that actually report any state changes */
|
||||||
mHandler = new RetryHandler(getMainLooper(), this);
|
mHandler = new RetryHandler(getMainLooper(), this);
|
||||||
@@ -121,6 +165,20 @@ public class VpnStateService extends Service
|
|||||||
@Override
|
@Override
|
||||||
public void onDestroy()
|
public void onDestroy()
|
||||||
{
|
{
|
||||||
|
if (sInstance == this)
|
||||||
|
{
|
||||||
|
sInstance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The instance of this service in the current process, or null if it is
|
||||||
|
* not (yet) running. Since the app is single-process, the watchdog
|
||||||
|
* receiver can use this to detect that the previous process died.
|
||||||
|
*/
|
||||||
|
public static VpnStateService getLiveInstance()
|
||||||
|
{
|
||||||
|
return sInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -144,6 +202,27 @@ public class VpnStateService extends Service
|
|||||||
mListeners.remove(listener);
|
mListeners.remove(listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify all registered listeners about a state change. Iterates over a
|
||||||
|
* copy (a listener may unregister itself from its callback) and isolates
|
||||||
|
* the listeners from each other: one failing listener must neither skip
|
||||||
|
* the others nor crash the main thread.
|
||||||
|
*/
|
||||||
|
private void notifyStateListeners()
|
||||||
|
{
|
||||||
|
for (VpnStateListener listener : new ArrayList<>(mListeners))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
listener.stateChanged();
|
||||||
|
}
|
||||||
|
catch (RuntimeException e)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "state listener failed: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current VPN profile.
|
* Get the current VPN profile.
|
||||||
*
|
*
|
||||||
@@ -267,9 +346,48 @@ public class VpnStateService extends Service
|
|||||||
*/
|
*/
|
||||||
public void disconnect()
|
public void disconnect()
|
||||||
{
|
{
|
||||||
/* reset any potential retry timer and error state */
|
disconnect(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect any existing connection and shutdown the daemon, the
|
||||||
|
* VpnService is not stopped but it is reset so new connections can be
|
||||||
|
* started.
|
||||||
|
*
|
||||||
|
* @param autoManaged true if this was done by the trusted-networks
|
||||||
|
* auto-connect (the tunnel is parked on purpose and is expected to come
|
||||||
|
* back when the network no longer matches, so the watchdog stays armed)
|
||||||
|
*/
|
||||||
|
public void disconnect(boolean autoManaged)
|
||||||
|
{
|
||||||
|
/* reset any potential retry timer and error state, and re-arm the
|
||||||
|
* double-start guard (a connect after a deliberate disconnect is a
|
||||||
|
* fresh request and must not be deduped) */
|
||||||
resetRetryTimer();
|
resetRetryTimer();
|
||||||
setError(ErrorState.NO_ERROR);
|
setError(ErrorState.NO_ERROR);
|
||||||
|
mLastConnectIssued = 0;
|
||||||
|
|
||||||
|
if (autoManaged)
|
||||||
|
{ /* the auto-connect feature parked the tunnel on a trusted network;
|
||||||
|
* mark the session as expected so the watchdog can restore it if
|
||||||
|
* this process dies before the network changes (auto-connect dies
|
||||||
|
* with the process and would otherwise never reconnect) */
|
||||||
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||||
|
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
||||||
|
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, true).apply();
|
||||||
|
WatchdogReceiver.schedule(this);
|
||||||
|
DiagLog.i(TAG, "auto-connect parked the tunnel, watchdog stays armed");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{ /* the user explicitly disconnected, so the watchdog must not restore
|
||||||
|
* the connection after this */
|
||||||
|
Events.connectionStopped(this);
|
||||||
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||||
|
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
||||||
|
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply();
|
||||||
|
WatchdogReceiver.cancel(this);
|
||||||
|
DiagLog.i(TAG, "user disconnect");
|
||||||
|
}
|
||||||
|
|
||||||
/* as soon as the TUN device is created by calling establish() on the
|
/* as soon as the TUN device is created by calling establish() on the
|
||||||
* VpnService.Builder object the system binds to the service and keeps
|
* VpnService.Builder object the system binds to the service and keeps
|
||||||
@@ -280,8 +398,16 @@ public class VpnStateService extends Service
|
|||||||
Context context = getApplicationContext();
|
Context context = getApplicationContext();
|
||||||
Intent intent = new Intent(context, CharonVpnService.class);
|
Intent intent = new Intent(context, CharonVpnService.class);
|
||||||
intent.setAction(CharonVpnService.DISCONNECT_ACTION);
|
intent.setAction(CharonVpnService.DISCONNECT_ACTION);
|
||||||
|
try
|
||||||
|
{
|
||||||
context.startService(intent);
|
context.startService(intent);
|
||||||
}
|
}
|
||||||
|
catch (IllegalStateException e)
|
||||||
|
{ /* background start of a service that is not running (Android 8+):
|
||||||
|
* without the service there is no tunnel to tear down */
|
||||||
|
DiagLog.i(TAG, "disconnect: VPN service not running (" + e.getMessage() + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect (or reconnect) a profile
|
* Connect (or reconnect) a profile
|
||||||
@@ -291,9 +417,25 @@ public class VpnStateService extends Service
|
|||||||
* @param fromScratch true if this is a manual retry/reconnect or a completely new connection
|
* @param fromScratch true if this is a manual retry/reconnect or a completely new connection
|
||||||
*/
|
*/
|
||||||
public void connect(Bundle profileInfo, boolean fromScratch)
|
public void connect(Bundle profileInfo, boolean fromScratch)
|
||||||
|
{
|
||||||
|
connect(profileInfo, fromScratch, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect (or reconnect) a profile
|
||||||
|
*
|
||||||
|
* @param profileInfo optional profile info (basically the UUID and password), taken from the
|
||||||
|
* previous profile if null
|
||||||
|
* @param fromScratch true if this is a manual retry/reconnect or a completely new connection
|
||||||
|
* @param preserveLog true if this is an automatic start whose charon log should
|
||||||
|
* be kept (e.g. recovery after the process died)
|
||||||
|
*/
|
||||||
|
public void connect(Bundle profileInfo, boolean fromScratch, boolean preserveLog)
|
||||||
{
|
{
|
||||||
/* we assume we have the necessary permission */
|
/* we assume we have the necessary permission */
|
||||||
Context context = getApplicationContext();
|
Context context = getApplicationContext();
|
||||||
|
/* any connect request (app, tile, widget, shortcut, pause alarm) ends a running pause */
|
||||||
|
PauseManager.cancel(context);
|
||||||
Intent intent = new Intent(context, CharonVpnService.class);
|
Intent intent = new Intent(context, CharonVpnService.class);
|
||||||
if (profileInfo == null)
|
if (profileInfo == null)
|
||||||
{
|
{
|
||||||
@@ -302,6 +444,31 @@ public class VpnStateService extends Service
|
|||||||
/* pass the previous password along */
|
/* pass the previous password along */
|
||||||
profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, mProfile.getPassword());
|
profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, mProfile.getPassword());
|
||||||
}
|
}
|
||||||
|
String uuid = profileInfo.getString(VpnProfileDataSource.KEY_UUID);
|
||||||
|
if (uuid == null && mProfile != null)
|
||||||
|
{
|
||||||
|
uuid = mProfile.getUUID().toString();
|
||||||
|
}
|
||||||
|
if (fromScratch)
|
||||||
|
{ /* a user's connect starts a new resilience attempt */
|
||||||
|
Resilience.onConnect(uuid, profileInfo);
|
||||||
|
}
|
||||||
|
/* double-start guard: while a healthy connect attempt for the same
|
||||||
|
* profile is in flight (or was just issued and the service has not
|
||||||
|
* reached startConnection() yet) a second start intent only churns
|
||||||
|
* the daemon - after an app self-update up to four starters (update
|
||||||
|
* receiver, process-start auto-connect, watchdog, UI) raced through
|
||||||
|
* here within 20 seconds. Deliberate retries during an error backoff
|
||||||
|
* (mError set) and requests for another profile always pass. */
|
||||||
|
if (uuid != null && uuid.equals(mLastConnectUuid) && mError == ErrorState.NO_ERROR
|
||||||
|
&& !profileInfo.getBoolean(Resilience.KEY_INTERNAL, false)
|
||||||
|
&& (mState == State.CONNECTING
|
||||||
|
|| ((mState == State.DISABLED || mState == State.DISCONNECTING)
|
||||||
|
&& SystemClock.uptimeMillis() - mLastConnectIssued < CONNECT_DEDUPE_MS)))
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "connect: same profile already connecting, ignored");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (fromScratch)
|
if (fromScratch)
|
||||||
{
|
{
|
||||||
/* reset if this is a manual retry or a new connection */
|
/* reset if this is a manual retry or a new connection */
|
||||||
@@ -311,9 +478,49 @@ public class VpnStateService extends Service
|
|||||||
{ /* mark this as an automatic retry */
|
{ /* mark this as an automatic retry */
|
||||||
profileInfo.putBoolean(CharonVpnService.KEY_IS_RETRY, true);
|
profileInfo.putBoolean(CharonVpnService.KEY_IS_RETRY, true);
|
||||||
}
|
}
|
||||||
|
if (preserveLog)
|
||||||
|
{ /* don't wipe the previous session's log; needed to diagnose why an
|
||||||
|
* automatic reconnect was necessary in the first place */
|
||||||
|
profileInfo.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true);
|
||||||
|
}
|
||||||
intent.putExtras(profileInfo);
|
intent.putExtras(profileInfo);
|
||||||
|
try
|
||||||
|
{
|
||||||
ContextCompat.startForegroundService(context, intent);
|
ContextCompat.startForegroundService(context, intent);
|
||||||
}
|
}
|
||||||
|
catch (IllegalStateException e)
|
||||||
|
{ /* Android 12+ forbids starting a foreground service from the
|
||||||
|
* background (e.g. auto-connect on a network change or from the
|
||||||
|
* quick settings tile). Exact alarms are on the allowlist for
|
||||||
|
* such starts, so deliver the start via a one-shot alarm. */
|
||||||
|
DiagLog.i(TAG, "FGS start blocked while in background, scheduling FgsStartRetry");
|
||||||
|
FgsStartRetry.schedule(context, intent);
|
||||||
|
}
|
||||||
|
DiagLog.i(TAG, "connect uuid=" + profileInfo.getString(VpnProfileDataSource.KEY_UUID) +
|
||||||
|
" fromScratch=" + fromScratch + " preserveLog=" + preserveLog);
|
||||||
|
/* remember that a connection is expected so the watchdog can restore
|
||||||
|
* it if this process dies (Always-on VPN is restarted by the system
|
||||||
|
* and bypasses this path anyway) */
|
||||||
|
if (uuid != null)
|
||||||
|
{
|
||||||
|
mLastConnectUuid = uuid;
|
||||||
|
mLastConnectIssued = SystemClock.uptimeMillis();
|
||||||
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
|
if (!uuid.equals(prefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null)))
|
||||||
|
{
|
||||||
|
prefs.edit().putString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, uuid).apply();
|
||||||
|
}
|
||||||
|
/* a real connection supersedes an auto-parked session: with the
|
||||||
|
* expected profile set the watchdog restores this connection
|
||||||
|
* regardless of the network (a session the user started manually
|
||||||
|
* comes back even on a trusted Wi-Fi) */
|
||||||
|
if (prefs.getBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false))
|
||||||
|
{
|
||||||
|
prefs.edit().putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply();
|
||||||
|
}
|
||||||
|
WatchdogReceiver.schedule(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reconnect to the previous profile.
|
* Reconnect to the previous profile.
|
||||||
@@ -346,6 +553,63 @@ public class VpnStateService extends Service
|
|||||||
connect(null, true);
|
connect(null, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconnect to the previous profile without user interaction (health
|
||||||
|
* check, routing managers). Unlike reconnect() this never launches UI
|
||||||
|
* and keeps the charon log of the failed session so an automatic
|
||||||
|
* reconnect can still be diagnosed afterwards.
|
||||||
|
*/
|
||||||
|
public void reconnectPreservingLog()
|
||||||
|
{
|
||||||
|
if (mProfile == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
connect(null, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by CharonVpnService when the set of underlying (physical)
|
||||||
|
* networks below the tunnel changed (e.g. Wi-Fi was lost, cellular became
|
||||||
|
* available).
|
||||||
|
*
|
||||||
|
* While the retry machinery is waiting out a backoff that was sized for
|
||||||
|
* the previous network, reconnect immediately: the backoff is meaningless
|
||||||
|
* after a network change, and waiting it out just delays the handover.
|
||||||
|
*/
|
||||||
|
public void onUnderlyingNetworksChanged()
|
||||||
|
{
|
||||||
|
mHandler.post(new Runnable()
|
||||||
|
{
|
||||||
|
@Override
|
||||||
|
public void run()
|
||||||
|
{
|
||||||
|
if (mState != State.CONNECTING || mError == ErrorState.NO_ERROR || mRetryTimeout <= 0)
|
||||||
|
{ /* either a connection attempt is already running (no retry
|
||||||
|
* pending) or we are not retrying at all - nothing to do */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long now = SystemClock.uptimeMillis();
|
||||||
|
if (now - mLastNetworkRetry < NETWORK_RETRY_DEBOUNCE_MS)
|
||||||
|
{ /* network callbacks can be chatty during a handover */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (WhiteListManager.getInstance(VpnStateService.this).shouldBlockReconnect())
|
||||||
|
{ /* same gate as the regular retry countdown */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mLastNetworkRetry = now;
|
||||||
|
Log.i(TAG, "Underlying networks changed while retrying, reconnecting immediately");
|
||||||
|
DiagLog.i(TAG, "network changed during backoff, retrying immediately");
|
||||||
|
mRetryTimeout = 0;
|
||||||
|
mRetryIn = 0;
|
||||||
|
mRetryFrozen = false;
|
||||||
|
mHandler.removeMessages(RETRY_MSG);
|
||||||
|
connect(null, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update state and notify all listeners about the change. By using a Handler
|
* Update state and notify all listeners about the change. By using a Handler
|
||||||
* this is done from the main UI thread and not the initial reporter thread.
|
* this is done from the main UI thread and not the initial reporter thread.
|
||||||
@@ -365,15 +629,17 @@ public class VpnStateService extends Service
|
|||||||
{
|
{
|
||||||
if (change.call())
|
if (change.call())
|
||||||
{ /* otherwise there is no need to notify the listeners */
|
{ /* otherwise there is no need to notify the listeners */
|
||||||
for (VpnStateListener listener : mListeners)
|
notifyStateListeners();
|
||||||
{
|
/* the home screen widget mirrors the state */
|
||||||
listener.stateChanged();
|
VpnProfile widgetProfile = mProfile;
|
||||||
}
|
ShxWidgetState.publish(VpnStateService.this, mState, getErrorState(),
|
||||||
|
widgetProfile != null ? widgetProfile.getName() : null,
|
||||||
|
widgetProfile != null ? widgetProfile.getUUID().toString() : null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
e.printStackTrace();
|
DiagLog.i(TAG, "state change failed: " + e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -402,6 +668,7 @@ public class VpnStateService extends Service
|
|||||||
VpnStateService.this.mError = ErrorState.NO_ERROR;
|
VpnStateService.this.mError = ErrorState.NO_ERROR;
|
||||||
VpnStateService.this.mImcState = ImcState.UNKNOWN;
|
VpnStateService.this.mImcState = ImcState.UNKNOWN;
|
||||||
VpnStateService.this.mRemediationInstructions.clear();
|
VpnStateService.this.mRemediationInstructions.clear();
|
||||||
|
scheduleConnectingTimeout();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -428,6 +695,20 @@ public class VpnStateService extends Service
|
|||||||
if (VpnStateService.this.mState != state)
|
if (VpnStateService.this.mState != state)
|
||||||
{
|
{
|
||||||
VpnStateService.this.mState = state;
|
VpnStateService.this.mState = state;
|
||||||
|
if (state == State.CONNECTED)
|
||||||
|
{
|
||||||
|
Events.connectionRestored(VpnStateService.this);
|
||||||
|
Resilience.onConnected(VpnStateService.this);
|
||||||
|
}
|
||||||
|
if (state == State.CONNECTING)
|
||||||
|
{ /* reentered while the daemon reestablishes the tunnel; bound
|
||||||
|
* the total time so a lost alert can't strand us here */
|
||||||
|
scheduleConnectingTimeout();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cancelConnectingTimeout();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -451,6 +732,14 @@ public class VpnStateService extends Service
|
|||||||
{
|
{
|
||||||
if (VpnStateService.this.mError != error)
|
if (VpnStateService.this.mError != error)
|
||||||
{
|
{
|
||||||
|
/* the server does not answer: backup ports, then the
|
||||||
|
* reserve server, before the regular backoff */
|
||||||
|
if (VpnStateService.this.mError == ErrorState.NO_ERROR
|
||||||
|
&& (error == ErrorState.UNREACHABLE || error == ErrorState.LOOKUP_FAILED)
|
||||||
|
&& Resilience.onUnreachable(VpnStateService.this))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (VpnStateService.this.mError == ErrorState.NO_ERROR)
|
if (VpnStateService.this.mError == ErrorState.NO_ERROR)
|
||||||
{
|
{
|
||||||
setRetryTimer(error);
|
setRetryTimer(error);
|
||||||
@@ -460,6 +749,17 @@ public class VpnStateService extends Service
|
|||||||
resetRetryTimer();
|
resetRetryTimer();
|
||||||
}
|
}
|
||||||
VpnStateService.this.mError = error;
|
VpnStateService.this.mError = error;
|
||||||
|
if (error != ErrorState.NO_ERROR)
|
||||||
|
{ /* repeats while retrying are merged into one event */
|
||||||
|
Events.connectionError(VpnStateService.this, error);
|
||||||
|
}
|
||||||
|
if (error == ErrorState.PASSWORD_MISSING)
|
||||||
|
{ /* only a person can type the password: the watchdog must not
|
||||||
|
* bring this attempt back after the process restarts */
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(VpnStateService.this).edit()
|
||||||
|
.remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
||||||
|
.remove(Constants.PREF_WATCHDOG_AUTO_EXPECTED).apply();
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -525,11 +825,16 @@ public class VpnStateService extends Service
|
|||||||
private void setRetryTimer(ErrorState error)
|
private void setRetryTimer(ErrorState error)
|
||||||
{
|
{
|
||||||
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
|
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
|
||||||
|
mRetryFrozen = false;
|
||||||
|
DiagLog.i(TAG, "error " + error + ", retry in " + (mRetryTimeout / 1000) + "s");
|
||||||
|
/* never run two countdown chains in parallel (double-speed countdown,
|
||||||
|
* double notification updates and two reconnects at zero) */
|
||||||
|
mHandler.removeMessages(RETRY_MSG);
|
||||||
if (mRetryTimeout <= 0)
|
if (mRetryTimeout <= 0)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mHandler.sendMessageAtTime(mHandler.obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_INTERVAL);
|
mHandler.sendMessageAtTime(mHandler.obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + retryStep(mRetryIn));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -537,8 +842,30 @@ public class VpnStateService extends Service
|
|||||||
*/
|
*/
|
||||||
private void resetRetryTimer()
|
private void resetRetryTimer()
|
||||||
{
|
{
|
||||||
|
mHandler.removeMessages(RETRY_MSG);
|
||||||
mRetryTimeout = 0;
|
mRetryTimeout = 0;
|
||||||
mRetryIn = 0;
|
mRetryIn = 0;
|
||||||
|
mRetryFrozen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the one-shot CONNECTING timeout. Uses a handler message counted in
|
||||||
|
* uptime, so it never wakes the device and simply freezes while it dozes.
|
||||||
|
* Called from the main thread (via notifyListeners) only.
|
||||||
|
*/
|
||||||
|
private void scheduleConnectingTimeout()
|
||||||
|
{
|
||||||
|
mHandler.removeMessages(CONNECTING_TIMEOUT_MSG);
|
||||||
|
mHandler.sendMessageAtTime(mHandler.obtainMessage(CONNECTING_TIMEOUT_MSG),
|
||||||
|
SystemClock.uptimeMillis() + CONNECTING_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disarm the CONNECTING timeout. Called from the main thread only.
|
||||||
|
*/
|
||||||
|
private void cancelConnectingTimeout()
|
||||||
|
{
|
||||||
|
mHandler.removeMessages(CONNECTING_TIMEOUT_MSG);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -557,26 +884,61 @@ public class VpnStateService extends Service
|
|||||||
@Override
|
@Override
|
||||||
public void handleMessage(Message msg)
|
public void handleMessage(Message msg)
|
||||||
{
|
{
|
||||||
/* handle retry countdown */
|
VpnStateService service = mService.get();
|
||||||
if (mService.get().mRetryTimeout <= 0)
|
if (msg.what == CONNECTING_TIMEOUT_MSG)
|
||||||
|
{
|
||||||
|
if (service != null && service.mState == State.CONNECTING &&
|
||||||
|
service.mError == ErrorState.NO_ERROR)
|
||||||
|
{ /* the daemon neither completed the connection nor reported a
|
||||||
|
* failure; surface it as UNREACHABLE so the regular retry
|
||||||
|
* machinery (with its whitelist handling) takes over */
|
||||||
|
service.setError(ErrorState.UNREACHABLE);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (service == null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mService.get().mRetryIn -= RETRY_INTERVAL;
|
/* handle retry countdown */
|
||||||
if (mService.get().mRetryIn > 0)
|
if (service.mRetryTimeout <= 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (service.mRetryFrozen)
|
||||||
|
{ /* the countdown is parked while the ISP whitelist blocks the server;
|
||||||
|
* re-check once per suppression period and go back to sleep - this
|
||||||
|
* reaches connect() again on its own once the whitelist clears */
|
||||||
|
if (WhiteListManager.getInstance(service).shouldBlockReconnect())
|
||||||
|
{
|
||||||
|
sendMessageAtTime(obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_SUPPRESS_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service.connect(null, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service.mRetryIn -= retryStep(service.mRetryIn);
|
||||||
|
if (service.mRetryIn > 0)
|
||||||
{
|
{
|
||||||
/* calculate next interval before notifying listeners */
|
/* calculate next interval before notifying listeners */
|
||||||
long next = SystemClock.uptimeMillis() + RETRY_INTERVAL;
|
long next = SystemClock.uptimeMillis() + retryStep(service.mRetryIn);
|
||||||
|
|
||||||
for (VpnStateListener listener : mService.get().mListeners)
|
service.notifyStateListeners();
|
||||||
{
|
|
||||||
listener.stateChanged();
|
|
||||||
}
|
|
||||||
sendMessageAtTime(obtainMessage(RETRY_MSG), next);
|
sendMessageAtTime(obtainMessage(RETRY_MSG), next);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
mService.get().connect(null, false);
|
if (WhiteListManager.getInstance(service).shouldBlockReconnect())
|
||||||
|
{ /* the ISP whitelist blocks the server, reconnecting is pointless;
|
||||||
|
* park the countdown until the whitelist clears instead of
|
||||||
|
* ticking every second */
|
||||||
|
service.mRetryFrozen = true;
|
||||||
|
service.mRetryIn = RETRY_SUPPRESS_MS;
|
||||||
|
service.notifyStateListeners();
|
||||||
|
sendMessageAtTime(obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_SUPPRESS_MS);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
service.connect(null, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+474
@@ -0,0 +1,474 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic;
|
||||||
|
|
||||||
|
import android.app.AlarmManager;
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.net.ConnectivityManager;
|
||||||
|
import android.net.Network;
|
||||||
|
import android.net.NetworkCapabilities;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.SystemClock;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
|
import org.strongswan.android.data.VpnProfileDataSource;
|
||||||
|
import org.strongswan.android.data.VpnProfileSource;
|
||||||
|
import org.strongswan.android.data.VpnProfile;
|
||||||
|
import org.strongswan.android.logic.autoconnect.AutoConnectManager;
|
||||||
|
import org.strongswan.android.logic.connectivity.WhiteListManager;
|
||||||
|
import org.strongswan.android.utils.Constants;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores the VPN connection after the hosting process died. The process
|
||||||
|
* hosting CharonVpnService dies completely on a native crash of the daemon,
|
||||||
|
* and the service is deliberately NOT_STICKY, so without this nothing would
|
||||||
|
* bring the tunnel back.
|
||||||
|
*
|
||||||
|
* Energy design: the maintenance alarm is a NON-WAKEUP exact alarm, so it
|
||||||
|
* never wakes the device; it fires only while the device is awake anyway
|
||||||
|
* (Doze defers it until then). A second WAKEUP safety-net alarm bounds the
|
||||||
|
* recovery time while the device sleeps (e.g. the process was evicted on a
|
||||||
|
* trusted Wi-Fi overnight). Once a state proves steady - the tunnel parked
|
||||||
|
* on a trusted network, or a healthy established connection - both alarms
|
||||||
|
* back off (5 min maintenance, 30 min/1 h safety net), because in those
|
||||||
|
* states a live process recovers via its own callbacks within seconds and
|
||||||
|
* the alarms only guard a dead one. The checks themselves are a couple of
|
||||||
|
* memory and preference reads - no network probes, no wake locks, no
|
||||||
|
* periodic work while the connection is healthy. A crash is therefore
|
||||||
|
* repaired within seconds of the next time the device is used.
|
||||||
|
*
|
||||||
|
* Trusted-networks auto sessions: while parked on a trusted Wi-Fi the VPN is
|
||||||
|
* expected to be DOWN (the auto session is "expected" in the sense that the
|
||||||
|
* tunnel must come back when the network changes). If the process died
|
||||||
|
* while parked, auto-connect died with it, so on cellular this receiver
|
||||||
|
* restarts the tunnel using the auto-connect profile; on Wi-Fi the decision
|
||||||
|
* belongs to auto-connect (which starts with the fresh process and knows
|
||||||
|
* about SSIDs), so we stay out of its way.
|
||||||
|
*/
|
||||||
|
public class WatchdogReceiver extends BroadcastReceiver
|
||||||
|
{
|
||||||
|
private static final String TAG = WatchdogReceiver.class.getSimpleName();
|
||||||
|
private static final int REQUEST_CODE = 43;
|
||||||
|
private static final int WAKEUP_REQUEST_CODE = 44;
|
||||||
|
private static final long WATCHDOG_INTERVAL_MS = 60000;
|
||||||
|
/* wakeup safety net; allow-while-idle alarms are throttled to a 9-minute
|
||||||
|
* minimum interval in Doze, so 10 minutes is effectively exact enough */
|
||||||
|
private static final long WATCHDOG_WAKEUP_INTERVAL_MS = 600000;
|
||||||
|
/* after this many consecutive checks that find the tunnel parked on a
|
||||||
|
* trusted network, stretch the maintenance interval: while parked there
|
||||||
|
* is nothing to do (auto-connect owns the return via its callbacks) and
|
||||||
|
* the 60 s cadence only burns battery and floods diag.log overnight.
|
||||||
|
* The wakeup safety net is stretched to 30 minutes as well: a live
|
||||||
|
* process reacts to network changes within seconds, so it only guards
|
||||||
|
* the rare "process died, then the user left the trusted Wi-Fi" corner.
|
||||||
|
* Static on purpose: a parked tunnel implies a live process, so the
|
||||||
|
* counter survives; a dead process starts fresh with the short interval. */
|
||||||
|
private static final int PARKED_BACKOFF_AFTER = 5;
|
||||||
|
private static final long WATCHDOG_PARKED_INTERVAL_MS = 5 * 60000;
|
||||||
|
private static final long WATCHDOG_PARKED_WAKEUP_INTERVAL_MS = 30 * 60000;
|
||||||
|
/* steady state of an established connection: charon's own DPD/rekey and
|
||||||
|
* the in-process retry machinery own recovery, so the alarms only guard
|
||||||
|
* a dead process - repaired within seconds of the device being used.
|
||||||
|
* The wakeup alarm in Doze is stretched to an hour (from 10 minutes),
|
||||||
|
* cutting the overnight CPU wakeups while the tunnel is healthy. */
|
||||||
|
private static final int CONNECTED_BACKOFF_AFTER = 5;
|
||||||
|
private static final long WATCHDOG_CONNECTED_INTERVAL_MS = 5 * 60000;
|
||||||
|
private static final long WATCHDOG_CONNECTED_WAKEUP_INTERVAL_MS = 60 * 60000;
|
||||||
|
private static volatile int sParkedChecks;
|
||||||
|
private static volatile int sConnectedChecks;
|
||||||
|
/** cadence announced in diag.log last ("parked"/"connected"/null), so a
|
||||||
|
* steady state is logged once on entry instead of on every fire */
|
||||||
|
private static volatile String sLastCadence;
|
||||||
|
|
||||||
|
/** What onReceive decided to do. */
|
||||||
|
public enum Action
|
||||||
|
{
|
||||||
|
/** nothing expected / feature off / nothing to do */
|
||||||
|
NONE,
|
||||||
|
/** a connection is expected but the process is gone - restart it */
|
||||||
|
RESTART,
|
||||||
|
/** an auto session is expected, but Wi-Fi is up - auto-connect owns the decision */
|
||||||
|
SKIP_WIFI,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision logic, unit-testable.
|
||||||
|
*
|
||||||
|
* @param featureEnabled the watchdog feature is enabled
|
||||||
|
* @param expectedProfileSet an explicit connection is expected (UUID stored)
|
||||||
|
* @param autoExpected an auto session is expected to return when the network allows it
|
||||||
|
* @param whitelistBlocks the ISP whitelist currently blocks the server
|
||||||
|
* @param serviceTracking the live VpnStateService tracks a connection (state != DISABLED)
|
||||||
|
* @param processAlive the hosting process is alive (live service instance found)
|
||||||
|
* @param wifiPresent any Wi-Fi network is currently available
|
||||||
|
* @param autoConnectProfileConfigured auto-connect has a profile configured
|
||||||
|
*/
|
||||||
|
static Action decideAction(boolean featureEnabled, boolean expectedProfileSet, boolean autoExpected,
|
||||||
|
boolean whitelistBlocks, boolean serviceTracking, boolean processAlive,
|
||||||
|
boolean wifiPresent, boolean autoConnectProfileConfigured)
|
||||||
|
{
|
||||||
|
if (!featureEnabled || (!expectedProfileSet && !autoExpected))
|
||||||
|
{
|
||||||
|
return Action.NONE;
|
||||||
|
}
|
||||||
|
if (whitelistBlocks)
|
||||||
|
{ /* the ISP whitelist parks the in-process retry machinery too */
|
||||||
|
return Action.NONE;
|
||||||
|
}
|
||||||
|
if (serviceTracking)
|
||||||
|
{ /* the process is alive and the service is actively tracking a
|
||||||
|
* connection; its own retry and timeout logic handles recovery */
|
||||||
|
return Action.NONE;
|
||||||
|
}
|
||||||
|
if (!expectedProfileSet && processAlive)
|
||||||
|
{ /* an auto session is expected but only parked (process alive);
|
||||||
|
* auto-connect is running and reconnects via its own callbacks */
|
||||||
|
return Action.NONE;
|
||||||
|
}
|
||||||
|
if (autoExpected && wifiPresent && autoConnectProfileConfigured)
|
||||||
|
{ /* the auto session was parked on a Wi-Fi; whether the tunnel
|
||||||
|
* belongs up here is auto-connect's call (SSID-based), and it
|
||||||
|
* starts with any fresh process */
|
||||||
|
return Action.SKIP_WIFI;
|
||||||
|
}
|
||||||
|
return Action.RESTART;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a check counts towards the steady-state backoff of a live
|
||||||
|
* process: connected, retrying/connecting, or held back by the ISP
|
||||||
|
* whitelist. In all of these the in-process machinery owns recovery and
|
||||||
|
* the alarm can only guard against the process dying - firing every
|
||||||
|
* minute (and waking Doze every 10 minutes) for hours of failed retries
|
||||||
|
* or whitelist blocking only burns battery.
|
||||||
|
*/
|
||||||
|
static boolean isAliveSteady(Action action, boolean processAlive, boolean parked)
|
||||||
|
{
|
||||||
|
return action == Action.NONE && processAlive && !parked;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent)
|
||||||
|
{
|
||||||
|
context = context.getApplicationContext();
|
||||||
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
|
String uuid = prefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null);
|
||||||
|
boolean autoExpected = prefs.getBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false);
|
||||||
|
boolean enabled = prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true);
|
||||||
|
if (!enabled || (uuid == null && !autoExpected))
|
||||||
|
{ /* nothing is expected to be connected (or the feature is off);
|
||||||
|
* schedule() re-arms the alarms when that changes */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reschedule(context);
|
||||||
|
/* the ISP whitelist blocks the server; the in-process retry
|
||||||
|
* machinery parks itself in that case, so should we */
|
||||||
|
boolean whitelistBlocks = WhiteListManager.getInstance(context).shouldBlockReconnect();
|
||||||
|
VpnStateService service = VpnStateService.getLiveInstance();
|
||||||
|
boolean processAlive = service != null;
|
||||||
|
boolean serviceTracking = processAlive && service.getState() != VpnStateService.State.DISABLED;
|
||||||
|
boolean wifiPresent = isWifiPresent(context);
|
||||||
|
boolean autoProfileConfigured = AutoConnectManager.getInstance(context).getCurrentConfig().getProfileUuid() != null;
|
||||||
|
Action action = decideAction(enabled, uuid != null, autoExpected, whitelistBlocks, serviceTracking,
|
||||||
|
processAlive, wifiPresent, autoProfileConfigured);
|
||||||
|
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
|
||||||
|
boolean alive = isAliveSteady(action, processAlive, parked);
|
||||||
|
if (parked)
|
||||||
|
{
|
||||||
|
sParkedChecks++;
|
||||||
|
sConnectedChecks = 0;
|
||||||
|
}
|
||||||
|
else if (alive)
|
||||||
|
{
|
||||||
|
sConnectedChecks++;
|
||||||
|
sParkedChecks = 0;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{ /* any other outcome is interesting - go back to the fast cadence */
|
||||||
|
sParkedChecks = 0;
|
||||||
|
sConnectedChecks = 0;
|
||||||
|
}
|
||||||
|
boolean parkedSteady = parked && sParkedChecks >= PARKED_BACKOFF_AFTER;
|
||||||
|
boolean connectedSteady = alive && sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
|
||||||
|
String cadence = parkedSteady ? "parked" : connectedSteady ? "alive" : null;
|
||||||
|
if (cadence != null)
|
||||||
|
{ /* steady state: log the transition once, then stay silent - a
|
||||||
|
* per-fire line only floods diag.log and burns flash I/O */
|
||||||
|
if (!cadence.equals(sLastCadence))
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "steady state (" + cadence + "), backing off checks to " +
|
||||||
|
(parkedSteady ? "5m/30m" : "5m/60m"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "check: uuid=" + (uuid != null) + " autoExpected=" + autoExpected +
|
||||||
|
" processAlive=" + processAlive + " tracking=" + serviceTracking +
|
||||||
|
" whitelist=" + whitelistBlocks + " wifi=" + wifiPresent + " -> " + action);
|
||||||
|
}
|
||||||
|
sLastCadence = cadence;
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case NONE:
|
||||||
|
case SKIP_WIFI:
|
||||||
|
return;
|
||||||
|
case RESTART:
|
||||||
|
{
|
||||||
|
boolean autoRestart = uuid == null;
|
||||||
|
if (uuid == null)
|
||||||
|
{
|
||||||
|
uuid = resolveAutoProfile(context, prefs);
|
||||||
|
if (uuid == null)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "auto session expected but no profile configured, not restarting");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* a profile that signs in with an unsaved password would only end
|
||||||
|
* in «password missing» after every restore: drop the expectation
|
||||||
|
* and say so instead */
|
||||||
|
VpnProfile expected = loadProfile(context, uuid);
|
||||||
|
if (expected != null && org.strongswan.android.logic.profiles.ProfileExtrasKt.needsPassword(expected))
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "expected profile needs a password, not restoring");
|
||||||
|
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
||||||
|
.remove(Constants.PREF_WATCHDOG_AUTO_EXPECTED).apply();
|
||||||
|
org.strongswan.android.logic.events.Events.restoreNeedsPassword(context, uuid,
|
||||||
|
expected.getName() != null ? expected.getName() : expected.getGateway());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* either the process died (the receiver runs in a fresh one) or the
|
||||||
|
* service is idle while a connection is expected - restart it. In
|
||||||
|
* the rare second case this may duplicate a pending in-process retry
|
||||||
|
* once, which is harmless (retry flag set, same profile). */
|
||||||
|
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
||||||
|
DiagLog.i(TAG, "restarting the VPN process");
|
||||||
|
if (autoRestart)
|
||||||
|
{ /* this process does not remember the session was auto-started */
|
||||||
|
AutoConnectManager.getInstance(context).markAutoSessionRestored();
|
||||||
|
}
|
||||||
|
Intent start = new Intent(context, CharonVpnService.class);
|
||||||
|
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
||||||
|
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
{
|
||||||
|
context.startForegroundService(start);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.startService(start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (IllegalStateException e)
|
||||||
|
{ /* background start blocked (should not happen from an exact alarm,
|
||||||
|
* but stay on the safe side) */
|
||||||
|
DiagLog.i(TAG, "FGS start blocked, falling back to FgsStartRetry");
|
||||||
|
FgsStartRetry.schedule(context, start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static VpnProfile loadProfile(Context context, String uuid)
|
||||||
|
{
|
||||||
|
VpnProfileSource source = new VpnProfileSource(context);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
source.open();
|
||||||
|
return source.getVpnProfile(java.util.UUID.fromString(uuid));
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile the trusted-networks auto session should use: the configured
|
||||||
|
* auto-connect profile, falling back to the most recently used one.
|
||||||
|
*/
|
||||||
|
private static String resolveAutoProfile(Context context, SharedPreferences prefs)
|
||||||
|
{
|
||||||
|
String uuid = AutoConnectManager.getInstance(context).getCurrentConfig().getProfileUuid();
|
||||||
|
if (uuid == null)
|
||||||
|
{
|
||||||
|
uuid = prefs.getString(Constants.PREF_MRU_VPN_PROFILE, null);
|
||||||
|
}
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any Wi-Fi network is currently available. A cheap transport
|
||||||
|
* check - deliberately no SSID lookup (slow and needs location).
|
||||||
|
*/
|
||||||
|
private static boolean isWifiPresent(Context context)
|
||||||
|
{
|
||||||
|
ConnectivityManager cm = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE);
|
||||||
|
if (cm == null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (Network network : cm.getAllNetworks())
|
||||||
|
{
|
||||||
|
NetworkCapabilities caps = cm.getNetworkCapabilities(network);
|
||||||
|
if (caps != null && caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||||
|
!caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the next maintenance check after a state transition (connect,
|
||||||
|
* park, settings toggle). Resets the steady-state counters so the
|
||||||
|
* checks return to the fast cadence until the new state proves itself
|
||||||
|
* stable again, then delegates to reschedule().
|
||||||
|
*/
|
||||||
|
public static void schedule(Context context)
|
||||||
|
{
|
||||||
|
sParkedChecks = 0;
|
||||||
|
sConnectedChecks = 0;
|
||||||
|
sLastCadence = null;
|
||||||
|
reschedule(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Arm the next maintenance check with the cadence the steady-state
|
||||||
|
* counters currently ask for. A one-shot non-wakeup alarm (fires only
|
||||||
|
* while the device is awake) plus a one-shot wakeup safety net that
|
||||||
|
* bounds the recovery time while the device sleeps. No-op when the
|
||||||
|
* feature is disabled or no connection is expected.
|
||||||
|
*/
|
||||||
|
private static void reschedule(Context context)
|
||||||
|
{
|
||||||
|
context = context.getApplicationContext();
|
||||||
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
|
boolean autoExpected = prefs.getBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false);
|
||||||
|
if (prefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null) == null && !autoExpected ||
|
||||||
|
!prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AlarmManager am = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||||
|
if (am == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean parkedBackoff = sParkedChecks >= PARKED_BACKOFF_AFTER;
|
||||||
|
boolean connectedBackoff = sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
|
||||||
|
long interval = parkedBackoff ? WATCHDOG_PARKED_INTERVAL_MS
|
||||||
|
: connectedBackoff ? WATCHDOG_CONNECTED_INTERVAL_MS : WATCHDOG_INTERVAL_MS;
|
||||||
|
long wakeupInterval = parkedBackoff ? WATCHDOG_PARKED_WAKEUP_INTERVAL_MS
|
||||||
|
: connectedBackoff ? WATCHDOG_CONNECTED_WAKEUP_INTERVAL_MS : WATCHDOG_WAKEUP_INTERVAL_MS;
|
||||||
|
arm(am, pendingIntent(context, REQUEST_CODE), SystemClock.elapsedRealtime() + interval, false);
|
||||||
|
arm(am, pendingIntent(context, WAKEUP_REQUEST_CODE), SystemClock.elapsedRealtime() + wakeupInterval, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a one-shot alarm. Wakeup alarms use allow-while-idle variants so
|
||||||
|
* they also fire in Doze (throttled to a 9-minute minimum there); regular
|
||||||
|
* ones use exact alarms that never wake the device.
|
||||||
|
*/
|
||||||
|
private static void arm(AlarmManager am, PendingIntent pi, long when, boolean wakeup)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
boolean exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms();
|
||||||
|
if (wakeup)
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||||
|
{
|
||||||
|
if (exact)
|
||||||
|
{
|
||||||
|
am.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
am.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
am.setExact(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (exact)
|
||||||
|
{
|
||||||
|
am.setExact(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (SecurityException e)
|
||||||
|
{ /* the exact-alarm grant was revoked in between; inexact is fine */
|
||||||
|
if (wakeup && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||||
|
{
|
||||||
|
am.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, when, pi);
|
||||||
|
}
|
||||||
|
else if (!wakeup)
|
||||||
|
{
|
||||||
|
am.set(AlarmManager.ELAPSED_REALTIME, when, pi);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the maintenance alarms (explicit disconnect).
|
||||||
|
*/
|
||||||
|
public static void cancel(Context context)
|
||||||
|
{
|
||||||
|
context = context.getApplicationContext();
|
||||||
|
AlarmManager am = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||||
|
if (am == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
am.cancel(pendingIntent(context, REQUEST_CODE));
|
||||||
|
am.cancel(pendingIntent(context, WAKEUP_REQUEST_CODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PendingIntent pendingIntent(Context context, int requestCode)
|
||||||
|
{
|
||||||
|
return PendingIntent.getBroadcast(context, requestCode,
|
||||||
|
new Intent(context, WatchdogReceiver.class),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
+530
@@ -0,0 +1,530 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.autoconnect
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import org.strongswan.android.logic.PauseManager
|
||||||
|
import org.strongswan.android.logic.events.Events
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.logic.WatchdogReceiver
|
||||||
|
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||||
|
import org.strongswan.android.ui.VpnProfileControlActivity
|
||||||
|
import org.strongswan.android.utils.Constants
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trusted networks model: a trusted Wi-Fi means the VPN should be down, everything else
|
||||||
|
* (cellular or untrusted Wi-Fi) means the VPN should be up.
|
||||||
|
*
|
||||||
|
* The manager disconnects any active session on a trusted Wi-Fi so that
|
||||||
|
* leaving the network (cellular / untrusted Wi-Fi) can start the tunnel again.
|
||||||
|
*/
|
||||||
|
class AutoConnectManager private constructor(
|
||||||
|
private val context: Context,
|
||||||
|
) : VpnStateService.VpnStateListener
|
||||||
|
{
|
||||||
|
data class Config(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val profileUuid: String? = null,
|
||||||
|
val trustedSsids: Set<String> = emptySet(),
|
||||||
|
/** F_Automation «In a trusted Wi-Fi → turn the VPN off» */
|
||||||
|
val parkOnTrusted: Boolean = true,
|
||||||
|
/** F_Automation «Mobile network or a foreign Wi-Fi → turn the VPN on» */
|
||||||
|
val connectOnUntrusted: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class NetworkKind
|
||||||
|
{
|
||||||
|
TRUSTED_WIFI,
|
||||||
|
UNTRUSTED_WIFI,
|
||||||
|
UNKNOWN_WIFI,
|
||||||
|
CELLULAR,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class Action
|
||||||
|
{
|
||||||
|
CONNECT,
|
||||||
|
DISCONNECT,
|
||||||
|
IGNORE,
|
||||||
|
}
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val pendingEvaluate = AtomicBoolean(false)
|
||||||
|
private val running = AtomicBoolean(false)
|
||||||
|
|
||||||
|
private var service: VpnStateService? = null
|
||||||
|
private var config = Config()
|
||||||
|
private var currentNetwork: NetworkKind? = null
|
||||||
|
/* marks the session this manager started itself */
|
||||||
|
private val autoSession = AtomicBoolean(false)
|
||||||
|
/* an auto session was restored by the watchdog and its tunnel is not up yet */
|
||||||
|
@Volatile private var awaitingRestoredTunnel = false
|
||||||
|
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
|
||||||
|
private var lastParkAt = 0L
|
||||||
|
|
||||||
|
private val monitor = NetworkMonitor(context, { config.trustedSsids }, ::onNetworkChanged)
|
||||||
|
private val lifecycleBound = AtomicBoolean(false)
|
||||||
|
|
||||||
|
private val appLifecycleObserver = object : DefaultLifecycleObserver
|
||||||
|
{
|
||||||
|
override fun onStart(owner: LifecycleOwner)
|
||||||
|
{
|
||||||
|
if (running.get())
|
||||||
|
{
|
||||||
|
monitor.forceEvaluate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val connection = object : ServiceConnection
|
||||||
|
{
|
||||||
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||||
|
{
|
||||||
|
service = (binder as VpnStateService.LocalBinder).service
|
||||||
|
service?.registerListener(this@AutoConnectManager)
|
||||||
|
evaluate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?)
|
||||||
|
{
|
||||||
|
service = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Network scenarios need the network events even when the network rules are off. */
|
||||||
|
private fun scenariosNeedNetwork(): Boolean =
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.needsNetworkEvents(context)
|
||||||
|
|
||||||
|
/** Called when the scenarios changed: starts or stops the network listening to fit. */
|
||||||
|
fun scenariosChanged()
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
if (config.enabled || scenariosNeedNetwork())
|
||||||
|
{
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start()
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
if (!config.enabled && !scenariosNeedNetwork())
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!running.compareAndSet(false, true))
|
||||||
|
{
|
||||||
|
monitor.forceEvaluate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
context.bindService(
|
||||||
|
Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE
|
||||||
|
)
|
||||||
|
monitor.start()
|
||||||
|
bindAppLifecycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop()
|
||||||
|
{
|
||||||
|
unbindAppLifecycle()
|
||||||
|
monitor.stop()
|
||||||
|
if (!running.compareAndSet(true, false))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler.removeCallbacksAndMessages(null)
|
||||||
|
/* the runnable that clears this flag was just removed with the
|
||||||
|
* callbacks: clear it here or every later connect is swallowed */
|
||||||
|
pendingEvaluate.set(false)
|
||||||
|
service?.unregisterListener(this)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.unbindService(connection)
|
||||||
|
}
|
||||||
|
catch (_: IllegalArgumentException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
service = null
|
||||||
|
/* the feature no longer manages sessions: drop the auto-expected marker
|
||||||
|
* and keep the watchdog armed only if an explicit connection is expected */
|
||||||
|
val defaultPrefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
defaultPrefs.edit().putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply()
|
||||||
|
if (defaultPrefs.getString(Constants.PREF_WATCHDOG_EXPECTED_PROFILE, null) == null)
|
||||||
|
{
|
||||||
|
WatchdogReceiver.cancel(context)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
WatchdogReceiver.schedule(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reloadConfig()
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
if (config.enabled)
|
||||||
|
{
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
autoSession.set(false)
|
||||||
|
scenariosChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentConfig: Config
|
||||||
|
get()
|
||||||
|
{
|
||||||
|
if (!running.get())
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateConfig(transform: (Config) -> Config)
|
||||||
|
{
|
||||||
|
val updated = transform(config)
|
||||||
|
saveConfig(updated)
|
||||||
|
config = updated
|
||||||
|
if (updated.enabled)
|
||||||
|
{
|
||||||
|
start()
|
||||||
|
currentNetwork = monitor.currentKind()
|
||||||
|
evaluate()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
autoSession.set(false)
|
||||||
|
scenariosChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by [NetworkMonitor] when the active network changed (already debounced).
|
||||||
|
*/
|
||||||
|
fun onNetworkChanged(kind: NetworkKind?)
|
||||||
|
{
|
||||||
|
if (!running.get())
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
currentNetwork = kind
|
||||||
|
evaluate()
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stateChanged()
|
||||||
|
{
|
||||||
|
val state = service?.state ?: return
|
||||||
|
if (state != VpnStateService.State.DISABLED)
|
||||||
|
{
|
||||||
|
awaitingRestoredTunnel = false
|
||||||
|
}
|
||||||
|
else if (!awaitingRestoredTunnel)
|
||||||
|
{
|
||||||
|
autoSession.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The watchdog brought an auto session back in a fresh process, whose memory
|
||||||
|
* knows nothing of it. Mark it as auto-started again, so it parks on a trusted
|
||||||
|
* Wi-Fi instead of running all night; the DISABLED state of the not yet
|
||||||
|
* started service must not clear the mark.
|
||||||
|
*/
|
||||||
|
fun markAutoSessionRestored()
|
||||||
|
{
|
||||||
|
awaitingRestoredTunnel = true
|
||||||
|
autoSession.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision function, unit-testable.
|
||||||
|
*/
|
||||||
|
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action
|
||||||
|
{
|
||||||
|
val action = evaluateDecision(
|
||||||
|
config, network, vpnActive, autoSession,
|
||||||
|
whiteListBlocksReconnect = WhiteListManager.getInstance(context).shouldBlockReconnect(),
|
||||||
|
)
|
||||||
|
/* the user paused the VPN: the pause alarm brings it back, not a network change */
|
||||||
|
if (action == Action.CONNECT && PauseManager.isPaused(context))
|
||||||
|
{
|
||||||
|
return Action.IGNORE
|
||||||
|
}
|
||||||
|
/* a user scenario above the network rules is in force */
|
||||||
|
return if (action != Action.IGNORE && org.strongswan.android.logic.scenarios.ScenarioEngine.inControl(context)) Action.IGNORE else action
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-runs the connect decision, e.g. when the white-list gate clears. */
|
||||||
|
fun reevaluate()
|
||||||
|
{
|
||||||
|
evaluate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evaluate()
|
||||||
|
{
|
||||||
|
if (!running.get() || !config.enabled)
|
||||||
|
{ /* running only for the scenarios: they decide, not the network rules */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val svc = service ?: return
|
||||||
|
val state = svc.state
|
||||||
|
val vpnActive = state == VpnStateService.State.CONNECTED || state == VpnStateService.State.CONNECTING
|
||||||
|
when (evaluate(config, currentNetwork, vpnActive, autoSession.get()))
|
||||||
|
{
|
||||||
|
Action.CONNECT -> connectProfile()
|
||||||
|
Action.DISCONNECT ->
|
||||||
|
{
|
||||||
|
Log.i(TAG, "Trusted network detected, disconnecting")
|
||||||
|
DiagLog.i(TAG, "trusted network detected, parking the tunnel")
|
||||||
|
autoSession.set(false)
|
||||||
|
lastParkAt = SystemClock.elapsedRealtime()
|
||||||
|
/* mark the auto session as expected so the watchdog can restore
|
||||||
|
* it if the process dies while parked and the device later
|
||||||
|
* moves to cellular (auto-connect dies with the process) */
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||||
|
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, true).apply()
|
||||||
|
svc.disconnect(true)
|
||||||
|
Events.autoConnect(context, parked = true)
|
||||||
|
}
|
||||||
|
Action.IGNORE ->
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectProfile()
|
||||||
|
{
|
||||||
|
val uuid = config.profileUuid ?: return
|
||||||
|
if (!pendingEvaluate.compareAndSet(false, true))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/* Wi-Fi at the edge of range flaps with a ~10-15 s period: a park
|
||||||
|
* followed by a quick "untrusted" flip - or a network kind that keeps
|
||||||
|
* bouncing - must not bounce the tunnel through full reconnects (one
|
||||||
|
* such flap produced a 7-second-lived session and a wasted IKE
|
||||||
|
* handshake). Require a longer stable-untrusted window before
|
||||||
|
* starting (the runnable re-evaluates and quietly gives up if a
|
||||||
|
* trusted Wi-Fi returned in between). */
|
||||||
|
val delay = if (SystemClock.elapsedRealtime() - lastParkAt < RECENT_PARK_WINDOW_MS || monitor.isFlapping())
|
||||||
|
{
|
||||||
|
FLAP_CONNECT_DELAY_MS
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CONNECT_DELAY_MS
|
||||||
|
}
|
||||||
|
handler.postDelayed({
|
||||||
|
pendingEvaluate.set(false)
|
||||||
|
val svc = service
|
||||||
|
if (svc != null && running.get())
|
||||||
|
{
|
||||||
|
val state = svc.state
|
||||||
|
val vpnActive = state == VpnStateService.State.CONNECTED ||
|
||||||
|
state == VpnStateService.State.CONNECTING
|
||||||
|
if (evaluate(config, currentNetwork, vpnActive, autoSession.get()) == Action.CONNECT)
|
||||||
|
{
|
||||||
|
Log.i(TAG, "Untrusted network detected, starting auto session")
|
||||||
|
autoSession.set(true)
|
||||||
|
startVpn(svc, uuid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startVpn(svc: VpnStateService, uuid: String)
|
||||||
|
{
|
||||||
|
val needsConsent = try
|
||||||
|
{
|
||||||
|
VpnService.prepare(context) != null
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.e(TAG, "VpnService.prepare failed", e)
|
||||||
|
true
|
||||||
|
}
|
||||||
|
val livePassword = svc.profile?.takeIf { it.getUUID().toString() == uuid }?.password
|
||||||
|
if (!needsConsent && livePassword == null && org.strongswan.android.logic.profiles.blockedByPassword(context, uuid))
|
||||||
|
{ /* nobody is there to type the password */
|
||||||
|
autoSession.set(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!needsConsent)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "auto session starting, profile=$uuid")
|
||||||
|
/* a real connection is expected again; connect() re-arms the
|
||||||
|
* watchdog with the profile, so the auto-parked marker goes away */
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||||
|
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply()
|
||||||
|
val extras = Bundle()
|
||||||
|
extras.putString(VpnProfileDataSource.KEY_UUID, uuid)
|
||||||
|
livePassword?.let { extras.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||||
|
/* automatic start: keep the previous session's charon log */
|
||||||
|
extras.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true)
|
||||||
|
svc.connect(extras, true)
|
||||||
|
Events.autoConnect(context, parked = false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
||||||
|
intent.action = VpnProfileControlActivity.START_PROFILE
|
||||||
|
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, uuid)
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.e(TAG, "Failed to start profile control activity", e)
|
||||||
|
autoSession.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadConfig(): Config
|
||||||
|
{
|
||||||
|
val json = prefs.getString(KEY_CONFIG, null) ?: return Config()
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
Config(
|
||||||
|
enabled = obj.optBoolean("enabled", false),
|
||||||
|
profileUuid = if (obj.isNull("profileUuid")) null else obj.optString("profileUuid").takeIf { it.isNotBlank() },
|
||||||
|
trustedSsids = obj.optJSONArray("trustedSsids")?.let { arr ->
|
||||||
|
(0 until arr.length()).mapNotNull { i ->
|
||||||
|
arr.optString(i)?.takeIf { it.isNotBlank() }
|
||||||
|
}.toSet()
|
||||||
|
} ?: emptySet(),
|
||||||
|
parkOnTrusted = obj.optBoolean("parkOnTrusted", true),
|
||||||
|
connectOnUntrusted = obj.optBoolean("connectOnUntrusted", true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch (_: JSONException)
|
||||||
|
{
|
||||||
|
Config()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveConfig(config: Config)
|
||||||
|
{
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("enabled", config.enabled)
|
||||||
|
obj.put("profileUuid", config.profileUuid ?: JSONObject.NULL)
|
||||||
|
obj.put("trustedSsids", JSONArray(config.trustedSsids.sorted()))
|
||||||
|
obj.put("parkOnTrusted", config.parkOnTrusted)
|
||||||
|
obj.put("connectOnUntrusted", config.connectOnUntrusted)
|
||||||
|
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindAppLifecycle()
|
||||||
|
{
|
||||||
|
if (!lifecycleBound.compareAndSet(false, true))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.addObserver(appLifecycleObserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unbindAppLifecycle()
|
||||||
|
{
|
||||||
|
if (!lifecycleBound.compareAndSet(true, false))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.removeObserver(appLifecycleObserver)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
private const val TAG = "AutoConnectManager"
|
||||||
|
private const val PREFS_NAME = "one.shx.strongswan.ext.AUTO_CONNECT"
|
||||||
|
private const val KEY_CONFIG = "config"
|
||||||
|
private const val CONNECT_DELAY_MS = 3000L
|
||||||
|
/* anti-flap: a start right after a trusted-Wi-Fi park waits longer
|
||||||
|
* (the network must stay cellular through the whole window) */
|
||||||
|
private const val FLAP_CONNECT_DELAY_MS = 10_000L
|
||||||
|
private const val RECENT_PARK_WINDOW_MS = 120_000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision function, unit-testable. Trusted Wi-Fi parks only
|
||||||
|
* sessions that the automation started itself; a connection the user
|
||||||
|
* started manually stays up until the user disconnects it, regardless
|
||||||
|
* of the network.
|
||||||
|
*/
|
||||||
|
fun evaluateDecision(
|
||||||
|
config: Config,
|
||||||
|
network: NetworkKind?,
|
||||||
|
vpnActive: Boolean,
|
||||||
|
autoSession: Boolean,
|
||||||
|
whiteListBlocksReconnect: Boolean = false,
|
||||||
|
): Action
|
||||||
|
{
|
||||||
|
if (!config.enabled || config.profileUuid == null)
|
||||||
|
{
|
||||||
|
return Action.IGNORE
|
||||||
|
}
|
||||||
|
return when (network)
|
||||||
|
{
|
||||||
|
null -> Action.IGNORE
|
||||||
|
NetworkKind.TRUSTED_WIFI ->
|
||||||
|
if (config.parkOnTrusted && vpnActive && autoSession) Action.DISCONNECT else Action.IGNORE
|
||||||
|
/* SSID is redacted in background until location is readable. Keep an
|
||||||
|
* existing tunnel (do not treat unknown as untrusted) and still
|
||||||
|
* auto-start if the VPN is down. */
|
||||||
|
NetworkKind.UNKNOWN_WIFI -> if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
||||||
|
NetworkKind.UNTRUSTED_WIFI, NetworkKind.CELLULAR ->
|
||||||
|
if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: AutoConnectManager? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(context: Context): AutoConnectManager =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: AutoConnectManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+360
@@ -0,0 +1,360 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.autoconnect
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import android.net.wifi.WifiInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches underlying (non-VPN) networks. While a VPN is up,
|
||||||
|
* [ConnectivityManager.getActiveNetwork] is the tunnel, so we must track
|
||||||
|
* Wi-Fi / cellular from the callback instead.
|
||||||
|
*
|
||||||
|
* Wi-Fi is preferred over cellular when both are up. Events are debounced
|
||||||
|
* because Wi-Fi ↔ mobile switches arrive as a cascade.
|
||||||
|
*
|
||||||
|
* SSID is cached from [ConnectivityManager.NetworkCallback] callbacks registered
|
||||||
|
* with [ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO] so it
|
||||||
|
* stays readable while the app is in the background.
|
||||||
|
*/
|
||||||
|
class NetworkMonitor(
|
||||||
|
private val context: Context,
|
||||||
|
private val trustedSsidsProvider: () -> Set<String>,
|
||||||
|
private val onNetworkChanged: (AutoConnectManager.NetworkKind?) -> Unit,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val networks = ConcurrentHashMap<Network, NetworkCapabilities>()
|
||||||
|
private val ssids = ConcurrentHashMap<Network, String>()
|
||||||
|
private var registered = false
|
||||||
|
private var lastReported: AutoConnectManager.NetworkKind? = null
|
||||||
|
private var ssidRetries = 0
|
||||||
|
/* rolling log of network-kind change times, for flap detection (main thread only) */
|
||||||
|
private val kindChangeTimes = ArrayDeque<Long>()
|
||||||
|
|
||||||
|
private val events = object
|
||||||
|
{
|
||||||
|
fun onAvailable(network: Network)
|
||||||
|
{
|
||||||
|
val cm = connectivityManager() ?: return
|
||||||
|
cm.getNetworkCapabilities(network)?.let { remember(network, it) }
|
||||||
|
scheduleEvaluate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLost(network: Network)
|
||||||
|
{
|
||||||
|
networks.remove(network)
|
||||||
|
ssids.remove(network)
|
||||||
|
scheduleEvaluate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
|
||||||
|
{
|
||||||
|
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||||
|
{
|
||||||
|
networks.remove(network)
|
||||||
|
ssids.remove(network)
|
||||||
|
scheduleEvaluate()
|
||||||
|
}
|
||||||
|
else if (remember(network, capabilities))
|
||||||
|
{
|
||||||
|
scheduleEvaluate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val callback: ConnectivityManager.NetworkCallback = createCallback()
|
||||||
|
|
||||||
|
fun start()
|
||||||
|
{
|
||||||
|
if (registered)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val cm = connectivityManager() ?: return
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||||
|
.build()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
{
|
||||||
|
cm.registerNetworkCallback(request, callback, handler)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cm.registerNetworkCallback(request, callback)
|
||||||
|
}
|
||||||
|
registered = true
|
||||||
|
snapshotExisting(cm)
|
||||||
|
scheduleEvaluate()
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.e(TAG, "Failed to register network callback", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop()
|
||||||
|
{
|
||||||
|
if (!registered)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handler.removeCallbacks(evaluateRunnable)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
connectivityManager()?.unregisterNetworkCallback(callback)
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
registered = false
|
||||||
|
networks.clear()
|
||||||
|
ssids.clear()
|
||||||
|
lastReported = null
|
||||||
|
ssidRetries = 0
|
||||||
|
kindChangeTimes.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun forceEvaluate()
|
||||||
|
{
|
||||||
|
lastReported = null
|
||||||
|
ssidRetries = 0
|
||||||
|
handler.removeCallbacks(evaluateRunnable)
|
||||||
|
handler.post(evaluateRunnable)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun currentKind(): AutoConnectManager.NetworkKind?
|
||||||
|
{
|
||||||
|
var wifiNetwork: Network? = null
|
||||||
|
var wifiCaps: NetworkCapabilities? = null
|
||||||
|
var cellular = false
|
||||||
|
for ((network, caps) in networks)
|
||||||
|
{
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
|
||||||
|
{
|
||||||
|
wifiNetwork = network
|
||||||
|
wifiCaps = caps
|
||||||
|
}
|
||||||
|
else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
|
||||||
|
{
|
||||||
|
cellular = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wifiNetwork != null && wifiCaps != null)
|
||||||
|
{
|
||||||
|
val ssid = ssidFor(wifiNetwork, wifiCaps)
|
||||||
|
val trusted = trustedSsidsProvider()
|
||||||
|
Log.i(TAG, "Underlying Wi-Fi ssid=$ssid trusted=$trusted")
|
||||||
|
if (ssid == null)
|
||||||
|
{
|
||||||
|
return AutoConnectManager.NetworkKind.UNKNOWN_WIFI
|
||||||
|
}
|
||||||
|
return if (trusted.any { it.equals(ssid, ignoreCase = true) })
|
||||||
|
{
|
||||||
|
AutoConnectManager.NetworkKind.TRUSTED_WIFI
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
AutoConnectManager.NetworkKind.UNTRUSTED_WIFI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cellular)
|
||||||
|
{
|
||||||
|
return AutoConnectManager.NetworkKind.CELLULAR
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the latest state of a network. Returns whether anything the
|
||||||
|
* trust decision depends on changed: capability callbacks fire on every
|
||||||
|
* signal-strength/validation tick, and re-evaluating on those only burns
|
||||||
|
* battery - the debounced runnable wakes the main thread and rescans all
|
||||||
|
* networks for no result. Only a transport change or a new SSID counts.
|
||||||
|
*/
|
||||||
|
private fun remember(network: Network, caps: NetworkCapabilities): Boolean
|
||||||
|
{
|
||||||
|
val previous = networks[network]
|
||||||
|
val oldSsid = ssids[network]
|
||||||
|
val ssid = SsidReader.fromWifiInfo(caps.transportInfo as? WifiInfo)
|
||||||
|
networks[network] = caps
|
||||||
|
if (ssid != null)
|
||||||
|
{
|
||||||
|
ssids[network] = ssid
|
||||||
|
}
|
||||||
|
if (previous == null)
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (!sameTransports(previous, caps))
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return ssid != null && ssid != oldSsid
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sameTransports(a: NetworkCapabilities, b: NetworkCapabilities): Boolean =
|
||||||
|
a.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == b.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) &&
|
||||||
|
a.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == b.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) &&
|
||||||
|
a.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == b.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
|
||||||
|
private fun ssidFor(network: Network, caps: NetworkCapabilities): String?
|
||||||
|
{
|
||||||
|
ssids[network]?.let { return it }
|
||||||
|
return SsidReader.from(context, caps)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapshotExisting(cm: ConnectivityManager)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (network in cm.allNetworks)
|
||||||
|
{
|
||||||
|
val caps = cm.getNetworkCapabilities(network) ?: continue
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
|
||||||
|
{
|
||||||
|
remember(network, caps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Failed to snapshot networks", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectivityManager(): ConnectivityManager? =
|
||||||
|
context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
|
||||||
|
private fun createCallback(): ConnectivityManager.NetworkCallback
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
{
|
||||||
|
return object : ConnectivityManager.NetworkCallback(
|
||||||
|
ConnectivityManager.NetworkCallback.FLAG_INCLUDE_LOCATION_INFO
|
||||||
|
)
|
||||||
|
{
|
||||||
|
override fun onAvailable(network: Network) = events.onAvailable(network)
|
||||||
|
|
||||||
|
override fun onLost(network: Network) = events.onLost(network)
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) =
|
||||||
|
events.onCapabilitiesChanged(network, capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return object : ConnectivityManager.NetworkCallback()
|
||||||
|
{
|
||||||
|
override fun onAvailable(network: Network) = events.onAvailable(network)
|
||||||
|
|
||||||
|
override fun onLost(network: Network) = events.onLost(network)
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) =
|
||||||
|
events.onCapabilitiesChanged(network, capabilities)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleEvaluate()
|
||||||
|
{
|
||||||
|
handler.removeCallbacks(evaluateRunnable)
|
||||||
|
handler.postDelayed(evaluateRunnable, DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun evaluateNow()
|
||||||
|
{
|
||||||
|
val kind = currentKind()
|
||||||
|
if (kind == AutoConnectManager.NetworkKind.UNKNOWN_WIFI && ssidRetries < SSID_RETRY_MAX)
|
||||||
|
{
|
||||||
|
ssidRetries++
|
||||||
|
Log.i(TAG, "Wi-Fi SSID not readable yet, retry $ssidRetries/$SSID_RETRY_MAX")
|
||||||
|
handler.postDelayed(evaluateRunnable, SSID_RETRY_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ssidRetries = 0
|
||||||
|
if (kind != lastReported)
|
||||||
|
{
|
||||||
|
Log.i(TAG, "Network kind $lastReported -> $kind")
|
||||||
|
DiagLog.i(TAG, "network kind $lastReported -> $kind")
|
||||||
|
lastReported = kind
|
||||||
|
recordKindChange(SystemClock.elapsedRealtime())
|
||||||
|
onNetworkChanged(kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remember a kind change for [isFlapping], dropping entries that fell out
|
||||||
|
* of the flap window (plus a hard cap so a pathological callback storm
|
||||||
|
* cannot grow the log unbounded).
|
||||||
|
*/
|
||||||
|
private fun recordKindChange(now: Long)
|
||||||
|
{
|
||||||
|
kindChangeTimes.addLast(now)
|
||||||
|
while (kindChangeTimes.size > KIND_CHANGE_LOG_MAX ||
|
||||||
|
(kindChangeTimes.isNotEmpty() && now - kindChangeTimes.first() > FLAP_WINDOW_MS))
|
||||||
|
{
|
||||||
|
kindChangeTimes.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the network kind is bouncing: at least [FLAP_MIN_CHANGES] kind
|
||||||
|
* changes (including the latest one) within [FLAP_WINDOW_MS]. Callers
|
||||||
|
* stretch out auto-connect delays while the underlying network settles
|
||||||
|
* instead of bouncing the tunnel through short-lived sessions.
|
||||||
|
*/
|
||||||
|
fun isFlapping(): Boolean
|
||||||
|
{
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
while (kindChangeTimes.isNotEmpty() && now - kindChangeTimes.first() > FLAP_WINDOW_MS)
|
||||||
|
{
|
||||||
|
kindChangeTimes.removeFirst()
|
||||||
|
}
|
||||||
|
return kindChangeTimes.size >= FLAP_MIN_CHANGES
|
||||||
|
}
|
||||||
|
|
||||||
|
private val evaluateRunnable = Runnable { evaluateNow() }
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
private const val TAG = "NetworkMonitor"
|
||||||
|
private const val DEBOUNCE_MS = 1500L
|
||||||
|
private const val SSID_RETRY_MS = 400L
|
||||||
|
private const val SSID_RETRY_MAX = 8
|
||||||
|
private const val FLAP_WINDOW_MS = 5 * 60_000L
|
||||||
|
private const val FLAP_MIN_CHANGES = 2
|
||||||
|
private const val KIND_CHANGE_LOG_MAX = 16
|
||||||
|
}
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.autoconnect
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.wifi.WifiInfo
|
||||||
|
import android.net.wifi.WifiManager
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the current Wi-Fi SSID.
|
||||||
|
*
|
||||||
|
* [WifiManager.connectionInfo] returns `"<unknown ssid>"` in the background on
|
||||||
|
* Android 10+ unless the app is in the foreground or holds background location.
|
||||||
|
* [NetworkCapabilities.transportInfo] is similarly redacted unless the
|
||||||
|
* [android.net.ConnectivityManager.NetworkCallback] was registered with
|
||||||
|
* `FLAG_INCLUDE_LOCATION_INFO` (API 31+).
|
||||||
|
*/
|
||||||
|
object SsidReader
|
||||||
|
{
|
||||||
|
private const val UNKNOWN = "<unknown ssid>"
|
||||||
|
|
||||||
|
fun from(context: Context, caps: NetworkCapabilities? = null): String?
|
||||||
|
{
|
||||||
|
fromWifiInfo(caps?.transportInfo as? WifiInfo)?.let { return it }
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val info = try
|
||||||
|
{
|
||||||
|
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
|
||||||
|
wifi?.connectionInfo
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return fromWifiInfo(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromWifiInfo(info: WifiInfo?): String?
|
||||||
|
{
|
||||||
|
if (info == null)
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
normalize(info.ssid)?.let { return it }
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||||
|
{
|
||||||
|
normalize(info.passpointProviderFriendlyName)?.let { return it }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun normalize(raw: String?): String?
|
||||||
|
{
|
||||||
|
val ssid = raw?.removeSurrounding("\"")?.trim() ?: return null
|
||||||
|
if (ssid.isEmpty() || ssid.equals(UNKNOWN, ignoreCase = true) || ssid == "0x")
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ssid
|
||||||
|
}
|
||||||
|
}
|
||||||
+227
@@ -0,0 +1,227 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.connectivity
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Network
|
||||||
|
import android.os.Build
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
|
||||||
|
/** What a probe measures. External targets go through the tunnel, SERVER goes direct. */
|
||||||
|
enum class ProbeTarget
|
||||||
|
{
|
||||||
|
SERVER,
|
||||||
|
GOOGLE,
|
||||||
|
YOUTUBE,
|
||||||
|
CLOUDFLARE,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ProbeResult(
|
||||||
|
val target: ProbeTarget,
|
||||||
|
val ok: Boolean,
|
||||||
|
val rttMs: Long?,
|
||||||
|
val timestampMs: Long = System.currentTimeMillis(),
|
||||||
|
/** true when the probe bypassed the tunnel (server ping). */
|
||||||
|
val direct: Boolean = target == ProbeTarget.SERVER,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Public exit (as seen by the internet on the other end of the tunnel). */
|
||||||
|
data class ExitInfo(
|
||||||
|
val ip: String,
|
||||||
|
val country: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TCP-connect "ping" for external targets and ICMP ping for the VPN server.
|
||||||
|
*
|
||||||
|
* External targets use raw IPs (8.8.8.8/1.1.1.1) to avoid DNS as a failure
|
||||||
|
* source; youtube.com additionally exercises the tunnel's DNS.
|
||||||
|
*/
|
||||||
|
object ConnectivityProbe
|
||||||
|
{
|
||||||
|
private const val GOOGLE_HOST = "8.8.8.8"
|
||||||
|
private const val GOOGLE_PORT = 53
|
||||||
|
private const val CLOUDFLARE_HOST = "1.1.1.1"
|
||||||
|
private const val CLOUDFLARE_PORT = 53
|
||||||
|
private const val YOUTUBE_HOST = "youtube.com"
|
||||||
|
private const val YOUTUBE_PORT = 443
|
||||||
|
private const val PING_BINARY = "/system/bin/ping"
|
||||||
|
|
||||||
|
suspend fun probe(target: ProbeTarget, timeoutMs: Int, context: Context, gateway: String?): ProbeResult =
|
||||||
|
when (target)
|
||||||
|
{
|
||||||
|
ProbeTarget.SERVER ->
|
||||||
|
{
|
||||||
|
val ping = pingServerDirect(context, gateway, timeoutMs)
|
||||||
|
ProbeResult(target = target, ok = ping != null, rttMs = ping?.first)
|
||||||
|
}
|
||||||
|
ProbeTarget.GOOGLE -> tcpPing(ProbeTarget.GOOGLE, GOOGLE_HOST, GOOGLE_PORT, timeoutMs)
|
||||||
|
ProbeTarget.CLOUDFLARE -> tcpPing(ProbeTarget.CLOUDFLARE, CLOUDFLARE_HOST, CLOUDFLARE_PORT, timeoutMs)
|
||||||
|
ProbeTarget.YOUTUBE -> tcpPing(ProbeTarget.YOUTUBE, YOUTUBE_HOST, YOUTUBE_PORT, timeoutMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TCP connect timing, used as "ping". The socket is created on the default
|
||||||
|
* network, which is the VPN tunnel while connected, so the RTT reflects
|
||||||
|
* the tunnel path to the target.
|
||||||
|
*/
|
||||||
|
suspend fun tcpPing(target: ProbeTarget, host: String, port: Int, timeoutMs: Int, network: Network? = null): ProbeResult =
|
||||||
|
withContext(Dispatchers.IO)
|
||||||
|
{
|
||||||
|
val started = System.nanoTime()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
/* the socket factory of the target network; createSocket() can
|
||||||
|
* throw (e.g. EPERM when the caller is disallowed from that
|
||||||
|
* network), so it must stay inside the try */
|
||||||
|
val socket = if (network != null) network.socketFactory.createSocket() else Socket()
|
||||||
|
/* close right away: a leaked socket holds an fd until GC and
|
||||||
|
* the peer's later FIN/RST wakes the radio a second time */
|
||||||
|
socket.use {
|
||||||
|
it.tcpNoDelay = true
|
||||||
|
it.connect(InetSocketAddress(host, port), timeoutMs)
|
||||||
|
}
|
||||||
|
ProbeResult(target = target, ok = true, rttMs = (System.nanoTime() - started) / 1_000_000)
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
ProbeResult(target = target, ok = false, rttMs = null, direct = network != null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct ICMP ping to the VPN server via /system/bin/ping. The gateway is
|
||||||
|
* resolved through the underlying (non-VPN) network so the tunnel's DNS is
|
||||||
|
* never used, and the ICMP packet bypasses the TUN interface. Returns
|
||||||
|
* (rttMs, resolvedIp) or null on failure.
|
||||||
|
*/
|
||||||
|
suspend fun pingServerDirect(context: Context, gateway: String?, timeoutMs: Int): Pair<Long, String>? =
|
||||||
|
withContext(Dispatchers.IO)
|
||||||
|
{
|
||||||
|
val host = gateway?.trim().takeIf { !it.isNullOrBlank() } ?: return@withContext null
|
||||||
|
val network = UnderlyingNetwork.resolve(context)
|
||||||
|
val address = UnderlyingNetwork.resolveHost(network, host)?.firstOrNull()
|
||||||
|
?: return@withContext null
|
||||||
|
val ip = address.hostAddress ?: return@withContext null
|
||||||
|
val seconds = ((timeoutMs + 999) / 1000).coerceAtLeast(1)
|
||||||
|
val process = try
|
||||||
|
{
|
||||||
|
ProcessBuilder(PING_BINARY, "-c", "1", "-W", seconds.toString(), ip)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start()
|
||||||
|
}
|
||||||
|
catch (_: IOException)
|
||||||
|
{
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val output = process.inputStream.bufferedReader().readText()
|
||||||
|
if (!waitForOrTimeout(process, timeoutMs + 1000L) || process.exitValue() != 0)
|
||||||
|
{
|
||||||
|
return@withContext null
|
||||||
|
}
|
||||||
|
parsePingRtt(output)?.let { it to ip }
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
process.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts "time=xx.x ms" from ping output. Visible for tests. */
|
||||||
|
fun parsePingRtt(output: String): Long?
|
||||||
|
{
|
||||||
|
val match = Regex("time[=<]([0-9]+(?:\\.[0-9]+)?)\\s*ms", RegexOption.IGNORE_CASE).find(output) ?: return null
|
||||||
|
return match.groupValues[1].toDoubleOrNull()?.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the public exit IP (and country code) as seen from the other
|
||||||
|
* end of the tunnel, via Cloudflare's cdn-cgi/trace. The plain
|
||||||
|
* connection rides the VPN (the app's default network while connected),
|
||||||
|
* so the answer reflects the tunnel egress. Returns null on any failure.
|
||||||
|
*/
|
||||||
|
suspend fun traceExit(timeoutMs: Int): ExitInfo? = withContext(Dispatchers.IO)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val connection = URL("https://www.cloudflare.com/cdn-cgi/trace").openConnection()
|
||||||
|
as HttpsURLConnection
|
||||||
|
try
|
||||||
|
{
|
||||||
|
connection.connectTimeout = timeoutMs
|
||||||
|
connection.readTimeout = timeoutMs
|
||||||
|
connection.instanceFollowRedirects = false
|
||||||
|
val text = connection.inputStream.bufferedReader().use { it.readText() }
|
||||||
|
parseTraceExit(text)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extracts ip= / loc= from a cdn-cgi/trace body. Visible for tests. */
|
||||||
|
fun parseTraceExit(text: String): ExitInfo?
|
||||||
|
{
|
||||||
|
var ip: String? = null
|
||||||
|
var loc: String? = null
|
||||||
|
text.lineSequence().forEach { line ->
|
||||||
|
val value = line.substringAfter('=', "").trim()
|
||||||
|
when
|
||||||
|
{
|
||||||
|
line.startsWith("ip=") -> ip = value.takeIf { it.isNotEmpty() }
|
||||||
|
line.startsWith("loc=") -> loc = value.takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ip?.let { ExitInfo(ip = it, country = loc) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForOrTimeout(process: Process, timeoutMs: Long): Boolean
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
{
|
||||||
|
return process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
}
|
||||||
|
/* pre-O: no timed waitFor() - poll coarsely instead of spinning */
|
||||||
|
val deadline = System.currentTimeMillis() + timeoutMs
|
||||||
|
while (System.currentTimeMillis() < deadline)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
process.exitValue()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (_: IllegalThreadStateException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
Thread.sleep(250)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
+879
@@ -0,0 +1,879 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.connectivity
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import android.os.BatteryManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.logic.events.Events
|
||||||
|
|
||||||
|
/** What to do when a check fails. */
|
||||||
|
enum class HealthAction
|
||||||
|
{
|
||||||
|
RECONNECT,
|
||||||
|
IGNORE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** When the VPN server itself is probed directly (outside the tunnel). */
|
||||||
|
enum class ServerCheckMode
|
||||||
|
{
|
||||||
|
/** Probe the server on every cycle. */
|
||||||
|
ALWAYS,
|
||||||
|
/** Only consider a server failure when the tunnel is dead too (protects against servers that block ICMP). */
|
||||||
|
WITH_TUNNEL,
|
||||||
|
/** Never probe the server. */
|
||||||
|
OFF,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HealthConfig(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val intervalSec: Int = 30,
|
||||||
|
val aggressiveMode: Boolean = false,
|
||||||
|
/** Stretch intervals and pause aggressive mode while the screen is off, power saver is on or the battery is low. */
|
||||||
|
val adaptiveMode: Boolean = true,
|
||||||
|
val timeoutMs: Int = 3000,
|
||||||
|
val targets: Set<ProbeTarget> = setOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE),
|
||||||
|
val serverCheckMode: ServerCheckMode = ServerCheckMode.WITH_TUNNEL,
|
||||||
|
val serverAction: HealthAction = HealthAction.RECONNECT,
|
||||||
|
val serverMaxAttempts: Int = 3,
|
||||||
|
val tunnelDeadAction: HealthAction = HealthAction.RECONNECT,
|
||||||
|
val tunnelDeadMaxAttempts: Int = 3,
|
||||||
|
val partialDownAction: HealthAction = HealthAction.IGNORE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Device power state that modulates probe intensity in adaptive mode. */
|
||||||
|
data class PowerState(
|
||||||
|
val screenOn: Boolean = true,
|
||||||
|
val powerSaveMode: Boolean = false,
|
||||||
|
val batteryLow: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watches tunnel health while the VPN is connected: probes external targets
|
||||||
|
* through the tunnel and the VPN server directly on the underlying network.
|
||||||
|
* Based on [decide] it may reconnect, with per-rule attempt budgets and a
|
||||||
|
* confirming probe (unless aggressive mode) to avoid false reconnects.
|
||||||
|
*
|
||||||
|
* The process stays alive with the VPN foreground service, so checks continue
|
||||||
|
* with the screen off. No alarms or wakelocks: plain coroutines that simply
|
||||||
|
* follow process liveness, and Doze may stretch intervals - acceptable for a
|
||||||
|
* safety net on top of IKE DPD.
|
||||||
|
*
|
||||||
|
* With adaptive mode enabled the probe intensity follows the device power
|
||||||
|
* state: screen off stretches the interval, power saver or a low battery
|
||||||
|
* additionally disables fast polls (see [adaptToPowerState]).
|
||||||
|
*/
|
||||||
|
class HealthCheckManager private constructor(
|
||||||
|
private val context: Context,
|
||||||
|
) : VpnStateService.VpnStateListener
|
||||||
|
{
|
||||||
|
enum class Decision
|
||||||
|
{
|
||||||
|
RECONNECT_SERVER,
|
||||||
|
RECONNECT_TUNNEL,
|
||||||
|
IGNORE,
|
||||||
|
}
|
||||||
|
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + kotlinx.coroutines.Dispatchers.Main.immediate)
|
||||||
|
private val cycleMutex = Mutex()
|
||||||
|
private var config = HealthConfig()
|
||||||
|
private var running = false
|
||||||
|
/* last state seen by onStateChanged(): VpnStateService also notifies on
|
||||||
|
* error/IMC/rebind changes, which must not restart a pending cycle */
|
||||||
|
private var lastState: VpnStateService.State? = null
|
||||||
|
private var service: VpnStateService? = null
|
||||||
|
private var cycleJob: Job? = null
|
||||||
|
private var confirmJob: Job? = null
|
||||||
|
private var pendingDecision: Decision? = null
|
||||||
|
private var serverAttempts = 0
|
||||||
|
private var tunnelAttempts = 0
|
||||||
|
private var fastPollShifts = 0
|
||||||
|
private var powerState = PowerState()
|
||||||
|
/* tunnel inbound bytes at the previous cycle (-1: unknown) and when the last real probe ran */
|
||||||
|
private var lastCycleInbound = -1L
|
||||||
|
private var lastProbeAt = 0L
|
||||||
|
|
||||||
|
/* tracks screen/battery state so probing can back off while the device sleeps */
|
||||||
|
private val powerReceiver = object : BroadcastReceiver()
|
||||||
|
{
|
||||||
|
override fun onReceive(ctx: Context, intent: Intent)
|
||||||
|
{
|
||||||
|
when (intent.action)
|
||||||
|
{
|
||||||
|
Intent.ACTION_SCREEN_ON -> powerState = powerState.copy(screenOn = true)
|
||||||
|
Intent.ACTION_SCREEN_OFF -> powerState = powerState.copy(screenOn = false)
|
||||||
|
PowerManager.ACTION_POWER_SAVE_MODE_CHANGED -> refreshPowerSaveMode()
|
||||||
|
Intent.ACTION_BATTERY_LOW -> powerState = powerState.copy(batteryLow = true)
|
||||||
|
Intent.ACTION_BATTERY_OKAY -> powerState = powerState.copy(batteryLow = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* event trigger: the system re-validates networks (e.g. on screen unlock) */
|
||||||
|
private var vpnNetwork: Network? = null
|
||||||
|
private var vpnValidated = false
|
||||||
|
private val vpnCallback = object : ConnectivityManager.NetworkCallback()
|
||||||
|
{
|
||||||
|
override fun onAvailable(network: Network)
|
||||||
|
{
|
||||||
|
vpnNetwork = network
|
||||||
|
vpnValidated = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
|
||||||
|
{
|
||||||
|
if (network != vpnNetwork)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val validated = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
if (vpnValidated && !validated)
|
||||||
|
{
|
||||||
|
Log.i(TAG, "VPN network lost validation, triggering immediate check")
|
||||||
|
triggerImmediateCheck()
|
||||||
|
}
|
||||||
|
vpnValidated = validated
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network)
|
||||||
|
{
|
||||||
|
if (network == vpnNetwork)
|
||||||
|
{
|
||||||
|
vpnNetwork = null
|
||||||
|
vpnValidated = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val connection = object : ServiceConnection
|
||||||
|
{
|
||||||
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||||
|
{
|
||||||
|
service = (binder as VpnStateService.LocalBinder).service
|
||||||
|
service?.registerListener(this@HealthCheckManager)
|
||||||
|
onStateChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?)
|
||||||
|
{
|
||||||
|
service = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start()
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
if (!config.enabled)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (running)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running = true
|
||||||
|
context.bindService(
|
||||||
|
Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE
|
||||||
|
)
|
||||||
|
registerVpnCallback()
|
||||||
|
registerPowerReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop()
|
||||||
|
{
|
||||||
|
if (!running)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running = false
|
||||||
|
cancelCycles()
|
||||||
|
unregisterVpnCallback()
|
||||||
|
unregisterPowerReceiver()
|
||||||
|
service?.unregisterListener(this)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.unbindService(connection)
|
||||||
|
}
|
||||||
|
catch (_: IllegalArgumentException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
service = null
|
||||||
|
lastState = null
|
||||||
|
resetCounters()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reloadConfig()
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
if (config.enabled)
|
||||||
|
{
|
||||||
|
start()
|
||||||
|
/* re-arm with the new settings even if the state is unchanged */
|
||||||
|
lastState = null
|
||||||
|
onStateChanged()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentConfig: HealthConfig
|
||||||
|
get()
|
||||||
|
{
|
||||||
|
if (!running)
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateConfig(transform: (HealthConfig) -> HealthConfig)
|
||||||
|
{
|
||||||
|
val updated = transform(config)
|
||||||
|
saveConfig(updated)
|
||||||
|
config = updated
|
||||||
|
if (updated.enabled)
|
||||||
|
{
|
||||||
|
start()
|
||||||
|
/* re-arm with the new settings even if the state is unchanged */
|
||||||
|
lastState = null
|
||||||
|
onStateChanged()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stateChanged()
|
||||||
|
{
|
||||||
|
handler.post { onStateChanged() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onStateChanged()
|
||||||
|
{
|
||||||
|
if (!running)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val state = service?.state
|
||||||
|
val previous = lastState
|
||||||
|
lastState = state
|
||||||
|
when (state)
|
||||||
|
{
|
||||||
|
VpnStateService.State.CONNECTED ->
|
||||||
|
{
|
||||||
|
if (previous != VpnStateService.State.CONNECTED)
|
||||||
|
{ /* let the fresh tunnel settle before the first probe */
|
||||||
|
scheduleCycle(INITIAL_DELAY_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VpnStateService.State.DISABLED ->
|
||||||
|
{
|
||||||
|
cancelCycles()
|
||||||
|
resetCounters()
|
||||||
|
}
|
||||||
|
else -> cancelCycles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun triggerImmediateCheck()
|
||||||
|
{
|
||||||
|
if (!running || service?.state != VpnStateService.State.CONNECTED)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
val ran = tryRunCycle()
|
||||||
|
if (!ran)
|
||||||
|
{
|
||||||
|
/* a cycle is already in flight; its result will reschedule */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one probe cycle unless another one is already running.
|
||||||
|
* Returns false when a cycle was already in progress.
|
||||||
|
*/
|
||||||
|
private suspend fun tryRunCycle(): Boolean
|
||||||
|
{
|
||||||
|
if (!cycleMutex.tryLock())
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
runCycleLocked()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cycleMutex.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun runCycleLocked()
|
||||||
|
{
|
||||||
|
val svc = service ?: return
|
||||||
|
if (svc.state != VpnStateService.State.CONNECTED)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val gateway = svc.profile?.gateway
|
||||||
|
val adapted = adaptToPowerState(config, powerState)
|
||||||
|
val nowMs = SystemClock.elapsedRealtime()
|
||||||
|
/* passive liveness: payload that came back through the tunnel since the
|
||||||
|
* previous cycle proves it works without waking the radio for probes */
|
||||||
|
val inbound = tunnelInboundBytes()
|
||||||
|
val inboundDelta = if (lastCycleInbound >= 0 && inbound >= 0) inbound - lastCycleInbound else -1L
|
||||||
|
lastCycleInbound = inbound
|
||||||
|
if (trafficProvesAlive(
|
||||||
|
adapted, serverAttempts + tunnelAttempts, fastPollShifts, pendingDecision != null,
|
||||||
|
inboundDelta, nowMs - lastProbeAt,
|
||||||
|
))
|
||||||
|
{
|
||||||
|
scheduleCycle(effectiveIntervalSec(adapted) * 1000L)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastProbeAt = nowMs
|
||||||
|
/* screen off: one target is enough (the confirmation probe uses it too) */
|
||||||
|
val targets = if (adapted.adaptiveMode && !powerState.screenOn && config.targets.size > 1)
|
||||||
|
{
|
||||||
|
setOf(config.targets.first())
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
config.targets
|
||||||
|
}
|
||||||
|
val mode = config.serverCheckMode
|
||||||
|
val timeout = config.timeoutMs
|
||||||
|
|
||||||
|
val externalResults = coroutineScope {
|
||||||
|
targets.map { target ->
|
||||||
|
async { ConnectivityProbe.probe(target, timeout, context, gateway) }
|
||||||
|
}.map { it.await() }
|
||||||
|
}
|
||||||
|
val externalOk = externalResults.count { it.ok }
|
||||||
|
val externalTotal = externalResults.size
|
||||||
|
/* the direct server ping (a fork of /system/bin/ping plus a DNS lookup
|
||||||
|
* outside the tunnel) only runs when its result can matter: always in
|
||||||
|
* ALWAYS mode, in WITH_TUNNEL mode only once the tunnel looks dead */
|
||||||
|
val serverDown = if (shouldProbeServer(mode, externalOk, externalTotal))
|
||||||
|
{
|
||||||
|
!ConnectivityProbe.probe(ProbeTarget.SERVER, timeout, context, gateway).ok
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
val decision = decide(config, serverDown, externalOk, externalTotal, serverAttempts, tunnelAttempts)
|
||||||
|
val healthy = serverDown != true && (externalTotal == 0 || externalOk == externalTotal)
|
||||||
|
|
||||||
|
when
|
||||||
|
{
|
||||||
|
healthy ->
|
||||||
|
{
|
||||||
|
pendingDecision = null
|
||||||
|
resetCounters()
|
||||||
|
scheduleCycle(effectiveIntervalSec(adaptToPowerState(config, powerState)) * 1000L)
|
||||||
|
}
|
||||||
|
decision == Decision.IGNORE ->
|
||||||
|
{
|
||||||
|
pendingDecision = null
|
||||||
|
scheduleFastPoll()
|
||||||
|
}
|
||||||
|
else -> handleFailure(decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleFailure(decision: Decision)
|
||||||
|
{
|
||||||
|
if (needsConfirmation(adaptToPowerState(config, powerState)) && pendingDecision == null)
|
||||||
|
{
|
||||||
|
/* first failing cycle: confirm with a single probe before acting */
|
||||||
|
pendingDecision = decision
|
||||||
|
Log.i(TAG, "Check failed ($decision), scheduling confirmation probe")
|
||||||
|
confirmJob?.cancel()
|
||||||
|
confirmJob = scope.launch {
|
||||||
|
delay(CONFIRM_DELAY_MS)
|
||||||
|
if (pendingDecision == decision)
|
||||||
|
{
|
||||||
|
confirmAndAct(decision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scheduleFastPoll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
act(decision)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun confirmAndAct(decision: Decision)
|
||||||
|
{
|
||||||
|
val svc = service ?: return
|
||||||
|
if (svc.state != VpnStateService.State.CONNECTED)
|
||||||
|
{
|
||||||
|
pendingDecision = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val gateway = svc.profile?.gateway
|
||||||
|
/* single probe of the failing dimension, not a whole cycle */
|
||||||
|
val confirmed = when (decision)
|
||||||
|
{
|
||||||
|
Decision.RECONNECT_SERVER ->
|
||||||
|
!ConnectivityProbe.probe(ProbeTarget.SERVER, config.timeoutMs, context, gateway).ok
|
||||||
|
else ->
|
||||||
|
{
|
||||||
|
val target = config.targets.firstOrNull() ?: ProbeTarget.GOOGLE
|
||||||
|
!ConnectivityProbe.probe(target, config.timeoutMs, context, gateway).ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (confirmed)
|
||||||
|
{
|
||||||
|
Log.i(TAG, "Failure confirmed, reconnecting ($decision)")
|
||||||
|
act(decision)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.i(TAG, "Confirmation probe succeeded, treating previous failure as transient")
|
||||||
|
pendingDecision = null
|
||||||
|
fastPollShifts = 0
|
||||||
|
scheduleCycle(effectiveIntervalSec(adaptToPowerState(config, powerState)) * 1000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun act(decision: Decision)
|
||||||
|
{
|
||||||
|
pendingDecision = null
|
||||||
|
if (decision != Decision.IGNORE && WhiteListManager.getInstance(context).shouldBlockReconnect())
|
||||||
|
{ /* the ISP whitelist explains the failing probes, restarting the tunnel
|
||||||
|
* will not help; re-check soon so we act once the whitelist clears */
|
||||||
|
Log.i(TAG, "Whitelist active, suppressing reconnect ($decision)")
|
||||||
|
scheduleFastPoll()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
when (decision)
|
||||||
|
{
|
||||||
|
Decision.RECONNECT_SERVER ->
|
||||||
|
{
|
||||||
|
if (serverAttempts < config.serverMaxAttempts)
|
||||||
|
{
|
||||||
|
serverAttempts++
|
||||||
|
Log.i(TAG, "Reconnecting, server unreachable (attempt $serverAttempts/${config.serverMaxAttempts})")
|
||||||
|
service?.reconnectPreservingLog()
|
||||||
|
Events.healthReconnect(context, serverDown = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Decision.RECONNECT_TUNNEL ->
|
||||||
|
{
|
||||||
|
if (tunnelAttempts < config.tunnelDeadMaxAttempts)
|
||||||
|
{
|
||||||
|
tunnelAttempts++
|
||||||
|
Log.i(TAG, "Reconnecting, tunnel dead (attempt $tunnelAttempts/${config.tunnelDeadMaxAttempts})")
|
||||||
|
service?.reconnectPreservingLog()
|
||||||
|
Events.healthReconnect(context, serverDown = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Decision.IGNORE ->
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleCycle(delayMs: Long)
|
||||||
|
{
|
||||||
|
cycleJob?.cancel()
|
||||||
|
cycleJob = scope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
tryRunCycle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules the next cycle while a failure is being observed: fast at
|
||||||
|
* first, then with exponential backoff up to the regular interval so a
|
||||||
|
* persisting failure doesn't keep probing (and waking the radio) at full
|
||||||
|
* rate. Under power saver or a low battery fast polls are skipped
|
||||||
|
* entirely.
|
||||||
|
*/
|
||||||
|
private fun scheduleFastPoll()
|
||||||
|
{
|
||||||
|
val adapted = adaptToPowerState(config, powerState)
|
||||||
|
val intervalMs = if (powerState.powerSaveMode || powerState.batteryLow)
|
||||||
|
{
|
||||||
|
adapted.intervalSec * 1000L
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
minOf(FAST_POLL_MS * (1L shl minOf(fastPollShifts, FAST_POLL_MAX_SHIFTS)), adapted.intervalSec * 1000L)
|
||||||
|
}
|
||||||
|
fastPollShifts++
|
||||||
|
scheduleCycle(intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshPowerSaveMode()
|
||||||
|
{
|
||||||
|
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||||
|
powerState = powerState.copy(powerSaveMode = pm?.isPowerSaveMode ?: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerPowerReceiver()
|
||||||
|
{
|
||||||
|
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||||
|
powerState = PowerState(
|
||||||
|
screenOn = pm?.isInteractive ?: true,
|
||||||
|
powerSaveMode = pm?.isPowerSaveMode ?: false,
|
||||||
|
batteryLow = isBatteryLowNow(),
|
||||||
|
)
|
||||||
|
/* BATTERY_LOW/OKAY are not sticky: the initial state comes from the
|
||||||
|
* sticky ACTION_BATTERY_CHANGED, later changes from the broadcasts */
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(Intent.ACTION_SCREEN_ON)
|
||||||
|
addAction(Intent.ACTION_SCREEN_OFF)
|
||||||
|
addAction(PowerManager.ACTION_POWER_SAVE_MODE_CHANGED)
|
||||||
|
addAction(Intent.ACTION_BATTERY_LOW)
|
||||||
|
addAction(Intent.ACTION_BATTERY_OKAY)
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.registerReceiver(powerReceiver, filter, null, handler)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Failed to register power state receiver", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isBatteryLowNow(): Boolean
|
||||||
|
{
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val sticky = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||||
|
?: return false
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||||
|
{
|
||||||
|
sticky.getBooleanExtra(BatteryManager.EXTRA_BATTERY_LOW, false)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{ /* the platform's default low-battery threshold */
|
||||||
|
val level = sticky.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||||
|
val scale = sticky.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||||
|
level >= 0 && scale > 0 && level * 100 / scale <= 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterPowerReceiver()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.unregisterReceiver(powerReceiver)
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelCycles()
|
||||||
|
{
|
||||||
|
cycleJob?.cancel()
|
||||||
|
cycleJob = null
|
||||||
|
confirmJob?.cancel()
|
||||||
|
confirmJob = null
|
||||||
|
pendingDecision = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetCounters()
|
||||||
|
{
|
||||||
|
serverAttempts = 0
|
||||||
|
tunnelAttempts = 0
|
||||||
|
fastPollShifts = 0
|
||||||
|
pendingDecision = null
|
||||||
|
lastCycleInbound = -1L
|
||||||
|
lastProbeAt = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inbound byte counter of the tunnel's CHILD_SAs, -1 when unavailable. */
|
||||||
|
private fun tunnelInboundBytes(): Long
|
||||||
|
{
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
CharonVpnService.queryTraffic()?.getOrNull(0) ?: -1L
|
||||||
|
}
|
||||||
|
catch (_: Throwable)
|
||||||
|
{
|
||||||
|
-1L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerVpnCallback()
|
||||||
|
{
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
.build()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cm.registerNetworkCallback(request, vpnCallback, handler)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Failed to register VPN network callback", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterVpnCallback()
|
||||||
|
{
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cm.unregisterNetworkCallback(vpnCallback)
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
vpnNetwork = null
|
||||||
|
vpnValidated = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadConfig(): HealthConfig
|
||||||
|
{
|
||||||
|
val json = prefs.getString(KEY_CONFIG, null) ?: return HealthConfig()
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
HealthConfig(
|
||||||
|
enabled = obj.optBoolean("enabled", false),
|
||||||
|
intervalSec = obj.optInt("intervalSec", 30),
|
||||||
|
aggressiveMode = obj.optBoolean("aggressiveMode", false),
|
||||||
|
adaptiveMode = obj.optBoolean("adaptiveMode", true),
|
||||||
|
timeoutMs = obj.optInt("timeoutMs", 3000),
|
||||||
|
targets = obj.optJSONArray("targets")?.let { arr ->
|
||||||
|
(0 until arr.length()).mapNotNull { i ->
|
||||||
|
arr.optString(i).let { name -> ProbeTarget.entries.firstOrNull { it.name == name } }
|
||||||
|
}.filter { it != ProbeTarget.SERVER }.toSet()
|
||||||
|
} ?: setOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE),
|
||||||
|
serverCheckMode = ServerCheckMode.entries.firstOrNull { it.name == obj.optString("serverCheckMode") }
|
||||||
|
?: ServerCheckMode.WITH_TUNNEL,
|
||||||
|
serverAction = HealthAction.entries.firstOrNull { it.name == obj.optString("serverAction") }
|
||||||
|
?: HealthAction.RECONNECT,
|
||||||
|
serverMaxAttempts = obj.optInt("serverMaxAttempts", 3),
|
||||||
|
tunnelDeadAction = HealthAction.entries.firstOrNull { it.name == obj.optString("tunnelDeadAction") }
|
||||||
|
?: HealthAction.RECONNECT,
|
||||||
|
tunnelDeadMaxAttempts = obj.optInt("tunnelDeadMaxAttempts", 3),
|
||||||
|
partialDownAction = HealthAction.entries.firstOrNull { it.name == obj.optString("partialDownAction") }
|
||||||
|
?: HealthAction.IGNORE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch (_: JSONException)
|
||||||
|
{
|
||||||
|
HealthConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveConfig(config: HealthConfig)
|
||||||
|
{
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("enabled", config.enabled)
|
||||||
|
obj.put("intervalSec", config.intervalSec)
|
||||||
|
obj.put("aggressiveMode", config.aggressiveMode)
|
||||||
|
obj.put("adaptiveMode", config.adaptiveMode)
|
||||||
|
obj.put("timeoutMs", config.timeoutMs)
|
||||||
|
obj.put("targets", JSONArray(config.targets.map { it.name }.sorted()))
|
||||||
|
obj.put("serverCheckMode", config.serverCheckMode.name)
|
||||||
|
obj.put("serverAction", config.serverAction.name)
|
||||||
|
obj.put("serverMaxAttempts", config.serverMaxAttempts)
|
||||||
|
obj.put("tunnelDeadAction", config.tunnelDeadAction.name)
|
||||||
|
obj.put("tunnelDeadMaxAttempts", config.tunnelDeadMaxAttempts)
|
||||||
|
obj.put("partialDownAction", config.partialDownAction.name)
|
||||||
|
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
private const val TAG = "HealthCheckManager"
|
||||||
|
private const val PREFS_NAME = "one.shx.strongswan.ext.HEALTH_CHECK"
|
||||||
|
private const val KEY_CONFIG = "config"
|
||||||
|
private const val INITIAL_DELAY_MS = 2000L
|
||||||
|
private const val CONFIRM_DELAY_MS = 2000L
|
||||||
|
private const val FAST_POLL_MS = 5000L
|
||||||
|
private const val FAST_POLL_MAX_SHIFTS = 3
|
||||||
|
private const val AGGRESSIVE_INTERVAL_SEC = 5
|
||||||
|
|
||||||
|
/** Inbound tunnel payload since the last cycle that counts as proof of life. */
|
||||||
|
internal const val PASSIVE_MIN_INBOUND_BYTES = 4096L
|
||||||
|
|
||||||
|
/** Even with traffic flowing a real probe runs at least this often. */
|
||||||
|
internal const val PASSIVE_MAX_PROBE_AGE_MS = 5 * 60 * 1000L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function: whether the cycle may skip its probes because the
|
||||||
|
* tunnel carried inbound data since the previous cycle. Only in
|
||||||
|
* adaptive mode, never in aggressive mode, never while a failure is
|
||||||
|
* being observed, and never for longer than [PASSIVE_MAX_PROBE_AGE_MS].
|
||||||
|
* Outbound-only traffic proves nothing, so it does not count.
|
||||||
|
*/
|
||||||
|
fun trafficProvesAlive(
|
||||||
|
adapted: HealthConfig,
|
||||||
|
failedAttempts: Int,
|
||||||
|
fastPollShifts: Int,
|
||||||
|
decisionPending: Boolean,
|
||||||
|
inboundDelta: Long,
|
||||||
|
sinceLastProbeMs: Long,
|
||||||
|
): Boolean =
|
||||||
|
adapted.adaptiveMode && !adapted.aggressiveMode &&
|
||||||
|
failedAttempts == 0 && fastPollShifts == 0 && !decisionPending &&
|
||||||
|
inboundDelta >= PASSIVE_MIN_INBOUND_BYTES &&
|
||||||
|
sinceLastProbeMs in 0 until PASSIVE_MAX_PROBE_AGE_MS
|
||||||
|
|
||||||
|
/** Minimum probe interval in adaptive mode while the screen is off. */
|
||||||
|
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
||||||
|
|
||||||
|
/** Probe interval in adaptive mode while power saver is on or the battery is low. */
|
||||||
|
internal const val POWER_SAVE_INTERVAL_SEC = 300
|
||||||
|
|
||||||
|
/** Effective interval between cycles: aggressive mode overrides the user interval. */
|
||||||
|
fun effectiveIntervalSec(config: HealthConfig): Int =
|
||||||
|
if (config.aggressiveMode) AGGRESSIVE_INTERVAL_SEC else config.intervalSec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure function: the config actually applied for the given device power
|
||||||
|
* state. With adaptive mode on, aggressive mode is paused and the
|
||||||
|
* interval stretched while the screen is off; under power saver or a low
|
||||||
|
* battery the interval goes to the maximum (fast polls are skipped in
|
||||||
|
* [scheduleFastPoll] based on the same state).
|
||||||
|
*/
|
||||||
|
fun adaptToPowerState(config: HealthConfig, power: PowerState): HealthConfig
|
||||||
|
{
|
||||||
|
if (!config.adaptiveMode)
|
||||||
|
{
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
power.powerSaveMode || power.batteryLow ->
|
||||||
|
config.copy(aggressiveMode = false, intervalSec = maxOf(config.intervalSec, POWER_SAVE_INTERVAL_SEC))
|
||||||
|
!power.screenOn ->
|
||||||
|
config.copy(aggressiveMode = false, intervalSec = maxOf(config.intervalSec, SCREEN_OFF_MIN_INTERVAL_SEC))
|
||||||
|
else -> config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aggressive mode reconnects without a confirming probe. */
|
||||||
|
fun needsConfirmation(config: HealthConfig): Boolean = !config.aggressiveMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure decision function, unit-testable.
|
||||||
|
*
|
||||||
|
* @param serverDown null when the server is not probed, true when the direct ping failed
|
||||||
|
* @param externalOk how many external targets answered through the tunnel
|
||||||
|
* @param externalTotal how many external targets were probed
|
||||||
|
* @param serverAttempts reconnect decisions already taken for the server rule
|
||||||
|
* @param tunnelAttempts reconnect decisions already taken for the tunnel rules
|
||||||
|
*/
|
||||||
|
/** Whether the server probe can influence [decide] this cycle. */
|
||||||
|
@JvmStatic
|
||||||
|
fun shouldProbeServer(mode: ServerCheckMode, externalOk: Int, externalTotal: Int): Boolean =
|
||||||
|
when (mode)
|
||||||
|
{
|
||||||
|
ServerCheckMode.OFF -> false
|
||||||
|
ServerCheckMode.ALWAYS -> true
|
||||||
|
ServerCheckMode.WITH_TUNNEL -> externalTotal > 0 && externalOk == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun decide(
|
||||||
|
config: HealthConfig,
|
||||||
|
serverDown: Boolean?,
|
||||||
|
externalOk: Int,
|
||||||
|
externalTotal: Int,
|
||||||
|
serverAttempts: Int,
|
||||||
|
tunnelAttempts: Int,
|
||||||
|
): Decision
|
||||||
|
{
|
||||||
|
if (!config.enabled)
|
||||||
|
{
|
||||||
|
return Decision.IGNORE
|
||||||
|
}
|
||||||
|
val tunnelDead = externalTotal > 0 && externalOk == 0
|
||||||
|
val serverFail = config.serverCheckMode != ServerCheckMode.OFF &&
|
||||||
|
serverDown == true &&
|
||||||
|
(config.serverCheckMode == ServerCheckMode.ALWAYS ||
|
||||||
|
(config.serverCheckMode == ServerCheckMode.WITH_TUNNEL && tunnelDead))
|
||||||
|
if (serverFail && config.serverAction == HealthAction.RECONNECT &&
|
||||||
|
serverAttempts < config.serverMaxAttempts)
|
||||||
|
{
|
||||||
|
return Decision.RECONNECT_SERVER
|
||||||
|
}
|
||||||
|
if (tunnelDead && config.tunnelDeadAction == HealthAction.RECONNECT &&
|
||||||
|
tunnelAttempts < config.tunnelDeadMaxAttempts)
|
||||||
|
{
|
||||||
|
return Decision.RECONNECT_TUNNEL
|
||||||
|
}
|
||||||
|
val partial = externalTotal > 0 && externalOk in 1 until externalTotal
|
||||||
|
if (partial && config.partialDownAction == HealthAction.RECONNECT &&
|
||||||
|
tunnelAttempts < config.tunnelDeadMaxAttempts)
|
||||||
|
{
|
||||||
|
return Decision.RECONNECT_TUNNEL
|
||||||
|
}
|
||||||
|
return Decision.IGNORE
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: HealthCheckManager? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(context: Context): HealthCheckManager =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: HealthCheckManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.connectivity
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the underlying (non-VPN) network so the VPN server can be reached
|
||||||
|
* directly, outside the tunnel. Wi-Fi is preferred over cellular, mirroring
|
||||||
|
* [org.strongswan.android.logic.autoconnect.NetworkMonitor].
|
||||||
|
*/
|
||||||
|
object UnderlyingNetwork
|
||||||
|
{
|
||||||
|
fun resolve(context: Context): Network?
|
||||||
|
{
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null
|
||||||
|
var wifi: Network? = null
|
||||||
|
var cellular: Network? = null
|
||||||
|
var validatedWifi: Network? = null
|
||||||
|
var validatedCellular: Network? = null
|
||||||
|
for (network in cm.allNetworks)
|
||||||
|
{
|
||||||
|
val caps = try
|
||||||
|
{
|
||||||
|
cm.getNetworkCapabilities(network)
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
null
|
||||||
|
} ?: continue
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
/* captive portals answer connects for anything, so an unvalidated
|
||||||
|
* network would probe as "everything reachable" and hide blocking */
|
||||||
|
val validated = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
|
||||||
|
{
|
||||||
|
wifi = network
|
||||||
|
if (validated)
|
||||||
|
{
|
||||||
|
validatedWifi = network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
|
||||||
|
{
|
||||||
|
cellular = network
|
||||||
|
if (validated)
|
||||||
|
{
|
||||||
|
validatedCellular = network
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validatedWifi ?: validatedCellular ?: wifi ?: cellular
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a hostname through the given network so the VPN tunnel's DNS
|
||||||
|
* is never used for the direct server probe.
|
||||||
|
*/
|
||||||
|
fun resolveHost(network: Network?, host: String): List<java.net.InetAddress>?
|
||||||
|
{
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
if (network != null) network.getAllByName(host).toList() else java.net.InetAddress.getAllByName(host).toList()
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+734
@@ -0,0 +1,734 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.connectivity
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.logic.events.Events
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/** Whether the ISP currently blocks direct access to foreign hosts. */
|
||||||
|
enum class WhiteListStatus
|
||||||
|
{
|
||||||
|
/** No usable data yet. */
|
||||||
|
UNKNOWN,
|
||||||
|
/** Foreign hosts unreachable directly while domestic ones still answer. */
|
||||||
|
ACTIVE,
|
||||||
|
/** Foreign hosts reachable directly. */
|
||||||
|
INACTIVE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A probe endpoint, serialized to preferences as "host:port" (IPv4 only).
|
||||||
|
*/
|
||||||
|
data class ProbeEndpoint(val host: String, val port: Int)
|
||||||
|
{
|
||||||
|
fun serialize(): String = "$host:$port"
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
fun parse(value: String): ProbeEndpoint?
|
||||||
|
{
|
||||||
|
val index = value.lastIndexOf(':')
|
||||||
|
if (index <= 0)
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val host = value.substring(0, index)
|
||||||
|
val port = value.substring(index + 1).toIntOrNull() ?: return null
|
||||||
|
if (host.isEmpty() || port !in 1..65535)
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return ProbeEndpoint(host, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class WhiteListConfig(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
/** Overrides for the foreign probe endpoints as "host:port"; empty keeps the built-in defaults. */
|
||||||
|
val foreignTargets: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class WhiteListState(
|
||||||
|
val config: WhiteListConfig = WhiteListConfig(),
|
||||||
|
val status: WhiteListStatus = WhiteListStatus.UNKNOWN,
|
||||||
|
val foreignDirectOk: Boolean? = null,
|
||||||
|
val domesticDirectOk: Boolean? = null,
|
||||||
|
val foreignDirectRttMs: Long? = null,
|
||||||
|
val domesticDirectRttMs: Long? = null,
|
||||||
|
val lastCheckMs: Long = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of one direct probe round (all probes run outside the tunnel),
|
||||||
|
* one entry per configured foreign target. Failed probes report false/null
|
||||||
|
* RTT; when there is no usable network at all every entry is null.
|
||||||
|
*/
|
||||||
|
data class WhiteListRound(
|
||||||
|
val foreignOks: List<Boolean?>,
|
||||||
|
val foreignRttMs: List<Long?>,
|
||||||
|
val domesticOk: Boolean?,
|
||||||
|
val domesticRttMs: Long?,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
/** Any foreign target reachable; null while nothing conclusive was probed. */
|
||||||
|
val foreignDirectOk: Boolean?
|
||||||
|
get() = when
|
||||||
|
{
|
||||||
|
foreignOks.isEmpty() -> null
|
||||||
|
foreignOks.any { it == true } -> true
|
||||||
|
foreignOks.all { it == false } -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fastest reply among the reachable foreign targets. */
|
||||||
|
val foreignDirectRttMs: Long?
|
||||||
|
get() = foreignRttMs.filterNotNull().minOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects "white listing" by ISPs (foreign hosts blocked directly, domestic
|
||||||
|
* hosts still reachable) by probing several foreign endpoints and one
|
||||||
|
* domestic host DIRECTLY on the underlying network, never through the
|
||||||
|
* tunnel. One reachable foreign host disproves whitelisting; ACTIVE is only
|
||||||
|
* reported after every foreign probe failed (each retried once) in two
|
||||||
|
* consecutive rounds, so a flaky round cannot produce a false verdict.
|
||||||
|
*
|
||||||
|
* While the whitelist is [WhiteListStatus.ACTIVE], reconnecting to the VPN
|
||||||
|
* server is pointless (the server is unreachable), so reconnection attempts
|
||||||
|
* are paused: the VpnStateService retry countdown freezes, HealthCheckManager
|
||||||
|
* skips its reconnect actions and AutoConnectManager ignores connect requests.
|
||||||
|
* As soon as the status becomes [WhiteListStatus.INACTIVE] those paths resume
|
||||||
|
* by themselves and the connection is (re)established. The ACTIVE verdict
|
||||||
|
* fails open when it has gone stale, so the tunnel can never be parked
|
||||||
|
* indefinitely by a wrong verdict.
|
||||||
|
*
|
||||||
|
* Battery: fully event-driven, no polling loop of its own. Automatic probes
|
||||||
|
* run on underlying network changes, on tunnel loss, and at most once per
|
||||||
|
* minute while the UI is visible (tunnel up or down); the availability card's
|
||||||
|
* refresh button triggers one immediately. The automatic recheck while the
|
||||||
|
* whitelist stays active and the tunnel is down follows a capped exponential
|
||||||
|
* backoff. No wakelocks or alarms: plain coroutines that follow process
|
||||||
|
* liveness.
|
||||||
|
*/
|
||||||
|
class WhiteListManager private constructor(private val context: Context) : VpnStateService.VpnStateListener
|
||||||
|
{
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
private val cycleMutex = Mutex()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var config = WhiteListConfig()
|
||||||
|
private var running = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var machine = StatusMachine()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var lastCheckElapsed = 0L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var backoffAttempt = 0
|
||||||
|
private var recheckJob: Job? = null
|
||||||
|
private var service: VpnStateService? = null
|
||||||
|
private var lastSeenState: VpnStateService.State? = null
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(WhiteListState())
|
||||||
|
val state: StateFlow<WhiteListState> = _state
|
||||||
|
|
||||||
|
/* last classification-relevant capabilities per network (see capabilityKey) */
|
||||||
|
private val lastCapabilities = ConcurrentHashMap<Network, String>()
|
||||||
|
|
||||||
|
/* event trigger: the underlying network changed, the ISP may classify differently now */
|
||||||
|
private val networkCallback = object : ConnectivityManager.NetworkCallback()
|
||||||
|
{
|
||||||
|
override fun onAvailable(network: Network)
|
||||||
|
{
|
||||||
|
onNetworkEvent()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
|
||||||
|
{ /* fired on every signal-strength change too: only a change that
|
||||||
|
* can alter the ISP's classification counts as an event (it also
|
||||||
|
* resets the recheck backoff) */
|
||||||
|
val key = capabilityKey(capabilities)
|
||||||
|
if (lastCapabilities.put(network, key) != key)
|
||||||
|
{
|
||||||
|
onNetworkEvent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network)
|
||||||
|
{
|
||||||
|
lastCapabilities.remove(network)
|
||||||
|
onNetworkEvent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val connection = object : ServiceConnection
|
||||||
|
{
|
||||||
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||||
|
{
|
||||||
|
service = (binder as VpnStateService.LocalBinder).service
|
||||||
|
service?.registerListener(this@WhiteListManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?)
|
||||||
|
{
|
||||||
|
service = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start()
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
if (!config.enabled)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (running)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running = true
|
||||||
|
publish()
|
||||||
|
context.bindService(Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE)
|
||||||
|
registerNetworkCallback()
|
||||||
|
Log.i(TAG, "Started")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop()
|
||||||
|
{
|
||||||
|
if (!running)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
running = false
|
||||||
|
recheckJob?.cancel()
|
||||||
|
recheckJob = null
|
||||||
|
unregisterNetworkCallback()
|
||||||
|
lastCapabilities.clear()
|
||||||
|
service?.unregisterListener(this)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.unbindService(connection)
|
||||||
|
}
|
||||||
|
catch (_: IllegalArgumentException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
service = null
|
||||||
|
lastSeenState = null
|
||||||
|
machine = StatusMachine()
|
||||||
|
backoffAttempt = 0
|
||||||
|
lastCheckElapsed = 0L
|
||||||
|
publish()
|
||||||
|
Log.i(TAG, "Stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reloadConfig()
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
if (config.enabled)
|
||||||
|
{
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentConfig: WhiteListConfig
|
||||||
|
get()
|
||||||
|
{
|
||||||
|
if (!running)
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateConfig(transform: (WhiteListConfig) -> WhiteListConfig)
|
||||||
|
{
|
||||||
|
val updated = transform(config)
|
||||||
|
saveConfig(updated)
|
||||||
|
config = updated
|
||||||
|
if (updated.enabled)
|
||||||
|
{
|
||||||
|
start()
|
||||||
|
scope.launch { runCheck(force = true) }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether reconnect attempts should currently be paused. Cheap, safe from
|
||||||
|
* any thread. Fails open once the ACTIVE verdict went stale: an
|
||||||
|
* unverifiable status must not keep the tunnel down forever; the
|
||||||
|
* triggered recheck restores ACTIVE if the whitelist is still in effect.
|
||||||
|
*/
|
||||||
|
fun shouldBlockReconnect(): Boolean
|
||||||
|
{
|
||||||
|
if (!running || !config.enabled)
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (machine.status != WhiteListStatus.ACTIVE)
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (SystemClock.elapsedRealtime() - lastCheckElapsed > ACTIVE_TTL_MS)
|
||||||
|
{
|
||||||
|
scope.launch { runCheck(force = false) }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when the app becomes visible: refresh the cached status if stale. */
|
||||||
|
fun refreshIfStale()
|
||||||
|
{
|
||||||
|
if (!running || !config.enabled)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope.launch { runCheck(force = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs a probe round immediately (e.g. the user tapped refresh). */
|
||||||
|
fun checkNow()
|
||||||
|
{
|
||||||
|
if (!running || !config.enabled)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope.launch { runCheck(force = true) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stateChanged()
|
||||||
|
{
|
||||||
|
handler.post { onStateChanged() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onStateChanged()
|
||||||
|
{
|
||||||
|
if (!running)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val svc = service ?: return
|
||||||
|
val state = svc.state
|
||||||
|
val changed = state != lastSeenState
|
||||||
|
lastSeenState = state
|
||||||
|
if (state == VpnStateService.State.CONNECTED)
|
||||||
|
{ /* tunnel up: nothing to reconnect, probes are not needed */
|
||||||
|
recheckJob?.cancel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/* the retry countdown notifies listeners every second; only act on transitions */
|
||||||
|
if (!changed)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backoffAttempt = 0
|
||||||
|
/* a flapping tunnel (CONNECTING -> DISCONNECTING -> DISABLED -> ...)
|
||||||
|
* must not trigger a fresh direct probe round on every transition */
|
||||||
|
val force = SystemClock.elapsedRealtime() - lastCheckElapsed >= TRANSITION_MIN_GAP_MS
|
||||||
|
scope.launch { runCheck(force = force) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onNetworkEvent()
|
||||||
|
{
|
||||||
|
if (!running)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backoffAttempt = 0
|
||||||
|
if (service?.state == VpnStateService.State.CONNECTED)
|
||||||
|
{ /* nothing to reconnect while the tunnel is up */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scope.launch { runCheck(force = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The configured foreign probe endpoints, falling back to the built-in defaults. */
|
||||||
|
private fun effectiveForeignTargets(): List<ProbeEndpoint> =
|
||||||
|
config.foreignTargets.mapNotNull { ProbeEndpoint.parse(it) }.ifEmpty { DEFAULT_FOREIGN_TARGETS }
|
||||||
|
|
||||||
|
private suspend fun runCheck(force: Boolean)
|
||||||
|
{
|
||||||
|
if (!running)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!cycleMutex.tryLock())
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
if (!force && now - lastCheckElapsed < FRESH_MS)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val targets = effectiveForeignTargets()
|
||||||
|
var round = probeRound(context, targets)
|
||||||
|
lastCheckElapsed = SystemClock.elapsedRealtime()
|
||||||
|
var candidate = decide(round.foreignOks, round.domesticOk)
|
||||||
|
if (candidate == WhiteListStatus.ACTIVE && machine.status != WhiteListStatus.ACTIVE)
|
||||||
|
{ /* confirm a first ACTIVE verdict with a second round: one flaky
|
||||||
|
* round must not flip the status and pause reconnects */
|
||||||
|
Log.i(TAG, "ACTIVE candidate, confirming with a second round")
|
||||||
|
delay(ACTIVE_CONFIRM_DELAY_MS)
|
||||||
|
round = probeRound(context, targets)
|
||||||
|
lastCheckElapsed = SystemClock.elapsedRealtime()
|
||||||
|
candidate = decide(round.foreignOks, round.domesticOk)
|
||||||
|
}
|
||||||
|
val foreignLog = round.foreignOks.mapIndexed { index, ok ->
|
||||||
|
"${targets.getOrNull(index)?.host ?: index}=${ok ?: "?"}/${round.foreignRttMs.getOrNull(index) ?: "-"}ms"
|
||||||
|
}
|
||||||
|
Log.i(TAG, "Direct round: ${foreignLog.joinToString(" ")}, " +
|
||||||
|
"domestic=${round.domesticOk}/${round.domesticRttMs ?: "-"}ms -> $candidate")
|
||||||
|
val before = machine
|
||||||
|
machine = before.advance(candidate)
|
||||||
|
publish(round)
|
||||||
|
if (before.status != WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.ACTIVE)
|
||||||
|
{
|
||||||
|
Events.whitelist(context, true)
|
||||||
|
android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, true); org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context) }
|
||||||
|
}
|
||||||
|
else if (before.status == WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.INACTIVE)
|
||||||
|
{
|
||||||
|
Events.whitelist(context, false)
|
||||||
|
android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, false); org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context) }
|
||||||
|
}
|
||||||
|
if (machine.status == WhiteListStatus.ACTIVE)
|
||||||
|
{ /* keep watching for the whitelist to clear, at a low rate */
|
||||||
|
backoffAttempt++
|
||||||
|
scheduleRecheck(nextBackoffMs(backoffAttempt))
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
backoffAttempt = 0
|
||||||
|
recheckJob?.cancel()
|
||||||
|
recheckJob = null
|
||||||
|
}
|
||||||
|
if (before.status == WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.INACTIVE)
|
||||||
|
{ /* the ISP cleared the whitelist; let the automation reconsider connecting */
|
||||||
|
Log.i(TAG, "Whitelist cleared, re-evaluating auto connect")
|
||||||
|
handler.post {
|
||||||
|
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).reevaluate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
cycleMutex.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleRecheck(delayMs: Long)
|
||||||
|
{
|
||||||
|
recheckJob?.cancel()
|
||||||
|
recheckJob = scope.launch {
|
||||||
|
delay(delayMs)
|
||||||
|
/* forced: the first backoff steps are shorter than FRESH_MS, an
|
||||||
|
* unforced recheck returned early and ended the chain */
|
||||||
|
runCheck(force = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish(round: WhiteListRound? = null)
|
||||||
|
{
|
||||||
|
val previous = _state.value
|
||||||
|
_state.value = WhiteListState(
|
||||||
|
config = config,
|
||||||
|
status = machine.status,
|
||||||
|
foreignDirectOk = round?.foreignDirectOk,
|
||||||
|
domesticDirectOk = round?.domesticOk,
|
||||||
|
foreignDirectRttMs = round?.foreignDirectRttMs,
|
||||||
|
domesticDirectRttMs = round?.domesticRttMs,
|
||||||
|
lastCheckMs = if (round != null || previous.lastCheckMs == 0L) System.currentTimeMillis() else previous.lastCheckMs,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerNetworkCallback()
|
||||||
|
{
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
|
||||||
|
val request = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||||
|
.build()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cm.registerNetworkCallback(request, networkCallback, handler)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Failed to register network callback", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterNetworkCallback()
|
||||||
|
{
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cm.unregisterNetworkCallback(networkCallback)
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadConfig(): WhiteListConfig
|
||||||
|
{
|
||||||
|
val json = prefs.getString(KEY_CONFIG, null) ?: return WhiteListConfig()
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
val targets = obj.optJSONArray("foreignTargets")?.let { arr ->
|
||||||
|
(0 until arr.length()).mapNotNull { ProbeEndpoint.parse(arr.optString(it))?.serialize() }
|
||||||
|
}.orEmpty()
|
||||||
|
WhiteListConfig(enabled = obj.optBoolean("enabled", false), foreignTargets = targets)
|
||||||
|
}
|
||||||
|
catch (_: JSONException)
|
||||||
|
{
|
||||||
|
WhiteListConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveConfig(config: WhiteListConfig)
|
||||||
|
{
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("enabled", config.enabled)
|
||||||
|
/* custom targets are stored only when set, so code updates to the
|
||||||
|
* built-in defaults keep reaching existing installs */
|
||||||
|
if (config.foreignTargets.isNotEmpty())
|
||||||
|
{
|
||||||
|
val arr = JSONArray()
|
||||||
|
config.foreignTargets.forEach { arr.put(it) }
|
||||||
|
obj.put("foreignTargets", arr)
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks the reported status with hysteresis: leaving UNKNOWN needs a
|
||||||
|
* single confirming round, flipping between ACTIVE and INACTIVE needs
|
||||||
|
* [CONFIRMATIONS] consecutive rounds so one dropped probe does not flap
|
||||||
|
* the status. (The manager additionally confirms a first ACTIVE verdict
|
||||||
|
* with a second probe round before it ever reaches this machine.)
|
||||||
|
*/
|
||||||
|
data class StatusMachine(
|
||||||
|
val status: WhiteListStatus = WhiteListStatus.UNKNOWN,
|
||||||
|
val activeStreak: Int = 0,
|
||||||
|
val inactiveStreak: Int = 0,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
fun advance(candidate: WhiteListStatus): StatusMachine
|
||||||
|
{
|
||||||
|
val activeStreak = if (candidate == WhiteListStatus.ACTIVE) this.activeStreak + 1 else 0
|
||||||
|
val inactiveStreak = if (candidate == WhiteListStatus.INACTIVE) this.inactiveStreak + 1 else 0
|
||||||
|
val confirmations = if (status == WhiteListStatus.UNKNOWN) 1 else CONFIRMATIONS_REQUIRED
|
||||||
|
val status = when
|
||||||
|
{
|
||||||
|
this.status != WhiteListStatus.ACTIVE && activeStreak >= confirmations -> WhiteListStatus.ACTIVE
|
||||||
|
this.status == WhiteListStatus.ACTIVE && inactiveStreak >= confirmations -> WhiteListStatus.INACTIVE
|
||||||
|
else -> this.status
|
||||||
|
}
|
||||||
|
return StatusMachine(status, activeStreak, inactiveStreak)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
private const val TAG = "WhiteListManager"
|
||||||
|
private const val PREFS_NAME = "one.shx.strongswan.ext.WHITE_LIST"
|
||||||
|
private const val KEY_CONFIG = "config"
|
||||||
|
|
||||||
|
/** Rounds required to flip between ACTIVE and INACTIVE. */
|
||||||
|
const val CONFIRMATIONS_REQUIRED = 2
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Foreign endpoints probed directly over TCP/443, so port-53 filtering
|
||||||
|
* cannot fake a verdict: Quad9 and OpenDNS DNS-over-HTTPS plus GitHub.
|
||||||
|
* All are normally reachable from Russian networks but get cut off by
|
||||||
|
* whitelist-style blocking. 8.8.8.8 and 1.1.1.1 are NOT usable here:
|
||||||
|
* both are blocked in RF even without any whitelisting.
|
||||||
|
*/
|
||||||
|
val DEFAULT_FOREIGN_TARGETS = listOf(
|
||||||
|
ProbeEndpoint("9.9.9.9", 443),
|
||||||
|
ProbeEndpoint("208.67.222.222", 443),
|
||||||
|
ProbeEndpoint("140.82.121.4", 443),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A domestic endpoint whitelists keep reachable (Yandex DNS), probed
|
||||||
|
* by IP; distinguishes "foreign blocked" from "no network at all".
|
||||||
|
*/
|
||||||
|
val DOMESTIC_TARGET = ProbeEndpoint("77.88.8.8", 53)
|
||||||
|
|
||||||
|
private const val PROBE_TIMEOUT_MS = 3000
|
||||||
|
|
||||||
|
/** A failed foreign probe is retried once before it counts as blocked. */
|
||||||
|
private const val PROBE_RETRY_DELAY_MS = 700L
|
||||||
|
|
||||||
|
/** A first ACTIVE verdict is re-probed after this delay to confirm. */
|
||||||
|
private const val ACTIVE_CONFIRM_DELAY_MS = 2000L
|
||||||
|
|
||||||
|
/** Cached results younger than this are not re-probed. */
|
||||||
|
private const val FRESH_MS = 60_000L
|
||||||
|
|
||||||
|
/** State transitions force a new round only if the last one is at least this old. */
|
||||||
|
private const val TRANSITION_MIN_GAP_MS = 15_000L
|
||||||
|
|
||||||
|
/** The parts of the capabilities that matter for the classification. */
|
||||||
|
@JvmStatic
|
||||||
|
internal fun capabilityKey(caps: NetworkCapabilities): String
|
||||||
|
{
|
||||||
|
val transports = intArrayOf(NetworkCapabilities.TRANSPORT_WIFI, NetworkCapabilities.TRANSPORT_CELLULAR,
|
||||||
|
NetworkCapabilities.TRANSPORT_ETHERNET, NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
.filter { caps.hasTransport(it) }
|
||||||
|
val capabilities = intArrayOf(NetworkCapabilities.NET_CAPABILITY_INTERNET,
|
||||||
|
NetworkCapabilities.NET_CAPABILITY_VALIDATED, NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||||
|
.filter { caps.hasCapability(it) }
|
||||||
|
return "$transports/$capabilities"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cached ACTIVE status stops suppressing reconnects once no recheck
|
||||||
|
* has confirmed it for this long (fail open). Kept above the backoff
|
||||||
|
* cap plus the probe duration so the scheduled rechecks keep the
|
||||||
|
* verdict fresh without gaps.
|
||||||
|
*/
|
||||||
|
private const val ACTIVE_TTL_MS = 7 * 60_000L
|
||||||
|
|
||||||
|
private val BACKOFF_STEPS_MS = longArrayOf(15_000L, 30_000L, 60_000L, 120_000L, 300_000L)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure classification of one probe round: whitelisting means every
|
||||||
|
* foreign host failed (each after a retry) while the domestic one
|
||||||
|
* answered. One reachable foreign host disproves it, anything else
|
||||||
|
* (no network, domestic failure) is inconclusive.
|
||||||
|
*/
|
||||||
|
fun decide(foreignOks: List<Boolean?>, domesticOk: Boolean?): WhiteListStatus = when
|
||||||
|
{
|
||||||
|
foreignOks.isEmpty() -> WhiteListStatus.UNKNOWN
|
||||||
|
foreignOks.any { it == true } -> WhiteListStatus.INACTIVE
|
||||||
|
domesticOk == true && foreignOks.all { it == false } -> WhiteListStatus.ACTIVE
|
||||||
|
else -> WhiteListStatus.UNKNOWN
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Capped exponential backoff for rechecks while the whitelist stays active. */
|
||||||
|
fun nextBackoffMs(attempt: Int): Long =
|
||||||
|
BACKOFF_STEPS_MS[(attempt - 1).coerceIn(0, BACKOFF_STEPS_MS.lastIndex)]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probes all configured foreign targets and the domestic one
|
||||||
|
* concurrently, directly on the underlying network: the sockets are
|
||||||
|
* created from [Network.socketFactory], so the tunnel is never used.
|
||||||
|
* Foreign probes that fail are retried once. All results are null
|
||||||
|
* when there is no usable network.
|
||||||
|
*/
|
||||||
|
suspend fun probeRound(context: Context, foreignTargets: List<ProbeEndpoint> = DEFAULT_FOREIGN_TARGETS): WhiteListRound
|
||||||
|
{
|
||||||
|
val network = UnderlyingNetwork.resolve(context)
|
||||||
|
?: return WhiteListRound(
|
||||||
|
foreignOks = List(foreignTargets.size) { null },
|
||||||
|
foreignRttMs = List(foreignTargets.size) { null },
|
||||||
|
domesticOk = null,
|
||||||
|
domesticRttMs = null,
|
||||||
|
)
|
||||||
|
return coroutineScope {
|
||||||
|
val foreign = foreignTargets.map { async { probeDirect(network, it) } }
|
||||||
|
val domestic = async { probeDirect(network, DOMESTIC_TARGET) }
|
||||||
|
var foreignResults = foreign.awaitAll()
|
||||||
|
if (foreignResults.any { !it.first })
|
||||||
|
{ /* a single lost packet must not look like blocking */
|
||||||
|
foreignResults = foreignResults.mapIndexed { index, result ->
|
||||||
|
async {
|
||||||
|
if (result.first)
|
||||||
|
{
|
||||||
|
result
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
delay(PROBE_RETRY_DELAY_MS)
|
||||||
|
probeDirect(network, foreignTargets[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
val domesticResult = domestic.await()
|
||||||
|
WhiteListRound(
|
||||||
|
foreignOks = foreignResults.map { it.first },
|
||||||
|
foreignRttMs = foreignResults.map { it.second },
|
||||||
|
domesticOk = domesticResult.first,
|
||||||
|
domesticRttMs = domesticResult.second,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun probeDirect(network: Network, target: ProbeEndpoint): Pair<Boolean, Long?>
|
||||||
|
{ /* the target tag is result metadata only, unused for direct probes */
|
||||||
|
val result = ConnectivityProbe.tcpPing(ProbeTarget.GOOGLE, target.host, target.port, PROBE_TIMEOUT_MS, network)
|
||||||
|
return result.ok to result.rttMs
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: WhiteListManager? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(context: Context): WhiteListManager =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: WhiteListManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+438
@@ -0,0 +1,438 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.events
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.ProcessLifecycleOwner
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.InboxActivity
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/** How an event looks and whether it waits for the user. */
|
||||||
|
enum class EventKind
|
||||||
|
{
|
||||||
|
/** something is wrong right now (pushed on the "problems" channel) */
|
||||||
|
WARN,
|
||||||
|
/** the app suggests something and waits for a decision */
|
||||||
|
ASK,
|
||||||
|
/** the app did something by itself */
|
||||||
|
INFO,
|
||||||
|
/** something got better */
|
||||||
|
OK,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class EventCategory
|
||||||
|
{
|
||||||
|
CONNECTION,
|
||||||
|
ROUTING,
|
||||||
|
APP,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the event's button does; the Inbox maps these to screens. */
|
||||||
|
enum class EventAction
|
||||||
|
{
|
||||||
|
NONE,
|
||||||
|
OPEN_HOME,
|
||||||
|
OPEN_ROUTING,
|
||||||
|
OPEN_ROUTE_CHECK,
|
||||||
|
OPEN_LOG,
|
||||||
|
INSTALL_UPDATE,
|
||||||
|
/** let the app in [ShxEvent.target] bypass the VPN */
|
||||||
|
SET_APP_DIRECT,
|
||||||
|
/** show the link update of the profile [ShxEvent.target] (F_ProfileUpdate) */
|
||||||
|
PROFILE_UPDATE,
|
||||||
|
/** open the server page of the profile [ShxEvent.target] */
|
||||||
|
OPEN_SERVER,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ShxEvent(
|
||||||
|
val id: Long,
|
||||||
|
val at: Long,
|
||||||
|
val kind: EventKind,
|
||||||
|
val category: EventCategory,
|
||||||
|
val title: String,
|
||||||
|
val text: String,
|
||||||
|
val action: EventAction = EventAction.NONE,
|
||||||
|
/** groups repeats of the same event; null for one-offs */
|
||||||
|
val key: String? = null,
|
||||||
|
/** how many times it happened within the merge window */
|
||||||
|
val count: Int = 1,
|
||||||
|
val read: Boolean = false,
|
||||||
|
/** the problem went away by itself; shown dimmed, no longer "waiting" */
|
||||||
|
val resolved: Boolean = false,
|
||||||
|
/** what the action applies to, e.g. a package name */
|
||||||
|
val target: String? = null,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
/** still needs the user's attention */
|
||||||
|
val waiting: Boolean
|
||||||
|
get() = !resolved && (kind == EventKind.WARN || kind == EventKind.ASK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the app did and noticed by itself, kept for [RETENTION_MS] on the
|
||||||
|
* device. Problems are additionally pushed while the app is in the
|
||||||
|
* background, so the user learns about them without opening it.
|
||||||
|
*/
|
||||||
|
class EventLog private constructor(private val context: Context)
|
||||||
|
{
|
||||||
|
private val file = File(context.filesDir, FILE_NAME)
|
||||||
|
private val io = Executors.newSingleThreadExecutor { r -> Thread(r, "EventLog").apply { isDaemon = true } }
|
||||||
|
private val _events = MutableStateFlow(load())
|
||||||
|
val events: StateFlow<List<ShxEvent>> = _events
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds an event. A repeat of an unresolved event with the same [key]
|
||||||
|
* within [MERGE_WINDOW_MS] bumps the existing entry instead of adding a
|
||||||
|
* new one (retries would otherwise flood the list).
|
||||||
|
*/
|
||||||
|
fun record(
|
||||||
|
kind: EventKind,
|
||||||
|
category: EventCategory,
|
||||||
|
title: String,
|
||||||
|
text: String,
|
||||||
|
action: EventAction = EventAction.NONE,
|
||||||
|
key: String? = null,
|
||||||
|
target: String? = null,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
var posted: ShxEvent? = null
|
||||||
|
_events.update { list ->
|
||||||
|
val merged = key?.let { k ->
|
||||||
|
list.indexOfFirst { it.key == k && !it.resolved && now - it.at < MERGE_WINDOW_MS }
|
||||||
|
} ?: -1
|
||||||
|
val next = if (merged >= 0)
|
||||||
|
{
|
||||||
|
val old = list[merged]
|
||||||
|
val bumped = old.copy(at = now, title = title, text = text, action = action, target = target, count = old.count + 1, read = false)
|
||||||
|
posted = bumped
|
||||||
|
listOf(bumped) + list.filterIndexed { i, _ -> i != merged }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
val event = ShxEvent(
|
||||||
|
id = now * 1000 + (list.size % 1000), at = now, kind = kind, category = category,
|
||||||
|
title = title, text = text, action = action, key = key, target = target,
|
||||||
|
)
|
||||||
|
posted = event
|
||||||
|
listOf(event) + list
|
||||||
|
}
|
||||||
|
prune(next, now)
|
||||||
|
}
|
||||||
|
persist()
|
||||||
|
posted?.let {
|
||||||
|
when
|
||||||
|
{
|
||||||
|
it.kind == EventKind.WARN && NotifPrefs.enabled(context, NotifPrefs.PROBLEMS) -> pushIfBackground(it, PROBLEMS_CHANNEL)
|
||||||
|
it.kind == EventKind.ASK && NotifPrefs.enabled(context, NotifPrefs.NEW_APPS) -> pushIfBackground(it, HINTS_CHANNEL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Records once per [key] for the whole retention time (e.g. one update notice per version). */
|
||||||
|
fun recordOnce(kind: EventKind, category: EventCategory, title: String, text: String, action: EventAction, key: String, target: String? = null)
|
||||||
|
{
|
||||||
|
if (_events.value.none { it.key == key })
|
||||||
|
{
|
||||||
|
record(kind, category, title, text, action, key, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user answered an event (did the action or said no): it no longer waits. */
|
||||||
|
fun answer(id: Long)
|
||||||
|
{
|
||||||
|
_events.update { list -> list.map { if (it.id == id) it.copy(resolved = true, read = true) else it } }
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the unresolved events whose key starts with [keyPrefix] as resolved. */
|
||||||
|
fun resolve(keyPrefix: String)
|
||||||
|
{
|
||||||
|
var changed = false
|
||||||
|
_events.update { list ->
|
||||||
|
list.map {
|
||||||
|
if (!it.resolved && it.key?.startsWith(keyPrefix) == true)
|
||||||
|
{
|
||||||
|
changed = true
|
||||||
|
NotificationManagerCompat.from(context).cancel(TAG, notificationId(it))
|
||||||
|
it.copy(resolved = true)
|
||||||
|
}
|
||||||
|
else it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markAllRead()
|
||||||
|
{
|
||||||
|
if (_events.value.none { !it.read })
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
_events.value.filter { !it.read }.forEach { manager.cancel(TAG, notificationId(it)) }
|
||||||
|
_events.update { list -> list.map { if (it.read) it else it.copy(read = true) } }
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hides an event the user acted on or dismissed. */
|
||||||
|
fun dismiss(id: Long)
|
||||||
|
{
|
||||||
|
_events.update { list -> list.filterNot { it.id == id } }
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pushIfBackground(event: ShxEvent, channel: String)
|
||||||
|
{
|
||||||
|
val foreground = try
|
||||||
|
{
|
||||||
|
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
if (foreground)
|
||||||
|
{ /* the Home screen shows the problem already */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
if (!manager.areNotificationsEnabled())
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ensureChannel(context)
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
context, 0, Intent(context, InboxActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val builder = NotificationCompat.Builder(context, channel)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(event.title)
|
||||||
|
.setContentText(event.text)
|
||||||
|
.setStyle(NotificationCompat.BigTextStyle().bigText(event.text))
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
/* F_SysNotifs: answers right in the notification */
|
||||||
|
if (event.action == EventAction.SET_APP_DIRECT)
|
||||||
|
{
|
||||||
|
builder.addAction(0, context.getString(R.string.notif_yes), answerIntent(event, true))
|
||||||
|
builder.addAction(0, context.getString(R.string.notif_no), answerIntent(event, false))
|
||||||
|
}
|
||||||
|
else if (event.kind == EventKind.WARN)
|
||||||
|
{
|
||||||
|
builder.addAction(0, context.getString(R.string.notif_details), open)
|
||||||
|
}
|
||||||
|
val notification = builder.build()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
manager.notify(TAG, notificationId(event), notification)
|
||||||
|
}
|
||||||
|
catch (e: SecurityException)
|
||||||
|
{ /* POST_NOTIFICATIONS was revoked in between */
|
||||||
|
Log.w(TAG, "Problem notification not allowed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun answerIntent(event: ShxEvent, yes: Boolean): PendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context, (event.id % 100000).toInt() * 2 + if (yes) 1 else 0,
|
||||||
|
Intent(context, EventAnswerReceiver::class.java).putExtra(EXTRA_ID, event.id).putExtra(EXTRA_YES, yes),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** «Yes» / «No» of a suggestion pushed as a notification. */
|
||||||
|
internal fun answerFromNotification(id: Long, yes: Boolean)
|
||||||
|
{
|
||||||
|
val event = _events.value.firstOrNull { it.id == id } ?: return
|
||||||
|
if (yes && event.action == EventAction.SET_APP_DIRECT)
|
||||||
|
{
|
||||||
|
event.target?.let { org.strongswan.android.logic.routing.RoutingManager.getInstance(context).setAppDirect(it, true) }
|
||||||
|
}
|
||||||
|
answer(id)
|
||||||
|
NotificationManagerCompat.from(context).cancel(TAG, notificationId(event))
|
||||||
|
}
|
||||||
|
|
||||||
|
/* repeats of a keyed event replace their notification instead of stacking */
|
||||||
|
private fun notificationId(event: ShxEvent): Int = (event.key ?: event.id.toString()).hashCode()
|
||||||
|
|
||||||
|
private fun prune(list: List<ShxEvent>, now: Long): List<ShxEvent> =
|
||||||
|
list.filter { now - it.at < RETENTION_MS }.take(MAX_EVENTS)
|
||||||
|
|
||||||
|
private fun persist()
|
||||||
|
{
|
||||||
|
val snapshot = _events.value
|
||||||
|
io.execute {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val array = JSONArray()
|
||||||
|
snapshot.forEach { array.put(toJson(it)) }
|
||||||
|
val tmp = File(file.path + ".tmp")
|
||||||
|
tmp.writeText(array.toString())
|
||||||
|
if (!tmp.renameTo(file))
|
||||||
|
{
|
||||||
|
file.writeText(array.toString())
|
||||||
|
tmp.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Saving events failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(): List<ShxEvent>
|
||||||
|
{
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
if (!file.exists())
|
||||||
|
{
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val array = JSONArray(file.readText())
|
||||||
|
val list = (0 until array.length()).mapNotNull { fromJson(array.optJSONObject(it)) }
|
||||||
|
prune(list, System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Loading events failed", e)
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
private const val TAG = "EventLog"
|
||||||
|
private const val FILE_NAME = "events.json"
|
||||||
|
const val PROBLEMS_CHANNEL = "org.strongswan.android.problems"
|
||||||
|
const val HINTS_CHANNEL = "org.strongswan.android.hints"
|
||||||
|
internal const val EXTRA_ID = "org.strongswan.android.extra.EVENT_ID"
|
||||||
|
internal const val EXTRA_YES = "org.strongswan.android.extra.EVENT_YES"
|
||||||
|
const val RETENTION_MS = 30L * 24 * 60 * 60 * 1000
|
||||||
|
const val MERGE_WINDOW_MS = 30L * 60 * 1000
|
||||||
|
private const val MAX_EVENTS = 300
|
||||||
|
|
||||||
|
@Volatile private var instance: EventLog? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(context: Context): EventLog =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: EventLog(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun ensureChannel(context: Context)
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
{
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
PROBLEMS_CHANNEL, context.getString(R.string.events_channel_problems),
|
||||||
|
NotificationManager.IMPORTANCE_DEFAULT,
|
||||||
|
)
|
||||||
|
channel.description = context.getString(R.string.events_channel_problems_desc)
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
/* suggestions arrive quietly (F_NotifSettings «Quiet») */
|
||||||
|
val hints = NotificationChannel(
|
||||||
|
HINTS_CHANNEL, context.getString(R.string.events_channel_hints),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
)
|
||||||
|
hints.description = context.getString(R.string.events_channel_hints_desc)
|
||||||
|
manager.createNotificationChannel(hints)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun toJson(e: ShxEvent): JSONObject = JSONObject()
|
||||||
|
.put("id", e.id).put("at", e.at).put("kind", e.kind.name).put("category", e.category.name)
|
||||||
|
.put("title", e.title).put("text", e.text).put("action", e.action.name)
|
||||||
|
.put("key", e.key ?: JSONObject.NULL).put("count", e.count).put("read", e.read).put("resolved", e.resolved)
|
||||||
|
.put("target", e.target ?: JSONObject.NULL)
|
||||||
|
|
||||||
|
internal fun fromJson(o: JSONObject?): ShxEvent?
|
||||||
|
{
|
||||||
|
o ?: return null
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
ShxEvent(
|
||||||
|
id = o.getLong("id"),
|
||||||
|
at = o.getLong("at"),
|
||||||
|
kind = EventKind.valueOf(o.getString("kind")),
|
||||||
|
category = EventCategory.valueOf(o.getString("category")),
|
||||||
|
title = o.getString("title"),
|
||||||
|
text = o.optString("text"),
|
||||||
|
action = runCatching { EventAction.valueOf(o.optString("action")) }.getOrDefault(EventAction.NONE),
|
||||||
|
key = if (o.isNull("key")) null else o.optString("key"),
|
||||||
|
count = o.optInt("count", 1),
|
||||||
|
read = o.optBoolean("read"),
|
||||||
|
resolved = o.optBoolean("resolved"),
|
||||||
|
target = if (o.isNull("target")) null else o.optString("target"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{ /* an entry from an older/newer format: skip it rather than lose all */
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which events are also pushed (F_NotifSettings); all of them stay in «Events» anyway. */
|
||||||
|
object NotifPrefs
|
||||||
|
{
|
||||||
|
const val PROBLEMS = "pref_notif_problems"
|
||||||
|
const val NEW_APPS = "pref_notif_new_apps"
|
||||||
|
const val UPDATES = "pref_notif_updates"
|
||||||
|
/** «Weekly summary», the only one off by default */
|
||||||
|
const val WEEKLY = "pref_notif_weekly"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun enabled(context: Context, key: String): Boolean =
|
||||||
|
androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, key != WEEKLY)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun set(context: Context, key: String, value: Boolean) =
|
||||||
|
androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Receives «Yes» / «No» from a suggestion notification. */
|
||||||
|
class EventAnswerReceiver : android.content.BroadcastReceiver()
|
||||||
|
{
|
||||||
|
override fun onReceive(context: Context, intent: Intent)
|
||||||
|
{
|
||||||
|
val id = intent.getLongExtra(EventLog.EXTRA_ID, 0L)
|
||||||
|
EventLog.getInstance(context).answerFromNotification(id, intent.getBooleanExtra(EventLog.EXTRA_YES, false))
|
||||||
|
}
|
||||||
|
}
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.events
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.VpnStateService.ErrorState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The events the app records, one function per occasion, so the call sites
|
||||||
|
* in the service code stay one-liners.
|
||||||
|
*/
|
||||||
|
object Events
|
||||||
|
{
|
||||||
|
private const val KEY_ERROR = "error:"
|
||||||
|
private const val KEY_WHITELIST = "whitelist"
|
||||||
|
|
||||||
|
/** The connection failed; repeats while retrying are merged. */
|
||||||
|
@JvmStatic
|
||||||
|
fun connectionError(context: Context, error: ErrorState)
|
||||||
|
{
|
||||||
|
val (title, text, action) = when (error)
|
||||||
|
{
|
||||||
|
ErrorState.UNREACHABLE -> Triple(R.string.home_problem_unreachable_title, R.string.home_problem_unreachable_text, EventAction.OPEN_HOME)
|
||||||
|
ErrorState.LOOKUP_FAILED -> Triple(R.string.home_problem_lookup_title, R.string.home_problem_lookup_text, EventAction.OPEN_HOME)
|
||||||
|
ErrorState.AUTH_FAILED, ErrorState.PASSWORD_MISSING ->
|
||||||
|
Triple(R.string.home_problem_auth_title, R.string.home_problem_auth_text, EventAction.OPEN_HOME)
|
||||||
|
ErrorState.PEER_AUTH_FAILED, ErrorState.CERTIFICATE_UNAVAILABLE ->
|
||||||
|
Triple(R.string.home_problem_cert_title, R.string.home_problem_cert_text, EventAction.OPEN_LOG)
|
||||||
|
ErrorState.NO_ERROR -> return
|
||||||
|
else -> Triple(R.string.home_problem_generic_title, R.string.home_problem_generic_text, EventAction.OPEN_LOG)
|
||||||
|
}
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.WARN, EventCategory.CONNECTION, context.getString(title), context.getString(text),
|
||||||
|
action, KEY_ERROR + error.name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The tunnel is up again after a recorded failure. */
|
||||||
|
@JvmStatic
|
||||||
|
fun connectionRestored(context: Context)
|
||||||
|
{
|
||||||
|
val log = EventLog.getInstance(context)
|
||||||
|
if (log.events.value.none { !it.resolved && it.key?.startsWith(KEY_ERROR) == true })
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.resolve(KEY_ERROR)
|
||||||
|
log.record(
|
||||||
|
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_restored_title),
|
||||||
|
context.getString(R.string.event_restored_text), key = "restored",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user stopped the VPN: pending connection problems no longer matter. */
|
||||||
|
@JvmStatic
|
||||||
|
fun connectionStopped(context: Context)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).resolve(KEY_ERROR)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun whitelist(context: Context, active: Boolean)
|
||||||
|
{
|
||||||
|
val log = EventLog.getInstance(context)
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
log.record(
|
||||||
|
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_whitelist_on_title),
|
||||||
|
context.getString(R.string.event_whitelist_on_text), EventAction.OPEN_HOME, KEY_WHITELIST,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log.resolve(KEY_WHITELIST)
|
||||||
|
log.record(
|
||||||
|
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_whitelist_off_title),
|
||||||
|
context.getString(R.string.event_whitelist_off_text), key = "whitelist-off",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The health check restarted the tunnel. */
|
||||||
|
@JvmStatic
|
||||||
|
fun healthReconnect(context: Context, serverDown: Boolean)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_health_reconnect_title),
|
||||||
|
context.getString(if (serverDown) R.string.event_health_reconnect_server else R.string.event_health_reconnect_tunnel),
|
||||||
|
key = "health-reconnect",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Auto-connect started the tunnel ([parked] = false) or parked it on a trusted Wi-Fi. */
|
||||||
|
@JvmStatic
|
||||||
|
fun autoConnect(context: Context, parked: Boolean)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.INFO, EventCategory.CONNECTION,
|
||||||
|
context.getString(if (parked) R.string.event_auto_parked_title else R.string.event_auto_started_title),
|
||||||
|
context.getString(if (parked) R.string.event_auto_parked_text else R.string.event_auto_started_text),
|
||||||
|
key = if (parked) "auto-parked" else "auto-started",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A newly installed app looks like a Russian service that is not direct yet. */
|
||||||
|
@JvmStatic
|
||||||
|
fun newRuApp(context: Context, packageName: String, label: String)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).recordOnce(
|
||||||
|
EventKind.ASK, EventCategory.ROUTING, context.getString(R.string.event_new_app_title, label),
|
||||||
|
context.getString(R.string.event_new_app_text), EventAction.SET_APP_DIRECT, "new-app:$packageName", packageName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun pauseOver(context: Context)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_pause_over_title),
|
||||||
|
context.getString(R.string.event_pause_over_text),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun updateAvailable(context: Context, versionName: String, versionCode: Long)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).recordOnce(
|
||||||
|
EventKind.INFO, EventCategory.APP, context.getString(R.string.event_update_title, versionName),
|
||||||
|
context.getString(R.string.event_update_text), EventAction.INSTALL_UPDATE, "update:$versionCode",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A report went to the panel: the code stays here so the user can find it later. */
|
||||||
|
@JvmStatic
|
||||||
|
fun reportSent(context: Context, code: String)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.OK, EventCategory.APP, context.getString(R.string.report_event_title, code),
|
||||||
|
context.getString(R.string.report_event_text), key = "report:$code",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The watchdog did not bring back [name]: a password would be needed. */
|
||||||
|
@JvmStatic
|
||||||
|
fun restoreNeedsPassword(context: Context, uuid: String, name: String)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_restore_pw_title, name),
|
||||||
|
context.getString(R.string.event_restore_pw_text), EventAction.OPEN_SERVER, "restore-pw:$uuid", uuid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reserve or bridge [target] of [origin] has no saved password, so no switch. */
|
||||||
|
@JvmStatic
|
||||||
|
fun switchNeedsPassword(context: Context, targetUuid: String, target: String, origin: String, bridge: Boolean)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_switch_pw_title, target),
|
||||||
|
context.getString(if (bridge) R.string.event_switch_pw_bridge else R.string.event_switch_pw_reserve, origin),
|
||||||
|
EventAction.OPEN_SERVER, "switch-pw:$targetUuid", targetUuid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A network rule or a scenario could not start [name]: no saved password. */
|
||||||
|
@JvmStatic
|
||||||
|
fun autoNeedsPassword(context: Context, uuid: String, name: String)
|
||||||
|
{
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_auto_pw_title, name),
|
||||||
|
context.getString(R.string.event_auto_pw_text), EventAction.OPEN_SERVER, "auto-pw:$uuid", uuid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+238
@@ -0,0 +1,238 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.profiles
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Where a profile came from (F_Profile «Profile source»). */
|
||||||
|
enum class ProfileSourceType { LINK, FILE, QR, MANUAL, UNKNOWN }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the app keeps about a profile besides the strongSwan profile itself:
|
||||||
|
* its source (and the update link), and the resilience settings of F_Profile.
|
||||||
|
*/
|
||||||
|
data class ProfileExtra(
|
||||||
|
val uuid: String,
|
||||||
|
val source: ProfileSourceType = ProfileSourceType.UNKNOWN,
|
||||||
|
/** https link for LINK, the file name for FILE */
|
||||||
|
val sourceValue: String? = null,
|
||||||
|
val addedAt: Long = 0L,
|
||||||
|
val autoUpdate: Boolean = true,
|
||||||
|
val checkedAt: Long = 0L,
|
||||||
|
/** number of changes found by the last check, -1 when it failed */
|
||||||
|
val lastChanges: Int = 0,
|
||||||
|
/** ports tried in turn when the main one does not answer */
|
||||||
|
val backupPorts: List<Int> = emptyList(),
|
||||||
|
/** profile used when this one does not connect in [RESERVE_TIMEOUT_S] */
|
||||||
|
val reserveUuid: String? = null,
|
||||||
|
/** profile switched to while the operator whitelists are on */
|
||||||
|
val bridgeUuid: String? = null,
|
||||||
|
val bridgeEnabled: Boolean = false,
|
||||||
|
/** IKE fragmentation (charon «fragmentation = yes»), on unless switched off */
|
||||||
|
val ikeFragmentation: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
const val RESERVE_TIMEOUT_S = 15
|
||||||
|
|
||||||
|
/** Store of [ProfileExtra] (profile_extras.json), observable. */
|
||||||
|
object ProfileExtras
|
||||||
|
{
|
||||||
|
private const val TAG = "ProfileExtras"
|
||||||
|
private const val FILE_NAME = "profile_extras.json"
|
||||||
|
|
||||||
|
private val _all = MutableStateFlow<Map<String, ProfileExtra>>(emptyMap())
|
||||||
|
val all: StateFlow<Map<String, ProfileExtra>> = _all
|
||||||
|
@Volatile private var loaded = false
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun get(context: Context, uuid: String): ProfileExtra
|
||||||
|
{
|
||||||
|
load(context)
|
||||||
|
return _all.value[uuid] ?: ProfileExtra(uuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flow(context: Context): StateFlow<Map<String, ProfileExtra>>
|
||||||
|
{
|
||||||
|
load(context)
|
||||||
|
return all
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun update(context: Context, uuid: String, transform: (ProfileExtra) -> ProfileExtra)
|
||||||
|
{
|
||||||
|
load(context)
|
||||||
|
_all.update { it + (uuid to transform(it[uuid] ?: ProfileExtra(uuid))) }
|
||||||
|
save(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A profile was imported: remember where from. A re-import from the
|
||||||
|
* same kind of source keeps the user's settings (auto update, resilience).
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun recordImport(context: Context, uuid: String, scheme: String?, uri: String?, qr: Boolean)
|
||||||
|
{
|
||||||
|
val type = when
|
||||||
|
{
|
||||||
|
qr -> ProfileSourceType.QR
|
||||||
|
scheme == "http" || scheme == "https" -> ProfileSourceType.LINK
|
||||||
|
else -> ProfileSourceType.FILE
|
||||||
|
}
|
||||||
|
val value = when (type)
|
||||||
|
{
|
||||||
|
ProfileSourceType.LINK -> uri
|
||||||
|
ProfileSourceType.FILE -> uri?.substringAfterLast('/')?.substringAfterLast("%2F")?.takeIf { it.isNotBlank() }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
update(context, uuid) {
|
||||||
|
/* a link stays the update source even when the new copy came from a file */
|
||||||
|
if (it.source == ProfileSourceType.LINK && type != ProfileSourceType.LINK) it.copy(addedAt = System.currentTimeMillis())
|
||||||
|
else it.copy(source = type, sourceValue = value, addedAt = System.currentTimeMillis(), checkedAt = if (type == ProfileSourceType.LINK) System.currentTimeMillis() else it.checkedAt, lastChanges = 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A profile created by hand (when nothing is recorded for it yet). */
|
||||||
|
@JvmStatic
|
||||||
|
fun recordManual(context: Context, uuid: String)
|
||||||
|
{
|
||||||
|
load(context)
|
||||||
|
if (_all.value[uuid]?.source.let { it == null || it == ProfileSourceType.UNKNOWN })
|
||||||
|
{
|
||||||
|
update(context, uuid) { it.copy(source = ProfileSourceType.MANUAL, addedAt = System.currentTimeMillis()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun remove(context: Context, uuid: String)
|
||||||
|
{
|
||||||
|
load(context)
|
||||||
|
_all.update { it - uuid }
|
||||||
|
save(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(context: Context)
|
||||||
|
{
|
||||||
|
if (loaded)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
synchronized(this)
|
||||||
|
{
|
||||||
|
if (loaded)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
if (file.exists())
|
||||||
|
{
|
||||||
|
val array = JSONArray(file.readText())
|
||||||
|
_all.value = (0 until array.length()).map { fromJson(array.getJSONObject(it)) }.associateBy { it.uuid }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Loading failed", e)
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(context: Context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val array = JSONArray()
|
||||||
|
_all.value.values.forEach { array.put(toJson(it)) }
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
val tmp = File(file.path + ".tmp")
|
||||||
|
tmp.writeText(array.toString())
|
||||||
|
if (!tmp.renameTo(file))
|
||||||
|
{
|
||||||
|
file.writeText(array.toString())
|
||||||
|
tmp.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Saving failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun toJson(e: ProfileExtra): JSONObject = JSONObject()
|
||||||
|
.put("uuid", e.uuid).put("source", e.source.name).put("value", e.sourceValue ?: JSONObject.NULL)
|
||||||
|
.put("added", e.addedAt).put("auto", e.autoUpdate).put("checked", e.checkedAt).put("changes", e.lastChanges)
|
||||||
|
.put("ports", JSONArray(e.backupPorts)).put("reserve", e.reserveUuid ?: JSONObject.NULL)
|
||||||
|
.put("bridge", e.bridgeUuid ?: JSONObject.NULL).put("bridgeOn", e.bridgeEnabled).put("frag", e.ikeFragmentation)
|
||||||
|
|
||||||
|
internal fun fromJson(o: JSONObject): ProfileExtra
|
||||||
|
{
|
||||||
|
val ports = o.optJSONArray("ports")
|
||||||
|
return ProfileExtra(
|
||||||
|
uuid = o.getString("uuid"),
|
||||||
|
source = runCatching { ProfileSourceType.valueOf(o.optString("source")) }.getOrDefault(ProfileSourceType.UNKNOWN),
|
||||||
|
sourceValue = o.optString("value").takeIf { !o.isNull("value") && it.isNotEmpty() },
|
||||||
|
addedAt = o.optLong("added"),
|
||||||
|
autoUpdate = o.optBoolean("auto", true),
|
||||||
|
checkedAt = o.optLong("checked"),
|
||||||
|
lastChanges = o.optInt("changes"),
|
||||||
|
backupPorts = if (ports == null) emptyList() else (0 until ports.length()).map { ports.getInt(it) },
|
||||||
|
reserveUuid = o.optString("reserve").takeIf { !o.isNull("reserve") && it.isNotEmpty() },
|
||||||
|
bridgeUuid = o.optString("bridge").takeIf { !o.isNull("bridge") && it.isNotEmpty() },
|
||||||
|
bridgeEnabled = o.optBoolean("bridgeOn"),
|
||||||
|
ikeFragmentation = o.optBoolean("frag", true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The profile signs in with a password and none is saved: nobody can start it unattended. */
|
||||||
|
fun org.strongswan.android.data.VpnProfile.needsPassword(): Boolean =
|
||||||
|
vpnType.has(org.strongswan.android.data.VpnType.VpnTypeFeature.USER_PASS) && password.isNullOrEmpty()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For unattended starts (network rules, scenarios): true when [uuid] signs in
|
||||||
|
* with an unsaved password - the start would only end in «password missing».
|
||||||
|
* Records one event pointing at the server.
|
||||||
|
*/
|
||||||
|
fun blockedByPassword(context: android.content.Context, uuid: String): Boolean
|
||||||
|
{
|
||||||
|
val profile = runCatching {
|
||||||
|
val source = org.strongswan.android.data.VpnProfileSource(context)
|
||||||
|
source.open()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
source.getVpnProfile(java.util.UUID.fromString(uuid))
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
}.getOrNull() ?: return false
|
||||||
|
if (!profile.needsPassword())
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
org.strongswan.android.logic.DiagLog.i("ProfileExtras", "${profile.name} needs a password, no unattended start")
|
||||||
|
org.strongswan.android.logic.events.Events.autoNeedsPassword(context, uuid, profile.name ?: profile.gateway)
|
||||||
|
return true
|
||||||
|
}
|
||||||
+292
@@ -0,0 +1,292 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.profiles
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.data.VpnProfile
|
||||||
|
import org.strongswan.android.data.VpnProfileSource
|
||||||
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import org.strongswan.android.logic.TrustedCertificateManager
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.logic.events.EventAction
|
||||||
|
import org.strongswan.android.logic.events.EventCategory
|
||||||
|
import org.strongswan.android.logic.events.EventKind
|
||||||
|
import org.strongswan.android.logic.events.EventLog
|
||||||
|
import org.strongswan.android.utils.Constants
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** One changed field (F_ProfileUpdate): label, old and new value as shown. */
|
||||||
|
data class ProfileChange(val label: String, val old: String, val new: String)
|
||||||
|
|
||||||
|
/** The server's copy of a profile and how it differs from the local one. */
|
||||||
|
class ProfileCheck(
|
||||||
|
val uuid: String,
|
||||||
|
val changes: List<ProfileChange>,
|
||||||
|
internal val gateway: String,
|
||||||
|
internal val port: Int?,
|
||||||
|
internal val remoteId: String?,
|
||||||
|
internal val certificate: X509Certificate?,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal enum class ProfileField { ADDRESS, PORT, REMOTE_ID, CA }
|
||||||
|
|
||||||
|
/** A raw difference: CA values are the expiry dates. */
|
||||||
|
internal data class FieldChange(val field: ProfileField, val old: Any?, val new: Any?)
|
||||||
|
|
||||||
|
/** The server-side fields of a downloaded .sswan. */
|
||||||
|
internal data class RemoteProfile(val gateway: String, val port: Int?, val remoteId: String?, val certificate: X509Certificate?)
|
||||||
|
|
||||||
|
internal fun parseRemote(text: String, decode: (String) -> ByteArray): RemoteProfile
|
||||||
|
{
|
||||||
|
val remote = JSONObject(text).getJSONObject("remote")
|
||||||
|
return RemoteProfile(
|
||||||
|
gateway = remote.getString("addr"),
|
||||||
|
port = remote.optInt("port").takeIf { it in 1..65535 },
|
||||||
|
remoteId = remote.optString("id").ifEmpty { null },
|
||||||
|
certificate = remote.optString("cert").ifEmpty { null }?.let {
|
||||||
|
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(decode(it))) as X509Certificate
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What changes when the local profile takes the [remote] fields (port 4500 when unset). */
|
||||||
|
internal fun diff(gateway: String?, port: Int?, remoteId: String?, currentCa: X509Certificate?, remote: RemoteProfile): List<FieldChange>
|
||||||
|
{
|
||||||
|
val changes = ArrayList<FieldChange>()
|
||||||
|
if (remote.gateway != gateway)
|
||||||
|
{
|
||||||
|
changes += FieldChange(ProfileField.ADDRESS, gateway, remote.gateway)
|
||||||
|
}
|
||||||
|
if ((remote.port ?: 4500) != (port ?: 4500))
|
||||||
|
{
|
||||||
|
changes += FieldChange(ProfileField.PORT, port ?: 4500, remote.port ?: 4500)
|
||||||
|
}
|
||||||
|
if (remote.remoteId != null && remote.remoteId != remoteId)
|
||||||
|
{
|
||||||
|
changes += FieldChange(ProfileField.REMOTE_ID, remoteId ?: gateway, remote.remoteId)
|
||||||
|
}
|
||||||
|
val cert = remote.certificate
|
||||||
|
if (cert != null && (currentCa == null || !currentCa.encoded.contentEquals(cert.encoded)))
|
||||||
|
{
|
||||||
|
changes += FieldChange(ProfileField.CA, currentCa?.notAfter, cert.notAfter)
|
||||||
|
}
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-reads a profile from its link (F_Profile «Check now», the daily
|
||||||
|
* check) and applies the server-side fields: address, port, remote
|
||||||
|
* identity and CA certificate. The login, DNS, resilience settings,
|
||||||
|
* routing rules and apps stay as they are.
|
||||||
|
*/
|
||||||
|
object ProfileUpdater
|
||||||
|
{
|
||||||
|
private const val TAG = "ProfileUpdater"
|
||||||
|
private const val WORK_NAME = "profile-update-check"
|
||||||
|
|
||||||
|
/** Downloads and compares; throws on network or format errors. */
|
||||||
|
@JvmStatic
|
||||||
|
fun check(context: Context, uuid: String): ProfileCheck
|
||||||
|
{
|
||||||
|
val extra = ProfileExtras.get(context, uuid)
|
||||||
|
val link = extra.sourceValue?.takeIf { extra.source == ProfileSourceType.LINK } ?: throw IllegalStateException("no link")
|
||||||
|
val profile = loadProfile(context, uuid) ?: throw IllegalStateException("no profile")
|
||||||
|
val text = download(link)
|
||||||
|
val remote = parseRemote(text) { Base64.decode(it, Base64.DEFAULT) }
|
||||||
|
val currentCa = profile.certificateAlias?.let { TrustedCertificateManager.getInstance().load().getCACertificateFromAlias(it) }
|
||||||
|
val until = SimpleDateFormat("MM.yyyy", Locale.getDefault())
|
||||||
|
val changes = diff(profile.gateway, profile.port, profile.remoteId, currentCa, remote).map {
|
||||||
|
val label = context.getString(
|
||||||
|
when (it.field)
|
||||||
|
{
|
||||||
|
ProfileField.ADDRESS -> R.string.profile_update_addr
|
||||||
|
ProfileField.PORT -> R.string.profile_update_port
|
||||||
|
ProfileField.REMOTE_ID -> R.string.profile_update_id
|
||||||
|
ProfileField.CA -> R.string.profile_update_ca
|
||||||
|
}
|
||||||
|
)
|
||||||
|
fun show(value: Any?): String = when (value)
|
||||||
|
{
|
||||||
|
null -> "—"
|
||||||
|
is java.util.Date -> context.getString(R.string.profile_update_until, until.format(value))
|
||||||
|
else -> value.toString()
|
||||||
|
}
|
||||||
|
ProfileChange(label, show(it.old), show(it.new))
|
||||||
|
}
|
||||||
|
val gateway = remote.gateway
|
||||||
|
val port = remote.port
|
||||||
|
val remoteId = remote.remoteId
|
||||||
|
val certificate = remote.certificate
|
||||||
|
ProfileExtras.update(context, uuid) { it.copy(checkedAt = System.currentTimeMillis(), lastChanges = changes.size) }
|
||||||
|
return ProfileCheck(uuid, changes, gateway, port, remoteId, certificate)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the server-side fields and reconnects when the profile is in use. */
|
||||||
|
@JvmStatic
|
||||||
|
fun apply(context: Context, check: ProfileCheck)
|
||||||
|
{
|
||||||
|
val source = VpnProfileSource(context)
|
||||||
|
source.open()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val profile = source.getVpnProfile(UUID.fromString(check.uuid)) ?: return
|
||||||
|
profile.gateway = check.gateway
|
||||||
|
profile.port = check.port
|
||||||
|
if (check.remoteId != null)
|
||||||
|
{
|
||||||
|
profile.remoteId = check.remoteId
|
||||||
|
}
|
||||||
|
check.certificate?.let { cert ->
|
||||||
|
val store = KeyStore.getInstance("LocalCertificateStore")
|
||||||
|
store.load(null, null)
|
||||||
|
store.setCertificateEntry(null, cert)
|
||||||
|
profile.certificateAlias = store.getCertificateAlias(cert)
|
||||||
|
TrustedCertificateManager.getInstance().reset()
|
||||||
|
}
|
||||||
|
source.updateVpnProfile(profile)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
ProfileExtras.update(context, check.uuid) { it.copy(lastChanges = 0) }
|
||||||
|
val intent = Intent(Constants.VPN_PROFILES_CHANGED).putExtra(Constants.VPN_PROFILES_SINGLE, check.uuid)
|
||||||
|
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||||
|
EventLog.getInstance(context).resolve("profile-update:${check.uuid}")
|
||||||
|
DiagLog.i(TAG, "profile ${check.uuid} updated from its link (${check.changes.size} changes)")
|
||||||
|
/* the running tunnel still uses the old settings */
|
||||||
|
val service = VpnStateService.getLiveInstance()
|
||||||
|
if (service != null && service.profile?.getUUID()?.toString() == check.uuid && service.state != VpnStateService.State.DISABLED)
|
||||||
|
{
|
||||||
|
service.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadProfile(context: Context, uuid: String): VpnProfile?
|
||||||
|
{
|
||||||
|
val source = VpnProfileSource(context)
|
||||||
|
source.open()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return source.getVpnProfile(UUID.fromString(uuid))
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun download(link: String): String
|
||||||
|
{
|
||||||
|
val connection = URL(link).openConnection() as HttpURLConnection
|
||||||
|
connection.connectTimeout = 15_000
|
||||||
|
connection.readTimeout = 15_000
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (connection.responseCode !in 200..299)
|
||||||
|
{
|
||||||
|
throw IllegalStateException("HTTP ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
/* a profile is a few KiB; anything huge is not one */
|
||||||
|
return connection.inputStream.use { String(it.readNBytesCompat(512 * 1024), Charsets.UTF_8) }
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
connection.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun java.io.InputStream.readNBytesCompat(max: Int): ByteArray
|
||||||
|
{
|
||||||
|
val out = java.io.ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(8192)
|
||||||
|
while (out.size() < max)
|
||||||
|
{
|
||||||
|
val n = read(buffer)
|
||||||
|
if (n < 0)
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
out.write(buffer, 0, n)
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Daily check of the profiles with a link and «Update automatically» on. */
|
||||||
|
@JvmStatic
|
||||||
|
fun schedule(context: Context)
|
||||||
|
{
|
||||||
|
val request = PeriodicWorkRequestBuilder<ProfileUpdateWorker>(24, TimeUnit.HOURS)
|
||||||
|
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun checkAll(context: Context)
|
||||||
|
{
|
||||||
|
val due = ProfileExtras.flow(context).value.values.filter { it.source == ProfileSourceType.LINK && it.autoUpdate }
|
||||||
|
for (extra in due)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val check = check(context, extra.uuid)
|
||||||
|
if (check.changes.isNotEmpty())
|
||||||
|
{
|
||||||
|
val name = loadProfile(context, extra.uuid)?.name ?: extra.uuid
|
||||||
|
EventLog.getInstance(context).recordOnce(
|
||||||
|
EventKind.ASK, EventCategory.CONNECTION, context.getString(R.string.event_profile_update_title, name),
|
||||||
|
context.getString(R.string.event_profile_update_text, check.changes.size), EventAction.PROFILE_UPDATE,
|
||||||
|
"profile-update:${extra.uuid}", extra.uuid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
ProfileExtras.update(context, extra.uuid) { it.copy(checkedAt = System.currentTimeMillis(), lastChanges = -1) }
|
||||||
|
DiagLog.i(TAG, "profile check failed: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProfileUpdateWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
|
||||||
|
{
|
||||||
|
override suspend fun doWork(): Result
|
||||||
|
{
|
||||||
|
ProfileUpdater.checkAll(applicationContext)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
+196
@@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.profiles
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
|
import org.strongswan.android.data.VpnProfileSource
|
||||||
|
import org.strongswan.android.data.VpnType
|
||||||
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.logic.events.EventCategory
|
||||||
|
import org.strongswan.android.logic.events.EventKind
|
||||||
|
import org.strongswan.android.logic.events.EventLog
|
||||||
|
import org.strongswan.android.logic.events.Events
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The resilience settings of F_Profile at work. When the server does not
|
||||||
|
* answer, the backup ports are tried in turn, then the reserve server; only
|
||||||
|
* then the regular retry backoff takes over. While the operator whitelists
|
||||||
|
* are on, a profile with «Bridge for whitelists» switches to its bridge and
|
||||||
|
* comes back when they clear.
|
||||||
|
*
|
||||||
|
* Called on the main thread by [VpnStateService] and the whitelist detector.
|
||||||
|
*/
|
||||||
|
object Resilience
|
||||||
|
{
|
||||||
|
private const val TAG = "Resilience"
|
||||||
|
/** marks the connects started here (they keep the attempt state) */
|
||||||
|
const val KEY_INTERNAL = "org.strongswan.android.resilience.INTERNAL"
|
||||||
|
/** port used instead of the profile's for this connect */
|
||||||
|
const val KEY_PORT_OVERRIDE = "org.strongswan.android.resilience.PORT"
|
||||||
|
|
||||||
|
/* the profile the user asked for and where the attempt stands */
|
||||||
|
private var origin: String? = null
|
||||||
|
private var portIndex = -1
|
||||||
|
private var onReserve = false
|
||||||
|
/* set while switched to the bridge: the profile to come back to */
|
||||||
|
private var bridgeOrigin: String? = null
|
||||||
|
|
||||||
|
/** Every connect request; a user's connect starts a new attempt. */
|
||||||
|
@JvmStatic
|
||||||
|
fun onConnect(uuid: String?, info: Bundle)
|
||||||
|
{
|
||||||
|
if (info.getBoolean(KEY_INTERNAL, false))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
origin = uuid
|
||||||
|
portIndex = -1
|
||||||
|
onReserve = false
|
||||||
|
bridgeOrigin = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server of the current attempt does not answer. Returns true when
|
||||||
|
* another attempt was started (the regular retry stays off).
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun onUnreachable(service: VpnStateService): Boolean
|
||||||
|
{
|
||||||
|
val current = service.profile ?: return false
|
||||||
|
val start = origin ?: current.getUUID().toString().also { origin = it }
|
||||||
|
val extra = ProfileExtras.get(service, start)
|
||||||
|
if (!onReserve && portIndex + 1 < extra.backupPorts.size)
|
||||||
|
{
|
||||||
|
portIndex++
|
||||||
|
val port = extra.backupPorts[portIndex]
|
||||||
|
DiagLog.i(TAG, "server does not answer, trying backup port $port")
|
||||||
|
connect(service, start, current.password.takeIf { current.getUUID().toString() == start }, port)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val reserve = extra.reserveUuid
|
||||||
|
if (!onReserve && reserve != null && reserve != start)
|
||||||
|
{
|
||||||
|
val profile = load(service, reserve) ?: return false
|
||||||
|
if (profile.needsPassword())
|
||||||
|
{ /* the reserve needs a password nobody can type in now: say so once */
|
||||||
|
DiagLog.i(TAG, "reserve ${profile.name} needs a password, not switching")
|
||||||
|
onReserve = true
|
||||||
|
Events.switchNeedsPassword(service, reserve, profile.name ?: profile.gateway, load(service, start)?.name ?: "", bridge = false)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
onReserve = true
|
||||||
|
DiagLog.i(TAG, "server does not answer, switching to the reserve ${profile.name}")
|
||||||
|
EventLog.getInstance(service).record(
|
||||||
|
EventKind.INFO, EventCategory.CONNECTION,
|
||||||
|
service.getString(R.string.event_reserve_title, profile.name ?: profile.gateway),
|
||||||
|
service.getString(R.string.event_reserve_text, load(service, start)?.name ?: ""), key = "reserve:$start",
|
||||||
|
)
|
||||||
|
connect(service, reserve, null, null)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Connected: tell when it took a backup port. */
|
||||||
|
@JvmStatic
|
||||||
|
fun onConnected(service: VpnStateService)
|
||||||
|
{
|
||||||
|
val start = origin ?: return
|
||||||
|
if (!onReserve && portIndex >= 0)
|
||||||
|
{
|
||||||
|
val port = ProfileExtras.get(service, start).backupPorts.getOrNull(portIndex) ?: return
|
||||||
|
EventLog.getInstance(service).record(
|
||||||
|
EventKind.INFO, EventCategory.CONNECTION, service.getString(R.string.event_backup_port_title, port),
|
||||||
|
service.getString(R.string.event_backup_port_text), key = "port:$start",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The operator whitelists came or went (WhiteListManager). */
|
||||||
|
@JvmStatic
|
||||||
|
fun onWhitelist(context: Context, active: Boolean)
|
||||||
|
{
|
||||||
|
val service = VpnStateService.getLiveInstance() ?: return
|
||||||
|
val current = service.profile?.getUUID()?.toString() ?: return
|
||||||
|
if (active)
|
||||||
|
{
|
||||||
|
val extra = ProfileExtras.get(context, current)
|
||||||
|
val bridge = extra.bridgeUuid?.takeIf { extra.bridgeEnabled && it != current } ?: return
|
||||||
|
if (service.state == VpnStateService.State.DISABLED && service.errorState == VpnStateService.ErrorState.NO_ERROR)
|
||||||
|
{ /* the VPN is off on purpose */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val profile = load(context, bridge) ?: return
|
||||||
|
if (profile.needsPassword())
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "bridge ${profile.name} needs a password, not switching")
|
||||||
|
Events.switchNeedsPassword(context, bridge, profile.name ?: profile.gateway, service.profile?.name ?: "", bridge = true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DiagLog.i(TAG, "whitelists on, switching to the bridge ${profile.name}")
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_bridge_on_title, profile.name ?: profile.gateway),
|
||||||
|
context.getString(R.string.event_bridge_on_text), key = "bridge",
|
||||||
|
)
|
||||||
|
connect(service, bridge, null, null)
|
||||||
|
bridgeOrigin = current
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
val back = bridgeOrigin ?: return
|
||||||
|
bridgeOrigin = null
|
||||||
|
if (service.state == VpnStateService.State.DISABLED && service.errorState == VpnStateService.ErrorState.NO_ERROR)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val profile = load(context, back) ?: return
|
||||||
|
DiagLog.i(TAG, "whitelists off, back to ${profile.name}")
|
||||||
|
EventLog.getInstance(context).resolve("bridge")
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_bridge_off_title, profile.name ?: profile.gateway),
|
||||||
|
context.getString(R.string.event_bridge_off_text), key = "bridge-off",
|
||||||
|
)
|
||||||
|
connect(service, back, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connect(service: VpnStateService, uuid: String, password: String?, port: Int?)
|
||||||
|
{
|
||||||
|
val info = Bundle()
|
||||||
|
info.putString(VpnProfileDataSource.KEY_UUID, uuid)
|
||||||
|
password?.let { info.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||||
|
port?.let { info.putInt(KEY_PORT_OVERRIDE, it) }
|
||||||
|
info.putBoolean(KEY_INTERNAL, true)
|
||||||
|
service.connect(info, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(context: Context, uuid: String) = runCatching {
|
||||||
|
val source = VpnProfileSource(context)
|
||||||
|
source.open()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
source.getVpnProfile(UUID.fromString(uuid))
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.protection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
|
/** The DNS server of the tunnel (F_Protection «DNS server»). */
|
||||||
|
enum class DnsChoice { PROFILE, CLOUDFLARE, ADGUARD, CUSTOM }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DNS and blocking (F_Protection). The blocking is done by the resolver
|
||||||
|
* itself, so it follows from the choice: AdGuard DNS blocks ads, trackers
|
||||||
|
* and phishing together, Cloudflare's 1.1.1.2 blocks malware and phishing
|
||||||
|
* only. The servers replace the profile's ones on the next connect.
|
||||||
|
*/
|
||||||
|
data class ProtectionState(
|
||||||
|
val choice: DnsChoice = DnsChoice.PROFILE,
|
||||||
|
/** with CLOUDFLARE: the malware / phishing filtering resolver */
|
||||||
|
val phishingFilter: Boolean = false,
|
||||||
|
val custom: String = "",
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val blocksAds: Boolean get() = choice == DnsChoice.ADGUARD
|
||||||
|
val blocksTrackers: Boolean get() = choice == DnsChoice.ADGUARD
|
||||||
|
val blocksPhishing: Boolean get() = choice == DnsChoice.ADGUARD || (choice == DnsChoice.CLOUDFLARE && phishingFilter)
|
||||||
|
|
||||||
|
/** null: keep the profile's (or the server's) DNS */
|
||||||
|
fun servers(): List<String>? = when (choice)
|
||||||
|
{
|
||||||
|
DnsChoice.PROFILE -> null
|
||||||
|
DnsChoice.ADGUARD -> listOf("94.140.14.14", "94.140.15.15", "2a10:50c0::ad1:ff", "2a10:50c0::ad2:ff")
|
||||||
|
DnsChoice.CLOUDFLARE -> if (phishingFilter) listOf("1.1.1.2", "1.0.0.2", "2606:4700:4700::1112", "2606:4700:4700::1002")
|
||||||
|
else listOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
||||||
|
DnsChoice.CUSTOM -> parseCustom(custom).takeIf { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withAds(on: Boolean): ProtectionState = when
|
||||||
|
{
|
||||||
|
on -> copy(choice = DnsChoice.ADGUARD)
|
||||||
|
choice == DnsChoice.ADGUARD -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = true)
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun withPhishing(on: Boolean): ProtectionState = when
|
||||||
|
{
|
||||||
|
on && choice == DnsChoice.ADGUARD -> this
|
||||||
|
on -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = true)
|
||||||
|
choice == DnsChoice.ADGUARD -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = false)
|
||||||
|
else -> copy(phishingFilter = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** «1.2.3.4, 2001:db8::1» → the addresses that parse as IPs */
|
||||||
|
fun parseCustom(text: String): List<String> = text.split(',', ' ', ';').map { it.trim() }.filter { it.isNotEmpty() }.filter { isIp(it) }
|
||||||
|
|
||||||
|
private fun isIp(s: String): Boolean =
|
||||||
|
Regex("""^(\d{1,3})(\.\d{1,3}){3}$""").matches(s) && s.split('.').all { it.toInt() in 0..255 } ||
|
||||||
|
(s.contains(':') && Regex("""^[0-9a-fA-F:]+$""").matches(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object Protection
|
||||||
|
{
|
||||||
|
private const val PREF_CHOICE = "pref_dns_choice"
|
||||||
|
private const val PREF_PHISHING = "pref_dns_phishing"
|
||||||
|
private const val PREF_CUSTOM = "pref_dns_custom"
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<ProtectionState?>(null)
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun get(context: Context): ProtectionState
|
||||||
|
{
|
||||||
|
_state.value?.let { return it }
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
val state = ProtectionState(
|
||||||
|
choice = runCatching { DnsChoice.valueOf(prefs.getString(PREF_CHOICE, DnsChoice.PROFILE.name)!!) }.getOrDefault(DnsChoice.PROFILE),
|
||||||
|
phishingFilter = prefs.getBoolean(PREF_PHISHING, false),
|
||||||
|
custom = prefs.getString(PREF_CUSTOM, "") ?: "",
|
||||||
|
)
|
||||||
|
_state.value = state
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
fun flow(context: Context): StateFlow<ProtectionState?>
|
||||||
|
{
|
||||||
|
get(context)
|
||||||
|
return _state
|
||||||
|
}
|
||||||
|
|
||||||
|
fun set(context: Context, state: ProtectionState)
|
||||||
|
{
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||||
|
.putString(PREF_CHOICE, state.choice.name).putBoolean(PREF_PHISHING, state.phishingFilter).putString(PREF_CUSTOM, state.custom).apply()
|
||||||
|
_state.value = state
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For CharonVpnService: the servers replacing the profile's ones, or null. */
|
||||||
|
@JvmStatic
|
||||||
|
fun dnsServers(context: Context): List<String>? = get(context).servers()
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.routing
|
||||||
|
|
||||||
|
import android.app.AppOpsManager
|
||||||
|
import android.app.usage.NetworkStats
|
||||||
|
import android.app.usage.NetworkStatsManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Process
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-app network traffic from the system statistics ("Usage access"), for
|
||||||
|
* the «Recent apps» list. Android attributes tunneled traffic back to the
|
||||||
|
* app that produced it, so the totals cover both the VPN and direct paths.
|
||||||
|
* Only byte counts per app are read - never destinations or content.
|
||||||
|
*/
|
||||||
|
object AppTraffic
|
||||||
|
{
|
||||||
|
private const val TAG = "AppTraffic"
|
||||||
|
/* the first uid of regular apps; lower ones are system services */
|
||||||
|
private const val FIRST_APP_UID = 10000
|
||||||
|
|
||||||
|
fun hasAccess(context: Context): Boolean
|
||||||
|
{
|
||||||
|
val ops = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager ?: return false
|
||||||
|
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||||
|
{
|
||||||
|
ops.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
ops.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
|
||||||
|
}
|
||||||
|
return mode == AppOpsManager.MODE_ALLOWED
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes per package since [sinceMs] (wall clock), Wi-Fi and mobile
|
||||||
|
* together; empty without usage access. The system keeps the counters
|
||||||
|
* in buckets of up to two hours, so the window is approximate.
|
||||||
|
*/
|
||||||
|
fun since(context: Context, sinceMs: Long): Map<String, Long>
|
||||||
|
{
|
||||||
|
if (!hasAccess(context))
|
||||||
|
{
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
val manager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as? NetworkStatsManager ?: return emptyMap()
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val perUid = HashMap<Int, Long>()
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
for (type in intArrayOf(ConnectivityManager.TYPE_WIFI, ConnectivityManager.TYPE_MOBILE))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
manager.querySummary(type, null, sinceMs, now).use { stats -> sum(stats, perUid) }
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{ /* no such network history or no access */
|
||||||
|
Log.d(TAG, "querySummary($type) failed: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pm = context.packageManager
|
||||||
|
val result = HashMap<String, Long>()
|
||||||
|
for ((uid, bytes) in perUid)
|
||||||
|
{
|
||||||
|
if (uid < FIRST_APP_UID || bytes <= 0)
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val pkg = pm.getPackagesForUid(uid)?.firstOrNull() ?: continue
|
||||||
|
if (pkg != context.packageName)
|
||||||
|
{
|
||||||
|
result[pkg] = (result[pkg] ?: 0L) + bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sum(stats: NetworkStats, into: HashMap<Int, Long>)
|
||||||
|
{
|
||||||
|
val bucket = NetworkStats.Bucket()
|
||||||
|
while (stats.hasNextBucket())
|
||||||
|
{
|
||||||
|
stats.getNextBucket(bucket)
|
||||||
|
into[bucket.uid] = (into[bucket.uid] ?: 0L) + bucket.rxBytes + bucket.txBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <R> NetworkStats.use(block: (NetworkStats) -> R): R = try
|
||||||
|
{
|
||||||
|
block(this)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.routing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-defined package name patterns ("package templates" in the UI):
|
||||||
|
* - "ru.*" - package names starting with "ru."
|
||||||
|
* - "*yandex*" - package names containing "yandex" anywhere
|
||||||
|
* - "*.beta" - package names ending with ".beta"
|
||||||
|
* - "ru.ozon.app" - without a star: prefix match, same as "ru.ozon.app*"
|
||||||
|
* Matching is case-insensitive.
|
||||||
|
*/
|
||||||
|
object PackagePatterns
|
||||||
|
{
|
||||||
|
private val ALLOWED = Regex("[a-z0-9._*-]+")
|
||||||
|
private val STARS = Regex("\\*+")
|
||||||
|
|
||||||
|
/** Canonical form of user input, or null if it is empty, too broad or has invalid characters. */
|
||||||
|
fun normalize(input: String): String?
|
||||||
|
{
|
||||||
|
val text = input.trim().lowercase()
|
||||||
|
if (text.isEmpty() || !ALLOWED.matches(text))
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val collapsed = text.replace(STARS, "*")
|
||||||
|
if (collapsed.replace("*", "").isEmpty())
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return if (collapsed.contains('*')) collapsed else "$collapsed*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun matches(pattern: String, pkg: String): Boolean
|
||||||
|
{
|
||||||
|
val core = pattern.trim('*').lowercase()
|
||||||
|
if (core.isEmpty())
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val name = pkg.lowercase()
|
||||||
|
val starts = pattern.startsWith("*")
|
||||||
|
val ends = pattern.endsWith("*")
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
starts && ends -> name.contains(core)
|
||||||
|
ends -> name.startsWith(core)
|
||||||
|
starts -> name.endsWith(core)
|
||||||
|
else -> name == core
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Packages (of [installed]) matched by any of the patterns. */
|
||||||
|
fun matchAll(patterns: Set<String>, installed: Set<String>): Set<String> =
|
||||||
|
if (patterns.isEmpty())
|
||||||
|
{
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
installed.filterTo(HashSet()) { pkg -> patterns.any { matches(it, pkg) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun fromPrefix(prefix: String): String = "$prefix*"
|
||||||
|
|
||||||
|
fun fromKeyword(keyword: String): String = "*${keyword.lowercase()}*"
|
||||||
|
}
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.routing
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.strongswan.android.utils.IPRange
|
||||||
|
import java.net.InetAddress
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks how a domain is routed for regular applications: which addresses
|
||||||
|
* the tunnel's DNS and the direct network's DNS return for it, and whether
|
||||||
|
* each address is tunneled or bypassed under the currently applied RU
|
||||||
|
* exclusion set. This is a diagnostic view - it reflects the configuration
|
||||||
|
* the VPN has installed, not a live traffic probe.
|
||||||
|
*/
|
||||||
|
object RouteCheck
|
||||||
|
{
|
||||||
|
/** Where an address goes under the currently applied exclusion set. */
|
||||||
|
enum class RouteKind { TUNNEL, DIRECT_RU, TUNNEL_DNS }
|
||||||
|
|
||||||
|
data class IpRoute(val ip: String, val kind: RouteKind, val resolvers: Set<String>)
|
||||||
|
|
||||||
|
data class Report(
|
||||||
|
val domain: String,
|
||||||
|
val vpnNetworkFound: Boolean,
|
||||||
|
val directNetworkFound: Boolean,
|
||||||
|
val vpnDnsIps: List<String>,
|
||||||
|
val directDnsIps: List<String>,
|
||||||
|
val routes: List<IpRoute>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the domain through the tunnel's DNS (the VPN network's
|
||||||
|
* resolvers) and through the direct network's DNS, then classifies every
|
||||||
|
* unique address against the currently applied exclusions.
|
||||||
|
*/
|
||||||
|
suspend fun check(
|
||||||
|
context: Context,
|
||||||
|
domain: String,
|
||||||
|
exclusionsV4: List<IPRange>,
|
||||||
|
exclusionsV6: List<IPRange>,
|
||||||
|
dnsPunchBlocks: Set<String>,
|
||||||
|
): Report = withContext(Dispatchers.IO)
|
||||||
|
{
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager
|
||||||
|
?: return@withContext Report(
|
||||||
|
domain = domain,
|
||||||
|
vpnNetworkFound = false,
|
||||||
|
directNetworkFound = false,
|
||||||
|
vpnDnsIps = emptyList(),
|
||||||
|
directDnsIps = emptyList(),
|
||||||
|
routes = emptyList(),
|
||||||
|
)
|
||||||
|
val networks = cm.allNetworks.toList()
|
||||||
|
val vpn = networks.firstOrNull {
|
||||||
|
cm.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
|
||||||
|
}
|
||||||
|
val direct = networks.firstOrNull {
|
||||||
|
val caps = cm.getNetworkCapabilities(it) ?: return@firstOrNull false
|
||||||
|
!caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN) &&
|
||||||
|
caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
(caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) ||
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
|
||||||
|
}
|
||||||
|
|
||||||
|
val vpnDnsIps = resolve(vpn, domain)
|
||||||
|
val directDnsIps = resolve(direct, domain)
|
||||||
|
val routes = (vpnDnsIps + directDnsIps).distinct().map { ip ->
|
||||||
|
val address = InetAddress.getByName(ip)
|
||||||
|
IpRoute(
|
||||||
|
ip = ip,
|
||||||
|
kind = classify(address, exclusionsV4, exclusionsV6, dnsPunchBlocks),
|
||||||
|
resolvers = buildSet {
|
||||||
|
if (ip in vpnDnsIps) add("vpn")
|
||||||
|
if (ip in directDnsIps) add("direct")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Report(
|
||||||
|
domain = domain,
|
||||||
|
vpnNetworkFound = vpn != null,
|
||||||
|
directNetworkFound = direct != null,
|
||||||
|
vpnDnsIps = vpnDnsIps,
|
||||||
|
directDnsIps = directDnsIps,
|
||||||
|
routes = routes,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why an address takes its route, in the order the engine decides. */
|
||||||
|
enum class Reason
|
||||||
|
{
|
||||||
|
/** a resolver of the tunnel or strict private DNS: always through the tunnel */
|
||||||
|
DNS,
|
||||||
|
/** a user rule (address or site) sends it through the VPN */
|
||||||
|
RULE_TUNNEL,
|
||||||
|
/** a user rule (address or site) sends it direct */
|
||||||
|
RULE_DIRECT,
|
||||||
|
/** it is in the RU address base */
|
||||||
|
RU_LIST,
|
||||||
|
/** nothing matched: the default route is the tunnel */
|
||||||
|
DEFAULT,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Verdict(val direct: Boolean, val reason: Reason)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full verdict for an address: like [classify], plus the user rules,
|
||||||
|
* which the engine applies when [rulesActive] (userspace routing):
|
||||||
|
* "through the VPN" wins over "direct", both win over the RU base.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun verdict(
|
||||||
|
address: InetAddress,
|
||||||
|
exclusionsV4: List<IPRange>,
|
||||||
|
exclusionsV6: List<IPRange>,
|
||||||
|
dnsPunchBlocks: Set<String>,
|
||||||
|
forceRanges: List<IPRange>,
|
||||||
|
directRanges: List<IPRange>,
|
||||||
|
rulesActive: Boolean,
|
||||||
|
): Verdict
|
||||||
|
{
|
||||||
|
if (insidePunchBlock(dnsPunchBlocks, address))
|
||||||
|
{
|
||||||
|
return Verdict(direct = false, reason = Reason.DNS)
|
||||||
|
}
|
||||||
|
if (rulesActive && containsAddress(forceRanges, address))
|
||||||
|
{
|
||||||
|
return Verdict(direct = false, reason = Reason.RULE_TUNNEL)
|
||||||
|
}
|
||||||
|
if (rulesActive && containsAddress(directRanges, address))
|
||||||
|
{
|
||||||
|
return Verdict(direct = true, reason = Reason.RULE_DIRECT)
|
||||||
|
}
|
||||||
|
val exclusions = if (address is java.net.Inet4Address) exclusionsV4 else exclusionsV6
|
||||||
|
if (containsAddress(exclusions, address))
|
||||||
|
{
|
||||||
|
return Verdict(direct = true, reason = Reason.RU_LIST)
|
||||||
|
}
|
||||||
|
return Verdict(direct = false, reason = Reason.DEFAULT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses CIDR strings, skipping invalid ones. */
|
||||||
|
@JvmStatic
|
||||||
|
fun ranges(cidrs: List<String>): List<IPRange> = cidrs.mapNotNull { runCatching { IPRange(it) }.getOrNull() }
|
||||||
|
|
||||||
|
/** Route verdict for one address under the applied exclusion set. */
|
||||||
|
@JvmStatic
|
||||||
|
fun classify(
|
||||||
|
address: InetAddress,
|
||||||
|
exclusionsV4: List<IPRange>,
|
||||||
|
exclusionsV6: List<IPRange>,
|
||||||
|
dnsPunchBlocks: Set<String>,
|
||||||
|
): RouteKind
|
||||||
|
{
|
||||||
|
if (insidePunchBlock(dnsPunchBlocks, address))
|
||||||
|
{
|
||||||
|
return RouteKind.TUNNEL_DNS
|
||||||
|
}
|
||||||
|
val exclusions = if (address is java.net.Inet4Address) exclusionsV4 else exclusionsV6
|
||||||
|
if (containsAddress(exclusions, address))
|
||||||
|
{
|
||||||
|
return RouteKind.DIRECT_RU
|
||||||
|
}
|
||||||
|
return RouteKind.TUNNEL
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the address falls into one of the DNS punch blocks ("a.b.c.d/prefix"). */
|
||||||
|
private fun insidePunchBlock(blocks: Set<String>, address: InetAddress): Boolean
|
||||||
|
{
|
||||||
|
if (blocks.isEmpty())
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val value = address.address
|
||||||
|
for (block in blocks)
|
||||||
|
{
|
||||||
|
val slash = block.lastIndexOf('/')
|
||||||
|
if (slash <= 0)
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val prefix = block.substring(slash + 1).toIntOrNull() ?: continue
|
||||||
|
val base = runCatching { InetAddress.getByName(block.substring(0, slash)).address }.getOrNull() ?: continue
|
||||||
|
if (base.size != value.size || prefix > value.size * 8)
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var equal = true
|
||||||
|
for (i in value.indices)
|
||||||
|
{
|
||||||
|
val keep = (prefix - i * 8).coerceIn(0, 8)
|
||||||
|
val mask = if (keep == 8) 0xff else ((0xff shl (8 - keep)) and 0xff)
|
||||||
|
if ((base[i].toInt() and mask) != (value[i].toInt() and mask))
|
||||||
|
{
|
||||||
|
equal = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (equal)
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether any of the ranges (inclusive bounds) contains the address. */
|
||||||
|
@JvmStatic
|
||||||
|
fun containsAddress(ranges: List<IPRange>, address: InetAddress): Boolean
|
||||||
|
{
|
||||||
|
val value = address.address
|
||||||
|
for (range in ranges)
|
||||||
|
{
|
||||||
|
val from = range.from.address
|
||||||
|
val to = range.to.address
|
||||||
|
if (from.size != value.size || to.size != value.size)
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (lexCompare(from, value) <= 0 && lexCompare(value, to) <= 0)
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsigned lexicographic byte comparison of two addresses. */
|
||||||
|
private fun lexCompare(a: ByteArray, b: ByteArray): Int
|
||||||
|
{
|
||||||
|
for (i in a.indices)
|
||||||
|
{
|
||||||
|
val diff = (a[i].toInt() and 0xff) - (b[i].toInt() and 0xff)
|
||||||
|
if (diff != 0)
|
||||||
|
{
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolve(network: Network?, domain: String): List<String>
|
||||||
|
{
|
||||||
|
if (network == null)
|
||||||
|
{
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
network.getAllByName(domain).mapNotNull { it.hostAddress }.filter { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+972
@@ -0,0 +1,972 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.routing
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONException
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||||
|
import org.strongswan.android.data.VpnProfileSqlDataSource
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.logic.routing.RoutingPresets.matches
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named set of applications that bypassed the VPN tunnel (pre-2.16 model).
|
||||||
|
* Only used to migrate old settings, see [RoutingMigration.migrateRuleSets].
|
||||||
|
*/
|
||||||
|
data class RuleSet(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
/** Concrete package names. */
|
||||||
|
val packages: Set<String> = emptySet(),
|
||||||
|
/** Package name prefixes, expanded against installed apps at connect time. */
|
||||||
|
val prefixes: Set<String> = emptySet(),
|
||||||
|
/** Substrings matched anywhere in the package name (case-insensitive). */
|
||||||
|
val keywords: Set<String> = emptySet(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** A network prefix that must always go through the tunnel (userspace mode). */
|
||||||
|
data class ForceTunnelPrefix(
|
||||||
|
val id: String,
|
||||||
|
/** Validated CIDR or single address, e.g. "130.49.213.176/32". */
|
||||||
|
val prefix: String,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The one global answer to "what goes around the VPN" for apps:
|
||||||
|
* [effectiveExcludes] = ([directPackages] + apps matched by [enabledCategories]
|
||||||
|
* and [packagePatterns]) - [tunnelOverrides].
|
||||||
|
*/
|
||||||
|
data class RoutingConfig(
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
/** Apps the user switched to "direct" one by one. */
|
||||||
|
val directPackages: Set<String> = emptySet(),
|
||||||
|
/** Apps switched back to "via VPN" although a category or pattern covers them. */
|
||||||
|
val tunnelOverrides: Set<String> = emptySet(),
|
||||||
|
/** Enabled built-in categories ([RoutingPresets] ids). */
|
||||||
|
val enabledCategories: Set<String> = emptySet(),
|
||||||
|
/** User package patterns, see [PackagePatterns]. */
|
||||||
|
val packagePatterns: Set<String> = emptySet(),
|
||||||
|
/** Patterns the user switched off without deleting them. */
|
||||||
|
val disabledPatterns: Set<String> = emptySet(),
|
||||||
|
val forceTunnelPrefixes: List<ForceTunnelPrefix> = emptyList(),
|
||||||
|
/** User addresses that always go direct (same shape as the force-tunnel entries). */
|
||||||
|
val directPrefixes: List<ForceTunnelPrefix> = emptyList(),
|
||||||
|
/** Per-site rules; the engine routes by address, so sites are resolved, see [SiteRule]. */
|
||||||
|
val siteRules: List<SiteRule> = emptyList(),
|
||||||
|
val version: Int = RoutingManager.CONFIG_VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "This site always goes direct / through the VPN". The userspace router
|
||||||
|
* decides per destination address, so the rule applies to the addresses the
|
||||||
|
* domain (and its www. name) resolves to; they are refreshed when the
|
||||||
|
* tunnel comes up and every [RoutingManager.SITE_REFRESH_MS] while it is up.
|
||||||
|
*/
|
||||||
|
data class SiteRule(
|
||||||
|
val id: String,
|
||||||
|
val domain: String,
|
||||||
|
val direct: Boolean,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Why a package is (not) direct, for the per-app reason line. */
|
||||||
|
data class TemplateMatch(
|
||||||
|
/** Built-in category id (may be [RoutingPresets.NAMESPACE]) or null. */
|
||||||
|
val category: String? = null,
|
||||||
|
/** User pattern or null. */
|
||||||
|
val pattern: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RoutingState(
|
||||||
|
val config: RoutingConfig = RoutingConfig(),
|
||||||
|
/** Number of profiles whose "only selected apps" list was converted at migration (one-time notice). */
|
||||||
|
val migratedOnlyProfiles: Int = 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global per-app routing: one list of applications that must NOT use the VPN
|
||||||
|
* tunnel (they go direct). [CharonVpnService.BuilderCache] merges the
|
||||||
|
* effective package list into the TUN builder on every (re)connect.
|
||||||
|
*
|
||||||
|
* When the list changes while the VPN is connected the connection is
|
||||||
|
* restarted automatically (debounced), because per-app routing only applies
|
||||||
|
* when the TUN interface is created.
|
||||||
|
*
|
||||||
|
* The constructor migrates pre-2.16 settings (rule sets, per-profile app
|
||||||
|
* lists) once, so it has to run before the first VPN start - it does, from
|
||||||
|
* StrongSwanApplication.onCreate.
|
||||||
|
*/
|
||||||
|
class RoutingManager private constructor(private val context: Context)
|
||||||
|
{
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
/* read from the charon and IO threads in computeExcludedPackages() */
|
||||||
|
@Volatile
|
||||||
|
private var config = RoutingConfig()
|
||||||
|
private var service: VpnStateService? = null
|
||||||
|
private var migratedOnlyProfiles = 0
|
||||||
|
|
||||||
|
/* package cache: expanded patterns against installed apps, keyed by config version */
|
||||||
|
@Volatile
|
||||||
|
private var configVersion = 0L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var packageCache: Pair<Long, Set<String>>? = null
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(RoutingState())
|
||||||
|
val state: StateFlow<RoutingState> = _state
|
||||||
|
|
||||||
|
/* resolved addresses of the site rules, domain -> CIDRs; read by the push on any thread */
|
||||||
|
@Volatile
|
||||||
|
private var siteAddresses: Map<String, List<String>> = emptyMap()
|
||||||
|
private val _siteAddresses = MutableStateFlow<Map<String, List<String>>>(emptyMap())
|
||||||
|
/** Resolved addresses per site rule domain, for the Rules screen. */
|
||||||
|
val resolvedSites: StateFlow<Map<String, List<String>>> = _siteAddresses
|
||||||
|
private var siteRefreshJob: Job? = null
|
||||||
|
|
||||||
|
/* site rules are resolved again whenever the tunnel comes up */
|
||||||
|
private var lastState: VpnStateService.State? = null
|
||||||
|
private val stateListener = VpnStateService.VpnStateListener {
|
||||||
|
val state = service?.state
|
||||||
|
if (state == VpnStateService.State.CONNECTED && lastState != VpnStateService.State.CONNECTED && config.siteRules.isNotEmpty())
|
||||||
|
{
|
||||||
|
refreshSites()
|
||||||
|
}
|
||||||
|
lastState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
private val connection = object : ServiceConnection
|
||||||
|
{
|
||||||
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||||
|
{
|
||||||
|
service = (binder as VpnStateService.LocalBinder).service
|
||||||
|
service?.registerListener(stateListener)
|
||||||
|
stateListener.stateChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?)
|
||||||
|
{
|
||||||
|
service = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val reconnectRunnable = Runnable {
|
||||||
|
val svc = service ?: return@Runnable
|
||||||
|
if (svc.state == VpnStateService.State.CONNECTED)
|
||||||
|
{
|
||||||
|
Log.i(TAG, "Routing rules changed, reconnecting to apply them")
|
||||||
|
svc.reconnectPreservingLog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* new/removed apps change what categories and patterns match */
|
||||||
|
private val packagesReceiver = object : BroadcastReceiver()
|
||||||
|
{
|
||||||
|
override fun onReceive(context: Context?, intent: Intent?)
|
||||||
|
{
|
||||||
|
if (intent?.action == Intent.ACTION_PACKAGE_ADDED && !intent.getBooleanExtra(Intent.EXTRA_REPLACING, false))
|
||||||
|
{
|
||||||
|
intent.data?.schemeSpecificPart?.let { suggestDirect(it) }
|
||||||
|
}
|
||||||
|
invalidateCache()
|
||||||
|
/* a full PackageManager scan: never on the main thread */
|
||||||
|
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init
|
||||||
|
{
|
||||||
|
config = loadConfig()
|
||||||
|
migrateProfiles()
|
||||||
|
migratedOnlyProfiles = prefs.getInt(KEY_NOTICE_ONLY, 0)
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start()
|
||||||
|
{
|
||||||
|
context.bindService(Intent(context, VpnStateService::class.java), connection, Service.BIND_AUTO_CREATE)
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(Intent.ACTION_PACKAGE_ADDED)
|
||||||
|
addAction(Intent.ACTION_PACKAGE_REMOVED)
|
||||||
|
addDataScheme("package")
|
||||||
|
}
|
||||||
|
context.registerReceiver(packagesReceiver, filter)
|
||||||
|
/* resolve the site rules once so the Rules screen shows their addresses */
|
||||||
|
refreshSites()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
|
||||||
|
{
|
||||||
|
val previous = config
|
||||||
|
val updated = transform(previous)
|
||||||
|
if (updated == previous)
|
||||||
|
{ /* e.g. a chip tapped that is already in that state: no reconnect */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
config = updated
|
||||||
|
saveConfig(updated)
|
||||||
|
invalidateCache()
|
||||||
|
publish()
|
||||||
|
if (updated.enabled)
|
||||||
|
{ /* warm the package cache (off the main thread) so the connect path stays fast */
|
||||||
|
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
|
||||||
|
}
|
||||||
|
if (updated.enabled || previous.enabled)
|
||||||
|
{ /* also when routing was just switched off: the excluded apps keep
|
||||||
|
* bypassing the tunnel until it is re-established */
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new app that a built-in RU category would cover, while that category
|
||||||
|
* is off and the app is not direct: ask in «Events» whether it should be.
|
||||||
|
*/
|
||||||
|
private fun suggestDirect(pkg: String)
|
||||||
|
{
|
||||||
|
val current = config
|
||||||
|
if (!current.enabled || pkg == context.packageName || pkg in current.directPackages || pkg in current.tunnelOverrides)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val category = RoutingPresets.CATEGORIES.firstOrNull { RoutingPresets.matchesCategory(it, pkg) } ?: return
|
||||||
|
if (category in current.enabledCategories || templateMatch(pkg, current) != null)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val pm = context.packageManager
|
||||||
|
val label = try
|
||||||
|
{
|
||||||
|
pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString()
|
||||||
|
}
|
||||||
|
catch (_: PackageManager.NameNotFoundException)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
org.strongswan.android.logic.events.Events.newRuApp(context, pkg, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- UI-facing helpers ------------------------- */
|
||||||
|
|
||||||
|
fun setGlobalEnabled(enabled: Boolean)
|
||||||
|
{
|
||||||
|
updateConfig { it.copy(enabled = enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The per-app switch: "direct" adds the app (or drops its tunnel
|
||||||
|
* override when a category/pattern already covers it); "via VPN" removes
|
||||||
|
* it, keeping a personal override when a category/pattern covers it.
|
||||||
|
*/
|
||||||
|
fun setAppDirect(pkg: String, direct: Boolean)
|
||||||
|
{
|
||||||
|
updateConfig { config ->
|
||||||
|
val covered = templateMatch(pkg, config) != null
|
||||||
|
if (direct)
|
||||||
|
{
|
||||||
|
config.copy(
|
||||||
|
enabled = true,
|
||||||
|
tunnelOverrides = config.tunnelOverrides - pkg,
|
||||||
|
directPackages = if (covered) config.directPackages else config.directPackages + pkg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
config.copy(
|
||||||
|
directPackages = config.directPackages - pkg,
|
||||||
|
tunnelOverrides = if (covered) config.tunnelOverrides + pkg else config.tunnelOverrides,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switches a built-in category on or off. Switching on also clears personal "via VPN" exceptions inside it. */
|
||||||
|
fun setCategoryEnabled(category: String, enabled: Boolean)
|
||||||
|
{
|
||||||
|
updateConfig { config ->
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
config.copy(
|
||||||
|
enabled = true,
|
||||||
|
enabledCategories = config.enabledCategories + category,
|
||||||
|
tunnelOverrides = config.tunnelOverrides.filterNot { RoutingPresets.matchesCategory(category, it) }.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
config.copy(enabledCategories = config.enabledCategories - category)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quick action: every RF category plus the ru.* namespace ("all RF apps"). */
|
||||||
|
fun enableAllRfApps()
|
||||||
|
{
|
||||||
|
updateConfig { config ->
|
||||||
|
config.copy(
|
||||||
|
enabled = true,
|
||||||
|
enabledCategories = config.enabledCategories + RoutingPresets.ALL_CATEGORIES,
|
||||||
|
tunnelOverrides = config.tunnelOverrides.filterNot { pkg ->
|
||||||
|
RoutingPresets.ALL_CATEGORIES.any { RoutingPresets.matchesCategory(it, pkg) }
|
||||||
|
}.toSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a package pattern; false if the input is invalid. */
|
||||||
|
fun addPattern(input: String): Boolean
|
||||||
|
{
|
||||||
|
val pattern = PackagePatterns.normalize(input) ?: return false
|
||||||
|
updateConfig { it.copy(packagePatterns = it.packagePatterns + pattern) }
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removePattern(pattern: String)
|
||||||
|
{
|
||||||
|
updateConfig { it.copy(packagePatterns = it.packagePatterns - pattern, disabledPatterns = it.disabledPatterns - pattern) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Switches a package pattern off (kept in the list) or back on. */
|
||||||
|
fun setPatternEnabled(pattern: String, enabled: Boolean)
|
||||||
|
{
|
||||||
|
updateConfig { it.copy(disabledPatterns = if (enabled) it.disabledPatterns - pattern else it.disabledPatterns + pattern) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Imports the split-tunneling apps of a profile file (JSON import): the
|
||||||
|
* per-profile setting no longer exists, so the apps join the global
|
||||||
|
* direct list ("excluded-apps" as is, "apps" as every other app).
|
||||||
|
*/
|
||||||
|
fun importProfileApps(handling: SelectedAppsHandling, selected: Set<String>)
|
||||||
|
{
|
||||||
|
val added = RoutingMigration.profileAppsToDirect(
|
||||||
|
handling, selected,
|
||||||
|
if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) loadInstalledPackages() else emptySet(),
|
||||||
|
context.packageName)
|
||||||
|
if (added.isNotEmpty())
|
||||||
|
{
|
||||||
|
updateConfig { it.copy(enabled = true, directPackages = it.directPackages + added, tunnelOverrides = it.tunnelOverrides - added) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one-time "your 'only selected apps' profile was converted" notice has been shown. */
|
||||||
|
fun consumeMigrationNotice()
|
||||||
|
{
|
||||||
|
if (migratedOnlyProfiles != 0)
|
||||||
|
{
|
||||||
|
migratedOnlyProfiles = 0
|
||||||
|
prefs.edit().putInt(KEY_NOTICE_ONLY, 0).apply()
|
||||||
|
publish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------- force-tunnel prefixes --------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a force-tunnel prefix (CIDR or bare address). Returns null on an
|
||||||
|
* invalid prefix, otherwise the new entry's id.
|
||||||
|
*/
|
||||||
|
fun addForcePrefix(cidr: String): String?
|
||||||
|
{
|
||||||
|
val normalized = normalizePrefix(cidr) ?: return null
|
||||||
|
updateConfigNoReconnect { config ->
|
||||||
|
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes + ForceTunnelPrefix(
|
||||||
|
id = UUID.randomUUID().toString(), prefix = normalized))
|
||||||
|
}
|
||||||
|
return config.forceTunnelPrefixes.lastOrNull { it.prefix == normalized }?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Replaces the prefix of an existing entry; false if the new one is invalid. */
|
||||||
|
fun updateForcePrefix(id: String, cidr: String): Boolean
|
||||||
|
{
|
||||||
|
val normalized = normalizePrefix(cidr) ?: return false
|
||||||
|
updateConfigNoReconnect { config ->
|
||||||
|
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.map {
|
||||||
|
if (it.id == id) it.copy(prefix = normalized) else it })
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteForcePrefix(id: String)
|
||||||
|
{
|
||||||
|
updateConfigNoReconnect { config ->
|
||||||
|
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.filter { it.id != id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setForcePrefixEnabled(id: String, enabled: Boolean)
|
||||||
|
{
|
||||||
|
updateConfigNoReconnect { config ->
|
||||||
|
config.copy(forceTunnelPrefixes = config.forceTunnelPrefixes.map {
|
||||||
|
if (it.id == id) it.copy(enabled = enabled) else it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enabled force-tunnel prefixes as validated CIDR strings (userspace
|
||||||
|
* routing feeds them into the native force set), including the
|
||||||
|
* addresses of "through the VPN" site rules.
|
||||||
|
*/
|
||||||
|
fun forceTunnelCidrs(): List<String> =
|
||||||
|
config.forceTunnelPrefixes.filter { it.enabled }.map { it.prefix } + siteCidrs(direct = false)
|
||||||
|
|
||||||
|
/** Enabled direct addresses plus the addresses of "direct" site rules. */
|
||||||
|
fun directCidrs(): List<String> =
|
||||||
|
config.directPrefixes.filter { it.enabled }.map { it.prefix } + siteCidrs(direct = true)
|
||||||
|
|
||||||
|
private fun siteCidrs(direct: Boolean): List<String>
|
||||||
|
{
|
||||||
|
val resolved = siteAddresses
|
||||||
|
return config.siteRules.filter { it.enabled && it.direct == direct }.flatMap { resolved[it.domain].orEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- direct addresses ------------------------- */
|
||||||
|
|
||||||
|
/** Adds a direct address/network; null if invalid. */
|
||||||
|
fun addDirectPrefix(cidr: String): String?
|
||||||
|
{
|
||||||
|
val normalized = normalizePrefix(cidr) ?: return null
|
||||||
|
val id = UUID.randomUUID().toString()
|
||||||
|
updateConfigNoReconnect { config ->
|
||||||
|
config.copy(directPrefixes = config.directPrefixes + ForceTunnelPrefix(id = id, prefix = normalized))
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteDirectPrefix(id: String)
|
||||||
|
{
|
||||||
|
updateConfigNoReconnect { config -> config.copy(directPrefixes = config.directPrefixes.filter { it.id != id }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDirectPrefixEnabled(id: String, enabled: Boolean)
|
||||||
|
{
|
||||||
|
updateConfigNoReconnect { config ->
|
||||||
|
config.copy(directPrefixes = config.directPrefixes.map { if (it.id == id) it.copy(enabled = enabled) else it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- site rules ------------------------- */
|
||||||
|
|
||||||
|
/** Adds (or replaces) the rule for [input]'s domain; null if it is not a domain. */
|
||||||
|
fun addSiteRule(input: String, direct: Boolean): String?
|
||||||
|
{
|
||||||
|
val domain = normalizeDomain(input) ?: return null
|
||||||
|
val id = UUID.randomUUID().toString()
|
||||||
|
updateConfigNoReconnect { config ->
|
||||||
|
config.copy(siteRules = config.siteRules.filter { it.domain != domain } + SiteRule(id, domain, direct))
|
||||||
|
}
|
||||||
|
refreshSites()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteSiteRule(id: String)
|
||||||
|
{
|
||||||
|
updateConfigNoReconnect { config -> config.copy(siteRules = config.siteRules.filter { it.id != id }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSiteRuleEnabled(id: String, enabled: Boolean)
|
||||||
|
{
|
||||||
|
updateConfigNoReconnect { config ->
|
||||||
|
config.copy(siteRules = config.siteRules.map { if (it.id == id) it.copy(enabled = enabled) else it })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the site rules again and pushes the result to the engine.
|
||||||
|
* Called when a rule is added and when the tunnel comes up; repeats every
|
||||||
|
* [SITE_REFRESH_MS] while there are rules and the VPN is connected.
|
||||||
|
*/
|
||||||
|
fun refreshSites()
|
||||||
|
{
|
||||||
|
siteRefreshJob?.cancel()
|
||||||
|
if (config.siteRules.isEmpty())
|
||||||
|
{
|
||||||
|
siteAddresses = emptyMap()
|
||||||
|
_siteAddresses.value = emptyMap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
siteRefreshJob = scope.launch {
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
val domains = config.siteRules.map { it.domain }.distinct()
|
||||||
|
val previous = siteAddresses
|
||||||
|
val resolved = withContext(Dispatchers.IO) { domains.associateWith { resolveSite(it) } }
|
||||||
|
/* keep the previous answer for a domain that failed to resolve this time */
|
||||||
|
val merged = resolved.mapValues { (domain, cidrs) -> cidrs.ifEmpty { previous[domain].orEmpty() } }
|
||||||
|
if (merged != previous)
|
||||||
|
{
|
||||||
|
siteAddresses = merged
|
||||||
|
_siteAddresses.value = merged
|
||||||
|
pushUserspaceRouting()
|
||||||
|
}
|
||||||
|
if (service?.state != VpnStateService.State.CONNECTED)
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delay(SITE_REFRESH_MS)
|
||||||
|
/* nobody looks at the screen: resolving only wakes the radio and
|
||||||
|
* CPU, so wait for it to come on (re-checked every few minutes),
|
||||||
|
* but never let the answers get older than the cap */
|
||||||
|
var waited = SITE_REFRESH_MS
|
||||||
|
while (waited < SITE_REFRESH_SCREEN_OFF_MAX_MS && !isScreenOn())
|
||||||
|
{
|
||||||
|
delay(SITE_SCREEN_RECHECK_MS)
|
||||||
|
waited += SITE_SCREEN_RECHECK_MS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isScreenOn(): Boolean =
|
||||||
|
(context.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager)?.isInteractive ?: true
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like [updateConfig], but force prefixes apply on the fly through the
|
||||||
|
* atomic set swap of the userspace router - no reconnect needed.
|
||||||
|
*/
|
||||||
|
private fun updateConfigNoReconnect(transform: (RoutingConfig) -> RoutingConfig)
|
||||||
|
{
|
||||||
|
config = transform(config)
|
||||||
|
saveConfig(config)
|
||||||
|
publish()
|
||||||
|
pushUserspaceRouting()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pushUserspaceRouting()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
org.strongswan.android.logic.CharonVpnService.pushUserspaceRoutingData(context)
|
||||||
|
}
|
||||||
|
catch (e: Throwable)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Failed to push userspace routing data", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- connect-time merge ------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packages that must bypass the tunnel right now. Empty when routing is
|
||||||
|
* disabled. Safe from any thread; cached per config version (the cache is
|
||||||
|
* computed on first use, e.g. on the charon thread while the TUN is built).
|
||||||
|
*/
|
||||||
|
fun computeExcludedPackages(): Set<String>
|
||||||
|
{
|
||||||
|
/* version before config: updateConfig() writes config first, so this
|
||||||
|
* order can at worst cache a newer config under an older version
|
||||||
|
* (recomputed next time), never an old config under the new one */
|
||||||
|
val version = configVersion
|
||||||
|
val current = config
|
||||||
|
if (!current.enabled)
|
||||||
|
{
|
||||||
|
return emptySet()
|
||||||
|
}
|
||||||
|
packageCache?.let { (cachedVersion, packages) ->
|
||||||
|
if (cachedVersion == version)
|
||||||
|
{
|
||||||
|
return packages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
synchronized(this)
|
||||||
|
{
|
||||||
|
packageCache?.let { (cachedVersion, packages) ->
|
||||||
|
if (cachedVersion == version)
|
||||||
|
{
|
||||||
|
return packages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val packages = effectiveExcludes(current, loadInstalledPackages())
|
||||||
|
packageCache = version to packages
|
||||||
|
return packages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateCache()
|
||||||
|
{
|
||||||
|
configVersion++
|
||||||
|
packageCache = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInstalledPackages(): Set<String> = loadInternetPackages(context.packageManager)
|
||||||
|
|
||||||
|
private fun publish()
|
||||||
|
{
|
||||||
|
_state.value = RoutingState(config = config, migratedOnlyProfiles = migratedOnlyProfiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleReconnect()
|
||||||
|
{
|
||||||
|
handler.removeCallbacks(reconnectRunnable)
|
||||||
|
handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------- persistence + migration ------------------------- */
|
||||||
|
|
||||||
|
private fun loadConfig(): RoutingConfig
|
||||||
|
{
|
||||||
|
val json = prefs.getString(KEY_CONFIG, null) ?: return RoutingConfig()
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val obj = JSONObject(json)
|
||||||
|
if (obj.optInt("version", 1) >= CONFIG_VERSION)
|
||||||
|
{
|
||||||
|
parseConfig(obj)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
migrateLegacyConfig(json, obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: JSONException)
|
||||||
|
{
|
||||||
|
RoutingConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseConfig(obj: JSONObject) = RoutingConfig(
|
||||||
|
enabled = obj.optBoolean("enabled", true),
|
||||||
|
directPackages = obj.optJSONArray("directPackages").toStringSet(),
|
||||||
|
tunnelOverrides = obj.optJSONArray("tunnelOverrides").toStringSet(),
|
||||||
|
enabledCategories = obj.optJSONArray("enabledCategories").toStringSet(),
|
||||||
|
packagePatterns = obj.optJSONArray("packagePatterns").toStringSet(),
|
||||||
|
disabledPatterns = obj.optJSONArray("disabledPatterns").toStringSet(),
|
||||||
|
forceTunnelPrefixes = parseForcePrefixes(obj),
|
||||||
|
directPrefixes = parseForcePrefixes(obj, "directPrefixes"),
|
||||||
|
siteRules = obj.optJSONArray("siteRules")?.let { arr ->
|
||||||
|
(0 until arr.length()).mapNotNull { i ->
|
||||||
|
arr.optJSONObject(i)?.let { o ->
|
||||||
|
SiteRule(
|
||||||
|
id = o.optString("id"),
|
||||||
|
domain = o.optString("domain"),
|
||||||
|
direct = o.optBoolean("direct"),
|
||||||
|
enabled = o.optBoolean("enabled", true),
|
||||||
|
).takeIf { it.domain.isNotBlank() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList(),
|
||||||
|
version = CONFIG_VERSION,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseForcePrefixes(obj: JSONObject, key: String = "forceTunnelPrefixes"): List<ForceTunnelPrefix> =
|
||||||
|
obj.optJSONArray(key)?.let { arr ->
|
||||||
|
(0 until arr.length()).mapNotNull { i ->
|
||||||
|
arr.optJSONObject(i)?.let { o ->
|
||||||
|
ForceTunnelPrefix(
|
||||||
|
id = o.optString("id"),
|
||||||
|
prefix = o.optString("prefix"),
|
||||||
|
enabled = o.optBoolean("enabled", true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
/** Rule sets (version 1) -> direct apps + package patterns; the old JSON is kept in a backup key. */
|
||||||
|
private fun migrateLegacyConfig(json: String, obj: JSONObject): RoutingConfig
|
||||||
|
{
|
||||||
|
if (!prefs.contains(KEY_CONFIG_BACKUP))
|
||||||
|
{
|
||||||
|
prefs.edit().putString(KEY_CONFIG_BACKUP, json).commit()
|
||||||
|
}
|
||||||
|
val ruleSets = obj.optJSONArray("ruleSets")?.let { arr ->
|
||||||
|
(0 until arr.length()).mapNotNull { i ->
|
||||||
|
arr.optJSONObject(i)?.let { o ->
|
||||||
|
RuleSet(
|
||||||
|
id = o.optString("id"),
|
||||||
|
name = o.optString("name"),
|
||||||
|
enabled = o.optBoolean("enabled", true),
|
||||||
|
packages = o.optJSONArray("packages").toStringSet(),
|
||||||
|
prefixes = o.optJSONArray("prefixes").toStringSet(),
|
||||||
|
keywords = o.optJSONArray("keywords").toStringSet(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
val migrated = RoutingMigration.migrateRuleSets(ruleSets)
|
||||||
|
val result = RoutingConfig(
|
||||||
|
enabled = obj.optBoolean("enabled", true),
|
||||||
|
directPackages = migrated.directPackages,
|
||||||
|
enabledCategories = migrated.categories,
|
||||||
|
packagePatterns = migrated.patterns,
|
||||||
|
forceTunnelPrefixes = parseForcePrefixes(obj),
|
||||||
|
)
|
||||||
|
Log.i(TAG, "Migrated ${ruleSets.size} rule sets to ${result.directPackages.size} apps, " +
|
||||||
|
"${result.packagePatterns.size} patterns, ${result.enabledCategories.size} categories")
|
||||||
|
saveConfig(result, commit = true)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-profile app handling is gone: EXCLUDE lists become direct apps, an
|
||||||
|
* ONLY list becomes "every other installed internet app" (snapshot), and
|
||||||
|
* the profile is reset to DISABLE. Runs once (flag in prefs); a failure
|
||||||
|
* leaves the flag unset so the next start retries.
|
||||||
|
*/
|
||||||
|
private fun migrateProfiles()
|
||||||
|
{
|
||||||
|
if (prefs.getBoolean(KEY_PROFILES_MIGRATED, false))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val source = VpnProfileSqlDataSource()
|
||||||
|
source.open()
|
||||||
|
val direct = HashSet<String>()
|
||||||
|
var onlyConverted = 0
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val installed by lazy { loadInstalledPackages() }
|
||||||
|
for (profile in source.allVpnProfiles)
|
||||||
|
{
|
||||||
|
val handling = profile.selectedAppsHandling
|
||||||
|
if (handling == SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val added = RoutingMigration.profileAppsToDirect(
|
||||||
|
handling, profile.selectedAppsSet, if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY) installed else emptySet(),
|
||||||
|
context.packageName)
|
||||||
|
direct.addAll(added)
|
||||||
|
if (handling == SelectedAppsHandling.SELECTED_APPS_ONLY && added.isNotEmpty())
|
||||||
|
{
|
||||||
|
onlyConverted++
|
||||||
|
}
|
||||||
|
profile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||||
|
source.updateVpnProfile(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
if (direct.isNotEmpty())
|
||||||
|
{ /* the old per-profile excludes applied regardless of the global switch */
|
||||||
|
config = config.copy(enabled = true, directPackages = config.directPackages + direct,
|
||||||
|
tunnelOverrides = config.tunnelOverrides - direct)
|
||||||
|
saveConfig(config, commit = true)
|
||||||
|
}
|
||||||
|
prefs.edit()
|
||||||
|
.putBoolean(KEY_PROFILES_MIGRATED, true)
|
||||||
|
.putInt(KEY_NOTICE_ONLY, onlyConverted)
|
||||||
|
.commit()
|
||||||
|
Log.i(TAG, "Migrated profile apps: ${direct.size} direct apps, $onlyConverted 'only' profiles converted")
|
||||||
|
}
|
||||||
|
catch (e: Throwable)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Profile app migration failed, will retry on next start", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveConfig(config: RoutingConfig, commit: Boolean = false)
|
||||||
|
{
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("version", CONFIG_VERSION)
|
||||||
|
obj.put("enabled", config.enabled)
|
||||||
|
obj.put("directPackages", JSONArray(config.directPackages.sorted()))
|
||||||
|
obj.put("tunnelOverrides", JSONArray(config.tunnelOverrides.sorted()))
|
||||||
|
obj.put("enabledCategories", JSONArray(config.enabledCategories.sorted()))
|
||||||
|
obj.put("packagePatterns", JSONArray(config.packagePatterns.sorted()))
|
||||||
|
obj.put("disabledPatterns", JSONArray(config.disabledPatterns.sorted()))
|
||||||
|
val forcePrefixes = JSONArray()
|
||||||
|
for (entry in config.forceTunnelPrefixes)
|
||||||
|
{
|
||||||
|
val o = JSONObject()
|
||||||
|
o.put("id", entry.id)
|
||||||
|
o.put("prefix", entry.prefix)
|
||||||
|
o.put("enabled", entry.enabled)
|
||||||
|
forcePrefixes.put(o)
|
||||||
|
}
|
||||||
|
obj.put("forceTunnelPrefixes", forcePrefixes)
|
||||||
|
val directPrefixes = JSONArray()
|
||||||
|
for (entry in config.directPrefixes)
|
||||||
|
{
|
||||||
|
directPrefixes.put(JSONObject().put("id", entry.id).put("prefix", entry.prefix).put("enabled", entry.enabled))
|
||||||
|
}
|
||||||
|
obj.put("directPrefixes", directPrefixes)
|
||||||
|
val sites = JSONArray()
|
||||||
|
for (rule in config.siteRules)
|
||||||
|
{
|
||||||
|
sites.put(JSONObject().put("id", rule.id).put("domain", rule.domain).put("direct", rule.direct).put("enabled", rule.enabled))
|
||||||
|
}
|
||||||
|
obj.put("siteRules", sites)
|
||||||
|
val editor = prefs.edit().putString(KEY_CONFIG, obj.toString())
|
||||||
|
if (commit) editor.commit() else editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun JSONArray?.toStringSet(): Set<String> =
|
||||||
|
this?.let { arr -> (0 until arr.length()).mapNotNull { i -> arr.optString(i).takeIf { it.isNotBlank() } }.toSet() } ?: emptySet()
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
private const val TAG = "RoutingManager"
|
||||||
|
private const val PREFS_NAME = "one.shx.strongswan.ext.ROUTING"
|
||||||
|
private const val KEY_CONFIG = "config"
|
||||||
|
private const val KEY_CONFIG_BACKUP = "config_v1_backup"
|
||||||
|
private const val KEY_PROFILES_MIGRATED = "profiles_migrated_v2"
|
||||||
|
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
|
||||||
|
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
||||||
|
const val SITE_REFRESH_MS = 30L * 60 * 1000
|
||||||
|
/* with the screen off the refresh waits for it, up to this age */
|
||||||
|
private const val SITE_REFRESH_SCREEN_OFF_MAX_MS = 3L * 60 * 60 * 1000
|
||||||
|
private const val SITE_SCREEN_RECHECK_MS = 5L * 60 * 1000
|
||||||
|
|
||||||
|
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
|
||||||
|
const val CONFIG_VERSION = 2
|
||||||
|
|
||||||
|
/** Validates and canonicalizes a CIDR/address; null if invalid. */
|
||||||
|
fun normalizePrefix(cidr: String): String? = try
|
||||||
|
{
|
||||||
|
org.strongswan.android.utils.IPRange(cidr.trim()).toString()
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "https://www.Example.com/path" -> "example.com"; null if the input
|
||||||
|
* is not a plausible host name (an address belongs in an address rule).
|
||||||
|
*/
|
||||||
|
fun normalizeDomain(input: String): String?
|
||||||
|
{
|
||||||
|
var host = input.trim().lowercase()
|
||||||
|
host = host.substringAfter("://").substringBefore('/').substringBefore('?').substringBefore(':')
|
||||||
|
host = host.removePrefix("*.").removePrefix("www.").trimEnd('.')
|
||||||
|
val label = Regex("[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?")
|
||||||
|
val labels = host.split('.')
|
||||||
|
if (labels.size < 2 || labels.any { !label.matches(it) } || labels.last().all { it.isDigit() })
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The addresses [domain] and www.[domain] resolve to, as host CIDRs. */
|
||||||
|
private fun resolveSite(domain: String): List<String> =
|
||||||
|
listOf(domain, "www.$domain").flatMap { name ->
|
||||||
|
try
|
||||||
|
{
|
||||||
|
java.net.InetAddress.getAllByName(name).toList()
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}.map { it.hostAddress + if (it is java.net.Inet6Address) "/128" else "/32" }.distinct()
|
||||||
|
|
||||||
|
/** All installed apps that hold the INTERNET permission. */
|
||||||
|
fun loadInternetPackages(pm: PackageManager): Set<String> = try
|
||||||
|
{
|
||||||
|
pm.getInstalledApplications(0)
|
||||||
|
.asSequence()
|
||||||
|
.filter { pm.checkPermission(android.Manifest.permission.INTERNET, it.packageName) == PackageManager.PERMISSION_GRANTED }
|
||||||
|
.map { it.packageName }
|
||||||
|
.toSet()
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Failed to list installed packages", e)
|
||||||
|
emptySet()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Built-in categories that are enabled in [config] and match [pkg], visible ones first. */
|
||||||
|
private fun matchingCategory(pkg: String, config: RoutingConfig): String?
|
||||||
|
{
|
||||||
|
val visible = RoutingPresets.CATEGORIES.firstOrNull {
|
||||||
|
it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
|
||||||
|
}
|
||||||
|
if (visible != null)
|
||||||
|
{
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
return RoutingPresets.NAMESPACE.takeIf {
|
||||||
|
it in config.enabledCategories && RoutingPresets.matchesCategory(it, pkg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why a category or pattern covers [pkg] (ignoring personal overrides
|
||||||
|
* and the explicit list), or null if none does.
|
||||||
|
*/
|
||||||
|
fun templateMatch(pkg: String, config: RoutingConfig): TemplateMatch?
|
||||||
|
{
|
||||||
|
val pattern = (config.packagePatterns - config.disabledPatterns).firstOrNull { PackagePatterns.matches(it, pkg) }
|
||||||
|
val category = matchingCategory(pkg, config)
|
||||||
|
return if (category == null && pattern == null) null else TemplateMatch(category, pattern)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All packages that bypass the tunnel: explicit direct apps plus the
|
||||||
|
* installed apps matched by enabled categories and package patterns,
|
||||||
|
* minus personal "via VPN" overrides. Explicit apps that are not
|
||||||
|
* installed are dropped (unless the installed list is unavailable).
|
||||||
|
*/
|
||||||
|
fun effectiveExcludes(config: RoutingConfig, installed: Set<String>): Set<String>
|
||||||
|
{
|
||||||
|
val result = HashSet<String>()
|
||||||
|
result.addAll(if (installed.isEmpty()) config.directPackages else config.directPackages.filter { it in installed })
|
||||||
|
result.addAll(PackagePatterns.matchAll(config.packagePatterns - config.disabledPatterns, installed))
|
||||||
|
for (category in config.enabledCategories)
|
||||||
|
{
|
||||||
|
val presets = RoutingPresets.presetsOf(category)
|
||||||
|
for (pkg in installed)
|
||||||
|
{
|
||||||
|
if (presets.any { it.matches(pkg) })
|
||||||
|
{
|
||||||
|
result.add(pkg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.removeAll(config.tunnelOverrides)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: RoutingManager? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(context: Context): RoutingManager =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: RoutingManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+99
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.routing
|
||||||
|
|
||||||
|
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure conversion rules from the pre-2.16 settings (named rule sets and
|
||||||
|
* per-profile split-tunneling apps) to the single global direct-apps list.
|
||||||
|
* No Android dependencies, so they are unit tested; [RoutingManager] applies
|
||||||
|
* them once at startup.
|
||||||
|
*/
|
||||||
|
object RoutingMigration
|
||||||
|
{
|
||||||
|
data class RuleSetResult(
|
||||||
|
val directPackages: Set<String>,
|
||||||
|
val patterns: Set<String>,
|
||||||
|
val categories: Set<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enabled rule sets become explicit packages plus package patterns. A
|
||||||
|
* built-in category whose every preset is fully present among the rule
|
||||||
|
* sets (what the old "all RF apps" action produced) turns into the
|
||||||
|
* category itself - same coverage, but shown as one switch. Disabled rule
|
||||||
|
* sets are dropped (the old JSON is kept in a backup key).
|
||||||
|
*/
|
||||||
|
fun migrateRuleSets(ruleSets: List<RuleSet>): RuleSetResult
|
||||||
|
{
|
||||||
|
val active = ruleSets.filter { it.enabled }
|
||||||
|
val direct = HashSet<String>()
|
||||||
|
val patterns = LinkedHashSet<String>()
|
||||||
|
for (ruleSet in active)
|
||||||
|
{
|
||||||
|
direct.addAll(ruleSet.packages)
|
||||||
|
ruleSet.prefixes.forEach { patterns.add(PackagePatterns.fromPrefix(it)) }
|
||||||
|
ruleSet.keywords.forEach { patterns.add(PackagePatterns.fromKeyword(it)) }
|
||||||
|
}
|
||||||
|
val categories = LinkedHashSet<String>()
|
||||||
|
for (category in RoutingPresets.ALL_CATEGORIES)
|
||||||
|
{
|
||||||
|
val presets = RoutingPresets.presetsOf(category)
|
||||||
|
if (presets.isNotEmpty() && presets.all { preset -> active.any { it.covers(preset) } })
|
||||||
|
{
|
||||||
|
categories.add(category)
|
||||||
|
for (preset in presets)
|
||||||
|
{
|
||||||
|
preset.prefixes.forEach { patterns.remove(PackagePatterns.fromPrefix(it)) }
|
||||||
|
preset.keywords.forEach { patterns.remove(PackagePatterns.fromKeyword(it)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return RuleSetResult(direct, patterns, categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun RuleSet.covers(preset: RoutingPresets.Preset): Boolean
|
||||||
|
{
|
||||||
|
if (preset.prefixes.isEmpty() && preset.keywords.isEmpty())
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val lowerKeywords = keywords.map { it.lowercase() }.toSet()
|
||||||
|
return prefixes.containsAll(preset.prefixes) && lowerKeywords.containsAll(preset.keywords.map { it.lowercase() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Packages a profile's old per-app setting sends direct (outside the VPN):
|
||||||
|
* - EXCLUDE: the listed apps;
|
||||||
|
* - ONLY: "all installed internet apps except the listed ones" snapshot
|
||||||
|
* (an empty ONLY list never took effect in the old code, so it yields
|
||||||
|
* nothing);
|
||||||
|
* - DISABLE: nothing.
|
||||||
|
* The app's own package is never listed (it is always excluded anyway).
|
||||||
|
*/
|
||||||
|
fun profileAppsToDirect(
|
||||||
|
handling: SelectedAppsHandling,
|
||||||
|
selected: Set<String>,
|
||||||
|
installed: Set<String>,
|
||||||
|
ownPackage: String,
|
||||||
|
): Set<String> = when (handling)
|
||||||
|
{
|
||||||
|
SelectedAppsHandling.SELECTED_APPS_EXCLUDE -> selected - ownPackage
|
||||||
|
SelectedAppsHandling.SELECTED_APPS_ONLY ->
|
||||||
|
if (selected.isEmpty()) emptySet() else installed - selected - ownPackage
|
||||||
|
else -> emptySet()
|
||||||
|
}
|
||||||
|
}
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.routing
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in app templates, grouped into categories the user can switch on in
|
||||||
|
* one tap ("Banks", "Marketplaces", ...).
|
||||||
|
* - Prefixes match package names, so "ru." catches every package in the ru.*
|
||||||
|
* reverse-domain namespace.
|
||||||
|
* - Keywords match anywhere in the package name (case-insensitive), so
|
||||||
|
* "yandex" also catches e.g. com.yandex.browser.
|
||||||
|
*
|
||||||
|
* Deliberately short or ambiguous strings (yota, cian, mts, ivi, mkb) are
|
||||||
|
* used as prefixes only, to avoid false positives.
|
||||||
|
*/
|
||||||
|
object RoutingPresets
|
||||||
|
{
|
||||||
|
/* category ids; the namespace-wide preset lives in the hidden NAMESPACE
|
||||||
|
* category that only "all RF apps" switches on */
|
||||||
|
const val BANKS = "banks"
|
||||||
|
const val GOV = "gov"
|
||||||
|
const val SHOP = "shop"
|
||||||
|
const val SOCIAL = "social"
|
||||||
|
const val TRAVEL = "travel"
|
||||||
|
const val SERVICES = "services"
|
||||||
|
const val NAMESPACE = "ru_namespace"
|
||||||
|
|
||||||
|
/** User-visible categories in display order (the hidden [NAMESPACE] one is excluded). */
|
||||||
|
val CATEGORIES: List<String> = listOf(BANKS, GOV, SHOP, SOCIAL, TRAVEL, SERVICES)
|
||||||
|
|
||||||
|
/** Everything "all RF apps" switches on. */
|
||||||
|
val ALL_CATEGORIES: Set<String> = (CATEGORIES + NAMESPACE).toSet()
|
||||||
|
|
||||||
|
data class Preset(
|
||||||
|
val name: String,
|
||||||
|
val category: String,
|
||||||
|
val prefixes: List<String> = emptyList(),
|
||||||
|
val keywords: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether a package belongs to the preset (prefix, or keyword anywhere, case-insensitive). */
|
||||||
|
fun Preset.matches(pkg: String): Boolean
|
||||||
|
{
|
||||||
|
if (prefixes.any { pkg.startsWith(it) })
|
||||||
|
{
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val lower = pkg.lowercase()
|
||||||
|
return keywords.any { lower.contains(it.lowercase()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Presets of one category, in declaration order. */
|
||||||
|
fun presetsOf(category: String): List<Preset> = RF_BUNDLE.filter { it.category == category }
|
||||||
|
|
||||||
|
/** Whether the package matches any preset of [category] (the namespace one included). */
|
||||||
|
fun matchesCategory(category: String, pkg: String): Boolean =
|
||||||
|
presetsOf(category).any { it.matches(pkg) }
|
||||||
|
|
||||||
|
/** First user-visible category the package belongs to, or null. */
|
||||||
|
fun categoryOf(pkg: String): String? =
|
||||||
|
CATEGORIES.firstOrNull { matchesCategory(it, pkg) }
|
||||||
|
|
||||||
|
val RF_BUNDLE: List<Preset> = listOf(
|
||||||
|
/* namespace-wide */
|
||||||
|
Preset("Все RU.*", NAMESPACE, prefixes = listOf("ru.")),
|
||||||
|
/* ecosystems */
|
||||||
|
Preset("Яндекс", SOCIAL, prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
|
||||||
|
Preset("VK", SOCIAL, prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
|
||||||
|
Preset("Одноклассники", SOCIAL, prefixes = listOf("ru.ok.")),
|
||||||
|
Preset("Дзен", SOCIAL, keywords = listOf("dzen")),
|
||||||
|
Preset("Rutube", SOCIAL, keywords = listOf("rutube")),
|
||||||
|
Preset("Звук", SOCIAL, keywords = listOf("zvuk", "zvooq")),
|
||||||
|
Preset("Иви", SOCIAL, prefixes = listOf("ru.ivi")),
|
||||||
|
/* banks & finance */
|
||||||
|
Preset("Сбер", BANKS, keywords = listOf("sber")),
|
||||||
|
Preset("Т-Банк", BANKS, keywords = listOf("tinkoff", "tbank")),
|
||||||
|
Preset("ВТБ", BANKS, keywords = listOf("vtb")),
|
||||||
|
Preset("Альфа-Банк", BANKS, keywords = listOf("alfabank", "alpha-bank")),
|
||||||
|
Preset("Газпромбанк", BANKS, keywords = listOf("gazprombank")),
|
||||||
|
Preset("Совкомбанк", BANKS, keywords = listOf("sovcombank")),
|
||||||
|
Preset("Почта Банк", BANKS, keywords = listOf("pochtabank")),
|
||||||
|
Preset("РСХБ", BANKS, keywords = listOf("rshb", "rosselkhozbank")),
|
||||||
|
Preset("ОТП-Банк", BANKS, prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
|
||||||
|
Preset("МКБ", BANKS, prefixes = listOf("ru.mkb")),
|
||||||
|
Preset("Уралсиб", BANKS, keywords = listOf("uralsib")),
|
||||||
|
Preset("ЮMoney", BANKS, keywords = listOf("yoomoney")),
|
||||||
|
Preset("Золотая Корона", BANKS, keywords = listOf("koronapay")),
|
||||||
|
/* government */
|
||||||
|
Preset("Госуслуги", GOV, prefixes = listOf("ru.rostel"), keywords = listOf("gosuslugi")),
|
||||||
|
Preset("Мой налог", GOV, keywords = listOf("nalog")),
|
||||||
|
/* retail & marketplaces */
|
||||||
|
Preset("Ozon", SHOP, keywords = listOf("ozon")),
|
||||||
|
Preset("Wildberries", SHOP, keywords = listOf("wildberries")),
|
||||||
|
Preset("Мегамаркет", SHOP, keywords = listOf("megamarket")),
|
||||||
|
Preset("Пятёрочка", SHOP, keywords = listOf("pyaterochka")),
|
||||||
|
Preset("Перекрёсток", SHOP, keywords = listOf("perekrestok")),
|
||||||
|
Preset("Магнит", SHOP, keywords = listOf("magnit")),
|
||||||
|
Preset("Лента", SHOP, keywords = listOf("lenta")),
|
||||||
|
Preset("М.Видео", SHOP, keywords = listOf("mvideo")),
|
||||||
|
Preset("Спортмастер", SHOP, keywords = listOf("sportmaster")),
|
||||||
|
Preset("Лемана ПРО", SHOP, keywords = listOf("leroymerlin", "lemana")),
|
||||||
|
Preset("Самокат", SHOP, keywords = listOf("samokat")),
|
||||||
|
Preset("ВкусВилл", SHOP, keywords = listOf("vkusvill")),
|
||||||
|
/* telecom */
|
||||||
|
Preset("МТС", SOCIAL, prefixes = listOf("ru.mts", "com.mts")),
|
||||||
|
Preset("Мегафон", SOCIAL, keywords = listOf("megafon")),
|
||||||
|
Preset("Билайн", SOCIAL, keywords = listOf("beeline")),
|
||||||
|
Preset("T2/Tele2", SOCIAL, prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
|
||||||
|
Preset("Yota", SOCIAL, prefixes = listOf("ru.yota")),
|
||||||
|
/* transport & travel */
|
||||||
|
Preset("РЖД", TRAVEL, keywords = listOf("rzd")),
|
||||||
|
Preset("Аэрофлот", TRAVEL, keywords = listOf("aeroflot")),
|
||||||
|
Preset("Туту.ру", TRAVEL, keywords = listOf("tutu.ru")),
|
||||||
|
Preset("Aviasales", TRAVEL, keywords = listOf("aviasales")),
|
||||||
|
Preset("Ситимобил", TRAVEL, keywords = listOf("sitimobil")),
|
||||||
|
Preset("Делимобиль", TRAVEL, keywords = listOf("delimobil")),
|
||||||
|
/* services */
|
||||||
|
Preset("2ГИС", SERVICES, keywords = listOf("2gis", "dublgis")),
|
||||||
|
Preset("ЦИАН", SERVICES, prefixes = listOf("ru.cian")),
|
||||||
|
Preset("Дром", SERVICES, keywords = listOf("drom")),
|
||||||
|
Preset("Юла", SERVICES, keywords = listOf("youla")),
|
||||||
|
Preset("HeadHunter", SERVICES, prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
|
||||||
|
Preset("Почта России", SERVICES, keywords = listOf("pochta", "russianpost")),
|
||||||
|
Preset("СДЭК", SERVICES, keywords = listOf("cdek")),
|
||||||
|
)
|
||||||
|
}
|
||||||
+794
@@ -0,0 +1,794 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.routing
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.utils.Constants
|
||||||
|
import org.strongswan.android.utils.IPRange
|
||||||
|
import org.strongswan.android.utils.IPRangeSet
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.Proxy
|
||||||
|
import java.net.URL
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
|
/** Refresh status of the RU geoip list. */
|
||||||
|
enum class RuIpListStatus
|
||||||
|
{
|
||||||
|
IDLE, UPDATING, UP_TO_DATE, ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RuIpListState(
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
val status: RuIpListStatus = RuIpListStatus.IDLE,
|
||||||
|
val lastUpdatedMs: Long = 0L,
|
||||||
|
val ipv4Ranges: Int = 0,
|
||||||
|
val ipv6Ranges: Int = 0,
|
||||||
|
val invalidLines: Int = 0,
|
||||||
|
val mode: String = Constants.RUSPLIT_MODE_SYSTEM,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RU geoip list (beta): downloads the list of Russian IP networks from
|
||||||
|
* MetaCubeX/meta-rules-dat so [CharonVpnService.BuilderCache] can keep those
|
||||||
|
* ranges out of the tunnel routes - traffic to RU addresses bypasses the
|
||||||
|
* VPN while everything else goes through the tunnel.
|
||||||
|
*
|
||||||
|
* The list is large (thousands of CIDRs), so it is merged into a sorted,
|
||||||
|
* non-overlapping form once, and then rounded up ([fitExclusionsToComplement])
|
||||||
|
* until the complement - the routes left for the tunnel - is small enough
|
||||||
|
* to install as regular routes on every Android version. excludeRoute() is
|
||||||
|
* deliberately never used: on some devices (One UI was observed) any
|
||||||
|
* excluded route breaks the resolver's DNS-over-TLS transport for every
|
||||||
|
* server, which kills strict private DNS. With plain routes the RU space
|
||||||
|
* is simply absent from the VPN table, and bypassed traffic falls through
|
||||||
|
* to the physical network. When the list or the toggle changes while
|
||||||
|
* connected, the connection is restarted (debounced) because routes only
|
||||||
|
* apply when the TUN interface is created.
|
||||||
|
*/
|
||||||
|
class RuIpListManager private constructor(private val context: Context)
|
||||||
|
{
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
|
||||||
|
private var service: VpnStateService? = null
|
||||||
|
|
||||||
|
/** Sorted, non-overlapping RU ranges; immutable snapshots swapped on update. */
|
||||||
|
@Volatile
|
||||||
|
private var excludedV4: List<IPRange> = emptyList()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var excludedV6: List<IPRange> = emptyList()
|
||||||
|
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var updating = false
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(RuIpListState())
|
||||||
|
val state: StateFlow<RuIpListState> = _state
|
||||||
|
|
||||||
|
private val connection = object : ServiceConnection
|
||||||
|
{
|
||||||
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||||
|
{
|
||||||
|
service = (binder as VpnStateService.LocalBinder).service
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?)
|
||||||
|
{
|
||||||
|
service = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val reconnectRunnable = Runnable {
|
||||||
|
val svc = service ?: return@Runnable
|
||||||
|
if (svc.state == VpnStateService.State.CONNECTED)
|
||||||
|
{
|
||||||
|
if (CharonVpnService.isUserspaceRoutingEnabled(context))
|
||||||
|
{ /* mode B: the classification set is swapped atomically in
|
||||||
|
* native code, no reestablish needed */
|
||||||
|
CharonVpnService.pushUserspaceRoutingData(context)
|
||||||
|
Log.i(TAG, "RU IP list changed, userspace routing set hot-swapped")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Log.i(TAG, "RU IP list changed, reconnecting to apply it")
|
||||||
|
svc.reconnectPreservingLog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start()
|
||||||
|
{
|
||||||
|
context.bindService(Intent(context, VpnStateService::class.java), connection, android.app.Service.BIND_AUTO_CREATE)
|
||||||
|
publish(
|
||||||
|
enabled = prefs.getBoolean(Constants.PREF_RUSPLIT_GEOIP_ENABLED, false),
|
||||||
|
mode = prefs.getString(Constants.PREF_RUSPLIT_MODE, Constants.RUSPLIT_MODE_SYSTEM)
|
||||||
|
?: Constants.RUSPLIT_MODE_SYSTEM,
|
||||||
|
)
|
||||||
|
scope.launch {
|
||||||
|
loadFromDisk()
|
||||||
|
maybeAutoCheck()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setEnabled(enabled: Boolean)
|
||||||
|
{
|
||||||
|
prefs.edit().putBoolean(Constants.PREF_RUSPLIT_GEOIP_ENABLED, enabled).apply()
|
||||||
|
publish(enabled = enabled)
|
||||||
|
if (isUserspaceMode())
|
||||||
|
{ /* the OS routes do not depend on the toggle in this mode, the
|
||||||
|
* classification set is hot-swapped in native code instead */
|
||||||
|
CharonVpnService.pushUserspaceRoutingData(context)
|
||||||
|
}
|
||||||
|
else if (enabled || excludedV4.isNotEmpty() || excludedV6.isNotEmpty())
|
||||||
|
{ /* routes only change when the TUN is re-established - also when
|
||||||
|
* the split is switched OFF, or the RU ranges keep bypassing the
|
||||||
|
* tunnel until the next manual reconnect */
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sorted non-overlapping RU IPv4 ranges; empty when disabled or not loaded. */
|
||||||
|
fun getExcludedV4(): List<IPRange> = if (_state.value.enabled) excludedV4 else emptyList()
|
||||||
|
|
||||||
|
/** Sorted non-overlapping RU IPv6 ranges; empty when disabled or not loaded. */
|
||||||
|
fun getExcludedV6(): List<IPRange> = if (_state.value.enabled) excludedV6 else emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads and applies a fresh list. Called from the UI (manual refresh,
|
||||||
|
* regardless of the toggle) and internally when the automatic check is due.
|
||||||
|
* @return true when a new list was downloaded and applied
|
||||||
|
*/
|
||||||
|
suspend fun updateNow(): Boolean
|
||||||
|
{
|
||||||
|
if (updating)
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
updating = true
|
||||||
|
publish(status = RuIpListStatus.UPDATING)
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val (text, sha256) = withContext(Dispatchers.IO) { fetchList() }
|
||||||
|
if (sha256 == prefs.getString(PREF_SHA256, null) &&
|
||||||
|
(excludedV4.isNotEmpty() || excludedV6.isNotEmpty()))
|
||||||
|
{ /* identical list: nothing to parse, save or re-apply - above
|
||||||
|
* all no reconnect that tears down a healthy tunnel daily */
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
prefs.edit().putLong(PREF_LAST_UPDATE, now).remove(PREF_LAST_FAIL).apply()
|
||||||
|
publish(status = RuIpListStatus.UP_TO_DATE, lastUpdatedMs = now)
|
||||||
|
Log.i(TAG, "RU geoip list unchanged")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
val parsed = withContext(Dispatchers.Default) { parse(text) }
|
||||||
|
if (parsed.v4.isEmpty() && parsed.v6.isEmpty())
|
||||||
|
{
|
||||||
|
throw IOException("downloaded list contains no CIDRs")
|
||||||
|
}
|
||||||
|
withContext(Dispatchers.IO) { saveToDisk(text) }
|
||||||
|
excludedV4 = parsed.v4
|
||||||
|
excludedV6 = parsed.v6
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
prefs.edit()
|
||||||
|
.putLong(PREF_LAST_UPDATE, now)
|
||||||
|
.putString(PREF_SHA256, sha256)
|
||||||
|
.remove(PREF_LAST_FAIL)
|
||||||
|
.apply()
|
||||||
|
publish(
|
||||||
|
status = RuIpListStatus.UP_TO_DATE,
|
||||||
|
lastUpdatedMs = now,
|
||||||
|
ipv4Ranges = parsed.v4.size,
|
||||||
|
ipv6Ranges = parsed.v6.size,
|
||||||
|
invalidLines = parsed.invalid,
|
||||||
|
)
|
||||||
|
Log.i(TAG, "RU geoip list applied: ${parsed.v4.size} IPv4, ${parsed.v6.size} IPv6 ranges")
|
||||||
|
if (_state.value.enabled)
|
||||||
|
{
|
||||||
|
scheduleReconnect()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "RU geoip list update failed", e)
|
||||||
|
/* remembered so a blocked source is not retried on every cold start */
|
||||||
|
prefs.edit().putLong(PREF_LAST_FAIL, System.currentTimeMillis()).apply()
|
||||||
|
publish(status = RuIpListStatus.ERROR)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
updating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ internals ------------------------------ */
|
||||||
|
|
||||||
|
private suspend fun loadFromDisk()
|
||||||
|
{
|
||||||
|
val file = listFile()
|
||||||
|
if (file.isFile)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
/* thousands of CIDRs: read and parse off the main thread (this
|
||||||
|
* runs at every process start) */
|
||||||
|
val parsed = withContext(Dispatchers.Default) { parse(file.readText()) }
|
||||||
|
excludedV4 = parsed.v4
|
||||||
|
excludedV6 = parsed.v6
|
||||||
|
publish(
|
||||||
|
ipv4Ranges = parsed.v4.size,
|
||||||
|
ipv6Ranges = parsed.v6.size,
|
||||||
|
invalidLines = parsed.invalid,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Failed to load the saved RU geoip list", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
publish(
|
||||||
|
status = if (_state.value.ipv4Ranges > 0 || _state.value.ipv6Ranges > 0) RuIpListStatus.UP_TO_DATE else RuIpListStatus.IDLE,
|
||||||
|
lastUpdatedMs = prefs.getLong(PREF_LAST_UPDATE, 0L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun maybeAutoCheck()
|
||||||
|
{
|
||||||
|
if (!prefs.getBoolean(Constants.PREF_RUSPLIT_GEOIP_ENABLED, false))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isAutoCheckDue(System.currentTimeMillis(), prefs.getLong(PREF_LAST_UPDATE, 0L),
|
||||||
|
prefs.getLong(PREF_LAST_FAIL, 0L)))
|
||||||
|
{
|
||||||
|
scope.launch { updateNow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches the list from the primary source, falling back to the mirror. */
|
||||||
|
private fun fetchList(): Pair<String, String>
|
||||||
|
{
|
||||||
|
var lastError: Exception? = null
|
||||||
|
for (url in listOf(Constants.RUSPLIT_GEOIP_URL, Constants.RUSPLIT_GEOIP_MIRROR_URL))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return fetch(url)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Fetch failed: $url", e)
|
||||||
|
lastError = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError ?: IOException("no source available")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetch(url: String): Pair<String, String>
|
||||||
|
{
|
||||||
|
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
|
||||||
|
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
||||||
|
conn.readTimeout = READ_TIMEOUT_MS
|
||||||
|
conn.setRequestProperty("Connection", "close")
|
||||||
|
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val code = conn.responseCode
|
||||||
|
if (code != HttpURLConnection.HTTP_OK)
|
||||||
|
{
|
||||||
|
throw IOException("HTTP $code from $url")
|
||||||
|
}
|
||||||
|
val bytes = conn.inputStream.use { it.readBytes() }
|
||||||
|
val text = bytes.toString(Charsets.UTF_8)
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(bytes)
|
||||||
|
val sha256 = digest.joinToString("") { "%02x".format(it) }
|
||||||
|
if (!text.contains('/'))
|
||||||
|
{ /* guard against an HTML error page served with status 200 */
|
||||||
|
throw IOException("not a CIDR list: $url")
|
||||||
|
}
|
||||||
|
return text to sha256
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveToDisk(text: String)
|
||||||
|
{
|
||||||
|
val dir = File(context.filesDir, DIR)
|
||||||
|
if (!dir.exists() && !dir.mkdirs())
|
||||||
|
{
|
||||||
|
throw IOException("cannot create ${dir.name}")
|
||||||
|
}
|
||||||
|
val target = listFile()
|
||||||
|
val tmp = File(dir, "$FILE_NAME.tmp")
|
||||||
|
try
|
||||||
|
{
|
||||||
|
tmp.writeText(text, Charsets.UTF_8)
|
||||||
|
if (!tmp.renameTo(target))
|
||||||
|
{
|
||||||
|
target.delete()
|
||||||
|
if (!tmp.renameTo(target))
|
||||||
|
{
|
||||||
|
throw IOException("cannot replace ${target.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
tmp.delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun listFile(): File = File(File(context.filesDir, DIR), FILE_NAME)
|
||||||
|
|
||||||
|
private fun scheduleReconnect()
|
||||||
|
{
|
||||||
|
handler.removeCallbacks(reconnectRunnable)
|
||||||
|
handler.postDelayed(reconnectRunnable, RECONNECT_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mode changes always alter the OS route scheme, so they force a reconnect. */
|
||||||
|
private val forceReconnectRunnable = Runnable {
|
||||||
|
val svc = service ?: return@Runnable
|
||||||
|
if (svc.state == VpnStateService.State.CONNECTED)
|
||||||
|
{
|
||||||
|
Log.i(TAG, "routing mode changed, reconnecting to apply it")
|
||||||
|
svc.reconnectPreservingLog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isUserspaceMode(): Boolean =
|
||||||
|
Constants.RUSPLIT_MODE_USERSPACE == _state.value.mode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches how the RU split is routed when the toggle is on:
|
||||||
|
* [Constants.RUSPLIT_MODE_SYSTEM] installs the coarsened complement as
|
||||||
|
* OS routes, [Constants.RUSPLIT_MODE_USERSPACE] classifies per packet
|
||||||
|
* in native code. Requires a reconnect because the OS routes change.
|
||||||
|
*/
|
||||||
|
fun setMode(mode: String)
|
||||||
|
{
|
||||||
|
if (_state.value.mode == mode)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs.edit().putString(Constants.PREF_RUSPLIT_MODE, mode).apply()
|
||||||
|
publish(mode = mode)
|
||||||
|
if (_state.value.enabled)
|
||||||
|
{
|
||||||
|
handler.removeCallbacks(reconnectRunnable)
|
||||||
|
handler.removeCallbacks(forceReconnectRunnable)
|
||||||
|
handler.postDelayed(forceReconnectRunnable, RECONNECT_DEBOUNCE_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publish(
|
||||||
|
enabled: Boolean = _state.value.enabled,
|
||||||
|
status: RuIpListStatus = _state.value.status,
|
||||||
|
lastUpdatedMs: Long = _state.value.lastUpdatedMs,
|
||||||
|
ipv4Ranges: Int = _state.value.ipv4Ranges,
|
||||||
|
ipv6Ranges: Int = _state.value.ipv6Ranges,
|
||||||
|
invalidLines: Int = _state.value.invalidLines,
|
||||||
|
mode: String = _state.value.mode,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
_state.value = RuIpListState(enabled, status, lastUpdatedMs, ipv4Ranges, ipv6Ranges, invalidLines, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
private const val TAG = "RuIpListManager"
|
||||||
|
private const val DIR = "rusplit"
|
||||||
|
private const val FILE_NAME = "ru.list"
|
||||||
|
private const val PREF_LAST_UPDATE = "rusplit_last_update"
|
||||||
|
private const val PREF_SHA256 = "rusplit_sha256"
|
||||||
|
private const val PREF_LAST_FAIL = "rusplit_last_fail"
|
||||||
|
/* after a failed download wait this long before the next automatic try */
|
||||||
|
internal const val FAIL_RETRY_MS = 3 * 60 * 60 * 1000L
|
||||||
|
|
||||||
|
/** Whether the automatic refresh is due (pure, unit-testable). */
|
||||||
|
@JvmStatic
|
||||||
|
internal fun isAutoCheckDue(now: Long, lastUpdate: Long, lastFail: Long): Boolean
|
||||||
|
{
|
||||||
|
if (now - lastUpdate < Constants.RUSPLIT_CHECK_INTERVAL_MS)
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
/* clock moved backwards: don't let a "future" failure block forever */
|
||||||
|
return lastFail <= 0L || lastFail > now || now - lastFail >= FAIL_RETRY_MS
|
||||||
|
}
|
||||||
|
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
||||||
|
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
private const val READ_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
data class Parsed(val v4: List<IPRange>, val v6: List<IPRange>, val invalid: Int)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a CIDR list tolerantly: blank lines and #-comments are
|
||||||
|
* ignored, a leading '+' include marker is stripped, unparsable and
|
||||||
|
* '-'-negated lines are counted but never fail the whole list.
|
||||||
|
*/
|
||||||
|
fun parse(text: String): Parsed
|
||||||
|
{
|
||||||
|
val v4 = ArrayList<IPRange>()
|
||||||
|
val v6 = ArrayList<IPRange>()
|
||||||
|
var invalid = 0
|
||||||
|
for (raw in text.lineSequence())
|
||||||
|
{
|
||||||
|
var line = raw
|
||||||
|
val comment = line.indexOf('#')
|
||||||
|
if (comment >= 0)
|
||||||
|
{
|
||||||
|
line = line.substring(0, comment)
|
||||||
|
}
|
||||||
|
line = line.trim()
|
||||||
|
if (line.isEmpty())
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (line[0] == '+')
|
||||||
|
{
|
||||||
|
line = line.substring(1).trim()
|
||||||
|
if (line.isEmpty())
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line[0] == '-')
|
||||||
|
{ /* negations are not part of a plain geoip list */
|
||||||
|
invalid++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val range = IPRange(line)
|
||||||
|
when (range.from)
|
||||||
|
{
|
||||||
|
is Inet4Address -> v4.add(range)
|
||||||
|
is Inet6Address -> v6.add(range)
|
||||||
|
else -> invalid++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
invalid++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Parsed(mergeRanges(v4), mergeRanges(v6), invalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sorts by address and merges overlapping/adjacent ranges linearly. */
|
||||||
|
fun mergeRanges(ranges: List<IPRange>): List<IPRange>
|
||||||
|
{
|
||||||
|
if (ranges.isEmpty())
|
||||||
|
{
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
val sorted = ranges.toMutableList()
|
||||||
|
sorted.sort()
|
||||||
|
val result = ArrayList<IPRange>(sorted.size)
|
||||||
|
for (range in sorted)
|
||||||
|
{
|
||||||
|
val last = result.lastOrNull()
|
||||||
|
val merged = last?.merge(range)
|
||||||
|
if (merged != null)
|
||||||
|
{
|
||||||
|
result[result.size - 1] = merged
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.add(range)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes to install for the TUN builder: the given ranges minus the
|
||||||
|
* (sorted, non-overlapping) exclusions, split into proper subnets,
|
||||||
|
* or null if that would exceed [maxRoutes] routes. Applied directly
|
||||||
|
* instead of via IPRangeSet.remove(), which is quadratic in the
|
||||||
|
* number of excluded ranges.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun subtractAndSplit(ranges: IPRangeSet, exclusions: List<IPRange>, maxRoutes: Int): List<IPRange>?
|
||||||
|
{
|
||||||
|
if (exclusions.isEmpty())
|
||||||
|
{
|
||||||
|
return ranges.subnets().toList()
|
||||||
|
}
|
||||||
|
val pieces = ArrayList<IPRange>()
|
||||||
|
for (range in ranges)
|
||||||
|
{
|
||||||
|
pieces.addAll(subtract(range, exclusions))
|
||||||
|
}
|
||||||
|
val result = ArrayList<IPRange>()
|
||||||
|
for (piece in pieces)
|
||||||
|
{
|
||||||
|
result.addAll(piece.toSubnets())
|
||||||
|
if (result.size > maxRoutes)
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The common part of two ranges, or null if they are disjoint. */
|
||||||
|
@JvmStatic
|
||||||
|
fun intersect(a: IPRange, b: IPRange): IPRange?
|
||||||
|
{
|
||||||
|
val from = if (compare(a.from, b.from) > 0) a.from else b.from
|
||||||
|
val to = if (compare(a.to, b.to) < 0) a.to else b.to
|
||||||
|
return if (compare(from, to) <= 0) IPRange(from, to) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The range rounded up to whole prefixes no longer than [maxPrefix].
|
||||||
|
* The result always contains the original range.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun roundUp(range: IPRange, maxPrefix: Int): IPRange =
|
||||||
|
IPRange(alignDown(range.from, maxPrefix), alignUp(range.to, maxPrefix))
|
||||||
|
|
||||||
|
/** Zeroes all host bits beyond [maxPrefix]. */
|
||||||
|
private fun alignDown(address: InetAddress, maxPrefix: Int): InetAddress
|
||||||
|
{
|
||||||
|
val bytes = address.address
|
||||||
|
for (i in bytes.indices)
|
||||||
|
{
|
||||||
|
val keep = (maxPrefix - i * 8).coerceIn(0, 8)
|
||||||
|
val mask = if (keep == 8) 0xff else (0xff shl (8 - keep)) and 0xff
|
||||||
|
bytes[i] = (bytes[i].toInt() and mask).toByte()
|
||||||
|
}
|
||||||
|
return InetAddress.getByAddress(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sets all host bits beyond [maxPrefix], i.e. the last address of the containing prefix. */
|
||||||
|
private fun alignUp(address: InetAddress, maxPrefix: Int): InetAddress
|
||||||
|
{
|
||||||
|
val bytes = address.address
|
||||||
|
for (i in bytes.indices)
|
||||||
|
{
|
||||||
|
val keep = (maxPrefix - i * 8).coerceIn(0, 8)
|
||||||
|
val mask = if (keep == 8) 0xff else (0xff shl (8 - keep)) and 0xff
|
||||||
|
bytes[i] = ((bytes[i].toInt() and mask) or (mask.inv() and 0xff)).toByte()
|
||||||
|
}
|
||||||
|
return InetAddress.getByAddress(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exclusion lists adjusted so the complement route set - what remains
|
||||||
|
* of the tunnel routes after subtracting the exclusions from both
|
||||||
|
* address families - is small enough for the system to install as
|
||||||
|
* regular routes: ranges are rounded up to whole prefixes of
|
||||||
|
* decreasing length (down to [MIN_FIT_PREFIX4]/[MIN_FIT_PREFIX6]) and
|
||||||
|
* re-merged, widening the bypassed space a little. Installing the
|
||||||
|
* complement instead of using excludeRoute() is deliberate: on some
|
||||||
|
* devices (One UI was observed) ANY excluded route breaks the
|
||||||
|
* resolver's DNS-over-TLS transport, while plain route sets of this
|
||||||
|
* size are universally supported. Returns null when even the
|
||||||
|
* coarsest acceptable rounding does not fit; the caller then tunnels
|
||||||
|
* all traffic instead of risking a failed TUN setup.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun fitExclusionsToComplement(
|
||||||
|
ranges4: IPRangeSet?,
|
||||||
|
ranges6: IPRangeSet?,
|
||||||
|
exact4: List<IPRange>,
|
||||||
|
exact6: List<IPRange>,
|
||||||
|
): Pair<List<IPRange>, List<IPRange>>?
|
||||||
|
{
|
||||||
|
if (exact4.isEmpty() && exact6.isEmpty())
|
||||||
|
{
|
||||||
|
return Pair(exact4, exact6)
|
||||||
|
}
|
||||||
|
if (complementBytes(ranges4, ranges6, exact4, exact6) <= ROUTE_BYTES_BUDGET)
|
||||||
|
{
|
||||||
|
return Pair(exact4, exact6)
|
||||||
|
}
|
||||||
|
var len4 = 32
|
||||||
|
var len6 = 128
|
||||||
|
var cur4 = exact4
|
||||||
|
var cur6 = exact6
|
||||||
|
while (complementBytes(ranges4, ranges6, cur4, cur6) > ROUTE_BYTES_BUDGET)
|
||||||
|
{
|
||||||
|
if (len4 <= MIN_FIT_PREFIX4 && len6 <= MIN_FIT_PREFIX6)
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
/* always round the original list, and coarsen whichever family
|
||||||
|
* currently contributes more to the transaction */
|
||||||
|
if (len4 > MIN_FIT_PREFIX4 && (len6 <= MIN_FIT_PREFIX6 || complementBytes4(ranges4, cur4) >= complementBytes6(ranges6, cur6)))
|
||||||
|
{
|
||||||
|
len4--
|
||||||
|
cur4 = mergeRanges(exact4.map { roundUp(it, len4) })
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
len6--
|
||||||
|
cur6 = mergeRanges(exact6.map { roundUp(it, len6) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Pair(cur4, cur6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worst-case marshalled size of a single route entry in the VpnConfig parcel. */
|
||||||
|
private const val V4_ROUTE_BYTES = 64L
|
||||||
|
private const val V6_ROUTE_BYTES = 80L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the estimated size of the complement routes below this, so the
|
||||||
|
* system reliably receives them (binder transactions are limited to
|
||||||
|
* 1 MB; the rest of the config needs room, too). With the real RU
|
||||||
|
* list this lands at roughly 3-4 thousand routes (about 230 KB):
|
||||||
|
* IPv4 rounded to /16, IPv6 to /22.
|
||||||
|
*/
|
||||||
|
private const val ROUTE_BYTES_BUDGET = 350_000L
|
||||||
|
|
||||||
|
/** Coarser rounding than this bypasses too much address space. */
|
||||||
|
private const val MIN_FIT_PREFIX4 = 16
|
||||||
|
private const val MIN_FIT_PREFIX6 = 22
|
||||||
|
|
||||||
|
/** Estimated marshalled size of the complement routes of one family. */
|
||||||
|
private fun complementBytes4(ranges: IPRangeSet?, exclusions: List<IPRange>): Long =
|
||||||
|
complementSubnets(ranges, exclusions) * V4_ROUTE_BYTES
|
||||||
|
|
||||||
|
private fun complementBytes6(ranges: IPRangeSet?, exclusions: List<IPRange>): Long =
|
||||||
|
complementSubnets(ranges, exclusions) * V6_ROUTE_BYTES
|
||||||
|
|
||||||
|
private fun complementBytes(
|
||||||
|
ranges4: IPRangeSet?,
|
||||||
|
ranges6: IPRangeSet?,
|
||||||
|
exclusions4: List<IPRange>,
|
||||||
|
exclusions6: List<IPRange>,
|
||||||
|
): Long = complementBytes4(ranges4, exclusions4) + complementBytes6(ranges6, exclusions6)
|
||||||
|
|
||||||
|
/** Number of proper subnets the complement of the exclusions consists of. */
|
||||||
|
private fun complementSubnets(ranges: IPRangeSet?, exclusions: List<IPRange>): Int
|
||||||
|
{
|
||||||
|
if (ranges == null)
|
||||||
|
{
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var count = 0
|
||||||
|
for (range in ranges)
|
||||||
|
{
|
||||||
|
for (piece in subtract(range, exclusions))
|
||||||
|
{
|
||||||
|
count += piece.toSubnets().size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The part of `range` not covered by the sorted, non-overlapping `exclusions`. */
|
||||||
|
fun subtract(range: IPRange, exclusions: List<IPRange>): List<IPRange>
|
||||||
|
{
|
||||||
|
val result = ArrayList<IPRange>()
|
||||||
|
var cursor = range.from
|
||||||
|
val end = range.to
|
||||||
|
for (exclusion in exclusions)
|
||||||
|
{
|
||||||
|
if (compare(cursor, exclusion.to) > 0)
|
||||||
|
{ /* entirely before the cursor */
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (compare(exclusion.from, end) > 0)
|
||||||
|
{ /* sorted, so nothing further can overlap */
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val from = if (compare(exclusion.from, cursor) > 0) exclusion.from else cursor
|
||||||
|
if (compare(from, cursor) > 0)
|
||||||
|
{ /* uncovered gap in front of the exclusion */
|
||||||
|
result.add(IPRange(cursor, dec(from)))
|
||||||
|
}
|
||||||
|
if (compare(exclusion.to, end) >= 0)
|
||||||
|
{ /* the rest of the range is covered */
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
cursor = inc(exclusion.to)
|
||||||
|
}
|
||||||
|
if (compare(cursor, end) <= 0)
|
||||||
|
{
|
||||||
|
result.add(IPRange(cursor, end))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsigned lexicographic byte comparison of two addresses. */
|
||||||
|
private fun compare(a: InetAddress, b: InetAddress): Int
|
||||||
|
{
|
||||||
|
val x = a.address
|
||||||
|
val y = b.address
|
||||||
|
if (x.size != y.size)
|
||||||
|
{
|
||||||
|
return if (x.size < y.size) -1 else 1
|
||||||
|
}
|
||||||
|
for (i in x.indices)
|
||||||
|
{
|
||||||
|
val diff = (x[i].toInt() and 0xff) - (y[i].toInt() and 0xff)
|
||||||
|
if (diff != 0)
|
||||||
|
{
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dec(address: InetAddress): InetAddress
|
||||||
|
{
|
||||||
|
val bytes = address.address
|
||||||
|
for (i in bytes.indices.reversed())
|
||||||
|
{
|
||||||
|
bytes[i] = (bytes[i].toInt() - 1).toByte()
|
||||||
|
if (bytes[i] != 0xff.toByte())
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InetAddress.getByAddress(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inc(address: InetAddress): InetAddress
|
||||||
|
{
|
||||||
|
val bytes = address.address
|
||||||
|
for (i in bytes.indices.reversed())
|
||||||
|
{
|
||||||
|
bytes[i] = (bytes[i].toInt() + 1).toByte()
|
||||||
|
if (bytes[i].toInt() != 0)
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return InetAddress.getByAddress(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: RuIpListManager? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(context: Context): RuIpListManager =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: RuIpListManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+740
@@ -0,0 +1,740 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.scenarios
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.usage.UsageEvents
|
||||||
|
import android.app.usage.UsageStatsManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.telephony.TelephonyManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import org.strongswan.android.logic.PauseManager
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.logic.autoconnect.SsidReader
|
||||||
|
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||||
|
import org.strongswan.android.logic.connectivity.WhiteListStatus
|
||||||
|
import org.strongswan.android.logic.events.EventCategory
|
||||||
|
import org.strongswan.android.logic.events.EventKind
|
||||||
|
import org.strongswan.android.logic.events.EventLog
|
||||||
|
import org.strongswan.android.logic.routing.AppTraffic
|
||||||
|
import org.strongswan.android.utils.Constants
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** «When» of a scenario (F_ScenarioAdd). */
|
||||||
|
enum class ScenarioTrigger { WIFI, MOBILE, APP, TIME, ROAMING, WHITELIST }
|
||||||
|
|
||||||
|
/** «What to do». */
|
||||||
|
enum class ScenarioAction { VPN_ON, VPN_OFF, PAUSE, SWITCH_SERVER }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user scenario. While its trigger holds, its action is in force; when
|
||||||
|
* the trigger ends the VPN goes back to how it was («restore»).
|
||||||
|
*/
|
||||||
|
data class Scenario(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val trigger: ScenarioTrigger,
|
||||||
|
/** WIFI: the networks; empty = any Wi-Fi */
|
||||||
|
val ssids: Set<String> = emptySet(),
|
||||||
|
/** APP: the packages */
|
||||||
|
val packages: Set<String> = emptySet(),
|
||||||
|
/** TIME: minutes of the day, the window may wrap past midnight */
|
||||||
|
val startMinute: Int = 60,
|
||||||
|
val endMinute: Int = 7 * 60,
|
||||||
|
val action: ScenarioAction,
|
||||||
|
/** VPN_ON / SWITCH_SERVER: the profile, null = the default one */
|
||||||
|
val profileUuid: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Whether [minute] of the day is inside the (possibly wrapping) window. */
|
||||||
|
internal fun inWindow(minute: Int, start: Int, end: Int): Boolean =
|
||||||
|
if (start == end) false else if (start < end) minute in start until end else minute >= start || minute < end
|
||||||
|
|
||||||
|
/** What the engine sees right now; the decision is a pure function of it. */
|
||||||
|
internal data class ScenarioInputs(
|
||||||
|
val ssid: String?,
|
||||||
|
val wifi: Boolean,
|
||||||
|
val mobile: Boolean,
|
||||||
|
val roaming: Boolean,
|
||||||
|
val foreground: String?,
|
||||||
|
val minute: Int,
|
||||||
|
val whitelist: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The first enabled scenario whose trigger holds: the top one wins. */
|
||||||
|
internal fun activeScenario(list: List<Scenario>, now: ScenarioInputs): Scenario? = list.firstOrNull { s ->
|
||||||
|
s.enabled && when (s.trigger)
|
||||||
|
{
|
||||||
|
ScenarioTrigger.WIFI -> now.wifi && (s.ssids.isEmpty() || s.ssids.any { it.equals(now.ssid, ignoreCase = true) })
|
||||||
|
ScenarioTrigger.MOBILE -> now.mobile && !now.wifi
|
||||||
|
ScenarioTrigger.APP -> now.foreground != null && now.foreground in s.packages
|
||||||
|
ScenarioTrigger.TIME -> inWindow(now.minute, s.startMinute, s.endMinute)
|
||||||
|
ScenarioTrigger.ROAMING -> now.roaming
|
||||||
|
ScenarioTrigger.WHITELIST -> now.whitelist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The two network rules of F_Automation, as entries of the common order. */
|
||||||
|
const val BUILTIN_TRUSTED = "builtin:trusted"
|
||||||
|
const val BUILTIN_UNTRUSTED = "builtin:untrusted"
|
||||||
|
|
||||||
|
/** What the built-in network rules say about the current network. */
|
||||||
|
internal data class BuiltinRules(
|
||||||
|
val enabled: Boolean,
|
||||||
|
val parkOnTrusted: Boolean,
|
||||||
|
val connectOnUntrusted: Boolean,
|
||||||
|
val trustedSsids: Set<String>,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The order of everything: [order] first (known ids), the rest after it (built-ins before new scenarios). */
|
||||||
|
internal fun orderedIds(order: List<String>, scenarios: List<Scenario>): List<String>
|
||||||
|
{
|
||||||
|
val all = listOf(BUILTIN_TRUSTED, BUILTIN_UNTRUSTED) + scenarios.map { it.id }
|
||||||
|
return order.filter { it in all } + all.filter { it !in order }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The top entry of the common order whose condition holds (built-in rule
|
||||||
|
* id or scenario id), null when nothing applies. «Upper wins».
|
||||||
|
*/
|
||||||
|
internal fun topEntry(order: List<String>, scenarios: List<Scenario>, rules: BuiltinRules, now: ScenarioInputs): String?
|
||||||
|
{
|
||||||
|
val byId = scenarios.associateBy { it.id }
|
||||||
|
val trustedHere = now.wifi && now.ssid != null && rules.trustedSsids.any { it.equals(now.ssid, ignoreCase = true) }
|
||||||
|
for (id in orderedIds(order, scenarios))
|
||||||
|
{
|
||||||
|
val holds = when (id)
|
||||||
|
{
|
||||||
|
BUILTIN_TRUSTED -> rules.enabled && rules.parkOnTrusted && trustedHere
|
||||||
|
/* like the auto-connect: a Wi-Fi with an unreadable name counts as foreign */
|
||||||
|
BUILTIN_UNTRUSTED -> rules.enabled && rules.connectOnUntrusted && ((now.wifi && !trustedHere) || (!now.wifi && now.mobile))
|
||||||
|
else -> byId[id]?.let { activeScenario(listOf(it), now) != null } ?: false
|
||||||
|
}
|
||||||
|
if (holds)
|
||||||
|
{
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the user scenarios of F_Automation. Re-evaluated on network
|
||||||
|
* changes, at the edges of time windows (alarm), while an app trigger
|
||||||
|
* exists (foreground app polled every few seconds, needs usage access)
|
||||||
|
* and when the whitelists come or go.
|
||||||
|
*/
|
||||||
|
object ScenarioEngine
|
||||||
|
{
|
||||||
|
private const val TAG = "Scenarios"
|
||||||
|
private const val FILE_NAME = "scenarios.json"
|
||||||
|
private const val POLL_MS = 4000L
|
||||||
|
/* how long a broadcast waits for the VPN state service of a fresh process */
|
||||||
|
private const val SERVICE_WAIT_MS = 8000L
|
||||||
|
private const val SERVICE_POLL_MS = 200L
|
||||||
|
/* the foreground app did not change for a minute: poll half as often */
|
||||||
|
private const val SLOW_AFTER_POLLS = 15
|
||||||
|
private const val SLOW_POLL_MS = 8000L
|
||||||
|
private const val PREF_APPLIED = "pref_scenario_applied"
|
||||||
|
private const val PREF_WAS_ON = "pref_scenario_was_on"
|
||||||
|
private const val PREF_WAS_PROFILE = "pref_scenario_was_profile"
|
||||||
|
/* the scenario the user overrode by hand: it stays out until it ends */
|
||||||
|
private const val PREF_SUSPENDED = "pref_scenario_suspended"
|
||||||
|
private const val PREF_ORDER = "pref_scenario_order"
|
||||||
|
|
||||||
|
private val _list = MutableStateFlow<List<Scenario>>(emptyList())
|
||||||
|
val list: StateFlow<List<Scenario>> = _list
|
||||||
|
@Volatile private var loaded = false
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private var appContext: Context? = null
|
||||||
|
private var polling = false
|
||||||
|
private var unchangedPolls = 0
|
||||||
|
private var lastForeground: String? = null
|
||||||
|
|
||||||
|
fun flow(context: Context): StateFlow<List<Scenario>>
|
||||||
|
{
|
||||||
|
load(context)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(context: Context, list: List<Scenario>)
|
||||||
|
{
|
||||||
|
load(context)
|
||||||
|
_list.value = list
|
||||||
|
persist(context)
|
||||||
|
start(context)
|
||||||
|
org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).scenariosChanged()
|
||||||
|
evaluate(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an enabled scenario reacts to Wi-Fi, mobile network or roaming changes. */
|
||||||
|
@JvmStatic
|
||||||
|
fun needsNetworkEvents(context: Context): Boolean
|
||||||
|
{
|
||||||
|
load(context.applicationContext)
|
||||||
|
return _list.value.any {
|
||||||
|
it.enabled && (it.trigger == ScenarioTrigger.WIFI || it.trigger == ScenarioTrigger.MOBILE || it.trigger == ScenarioTrigger.ROAMING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts listening (from the application); idempotent. */
|
||||||
|
@JvmStatic
|
||||||
|
fun start(context: Context)
|
||||||
|
{
|
||||||
|
val app = context.applicationContext
|
||||||
|
appContext = app
|
||||||
|
load(app)
|
||||||
|
scheduleAlarm(app)
|
||||||
|
val needPoll = _list.value.any { it.enabled && it.trigger == ScenarioTrigger.APP }
|
||||||
|
if (needPoll && !polling)
|
||||||
|
{
|
||||||
|
polling = true
|
||||||
|
registerScreenReceiver(app)
|
||||||
|
if (isScreenOn(app))
|
||||||
|
{
|
||||||
|
handler.post(poll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!needPoll)
|
||||||
|
{
|
||||||
|
polling = false
|
||||||
|
handler.removeCallbacks(poll)
|
||||||
|
unregisterScreenReceiver(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isScreenOn(context: Context): Boolean =
|
||||||
|
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive ?: true
|
||||||
|
|
||||||
|
/* the foreground app only changes while the screen is on: no polling (and no
|
||||||
|
* wake-ups for it) while it is off, one immediate poll when it comes back */
|
||||||
|
private val screenReceiver = object : BroadcastReceiver()
|
||||||
|
{
|
||||||
|
override fun onReceive(context: Context, intent: Intent)
|
||||||
|
{
|
||||||
|
handler.removeCallbacks(poll)
|
||||||
|
if (polling && intent.action == Intent.ACTION_SCREEN_ON)
|
||||||
|
{
|
||||||
|
unchangedPolls = 0
|
||||||
|
handler.post(poll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var screenReceiverRegistered = false
|
||||||
|
|
||||||
|
private fun registerScreenReceiver(app: Context)
|
||||||
|
{
|
||||||
|
if (screenReceiverRegistered)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(Intent.ACTION_SCREEN_ON)
|
||||||
|
addAction(Intent.ACTION_SCREEN_OFF)
|
||||||
|
}
|
||||||
|
runCatching { app.registerReceiver(screenReceiver, filter, null, handler) }
|
||||||
|
.onSuccess { screenReceiverRegistered = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterScreenReceiver(app: Context)
|
||||||
|
{
|
||||||
|
if (!screenReceiverRegistered)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runCatching { app.unregisterReceiver(screenReceiver) }
|
||||||
|
screenReceiverRegistered = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private val poll = object : Runnable
|
||||||
|
{
|
||||||
|
override fun run()
|
||||||
|
{
|
||||||
|
val app = appContext ?: return
|
||||||
|
if (!polling || !isScreenOn(app))
|
||||||
|
{ /* resumed by the screen-on broadcast */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val fg = foregroundApp(app)
|
||||||
|
if (fg != null && fg != lastForeground)
|
||||||
|
{
|
||||||
|
lastForeground = fg
|
||||||
|
unchangedPolls = 0
|
||||||
|
evaluate(app)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
unchangedPolls++
|
||||||
|
}
|
||||||
|
handler.postDelayed(this, if (unchangedPolls >= SLOW_AFTER_POLLS) SLOW_POLL_MS else POLL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides again once the VPN state service exists. A broadcast that starts a
|
||||||
|
* fresh process arrives before the managers bound the service; evaluating
|
||||||
|
* then would silently drop the event. Gives up after [SERVICE_WAIT_MS]
|
||||||
|
* (the next network or alarm event evaluates again) and always calls [done].
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun evaluateWhenReady(context: Context, done: Runnable?)
|
||||||
|
{
|
||||||
|
val app = context.applicationContext
|
||||||
|
val deadline = SystemClock.elapsedRealtime() + SERVICE_WAIT_MS
|
||||||
|
val check = object : Runnable
|
||||||
|
{
|
||||||
|
override fun run()
|
||||||
|
{
|
||||||
|
if (VpnStateService.getLiveInstance() == null && SystemClock.elapsedRealtime() < deadline)
|
||||||
|
{
|
||||||
|
handler.postDelayed(this, SERVICE_POLL_MS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
evaluate(app)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
done?.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.post(check)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Network, whitelist or alarm: decide again. */
|
||||||
|
@JvmStatic
|
||||||
|
fun evaluate(context: Context)
|
||||||
|
{
|
||||||
|
val app = context.applicationContext
|
||||||
|
load(app)
|
||||||
|
if (_list.value.none { it.enabled })
|
||||||
|
{
|
||||||
|
restoreIfApplied(app)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val now = inputs(app)
|
||||||
|
val top = topEntry(order(app), _list.value, builtinRules(app), now)
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(app)
|
||||||
|
/* a scenario overridden by hand stays out until it ends */
|
||||||
|
val suspended = prefs.getString(PREF_SUSPENDED, null)
|
||||||
|
if (suspended != null)
|
||||||
|
{
|
||||||
|
if (top == suspended)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs.edit().remove(PREF_SUSPENDED).apply()
|
||||||
|
}
|
||||||
|
val active = _list.value.firstOrNull { it.id == top }
|
||||||
|
val applied = prefs.getString(PREF_APPLIED, null)
|
||||||
|
if (active?.id == applied && active != null)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (active == null)
|
||||||
|
{ /* nothing, or a built-in network rule above: the auto-connect acts */
|
||||||
|
restoreIfApplied(app)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val service = VpnStateService.getLiveInstance() ?: return
|
||||||
|
if (applied == null)
|
||||||
|
{ /* remember how it was, to put it back when the scenario ends */
|
||||||
|
val on = service.state != VpnStateService.State.DISABLED
|
||||||
|
prefs.edit().putBoolean(PREF_WAS_ON, on)
|
||||||
|
.putString(PREF_WAS_PROFILE, service.profile?.getUUID()?.toString()).apply()
|
||||||
|
}
|
||||||
|
prefs.edit().putString(PREF_APPLIED, active.id).apply()
|
||||||
|
DiagLog.i(TAG, "scenario ${active.trigger} → ${active.action}")
|
||||||
|
apply(app, service, active)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun apply(context: Context, service: VpnStateService, scenario: Scenario)
|
||||||
|
{
|
||||||
|
val profile = scenario.profileUuid ?: defaultProfile(context) ?: service.profile?.getUUID()?.toString()
|
||||||
|
when (scenario.action)
|
||||||
|
{
|
||||||
|
ScenarioAction.VPN_ON, ScenarioAction.SWITCH_SERVER ->
|
||||||
|
{
|
||||||
|
val running = service.state != VpnStateService.State.DISABLED
|
||||||
|
if (profile != null && (!running || service.profile?.getUUID()?.toString() != profile) && !connect(context, service, profile))
|
||||||
|
{ /* nothing happened: no «scenario did it» event */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ScenarioAction.VPN_OFF -> if (service.state != VpnStateService.State.DISABLED) service.disconnect()
|
||||||
|
ScenarioAction.PAUSE ->
|
||||||
|
{
|
||||||
|
val current = service.profile?.getUUID()?.toString() ?: profile ?: return
|
||||||
|
val until = if (scenario.trigger == ScenarioTrigger.TIME) windowEnd(scenario) else System.currentTimeMillis() + 12L * 60 * 60 * 1000
|
||||||
|
if (service.state != VpnStateService.State.DISABLED)
|
||||||
|
{
|
||||||
|
PauseManager.pauseUntil(context, service, current, until)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.INFO, EventCategory.CONNECTION,
|
||||||
|
context.getString(R.string.event_scenario_title, context.getString(actionLabel(scenario.action))),
|
||||||
|
context.getString(R.string.event_scenario_text, describeWhen(context, scenario)), key = "scenario:${scenario.id}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The common order of the built-in rules and the scenarios (F_Automation, top wins). */
|
||||||
|
fun order(context: Context): List<String>
|
||||||
|
{
|
||||||
|
val raw = PreferenceManager.getDefaultSharedPreferences(context).getString(PREF_ORDER, null)
|
||||||
|
return orderedIds(raw?.split(',')?.filter { it.isNotBlank() } ?: emptyList(), flow(context).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveOrder(context: Context, order: List<String>)
|
||||||
|
{
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).edit().putString(PREF_ORDER, order.joinToString(",")).apply()
|
||||||
|
_orderChanges.value = _orderChanges.value + 1
|
||||||
|
evaluate(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _orderChanges = MutableStateFlow(0)
|
||||||
|
/** Bumps when the order changed (the list screen redraws). */
|
||||||
|
val orderChanges: StateFlow<Int> = _orderChanges
|
||||||
|
|
||||||
|
/** Moves [id] one place up (-1) or down (+1) in the common order. */
|
||||||
|
fun move(context: Context, id: String, delta: Int)
|
||||||
|
{
|
||||||
|
val list = order(context).toMutableList()
|
||||||
|
val i = list.indexOf(id)
|
||||||
|
val j = i + delta
|
||||||
|
if (i < 0 || j < 0 || j >= list.size)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list.add(j, list.removeAt(i))
|
||||||
|
saveOrder(context, list)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user turned the VPN on or off or paused it by hand: the running
|
||||||
|
* scenario steps back until it ends and puts nothing back afterwards.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun onManual(context: Context)
|
||||||
|
{
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
|
||||||
|
val applied = prefs.getString(PREF_APPLIED, null) ?: return
|
||||||
|
DiagLog.i(TAG, "manual action, scenario steps back until it ends")
|
||||||
|
prefs.edit().putString(PREF_SUSPENDED, applied)
|
||||||
|
.remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A scenario is in force: the built-in network rules (auto-connect) stand aside. */
|
||||||
|
@JvmStatic
|
||||||
|
fun inControl(context: Context): Boolean =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context.applicationContext).getString(PREF_APPLIED, null) != null
|
||||||
|
|
||||||
|
private fun builtinRules(context: Context): BuiltinRules
|
||||||
|
{
|
||||||
|
val c = org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).currentConfig
|
||||||
|
return BuiltinRules(c.enabled, c.parkOnTrusted, c.connectOnUntrusted, c.trustedSsids)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun restoreIfApplied(context: Context)
|
||||||
|
{
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
if (prefs.getString(PREF_APPLIED, null) == null)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/* without a live service nothing can be put back: keep the record so the
|
||||||
|
* next evaluation (the receiver waits for the service) still restores */
|
||||||
|
val service = VpnStateService.getLiveInstance() ?: return
|
||||||
|
val wasOn = prefs.getBoolean(PREF_WAS_ON, false)
|
||||||
|
val wasProfile = prefs.getString(PREF_WAS_PROFILE, null)
|
||||||
|
prefs.edit().remove(PREF_APPLIED).remove(PREF_WAS_ON).remove(PREF_WAS_PROFILE).apply()
|
||||||
|
DiagLog.i(TAG, "scenario over, restoring (on=$wasOn)")
|
||||||
|
if (PauseManager.isPaused(context))
|
||||||
|
{
|
||||||
|
if (wasOn) PauseManager.resumeNow(context) else PauseManager.cancel(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val running = service.state != VpnStateService.State.DISABLED
|
||||||
|
when
|
||||||
|
{
|
||||||
|
wasOn && wasProfile != null && (!running || service.profile?.getUUID()?.toString() != wasProfile) -> connect(context, service, wasProfile)
|
||||||
|
!wasOn && running -> service.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts [uuid]; false when it could not (no VPN permission, no saved password). */
|
||||||
|
private fun connect(context: Context, service: VpnStateService, uuid: String): Boolean
|
||||||
|
{
|
||||||
|
val needsConsent = runCatching { VpnService.prepare(context) != null }.getOrDefault(true)
|
||||||
|
if (needsConsent)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "VPN permission missing, scenario cannot connect")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
/* a password typed for the running session goes along with it */
|
||||||
|
val livePassword = service.profile?.takeIf { it.getUUID().toString() == uuid }?.password
|
||||||
|
if (livePassword == null && org.strongswan.android.logic.profiles.blockedByPassword(context, uuid))
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val info = Bundle()
|
||||||
|
info.putString(VpnProfileDataSource.KEY_UUID, uuid)
|
||||||
|
livePassword?.let { info.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||||
|
service.connect(info, true)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultProfile(context: Context): String? =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).getString(Constants.PREF_MRU_VPN_PROFILE, null)
|
||||||
|
|
||||||
|
private fun inputs(context: Context): ScenarioInputs
|
||||||
|
{
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
var wifi = false
|
||||||
|
var mobile = false
|
||||||
|
var ssid: String? = null
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
cm?.allNetworks?.forEach { network ->
|
||||||
|
val caps = runCatching { cm.getNetworkCapabilities(network) }.getOrNull() ?: return@forEach
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) return@forEach
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI))
|
||||||
|
{
|
||||||
|
wifi = true
|
||||||
|
ssid = SsidReader.from(context, caps)
|
||||||
|
}
|
||||||
|
else if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR))
|
||||||
|
{
|
||||||
|
mobile = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val roaming = mobile && runCatching { (context.getSystemService(Context.TELEPHONY_SERVICE) as? TelephonyManager)?.isNetworkRoaming == true }.getOrDefault(false)
|
||||||
|
val c = Calendar.getInstance()
|
||||||
|
val whitelist = runCatching { WhiteListManager.getInstance(context).state.value.status == WhiteListStatus.ACTIVE }.getOrDefault(false)
|
||||||
|
val fg = if (_list.value.any { it.enabled && it.trigger == ScenarioTrigger.APP }) lastForeground ?: foregroundApp(context) else null
|
||||||
|
return ScenarioInputs(ssid, wifi, mobile, roaming, fg, c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE), whitelist)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app last moved to the foreground (usage events of the last minute); null without access. */
|
||||||
|
private fun foregroundApp(context: Context): String?
|
||||||
|
{
|
||||||
|
if (!AppTraffic.hasAccess(context))
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager ?: return null
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val events = runCatching { usm.queryEvents(now - 60_000, now) }.getOrNull() ?: return lastForeground
|
||||||
|
var last: String? = null
|
||||||
|
val event = UsageEvents.Event()
|
||||||
|
while (events.hasNextEvent())
|
||||||
|
{
|
||||||
|
events.getNextEvent(event)
|
||||||
|
if (event.eventType == UsageEvents.Event.ACTIVITY_RESUMED)
|
||||||
|
{
|
||||||
|
last = event.packageName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last ?: lastForeground
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun windowEnd(s: Scenario): Long
|
||||||
|
{
|
||||||
|
val c = Calendar.getInstance()
|
||||||
|
val now = c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE)
|
||||||
|
c.set(Calendar.HOUR_OF_DAY, s.endMinute / 60)
|
||||||
|
c.set(Calendar.MINUTE, s.endMinute % 60)
|
||||||
|
c.set(Calendar.SECOND, 0)
|
||||||
|
c.set(Calendar.MILLISECOND, 0)
|
||||||
|
if (s.endMinute <= now)
|
||||||
|
{
|
||||||
|
c.add(Calendar.DAY_OF_YEAR, 1)
|
||||||
|
}
|
||||||
|
return c.timeInMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An alarm at the next edge of any time window. */
|
||||||
|
private fun scheduleAlarm(context: Context)
|
||||||
|
{
|
||||||
|
val am = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager ?: return
|
||||||
|
val pi = PendingIntent.getBroadcast(context, 46, Intent(context, ScenarioReceiver::class.java),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
|
val edges = _list.value.filter { it.enabled && it.trigger == ScenarioTrigger.TIME }.flatMap { listOf(it.startMinute, it.endMinute) }
|
||||||
|
if (edges.isEmpty())
|
||||||
|
{
|
||||||
|
am.cancel(pi)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val c = Calendar.getInstance()
|
||||||
|
val now = c.get(Calendar.HOUR_OF_DAY) * 60 + c.get(Calendar.MINUTE)
|
||||||
|
val next = edges.minByOrNull { (it - now + 1440 - 1) % 1440 + 1 } ?: return
|
||||||
|
c.set(Calendar.HOUR_OF_DAY, next / 60)
|
||||||
|
c.set(Calendar.MINUTE, next % 60)
|
||||||
|
c.set(Calendar.SECOND, 5)
|
||||||
|
c.set(Calendar.MILLISECOND, 0)
|
||||||
|
if (c.timeInMillis <= System.currentTimeMillis())
|
||||||
|
{
|
||||||
|
c.add(Calendar.DAY_OF_YEAR, 1)
|
||||||
|
}
|
||||||
|
/* same choice as PauseManager: exact when the grant allows it */
|
||||||
|
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (exact)
|
||||||
|
{
|
||||||
|
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: SecurityException)
|
||||||
|
{ /* revoked in between: an inexact alarm is still better than none */
|
||||||
|
runCatching { am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, c.timeInMillis, pi) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun actionLabel(action: ScenarioAction): Int = when (action)
|
||||||
|
{
|
||||||
|
ScenarioAction.VPN_ON -> R.string.scenario_do_on
|
||||||
|
ScenarioAction.VPN_OFF -> R.string.scenario_do_off
|
||||||
|
ScenarioAction.PAUSE -> R.string.scenario_do_pause
|
||||||
|
ScenarioAction.SWITCH_SERVER -> R.string.scenario_do_switch
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Telegram or YouTube is open», «Every night 01:00–07:00» … */
|
||||||
|
fun describeWhen(context: Context, s: Scenario): String
|
||||||
|
{
|
||||||
|
fun hm(m: Int) = String.format("%02d:%02d", m / 60, m % 60)
|
||||||
|
return when (s.trigger)
|
||||||
|
{
|
||||||
|
ScenarioTrigger.WIFI -> if (s.ssids.isEmpty()) context.getString(R.string.scenario_when_any_wifi)
|
||||||
|
else context.getString(R.string.scenario_when_wifi, s.ssids.joinToString(", "))
|
||||||
|
ScenarioTrigger.MOBILE -> context.getString(R.string.scenario_when_mobile)
|
||||||
|
ScenarioTrigger.APP ->
|
||||||
|
{
|
||||||
|
val pm = context.packageManager
|
||||||
|
val names = s.packages.map { pkg -> runCatching { pm.getApplicationLabel(pm.getApplicationInfo(pkg, 0)).toString() }.getOrDefault(pkg) }
|
||||||
|
context.getString(R.string.scenario_when_app, names.joinToString(context.getString(R.string.scenario_or)))
|
||||||
|
}
|
||||||
|
ScenarioTrigger.TIME -> context.getString(R.string.scenario_when_time, hm(s.startMinute), hm(s.endMinute))
|
||||||
|
ScenarioTrigger.ROAMING -> context.getString(R.string.scenario_when_roaming)
|
||||||
|
ScenarioTrigger.WHITELIST -> context.getString(R.string.scenario_when_whitelist)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(context: Context)
|
||||||
|
{
|
||||||
|
if (loaded)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
synchronized(this)
|
||||||
|
{
|
||||||
|
if (loaded)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
if (file.exists())
|
||||||
|
{
|
||||||
|
val array = JSONArray(file.readText())
|
||||||
|
_list.value = (0 until array.length()).mapNotNull { runCatching { fromJson(array.getJSONObject(it)) }.getOrNull() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Loading failed", e)
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persist(context: Context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val array = JSONArray()
|
||||||
|
_list.value.forEach { s ->
|
||||||
|
array.put(
|
||||||
|
JSONObject().put("id", s.id).put("on", s.enabled).put("when", s.trigger.name)
|
||||||
|
.put("ssids", JSONArray(s.ssids.toList())).put("apps", JSONArray(s.packages.toList()))
|
||||||
|
.put("start", s.startMinute).put("end", s.endMinute).put("do", s.action.name)
|
||||||
|
.put("profile", s.profileUuid ?: JSONObject.NULL)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
File(context.filesDir, FILE_NAME).writeText(array.toString())
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Saving failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fromJson(o: JSONObject): Scenario
|
||||||
|
{
|
||||||
|
fun set(key: String): Set<String> = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getString(it) }.toSet() } ?: emptySet()
|
||||||
|
return Scenario(
|
||||||
|
id = o.getString("id"),
|
||||||
|
enabled = o.optBoolean("on", true),
|
||||||
|
trigger = ScenarioTrigger.valueOf(o.getString("when")),
|
||||||
|
ssids = set("ssids"),
|
||||||
|
packages = set("apps"),
|
||||||
|
startMinute = o.optInt("start", 60),
|
||||||
|
endMinute = o.optInt("end", 420),
|
||||||
|
action = ScenarioAction.valueOf(o.getString("do")),
|
||||||
|
profileUuid = o.optString("profile").takeIf { !o.isNull("profile") && it.isNotEmpty() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Edge of a time window: decide again and arm the next edge. */
|
||||||
|
class ScenarioReceiver : BroadcastReceiver()
|
||||||
|
{
|
||||||
|
override fun onReceive(context: Context, intent: Intent?)
|
||||||
|
{
|
||||||
|
ScenarioEngine.start(context)
|
||||||
|
val pending = goAsync()
|
||||||
|
ScenarioEngine.evaluateWhenReady(context) { pending.finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+212
@@ -0,0 +1,212 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.shortcuts
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.pm.ShortcutInfoCompat
|
||||||
|
import androidx.core.content.pm.ShortcutManagerCompat
|
||||||
|
import androidx.core.graphics.drawable.IconCompat
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.data.VpnProfile
|
||||||
|
import org.strongswan.android.data.VpnProfileSource
|
||||||
|
import org.strongswan.android.logic.StrongSwanApplication
|
||||||
|
import org.strongswan.android.ui.VpnProfileControlActivity
|
||||||
|
import org.strongswan.android.utils.Constants
|
||||||
|
import org.strongswan.android.utils.DefaultVpnProfile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes dynamic launcher shortcuts that automation tools (e.g. Samsung
|
||||||
|
* Modes and Routines) can execute as routine actions: connecting the default
|
||||||
|
* or a specific profile, disconnecting and toggling the VPN.
|
||||||
|
*
|
||||||
|
* All shortcuts target VpnProfileControlActivity with EXTRA_SILENT set, so
|
||||||
|
* routines run without confirmation dialogs (one-time consent dialogs aside).
|
||||||
|
*/
|
||||||
|
class VpnShortcutManager private constructor(private val context: Context)
|
||||||
|
{
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/* hardcoded: the runtime class name is obfuscated by R8 in release builds */
|
||||||
|
private const val TAG = "VpnShortcutManager"
|
||||||
|
|
||||||
|
/** slots always occupied by the connect/disconnect/toggle shortcuts */
|
||||||
|
private const val RESERVED_SLOTS = 3
|
||||||
|
|
||||||
|
private var mInstance: VpnShortcutManager? = null
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun getInstance(context: Context): VpnShortcutManager
|
||||||
|
{
|
||||||
|
return mInstance ?: VpnShortcutManager(context.applicationContext).also { mInstance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val mPrefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
|
||||||
|
private val mProfilesChangedReceiver = object : BroadcastReceiver()
|
||||||
|
{
|
||||||
|
override fun onReceive(context: Context, intent: Intent)
|
||||||
|
{
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isEnabled(): Boolean
|
||||||
|
{
|
||||||
|
return mPrefs.getBoolean(Constants.PREF_PUBLISH_SHORTCUTS, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable or disable the automation shortcuts and update the published set
|
||||||
|
*/
|
||||||
|
fun setEnabled(enabled: Boolean)
|
||||||
|
{
|
||||||
|
mPrefs.edit().putBoolean(Constants.PREF_PUBLISH_SHORTCUTS, enabled).apply()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* (Re)publish the automation shortcuts, or remove them if the feature is disabled
|
||||||
|
*/
|
||||||
|
fun refresh()
|
||||||
|
{
|
||||||
|
StrongSwanApplication.getInstance().getExecutor().execute { updateShortcuts() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start listening for profile changes; called from StrongSwanApplication
|
||||||
|
*/
|
||||||
|
fun start()
|
||||||
|
{
|
||||||
|
LocalBroadcastManager.getInstance(context).registerReceiver(
|
||||||
|
mProfilesChangedReceiver, IntentFilter(Constants.VPN_PROFILES_CHANGED))
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateShortcuts()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!isEnabled())
|
||||||
|
{
|
||||||
|
ShortcutManagerCompat.removeAllDynamicShortcuts(context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publishShortcuts()
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.e(TAG, "Failed to update automation shortcuts", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishShortcuts()
|
||||||
|
{
|
||||||
|
val max = ShortcutManagerCompat.getMaxShortcutCountPerActivity(context)
|
||||||
|
if (max <= 0)
|
||||||
|
{ /* dynamic shortcuts are not supported on this API level */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val shortcuts = ArrayList<ShortcutInfoCompat>(max)
|
||||||
|
shortcuts.add(buildFunctionShortcut("disconnect_vpn", R.string.shortcut_disconnect_vpn,
|
||||||
|
VpnProfileControlActivity.DISCONNECT, null))
|
||||||
|
if (shortcuts.size < max)
|
||||||
|
{
|
||||||
|
shortcuts.add(buildFunctionShortcut("toggle_vpn", R.string.shortcut_toggle_vpn,
|
||||||
|
VpnProfileControlActivity.TOGGLE_VPN, null))
|
||||||
|
}
|
||||||
|
if (shortcuts.size < max)
|
||||||
|
{
|
||||||
|
shortcuts.add(buildFunctionShortcut("connect_vpn", R.string.shortcut_connect_vpn,
|
||||||
|
VpnProfileControlActivity.START_PROFILE, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
val profileSlots = (max - RESERVED_SLOTS).coerceAtLeast(0)
|
||||||
|
if (profileSlots > 0)
|
||||||
|
{
|
||||||
|
val profiles = loadProfiles()
|
||||||
|
val defaultUuid = DefaultVpnProfile.getDefaultProfileUuid(context)
|
||||||
|
profiles.sortWith(compareBy(
|
||||||
|
{ it.getUUID().toString() != defaultUuid },
|
||||||
|
{ it.name?.lowercase() ?: it.getUUID().toString() }))
|
||||||
|
for (profile in profiles.take(profileSlots))
|
||||||
|
{
|
||||||
|
if (shortcuts.size >= max)
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
shortcuts.add(buildProfileShortcut(profile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShortcutManagerCompat.setDynamicShortcuts(context, shortcuts)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildFunctionShortcut(id: String, labelRes: Int, action: String, profileUuid: String?): ShortcutInfoCompat
|
||||||
|
{
|
||||||
|
val label = context.getString(labelRes)
|
||||||
|
return buildShortcut(id, label, label, action, profileUuid)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildProfileShortcut(profile: VpnProfile): ShortcutInfoCompat
|
||||||
|
{
|
||||||
|
val name = profile.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: profile.gateway?.takeIf { it.isNotBlank() }
|
||||||
|
?: profile.getUUID().toString()
|
||||||
|
val longLabel = context.getString(R.string.shortcut_connect_profile_fmt, name)
|
||||||
|
return buildShortcut("connect_" + profile.getUUID(), name, longLabel,
|
||||||
|
VpnProfileControlActivity.START_PROFILE, profile.getUUID().toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildShortcut(id: String, shortLabel: String, longLabel: String, action: String, profileUuid: String?): ShortcutInfoCompat
|
||||||
|
{
|
||||||
|
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
||||||
|
.setAction(action)
|
||||||
|
.putExtra(VpnProfileControlActivity.EXTRA_SILENT, true)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
if (profileUuid != null)
|
||||||
|
{
|
||||||
|
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profileUuid)
|
||||||
|
}
|
||||||
|
return ShortcutInfoCompat.Builder(context, id)
|
||||||
|
.setShortLabel(shortLabel)
|
||||||
|
.setLongLabel(longLabel)
|
||||||
|
.setIntent(intent)
|
||||||
|
.setLongLived(true)
|
||||||
|
.setIcon(IconCompat.createWithResource(context, R.mipmap.ic_shortcut))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadProfiles(): MutableList<VpnProfile>
|
||||||
|
{
|
||||||
|
val source = VpnProfileSource(context)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
source.open()
|
||||||
|
return source.allVpnProfiles
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+263
@@ -0,0 +1,263 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.stats
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.ScheduledFuture
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** Bytes of one day: through the tunnel and straight past it (userspace router). */
|
||||||
|
data class DayTraffic(val day: String, val vpn: Long, val direct: Long)
|
||||||
|
{
|
||||||
|
val total: Long get() = vpn + direct
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily traffic history (F_Stats). While the tunnel is up the daemon
|
||||||
|
* counters are sampled every [SAMPLE_S] seconds (every [SAMPLE_OFF_S] with
|
||||||
|
* the screen off: the counters are cumulative, so nothing is lost) and the growth is added to
|
||||||
|
* the current day; the counters restart with every connection, so a drop
|
||||||
|
* starts a new baseline. Kept for [KEEP_DAYS] days in traffic.json.
|
||||||
|
*/
|
||||||
|
object TrafficHistory
|
||||||
|
{
|
||||||
|
private const val TAG = "TrafficHistory"
|
||||||
|
private const val FILE_NAME = "traffic.json"
|
||||||
|
private const val SAMPLE_S = 15L
|
||||||
|
private const val SAMPLE_OFF_S = 60L
|
||||||
|
/* the history lives in memory and is written out this often (not on every
|
||||||
|
* sample: a full JSON rewrite every 15 s is flash wear for nothing); a
|
||||||
|
* killed process loses at most this much of the statistics */
|
||||||
|
internal const val SAVE_EVERY_MS = 5 * 60 * 1000L
|
||||||
|
private const val KEEP_DAYS = 120
|
||||||
|
|
||||||
|
private val executor = Executors.newSingleThreadScheduledExecutor()
|
||||||
|
private var task: ScheduledFuture<*>? = null
|
||||||
|
private var lastVpn = -1L
|
||||||
|
private var lastDirect = -1L
|
||||||
|
private var lastSampleAt = 0L
|
||||||
|
private var dirty = false
|
||||||
|
private var lastSavedAt = 0L
|
||||||
|
private var lastSavedDay: String? = null
|
||||||
|
@Volatile private var loaded = false
|
||||||
|
private val days = LinkedHashMap<String, LongArray>()
|
||||||
|
private var firstDay: String? = null
|
||||||
|
|
||||||
|
private val _changes = MutableStateFlow(0L)
|
||||||
|
/** Bumps whenever a sample changed the history (for live screens). */
|
||||||
|
val changes: StateFlow<Long> = _changes
|
||||||
|
|
||||||
|
internal fun dayKey(ms: Long): String = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(ms)
|
||||||
|
|
||||||
|
/** Starts sampling (the tunnel came up); harmless when already running. */
|
||||||
|
@JvmStatic
|
||||||
|
@Synchronized
|
||||||
|
fun start(context: Context)
|
||||||
|
{
|
||||||
|
val app = context.applicationContext
|
||||||
|
if (task != null)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastVpn = -1L
|
||||||
|
lastDirect = -1L
|
||||||
|
task = executor.scheduleWithFixedDelay({ sample(app) }, 0, SAMPLE_S, TimeUnit.SECONDS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops sampling after a last sample (the tunnel goes down). */
|
||||||
|
@JvmStatic
|
||||||
|
@Synchronized
|
||||||
|
fun stop(context: Context)
|
||||||
|
{
|
||||||
|
val app = context.applicationContext
|
||||||
|
val running = task ?: return
|
||||||
|
running.cancel(false)
|
||||||
|
task = null
|
||||||
|
executor.execute {
|
||||||
|
sample(app, force = true)
|
||||||
|
flush(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes the history out now if it changed since the last write. */
|
||||||
|
@Synchronized
|
||||||
|
fun flush(context: Context)
|
||||||
|
{
|
||||||
|
if (dirty)
|
||||||
|
{
|
||||||
|
save(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a changed history has to be written now: periodically and when the day turns. */
|
||||||
|
internal fun saveDue(dirty: Boolean, now: Long, lastSavedAt: Long, dayChanged: Boolean): Boolean =
|
||||||
|
dirty && (dayChanged || now - lastSavedAt >= SAVE_EVERY_MS)
|
||||||
|
|
||||||
|
private fun screenOn(context: Context): Boolean =
|
||||||
|
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isInteractive != false
|
||||||
|
|
||||||
|
private fun sample(context: Context, force: Boolean = false)
|
||||||
|
{
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
val screen = screenOn(context)
|
||||||
|
if (!force && !screen && now - lastSampleAt < SAMPLE_OFF_S * 1000L - 1000L)
|
||||||
|
{ /* screen off: a sample a minute is enough, the counters accumulate */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastSampleAt = now
|
||||||
|
val traffic = runCatching { CharonVpnService.queryTraffic() }.getOrNull()
|
||||||
|
val vpn = traffic?.let { it.getOrElse(0) { 0L } + it.getOrElse(1) { 0L } } ?: return
|
||||||
|
/* direct bytes only exist with the userspace router (s[3]) */
|
||||||
|
val direct = runCatching { CharonVpnService.getRoutingStats() }.getOrNull()?.takeIf { it.size >= 4 }?.get(3) ?: 0L
|
||||||
|
val dVpn = if (lastVpn < 0 || vpn < lastVpn) 0L else vpn - lastVpn
|
||||||
|
val dDirect = if (lastDirect < 0 || direct < lastDirect) 0L else direct - lastDirect
|
||||||
|
/* a new session: count what it already did since its counters started */
|
||||||
|
val addVpn = if (lastVpn >= 0 && vpn < lastVpn) vpn else dVpn
|
||||||
|
val addDirect = if (lastDirect >= 0 && direct < lastDirect) direct else dDirect
|
||||||
|
lastVpn = vpn
|
||||||
|
lastDirect = direct
|
||||||
|
if (addVpn > 0 || addDirect > 0)
|
||||||
|
{
|
||||||
|
add(context, System.currentTimeMillis(), addVpn, addDirect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
internal fun add(context: Context, at: Long, vpn: Long, direct: Long)
|
||||||
|
{
|
||||||
|
load(context)
|
||||||
|
val key = dayKey(at)
|
||||||
|
val row = days.getOrPut(key) { LongArray(2) }
|
||||||
|
row[0] += vpn
|
||||||
|
row[1] += direct
|
||||||
|
if (firstDay == null)
|
||||||
|
{
|
||||||
|
firstDay = key
|
||||||
|
}
|
||||||
|
prune()
|
||||||
|
dirty = true
|
||||||
|
if (saveDue(dirty, SystemClock.elapsedRealtime(), lastSavedAt, lastSavedDay != null && lastSavedDay != key))
|
||||||
|
{
|
||||||
|
save(context)
|
||||||
|
}
|
||||||
|
_changes.value = System.currentTimeMillis()
|
||||||
|
if (screenOn(context))
|
||||||
|
{ /* nobody sees the widget with the screen off */
|
||||||
|
org.strongswan.android.ui.widget.ShxWidgetState.refresh(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The days from [from] to [to] (inclusive, wall clock), zeros where nothing was recorded. */
|
||||||
|
@Synchronized
|
||||||
|
fun range(context: Context, from: Long, to: Long): List<DayTraffic>
|
||||||
|
{
|
||||||
|
load(context)
|
||||||
|
val result = ArrayList<DayTraffic>()
|
||||||
|
val c = Calendar.getInstance()
|
||||||
|
c.timeInMillis = from
|
||||||
|
while (c.timeInMillis <= to)
|
||||||
|
{
|
||||||
|
val key = dayKey(c.timeInMillis)
|
||||||
|
val row = days[key]
|
||||||
|
result += DayTraffic(key, row?.get(0) ?: 0L, row?.get(1) ?: 0L)
|
||||||
|
c.add(Calendar.DAY_OF_YEAR, 1)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun today(context: Context): DayTraffic = range(context, System.currentTimeMillis(), System.currentTimeMillis()).first()
|
||||||
|
|
||||||
|
/** First day with any record, null before the first connection. */
|
||||||
|
@Synchronized
|
||||||
|
fun firstDay(context: Context): String?
|
||||||
|
{
|
||||||
|
load(context)
|
||||||
|
return firstDay
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prune()
|
||||||
|
{
|
||||||
|
val cutoff = dayKey(System.currentTimeMillis() - KEEP_DAYS * 24L * 60 * 60 * 1000)
|
||||||
|
days.keys.filter { it < cutoff }.forEach { days.remove(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun load(context: Context)
|
||||||
|
{
|
||||||
|
if (loaded)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loaded = true
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
if (!file.exists())
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val json = JSONObject(file.readText())
|
||||||
|
firstDay = json.optString("first").ifEmpty { null }
|
||||||
|
val array = json.optJSONArray("days") ?: JSONArray()
|
||||||
|
for (i in 0 until array.length())
|
||||||
|
{
|
||||||
|
val o = array.getJSONObject(i)
|
||||||
|
days[o.getString("d")] = longArrayOf(o.optLong("v"), o.optLong("r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Loading the traffic history failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save(context: Context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val array = JSONArray()
|
||||||
|
days.toSortedMap().forEach { (d, row) -> array.put(JSONObject().put("d", d).put("v", row[0]).put("r", row[1])) }
|
||||||
|
val json = JSONObject().put("first", firstDay ?: JSONObject.NULL).put("days", array)
|
||||||
|
val file = File(context.filesDir, FILE_NAME)
|
||||||
|
val tmp = File(file.path + ".tmp")
|
||||||
|
tmp.writeText(json.toString())
|
||||||
|
if (!tmp.renameTo(file))
|
||||||
|
{
|
||||||
|
file.writeText(json.toString())
|
||||||
|
tmp.delete()
|
||||||
|
}
|
||||||
|
dirty = false
|
||||||
|
lastSavedAt = SystemClock.elapsedRealtime()
|
||||||
|
lastSavedDay = dayKey(System.currentTimeMillis())
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Log.w(TAG, "Saving the traffic history failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.stats
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.events.NotifPrefs
|
||||||
|
import org.strongswan.android.ui.StatsActivity
|
||||||
|
import org.strongswan.android.ui.compose.formatBytes
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** «Weekly summary» (F_SysNotifs): last week's traffic on Monday morning, off by default. */
|
||||||
|
object WeeklySummary
|
||||||
|
{
|
||||||
|
private const val WORK_NAME = "weekly-summary"
|
||||||
|
private const val CHANNEL = "org.strongswan.android.weekly"
|
||||||
|
private const val NOTIFICATION_ID = 0x5717
|
||||||
|
|
||||||
|
/** Arms (or cancels) the Monday 10:00 job after the switch. */
|
||||||
|
@JvmStatic
|
||||||
|
fun schedule(context: Context)
|
||||||
|
{
|
||||||
|
val wm = WorkManager.getInstance(context)
|
||||||
|
if (!NotifPrefs.enabled(context, NotifPrefs.WEEKLY))
|
||||||
|
{
|
||||||
|
wm.cancelUniqueWork(WORK_NAME)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val c = Calendar.getInstance()
|
||||||
|
c.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
|
||||||
|
c.set(Calendar.HOUR_OF_DAY, 10)
|
||||||
|
c.set(Calendar.MINUTE, 0)
|
||||||
|
c.set(Calendar.SECOND, 0)
|
||||||
|
if (c.timeInMillis <= System.currentTimeMillis())
|
||||||
|
{
|
||||||
|
c.add(Calendar.WEEK_OF_YEAR, 1)
|
||||||
|
}
|
||||||
|
val request = PeriodicWorkRequestBuilder<WeeklySummaryWorker>(7, TimeUnit.DAYS)
|
||||||
|
.setInitialDelay(c.timeInMillis - System.currentTimeMillis(), TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
wm.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun post(context: Context)
|
||||||
|
{
|
||||||
|
if (!NotifPrefs.enabled(context, NotifPrefs.WEEKLY))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val day = 24L * 60 * 60 * 1000
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val week = TrafficHistory.range(context, now - 7 * day, now - day)
|
||||||
|
val total = week.sumOf { it.total }
|
||||||
|
if (total <= 0)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val vpnShare = (week.sumOf { it.vpn } * 100 / total).toInt()
|
||||||
|
val manager = NotificationManagerCompat.from(context)
|
||||||
|
if (!manager.areNotificationsEnabled())
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
{
|
||||||
|
context.getSystemService(NotificationManager::class.java)?.createNotificationChannel(
|
||||||
|
NotificationChannel(CHANNEL, context.getString(R.string.notif_weekly), NotificationManager.IMPORTANCE_LOW)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val open = PendingIntent.getActivity(
|
||||||
|
context, 0, Intent(context, StatsActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(context.getString(R.string.weekly_title, formatBytes(total)))
|
||||||
|
.setContentText(context.getString(R.string.weekly_text, vpnShare, 100 - vpnShare))
|
||||||
|
.setContentIntent(open)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
manager.notify(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
catch (_: SecurityException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class WeeklySummaryWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
|
||||||
|
{
|
||||||
|
override suspend fun doWork(): Result
|
||||||
|
{
|
||||||
|
WeeklySummary.post(applicationContext)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
import java.io.RandomAccessFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What goes into a report sent from the log screen: today's part of charon.log and,
|
||||||
|
* on request, the app's diag.log. No Android types here, so it runs in unit tests.
|
||||||
|
*/
|
||||||
|
object ReportContent
|
||||||
|
{
|
||||||
|
/* the panel unpacks a report with a 5 MB cap; 3 + 0.75 MB of text leaves room and stays far below 2 MB gzip */
|
||||||
|
const val CHARON_MAX_BYTES = 3 * 1024 * 1024
|
||||||
|
const val DIAG_MAX_BYTES = 768 * 1024
|
||||||
|
const val CUT_MARK = "... (earlier lines cut)\n"
|
||||||
|
|
||||||
|
/** Why a report was not sent; each maps to a reason line of the "not sent" sheet */
|
||||||
|
enum class Failure { NO_NETWORK, SERVER_DOWN, SIGNATURE, TOO_OFTEN, TOO_LARGE, REJECTED }
|
||||||
|
|
||||||
|
fun failure(httpCode: Int): Failure = when
|
||||||
|
{
|
||||||
|
httpCode == 0 -> Failure.NO_NETWORK
|
||||||
|
httpCode == 401 -> Failure.SIGNATURE
|
||||||
|
httpCode == 413 -> Failure.TOO_LARGE
|
||||||
|
httpCode == 429 -> Failure.TOO_OFTEN
|
||||||
|
httpCode >= 500 || httpCode == 404 -> Failure.SERVER_DOWN
|
||||||
|
else -> Failure.REJECTED
|
||||||
|
}
|
||||||
|
|
||||||
|
private val MONTHS = listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
|
||||||
|
/* charon: «Nov 8 14:02:11 06[IKE] ...» (strftime %b %e, C locale) */
|
||||||
|
private val CHARON_DATE = Regex("""^(\w{3})\s+(\d{1,2})\s+\d{2}:\d{2}:\d{2}\s""")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lines of [month] (1-12) / [day] only; lines without a date stay with the dated line before them.
|
||||||
|
* When nothing is from today the whole text is kept: an old log still helps support.
|
||||||
|
*/
|
||||||
|
fun todayOnly(text: String, month: Int, day: Int): String
|
||||||
|
{
|
||||||
|
var keep = true
|
||||||
|
var today = false
|
||||||
|
val out = StringBuilder()
|
||||||
|
for (line in text.lineSequence())
|
||||||
|
{
|
||||||
|
CHARON_DATE.find(line)?.let { m ->
|
||||||
|
keep = MONTHS.indexOf(m.groupValues[1]) + 1 == month && m.groupValues[2].toInt() == day
|
||||||
|
today = today || keep
|
||||||
|
}
|
||||||
|
if (keep && line.isNotEmpty())
|
||||||
|
{
|
||||||
|
out.append(line).append('\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* undated lines alone (e.g. the cut mark of readTail) do not count as today's log */
|
||||||
|
return if (today) out.toString() else text
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The last [maxBytes] of UTF-8 [text], cut at a line start; the cut is marked */
|
||||||
|
fun tailBytes(text: String, maxBytes: Int): String
|
||||||
|
{
|
||||||
|
val bytes = text.toByteArray(Charsets.UTF_8)
|
||||||
|
if (bytes.size <= maxBytes)
|
||||||
|
{
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
var start = bytes.size - maxBytes
|
||||||
|
/* '\n' is never part of a multi-byte UTF-8 sequence, so cutting after it keeps characters whole */
|
||||||
|
while (start < bytes.size && bytes[start - 1] != '\n'.code.toByte())
|
||||||
|
{
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
if (start == bytes.size)
|
||||||
|
{ /* one huge line: cut anywhere, but not inside a character (skip 10xxxxxx continuation bytes) */
|
||||||
|
start = bytes.size - maxBytes
|
||||||
|
while (start < bytes.size && (bytes[start].toInt() and 0xC0) == 0x80)
|
||||||
|
{
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CUT_MARK + String(bytes, start, bytes.size - start, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Up to [maxBytes] from the end of [file] ("" when missing); a partial first line is dropped */
|
||||||
|
fun readTail(file: File, maxBytes: Int): String
|
||||||
|
{
|
||||||
|
if (!file.exists())
|
||||||
|
{
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
RandomAccessFile(file, "r").use { f ->
|
||||||
|
val from = maxOf(0L, f.length() - maxBytes)
|
||||||
|
val buf = ByteArray((f.length() - from).toInt())
|
||||||
|
f.seek(from)
|
||||||
|
f.readFully(buf)
|
||||||
|
val text = String(buf, Charsets.UTF_8)
|
||||||
|
if (from > 0) CUT_MARK + text.substringAfter('\n') else text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One network check of the diagnostics screen: the panel shows them as a check list */
|
||||||
|
data class Check(val title: String, val value: String, val ok: Boolean?)
|
||||||
|
|
||||||
|
/** JSON array of the checks (no org.json: this object stays testable without Android) */
|
||||||
|
fun checksJson(checks: List<Check>): String = checks.joinToString(",", "[", "]") { c ->
|
||||||
|
"{\"title\":${jsonString(c.title)},\"value\":${jsonString(c.value)},\"ok\":${c.ok?.toString() ?: "null"}}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jsonString(s: String): String = buildString {
|
||||||
|
append('"')
|
||||||
|
for (ch in s)
|
||||||
|
{
|
||||||
|
when
|
||||||
|
{
|
||||||
|
ch == '"' -> append("\\\"")
|
||||||
|
ch == '\\' -> append("\\\\")
|
||||||
|
ch == '\n' -> append("\\n")
|
||||||
|
ch == '\r' -> append("\\r")
|
||||||
|
ch == '\t' -> append("\\t")
|
||||||
|
ch < ' ' -> append("\\u%04x".format(ch.code))
|
||||||
|
else -> append(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
append('"')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The report text: the connection log, the network checks and diag.log when asked for */
|
||||||
|
fun build(charon: String, diag: String?, checks: List<Check>? = null): String = buildString {
|
||||||
|
append("=== charon.log ===\n")
|
||||||
|
append(tailBytes(charon, CHARON_MAX_BYTES).ifBlank { "(empty)\n" })
|
||||||
|
if (!checks.isNullOrEmpty())
|
||||||
|
{
|
||||||
|
append("\n=== checks.json ===\n")
|
||||||
|
append(checksJson(checks)).append('\n')
|
||||||
|
}
|
||||||
|
if (diag != null)
|
||||||
|
{
|
||||||
|
append("\n=== diag.log ===\n")
|
||||||
|
append(tailBytes(diag, DIAG_MAX_BYTES).ifBlank { "(empty)\n" })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meta of a report from the log screen; [network] is "Wi-Fi", "mobile" or "none" */
|
||||||
|
fun meta(
|
||||||
|
versionName: String, versionCode: Long, manufacturer: String, model: String, release: String, sdk: Int,
|
||||||
|
network: String, comment: String, profile: String,
|
||||||
|
) = ReportUploader.Meta(
|
||||||
|
versionName = versionName,
|
||||||
|
versionCode = versionCode,
|
||||||
|
device = if (model.startsWith(manufacturer, ignoreCase = true)) model else "$manufacturer $model",
|
||||||
|
os = "Android $release (API $sdk)",
|
||||||
|
network = network,
|
||||||
|
source = "log",
|
||||||
|
comment = comment.trim(),
|
||||||
|
profile = profile,
|
||||||
|
)
|
||||||
|
}
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.zip.GZIPOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a diagnostic log to the web panel (POST /api/v1/reports) and returns the report
|
||||||
|
* code the user can quote to support. The request is signed like the update check.
|
||||||
|
* The "Send report" sheet of the log screen (LogActivity) calls it, see [ReportContent].
|
||||||
|
*/
|
||||||
|
class ReportUploader(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val hmacKey: String,
|
||||||
|
private val installId: String,
|
||||||
|
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||||
|
)
|
||||||
|
{
|
||||||
|
data class Meta(
|
||||||
|
val versionName: String,
|
||||||
|
val versionCode: Long,
|
||||||
|
val device: String,
|
||||||
|
val os: String,
|
||||||
|
val network: String,
|
||||||
|
val source: String,
|
||||||
|
val comment: String,
|
||||||
|
val profile: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class Result
|
||||||
|
{
|
||||||
|
data class Sent(val code: String) : Result()
|
||||||
|
data class Failed(val httpCode: Int, val detail: String) : Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** Server limit: the whole request body must stay below 2 MB */
|
||||||
|
const val MAX_BYTES = 2 * 1024 * 1024
|
||||||
|
const val BOUNDARY = "----shxreport"
|
||||||
|
private val CODE = Regex("^SHX-[0-9A-Z]{5}$")
|
||||||
|
|
||||||
|
/** The panel serves the app API under /api/v1 (the proxy routes only that prefix); the build may give just the host */
|
||||||
|
fun apiBase(url: String): String
|
||||||
|
{
|
||||||
|
val base = url.trim().trimEnd('/')
|
||||||
|
return if (base.endsWith("/api/v1")) base else "$base/api/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 201 with {"code":"SHX-XXXXX"} is the only success; a broken 201 is not "no network" */
|
||||||
|
fun parseResponse(httpCode: Int, body: String): Result
|
||||||
|
{
|
||||||
|
if (httpCode != 201)
|
||||||
|
{
|
||||||
|
return Result.Failed(httpCode, body.take(200))
|
||||||
|
}
|
||||||
|
val code = try { JSONObject(body).optString("code", "") } catch (e: Exception) { "" }
|
||||||
|
return if (CODE.matches(code)) Result.Sent(code) else Result.Failed(httpCode, "bad response: ${body.take(100)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun gzip(text: String): ByteArray
|
||||||
|
{
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
GZIPOutputStream(out).use { it.write(text.toByteArray(Charsets.UTF_8)) }
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun metaJson(m: Meta): String = JSONObject()
|
||||||
|
.put("versionName", m.versionName).put("versionCode", m.versionCode)
|
||||||
|
.put("device", m.device.take(120)).put("os", m.os.take(60)).put("network", m.network.take(40))
|
||||||
|
.put("source", m.source).put("comment", m.comment.take(1000)).put("profile", m.profile.take(200))
|
||||||
|
.toString()
|
||||||
|
|
||||||
|
/** multipart/form-data with the gzip log and the meta JSON */
|
||||||
|
fun multipartBody(logGz: ByteArray, meta: String): ByteArray
|
||||||
|
{
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
fun w(s: String) = out.write(s.toByteArray(Charsets.UTF_8))
|
||||||
|
w("--$BOUNDARY\r\nContent-Disposition: form-data; name=\"log\"; filename=\"log.gz\"\r\nContent-Type: application/gzip\r\n\r\n")
|
||||||
|
out.write(logGz)
|
||||||
|
/* a plain text field: @fastify/multipart turns an application/json field into an object and the server then reads "[object Object]" */
|
||||||
|
w("\r\n--$BOUNDARY\r\nContent-Disposition: form-data; name=\"meta\"\r\n\r\n")
|
||||||
|
w(meta)
|
||||||
|
w("\r\n--$BOUNDARY--\r\n")
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(logText: String, meta: Meta): Result
|
||||||
|
{
|
||||||
|
val body = multipartBody(gzip(logText), metaJson(meta))
|
||||||
|
if (body.size > MAX_BYTES)
|
||||||
|
{
|
||||||
|
return Result.Failed(413, "report is larger than 2 MB")
|
||||||
|
}
|
||||||
|
val url = apiBase(baseUrl) + "/reports"
|
||||||
|
val ts = clock() / 1000
|
||||||
|
val conn = URL(url).openConnection() as HttpURLConnection
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
conn.requestMethod = "POST"
|
||||||
|
conn.doOutput = true
|
||||||
|
conn.connectTimeout = 10_000
|
||||||
|
conn.readTimeout = 20_000
|
||||||
|
conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=$BOUNDARY")
|
||||||
|
conn.setRequestProperty("X-Install-Id", installId)
|
||||||
|
conn.setRequestProperty("X-Timestamp", ts.toString())
|
||||||
|
conn.setRequestProperty("X-Signature", UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("POST", URL(url).path, ts)))
|
||||||
|
conn.outputStream.use { it.write(body) }
|
||||||
|
val code = conn.responseCode
|
||||||
|
val text = (if (code in 200..299) conn.inputStream else conn.errorStream)?.use { it.readBytes().decodeToString() } ?: ""
|
||||||
|
parseResponse(code, text)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Result.Failed(0, e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -0,0 +1,96 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import java.net.URL
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal AWS Signature Version 4 signer for authenticated GET requests to an
|
||||||
|
* S3-compatible storage (no request body, no query parameters).
|
||||||
|
*/
|
||||||
|
object S3Signer
|
||||||
|
{
|
||||||
|
private const val ALGORITHM = "AWS4-HMAC-SHA256"
|
||||||
|
private val EMPTY_BODY_SHA256 = sha256Hex(ByteArray(0))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the headers that must be set on the request:
|
||||||
|
* x-amz-date, x-amz-content-sha256 and Authorization
|
||||||
|
*/
|
||||||
|
fun signGet(url: URL, region: String, accessKey: String, secretKey: String, nowMillis: Long): Map<String, String>
|
||||||
|
{
|
||||||
|
val timestamp = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply {
|
||||||
|
timeZone = TimeZone.getTimeZone("UTC")
|
||||||
|
}.format(Date(nowMillis))
|
||||||
|
val dateStamp = timestamp.substring(0, 8)
|
||||||
|
|
||||||
|
val host = url.host.let { if (url.port in -1..0 || url.port == 443) it else "$it:${url.port}" }
|
||||||
|
val path = url.path?.ifEmpty { "/" } ?: "/"
|
||||||
|
|
||||||
|
val canonicalHeaders = "host:$host\n" +
|
||||||
|
"x-amz-content-sha256:$EMPTY_BODY_SHA256\n" +
|
||||||
|
"x-amz-date:$timestamp\n"
|
||||||
|
val signedHeaders = "host;x-amz-content-sha256;x-amz-date"
|
||||||
|
val canonicalRequest = "GET\n$path\n\n$canonicalHeaders\n$signedHeaders\n$EMPTY_BODY_SHA256"
|
||||||
|
|
||||||
|
val scope = "$dateStamp/$region/s3/aws4_request"
|
||||||
|
val stringToSign = "$ALGORITHM\n$timestamp\n$scope\n${sha256Hex(canonicalRequest.toByteArray())}"
|
||||||
|
|
||||||
|
val signingKey = deriveSigningKey(secretKey, dateStamp, region, "s3")
|
||||||
|
val signature = hex(hmacSha256(signingKey, stringToSign.toByteArray()))
|
||||||
|
|
||||||
|
return mapOf(
|
||||||
|
"x-amz-date" to timestamp,
|
||||||
|
"x-amz-content-sha256" to EMPTY_BODY_SHA256,
|
||||||
|
"Authorization" to "$ALGORITHM Credential=$accessKey/$scope, SignedHeaders=$signedHeaders, Signature=$signature",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HMAC-SHA256 key chain described by AWS:
|
||||||
|
* kDate -> kRegion -> kService -> kSigning
|
||||||
|
*/
|
||||||
|
fun deriveSigningKey(secretKey: String, dateStamp: String, region: String, service: String): ByteArray
|
||||||
|
{
|
||||||
|
var key = hmacSha256(("AWS4$secretKey").toByteArray(), dateStamp.toByteArray())
|
||||||
|
key = hmacSha256(key, region.toByteArray())
|
||||||
|
key = hmacSha256(key, service.toByteArray())
|
||||||
|
return hmacSha256(key, "aws4_request".toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray
|
||||||
|
{
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
mac.init(SecretKeySpec(key, "HmacSHA256"))
|
||||||
|
return mac.doFinal(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sha256Hex(data: ByteArray): String
|
||||||
|
{
|
||||||
|
return hex(MessageDigest.getInstance("SHA-256").digest(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun hex(bytes: ByteArray): String
|
||||||
|
{
|
||||||
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import net.i2p.crypto.eddsa.EdDSAEngine
|
||||||
|
import net.i2p.crypto.eddsa.EdDSAPublicKey
|
||||||
|
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
|
||||||
|
import net.i2p.crypto.eddsa.spec.EdDSAPublicKeySpec
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.URL
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import javax.crypto.Mac
|
||||||
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client of the SHX update API (/api/v1/updates/check).
|
||||||
|
*
|
||||||
|
* The wire contract is frozen in the web panel repository: docs/openapi.v1.json
|
||||||
|
* and packages/contracts/vectors/signing.json. [UpdateSigning] builds exactly
|
||||||
|
* the strings the server signs; UpdateApiTest checks it against those vectors.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
object UpdateSigning
|
||||||
|
{
|
||||||
|
const val SIGNING_VERSION = "shx-manifest-v1"
|
||||||
|
|
||||||
|
/** The fields covered by the manifest signature (notes are display-only and not signed) */
|
||||||
|
data class Fields(
|
||||||
|
val update: Boolean,
|
||||||
|
val mandatory: Boolean,
|
||||||
|
val versionCode: Long,
|
||||||
|
val versionName: String,
|
||||||
|
val sha256: String,
|
||||||
|
val size: Long,
|
||||||
|
val apkUrl: String,
|
||||||
|
val issuedAt: Long,
|
||||||
|
val checkHours: Int,
|
||||||
|
val wifiOnly: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Fields joined by '\n' in a strict order; absent values are 0 / empty (same as the server) */
|
||||||
|
fun manifestInput(f: Fields): String
|
||||||
|
{
|
||||||
|
return listOf(
|
||||||
|
SIGNING_VERSION,
|
||||||
|
if (f.update) "1" else "0",
|
||||||
|
if (f.mandatory) "1" else "0",
|
||||||
|
f.versionCode.toString(),
|
||||||
|
f.versionName,
|
||||||
|
f.sha256,
|
||||||
|
f.size.toString(),
|
||||||
|
f.apkUrl,
|
||||||
|
f.issuedAt.toString(),
|
||||||
|
f.checkHours.toString(),
|
||||||
|
if (f.wifiOnly) "1" else "0",
|
||||||
|
).joinToString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestInput(method: String, path: String, timestamp: Long): String
|
||||||
|
{
|
||||||
|
return "${method.uppercase()}|$path|$timestamp"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hmacBase64(key: String, input: String): String
|
||||||
|
{
|
||||||
|
val mac = Mac.getInstance("HmacSHA256")
|
||||||
|
mac.init(SecretKeySpec(key.toByteArray(Charsets.UTF_8), "HmacSHA256"))
|
||||||
|
return Base64.Default.encode(mac.doFinal(input.toByteArray(Charsets.UTF_8)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ed25519 verification on API 24+ (java.security Ed25519 exists only from API 33),
|
||||||
|
* therefore a small pure-Java implementation is used
|
||||||
|
*/
|
||||||
|
fun verifyEd25519(publicKeyRaw: ByteArray, input: String, signatureBase64: String): Boolean
|
||||||
|
{
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val spec = EdDSAPublicKeySpec(publicKeyRaw, EdDSANamedCurveTable.getByName(EdDSANamedCurveTable.ED_25519))
|
||||||
|
val engine = EdDSAEngine(MessageDigest.getInstance("SHA-512"))
|
||||||
|
engine.initVerify(EdDSAPublicKey(spec))
|
||||||
|
engine.update(input.toByteArray(Charsets.UTF_8))
|
||||||
|
engine.verify(Base64.Default.decode(signatureBase64))
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where the HTTP is delegated to, so the client can be tested without a network */
|
||||||
|
interface UpdateHttp
|
||||||
|
{
|
||||||
|
data class Response(val code: Int, val body: String)
|
||||||
|
|
||||||
|
fun get(url: String, headers: Map<String, String>): Response
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpUrlConnectionHttp(private val timeoutMs: Int = 10_000) : UpdateHttp
|
||||||
|
{
|
||||||
|
override fun get(url: String, headers: Map<String, String>): UpdateHttp.Response
|
||||||
|
{
|
||||||
|
val conn = URL(url).openConnection(java.net.Proxy.NO_PROXY) as HttpURLConnection
|
||||||
|
conn.connectTimeout = timeoutMs
|
||||||
|
conn.readTimeout = timeoutMs
|
||||||
|
conn.setRequestProperty("Connection", "close")
|
||||||
|
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||||
|
for ((k, v) in headers)
|
||||||
|
{
|
||||||
|
conn.setRequestProperty(k, v)
|
||||||
|
}
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val code = conn.responseCode
|
||||||
|
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||||
|
UpdateHttp.Response(code, stream?.use { it.readBytes().decodeToString() } ?: "")
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
conn.disconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Policy delivered inside the signed manifest; the user's own settings stay the default */
|
||||||
|
data class UpdatePolicy(val checkHours: Int, val wifiOnly: Boolean)
|
||||||
|
|
||||||
|
sealed class UpdateApiResult
|
||||||
|
{
|
||||||
|
data class Update(val info: UpdateInfo, val mandatory: Boolean, val policy: UpdatePolicy, val issuedAt: Long) : UpdateApiResult()
|
||||||
|
|
||||||
|
data class NoUpdate(val policy: UpdatePolicy) : UpdateApiResult()
|
||||||
|
|
||||||
|
/** [kind]: NETWORK (try the legacy path), SIGNATURE (never fall back), OTHER (rate limit, bad request) */
|
||||||
|
data class Failure(val kind: UpdateError, val detail: String) : UpdateApiResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
class UpdateApiClient(
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val hmacKey: String,
|
||||||
|
private val publicKeyBase64: String,
|
||||||
|
private val installId: String,
|
||||||
|
private val http: UpdateHttp = HttpUrlConnectionHttp(),
|
||||||
|
private val clock: () -> Long = { System.currentTimeMillis() },
|
||||||
|
)
|
||||||
|
{
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** The manifest is rejected when its issue time is far from the device clock (replay of a stale "no update") */
|
||||||
|
const val MAX_SKEW_SECONDS = 6 * 3600L
|
||||||
|
|
||||||
|
fun isConfigured(baseUrl: String, hmacKey: String, publicKey: String): Boolean
|
||||||
|
{
|
||||||
|
return baseUrl.isNotBlank() && hmacKey.isNotBlank() && publicKey.isNotBlank()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun check(packageId: String, versionCode: Long, channel: String, abi: String?, sdk: Int, betaToken: String?): UpdateApiResult
|
||||||
|
{
|
||||||
|
val path = URL(ReportUploader.apiBase(baseUrl) + "/updates/check").path
|
||||||
|
val query = buildString {
|
||||||
|
append("package=").append(enc(packageId))
|
||||||
|
append("&versionCode=").append(versionCode)
|
||||||
|
append("&channel=").append(enc(channel))
|
||||||
|
if (!abi.isNullOrBlank()) append("&abi=").append(enc(abi))
|
||||||
|
append("&sdk=").append(sdk)
|
||||||
|
if (!betaToken.isNullOrBlank()) append("&betaToken=").append(enc(betaToken))
|
||||||
|
}
|
||||||
|
val ts = clock() / 1000
|
||||||
|
val headers = mapOf(
|
||||||
|
"X-Install-Id" to installId,
|
||||||
|
"X-Timestamp" to ts.toString(),
|
||||||
|
"X-Signature" to UpdateSigning.hmacBase64(hmacKey, UpdateSigning.requestInput("GET", path, ts)),
|
||||||
|
)
|
||||||
|
val response = try
|
||||||
|
{
|
||||||
|
http.get(ReportUploader.apiBase(baseUrl) + "/updates/check?" + query, headers)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.NETWORK, e.message ?: e.javaClass.simpleName)
|
||||||
|
}
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
response.code in 500..599 || response.code == 404 -> UpdateApiResult.Failure(UpdateError.NETWORK, "HTTP ${response.code}")
|
||||||
|
response.code != 200 -> UpdateApiResult.Failure(UpdateError.OTHER, "HTTP ${response.code}")
|
||||||
|
else -> parse(response.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parse(body: String): UpdateApiResult
|
||||||
|
{
|
||||||
|
val json = try { JSONObject(body) } catch (e: Exception) { return UpdateApiResult.Failure(UpdateError.OTHER, "bad json") }
|
||||||
|
val update = json.optBoolean("update", false)
|
||||||
|
val fields = UpdateSigning.Fields(
|
||||||
|
update = update,
|
||||||
|
mandatory = json.optBoolean("mandatory", false),
|
||||||
|
versionCode = if (update) json.optLong("versionCode", 0L) else 0L,
|
||||||
|
versionName = if (update) json.optString("versionName", "") else "",
|
||||||
|
sha256 = if (update) json.optString("sha256", "").lowercase() else "",
|
||||||
|
size = if (update) json.optLong("size", 0L) else 0L,
|
||||||
|
apkUrl = if (update) json.optString("apkUrl", "") else "",
|
||||||
|
issuedAt = json.optLong("issuedAt", 0L),
|
||||||
|
checkHours = json.optInt("checkHours", 12),
|
||||||
|
wifiOnly = json.optBoolean("wifiOnly", true),
|
||||||
|
)
|
||||||
|
val key = try { Base64.Default.decode(publicKeyBase64) } catch (e: Exception) { ByteArray(0) }
|
||||||
|
if (key.size != 32 || !UpdateSigning.verifyEd25519(key, UpdateSigning.manifestInput(fields), json.optString("signature", "")))
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest signature mismatch")
|
||||||
|
}
|
||||||
|
val skew = Math.abs(clock() / 1000 - fields.issuedAt)
|
||||||
|
if (skew > MAX_SKEW_SECONDS)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.SIGNATURE, "manifest is too old or the device clock is off ($skew s)")
|
||||||
|
}
|
||||||
|
val policy = UpdatePolicy(fields.checkHours, fields.wifiOnly)
|
||||||
|
if (!update)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.NoUpdate(policy)
|
||||||
|
}
|
||||||
|
if (fields.apkUrl.isBlank() || fields.sha256.length != 64)
|
||||||
|
{
|
||||||
|
return UpdateApiResult.Failure(UpdateError.OTHER, "incomplete manifest")
|
||||||
|
}
|
||||||
|
val notes = HashMap<String, String>()
|
||||||
|
json.optJSONObject("notes")?.let { n -> for (k in n.keys()) notes[k.lowercase()] = n.optString(k, "") }
|
||||||
|
val info = UpdateInfo(
|
||||||
|
versionCode = fields.versionCode, versionName = fields.versionName, apkUrl = fields.apkUrl,
|
||||||
|
sha256 = fields.sha256, size = fields.size, notes = notes, fromApi = true, issuedAt = fields.issuedAt,
|
||||||
|
)
|
||||||
|
return UpdateApiResult.Update(info, fields.mandatory, policy, fields.issuedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enc(s: String) = URLEncoder.encode(s, "UTF-8")
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic background update check. Runs via WorkManager, so it survives the
|
||||||
|
* process being killed by the system (an in-process timer does not - the
|
||||||
|
* system reclaims backgrounded processes aggressively, especially with the
|
||||||
|
* VPN disconnected). The actual check is throttled by
|
||||||
|
* [UpdateManager.checkIfDue] through the shared preference timestamp.
|
||||||
|
*/
|
||||||
|
class UpdateCheckWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
|
||||||
|
{
|
||||||
|
override suspend fun doWork(): Result
|
||||||
|
{
|
||||||
|
/* wait for the check (and a silent pre-download it starts): returning
|
||||||
|
* early lets the system reclaim the process mid-request, outside the
|
||||||
|
* worker's charging/network constraints */
|
||||||
|
val manager = UpdateManager.getInstance(applicationContext)
|
||||||
|
manager.checkIfDue()?.join()
|
||||||
|
manager.awaitDownload()
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
const val WORK_NAME = "update-check"
|
||||||
|
}
|
||||||
|
}
|
||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.content.IntentCompat
|
||||||
|
import org.strongswan.android.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Receives the result of the self-update installation from PackageInstaller,
|
||||||
|
* shows the system confirmation dialog when required and notifies the user
|
||||||
|
* about the outcome
|
||||||
|
*/
|
||||||
|
class UpdateInstallStatusReceiver : BroadcastReceiver()
|
||||||
|
{
|
||||||
|
override fun onReceive(context: Context, intent: Intent)
|
||||||
|
{
|
||||||
|
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE)
|
||||||
|
when (status)
|
||||||
|
{
|
||||||
|
PackageInstaller.STATUS_PENDING_USER_ACTION ->
|
||||||
|
{
|
||||||
|
/* the system requires the user to confirm the installation */
|
||||||
|
android.util.Log.w("UpdateInstall", "System required user action, the silent install was overridden")
|
||||||
|
val confirmation = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_INTENT, Intent::class.java)
|
||||||
|
if (confirmation != null)
|
||||||
|
{
|
||||||
|
confirmation.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.startActivity(confirmation)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
Toast.makeText(context,
|
||||||
|
context.getString(R.string.update_install_failed_fmt, ""),
|
||||||
|
Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PackageInstaller.STATUS_SUCCESS ->
|
||||||
|
{
|
||||||
|
Toast.makeText(context, R.string.update_install_success, Toast.LENGTH_LONG).show()
|
||||||
|
UpdateManager.getInstance(context).onInstallFinished()
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
{
|
||||||
|
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
context.getString(R.string.update_install_failed_fmt, message ?: ""),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
UpdateManager.getInstance(context).onInstallFinished()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageInstaller
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Installs a downloaded update APK as an in-place update (same package, higher
|
||||||
|
* versionCode, same signing key - the app data is preserved, nothing is uninstalled).
|
||||||
|
*/
|
||||||
|
object UpdateInstaller
|
||||||
|
{
|
||||||
|
/* hardcoded: the runtime class name is obfuscated by R8 in release builds */
|
||||||
|
private const val TAG = "UpdateInstaller"
|
||||||
|
|
||||||
|
fun install(context: Context, apk: File)
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
installViaSession(context, apk)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
/* fall through to the package-archive intent below */
|
||||||
|
Log.e(TAG, "PackageInstaller session failed, falling back", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
installViaIntent(context, apk)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installViaSession(context: Context, apk: File)
|
||||||
|
{
|
||||||
|
val packageInstaller = context.packageManager.packageInstaller
|
||||||
|
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
{
|
||||||
|
/* the app updates itself (same package, same signing key, higher
|
||||||
|
* versionCode); with UPDATE_PACKAGES_WITHOUT_USER_ACTION the
|
||||||
|
* system applies the update without any confirmation dialog */
|
||||||
|
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
|
{ /* claim update ownership (Android 14+): without it, newer Android
|
||||||
|
* versions (and vendors like Samsung) override the silent install
|
||||||
|
* and show the Play Protect confirmation on every update */
|
||||||
|
params.setRequestUpdateOwnership(true)
|
||||||
|
}
|
||||||
|
val sessionId = packageInstaller.createSession(params)
|
||||||
|
Log.i(TAG, "Committing install session $sessionId (silent self-update)")
|
||||||
|
val session = packageInstaller.openSession(sessionId)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
session.openWrite("shx-vpn", 0, apk.length()).use { output ->
|
||||||
|
apk.inputStream().use { input ->
|
||||||
|
input.copyTo(output, 64 * 1024)
|
||||||
|
}
|
||||||
|
session.fsync(output)
|
||||||
|
}
|
||||||
|
val statusIntent = Intent(context, UpdateInstallStatusReceiver::class.java)
|
||||||
|
val pendingIntent = PendingIntent.getBroadcast(context, sessionId, statusIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
|
||||||
|
session.commit(pendingIntent.intentSender)
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
packageInstaller.abandonSession(sessionId)
|
||||||
|
}
|
||||||
|
catch (ignored: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
session.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for older devices (or a failing session): open the downloaded
|
||||||
|
* APK with the system package installer
|
||||||
|
*/
|
||||||
|
private fun installViaIntent(context: Context, apk: File)
|
||||||
|
{
|
||||||
|
val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", apk)
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW)
|
||||||
|
.setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the session-based install may be used right away */
|
||||||
|
fun canInstall(context: Context): Boolean
|
||||||
|
{
|
||||||
|
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||||
|
context.packageManager.canRequestPackageInstalls()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Intent for the system page that grants this app the "install unknown
|
||||||
|
* apps" permission (required since Android 8 for the session-based install)
|
||||||
|
*/
|
||||||
|
fun createInstallPermissionIntent(context: Context): Intent
|
||||||
|
{
|
||||||
|
return Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
|
||||||
|
Uri.parse("package:" + context.packageName))
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the system page that grants this app the "install unknown apps"
|
||||||
|
* permission (required since Android 8 for the session-based install)
|
||||||
|
*/
|
||||||
|
fun requestInstallPermission(context: Context)
|
||||||
|
{
|
||||||
|
context.startActivity(createInstallPermissionIntent(context))
|
||||||
|
}
|
||||||
|
}
|
||||||
+1017
File diff suppressed because it is too large.
Load diff
+71
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Release notes of the app language: the full tag ("zh-tw"), then the language ("zh"),
|
||||||
|
* then Russian, then English. Keys of [notes] are lowercase (see UpdateManager.parseNotes).
|
||||||
|
*/
|
||||||
|
fun pickNotes(notes: Map<String, String>, languageTag: String, language: String): String?
|
||||||
|
{
|
||||||
|
return listOf(languageTag.lowercase(), language.lowercase(), "ru", "en")
|
||||||
|
.firstNotNullOfOrNull { key -> notes[key]?.takeIf { it.isNotBlank() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every non-empty line of the notes is one list item; a leading "-", "*" or "•" is dropped. */
|
||||||
|
fun noteLines(text: String?): List<String>
|
||||||
|
{
|
||||||
|
return text.orEmpty().lines().map { it.trim().trimStart('-', '*', '•').trim() }.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the download into [output] and the digest; [isCancelled] is checked between reads and
|
||||||
|
* when a read fails (cancelling disconnects the connection, which breaks a blocked read).
|
||||||
|
*/
|
||||||
|
internal fun copyDownload(input: InputStream, output: OutputStream, digest: MessageDigest,
|
||||||
|
isCancelled: () -> Boolean, onProgress: (Long) -> Unit): Long
|
||||||
|
{
|
||||||
|
val buffer = ByteArray(64 * 1024)
|
||||||
|
var total = 0L
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
if (isCancelled())
|
||||||
|
{
|
||||||
|
throw CancellationException("update download cancelled")
|
||||||
|
}
|
||||||
|
val len = try
|
||||||
|
{
|
||||||
|
input.read(buffer)
|
||||||
|
}
|
||||||
|
catch (e: IOException)
|
||||||
|
{
|
||||||
|
if (isCancelled()) throw CancellationException("update download cancelled") else throw e
|
||||||
|
}
|
||||||
|
if (len < 0)
|
||||||
|
{
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, len)
|
||||||
|
digest.update(buffer, 0, len)
|
||||||
|
total += len
|
||||||
|
onProgress(total)
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.logic.updates
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import org.strongswan.android.logic.autoconnect.AutoConnectManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs after the app updated itself (the system kills the process during the
|
||||||
|
* install, so this is the first code of the new version that runs): clears
|
||||||
|
* the update state and lets the auto-connect logic restore the VPN connection
|
||||||
|
* if it was up before the update.
|
||||||
|
*/
|
||||||
|
class UpdateReplacedReceiver : BroadcastReceiver()
|
||||||
|
{
|
||||||
|
override fun onReceive(context: Context, intent: Intent)
|
||||||
|
{
|
||||||
|
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
UpdateManager.getInstance(context).onInstallFinished()
|
||||||
|
AutoConnectManager.getInstance(context).reevaluate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxChip
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
|
||||||
|
/** «Without battery saving» (F_Battery): the system exemption, then the vendor switches. */
|
||||||
|
class BatteryActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
BatteryScreen(onBack = { finish() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* phone makers with their own background killers; the steps differ per brand */
|
||||||
|
private enum class Brand(val label: Int, val steps: List<Int>)
|
||||||
|
{
|
||||||
|
XIAOMI(R.string.battery_brand_xiaomi, listOf(R.string.battery_step_open, R.string.battery_xiaomi_2, R.string.battery_xiaomi_3)),
|
||||||
|
SAMSUNG(R.string.battery_brand_samsung, listOf(R.string.battery_step_open, R.string.battery_samsung_2, R.string.battery_samsung_3)),
|
||||||
|
HUAWEI(R.string.battery_brand_huawei, listOf(R.string.battery_step_open, R.string.battery_huawei_2, R.string.battery_huawei_3)),
|
||||||
|
OPPO(R.string.battery_brand_oppo, listOf(R.string.battery_step_open, R.string.battery_oppo_2, R.string.battery_oppo_3)),
|
||||||
|
OTHER(R.string.battery_brand_other, listOf(R.string.battery_step_open, R.string.battery_other_2)),
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectBrand(): Brand
|
||||||
|
{
|
||||||
|
val m = (Build.MANUFACTURER + " " + Build.BRAND).lowercase()
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
listOf("xiaomi", "redmi", "poco").any { it in m } -> Brand.XIAOMI
|
||||||
|
"samsung" in m -> Brand.SAMSUNG
|
||||||
|
"huawei" in m || "honor" in m -> Brand.HUAWEI
|
||||||
|
listOf("oppo", "realme", "oneplus").any { it in m } -> Brand.OPPO
|
||||||
|
else -> Brand.OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isExempt(context: Context): Boolean =
|
||||||
|
(context.getSystemService(Context.POWER_SERVICE) as? PowerManager)?.isIgnoringBatteryOptimizations(context.packageName) ?: true
|
||||||
|
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
|
@Composable
|
||||||
|
private fun BatteryScreen(onBack: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
var checks by remember { mutableIntStateOf(0) }
|
||||||
|
val owner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(owner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) checks++ }
|
||||||
|
owner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
val exempt = remember(checks) { isExempt(context) }
|
||||||
|
val detected = remember { detectBrand() }
|
||||||
|
var brand by rememberSaveable { mutableIntStateOf(detected.ordinal) }
|
||||||
|
fun open(intent: Intent) = runCatching { context.startActivity(intent) }.isSuccess
|
||||||
|
|
||||||
|
SecondaryScaffold(title = stringResource(R.string.battery_title), onBack = onBack) { padding ->
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())
|
||||||
|
.padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.battery_intro), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp, fontWeight = FontWeight.Normal),
|
||||||
|
color = c.textTonal, modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
StepCard(1, stringResource(R.string.battery_step1_title), highlight = !exempt)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.battery_step1_text), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
|
||||||
|
if (exempt)
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(24.dp).clip(CircleShape).background(c.success))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp))
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.battery_step1_done), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.onSuccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.perm_allow),
|
||||||
|
{ open(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:" + context.packageName))) },
|
||||||
|
height = 48.dp, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StepCard(2, stringResource(R.string.battery_step2_title), highlight = false)
|
||||||
|
{
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
Brand.entries.forEach { b -> ShxChip(stringResource(b.label), brand == b.ordinal, { brand = b.ordinal }) }
|
||||||
|
}
|
||||||
|
if (detected != Brand.OTHER)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.battery_detected, Build.MANUFACTURER.replaceFirstChar { it.uppercase() }),
|
||||||
|
style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.accentText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
Brand.entries[brand].steps.forEachIndexed { i, step ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(22.dp).clip(CircleShape).background(c.tile))
|
||||||
|
{
|
||||||
|
Text("${i + 1}", style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||||
|
}
|
||||||
|
Text(stringResource(step), style = ShxType.body.copy(fontWeight = FontWeight.Normal, lineHeight = 20.sp), color = c.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.battery_open_app_settings),
|
||||||
|
{ open(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null))) },
|
||||||
|
style = ShxButtonStyle.OUTLINE, height = 48.dp, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(if (exempt) c.successSoft else c.warningContainer).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(if (exempt) ShxIcons.Check else ShxIcons.Alert, contentDescription = null, tint = if (exempt) c.onSuccess else c.onWarningContainer, modifier = Modifier.size(20.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(if (exempt) R.string.battery_ok else R.string.battery_not_done),
|
||||||
|
style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = if (exempt) c.onSuccess else c.onWarningContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShxButton(stringResource(R.string.battery_check_again), { checks++ }, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StepCard(n: Int, title: String, highlight: Boolean, content: @Composable () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, if (highlight) c.accentBorder else c.cardBorder, shape).padding(16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(c.accent))
|
||||||
|
{
|
||||||
|
Text("$n", style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
|
||||||
|
}
|
||||||
|
Text(title, style = ShxType.rowTitle.copy(fontWeight = FontWeight.ExtraBold), color = c.text)
|
||||||
|
}
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
-82
@@ -1,82 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2014 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.app.Dialog;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.DialogInterface;
|
|
||||||
import android.os.Bundle;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
import androidx.appcompat.app.AppCompatDialogFragment;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class that displays a confirmation dialog to delete a selected local
|
|
||||||
* certificate.
|
|
||||||
*/
|
|
||||||
public class CertificateDeleteConfirmationDialog extends AppCompatDialogFragment
|
|
||||||
{
|
|
||||||
public static final String ALIAS = "alias";
|
|
||||||
OnCertificateDeleteListener mListener;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface that can be implemented by parent activities to get the
|
|
||||||
* alias of the certificate to delete, if the user confirms the deletion.
|
|
||||||
*/
|
|
||||||
public interface OnCertificateDeleteListener
|
|
||||||
{
|
|
||||||
public void onDelete(String alias);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onAttach(Context context)
|
|
||||||
{
|
|
||||||
super.onAttach(context);
|
|
||||||
if (context instanceof OnCertificateDeleteListener)
|
|
||||||
{
|
|
||||||
mListener = (OnCertificateDeleteListener)context;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Dialog onCreateDialog(Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
return new AlertDialog.Builder(getActivity())
|
|
||||||
.setIcon(android.R.drawable.ic_dialog_alert)
|
|
||||||
.setTitle(R.string.delete_certificate_question)
|
|
||||||
.setMessage(R.string.delete_certificate)
|
|
||||||
.setPositiveButton(R.string.delete_profile, new DialogInterface.OnClickListener() {
|
|
||||||
@Override
|
|
||||||
public void onClick(DialogInterface dialog, int whichButton)
|
|
||||||
{
|
|
||||||
if (mListener != null)
|
|
||||||
{
|
|
||||||
mListener.onDelete(getArguments().getString(ALIAS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() {
|
|
||||||
@Override
|
|
||||||
public void onClick(DialogInterface dialog, int which)
|
|
||||||
{
|
|
||||||
dismiss();
|
|
||||||
}
|
|
||||||
}).create();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.connectivity.HealthCheckManager
|
||||||
|
import org.strongswan.android.ui.compose.ConnectivityCheckScreen
|
||||||
|
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
class ConnectivityCheckActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
HealthCheckManager.getInstance(this).reloadConfig()
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
SecondaryScaffold(title = stringResource(R.string.health_check_title), onBack = { finish() }) { padding ->
|
||||||
|
Box(Modifier.padding(padding)) {
|
||||||
|
ConnectivityCheckScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import org.strongswan.android.ui.compose.DiagnosticsScreen
|
||||||
|
import org.strongswan.android.ui.compose.RouteCheckMode
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
/** «Diagnostics», opened from the pulse button on Home and from the alert states. */
|
||||||
|
class DiagnosticsActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background))
|
||||||
|
{
|
||||||
|
DiagnosticsScreen(
|
||||||
|
onBack = { finish() },
|
||||||
|
onLog = { startActivity(Intent(this@DiagnosticsActivity, LogActivity::class.java)) },
|
||||||
|
onCheckSite = { site -> startActivity(RouteCheckActivity.intent(this@DiagnosticsActivity, RouteCheckMode.SITE, site)) },
|
||||||
|
onCheckApp = { pkg -> startActivity(RouteCheckActivity.intent(this@DiagnosticsActivity, RouteCheckMode.APP, pkg)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-225
@@ -1,225 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2013 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.app.Service;
|
|
||||||
import android.content.ComponentName;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.ServiceConnection;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.IBinder;
|
|
||||||
import android.view.GestureDetector;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.MotionEvent;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.View.OnClickListener;
|
|
||||||
import android.view.View.OnTouchListener;
|
|
||||||
import android.view.ViewConfiguration;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.LinearLayout;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
import org.strongswan.android.logic.VpnStateService;
|
|
||||||
import org.strongswan.android.logic.VpnStateService.VpnStateListener;
|
|
||||||
import org.strongswan.android.logic.imc.ImcState;
|
|
||||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
import androidx.fragment.app.Fragment;
|
|
||||||
import androidx.fragment.app.FragmentManager;
|
|
||||||
import androidx.fragment.app.FragmentTransaction;
|
|
||||||
|
|
||||||
public class ImcStateFragment extends Fragment implements VpnStateListener
|
|
||||||
{
|
|
||||||
private int mColorIsolate;
|
|
||||||
private int mColorBlock;
|
|
||||||
private boolean mVisible;
|
|
||||||
private TextView mStateView;
|
|
||||||
private TextView mAction;
|
|
||||||
private LinearLayout mButton;
|
|
||||||
private VpnStateService mService;
|
|
||||||
private final ServiceConnection mServiceConnection = new ServiceConnection()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public void onServiceDisconnected(ComponentName name)
|
|
||||||
{
|
|
||||||
mService = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onServiceConnected(ComponentName name, IBinder service)
|
|
||||||
{
|
|
||||||
mService = ((VpnStateService.LocalBinder)service).getService();
|
|
||||||
if (mVisible)
|
|
||||||
{
|
|
||||||
mService.registerListener(ImcStateFragment.this);
|
|
||||||
updateView();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
|
|
||||||
mColorIsolate = ContextCompat.getColor(getActivity(), R.color.warning_text);
|
|
||||||
mColorBlock = ContextCompat.getColor(getActivity(), R.color.error_text);
|
|
||||||
|
|
||||||
/* bind to the service only seems to work from the ApplicationContext */
|
|
||||||
Context context = getActivity().getApplicationContext();
|
|
||||||
context.bindService(new Intent(context, VpnStateService.class),
|
|
||||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
|
||||||
/* hide it initially */
|
|
||||||
getParentFragmentManager().beginTransaction().hide(this).commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public View onCreateView(LayoutInflater inflater, ViewGroup container,
|
|
||||||
Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
View view = inflater.inflate(R.layout.imc_state_fragment, container, false);
|
|
||||||
|
|
||||||
mButton = (LinearLayout)view.findViewById(R.id.imc_state_button);
|
|
||||||
mButton.setOnClickListener(new OnClickListener()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public void onClick(View v)
|
|
||||||
{
|
|
||||||
Intent intent;
|
|
||||||
if (mService != null && !mService.getRemediationInstructions().isEmpty())
|
|
||||||
{
|
|
||||||
intent = new Intent(getActivity(), RemediationInstructionsActivity.class);
|
|
||||||
intent.putParcelableArrayListExtra(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS,
|
|
||||||
new ArrayList<RemediationInstruction>(mService.getRemediationInstructions()));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
intent = new Intent(getActivity(), LogActivity.class);
|
|
||||||
}
|
|
||||||
startActivity(intent);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
final GestureDetector gestures = new GestureDetector(getActivity(), new GestureDetector.SimpleOnGestureListener()
|
|
||||||
{
|
|
||||||
/* a better value would be getScaledTouchExplorationTapSlop() but that is hidden */
|
|
||||||
private final int mMinDistance = ViewConfiguration.get(getActivity()).getScaledTouchSlop() * 4;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)
|
|
||||||
{
|
|
||||||
if (Math.abs(e1.getX() - e2.getX()) >= mMinDistance)
|
|
||||||
{ /* only if the user swiped a minimum horizontal distance */
|
|
||||||
if (mService != null)
|
|
||||||
{
|
|
||||||
mService.setImcState(ImcState.UNKNOWN);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
mButton.setOnTouchListener(new OnTouchListener()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public boolean onTouch(View v, MotionEvent event)
|
|
||||||
{
|
|
||||||
return gestures.onTouchEvent(event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
mStateView = (TextView)view.findViewById(R.id.imc_state);
|
|
||||||
mAction = (TextView)view.findViewById(R.id.action);
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onResume()
|
|
||||||
{
|
|
||||||
super.onResume();
|
|
||||||
mVisible = true;
|
|
||||||
if (mService != null)
|
|
||||||
{
|
|
||||||
mService.registerListener(this);
|
|
||||||
updateView();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onPause()
|
|
||||||
{
|
|
||||||
super.onPause();
|
|
||||||
mVisible = false;
|
|
||||||
if (mService != null)
|
|
||||||
{
|
|
||||||
mService.unregisterListener(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroy()
|
|
||||||
{
|
|
||||||
super.onDestroy();
|
|
||||||
if (mService != null)
|
|
||||||
{
|
|
||||||
getActivity().getApplicationContext().unbindService(mServiceConnection);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void stateChanged()
|
|
||||||
{
|
|
||||||
updateView();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void updateView()
|
|
||||||
{
|
|
||||||
FragmentManager fm = getParentFragmentManager();
|
|
||||||
if (fm == null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
FragmentTransaction ft = fm.beginTransaction();
|
|
||||||
|
|
||||||
switch (mService.getImcState())
|
|
||||||
{
|
|
||||||
case UNKNOWN:
|
|
||||||
case ALLOW:
|
|
||||||
ft.hide(this);
|
|
||||||
break;
|
|
||||||
case ISOLATE:
|
|
||||||
mStateView.setText(R.string.imc_state_isolate);
|
|
||||||
mStateView.setTextColor(mColorIsolate);
|
|
||||||
ft.show(this);
|
|
||||||
break;
|
|
||||||
case BLOCK:
|
|
||||||
mStateView.setText(R.string.imc_state_block);
|
|
||||||
mStateView.setTextColor(mColorBlock);
|
|
||||||
ft.show(this);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
ft.commit();
|
|
||||||
|
|
||||||
mAction.setText(mService.getRemediationInstructions().isEmpty() ? R.string.show_log
|
|
||||||
: R.string.show_remediation_instructions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import org.strongswan.android.logic.events.EventAction
|
||||||
|
import org.strongswan.android.logic.events.EventLog
|
||||||
|
import org.strongswan.android.logic.events.ShxEvent
|
||||||
|
import org.strongswan.android.logic.routing.RoutingManager
|
||||||
|
import org.strongswan.android.ui.compose.InboxScreen
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
/** «Events»: what the app noticed and did by itself, opened from the bell on Home and from problem notifications. */
|
||||||
|
class InboxActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
InboxScreen(onBack = { finish() }, onAction = ::perform, onNotificationSettings = ::openNotificationSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop()
|
||||||
|
{
|
||||||
|
/* everything shown counts as seen once the user leaves the screen */
|
||||||
|
EventLog.getInstance(this).markAllRead()
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun perform(event: ShxEvent)
|
||||||
|
{
|
||||||
|
val action = event.action
|
||||||
|
if (action == EventAction.SET_APP_DIRECT)
|
||||||
|
{
|
||||||
|
event.target?.let { RoutingManager.getInstance(this).setAppDirect(it, true) }
|
||||||
|
EventLog.getInstance(this).answer(event.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val main = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
when (action)
|
||||||
|
{
|
||||||
|
EventAction.NONE, EventAction.SET_APP_DIRECT -> return
|
||||||
|
EventAction.OPEN_HOME -> startActivity(main)
|
||||||
|
EventAction.OPEN_ROUTING -> startActivity(main.putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
|
||||||
|
EventAction.INSTALL_UPDATE -> startActivity(main.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true))
|
||||||
|
EventAction.OPEN_ROUTE_CHECK -> startActivity(Intent(this, RouteCheckActivity::class.java))
|
||||||
|
EventAction.PROFILE_UPDATE -> event.target?.let { startActivity(ServerActivity.intent(this, it, checkUpdate = true)) }
|
||||||
|
EventAction.OPEN_SERVER -> event.target?.let { startActivity(ServerActivity.intent(this, it)) }
|
||||||
|
EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java))
|
||||||
|
}
|
||||||
|
if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE)
|
||||||
|
{
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openNotificationSettings()
|
||||||
|
{
|
||||||
|
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
{
|
||||||
|
EventLog.ensureChannel(this)
|
||||||
|
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.fromParts("package", packageName, null))
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
catch (_: ActivityNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2012 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.content.pm.PackageManager.NameNotFoundException;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuItem;
|
|
||||||
import android.widget.Toast;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
import org.strongswan.android.data.LogContentProvider;
|
|
||||||
import org.strongswan.android.logic.CharonVpnService;
|
|
||||||
import org.strongswan.android.utils.Utils;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
|
||||||
import androidx.core.view.WindowCompat;
|
|
||||||
|
|
||||||
public class LogActivity extends AppCompatActivity
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
setContentView(R.layout.log_activity);
|
|
||||||
WindowCompat.enableEdgeToEdge(getWindow());
|
|
||||||
Utils.applyWindowInsetsAsMarginsForLists(findViewById(R.id.layout));
|
|
||||||
|
|
||||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onCreateOptionsMenu(Menu menu)
|
|
||||||
{
|
|
||||||
getMenuInflater().inflate(R.menu.log, menu);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onOptionsItemSelected(MenuItem item)
|
|
||||||
{
|
|
||||||
switch (item.getItemId())
|
|
||||||
{
|
|
||||||
case android.R.id.home:
|
|
||||||
finish();
|
|
||||||
return true;
|
|
||||||
case R.id.menu_send_log:
|
|
||||||
File logfile = new File(getFilesDir(), CharonVpnService.LOG_FILE);
|
|
||||||
if (!logfile.exists() || logfile.length() == 0)
|
|
||||||
{
|
|
||||||
Toast.makeText(this, getString(R.string.empty_log), Toast.LENGTH_SHORT).show();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
String version = "";
|
|
||||||
try
|
|
||||||
{
|
|
||||||
version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
|
|
||||||
}
|
|
||||||
catch (NameNotFoundException e)
|
|
||||||
{
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
|
|
||||||
Intent intent = new Intent(Intent.ACTION_SEND);
|
|
||||||
intent.putExtra(Intent.EXTRA_EMAIL, new String[]{MainActivity.CONTACT_EMAIL});
|
|
||||||
intent.putExtra(Intent.EXTRA_SUBJECT, String.format(getString(R.string.log_mail_subject), version));
|
|
||||||
intent.setType("text/plain");
|
|
||||||
intent.putExtra(Intent.EXTRA_STREAM, LogContentProvider.createContentUri());
|
|
||||||
startActivity(Intent.createChooser(intent, getString(R.string.send_log)));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return super.onOptionsItemSelected(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxChip
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.data.LogContentProvider
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileReader
|
||||||
|
|
||||||
|
class LogActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
|
||||||
|
|
||||||
|
/* the active profile goes into the report meta */
|
||||||
|
private var vpn: VpnStateService? = null
|
||||||
|
private val connection = object : ServiceConnection
|
||||||
|
{
|
||||||
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||||
|
{
|
||||||
|
vpn = (binder as VpnStateService.LocalBinder).service
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceDisconnected(name: ComponentName?)
|
||||||
|
{
|
||||||
|
vpn = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
bindService(Intent(this, VpnStateService::class.java), connection, Context.BIND_AUTO_CREATE)
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
val report: ReportViewModel = viewModel()
|
||||||
|
/* without the panel API in this build the report goes by email, as before */
|
||||||
|
LogScreen(onBack = { finish() }, onSend = { if (ReportViewModel.apiReady(this)) report.open() else sendLog("") }, messages = messages)
|
||||||
|
ReportSheet(report, profile = ::activeProfile, onMail = { comment -> report.close(); sendLog(comment) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy()
|
||||||
|
{
|
||||||
|
unbindService(connection)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activeProfile(): String
|
||||||
|
{
|
||||||
|
val profile = vpn?.profile ?: return ""
|
||||||
|
return listOfNotNull(profile.name, profile.gateway).filter { it.isNotBlank() }.distinct().joinToString(" · ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendLog(comment: String)
|
||||||
|
{
|
||||||
|
/* the daemon log plus the lifecycle log; the latter survives process
|
||||||
|
* deaths and documents watchdog/reconnect decisions */
|
||||||
|
val files = listOf(
|
||||||
|
File(filesDir, CharonVpnService.LOG_FILE),
|
||||||
|
DiagLog.file(this),
|
||||||
|
).filter { it.exists() && it.length() > 0L }
|
||||||
|
if (files.isEmpty())
|
||||||
|
{
|
||||||
|
messages.tryEmit(R.string.empty_log)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var version = ""
|
||||||
|
try
|
||||||
|
{
|
||||||
|
version = packageManager.getPackageInfo(packageName, 0).versionName ?: ""
|
||||||
|
}
|
||||||
|
catch (_: PackageManager.NameNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
val uris = ArrayList(files.mapNotNull { file -> LogContentProvider.createContentUri(file) })
|
||||||
|
val intent = Intent(if (uris.size > 1) Intent.ACTION_SEND_MULTIPLE else Intent.ACTION_SEND)
|
||||||
|
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
|
||||||
|
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
|
||||||
|
intent.type = "text/plain"
|
||||||
|
if (comment.isNotBlank())
|
||||||
|
{
|
||||||
|
intent.putExtra(Intent.EXTRA_TEXT, comment)
|
||||||
|
}
|
||||||
|
if (uris.size > 1)
|
||||||
|
{
|
||||||
|
intent.putExtra(Intent.EXTRA_STREAM, uris)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
intent.putExtra(Intent.EXTRA_STREAM, uris.first())
|
||||||
|
}
|
||||||
|
startActivity(Intent.createChooser(intent, getString(R.string.send_log)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogViewModel : ViewModel()
|
||||||
|
{
|
||||||
|
private val _lines = MutableStateFlow<List<String>>(emptyList())
|
||||||
|
val lines: StateFlow<List<String>> = _lines
|
||||||
|
|
||||||
|
/* tail only while the screen is shown (set by LogScreen) */
|
||||||
|
private val active = MutableStateFlow(false)
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
fun setActive(value: Boolean)
|
||||||
|
{
|
||||||
|
active.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun start(file: File)
|
||||||
|
{
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
val buffer = ArrayList<String>()
|
||||||
|
var reader: BufferedReader? = null
|
||||||
|
var lastLength = 0L
|
||||||
|
suspend fun flush()
|
||||||
|
{
|
||||||
|
if (buffer.isEmpty())
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val batch = buffer.toList()
|
||||||
|
buffer.clear()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
val merged = _lines.value + batch
|
||||||
|
/* bounded: a long session must not grow the list forever */
|
||||||
|
_lines.value = if (merged.size > MAX_LINES) merged.takeLast(MAX_LINES) else merged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (isActive)
|
||||||
|
{
|
||||||
|
if (!active.value)
|
||||||
|
{
|
||||||
|
active.first { it }
|
||||||
|
}
|
||||||
|
if (reader == null)
|
||||||
|
{ /* the log may not exist yet (no connection so far) */
|
||||||
|
if (file.exists())
|
||||||
|
{
|
||||||
|
reader = BufferedReader(FileReader(file))
|
||||||
|
lastLength = 0L
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
delay(POLL_MS)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val line = reader.readLine()
|
||||||
|
if (line == null)
|
||||||
|
{
|
||||||
|
flush()
|
||||||
|
val length = if (file.exists()) file.length() else -1L
|
||||||
|
if (length < lastLength)
|
||||||
|
{ /* deleted or truncated (a new connection starts a fresh log):
|
||||||
|
* the reader would stay on the old file forever */
|
||||||
|
try { reader.close() } catch (_: Exception) {}
|
||||||
|
reader = null
|
||||||
|
withContext(Dispatchers.Main) { _lines.value = emptyList() }
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastLength = length
|
||||||
|
delay(POLL_MS)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
buffer.add(line)
|
||||||
|
if (buffer.size >= 50)
|
||||||
|
{
|
||||||
|
flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try { reader?.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object
|
||||||
|
{
|
||||||
|
const val MAX_LINES = 5000
|
||||||
|
const val POLL_MS = 800L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* F_Log: «Important» (default), «All», «Errors» */
|
||||||
|
private enum class LogLevelFilter(val labelRes: Int)
|
||||||
|
{
|
||||||
|
IMPORTANT(R.string.log_filter_important),
|
||||||
|
ALL(R.string.log_filter_everything),
|
||||||
|
ERROR(R.string.log_filter_error),
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.isErrorLine() = contains(" ERROR ") || contains("failed", ignoreCase = true) ||
|
||||||
|
contains("timeout", ignoreCase = true) || contains("giving up", ignoreCase = true)
|
||||||
|
|
||||||
|
private fun String.isWarnLine() = contains(" WARN ") || contains(" WARNING ") || contains("retransmit", ignoreCase = true)
|
||||||
|
|
||||||
|
private fun String.isGoodLine() = contains("established", ignoreCase = true) || contains("successful", ignoreCase = true) ||
|
||||||
|
contains("installed", ignoreCase = true)
|
||||||
|
|
||||||
|
/* the milestones of a connection: what «Important» keeps besides problems */
|
||||||
|
private val MILESTONES = listOf("initiating", "IKE_SA", "CHILD_SA", "authentication", "established", "deleting", "closing", "reauth", "network")
|
||||||
|
|
||||||
|
private fun String.isImportantLine() = isErrorLine() || isWarnLine() || MILESTONES.any { contains(it, ignoreCase = true) }
|
||||||
|
|
||||||
|
/* «Oct 12 10:21:49 06[IKE] message» → «10:21:49», «message» */
|
||||||
|
private val CHARON_LINE = Regex("""^\w{3}\s+\d+\s+(\d{2}:\d{2}:\d{2})\s+\d+\[\w+]\s?(.*)$""")
|
||||||
|
|
||||||
|
private fun splitLine(line: String): Pair<String, String>
|
||||||
|
{
|
||||||
|
val m = CHARON_LINE.find(line) ?: return "" to line
|
||||||
|
return m.groupValues[1] to m.groupValues[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LogScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSend: () -> Unit,
|
||||||
|
messages: kotlinx.coroutines.flow.Flow<Int>,
|
||||||
|
viewModel: LogViewModel = viewModel(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val allLines by viewModel.lines.collectAsStateWithLifecycle()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
||||||
|
var filter by rememberSaveable { mutableStateOf(LogLevelFilter.IMPORTANT) }
|
||||||
|
val lines = remember(allLines, filter) {
|
||||||
|
when (filter)
|
||||||
|
{
|
||||||
|
LogLevelFilter.IMPORTANT -> allLines.filter { it.isImportantLine() }
|
||||||
|
LogLevelFilter.ALL -> allLines
|
||||||
|
LogLevelFilter.ERROR -> allLines.filter { it.isErrorLine() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.start(File(context.filesDir, CharonVpnService.LOG_FILE))
|
||||||
|
}
|
||||||
|
LaunchedEffect(lifecycle) {
|
||||||
|
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
viewModel.setActive(true)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
awaitCancellation()
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
viewModel.setActive(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(messages) {
|
||||||
|
messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
|
||||||
|
}
|
||||||
|
/* follow the tail only while the user is at the bottom (or switched the
|
||||||
|
* filter): scrolling up to read must not be undone by every new batch */
|
||||||
|
var followedSize by remember { mutableStateOf(0) }
|
||||||
|
var followedFilter by remember { mutableStateOf(filter) }
|
||||||
|
LaunchedEffect(lines.size, filter) {
|
||||||
|
if (lines.isNotEmpty())
|
||||||
|
{
|
||||||
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||||
|
if (filter != followedFilter || followedSize == 0 || lastVisible >= followedSize - 2 ||
|
||||||
|
lines.size < followedSize)
|
||||||
|
{
|
||||||
|
listState.scrollToItem(lines.lastIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
followedSize = lines.size
|
||||||
|
followedFilter = filter
|
||||||
|
}
|
||||||
|
val copied = stringResource(R.string.log_copied)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
SecondaryScaffold(
|
||||||
|
title = stringResource(R.string.log_title),
|
||||||
|
onBack = onBack,
|
||||||
|
actions = {
|
||||||
|
/* 44dp tonal circle of the mockup header */
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile)
|
||||||
|
.clickable(role = Role.Button) {
|
||||||
|
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("log", lines.joinToString("\n")))
|
||||||
|
scope.launch { snackbarHostState.showSnackbar(copied) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.log_copy), tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
|
||||||
|
) { padding ->
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding).padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
LogLevelFilter.entries.forEach { level ->
|
||||||
|
ShxChip(stringResource(level.labelRes), filter == level, { filter = level })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* the dark console: grey time, coloured message */
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(Color(0xFF1C1B20)))
|
||||||
|
{
|
||||||
|
if (lines.isEmpty())
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.empty_log), style = LOG_STYLE, color = Color(0xFF79767F),
|
||||||
|
modifier = Modifier.padding(14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(14.dp),
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
items(lines.size) { index ->
|
||||||
|
val line = lines[index]
|
||||||
|
val (time, message) = splitLine(line)
|
||||||
|
val color = when
|
||||||
|
{
|
||||||
|
line.isErrorLine() || line.isWarnLine() -> Color(0xFFFFB869)
|
||||||
|
line.isGoodLine() -> Color(0xFF7CE3B0)
|
||||||
|
else -> Color(0xFFE5E1E9)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
if (time.isNotEmpty())
|
||||||
|
{
|
||||||
|
Text(time, style = LOG_STYLE, color = Color(0xFF79767F))
|
||||||
|
}
|
||||||
|
Text(message, style = LOG_STYLE, color = color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.log_report_note))
|
||||||
|
ShxButton(stringResource(R.string.log_send_report), onSend, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val LOG_STYLE = TextStyle(fontFamily = FontFamily.Monospace, fontWeight = FontWeight.Medium, fontSize = 12.sp, lineHeight = 17.sp)
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2012-2018 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.FileObserver;
|
|
||||||
import android.os.Handler;
|
|
||||||
import android.os.Looper;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.ArrayAdapter;
|
|
||||||
import android.widget.ListView;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
import org.strongswan.android.logic.CharonVpnService;
|
|
||||||
import org.strongswan.android.utils.Utils;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.File;
|
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
import java.io.FileReader;
|
|
||||||
import java.io.StringReader;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.RequiresApi;
|
|
||||||
import androidx.fragment.app.Fragment;
|
|
||||||
|
|
||||||
public class LogFragment extends Fragment
|
|
||||||
{
|
|
||||||
private static String SCROLL_POSITION = "SCROLL_POSITION";
|
|
||||||
private String mLogFilePath;
|
|
||||||
private Handler mLogHandler;
|
|
||||||
private ListView mLog;
|
|
||||||
private LogAdapter mLogAdapter;
|
|
||||||
private FileObserver mDirectoryObserver;
|
|
||||||
private int mScrollPosition;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
|
|
||||||
mLogFilePath = getActivity().getFilesDir() + File.separator + CharonVpnService.LOG_FILE;
|
|
||||||
|
|
||||||
mLogHandler = new Handler(Looper.getMainLooper());
|
|
||||||
|
|
||||||
File logdir = getActivity().getFilesDir();
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
|
||||||
{
|
|
||||||
mDirectoryObserver = new LogDirectoryObserver(logdir);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mDirectoryObserver = new LogDirectoryObserver(logdir.getAbsolutePath());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
View view = inflater.inflate(R.layout.log_fragment, null);
|
|
||||||
|
|
||||||
mLogAdapter = new LogAdapter(getActivity());
|
|
||||||
mLog = view.findViewById(R.id.log);
|
|
||||||
mLog.setAdapter(mLogAdapter);
|
|
||||||
|
|
||||||
Utils.applyWindowInsetsAsPaddingForLists(mLog);
|
|
||||||
|
|
||||||
mScrollPosition = -1;
|
|
||||||
if (savedInstanceState != null)
|
|
||||||
{
|
|
||||||
mScrollPosition = savedInstanceState.getInt(SCROLL_POSITION, mScrollPosition);
|
|
||||||
}
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSaveInstanceState(Bundle outState)
|
|
||||||
{
|
|
||||||
super.onSaveInstanceState(outState);
|
|
||||||
|
|
||||||
if (mLog.getLastVisiblePosition() == (mLogAdapter.getCount() - 1))
|
|
||||||
{
|
|
||||||
outState.putInt(SCROLL_POSITION, -1);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
outState.putInt(SCROLL_POSITION, mLog.getFirstVisiblePosition());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStart()
|
|
||||||
{
|
|
||||||
super.onStart();
|
|
||||||
mLogAdapter.restart();
|
|
||||||
mDirectoryObserver.startWatching();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStop()
|
|
||||||
{
|
|
||||||
super.onStop();
|
|
||||||
mDirectoryObserver.stopWatching();
|
|
||||||
mLogAdapter.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
private class LogAdapter extends ArrayAdapter<String> implements Runnable
|
|
||||||
{
|
|
||||||
private BufferedReader mReader;
|
|
||||||
private Thread mThread;
|
|
||||||
private volatile boolean mRunning;
|
|
||||||
|
|
||||||
public LogAdapter(@NonNull Context context)
|
|
||||||
{
|
|
||||||
super(context, R.layout.log_list_item, R.id.log_line);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void restart()
|
|
||||||
{
|
|
||||||
if (mRunning)
|
|
||||||
{
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
clear();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
mReader = new BufferedReader(new FileReader(mLogFilePath));
|
|
||||||
}
|
|
||||||
catch (FileNotFoundException e)
|
|
||||||
{
|
|
||||||
mReader = new BufferedReader(new StringReader(""));
|
|
||||||
}
|
|
||||||
mRunning = true;
|
|
||||||
mThread = new Thread(this);
|
|
||||||
mThread.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void stop()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
mRunning = false;
|
|
||||||
mThread.interrupt();
|
|
||||||
mThread.join();
|
|
||||||
}
|
|
||||||
catch (InterruptedException e)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void logLines(final ArrayList<String> lines)
|
|
||||||
{
|
|
||||||
mLogHandler.post(() -> {
|
|
||||||
boolean scroll = getCount() == 0;
|
|
||||||
setNotifyOnChange(false);
|
|
||||||
for (String line : lines)
|
|
||||||
{
|
|
||||||
if (getResources().getConfiguration().screenWidthDp < 600)
|
|
||||||
{ /* strip off prefix (month=3, day=2, time=8, thread=2, spaces=3) */
|
|
||||||
line = line.length() > 18 ? line.substring(18) : line;
|
|
||||||
}
|
|
||||||
add(line);
|
|
||||||
}
|
|
||||||
notifyDataSetChanged();
|
|
||||||
if (scroll)
|
|
||||||
{ /* scroll to the bottom or saved position after adding the first batch */
|
|
||||||
mLogHandler.post(() -> mLog.setSelection(mScrollPosition == -1 ? getCount() - 1 : mScrollPosition));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run()
|
|
||||||
{
|
|
||||||
ArrayList<String> lines = null;
|
|
||||||
|
|
||||||
while (mRunning)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{ /* this works as long as the file is not truncated */
|
|
||||||
String line = mReader.readLine();
|
|
||||||
if (line == null)
|
|
||||||
{
|
|
||||||
if (lines != null)
|
|
||||||
{
|
|
||||||
logLines(lines);
|
|
||||||
lines = null;
|
|
||||||
}
|
|
||||||
/* wait until there is more to log */
|
|
||||||
Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
if (lines == null)
|
|
||||||
{
|
|
||||||
lines = new ArrayList<>();
|
|
||||||
}
|
|
||||||
lines.add(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (lines != null)
|
|
||||||
{
|
|
||||||
logLines(lines);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FileObserver that checks for changes regarding the log file. Since charon
|
|
||||||
* truncates it (for which there is no explicit event) we check for any modification
|
|
||||||
* to the file, keep track of the file size and reopen it if it got smaller.
|
|
||||||
*/
|
|
||||||
private class LogDirectoryObserver extends FileObserver
|
|
||||||
{
|
|
||||||
private static final int mMask = FileObserver.CREATE | FileObserver.MODIFY | FileObserver.DELETE;
|
|
||||||
private final File mFile = new File(mLogFilePath);
|
|
||||||
private long mSize = mFile.length();
|
|
||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
|
||||||
public LogDirectoryObserver(String path)
|
|
||||||
{
|
|
||||||
super(path, mMask);
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
|
||||||
public LogDirectoryObserver(File path)
|
|
||||||
{
|
|
||||||
super(path, mMask);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onEvent(int event, String path)
|
|
||||||
{
|
|
||||||
if (path == null || !path.equals(CharonVpnService.LOG_FILE))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
switch (event)
|
|
||||||
{ /* even though we only subscribed for these we check them,
|
|
||||||
* as strange events are sometimes received */
|
|
||||||
case FileObserver.CREATE:
|
|
||||||
case FileObserver.DELETE:
|
|
||||||
restartLogReader();
|
|
||||||
break;
|
|
||||||
case FileObserver.MODIFY:
|
|
||||||
/* if the size got smaller reopen the log file, as it was probably truncated */
|
|
||||||
long size = mFile.length();
|
|
||||||
if (size < mSize)
|
|
||||||
{
|
|
||||||
restartLogReader();
|
|
||||||
}
|
|
||||||
mSize = size;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void restartLogReader()
|
|
||||||
{
|
|
||||||
/* we are called from a separate thread, so we use the handler */
|
|
||||||
mLogHandler.post(new Runnable() {
|
|
||||||
@Override
|
|
||||||
public void run()
|
|
||||||
{
|
|
||||||
mLogAdapter.restart();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+96
-179
@@ -1,7 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2012-2018 Tobias Brunner
|
* Copyright (C) 2012-2018 Tobias Brunner
|
||||||
* Copyright (C) 2012 Giuliano Grassi
|
|
||||||
* Copyright (C) 2012 Ralf Sager
|
|
||||||
*
|
*
|
||||||
* Copyright (C) secunet Security Networks AG
|
* Copyright (C) secunet Security Networks AG
|
||||||
*
|
*
|
||||||
@@ -18,217 +16,136 @@
|
|||||||
|
|
||||||
package org.strongswan.android.ui;
|
package org.strongswan.android.ui;
|
||||||
|
|
||||||
import android.app.Dialog;
|
|
||||||
import android.content.DialogInterface;
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.text.format.Formatter;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuItem;
|
|
||||||
import android.widget.Toast;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
import org.strongswan.android.logic.updates.UpdateManager;
|
||||||
import org.strongswan.android.data.ManagedConfiguration;
|
import org.strongswan.android.ui.compose.ShxAppKt;
|
||||||
import org.strongswan.android.data.ManagedConfigurationService;
|
import org.strongswan.android.ui.compose.ShxNavigation;
|
||||||
import org.strongswan.android.data.VpnProfile;
|
import org.strongswan.android.ui.compose.ShxTab;
|
||||||
import org.strongswan.android.logic.StrongSwanApplication;
|
import android.service.quicksettings.TileService;
|
||||||
import org.strongswan.android.logic.TrustedCertificateManager;
|
|
||||||
import org.strongswan.android.ui.VpnProfileListFragment.OnVpnProfileSelectedListener;
|
|
||||||
import org.strongswan.android.utils.Utils;
|
|
||||||
|
|
||||||
import java.io.File;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import androidx.appcompat.app.ActionBar;
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.appcompat.app.AppCompatDialogFragment;
|
import androidx.core.splashscreen.SplashScreen;
|
||||||
import androidx.core.view.WindowCompat;
|
|
||||||
import androidx.fragment.app.Fragment;
|
|
||||||
import androidx.fragment.app.FragmentManager;
|
|
||||||
import androidx.fragment.app.FragmentTransaction;
|
|
||||||
|
|
||||||
public class MainActivity extends AppCompatActivity implements OnVpnProfileSelectedListener
|
public class MainActivity extends AppCompatActivity
|
||||||
{
|
{
|
||||||
public static final String CONTACT_EMAIL = "android@strongswan.org";
|
public static final String CONTACT_EMAIL = "support@shx.one";
|
||||||
public static final String EXTRA_CRL_LIST = "org.strongswan.android.CRL_LIST";
|
|
||||||
|
/**
|
||||||
|
* Set as an intent extra by the "update ready" notification; starts the
|
||||||
|
* installation of the already downloaded update
|
||||||
|
*/
|
||||||
|
public static final String EXTRA_INSTALL_UPDATE = "org.strongswan.android.extra.INSTALL_UPDATE";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set as an intent extra to open the Routing tab (e.g. from the profile form)
|
||||||
|
*/
|
||||||
|
public static final String EXTRA_OPEN_ROUTING = "org.strongswan.android.extra.OPEN_ROUTING";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set as an intent extra to open the «Add server» sheet (end of the first-run screens)
|
||||||
|
*/
|
||||||
|
public static final String EXTRA_ADD_SERVER = "org.strongswan.android.extra.ADD_SERVER";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set as an intent extra to open the Servers tab (the «Change» action of the notification)
|
||||||
|
*/
|
||||||
|
public static final String EXTRA_OPEN_SERVERS = "org.strongswan.android.extra.OPEN_SERVERS";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set as an intent extra to connect the fastest server (the «Fastest» button of the widget)
|
||||||
|
*/
|
||||||
|
public static final String EXTRA_CONNECT_FASTEST = "org.strongswan.android.extra.CONNECT_FASTEST";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set as an intent extra to show the Updates page (the update notification)
|
||||||
|
*/
|
||||||
|
public static final String EXTRA_OPEN_UPDATES = "org.strongswan.android.extra.OPEN_UPDATES";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use "bring your own device" (BYOD) features
|
* Use "bring your own device" (BYOD) features
|
||||||
*/
|
*/
|
||||||
public static final boolean USE_BYOD = true;
|
public static final boolean USE_BYOD = true;
|
||||||
|
|
||||||
private static final String DIALOG_TAG = "Dialog";
|
|
||||||
|
|
||||||
private ManagedConfigurationService mManagedConfigurationService;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreate(Bundle savedInstanceState)
|
public void onCreate(Bundle savedInstanceState)
|
||||||
{
|
{
|
||||||
|
/* must run before super.onCreate() so the system splash hands over cleanly */
|
||||||
|
SplashScreen.Companion.installSplashScreen(this);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
setContentView(R.layout.main);
|
handleInstallUpdate(getIntent());
|
||||||
WindowCompat.enableEdgeToEdge(getWindow());
|
handleOpenRouting(getIntent());
|
||||||
Utils.applyWindowInsetsAsMarginsForLists(findViewById(R.id.layout));
|
handleAddServer(getIntent());
|
||||||
|
ShxAppKt.installShxUi(this);
|
||||||
ActionBar bar = getSupportActionBar();
|
if (savedInstanceState == null && OnboardingActivity.isDue(this))
|
||||||
bar.setDisplayShowHomeEnabled(true);
|
{ /* a fresh install: what the app does and the permissions first */
|
||||||
bar.setDisplayShowTitleEnabled(false);
|
startActivity(new Intent(this, OnboardingActivity.class));
|
||||||
bar.setIcon(R.mipmap.ic_app);
|
|
||||||
|
|
||||||
/* load CA certificates in a background thread */
|
|
||||||
((StrongSwanApplication)getApplication()).getExecutor().execute(() -> {
|
|
||||||
TrustedCertificateManager.getInstance().load();
|
|
||||||
});
|
|
||||||
|
|
||||||
mManagedConfigurationService = StrongSwanApplication.getInstance().getManagedConfigurationService();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onCreateOptionsMenu(Menu menu)
|
|
||||||
{
|
|
||||||
getMenuInflater().inflate(R.menu.main, menu);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onPrepareOptionsMenu(Menu menu)
|
|
||||||
{
|
|
||||||
final MenuItem importProfile = menu.findItem(R.id.menu_import_profile);
|
|
||||||
if (importProfile != null)
|
|
||||||
{
|
|
||||||
final ManagedConfiguration managedConfiguration = mManagedConfigurationService.getManagedConfiguration();
|
|
||||||
importProfile.setVisible(managedConfiguration.isAllowProfileImport());
|
|
||||||
importProfile.setEnabled(managedConfiguration.isAllowProfileImport());
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onOptionsItemSelected(MenuItem item)
|
|
||||||
{
|
|
||||||
switch (item.getItemId())
|
|
||||||
{
|
|
||||||
case R.id.menu_import_profile:
|
|
||||||
Intent intent = new Intent(this, VpnProfileImportActivity.class);
|
|
||||||
startActivity(intent);
|
|
||||||
return true;
|
|
||||||
case R.id.menu_manage_certs:
|
|
||||||
Intent certIntent = new Intent(this, TrustedCertificatesActivity.class);
|
|
||||||
startActivity(certIntent);
|
|
||||||
return true;
|
|
||||||
case R.id.menu_crl_cache:
|
|
||||||
clearCRLs();
|
|
||||||
return true;
|
|
||||||
case R.id.menu_show_log:
|
|
||||||
Intent logIntent = new Intent(this, LogActivity.class);
|
|
||||||
startActivity(logIntent);
|
|
||||||
return true;
|
|
||||||
case R.id.menu_settings:
|
|
||||||
Intent settingsIntent = new Intent(this, SettingsActivity.class);
|
|
||||||
startActivity(settingsIntent);
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return super.onOptionsItemSelected(item);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onVpnProfileSelected(VpnProfile profile)
|
protected void onResume()
|
||||||
{
|
{
|
||||||
Intent intent = new Intent(this, VpnProfileControlActivity.class);
|
super.onResume();
|
||||||
intent.setAction(VpnProfileControlActivity.START_PROFILE);
|
/* catch up on due update checks right away (the periodic check
|
||||||
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profile.getUUID().toString());
|
* otherwise waits for the WorkManager tick or the next relaunch) */
|
||||||
startActivity(intent);
|
UpdateManager.getInstance(this).checkIfDue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Ask the user whether to clear the CRL cache.
|
|
||||||
*/
|
|
||||||
private void clearCRLs()
|
|
||||||
{
|
|
||||||
final String FILE_PREFIX = "crl-";
|
|
||||||
ArrayList<String> list = new ArrayList<>();
|
|
||||||
|
|
||||||
for (String file : fileList())
|
|
||||||
{
|
|
||||||
if (file.startsWith(FILE_PREFIX))
|
|
||||||
{
|
|
||||||
list.add(file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (list.size() == 0)
|
|
||||||
{
|
|
||||||
Toast.makeText(this, R.string.clear_crl_cache_msg_none, Toast.LENGTH_SHORT).show();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
removeFragmentByTag(DIALOG_TAG);
|
|
||||||
|
|
||||||
Bundle args = new Bundle();
|
|
||||||
args.putStringArrayList(EXTRA_CRL_LIST, list);
|
|
||||||
|
|
||||||
CRLCacheDialog dialog = new CRLCacheDialog();
|
|
||||||
dialog.setArguments(args);
|
|
||||||
dialog.show(this.getSupportFragmentManager(), DIALOG_TAG);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dismiss dialog if shown
|
|
||||||
*/
|
|
||||||
public void removeFragmentByTag(String tag)
|
|
||||||
{
|
|
||||||
FragmentManager fm = getSupportFragmentManager();
|
|
||||||
Fragment login = fm.findFragmentByTag(tag);
|
|
||||||
if (login != null)
|
|
||||||
{
|
|
||||||
FragmentTransaction ft = fm.beginTransaction();
|
|
||||||
ft.remove(login);
|
|
||||||
ft.commit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Confirmation dialog to clear CRL cache
|
|
||||||
*/
|
|
||||||
public static class CRLCacheDialog extends AppCompatDialogFragment
|
|
||||||
{
|
|
||||||
@Override
|
@Override
|
||||||
public Dialog onCreateDialog(Bundle savedInstanceState)
|
protected void onNewIntent(Intent intent)
|
||||||
{
|
{
|
||||||
final List<String> list = getArguments().getStringArrayList(EXTRA_CRL_LIST);
|
handleAddServer(intent);
|
||||||
String size;
|
super.onNewIntent(intent);
|
||||||
long s = 0;
|
handleInstallUpdate(intent);
|
||||||
|
handleOpenRouting(intent);
|
||||||
|
}
|
||||||
|
|
||||||
for (String file : list)
|
private void handleOpenRouting(Intent intent)
|
||||||
{
|
{
|
||||||
File crl = getActivity().getFileStreamPath(file);
|
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_ROUTING, false))
|
||||||
s += crl.length();
|
{
|
||||||
|
intent.removeExtra(EXTRA_OPEN_ROUTING);
|
||||||
|
ShxNavigation.INSTANCE.request(ShxTab.ROUTING);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
size = Formatter.formatFileSize(getActivity(), s);
|
|
||||||
|
|
||||||
AlertDialog.Builder builder = new AlertDialog.Builder(getActivity())
|
private void handleInstallUpdate(Intent intent)
|
||||||
.setTitle(R.string.clear_crl_cache_title)
|
|
||||||
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener()
|
|
||||||
{
|
{
|
||||||
@Override
|
if (intent != null && intent.getBooleanExtra(EXTRA_INSTALL_UPDATE, false))
|
||||||
public void onClick(DialogInterface dialog, int which)
|
|
||||||
{
|
{
|
||||||
dismiss();
|
intent.removeExtra(EXTRA_INSTALL_UPDATE);
|
||||||
}
|
UpdateManager.getInstance(this).downloadAndInstall();
|
||||||
})
|
|
||||||
.setPositiveButton(R.string.clear, new DialogInterface.OnClickListener()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public void onClick(DialogInterface dialog, int whichButton)
|
|
||||||
{
|
|
||||||
for (String file : list)
|
|
||||||
{
|
|
||||||
getActivity().deleteFile(file);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
builder.setMessage(getActivity().getResources().getQuantityString(R.plurals.clear_crl_cache_msg, list.size(), list.size(), size));
|
private void handleAddServer(Intent intent)
|
||||||
return builder.create();
|
{
|
||||||
|
if (intent != null && intent.getBooleanExtra(EXTRA_ADD_SERVER, false))
|
||||||
|
{
|
||||||
|
intent.removeExtra(EXTRA_ADD_SERVER);
|
||||||
|
ShxNavigation.INSTANCE.requestAddServer();
|
||||||
|
}
|
||||||
|
if (intent != null && TileService.ACTION_QS_TILE_PREFERENCES.equals(intent.getAction()))
|
||||||
|
{
|
||||||
|
intent.setAction(null);
|
||||||
|
ShxNavigation.INSTANCE.request(ShxTab.PROFILES);
|
||||||
|
}
|
||||||
|
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_SERVERS, false))
|
||||||
|
{
|
||||||
|
intent.removeExtra(EXTRA_OPEN_SERVERS);
|
||||||
|
ShxNavigation.INSTANCE.request(ShxTab.PROFILES);
|
||||||
|
}
|
||||||
|
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_UPDATES, false))
|
||||||
|
{
|
||||||
|
intent.removeExtra(EXTRA_OPEN_UPDATES);
|
||||||
|
ShxNavigation.INSTANCE.requestUpdatesPage();
|
||||||
|
}
|
||||||
|
if (intent != null && intent.getBooleanExtra(EXTRA_CONNECT_FASTEST, false))
|
||||||
|
{
|
||||||
|
intent.removeExtra(EXTRA_CONNECT_FASTEST);
|
||||||
|
ShxNavigation.INSTANCE.requestFastest();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+232
@@ -0,0 +1,232 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.offset
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.PermissionsList
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First run (F_Onboarding → F_Permissions → add a server): what the app
|
||||||
|
* does, the permissions it needs, then Home with the «Add server» sheet.
|
||||||
|
*/
|
||||||
|
class OnboardingActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
var step by rememberSaveable { mutableIntStateOf(0) }
|
||||||
|
BackHandler(enabled = step > 0) { step-- }
|
||||||
|
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background).windowInsetsPadding(WindowInsets.safeDrawing))
|
||||||
|
{
|
||||||
|
when (step)
|
||||||
|
{
|
||||||
|
0 -> IntroStep(onStart = { step = 1 }, onSkip = ::finishToHome, onScan = { finishToHome(addServer = true) })
|
||||||
|
else -> PermissionsStep(onContinue = { finishToHome(addServer = true) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finishToHome(addServer: Boolean = false)
|
||||||
|
{
|
||||||
|
markDone(this)
|
||||||
|
startActivity(
|
||||||
|
Intent(this, MainActivity::class.java)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
.putExtra(MainActivity.EXTRA_ADD_SERVER, addServer)
|
||||||
|
)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
private const val PREF_DONE = "pref_onboarding_done"
|
||||||
|
|
||||||
|
/** Whether the first-run screens are still due: a fresh install that has not seen them. */
|
||||||
|
@JvmStatic
|
||||||
|
fun isDue(context: Context): Boolean
|
||||||
|
{
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
if (prefs.getBoolean(PREF_DONE, false))
|
||||||
|
{
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
/* an update of an existing installation never shows them */
|
||||||
|
val info = runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull()
|
||||||
|
if (info != null && info.firstInstallTime != info.lastUpdateTime)
|
||||||
|
{
|
||||||
|
markDone(context)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun markDone(context: Context) = PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(PREF_DONE, true).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 1: the two blobs with the route bubbles, the promise, Start / Scan QR. */
|
||||||
|
@Composable
|
||||||
|
private fun IntroStep(onStart: () -> Unit, onSkip: () -> Unit, onScan: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 24.dp, end = 24.dp, top = 24.dp, bottom = 28.dp))
|
||||||
|
{
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.onb_skip), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), color = c.textSub,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onSkip).padding(horizontal = 8.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(290.dp).padding(top = 8.dp), contentAlignment = Alignment.Center)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
Modifier.size(260.dp).clip(RoundedCornerShape(topStartPercent = 42, topEndPercent = 58, bottomEndPercent = 50, bottomStartPercent = 50))
|
||||||
|
.background(c.accentContainer),
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(180.dp).clip(RoundedCornerShape(topStartPercent = 55, topEndPercent = 45, bottomEndPercent = 48, bottomStartPercent = 52))
|
||||||
|
.background(c.accent),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(84.dp))
|
||||||
|
}
|
||||||
|
Bubble(stringResource(R.string.onb_bubble_direct), Modifier.align(Alignment.TopStart).offset(x = 10.dp, y = 46.dp))
|
||||||
|
Bubble(stringResource(R.string.onb_bubble_vpn), Modifier.align(Alignment.BottomEnd).offset(x = (-2).dp, y = (-50).dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.padding(top = 20.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.onb_step, 1, 3).uppercase(), style = ShxType.section.copy(letterSpacing = androidx.compose.ui.unit.TextUnit(0.06f, androidx.compose.ui.unit.TextUnitType.Em)), color = c.accentText)
|
||||||
|
Text(stringResource(R.string.onb_title), style = ShxType.screenTitle.copy(fontSize = 30.sp, lineHeight = 34.sp), color = c.text)
|
||||||
|
Text(stringResource(R.string.onb_text), style = ShxType.note.copy(fontSize = 16.sp, lineHeight = 24.sp), color = c.textSoft)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(32.dp))
|
||||||
|
Dots(0)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.onb_start), onStart, modifier = Modifier.fillMaxWidth())
|
||||||
|
ShxButton(stringResource(R.string.add_qr), onScan, style = ShxButtonStyle.SOFT, modifier = Modifier.fillMaxWidth(), icon = ShxIcons.Scan)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step 2: the permissions list with Continue. */
|
||||||
|
@Composable
|
||||||
|
private fun PermissionsStep(onContinue: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 28.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(start = 6.dp, end = 6.dp, top = 8.dp, bottom = 18.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.onb_step, 2, 3).uppercase(), style = ShxType.section, color = c.accentText)
|
||||||
|
Text(stringResource(R.string.perm_title), style = ShxType.screenTitle.copy(fontSize = 28.sp, lineHeight = 32.sp), color = c.text)
|
||||||
|
Text(stringResource(R.string.perm_intro), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp), color = c.textSoft)
|
||||||
|
}
|
||||||
|
PermissionsList()
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
ShxButton(stringResource(R.string.onb_continue), onContinue, modifier = Modifier.fillMaxWidth())
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.perm_later), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Bubble(text: String, modifier: Modifier)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold),
|
||||||
|
color = c.onAccentContainer,
|
||||||
|
modifier = modifier.shadow(6.dp, RoundedCornerShape(14.dp), ambientColor = c.onAccentContainer.copy(alpha = 0.2f), spotColor = c.onAccentContainer.copy(alpha = 0.2f))
|
||||||
|
.clip(RoundedCornerShape(14.dp)).background(c.card).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Dots(current: Int)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally))
|
||||||
|
{
|
||||||
|
repeat(3) { i ->
|
||||||
|
Box(Modifier.width(if (i == current) 28.dp else 8.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(if (i == current) c.accent else c.outline))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-141
@@ -1,141 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2013-2016 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.ArrayAdapter;
|
|
||||||
import android.widget.FrameLayout;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.fragment.app.ListFragment;
|
|
||||||
|
|
||||||
public class RemediationInstructionFragment extends ListFragment
|
|
||||||
{
|
|
||||||
public static final String ARG_REMEDIATION_INSTRUCTION = "instruction";
|
|
||||||
private RemediationInstruction mInstruction = null;
|
|
||||||
private TextView mTitle;
|
|
||||||
private TextView mDescription;
|
|
||||||
private TextView mHeader;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
/* while the documentation recommends to include "@android:layout/list_content" to retain
|
|
||||||
* the default functionality, this does not actually work with the ListFragment provided by
|
|
||||||
* the support library as it builds the view manually and uses different IDs */
|
|
||||||
View layout = inflater.inflate(R.layout.remediation_instruction, container, false);
|
|
||||||
FrameLayout list = (FrameLayout)layout.findViewById(R.id.list_container);
|
|
||||||
list.addView(super.onCreateView(inflater, list, savedInstanceState));
|
|
||||||
return layout;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
super.onViewCreated(view, savedInstanceState);
|
|
||||||
|
|
||||||
if (savedInstanceState != null)
|
|
||||||
{
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
|
||||||
{
|
|
||||||
mInstruction = getInstructionCompat(savedInstanceState);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mInstruction = savedInstanceState.getParcelable(ARG_REMEDIATION_INSTRUCTION, RemediationInstruction.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* show dividers only between list items */
|
|
||||||
getListView().setHeaderDividersEnabled(false);
|
|
||||||
getListView().setFooterDividersEnabled(false);
|
|
||||||
/* don't show loader while adapter is not set */
|
|
||||||
setListShown(true);
|
|
||||||
mTitle = (TextView)getView().findViewById(R.id.title);
|
|
||||||
mDescription = (TextView)getView().findViewById(R.id.description);
|
|
||||||
mHeader = (TextView)getView().findViewById(R.id.list_header);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSaveInstanceState(Bundle outState)
|
|
||||||
{
|
|
||||||
super.onSaveInstanceState(outState);
|
|
||||||
outState.putParcelable(ARG_REMEDIATION_INSTRUCTION, mInstruction);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStart()
|
|
||||||
{
|
|
||||||
super.onStart();
|
|
||||||
|
|
||||||
Bundle args = getArguments();
|
|
||||||
if (args != null)
|
|
||||||
{
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
|
||||||
{
|
|
||||||
mInstruction = getInstructionCompat(args);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mInstruction = args.getParcelable(ARG_REMEDIATION_INSTRUCTION, RemediationInstruction.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateView(mInstruction);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void updateView(RemediationInstruction instruction)
|
|
||||||
{
|
|
||||||
mInstruction = instruction;
|
|
||||||
if (mInstruction != null)
|
|
||||||
{
|
|
||||||
mTitle.setText(mInstruction.getTitle());
|
|
||||||
mDescription.setText(mInstruction.getDescription());
|
|
||||||
if (mInstruction.getHeader() != null)
|
|
||||||
{
|
|
||||||
mHeader.setText(mInstruction.getHeader());
|
|
||||||
setListAdapter(new ArrayAdapter<String>(getActivity(),
|
|
||||||
android.R.layout.simple_list_item_1, mInstruction.getItems()));
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mHeader.setText("");
|
|
||||||
setListAdapter(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mTitle.setText("");
|
|
||||||
mDescription.setText("");
|
|
||||||
mHeader.setText("");
|
|
||||||
setListAdapter(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
|
||||||
private static RemediationInstruction getInstructionCompat(Bundle bundle)
|
|
||||||
{
|
|
||||||
return bundle.getParcelable(ARG_REMEDIATION_INSTRUCTION);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-107
@@ -1,107 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2013 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.os.Bundle;
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
|
||||||
import androidx.core.view.WindowCompat;
|
|
||||||
|
|
||||||
import android.view.MenuItem;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
|
||||||
import org.strongswan.android.ui.RemediationInstructionsFragment.OnRemediationInstructionSelectedListener;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
public class RemediationInstructionsActivity extends AppCompatActivity implements OnRemediationInstructionSelectedListener
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
protected void onCreate(Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
setContentView(R.layout.remediation_instructions);
|
|
||||||
WindowCompat.enableEdgeToEdge(getWindow());
|
|
||||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
|
||||||
|
|
||||||
if (savedInstanceState != null)
|
|
||||||
{ /* only update if we're not restoring */
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
RemediationInstructionsFragment frag = (RemediationInstructionsFragment)getSupportFragmentManager().findFragmentById(R.id.remediation_instructions_fragment);
|
|
||||||
if (frag != null)
|
|
||||||
{ /* two-pane layout, update fragment */
|
|
||||||
Bundle extras = getIntent().getExtras();
|
|
||||||
ArrayList<RemediationInstruction> list = null;
|
|
||||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU)
|
|
||||||
{
|
|
||||||
list = RemediationInstructionsFragment.getInstructionsCompat(extras);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
list = extras.getParcelableArrayList(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS,
|
|
||||||
RemediationInstruction.class);
|
|
||||||
}
|
|
||||||
frag.updateView(list);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{ /* one-pane layout, create fragment */
|
|
||||||
frag = new RemediationInstructionsFragment();
|
|
||||||
frag.setArguments(getIntent().getExtras());
|
|
||||||
getSupportFragmentManager().beginTransaction().add(R.id.fragment_container, frag).commit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onOptionsItemSelected(MenuItem item)
|
|
||||||
{
|
|
||||||
switch (item.getItemId())
|
|
||||||
{
|
|
||||||
case android.R.id.home:
|
|
||||||
/* one-pane layout, pop possible fragment from stack, finish otherwise */
|
|
||||||
if (!getSupportFragmentManager().popBackStackImmediate())
|
|
||||||
{
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
getSupportActionBar().setTitle(getTitle());
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return super.onOptionsItemSelected(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onRemediationInstructionSelected(RemediationInstruction instruction)
|
|
||||||
{
|
|
||||||
RemediationInstructionFragment frag = (RemediationInstructionFragment)getSupportFragmentManager().findFragmentById(R.id.remediation_instruction_fragment);
|
|
||||||
|
|
||||||
if (frag != null)
|
|
||||||
{ /* two-pane layout, update directly */
|
|
||||||
frag.updateView(instruction);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{ /* one-pane layout, replace fragment */
|
|
||||||
frag = new RemediationInstructionFragment();
|
|
||||||
Bundle args = new Bundle();
|
|
||||||
args.putParcelable(RemediationInstructionFragment.ARG_REMEDIATION_INSTRUCTION, instruction);
|
|
||||||
frag.setArguments(args);
|
|
||||||
|
|
||||||
getSupportFragmentManager().beginTransaction().replace(R.id.fragment_container, frag).addToBackStack(null).commit();
|
|
||||||
getSupportActionBar().setTitle(instruction.getTitle());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2013 Tobias Brunner
|
||||||
|
*
|
||||||
|
* Copyright (C) secunet Security Networks AG
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInHorizontally
|
||||||
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
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.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.components.PredictiveBackScale
|
||||||
|
import org.strongswan.android.logic.imc.RemediationInstruction
|
||||||
|
import org.strongswan.android.ui.compose.components.ShxCard
|
||||||
|
import org.strongswan.android.ui.compose.components.ShxEmptyState
|
||||||
|
import org.strongswan.android.ui.compose.components.ShxSectionDivider
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the remediation instructions received from the server after a failed
|
||||||
|
* IMC/IMV compliance check. Single-pane Compose master-detail: the list is
|
||||||
|
* replaced by the selected instruction (with back navigation).
|
||||||
|
*/
|
||||||
|
class RemediationInstructionsActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
private var selected by mutableStateOf<RemediationInstruction?>(null)
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
Surface(Modifier.fillMaxSize(), color = LocalShxColors.current.background) {
|
||||||
|
RemediationUi(
|
||||||
|
instructions = loadInstructions(),
|
||||||
|
selected = selected,
|
||||||
|
onSelect = { selected = it },
|
||||||
|
onFinish = { finish() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadInstructions(): ArrayList<RemediationInstruction>
|
||||||
|
{
|
||||||
|
val extras = getIntent().getExtras() ?: return arrayListOf()
|
||||||
|
return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||||
|
{
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
extras.getParcelableArrayList(RemediationInstructionsExtra.KEY) ?: arrayListOf()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
extras.getParcelableArrayList(RemediationInstructionsExtra.KEY, RemediationInstruction::class.java)
|
||||||
|
?: arrayListOf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object RemediationInstructionsExtra
|
||||||
|
{
|
||||||
|
/* former RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS */
|
||||||
|
const val KEY = "instructions"
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun RemediationUi(
|
||||||
|
instructions: List<RemediationInstruction>,
|
||||||
|
selected: RemediationInstruction?,
|
||||||
|
onSelect: (RemediationInstruction?) -> Unit,
|
||||||
|
onFinish: () -> Unit,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
PredictiveBackScale(enabled = selected != null, onBack = { onSelect(null) }, modifier = Modifier.fillMaxSize())
|
||||||
|
{
|
||||||
|
Column(Modifier.fillMaxSize())
|
||||||
|
{
|
||||||
|
ShxPageHeader(
|
||||||
|
selected?.getTitle() ?: stringResource(R.string.remediation_instructions_title),
|
||||||
|
onBack = { if (selected != null) onSelect(null) else onFinish() },
|
||||||
|
)
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = selected,
|
||||||
|
transitionSpec = {
|
||||||
|
val forward = targetState != null
|
||||||
|
val direction = if (forward) 1 else -1
|
||||||
|
(slideInHorizontally(tween(240)) { it / 4 * direction } + fadeIn(tween(240))) togetherWith
|
||||||
|
(slideOutHorizontally(tween(180)) { -it / 4 * direction } + fadeOut(tween(160)))
|
||||||
|
},
|
||||||
|
label = "remediationPane",
|
||||||
|
) { instruction ->
|
||||||
|
if (instruction == null)
|
||||||
|
{
|
||||||
|
if (instructions.isEmpty())
|
||||||
|
{
|
||||||
|
ShxEmptyState(
|
||||||
|
icon = ShxIcons.Check,
|
||||||
|
message = stringResource(R.string.remediation_instructions_title),
|
||||||
|
modifier = Modifier.padding(ShxSpacing.lg),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
LazyColumn(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
itemsIndexed(instructions) { index, item ->
|
||||||
|
ShxCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = ShxSpacing.lg, vertical = 6.dp)
|
||||||
|
.animateItem(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onSelect(item) }
|
||||||
|
.padding(ShxSpacing.lg),
|
||||||
|
) {
|
||||||
|
Text(item.getTitle(), style = ShxType.cardTitle)
|
||||||
|
if (!item.getDescription().isNullOrEmpty())
|
||||||
|
{
|
||||||
|
Spacer(Modifier.height(ShxSpacing.xs))
|
||||||
|
Text(
|
||||||
|
item.getDescription(),
|
||||||
|
style = ShxType.note,
|
||||||
|
color = LocalShxColors.current.textSub,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = ShxSpacing.lg),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
instruction.getDescription(),
|
||||||
|
style = ShxType.note,
|
||||||
|
modifier = Modifier.padding(top = ShxSpacing.sm),
|
||||||
|
)
|
||||||
|
if (!instruction.getHeader().isNullOrEmpty())
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
instruction.getHeader(),
|
||||||
|
style = ShxType.section,
|
||||||
|
color = LocalShxColors.current.textSub,
|
||||||
|
modifier = Modifier.padding(top = 20.dp, bottom = ShxSpacing.sm),
|
||||||
|
)
|
||||||
|
ShxCard {
|
||||||
|
Column(Modifier.padding(vertical = ShxSpacing.xs)) {
|
||||||
|
instruction.getItems().forEachIndexed { index, item ->
|
||||||
|
Text(
|
||||||
|
item,
|
||||||
|
style = ShxType.body,
|
||||||
|
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
if (index < instruction.getItems().lastIndex)
|
||||||
|
{
|
||||||
|
ShxSectionDivider(indent = 16.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(ShxSpacing.xl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-147
@@ -1,147 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2013 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.view.View;
|
|
||||||
import android.widget.ListView;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
|
||||||
import org.strongswan.android.ui.adapter.RemediationInstructionAdapter;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.fragment.app.ListFragment;
|
|
||||||
|
|
||||||
public class RemediationInstructionsFragment extends ListFragment
|
|
||||||
{
|
|
||||||
public static final String EXTRA_REMEDIATION_INSTRUCTIONS = "instructions";
|
|
||||||
private static final String KEY_POSITION = "position";
|
|
||||||
private ArrayList<RemediationInstruction> mInstructions = null;
|
|
||||||
private OnRemediationInstructionSelectedListener mListener;
|
|
||||||
private RemediationInstructionAdapter mAdapter;
|
|
||||||
private int mCurrentPosition = -1;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The activity containing this fragment should implement this interface
|
|
||||||
*/
|
|
||||||
public interface OnRemediationInstructionSelectedListener
|
|
||||||
{
|
|
||||||
public void onRemediationInstructionSelected(RemediationInstruction instruction);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
super.onViewCreated(view, savedInstanceState);
|
|
||||||
|
|
||||||
if (savedInstanceState != null)
|
|
||||||
{
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
|
||||||
{
|
|
||||||
mInstructions = getInstructionsCompat(savedInstanceState);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mInstructions = savedInstanceState.getParcelableArrayList(EXTRA_REMEDIATION_INSTRUCTIONS, RemediationInstruction.class);
|
|
||||||
}
|
|
||||||
mCurrentPosition = savedInstanceState.getInt(KEY_POSITION);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSaveInstanceState(Bundle outState)
|
|
||||||
{
|
|
||||||
super.onSaveInstanceState(outState);
|
|
||||||
outState.putParcelableArrayList(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS, mInstructions);
|
|
||||||
outState.putInt(KEY_POSITION, mCurrentPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onAttach(Context context)
|
|
||||||
{
|
|
||||||
super.onAttach(context);
|
|
||||||
|
|
||||||
if (context instanceof OnRemediationInstructionSelectedListener)
|
|
||||||
{
|
|
||||||
mListener = (OnRemediationInstructionSelectedListener)context;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onStart()
|
|
||||||
{
|
|
||||||
super.onStart();
|
|
||||||
|
|
||||||
boolean two_pane = getParentFragmentManager().findFragmentById(R.id.remediation_instruction_fragment) != null;
|
|
||||||
if (two_pane)
|
|
||||||
{ /* two-pane layout, make list items selectable */
|
|
||||||
getListView().setChoiceMode(ListView.CHOICE_MODE_SINGLE);
|
|
||||||
}
|
|
||||||
|
|
||||||
Bundle args = getArguments();
|
|
||||||
if (mInstructions == null && args != null)
|
|
||||||
{
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
|
||||||
{
|
|
||||||
mInstructions = getInstructionsCompat(args);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mInstructions = args.getParcelableArrayList(EXTRA_REMEDIATION_INSTRUCTIONS, RemediationInstruction.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateView(mInstructions);
|
|
||||||
|
|
||||||
if (two_pane && mCurrentPosition == -1 && mInstructions.size() > 0)
|
|
||||||
{ /* two-pane layout, select first instruction */
|
|
||||||
mCurrentPosition = 0;
|
|
||||||
mListener.onRemediationInstructionSelected(mInstructions.get(0));
|
|
||||||
}
|
|
||||||
getListView().setItemChecked(mCurrentPosition, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onListItemClick(ListView l, View v, int position, long id)
|
|
||||||
{
|
|
||||||
mCurrentPosition = position;
|
|
||||||
mListener.onRemediationInstructionSelected(mInstructions.get(position));
|
|
||||||
getListView().setItemChecked(position, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void updateView(ArrayList<RemediationInstruction> instructions)
|
|
||||||
{
|
|
||||||
if (mAdapter == null)
|
|
||||||
{
|
|
||||||
mAdapter = new RemediationInstructionAdapter(getActivity());
|
|
||||||
setListAdapter(mAdapter);
|
|
||||||
}
|
|
||||||
mInstructions = instructions;
|
|
||||||
mAdapter.setData(mInstructions);
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
|
||||||
public static ArrayList<RemediationInstruction> getInstructionsCompat(Bundle bundle)
|
|
||||||
{
|
|
||||||
return bundle.getParcelableArrayList(RemediationInstructionsFragment.EXTRA_REMEDIATION_INSTRUCTIONS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
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.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import org.strongswan.android.ui.compose.runDiagnostics
|
||||||
|
import org.strongswan.android.BuildConfig
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
|
||||||
|
import org.strongswan.android.logic.events.Events
|
||||||
|
import org.strongswan.android.logic.updates.ReportContent
|
||||||
|
import org.strongswan.android.logic.updates.ReportUploader
|
||||||
|
import org.strongswan.android.logic.updates.UpdateManager
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIntro
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
/** State of the "Send report" sheet (mockups App8-App10); the upload survives a rotation */
|
||||||
|
class ReportViewModel(app: Application) : AndroidViewModel(app)
|
||||||
|
{
|
||||||
|
sealed class Ui
|
||||||
|
{
|
||||||
|
data object Hidden : Ui()
|
||||||
|
data class Form(val sending: Boolean = false) : Ui()
|
||||||
|
data class Sent(val code: String) : Ui()
|
||||||
|
data class Failed(val failure: ReportContent.Failure) : Ui()
|
||||||
|
}
|
||||||
|
|
||||||
|
var ui by mutableStateOf<Ui>(Ui.Hidden)
|
||||||
|
private set
|
||||||
|
var comment by mutableStateOf("")
|
||||||
|
var attachDiag by mutableStateOf(true)
|
||||||
|
|
||||||
|
/* a swipe may hide the sheet mid-upload: the result then opens it again */
|
||||||
|
private var job: Job? = null
|
||||||
|
|
||||||
|
fun open()
|
||||||
|
{
|
||||||
|
ui = Ui.Form(sending = job?.isActive == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close()
|
||||||
|
{
|
||||||
|
ui = Ui.Hidden
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(profile: String)
|
||||||
|
{
|
||||||
|
if (job?.isActive == true)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui = Ui.Form(sending = true)
|
||||||
|
val app = getApplication<Application>()
|
||||||
|
val note = comment
|
||||||
|
val diag = attachDiag
|
||||||
|
job = viewModelScope.launch {
|
||||||
|
/* the same checks as the Diagnostics screen (~3 s); a hang must not block the report */
|
||||||
|
val checks = if (!diag) null else withTimeoutOrNull(10_000) {
|
||||||
|
runCatching { runDiagnostics(app) }.getOrNull()?.steps?.map { ReportContent.Check(it.title, it.value, it.ok) }
|
||||||
|
}
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
val now = Calendar.getInstance()
|
||||||
|
val charon = ReportContent.todayOnly(
|
||||||
|
ReportContent.readTail(File(app.filesDir, CharonVpnService.LOG_FILE), ReportContent.CHARON_MAX_BYTES * 2),
|
||||||
|
now.get(Calendar.MONTH) + 1, now.get(Calendar.DAY_OF_MONTH),
|
||||||
|
)
|
||||||
|
val text = ReportContent.build(charon, if (diag) ReportContent.readTail(DiagLog.file(app), ReportContent.DIAG_MAX_BYTES) else null, checks)
|
||||||
|
val meta = ReportContent.meta(
|
||||||
|
BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE.toLong(), Build.MANUFACTURER, Build.MODEL,
|
||||||
|
Build.VERSION.RELEASE, Build.VERSION.SDK_INT, networkLabel(app), note, profile,
|
||||||
|
)
|
||||||
|
val updates = UpdateManager.getInstance(app)
|
||||||
|
ReportUploader(BuildConfig.UPDATE_API_URL, updates.apiHmacKey(), updates.installId()).send(text, meta)
|
||||||
|
}
|
||||||
|
when (result)
|
||||||
|
{
|
||||||
|
is ReportUploader.Result.Sent ->
|
||||||
|
{
|
||||||
|
Events.reportSent(app, result.code)
|
||||||
|
comment = ""
|
||||||
|
ui = Ui.Sent(result.code)
|
||||||
|
}
|
||||||
|
is ReportUploader.Result.Failed ->
|
||||||
|
{
|
||||||
|
DiagLog.i("Report", "upload failed: HTTP ${result.httpCode} ${result.detail}")
|
||||||
|
ui = Ui.Failed(ReportContent.failure(result.httpCode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** Only the address and the HMAC key are needed (the manifest key is for updates) */
|
||||||
|
fun apiReady(context: Context): Boolean =
|
||||||
|
BuildConfig.UPDATE_API_URL.isNotBlank() && UpdateManager.getInstance(context).apiHmacKey().isNotBlank()
|
||||||
|
|
||||||
|
/** The network under the tunnel: with the VPN up the active network is the VPN itself */
|
||||||
|
private fun networkLabel(context: Context): String
|
||||||
|
{
|
||||||
|
val network = UnderlyingNetwork.resolve(context) ?: return "none"
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return "none"
|
||||||
|
val caps = try { cm.getNetworkCapabilities(network) } catch (_: Exception) { null } ?: return "none"
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "Wi-Fi"
|
||||||
|
caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "mobile"
|
||||||
|
else -> "other"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ReportSheet(model: ReportViewModel, profile: () -> String, onMail: (String) -> Unit)
|
||||||
|
{
|
||||||
|
val ui = model.ui
|
||||||
|
if (ui == ReportViewModel.Ui.Hidden)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ShxSheet(onDismiss = model::close)
|
||||||
|
{
|
||||||
|
when (ui)
|
||||||
|
{
|
||||||
|
is ReportViewModel.Ui.Form -> ReportForm(model, ui.sending, onSend = { model.send(profile()) }, onMail = { onMail(model.comment) })
|
||||||
|
is ReportViewModel.Ui.Sent -> ReportSent(ui.code, onDone = model::close)
|
||||||
|
is ReportViewModel.Ui.Failed -> ReportFailed(ui.failure, onRetry = model::open, onMail = { onMail(model.comment) })
|
||||||
|
ReportViewModel.Ui.Hidden -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App8 */
|
||||||
|
@Composable
|
||||||
|
private fun ReportForm(model: ReportViewModel, sending: Boolean, onSend: () -> Unit, onMail: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
ShxSheetTitle(stringResource(R.string.log_send_report))
|
||||||
|
ShxIntro(stringResource(R.string.report_intro))
|
||||||
|
}
|
||||||
|
ReasonCard(
|
||||||
|
stringResource(R.string.report_contents), c.accent,
|
||||||
|
listOf(R.string.report_item_log, R.string.report_item_device, R.string.report_item_network, R.string.report_item_ips).map { stringResource(it) to null },
|
||||||
|
)
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
ShxToggleRow(
|
||||||
|
stringResource(R.string.report_diag_title), model.attachDiag, { model.attachDiag = it },
|
||||||
|
subtitle = stringResource(R.string.report_diag_sub), enabled = !sending,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.report_comment_label),
|
||||||
|
value = model.comment,
|
||||||
|
onValueChange = { model.comment = it.take(1000) },
|
||||||
|
singleLine = false,
|
||||||
|
enabled = !sending,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.report_cancel), model::close, style = ShxButtonStyle.OUTLINE, enabled = !sending, modifier = Modifier.weight(1f))
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.weight(2f))
|
||||||
|
{
|
||||||
|
ShxButton(if (sending) "" else stringResource(R.string.report_send), { if (!sending) onSend() }, modifier = Modifier.fillMaxWidth())
|
||||||
|
if (sending)
|
||||||
|
{
|
||||||
|
CircularProgressIndicator(color = c.onAccent, strokeWidth = 2.5.dp, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!sending)
|
||||||
|
{
|
||||||
|
TextLink(stringResource(R.string.report_by_mail), onMail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App9 */
|
||||||
|
@Composable
|
||||||
|
private fun ReportSent(code: String, onDone: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val copied = stringResource(R.string.report_code_copied)
|
||||||
|
ResultHeader(ShxIcons.Check, c.successContainer, c.success, stringResource(R.string.report_sent_title), stringResource(R.string.report_sent_sub))
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(16.dp))
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.report_code_label).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
Text(code, style = ShxType.mono.copy(fontSize = 22.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, letterSpacing = 0.9.sp), color = c.text)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile).clickable(role = Role.Button) {
|
||||||
|
(context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager).setPrimaryClip(ClipData.newPlainText("report", code))
|
||||||
|
Toast.makeText(context, copied, Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.report_copy_code), tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.report_saved_note))
|
||||||
|
ShxButton(stringResource(R.string.report_done), onDone, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App10 */
|
||||||
|
@Composable
|
||||||
|
private fun ReportFailed(failure: ReportContent.Failure, onRetry: () -> Unit, onMail: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val noAnswer = failure == ReportContent.Failure.NO_NETWORK || failure == ReportContent.Failure.SERVER_DOWN
|
||||||
|
ResultHeader(
|
||||||
|
ShxIcons.Alert, c.errorContainer, c.error, stringResource(R.string.report_failed_title),
|
||||||
|
stringResource(if (noAnswer) R.string.report_failed_no_answer else R.string.report_failed_refused),
|
||||||
|
)
|
||||||
|
val reasons = when (failure)
|
||||||
|
{
|
||||||
|
ReportContent.Failure.NO_NETWORK -> listOf(R.string.report_reason_offline to R.string.report_reason_offline_d, R.string.report_reason_server to R.string.report_reason_server_d)
|
||||||
|
ReportContent.Failure.SERVER_DOWN -> listOf(R.string.report_reason_server to R.string.report_reason_server_d)
|
||||||
|
ReportContent.Failure.SIGNATURE -> listOf(R.string.report_reason_clock to R.string.report_reason_clock_d)
|
||||||
|
ReportContent.Failure.TOO_OFTEN -> listOf(R.string.report_reason_often to R.string.report_reason_often_d)
|
||||||
|
ReportContent.Failure.TOO_LARGE -> listOf(R.string.report_reason_large to R.string.report_reason_large_d)
|
||||||
|
ReportContent.Failure.REJECTED -> listOf(R.string.report_reason_rejected to R.string.report_reason_rejected_d)
|
||||||
|
}
|
||||||
|
ReasonCard(stringResource(R.string.report_reasons), c.error, reasons.map { (t, d) -> stringResource(t) to stringResource(d) })
|
||||||
|
ShxNote(stringResource(R.string.report_mail_note))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.report_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.report_by_letter), onMail, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 52dp round icon, 22/800 title, 13 sub (as F_ErrImport) */
|
||||||
|
@Composable
|
||||||
|
private fun ResultHeader(icon: ImageVector, background: Color, tint: Color, title: String, sub: String)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(CircleShape).background(background))
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text)
|
||||||
|
Text(sub, style = ShxType.sub, color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** White card with a section label and dotted lines: «bold — muted» when [items] carry a second part */
|
||||||
|
@Composable
|
||||||
|
private fun ReasonCard(title: String, dot: Color, items: List<Pair<String, String?>>)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(22.dp)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(title.uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
items.forEach { (head, tail) ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
if (tail == null)
|
||||||
|
{
|
||||||
|
append(head)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(head) }
|
||||||
|
append(" — ")
|
||||||
|
withStyle(SpanStyle(color = c.textSub)) { append(tail) }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextLink(text: String, onClick: () -> Unit)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text, style = ShxType.chip, color = LocalShxColors.current.accentText,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(12.dp)).clickable(role = Role.Button, onClick = onClick).padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import org.strongswan.android.ui.compose.RouteCheckMode
|
||||||
|
import org.strongswan.android.ui.compose.RouteCheckScreen
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
/** «How traffic goes»: route of a site, an address or an app. */
|
||||||
|
class RouteCheckActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
val mode = RouteCheckMode.entries.getOrElse(intent.getIntExtra(EXTRA_MODE, 0)) { RouteCheckMode.SITE }
|
||||||
|
val query = intent.getStringExtra(EXTRA_QUERY).orEmpty()
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background))
|
||||||
|
{
|
||||||
|
RouteCheckScreen(onBack = { finish() }, initialMode = mode, initialQuery = query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
private const val EXTRA_MODE = "mode"
|
||||||
|
private const val EXTRA_QUERY = "query"
|
||||||
|
|
||||||
|
fun intent(context: Context, mode: RouteCheckMode, query: String): Intent =
|
||||||
|
Intent(context, RouteCheckActivity::class.java).putExtra(EXTRA_MODE, mode.ordinal).putExtra(EXTRA_QUERY, query)
|
||||||
|
}
|
||||||
|
}
|
||||||
+737
@@ -0,0 +1,737 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.TimePickerDialog
|
||||||
|
import android.app.usage.UsageStatsManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.drawable.Drawable
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Checkbox
|
||||||
|
import androidx.compose.material3.CheckboxDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
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.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.data.VpnProfile
|
||||||
|
import org.strongswan.android.data.VpnProfileSource
|
||||||
|
import org.strongswan.android.logic.autoconnect.AutoConnectManager
|
||||||
|
import org.strongswan.android.logic.autoconnect.SsidReader
|
||||||
|
import org.strongswan.android.logic.routing.AppTraffic
|
||||||
|
import org.strongswan.android.logic.scenarios.Scenario
|
||||||
|
import org.strongswan.android.logic.scenarios.ScenarioAction
|
||||||
|
import org.strongswan.android.logic.scenarios.ScenarioEngine
|
||||||
|
import org.strongswan.android.logic.scenarios.ScenarioTrigger
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSwitch
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import org.strongswan.android.utils.Constants
|
||||||
|
|
||||||
|
/** «Scenarios» (F_Automation): the built-in network rules, the user scenarios, trusted networks. */
|
||||||
|
class ScenariosActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
ScenariosScreen(onBack = { finish() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadProfiles(context: Context): List<VpnProfile>
|
||||||
|
{
|
||||||
|
val source = VpnProfileSource(context)
|
||||||
|
source.open()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return source.allVpnProfiles
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScenariosScreen(onBack: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val auto = remember { AutoConnectManager.getInstance(context) }
|
||||||
|
var config by remember { mutableStateOf(auto.currentConfig) }
|
||||||
|
val scenarios by remember { ScenarioEngine.flow(context) }.collectAsStateWithLifecycle()
|
||||||
|
var editing by remember { mutableStateOf<Scenario?>(null) }
|
||||||
|
var pickRulesServer by remember { mutableStateOf(false) }
|
||||||
|
val orderChanges by ScenarioEngine.orderChanges.collectAsStateWithLifecycle()
|
||||||
|
var creating by remember { mutableStateOf(false) }
|
||||||
|
var resumes by remember { mutableIntStateOf(0) }
|
||||||
|
val owner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(owner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { resumes++; config = auto.currentConfig } }
|
||||||
|
owner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
val profiles by produceState(emptyList<VpnProfile>(), resumes) { value = withContext(Dispatchers.IO) { loadProfiles(context) } }
|
||||||
|
|
||||||
|
fun updateAuto(transform: (AutoConnectManager.Config) -> AutoConnectManager.Config)
|
||||||
|
{
|
||||||
|
auto.updateConfig { current ->
|
||||||
|
var next = transform(current)
|
||||||
|
next = next.copy(enabled = next.parkOnTrusted || next.connectOnUntrusted)
|
||||||
|
if (next.enabled && next.profileUuid == null)
|
||||||
|
{ /* the rules need a server: the last used, else the first */
|
||||||
|
val mru = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getString(Constants.PREF_MRU_VPN_PROFILE, null)
|
||||||
|
next = next.copy(profileUuid = mru ?: profiles.firstOrNull()?.getUUID()?.toString())
|
||||||
|
}
|
||||||
|
next
|
||||||
|
}
|
||||||
|
config = auto.currentConfig
|
||||||
|
}
|
||||||
|
/* «+ Add current network» needs the location permission to read the SSID */
|
||||||
|
var pendingAdd by remember { mutableStateOf(false) }
|
||||||
|
fun addCurrentNetwork()
|
||||||
|
{
|
||||||
|
val ssid = SsidReader.from(context)
|
||||||
|
if (ssid != null)
|
||||||
|
{
|
||||||
|
updateAuto { it.copy(trustedSsids = it.trustedSsids + ssid) }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
android.widget.Toast.makeText(context, R.string.scenarios_no_wifi, android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||||
|
if (pendingAdd)
|
||||||
|
{
|
||||||
|
pendingAdd = false
|
||||||
|
addCurrentNetwork()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize().background(c.background))
|
||||||
|
{
|
||||||
|
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 104.dp))
|
||||||
|
{
|
||||||
|
ShxPageHeader(stringResource(R.string.scenarios_title), onBack = onBack)
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.scenarios_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal),
|
||||||
|
color = c.textSoft, modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
/* one order for the network rules and the scenarios: the top one that applies wins */
|
||||||
|
val order = remember(scenarios, orderChanges) { ScenarioEngine.order(context) }
|
||||||
|
order.forEach { id ->
|
||||||
|
when (id)
|
||||||
|
{
|
||||||
|
org.strongswan.android.logic.scenarios.BUILTIN_TRUSTED ->
|
||||||
|
RuleCard(ShxIcons.Wifi, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_trusted), stringResource(R.string.scenario_do_off), config.enabled && config.parkOnTrusted, null)
|
||||||
|
{ on -> updateAuto { it.copy(parkOnTrusted = on, connectOnUntrusted = if (on && !it.enabled) true else it.connectOnUntrusted) } }
|
||||||
|
org.strongswan.android.logic.scenarios.BUILTIN_UNTRUSTED ->
|
||||||
|
RuleCard(ShxIcons.Phone, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_untrusted), stringResource(R.string.scenario_do_on), config.enabled && config.connectOnUntrusted, null)
|
||||||
|
{ on -> updateAuto { it.copy(connectOnUntrusted = on, parkOnTrusted = if (on && !it.enabled) true else it.parkOnTrusted) } }
|
||||||
|
else -> scenarios.firstOrNull { it.id == id }?.let { s ->
|
||||||
|
val (icon, bg, fg) = triggerLook(s.trigger)
|
||||||
|
RuleCard(icon, bg, fg, ScenarioEngine.describeWhen(context, s), stringResource(ScenarioEngine.actionLabel(s.action)), s.enabled, onClick = { editing = s })
|
||||||
|
{ on -> ScenarioEngine.save(context, scenarios.map { if (it.id == s.id) it.copy(enabled = on) else it }) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* the server the two network rules connect (was the old «Trusted networks» screen) */
|
||||||
|
if (config.enabled)
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(18.dp))
|
||||||
|
.clickable(role = Role.Button) { pickRulesServer = true }.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.scenarios_rules_server), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
profiles.firstOrNull { it.getUUID().toString() == config.profileUuid }?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto),
|
||||||
|
style = ShxType.body, color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxSectionHeader(stringResource(R.string.scenarios_trusted).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 10.dp))
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
config.trustedSsids.sorted().forEach { ssid ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().height(56.dp).padding(start = 16.dp, end = 8.dp))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Wifi, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||||
|
Text(ssid, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(40.dp).clip(CircleShape).clickable(role = Role.Button) { updateAuto { it.copy(trustedSsids = it.trustedSsids - ssid) } },
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.scenarios_remove_net), tint = c.iconMuted, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp).clickable(role = Role.Button) {
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)
|
||||||
|
{
|
||||||
|
pendingAdd = true
|
||||||
|
permission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
}
|
||||||
|
else addCurrentNetwork()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.scenarios_add_current), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), color = c.accentText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* «+ Scenario» */
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 24.dp).height(60.dp)
|
||||||
|
.shadow(10.dp, RoundedCornerShape(20.dp), ambientColor = c.accent, spotColor = c.accent)
|
||||||
|
.clip(RoundedCornerShape(20.dp)).background(c.accent).clickable(role = Role.Button) { creating = true }.padding(horizontal = 22.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.scenarios_add), style = ShxType.button.copy(fontSize = 16.sp), color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pickRulesServer)
|
||||||
|
{
|
||||||
|
ShxSheet(onDismiss = { pickRulesServer = false })
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.scenarios_rules_server_pick), style = ShxType.note.copy(fontWeight = FontWeight.Normal, lineHeight = 19.sp), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
profiles.forEachIndexed { i, p ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable {
|
||||||
|
updateAuto { it.copy(profileUuid = p.getUUID().toString()) }
|
||||||
|
pickRulesServer = false
|
||||||
|
}.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(p.name ?: p.gateway, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
if (config.profileUuid == p.getUUID().toString())
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i < profiles.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (creating || editing != null)
|
||||||
|
{
|
||||||
|
ScenarioSheet(
|
||||||
|
initial = editing,
|
||||||
|
profiles = profiles,
|
||||||
|
onDismiss = { creating = false; editing = null },
|
||||||
|
onSave = { saved ->
|
||||||
|
val list = if (scenarios.any { it.id == saved.id }) scenarios.map { if (it.id == saved.id) saved else it } else scenarios + saved
|
||||||
|
ScenarioEngine.save(context, list)
|
||||||
|
creating = false
|
||||||
|
editing = null
|
||||||
|
},
|
||||||
|
onDelete = editing?.let { e -> { ScenarioEngine.save(context, scenarios.filter { it.id != e.id }); editing = null } },
|
||||||
|
onMove = editing?.let { e ->
|
||||||
|
{ delta: Int ->
|
||||||
|
ScenarioEngine.move(context, e.id, delta)
|
||||||
|
editing = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun triggerLook(trigger: ScenarioTrigger): Triple<ImageVector, Color, Color>
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
return when (trigger)
|
||||||
|
{
|
||||||
|
ScenarioTrigger.WIFI -> Triple(ShxIcons.Wifi, c.accentContainer, c.onAccentContainer)
|
||||||
|
ScenarioTrigger.MOBILE -> Triple(ShxIcons.Phone, c.accentContainer, c.onAccentContainer)
|
||||||
|
ScenarioTrigger.APP -> Triple(ShxIcons.Apps, c.warningStrong, c.onWarning)
|
||||||
|
ScenarioTrigger.TIME -> Triple(ShxIcons.Clock, c.tile, c.textSoft)
|
||||||
|
ScenarioTrigger.ROAMING -> Triple(ShxIcons.Globe, c.accentContainer, c.onAccentContainer)
|
||||||
|
ScenarioTrigger.WHITELIST -> Triple(ShxIcons.Alert, c.warningStrong, c.onWarning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One rule of F_Automation: icon well, «when», «→ then», switch. */
|
||||||
|
@Composable
|
||||||
|
private fun RuleCard(icon: ImageVector, bg: Color, fg: Color, whenText: String, thenText: String, on: Boolean, onClick: (() -> Unit)?, onToggle: (Boolean) -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape)
|
||||||
|
.then(if (onClick != null) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier).padding(14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(bg))
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Text(whenText, style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.textSub, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.accent, modifier = Modifier.size(16.dp))
|
||||||
|
Text(thenText, style = ShxType.cardTitle, color = c.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxSwitch(on, onToggle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «New scenario» (F_ScenarioAdd): when, what, server, the summary. */
|
||||||
|
@Composable
|
||||||
|
private fun ScenarioSheet(
|
||||||
|
initial: Scenario?,
|
||||||
|
profiles: List<VpnProfile>,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onSave: (Scenario) -> Unit,
|
||||||
|
onDelete: (() -> Unit)?,
|
||||||
|
onMove: ((Int) -> Unit)?,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
var trigger by remember { mutableStateOf(initial?.trigger ?: ScenarioTrigger.APP) }
|
||||||
|
var action by remember { mutableStateOf(initial?.action ?: ScenarioAction.VPN_ON) }
|
||||||
|
var packages by remember { mutableStateOf(initial?.packages ?: emptySet()) }
|
||||||
|
var ssids by remember { mutableStateOf(initial?.ssids?.joinToString(", ") ?: SsidReader.from(context).orEmpty()) }
|
||||||
|
var start by remember { mutableIntStateOf(initial?.startMinute ?: 60) }
|
||||||
|
var end by remember { mutableIntStateOf(initial?.endMinute ?: 7 * 60) }
|
||||||
|
var profile by remember { mutableStateOf(initial?.profileUuid) }
|
||||||
|
var picking by remember { mutableStateOf(false) }
|
||||||
|
var pickProfile by remember { mutableStateOf(false) }
|
||||||
|
val draft = Scenario(
|
||||||
|
id = initial?.id ?: java.util.UUID.randomUUID().toString(),
|
||||||
|
enabled = initial?.enabled ?: true,
|
||||||
|
trigger = trigger,
|
||||||
|
ssids = ssids.split(',').map { it.trim() }.filter { it.isNotEmpty() }.toSet(),
|
||||||
|
packages = packages,
|
||||||
|
startMinute = start,
|
||||||
|
endMinute = end,
|
||||||
|
action = action,
|
||||||
|
profileUuid = profile?.takeIf { action == ScenarioAction.VPN_ON || action == ScenarioAction.SWITCH_SERVER },
|
||||||
|
)
|
||||||
|
val valid = when (trigger)
|
||||||
|
{
|
||||||
|
ScenarioTrigger.APP -> packages.isNotEmpty()
|
||||||
|
ScenarioTrigger.TIME -> start != end
|
||||||
|
ScenarioTrigger.WIFI -> true
|
||||||
|
else -> true
|
||||||
|
} && !(action == ScenarioAction.SWITCH_SERVER && profile == null)
|
||||||
|
|
||||||
|
ShxSheet(onDismiss = onDismiss)
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(if (initial == null) R.string.scenario_new else R.string.scenario_edit), style = ShxType.sheetTitle, color = c.text, modifier = Modifier.padding(start = 4.dp, top = 4.dp))
|
||||||
|
Text(stringResource(R.string.scenario_step_when), style = ShxType.section, color = c.accentText, modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
|
val whens = listOf(
|
||||||
|
ScenarioTrigger.WIFI to R.string.scenario_t_wifi, ScenarioTrigger.MOBILE to R.string.scenario_t_mobile, ScenarioTrigger.APP to R.string.scenario_t_app,
|
||||||
|
ScenarioTrigger.TIME to R.string.scenario_t_time, ScenarioTrigger.ROAMING to R.string.scenario_t_roaming, ScenarioTrigger.WHITELIST to R.string.scenario_t_whitelist,
|
||||||
|
)
|
||||||
|
whens.chunked(3).forEach { row ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
row.forEach { (t, label) ->
|
||||||
|
val on = trigger == t
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
|
||||||
|
modifier = Modifier.weight(1f).height(76.dp).clip(RoundedCornerShape(18.dp)).background(if (on) c.accentSoft else c.card)
|
||||||
|
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, RoundedCornerShape(18.dp))
|
||||||
|
.clickable(role = Role.RadioButton) { trigger = t }.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(triggerLook(t).first, contentDescription = null, tint = if (on) c.onAccentContainer else c.text, modifier = Modifier.size(20.dp))
|
||||||
|
Text(stringResource(label), style = ShxType.meta.copy(fontWeight = FontWeight.Bold, lineHeight = 14.sp), color = if (on) c.onAccentContainer else c.text, textAlign = TextAlign.Center, maxLines = 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (trigger)
|
||||||
|
{
|
||||||
|
ScenarioTrigger.APP -> AppChips(packages, onPick = { picking = true }, onRemove = { packages = packages - it })
|
||||||
|
ScenarioTrigger.WIFI -> ShxFormField(label = stringResource(R.string.scenario_ssids), value = ssids, onValueChange = { ssids = it }, placeholder = stringResource(R.string.scenario_ssids_hint))
|
||||||
|
ScenarioTrigger.TIME -> Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
TimeBox(stringResource(R.string.scenario_from), start, Modifier.weight(1f)) { start = it }
|
||||||
|
TimeBox(stringResource(R.string.scenario_to), end, Modifier.weight(1f)) { end = it }
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.scenario_step_what), style = ShxType.section, color = c.accentText, modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 6.dp))
|
||||||
|
listOf(ScenarioAction.VPN_ON, ScenarioAction.VPN_OFF, ScenarioAction.PAUSE, ScenarioAction.SWITCH_SERVER).chunked(2).forEach { row ->
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
row.forEach { a ->
|
||||||
|
val on = action == a
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.weight(1f).height(52.dp).clip(RoundedCornerShape(16.dp)).background(if (on) c.accentSoft else c.card)
|
||||||
|
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, RoundedCornerShape(16.dp))
|
||||||
|
.clickable(role = Role.RadioButton) { action = a },
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(ScenarioEngine.actionLabel(a)), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (on) c.onAccentContainer else c.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action == ScenarioAction.VPN_ON || action == ScenarioAction.SWITCH_SERVER)
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(18.dp))
|
||||||
|
.clickable(role = Role.Button) { pickProfile = true }.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.scenario_server), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
profiles.firstOrNull { it.getUUID().toString() == profile }?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto),
|
||||||
|
style = ShxType.body, color = c.textSub,
|
||||||
|
)
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.scenario_summary, ScenarioEngine.describeWhen(context, draft),
|
||||||
|
midSentence(stringResource(ScenarioEngine.actionLabel(action)))),
|
||||||
|
style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal), color = c.onAccentContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.accentSoft).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
if (trigger == ScenarioTrigger.APP && !AppTraffic.hasAccess(context))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.scenario_app_access), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.onWarningContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(c.warningContainer).padding(12.dp))
|
||||||
|
}
|
||||||
|
if (onMove != null)
|
||||||
|
{
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.scenario_raise), { onMove(-1) }, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.ArrowUp)
|
||||||
|
ShxButton(stringResource(R.string.scenario_lower), { onMove(1) }, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.ArrowDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onDelete != null)
|
||||||
|
{
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
onDelete?.let { ShxButton(stringResource(R.string.server_delete_confirm), it, style = ShxButtonStyle.DANGER, height = 48.dp, modifier = Modifier.weight(1f)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.server_save), { if (valid) onSave(draft) }, modifier = Modifier.weight(2f), enabled = valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (picking)
|
||||||
|
{
|
||||||
|
AppPicker(packages, onDone = { packages = it; picking = false }, onDismiss = { picking = false })
|
||||||
|
}
|
||||||
|
if (pickProfile)
|
||||||
|
{
|
||||||
|
ShxSheet(onDismiss = { pickProfile = false })
|
||||||
|
{
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
(listOf<VpnProfile?>(null) + profiles).forEachIndexed { i, p ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable { profile = p?.getUUID()?.toString(); pickProfile = false }.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(p?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
if (profile == p?.getUUID()?.toString())
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i < profiles.size)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TimeBox(label: String, minute: Int, modifier: Modifier, onPick: (Int) -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
modifier = modifier.clip(RoundedCornerShape(16.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(16.dp))
|
||||||
|
.clickable(role = Role.Button) {
|
||||||
|
TimePickerDialog(context, { _, h, m -> onPick(h * 60 + m) }, minute / 60, minute % 60, true).show()
|
||||||
|
}.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(label, style = ShxType.meta, color = c.textSub)
|
||||||
|
Text(String.format("%02d:%02d", minute / 60, minute % 60), style = ShxType.cardHeading, color = c.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chosen apps as chips with icons, plus «+ Choose». */
|
||||||
|
@Composable
|
||||||
|
private fun AppChips(packages: Set<String>, onPick: () -> Unit, onRemove: (String) -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val pm = context.packageManager
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 2.dp))
|
||||||
|
{
|
||||||
|
packages.forEach { pkg ->
|
||||||
|
val info = remember(pkg) { runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull() }
|
||||||
|
val label = remember(pkg) { info?.let { pm.getApplicationLabel(it).toString() } ?: pkg }
|
||||||
|
val icon = remember(pkg) { info?.let { runCatching { pm.getApplicationIcon(it).toBitmap(72, 72).asImageBitmap() }.getOrNull() } }
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(18.dp)).background(c.accentContainer).clickable { onRemove(pkg) }.padding(start = 6.dp, end = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (icon != null) Image(icon, contentDescription = null, modifier = Modifier.size(24.dp).clip(RoundedCornerShape(8.dp)))
|
||||||
|
Text(label, style = ShxType.chip, color = c.onAccentContainer, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(18.dp)).border(1.dp, c.iconMuted, RoundedCornerShape(18.dp)).clickable(role = Role.Button, onClick = onPick).padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.scenario_pick_apps), style = ShxType.chip, color = c.accentText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LaunchableApp(val pkg: String, val label: String, val icon: Drawable?)
|
||||||
|
|
||||||
|
/** Launchable apps, the most used of the last week first (with usage access). */
|
||||||
|
private fun loadApps(context: Context): Pair<List<LaunchableApp>, Set<String>>
|
||||||
|
{
|
||||||
|
val pm = context.packageManager
|
||||||
|
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||||
|
val apps = pm.queryIntentActivities(intent, 0).map { it.activityInfo.packageName }.distinct()
|
||||||
|
.filter { it != context.packageName }
|
||||||
|
.map { pkg ->
|
||||||
|
val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull()
|
||||||
|
LaunchableApp(pkg, info?.let { pm.getApplicationLabel(it).toString() } ?: pkg, info?.let { runCatching { pm.getApplicationIcon(it) }.getOrNull() })
|
||||||
|
}
|
||||||
|
val frequent = if (AppTraffic.hasAccess(context))
|
||||||
|
{
|
||||||
|
val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
usm?.queryUsageStats(UsageStatsManager.INTERVAL_BEST, now - 7L * 24 * 60 * 60 * 1000, now)
|
||||||
|
?.filter { it.totalTimeInForeground > 0 }?.sortedByDescending { it.totalTimeInForeground }
|
||||||
|
?.map { it.packageName }?.filter { p -> apps.any { it.pkg == p } }?.distinct()?.take(7)?.toSet() ?: emptySet()
|
||||||
|
}
|
||||||
|
else emptySet()
|
||||||
|
return apps.sortedBy { it.label.lowercase() } to frequent
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Apps» (F_AppPicker): search, often opened first, checkboxes, Done. */
|
||||||
|
@Composable
|
||||||
|
private fun AppPicker(selected: Set<String>, onDone: (Set<String>) -> Unit, onDismiss: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
var chosen by remember { mutableStateOf(selected) }
|
||||||
|
var query by remember { mutableStateOf("") }
|
||||||
|
val data by produceState<Pair<List<LaunchableApp>, Set<String>>?>(null) { value = withContext(Dispatchers.IO) { loadApps(context) } }
|
||||||
|
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false))
|
||||||
|
{
|
||||||
|
Column(Modifier.fillMaxSize().background(c.background).statusBarsPadding().navigationBarsPadding())
|
||||||
|
{
|
||||||
|
ShxPageHeader(stringResource(R.string.stats_apps), onBack = onDismiss)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.scenario_chosen, chosen.size), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.accentText, modifier = Modifier.padding(end = 8.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(26.dp)).background(c.tile).padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
|
Box(Modifier.weight(1f))
|
||||||
|
{
|
||||||
|
if (query.isEmpty())
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.scenario_search_apps), style = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium), color = c.textSub)
|
||||||
|
}
|
||||||
|
BasicTextField(query, { query = it }, singleLine = true, textStyle = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium, color = c.text), cursorBrush = SolidColor(c.accent), modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val (apps, frequent) = data ?: (emptyList<LaunchableApp>() to emptySet())
|
||||||
|
val shown = apps.filter { query.isBlank() || it.label.contains(query.trim(), ignoreCase = true) }
|
||||||
|
val top = if (query.isBlank()) shown.filter { it.pkg in frequent }.sortedBy { frequent.indexOf(it.pkg) } else emptyList()
|
||||||
|
val rest = shown - top.toSet()
|
||||||
|
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
if (top.isNotEmpty())
|
||||||
|
{
|
||||||
|
item { ShxSectionHeader(stringResource(R.string.scenario_often).uppercase(), Modifier.padding(horizontal = 6.dp)) }
|
||||||
|
item { AppList(top, chosen) { pkg -> chosen = if (pkg in chosen) chosen - pkg else chosen + pkg } }
|
||||||
|
item { ShxSectionHeader(stringResource(R.string.scenario_all_apps).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp)) }
|
||||||
|
}
|
||||||
|
item { AppList(rest, chosen) { pkg -> chosen = if (pkg in chosen) chosen - pkg else chosen + pkg } }
|
||||||
|
}
|
||||||
|
ShxButton(stringResource(R.string.scenario_done), { onDone(chosen) }, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppList(apps: List<LaunchableApp>, chosen: Set<String>, onToggle: (String) -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
if (apps.isEmpty()) return
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
apps.forEachIndexed { i, app ->
|
||||||
|
val icon = remember(app.pkg) { app.icon?.let { runCatching { it.toBitmap(96, 96).asImageBitmap() }.getOrNull() } }
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(60.dp).clickable { onToggle(app.pkg) }.padding(start = 16.dp, end = 8.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(c.tile), contentAlignment = Alignment.Center)
|
||||||
|
{
|
||||||
|
if (icon != null) Image(icon, contentDescription = null, modifier = Modifier.size(36.dp))
|
||||||
|
else Text(app.label.take(1).uppercase(), style = ShxType.chip, color = c.textSoft)
|
||||||
|
}
|
||||||
|
Text(app.label, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||||
|
Checkbox(app.pkg in chosen, { onToggle(app.pkg) }, colors = CheckboxDefaults.colors(checkedColor = c.accent))
|
||||||
|
}
|
||||||
|
if (i < apps.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An action label inside the summary sentence: lower case, unless German (nouns) or it starts with an acronym like «VPN». */
|
||||||
|
private fun midSentence(label: String): String =
|
||||||
|
if (java.util.Locale.getDefault().language == "de" || label.getOrNull(1)?.isUpperCase() == true) label
|
||||||
|
else label.replaceFirstChar { it.lowercase() }
|
||||||
-88
@@ -1,88 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2017 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.view.MenuItem;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
import org.strongswan.android.data.VpnProfileDataSource;
|
|
||||||
import org.strongswan.android.utils.Utils;
|
|
||||||
|
|
||||||
import androidx.activity.OnBackPressedCallback;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.appcompat.app.ActionBar;
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
|
||||||
import androidx.core.view.WindowCompat;
|
|
||||||
import androidx.fragment.app.FragmentManager;
|
|
||||||
|
|
||||||
public class SelectedApplicationsActivity extends AppCompatActivity
|
|
||||||
{
|
|
||||||
private static final String LIST_TAG = "ApplicationList";
|
|
||||||
private SelectedApplicationsListFragment mApps;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onCreate(@Nullable Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
setContentView(R.layout.selected_applications_activity);
|
|
||||||
WindowCompat.enableEdgeToEdge(getWindow());
|
|
||||||
Utils.applyWindowInsetsAsMarginsForLists(findViewById(R.id.fragment_container));
|
|
||||||
|
|
||||||
ActionBar actionBar = getSupportActionBar();
|
|
||||||
actionBar.setDisplayHomeAsUpEnabled(true);
|
|
||||||
|
|
||||||
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true)
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public void handleOnBackPressed()
|
|
||||||
{
|
|
||||||
prepareResult();
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
FragmentManager fm = getSupportFragmentManager();
|
|
||||||
mApps = (SelectedApplicationsListFragment)fm.findFragmentByTag(LIST_TAG);
|
|
||||||
if (mApps == null)
|
|
||||||
{
|
|
||||||
mApps = new SelectedApplicationsListFragment();
|
|
||||||
fm.beginTransaction().add(R.id.fragment_container, mApps, LIST_TAG).commit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onOptionsItemSelected(MenuItem item)
|
|
||||||
{
|
|
||||||
switch (item.getItemId())
|
|
||||||
{
|
|
||||||
case android.R.id.home:
|
|
||||||
prepareResult();
|
|
||||||
finish();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return super.onOptionsItemSelected(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void prepareResult()
|
|
||||||
{
|
|
||||||
Intent data = new Intent();
|
|
||||||
data.putExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, mApps.getSelectedApplications());
|
|
||||||
setResult(RESULT_OK, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-286
@@ -1,286 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2017 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.Manifest;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.pm.ApplicationInfo;
|
|
||||||
import android.content.pm.PackageManager;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.util.Pair;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuInflater;
|
|
||||||
import android.view.MenuItem;
|
|
||||||
import android.view.View;
|
|
||||||
import android.widget.Filter;
|
|
||||||
import android.widget.ListView;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
import org.strongswan.android.data.VpnProfileDataSource;
|
|
||||||
import org.strongswan.android.ui.adapter.SelectedApplicationEntry;
|
|
||||||
import org.strongswan.android.ui.adapter.SelectedApplicationsAdapter;
|
|
||||||
import org.strongswan.android.utils.Utils;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.SortedSet;
|
|
||||||
import java.util.TreeSet;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.appcompat.widget.SearchView;
|
|
||||||
import androidx.core.view.MenuProvider;
|
|
||||||
import androidx.fragment.app.ListFragment;
|
|
||||||
import androidx.loader.app.LoaderManager;
|
|
||||||
import androidx.loader.content.AsyncTaskLoader;
|
|
||||||
import androidx.loader.content.Loader;
|
|
||||||
|
|
||||||
public class SelectedApplicationsListFragment extends ListFragment implements MenuProvider, LoaderManager.LoaderCallbacks<Pair<List<SelectedApplicationEntry>, List<String>>>, SearchView.OnQueryTextListener
|
|
||||||
{
|
|
||||||
private SelectedApplicationsAdapter mAdapter;
|
|
||||||
private SortedSet<String> mSelection;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
super.onViewCreated(view, savedInstanceState);
|
|
||||||
requireActivity().addMenuProvider(this, getViewLifecycleOwner());
|
|
||||||
|
|
||||||
getListView().setClipToPadding(false);
|
|
||||||
Utils.applyWindowInsetsAsPaddingForLists(getListView());
|
|
||||||
|
|
||||||
final boolean readOnly = getActivity().getIntent().getBooleanExtra(VpnProfileDataSource.KEY_READ_ONLY, false);
|
|
||||||
getListView().setChoiceMode(readOnly ? ListView.CHOICE_MODE_NONE : ListView.CHOICE_MODE_MULTIPLE);
|
|
||||||
|
|
||||||
mAdapter = new SelectedApplicationsAdapter(getActivity());
|
|
||||||
mAdapter.setReadOnly(readOnly);
|
|
||||||
setListAdapter(mAdapter);
|
|
||||||
setListShown(false);
|
|
||||||
|
|
||||||
ArrayList<String> selection;
|
|
||||||
if (savedInstanceState == null)
|
|
||||||
{
|
|
||||||
selection = getActivity().getIntent().getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
selection = savedInstanceState.getStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST);
|
|
||||||
}
|
|
||||||
mSelection = new TreeSet<>(selection);
|
|
||||||
|
|
||||||
LoaderManager.getInstance(this).initLoader(0, null, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSaveInstanceState(Bundle outState)
|
|
||||||
{
|
|
||||||
super.onSaveInstanceState(outState);
|
|
||||||
outState.putStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, new ArrayList<>(mSelection));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the package names of all selected apps
|
|
||||||
*/
|
|
||||||
public ArrayList<String> getSelectedApplications()
|
|
||||||
{
|
|
||||||
return new ArrayList<>(mSelection);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Track check state as ListView is unable to do that when using filters
|
|
||||||
*/
|
|
||||||
@Override
|
|
||||||
public void onListItemClick(ListView l, View v, int position, long id)
|
|
||||||
{
|
|
||||||
if (mAdapter.isReadOnly())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onListItemClick(l, v, position, id);
|
|
||||||
SelectedApplicationEntry item = (SelectedApplicationEntry)getListView().getItemAtPosition(position);
|
|
||||||
item.setSelected(!item.isSelected());
|
|
||||||
if (item.isSelected())
|
|
||||||
{
|
|
||||||
mSelection.add(item.getInfo().packageName);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mSelection.remove(item.getInfo().packageName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manually set the check state as ListView is unable to track that when using filters
|
|
||||||
*/
|
|
||||||
private void setCheckState()
|
|
||||||
{
|
|
||||||
for (int i = 0; i < getListView().getCount(); i++)
|
|
||||||
{
|
|
||||||
SelectedApplicationEntry item = mAdapter.getItem(i);
|
|
||||||
getListView().setItemChecked(i, item.isSelected());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater)
|
|
||||||
{
|
|
||||||
MenuItem item = menu.add(R.string.search);
|
|
||||||
item.setIcon(android.R.drawable.ic_menu_search);
|
|
||||||
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
|
||||||
|
|
||||||
SearchView sv = new SearchView(getActivity());
|
|
||||||
sv.setOnQueryTextListener(this);
|
|
||||||
item.setActionView(sv);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onMenuItemSelected(@NonNull MenuItem menuItem)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Loader<Pair<List<SelectedApplicationEntry>, List<String>>> onCreateLoader(int id, Bundle args)
|
|
||||||
{
|
|
||||||
return new InstalledPackagesLoader(getActivity(), mSelection);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoadFinished(Loader<Pair<List<SelectedApplicationEntry>, List<String>>> loader, Pair<List<SelectedApplicationEntry>, List<String>> data)
|
|
||||||
{
|
|
||||||
mAdapter.setData(data.first);
|
|
||||||
mSelection.removeAll(data.second);
|
|
||||||
setCheckState();
|
|
||||||
|
|
||||||
if (isResumed())
|
|
||||||
{
|
|
||||||
setListShown(true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
setListShownNoAnimation(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoaderReset(Loader<Pair<List<SelectedApplicationEntry>, List<String>>> loader)
|
|
||||||
{
|
|
||||||
mAdapter.setData(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onQueryTextSubmit(String query)
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onQueryTextChange(String newText)
|
|
||||||
{
|
|
||||||
String search = TextUtils.isEmpty(newText) ? null : newText;
|
|
||||||
mAdapter.getFilter().filter(search, new Filter.FilterListener()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public void onFilterComplete(int count)
|
|
||||||
{
|
|
||||||
setCheckState();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class InstalledPackagesLoader extends AsyncTaskLoader<Pair<List<SelectedApplicationEntry>, List<String>>>
|
|
||||||
{
|
|
||||||
private final PackageManager mPackageManager;
|
|
||||||
private final SortedSet<String> mSelection;
|
|
||||||
private Pair<List<SelectedApplicationEntry>, List<String>> mData;
|
|
||||||
|
|
||||||
public InstalledPackagesLoader(Context context, SortedSet<String> selection)
|
|
||||||
{
|
|
||||||
super(context);
|
|
||||||
mPackageManager = context.getPackageManager();
|
|
||||||
mSelection = selection;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Pair<List<SelectedApplicationEntry>, List<String>> loadInBackground()
|
|
||||||
{
|
|
||||||
List<SelectedApplicationEntry> apps = new ArrayList<>();
|
|
||||||
SortedSet<String> seen = new TreeSet<>();
|
|
||||||
for (ApplicationInfo info : mPackageManager.getInstalledApplications(PackageManager.GET_META_DATA))
|
|
||||||
{
|
|
||||||
/* skip apps that can't access the network anyway */
|
|
||||||
if (mPackageManager.checkPermission(Manifest.permission.INTERNET, info.packageName) == PackageManager.PERMISSION_GRANTED)
|
|
||||||
{
|
|
||||||
SelectedApplicationEntry entry = new SelectedApplicationEntry(mPackageManager, info);
|
|
||||||
entry.setSelected(mSelection.contains(info.packageName));
|
|
||||||
apps.add(entry);
|
|
||||||
seen.add(info.packageName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Collections.sort(apps);
|
|
||||||
/* check for selected packages that don't exist anymore */
|
|
||||||
List<String> missing = new ArrayList<>();
|
|
||||||
for (String pkg : mSelection)
|
|
||||||
{
|
|
||||||
if (!seen.contains(pkg))
|
|
||||||
{
|
|
||||||
missing.add(pkg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return new Pair<>(apps, missing);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onStartLoading()
|
|
||||||
{
|
|
||||||
if (mData != null)
|
|
||||||
{ /* if we have data ready, deliver it directly */
|
|
||||||
deliverResult(mData);
|
|
||||||
}
|
|
||||||
if (takeContentChanged() || mData == null)
|
|
||||||
{
|
|
||||||
forceLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void deliverResult(Pair<List<SelectedApplicationEntry>, List<String>> data)
|
|
||||||
{
|
|
||||||
if (isReset())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mData = data;
|
|
||||||
if (isStarted())
|
|
||||||
{ /* if it is started we deliver the data directly,
|
|
||||||
* otherwise this is handled in onStartLoading */
|
|
||||||
super.deliverResult(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onReset()
|
|
||||||
{
|
|
||||||
mData = null;
|
|
||||||
super.onReset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,818 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxDialogButton
|
||||||
|
import org.strongswan.android.data.VpnProfile
|
||||||
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
|
import org.strongswan.android.data.VpnProfileSource
|
||||||
|
import org.strongswan.android.data.VpnType
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.logic.connectivity.ConnectivityProbe
|
||||||
|
import org.strongswan.android.logic.profiles.ProfileCheck
|
||||||
|
import org.strongswan.android.logic.profiles.ProfileExtra
|
||||||
|
import org.strongswan.android.logic.profiles.ProfileExtras
|
||||||
|
import org.strongswan.android.logic.profiles.ProfileSourceType
|
||||||
|
import org.strongswan.android.logic.profiles.ProfileUpdater
|
||||||
|
import org.strongswan.android.logic.profiles.needsPassword
|
||||||
|
import org.strongswan.android.logic.profiles.RESERVE_TIMEOUT_S
|
||||||
|
import org.strongswan.android.ui.compose.ProfileQr
|
||||||
|
import org.strongswan.android.ui.compose.ShareProfileScreen
|
||||||
|
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxDialog
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSwitch
|
||||||
|
import org.strongswan.android.ui.compose.home.serverCode
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import org.strongswan.android.utils.Constants
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** One server (F_Profile): source and updates, server, resilience and network settings. */
|
||||||
|
class ServerActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
val uuid = intent.getStringExtra(VpnProfileDataSource.KEY_UUID)
|
||||||
|
if (uuid == null)
|
||||||
|
{
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val checkNow = intent.getBooleanExtra(EXTRA_CHECK_UPDATE, false)
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
ServerScreen(uuid, checkNow, onBack = { finish() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
private const val EXTRA_CHECK_UPDATE = "org.strongswan.android.extra.CHECK_UPDATE"
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@JvmOverloads
|
||||||
|
fun intent(context: Context, uuid: String, checkUpdate: Boolean = false): Intent =
|
||||||
|
Intent(context, ServerActivity::class.java)
|
||||||
|
.putExtra(VpnProfileDataSource.KEY_UUID, uuid)
|
||||||
|
.putExtra(EXTRA_CHECK_UPDATE, checkUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadProfiles(context: Context): List<VpnProfile>
|
||||||
|
{
|
||||||
|
val source = VpnProfileSource(context)
|
||||||
|
source.open()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return source.allVpnProfiles
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* which picker sheet is open */
|
||||||
|
private enum class Picker { RESERVE, BRIDGE }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ServerScreen(uuid: String, checkOnOpen: Boolean, onBack: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
var reloads by remember { mutableIntStateOf(0) }
|
||||||
|
val owner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(owner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) reloads++ }
|
||||||
|
owner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
val profiles by produceState<List<VpnProfile>?>(null, reloads) { value = withContext(Dispatchers.IO) { loadProfiles(context) } }
|
||||||
|
val all = profiles ?: return
|
||||||
|
val profile = all.firstOrNull { it.getUUID().toString() == uuid }
|
||||||
|
if (profile == null)
|
||||||
|
{
|
||||||
|
LaunchedEffect(Unit) { onBack() }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val extras by remember { ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
|
||||||
|
val extra = extras[uuid] ?: ProfileExtra(uuid)
|
||||||
|
/* the live state of the tunnel and a direct ping of the server */
|
||||||
|
val vpn by produceState<Pair<VpnStateService.State, String?>?>(null, owner) {
|
||||||
|
/* only while the page is on screen */
|
||||||
|
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
val service = VpnStateService.getLiveInstance()
|
||||||
|
value = service?.let { it.state to it.profile?.getUUID()?.toString() }
|
||||||
|
delay(1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val rtt by produceState<Long?>(null, profile.gateway, owner) {
|
||||||
|
owner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
value = withContext(Dispatchers.IO) { ConnectivityProbe.pingServerDirect(context, profile.gateway, 1500)?.first }
|
||||||
|
delay(30_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val active = vpn?.second == uuid
|
||||||
|
val connected = active && vpn?.first == VpnStateService.State.CONNECTED
|
||||||
|
|
||||||
|
var checking by remember { mutableStateOf(false) }
|
||||||
|
var check by remember { mutableStateOf<ProfileCheck?>(null) }
|
||||||
|
var showShare by remember { mutableStateOf(false) }
|
||||||
|
var showDelete by remember { mutableStateOf(false) }
|
||||||
|
var showLink by remember { mutableStateOf(false) }
|
||||||
|
var showPorts by remember { mutableStateOf(false) }
|
||||||
|
var picker by remember { mutableStateOf<Picker?>(null) }
|
||||||
|
val noChanges = stringResource(R.string.profile_check_none)
|
||||||
|
val failed = stringResource(R.string.profile_check_failed)
|
||||||
|
val updated = stringResource(R.string.profile_updated)
|
||||||
|
|
||||||
|
fun runCheck()
|
||||||
|
{
|
||||||
|
if (checking) return
|
||||||
|
checking = true
|
||||||
|
scope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) { runCatching { ProfileUpdater.check(context, uuid) } }
|
||||||
|
checking = false
|
||||||
|
result.onSuccess { if (it.changes.isEmpty()) snackbar.showSnackbar(noChanges) else check = it }
|
||||||
|
result.onFailure {
|
||||||
|
ProfileExtras.update(context, uuid) { e -> e.copy(checkedAt = System.currentTimeMillis(), lastChanges = -1) }
|
||||||
|
snackbar.showSnackbar(failed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(checkOnOpen) { if (checkOnOpen && extra.source == ProfileSourceType.LINK) runCheck() }
|
||||||
|
|
||||||
|
fun edit() = context.startActivity(Intent(context, VpnProfileDetailActivity::class.java).putExtra(VpnProfileDataSource.KEY_UUID, uuid))
|
||||||
|
fun export() = scope.launch {
|
||||||
|
val file = withContext(Dispatchers.IO) { org.strongswan.android.logic.VpnProfileExporter.exportProfile(context, profile, false) }
|
||||||
|
if (file == null)
|
||||||
|
{
|
||||||
|
snackbar.showSnackbar(context.getString(R.string.share_profile_failed))
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val send = Intent(Intent.ACTION_SEND).setType(org.strongswan.android.data.ProfileExportProvider.MIME_TYPE).putExtra(Intent.EXTRA_STREAM, file)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.startActivity(Intent.createChooser(send, context.getString(R.string.share_profile)))
|
||||||
|
}
|
||||||
|
catch (_: ActivityNotFoundException)
|
||||||
|
{
|
||||||
|
snackbar.showSnackbar(context.getString(R.string.share_profile_failed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||||
|
result.contents?.let { text -> ProfileQr.importIntent(context, text)?.let { context.startActivity(it) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize().background(c.background))
|
||||||
|
{
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()).navigationBarsPadding()
|
||||||
|
.padding(bottom = 24.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
/* header: back, «Share» pill */
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 8.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onBack))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Back, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
Spacer(Modifier.weight(1f))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).background(c.accentContainer)
|
||||||
|
.clickable(role = Role.Button) { showShare = true }.padding(horizontal = 14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Scan, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
|
||||||
|
Text(stringResource(R.string.server_share), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
|
{
|
||||||
|
/* who: avatar, name, state */
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 6.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(CircleShape).background(c.accent))
|
||||||
|
{
|
||||||
|
Text(serverCode(profile, null), style = ShxType.cardHeading.copy(fontSize = 18.sp), color = Color.White)
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
|
||||||
|
{
|
||||||
|
Text(profile.name ?: profile.gateway, style = ShxType.screenTitle, color = c.text)
|
||||||
|
val (dot, textColor, label) = when
|
||||||
|
{
|
||||||
|
connected -> Triple(c.success, c.onSuccess, rtt?.let { stringResource(R.string.server_state_connected_ms, it.toInt()) } ?: stringResource(R.string.server_state_connected))
|
||||||
|
active && vpn?.first == VpnStateService.State.CONNECTING -> Triple(c.accent, c.accentText, stringResource(R.string.server_state_connecting))
|
||||||
|
rtt != null -> Triple(c.textSub, c.textSub, stringResource(R.string.server_state_answers, rtt!!.toInt()))
|
||||||
|
else -> Triple(c.warning, c.onWarningContainer, stringResource(R.string.server_state_no_answer))
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
Box(Modifier.size(8.dp).clip(CircleShape).background(dot))
|
||||||
|
Text(label, style = ShxType.chip, color = textColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceSection(
|
||||||
|
extra = extra,
|
||||||
|
checking = checking,
|
||||||
|
onEditLink = { showLink = true },
|
||||||
|
onAuto = { on -> ProfileExtras.update(context, uuid) { it.copy(autoUpdate = on) } },
|
||||||
|
onCheck = ::runCheck,
|
||||||
|
onFile = { context.startActivity(Intent(context, VpnProfileImportActivity::class.java)) },
|
||||||
|
onQr = { scanner.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE).setBeepEnabled(false).setOrientationLocked(true).setPrompt("")) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val others = all.filter { it.getUUID().toString() != uuid }
|
||||||
|
fun nameOf(other: String?) = others.firstOrNull { it.getUUID().toString() == other }?.let { it.name ?: it.gateway }
|
||||||
|
/* a reserve or bridge that signs in with an unsaved password cannot be switched to */
|
||||||
|
fun lacksPassword(other: String?) = others.firstOrNull { it.getUUID().toString() == other }?.needsPassword() ?: false
|
||||||
|
Group(stringResource(R.string.server_group_server))
|
||||||
|
{
|
||||||
|
ValueRow(stringResource(R.string.server_addr), profile.gateway + (profile.port?.let { ":$it" } ?: ""), onClick = ::edit, divider = true)
|
||||||
|
ValueRow(stringResource(R.string.server_login), stringResource(loginLabel(profile.vpnType)), onClick = ::edit)
|
||||||
|
}
|
||||||
|
Group(stringResource(R.string.server_group_resilience))
|
||||||
|
{
|
||||||
|
ValueRow(
|
||||||
|
stringResource(R.string.server_ports), extra.backupPorts.joinToString(", ").ifEmpty { stringResource(R.string.server_none) },
|
||||||
|
sub = stringResource(R.string.server_ports_sub), onClick = { showPorts = true }, divider = true,
|
||||||
|
)
|
||||||
|
ValueRow(
|
||||||
|
stringResource(R.string.server_reserve), nameOf(extra.reserveUuid) ?: stringResource(R.string.server_none),
|
||||||
|
sub = stringResource(R.string.server_reserve_sub, RESERVE_TIMEOUT_S), onClick = { picker = Picker.RESERVE },
|
||||||
|
divider = !lacksPassword(extra.reserveUuid),
|
||||||
|
)
|
||||||
|
if (lacksPassword(extra.reserveUuid))
|
||||||
|
{
|
||||||
|
PasswordWarning(divider = true)
|
||||||
|
}
|
||||||
|
val bridgeName = nameOf(extra.bridgeUuid)
|
||||||
|
ToggleRow(
|
||||||
|
stringResource(R.string.server_bridge),
|
||||||
|
if (bridgeName != null) stringResource(R.string.server_bridge_sub, bridgeName) else stringResource(R.string.server_bridge_pick),
|
||||||
|
extra.bridgeEnabled && bridgeName != null,
|
||||||
|
) { on ->
|
||||||
|
if (on && bridgeName == null) picker = Picker.BRIDGE
|
||||||
|
else ProfileExtras.update(context, uuid) { it.copy(bridgeEnabled = on) }
|
||||||
|
}
|
||||||
|
if (extra.bridgeEnabled && lacksPassword(extra.bridgeUuid))
|
||||||
|
{
|
||||||
|
PasswordWarning(divider = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Group(stringResource(R.string.server_group_network))
|
||||||
|
{
|
||||||
|
ValueRow("DNS", profile.dnsServers?.takeIf { it.isNotBlank() }?.replace(" ", ", ") ?: stringResource(R.string.server_dns_default), onClick = ::edit, divider = true)
|
||||||
|
ValueRow("MTU", profile.mtu?.toString() ?: stringResource(R.string.server_auto_value), onClick = ::edit, divider = true)
|
||||||
|
ToggleRow(stringResource(R.string.server_fragmentation), null, extra.ikeFragmentation) { on ->
|
||||||
|
ProfileExtras.update(context, uuid) { it.copy(ikeFragmentation = on) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.padding(top = 4.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.server_export), { export() }, style = ShxButtonStyle.OUTLINE, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.weight(1f).height(52.dp).clip(RoundedCornerShape(18.dp)).border(1.dp, c.errorBorder, RoundedCornerShape(18.dp))
|
||||||
|
.clickable(role = Role.Button) { showDelete = true },
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.server_delete_confirm), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxSnackbarHost(snackbar, Modifier.align(Alignment.BottomCenter).navigationBarsPadding())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showShare)
|
||||||
|
{
|
||||||
|
ShareProfileScreen(profile, onDismiss = { showShare = false }, onSendFile = { export() })
|
||||||
|
}
|
||||||
|
check?.let { found ->
|
||||||
|
ProfileUpdateSheet(
|
||||||
|
name = profile.name ?: profile.gateway,
|
||||||
|
check = found,
|
||||||
|
onLater = { check = null },
|
||||||
|
onApply = {
|
||||||
|
check = null
|
||||||
|
scope.launch {
|
||||||
|
withContext(Dispatchers.IO) { ProfileUpdater.apply(context, found) }
|
||||||
|
reloads++
|
||||||
|
snackbar.showSnackbar(updated)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showDelete)
|
||||||
|
{
|
||||||
|
ShxDialog(
|
||||||
|
title = stringResource(R.string.server_delete_title, profile.name ?: profile.gateway),
|
||||||
|
paragraphs = listOfNotNull(
|
||||||
|
if (active && vpn?.first != VpnStateService.State.DISABLED) stringResource(R.string.server_delete_current) else null,
|
||||||
|
stringResource(R.string.server_delete_restore),
|
||||||
|
),
|
||||||
|
confirm = stringResource(R.string.server_delete_confirm),
|
||||||
|
dismiss = stringResource(android.R.string.cancel),
|
||||||
|
onConfirm = {
|
||||||
|
showDelete = false
|
||||||
|
scope.launch {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
if (active) VpnStateService.getLiveInstance()?.disconnect()
|
||||||
|
val source = VpnProfileSource(context)
|
||||||
|
source.open()
|
||||||
|
try { source.deleteVpnProfile(profile) } finally { source.close() }
|
||||||
|
}
|
||||||
|
val intent = Intent(Constants.VPN_PROFILES_CHANGED).putExtra(Constants.VPN_PROFILES_MULTIPLE, arrayOf(uuid))
|
||||||
|
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDismiss = { showDelete = false },
|
||||||
|
icon = ShxIcons.Trash,
|
||||||
|
danger = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showLink)
|
||||||
|
{
|
||||||
|
TextDialog(
|
||||||
|
title = stringResource(R.string.server_link_title),
|
||||||
|
label = stringResource(R.string.server_link_label),
|
||||||
|
initial = extra.sourceValue?.takeIf { extra.source == ProfileSourceType.LINK } ?: "https://",
|
||||||
|
keyboard = KeyboardType.Uri,
|
||||||
|
validate = { it.startsWith("https://") && it.length > 10 },
|
||||||
|
onDone = { link ->
|
||||||
|
showLink = false
|
||||||
|
ProfileExtras.update(context, uuid) { it.copy(source = ProfileSourceType.LINK, sourceValue = link.trim(), autoUpdate = true) }
|
||||||
|
runCheck()
|
||||||
|
},
|
||||||
|
onDismiss = { showLink = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (showPorts)
|
||||||
|
{
|
||||||
|
TextDialog(
|
||||||
|
title = stringResource(R.string.server_ports),
|
||||||
|
label = stringResource(R.string.server_ports_label),
|
||||||
|
initial = extra.backupPorts.joinToString(", "),
|
||||||
|
keyboard = KeyboardType.Number,
|
||||||
|
validate = { text -> parsePorts(text) != null },
|
||||||
|
onDone = { text ->
|
||||||
|
showPorts = false
|
||||||
|
ProfileExtras.update(context, uuid) { it.copy(backupPorts = parsePorts(text).orEmpty()) }
|
||||||
|
},
|
||||||
|
onDismiss = { showPorts = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
picker?.let { which ->
|
||||||
|
val others = all.filter { it.getUUID().toString() != uuid }
|
||||||
|
ShxSheet(onDismiss = { picker = null })
|
||||||
|
{
|
||||||
|
ShxSheetTitle(stringResource(if (which == Picker.RESERVE) R.string.server_reserve else R.string.server_bridge_title))
|
||||||
|
Text(
|
||||||
|
stringResource(if (which == Picker.RESERVE) R.string.server_reserve_pick_note else R.string.server_bridge_pick_note),
|
||||||
|
style = ShxType.note.copy(fontWeight = FontWeight.Normal, lineHeight = 19.sp), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
val current = if (which == Picker.RESERVE) extra.reserveUuid else extra.bridgeUuid
|
||||||
|
val rows = listOf<VpnProfile?>(null) + others
|
||||||
|
rows.forEachIndexed { i, other ->
|
||||||
|
val id = other?.getUUID()?.toString()
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable(role = Role.Button) {
|
||||||
|
picker = null
|
||||||
|
ProfileExtras.update(context, uuid) {
|
||||||
|
if (which == Picker.RESERVE) it.copy(reserveUuid = id) else it.copy(bridgeUuid = id, bridgeEnabled = id != null)
|
||||||
|
}
|
||||||
|
}.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(other?.let { it.name ?: it.gateway } ?: stringResource(R.string.server_none), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
if (current == id)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i < rows.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «No saved password» note under a reserve / bridge row. */
|
||||||
|
@Composable
|
||||||
|
private fun PasswordWarning(divider: Boolean)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 14.dp, bottom = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Alert, contentDescription = null, tint = c.onWarningContainer, modifier = Modifier.size(16.dp))
|
||||||
|
Text(stringResource(R.string.server_needs_password), style = ShxType.meta.copy(fontWeight = FontWeight.SemiBold, lineHeight = 16.sp), color = c.onWarningContainer)
|
||||||
|
}
|
||||||
|
if (divider)
|
||||||
|
{
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «443, 53» → [443, 53]; null when something is not a port. */
|
||||||
|
internal fun parsePorts(text: String): List<Int>?
|
||||||
|
{
|
||||||
|
val parts = text.split(',', ' ', ';').map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
val ports = parts.map { it.toIntOrNull()?.takeIf { p -> p in 1..65535 } ?: return null }
|
||||||
|
return ports.distinct()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loginLabel(type: VpnType): Int = when (type)
|
||||||
|
{
|
||||||
|
VpnType.IKEV2_CERT, VpnType.IKEV2_EAP_TLS -> R.string.server_login_cert
|
||||||
|
VpnType.IKEV2_CERT_EAP -> R.string.server_login_both
|
||||||
|
else -> R.string.server_login_password
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SourceSection(
|
||||||
|
extra: ProfileExtra,
|
||||||
|
checking: Boolean,
|
||||||
|
onEditLink: () -> Unit,
|
||||||
|
onAuto: (Boolean) -> Unit,
|
||||||
|
onCheck: () -> Unit,
|
||||||
|
onFile: () -> Unit,
|
||||||
|
onQr: () -> Unit,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val date = if (extra.addedAt > 0) SimpleDateFormat("d MMM", Locale.getDefault()).format(Date(extra.addedAt)) else null
|
||||||
|
val type = if (extra.source == ProfileSourceType.UNKNOWN) ProfileSourceType.FILE else extra.source
|
||||||
|
ShxSectionHeader(stringResource(R.string.server_source).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
val (icon, label, value) = when (type)
|
||||||
|
{
|
||||||
|
ProfileSourceType.LINK -> Triple(ShxIcons.Globe, stringResource(R.string.server_source_link), extra.sourceValue.orEmpty().removePrefix("https://"))
|
||||||
|
ProfileSourceType.QR -> Triple(ShxIcons.Scan, stringResource(R.string.server_source_qr), date?.let { stringResource(R.string.server_source_scanned, it) } ?: "")
|
||||||
|
ProfileSourceType.MANUAL -> Triple(ShxIcons.Sliders, stringResource(R.string.server_source_manual), date?.let { stringResource(R.string.server_source_created, it) } ?: "")
|
||||||
|
else -> Triple(
|
||||||
|
ShxIcons.Doc, stringResource(R.string.server_source_file),
|
||||||
|
listOfNotNull(extra.sourceValue, date?.let { stringResource(R.string.server_source_added, it) }).joinToString(" · ")
|
||||||
|
.ifEmpty { stringResource(R.string.server_source_earlier) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.then(if (type == ProfileSourceType.LINK) Modifier.clickable(role = Role.Button, onClick = onEditLink) else Modifier)
|
||||||
|
.padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(c.tile))
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(label, style = ShxType.chip, color = c.textSub)
|
||||||
|
Text(
|
||||||
|
value, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
|
style = ShxType.body.copy(fontWeight = FontWeight.Bold, fontFamily = if (type == ProfileSourceType.LINK) FontFamily.Monospace else ShxType.body.fontFamily),
|
||||||
|
color = c.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (type == ProfileSourceType.LINK)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Sliders, contentDescription = stringResource(R.string.server_link_title), tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
if (type == ProfileSourceType.LINK)
|
||||||
|
{
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val checked = when
|
||||||
|
{
|
||||||
|
extra.checkedAt <= 0 -> null
|
||||||
|
now - extra.checkedAt < DateUtils.MINUTE_IN_MILLIS -> stringResource(R.string.server_just_now)
|
||||||
|
else -> DateUtils.getRelativeTimeSpanString(extra.checkedAt, now, DateUtils.MINUTE_IN_MILLIS).toString()
|
||||||
|
}
|
||||||
|
val result = when
|
||||||
|
{
|
||||||
|
extra.lastChanges < 0 -> stringResource(R.string.server_auto_failed)
|
||||||
|
extra.lastChanges > 0 -> stringResource(R.string.server_auto_changes, extra.lastChanges)
|
||||||
|
else -> stringResource(R.string.server_auto_same)
|
||||||
|
}
|
||||||
|
ToggleRow(
|
||||||
|
stringResource(R.string.server_auto),
|
||||||
|
listOfNotNull(stringResource(R.string.server_auto_daily), checked?.let { stringResource(R.string.server_auto_checked, it) }, if (checked != null) result else null).joinToString(" · "),
|
||||||
|
extra.autoUpdate, onAuto,
|
||||||
|
)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
when (type)
|
||||||
|
{
|
||||||
|
ProfileSourceType.QR -> R.string.server_note_qr
|
||||||
|
ProfileSourceType.MANUAL -> R.string.server_note_manual
|
||||||
|
else -> R.string.server_note_file
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
if (type != ProfileSourceType.MANUAL)
|
||||||
|
{
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type != ProfileSourceType.MANUAL)
|
||||||
|
{
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min).padding(horizontal = 14.dp, vertical = 12.dp))
|
||||||
|
{
|
||||||
|
when (type)
|
||||||
|
{
|
||||||
|
ProfileSourceType.LINK -> SoftButton(stringResource(if (checking) R.string.server_checking else R.string.server_check_now), onCheck, Modifier.weight(1f))
|
||||||
|
ProfileSourceType.QR -> SoftButton(stringResource(R.string.server_replace_qr), onQr, Modifier.weight(1f))
|
||||||
|
else ->
|
||||||
|
{
|
||||||
|
SoftButton(stringResource(R.string.server_replace_file), onFile, Modifier.weight(1f))
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.weight(1f).heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).border(1.dp, c.outline, RoundedCornerShape(14.dp))
|
||||||
|
.clickable(role = Role.Button, onClick = onEditLink).padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.server_set_link), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SoftButton(text: String, onClick: () -> Unit, modifier: Modifier)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = modifier.heightIn(min = 44.dp).clip(RoundedCornerShape(14.dp)).background(c.accentMuted).clickable(role = Role.Button, onClick = onClick)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = c.textTonal, maxLines = 2, textAlign = androidx.compose.ui.text.style.TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Divider()
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(LocalShxColors.current.tile))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Group(title: String, content: @Composable ColumnScope.() -> Unit)
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
ShxSectionHeader(title.uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
|
||||||
|
ShxPanel(content = content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ValueRow(title: String, value: String, sub: String? = null, onClick: () -> Unit, divider: Boolean = false)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
|
||||||
|
sub?.let { Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub) }
|
||||||
|
}
|
||||||
|
Text(value, style = ShxType.body, color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(0.8f, fill = false))
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
if (divider)
|
||||||
|
{
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ToggleRow(title: String, sub: String?, checked: Boolean, onChange: (Boolean) -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp).padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
|
||||||
|
sub?.let { Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub) }
|
||||||
|
}
|
||||||
|
ShxSwitch(checked, onChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextDialog(
|
||||||
|
title: String,
|
||||||
|
label: String,
|
||||||
|
initial: String,
|
||||||
|
keyboard: KeyboardType,
|
||||||
|
validate: (String) -> Boolean,
|
||||||
|
onDone: (String) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var text by remember { mutableStateOf(initial) }
|
||||||
|
ShxAlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = {
|
||||||
|
ShxFormField(label = label, value = text, onValueChange = { text = it }, keyboardOptions = KeyboardOptions(keyboardType = keyboard))
|
||||||
|
},
|
||||||
|
confirmButton = { ShxDialogButton(primary = true, onClick = { onDone(text) }, enabled = validate(text.trim())) { Text(stringResource(R.string.server_save)) } },
|
||||||
|
dismissButton = { ShxDialogButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «The profile has changes» (F_ProfileUpdate): old → new per field, «Later» / «Apply and reconnect». */
|
||||||
|
@Composable
|
||||||
|
private fun ProfileUpdateSheet(name: String, check: ProfileCheck, onLater: () -> Unit, onApply: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
ShxSheet(onDismiss = onLater)
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.profile_update_title), style = ShxType.sheetTitle, color = c.text)
|
||||||
|
Text(stringResource(R.string.profile_update_sub, name), style = ShxType.body.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
check.changes.forEachIndexed { i, change ->
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp))
|
||||||
|
{
|
||||||
|
Text(change.label, style = ShxType.chip, color = c.textSub)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
val mono = ShxType.body.copy(fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace)
|
||||||
|
Text(change.old, style = mono.copy(textDecoration = TextDecoration.LineThrough), color = c.iconMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(16.dp))
|
||||||
|
Text(change.new, style = mono, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i < check.changes.lastIndex)
|
||||||
|
{
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
|
||||||
|
Text(stringResource(R.string.profile_update_keep), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onSuccess)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.cert_later), onLater, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.profile_update_apply), onApply, modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2018 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.view.MenuItem;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
|
||||||
import androidx.core.view.WindowCompat;
|
|
||||||
|
|
||||||
public class SettingsActivity extends AppCompatActivity
|
|
||||||
{
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onCreate(Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
setContentView(R.layout.settings_activity);
|
|
||||||
WindowCompat.enableEdgeToEdge(getWindow());
|
|
||||||
|
|
||||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
|
||||||
|
|
||||||
if (savedInstanceState == null)
|
|
||||||
{
|
|
||||||
getSupportFragmentManager().beginTransaction()
|
|
||||||
.setReorderingAllowed(true)
|
|
||||||
.add(R.id.fragment_container, SettingsFragment.class, null)
|
|
||||||
.commit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onOptionsItemSelected(MenuItem item)
|
|
||||||
{
|
|
||||||
switch (item.getItemId())
|
|
||||||
{
|
|
||||||
case android.R.id.home:
|
|
||||||
finish();
|
|
||||||
return true;
|
|
||||||
default:
|
|
||||||
return super.onOptionsItemSelected(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.data.VpnProfile
|
||||||
|
import org.strongswan.android.data.VpnProfileSource
|
||||||
|
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||||
|
import org.strongswan.android.ui.compose.SettingsScreen
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
class SettingsActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
/* the profile list is only needed for the default-profile picker;
|
||||||
|
* load it off the main thread */
|
||||||
|
val profiles by produceState<List<VpnProfile>>(emptyList(), this@SettingsActivity) {
|
||||||
|
value = withContext(Dispatchers.IO) {
|
||||||
|
val source = VpnProfileSource(this@SettingsActivity).open()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
source.allVpnProfiles
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SecondaryScaffold(title = stringResource(R.string.pref_title), onBack = { finish() }) { padding ->
|
||||||
|
Box(Modifier.padding(padding)) {
|
||||||
|
SettingsScreen(profiles = profiles, showTitle = false)
|
||||||
|
}
|
||||||
|
org.strongswan.android.ui.compose.UpdateSheetHost()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-155
@@ -1,155 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2018 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import static org.strongswan.android.utils.Constants.PREF_DEFAULT_VPN_PROFILE;
|
|
||||||
import static org.strongswan.android.utils.Constants.PREF_DEFAULT_VPN_PROFILE_MRU;
|
|
||||||
import static org.strongswan.android.utils.Constants.PREF_IGNORE_POWER_WHITELIST;
|
|
||||||
|
|
||||||
import android.content.SharedPreferences;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
import org.strongswan.android.data.ManagedConfiguration;
|
|
||||||
import org.strongswan.android.data.ManagedConfigurationService;
|
|
||||||
import org.strongswan.android.data.VpnProfile;
|
|
||||||
import org.strongswan.android.data.VpnProfileDataSource;
|
|
||||||
import org.strongswan.android.data.VpnProfileSource;
|
|
||||||
import org.strongswan.android.logic.StrongSwanApplication;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import androidx.preference.ListPreference;
|
|
||||||
import androidx.preference.Preference;
|
|
||||||
import androidx.preference.PreferenceFragmentCompat;
|
|
||||||
import androidx.preference.PreferenceManager;
|
|
||||||
import androidx.preference.SwitchPreference;
|
|
||||||
|
|
||||||
public class SettingsFragment extends PreferenceFragmentCompat implements Preference.OnPreferenceChangeListener
|
|
||||||
{
|
|
||||||
private ManagedConfigurationService mManagedConfigurationService;
|
|
||||||
|
|
||||||
private ListPreference mDefaultVPNProfile;
|
|
||||||
private SwitchPreference mIgnorePowerWhitelist;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreatePreferences(Bundle bundle, String s)
|
|
||||||
{
|
|
||||||
mManagedConfigurationService = StrongSwanApplication.getInstance().getManagedConfigurationService();
|
|
||||||
mManagedConfigurationService.updateSettings();
|
|
||||||
|
|
||||||
setPreferencesFromResource(R.xml.settings, s);
|
|
||||||
|
|
||||||
mDefaultVPNProfile = findPreference(PREF_DEFAULT_VPN_PROFILE);
|
|
||||||
mDefaultVPNProfile.setOnPreferenceChangeListener(this);
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N)
|
|
||||||
{
|
|
||||||
mDefaultVPNProfile.setEnabled(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
mIgnorePowerWhitelist = findPreference(PREF_IGNORE_POWER_WHITELIST);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onResume()
|
|
||||||
{
|
|
||||||
super.onResume();
|
|
||||||
|
|
||||||
VpnProfileDataSource profiles = new VpnProfileSource(getActivity());
|
|
||||||
profiles.open();
|
|
||||||
|
|
||||||
List<VpnProfile> all = profiles.getAllVpnProfiles();
|
|
||||||
Collections.sort(all, new Comparator<VpnProfile>()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public int compare(VpnProfile lhs, VpnProfile rhs)
|
|
||||||
{
|
|
||||||
return lhs.getName().compareToIgnoreCase(rhs.getName());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ArrayList<CharSequence> entries = new ArrayList<>();
|
|
||||||
ArrayList<CharSequence> entryvalues = new ArrayList<>();
|
|
||||||
|
|
||||||
entries.add(getString(R.string.pref_default_vpn_profile_mru));
|
|
||||||
entryvalues.add(PREF_DEFAULT_VPN_PROFILE_MRU);
|
|
||||||
|
|
||||||
for (VpnProfile profile : all)
|
|
||||||
{
|
|
||||||
entries.add(profile.getName());
|
|
||||||
entryvalues.add(profile.getUUID().toString());
|
|
||||||
}
|
|
||||||
profiles.close();
|
|
||||||
|
|
||||||
final ManagedConfiguration managedConfiguration = mManagedConfigurationService.getManagedConfiguration();
|
|
||||||
if (entries.size() <= 1 || !managedConfiguration.isAllowSettingsAccess())
|
|
||||||
{
|
|
||||||
mDefaultVPNProfile.setEnabled(false);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mDefaultVPNProfile.setEnabled(true);
|
|
||||||
mDefaultVPNProfile.setEntries(entries.toArray(new CharSequence[0]));
|
|
||||||
mDefaultVPNProfile.setEntryValues(entryvalues.toArray(new CharSequence[0]));
|
|
||||||
}
|
|
||||||
if (!managedConfiguration.isAllowSettingsAccess())
|
|
||||||
{
|
|
||||||
mIgnorePowerWhitelist.setEnabled(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(getActivity());
|
|
||||||
setCurrentProfileName(pref.getString(PREF_DEFAULT_VPN_PROFILE, PREF_DEFAULT_VPN_PROFILE_MRU));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onPreferenceChange(Preference preference, Object newValue)
|
|
||||||
{
|
|
||||||
if (preference == mDefaultVPNProfile)
|
|
||||||
{
|
|
||||||
setCurrentProfileName((String)newValue);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setCurrentProfileName(String uuid)
|
|
||||||
{
|
|
||||||
VpnProfileDataSource profiles = new VpnProfileSource(getActivity());
|
|
||||||
profiles.open();
|
|
||||||
|
|
||||||
if (!uuid.equals(PREF_DEFAULT_VPN_PROFILE_MRU))
|
|
||||||
{
|
|
||||||
VpnProfile current = profiles.getVpnProfile(uuid);
|
|
||||||
if (current != null)
|
|
||||||
{
|
|
||||||
mDefaultVPNProfile.setSummary(current.getName());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mDefaultVPNProfile.setSummary(R.string.profile_not_found);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mDefaultVPNProfile.setSummary(R.string.pref_default_vpn_profile_mru);
|
|
||||||
}
|
|
||||||
profiles.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 SHX VPN
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
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.layout.wrapContentWidth
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.drawBehind
|
||||||
|
import androidx.compose.ui.geometry.CornerRadius
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.routing.AppTraffic
|
||||||
|
import org.strongswan.android.logic.routing.RoutingManager
|
||||||
|
import org.strongswan.android.logic.stats.DayTraffic
|
||||||
|
import org.strongswan.android.logic.stats.TrafficHistory
|
||||||
|
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTabs
|
||||||
|
import org.strongswan.android.ui.compose.formatBytes
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Calendar
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/** «Statistics» (F_Stats / F_StatsEmpty): traffic per day, VPN vs direct, top apps. */
|
||||||
|
class StatsActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
StatsScreen(onBack = { finish() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class Period(val label: Int) { DAY(R.string.stats_day), WEEK(R.string.stats_week), MONTH(R.string.stats_month) }
|
||||||
|
|
||||||
|
/** One app row: label, icon, bytes and whether it goes direct. */
|
||||||
|
private class AppRow(val label: String, val icon: android.graphics.drawable.Drawable?, val bytes: Long, val direct: Boolean)
|
||||||
|
|
||||||
|
private class StatsData(val days: List<DayTraffic>, val title: String, val labels: List<String>, val highlight: Int, val first: String?, val apps: List<AppRow>, val usage: Boolean)
|
||||||
|
|
||||||
|
private fun startOfDay(ms: Long): Long = Calendar.getInstance().apply {
|
||||||
|
timeInMillis = ms; set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
|
||||||
|
private fun load(context: Context, period: Period): StatsData
|
||||||
|
{
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val day = 24L * 60 * 60 * 1000
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
val (from, to) = when (period)
|
||||||
|
{
|
||||||
|
/* the day view still shows the last week, today highlighted */
|
||||||
|
Period.DAY -> startOfDay(now) - 6 * day to now
|
||||||
|
Period.WEEK ->
|
||||||
|
{
|
||||||
|
cal.timeInMillis = startOfDay(now)
|
||||||
|
cal.firstDayOfWeek = Calendar.MONDAY
|
||||||
|
cal.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
|
||||||
|
if (cal.timeInMillis > now) cal.add(Calendar.WEEK_OF_YEAR, -1)
|
||||||
|
cal.timeInMillis to cal.timeInMillis + 6 * day
|
||||||
|
}
|
||||||
|
Period.MONTH ->
|
||||||
|
{
|
||||||
|
cal.timeInMillis = startOfDay(now)
|
||||||
|
cal.set(Calendar.DAY_OF_MONTH, 1)
|
||||||
|
val start = cal.timeInMillis
|
||||||
|
start to start + (cal.getActualMaximum(Calendar.DAY_OF_MONTH) - 1) * day
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val days = TrafficHistory.range(context, from, to)
|
||||||
|
val dow = SimpleDateFormat("EEEEEE", Locale.getDefault())
|
||||||
|
val parse = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
val labels = days.mapIndexed { i, d ->
|
||||||
|
val date = parse.parse(d.day)!!
|
||||||
|
when (period)
|
||||||
|
{
|
||||||
|
Period.MONTH -> if (i % 5 == 0) (i + 1).toString() else ""
|
||||||
|
else -> dow.format(date).replaceFirstChar { it.uppercase() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val today = TrafficHistory.dayKey(now)
|
||||||
|
val highlight = days.indexOfFirst { it.day == today }
|
||||||
|
val title = when (period)
|
||||||
|
{
|
||||||
|
Period.DAY -> context.getString(R.string.stats_today)
|
||||||
|
Period.WEEK ->
|
||||||
|
{
|
||||||
|
val f = SimpleDateFormat("d MMMM", Locale.getDefault())
|
||||||
|
val a = parse.parse(days.first().day)!!
|
||||||
|
val b = parse.parse(days.last().day)!!
|
||||||
|
SimpleDateFormat("d", Locale.getDefault()).format(a) + " – " + f.format(b)
|
||||||
|
}
|
||||||
|
Period.MONTH -> SimpleDateFormat("LLLL yyyy", Locale.getDefault()).format(parse.parse(days.first().day)!!).replaceFirstChar { it.uppercase() }
|
||||||
|
}
|
||||||
|
/* apps: system counters over the same period (needs usage access) */
|
||||||
|
val usage = AppTraffic.hasAccess(context)
|
||||||
|
val since = if (period == Period.DAY) startOfDay(now) else from
|
||||||
|
val direct = runCatching { RoutingManager.getInstance(context).computeExcludedPackages() }.getOrDefault(emptySet())
|
||||||
|
val pm = context.packageManager
|
||||||
|
val apps = if (!usage) emptyList() else AppTraffic.since(context, since).entries.sortedByDescending { it.value }.take(5).map { (pkg, bytes) ->
|
||||||
|
val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull()
|
||||||
|
AppRow(info?.let { pm.getApplicationLabel(it).toString() } ?: pkg, info?.let { runCatching { pm.getApplicationIcon(it) }.getOrNull() }, bytes, pkg in direct)
|
||||||
|
}
|
||||||
|
return StatsData(days, title, labels, highlight, TrafficHistory.firstDay(context), apps, usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun StatsScreen(onBack: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
var period by rememberSaveable { mutableIntStateOf(Period.WEEK.ordinal) }
|
||||||
|
val changes by TrafficHistory.changes.collectAsStateWithLifecycle()
|
||||||
|
val data by produceState<StatsData?>(null, period, changes) {
|
||||||
|
value = withContext(Dispatchers.IO) { load(context, Period.entries[period]) }
|
||||||
|
}
|
||||||
|
SecondaryScaffold(title = stringResource(R.string.stats_title), onBack = onBack) { padding ->
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ShxTabs(Period.entries.map { stringResource(it.label) }, period, { period = it })
|
||||||
|
val d = data ?: return@Column
|
||||||
|
val today = TrafficHistory.dayKey(System.currentTimeMillis())
|
||||||
|
/* F_StatsEmpty: nothing older than today yet */
|
||||||
|
val fresh = d.first == null || d.first >= today
|
||||||
|
val shown = if (Period.entries[period] == Period.DAY) d.days.filter { it.day == today } else d.days
|
||||||
|
val vpn = shown.sumOf { it.vpn }
|
||||||
|
val direct = shown.sumOf { it.direct }
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(18.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(d.title, style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.textSub)
|
||||||
|
Text(formatBytes(vpn + direct), style = ShxType.number.copy(fontSize = 32.sp, lineHeight = 36.sp), color = c.text)
|
||||||
|
}
|
||||||
|
Bars(d, fresh, today)
|
||||||
|
if (!fresh)
|
||||||
|
{
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Tile(stringResource(R.string.stats_vpn), formatBytes(vpn), c.accentContainer, c.onAccentContainer, Modifier.weight(1f))
|
||||||
|
Tile(stringResource(R.string.stats_direct), formatBytes(direct), c.tile, c.textSoft, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fresh)
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(c.accentSoft).padding(16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Stats, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(22.dp))
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.stats_fresh_title), style = ShxType.cardTitle, color = c.text)
|
||||||
|
Text(stringResource(R.string.stats_fresh_text), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onAccentContainer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.stats_apps), style = ShxType.cardTitle, color = c.text, modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
|
val panel = RoundedCornerShape(22.dp)
|
||||||
|
Column(Modifier.fillMaxWidth().clip(panel).background(c.card).border(1.dp, c.cardBorder, panel).padding(horizontal = 14.dp, vertical = 4.dp))
|
||||||
|
{
|
||||||
|
when
|
||||||
|
{
|
||||||
|
!d.usage -> Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable(role = Role.Button) {
|
||||||
|
if (runCatching { context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS, Uri.fromParts("package", context.packageName, null))) }.isFailure)
|
||||||
|
{
|
||||||
|
runCatching { context.startActivity(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)) }
|
||||||
|
}
|
||||||
|
}.padding(vertical = 14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.stats_apps_access), style = ShxType.note.copy(fontWeight = FontWeight.Normal), color = c.textSoft, modifier = Modifier.weight(1f))
|
||||||
|
Text(stringResource(R.string.perm_allow), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.accentText)
|
||||||
|
}
|
||||||
|
d.apps.isEmpty() -> Text(stringResource(R.string.stats_apps_none), style = ShxType.note, color = c.textSub, modifier = Modifier.padding(vertical = 14.dp))
|
||||||
|
else ->
|
||||||
|
{
|
||||||
|
val max = d.apps.maxOf { it.bytes }.coerceAtLeast(1)
|
||||||
|
d.apps.forEach { AppLine(it, it.bytes.toFloat() / max) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Bars(d: StatsData, fresh: Boolean, today: String)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val max = d.days.maxOf { it.total }.coerceAtLeast(1)
|
||||||
|
val month = d.days.size > 7
|
||||||
|
val outline = c.outline
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(if (month) 3.dp else 10.dp),
|
||||||
|
verticalAlignment = Alignment.Bottom,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(if (fresh) 140.dp else 170.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
d.days.forEachIndexed { i, day ->
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.weight(1f))
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f).fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.Bottom))
|
||||||
|
{
|
||||||
|
val before = d.first == null || day.day < d.first
|
||||||
|
if (day.day > today)
|
||||||
|
{ /* days still ahead: nothing to draw */
|
||||||
|
}
|
||||||
|
else if (before)
|
||||||
|
{ /* no history yet: a dashed placeholder as on F_StatsEmpty */
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxWidth().height(34.dp).drawBehind {
|
||||||
|
drawRoundRect(
|
||||||
|
outline, cornerRadius = CornerRadius(6.dp.toPx()),
|
||||||
|
style = Stroke(1.5.dp.toPx(), pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 6f))),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
val full = 150f
|
||||||
|
val directH = (day.direct.toFloat() / max * full).coerceAtLeast(if (day.direct > 0) 3f else 0f)
|
||||||
|
val vpnH = (day.vpn.toFloat() / max * full).coerceAtLeast(if (day.vpn > 0 || day.direct == 0L) 4f else 0f)
|
||||||
|
if (directH > 0f)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(directH.dp).clip(RoundedCornerShape(topStart = 6.dp, topEnd = 6.dp, bottomStart = 2.dp, bottomEnd = 2.dp)).background(outline))
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxWidth().height(vpnH.dp)
|
||||||
|
.clip(if (directH > 0f) RoundedCornerShape(topStart = 2.dp, topEnd = 2.dp, bottomStart = 6.dp, bottomEnd = 6.dp) else RoundedCornerShape(6.dp))
|
||||||
|
.background(if (fresh && day.day == today) c.accent else if (fresh) c.tile else c.accent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
d.labels[i], style = ShxType.meta.copy(fontWeight = if (i == d.highlight) FontWeight.ExtraBold else FontWeight.SemiBold),
|
||||||
|
color = if (i == d.highlight) c.text else c.textSub, maxLines = 1, softWrap = false,
|
||||||
|
/* two-digit dates of the month view are wider than their thin bar */
|
||||||
|
modifier = Modifier.wrapContentWidth(unbounded = true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Tile(label: String, value: String, bg: Color, fg: Color, modifier: Modifier)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp), modifier = modifier.clip(RoundedCornerShape(16.dp)).background(bg).padding(12.dp))
|
||||||
|
{
|
||||||
|
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = fg)
|
||||||
|
Text(value, style = ShxType.cardHeading.copy(fontSize = 20.sp), color = c.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppLine(app: AppRow, share: Float)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val icon = remember(app) { app.icon?.toBitmap(96, 96)?.asImageBitmap() }
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(c.tile))
|
||||||
|
{
|
||||||
|
if (icon != null)
|
||||||
|
{
|
||||||
|
Image(icon, contentDescription = null, modifier = Modifier.size(36.dp))
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Text(app.label.take(1).uppercase(), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.textSoft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
Row()
|
||||||
|
{
|
||||||
|
Text(app.label, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(formatBytes(app.bytes), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(6.dp).clip(RoundedCornerShape(3.dp)).background(c.tile))
|
||||||
|
{
|
||||||
|
/* purple: through the VPN, grey: goes direct */
|
||||||
|
Box(Modifier.fillMaxWidth(share.coerceIn(0.02f, 1f)).height(6.dp).clip(RoundedCornerShape(3.dp)).background(if (app.direct) c.textSub else c.accent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-248
@@ -1,248 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2014 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.app.Dialog;
|
|
||||||
import android.content.ActivityNotFoundException;
|
|
||||||
import android.content.DialogInterface;
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.widget.Toast;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
import org.strongswan.android.data.VpnProfileDataSource;
|
|
||||||
import org.strongswan.android.logic.TrustedCertificateManager;
|
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
import java.io.InputStream;
|
|
||||||
import java.security.KeyStore;
|
|
||||||
import java.security.cert.CertificateException;
|
|
||||||
import java.security.cert.CertificateFactory;
|
|
||||||
import java.security.cert.X509Certificate;
|
|
||||||
|
|
||||||
import androidx.activity.result.ActivityResultLauncher;
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts;
|
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
|
||||||
import androidx.appcompat.app.AppCompatDialogFragment;
|
|
||||||
import androidx.fragment.app.FragmentTransaction;
|
|
||||||
|
|
||||||
public class TrustedCertificateImportActivity extends AppCompatActivity
|
|
||||||
{
|
|
||||||
private static final String DIALOG_TAG = "Dialog";
|
|
||||||
private Uri mCertificateUri;
|
|
||||||
|
|
||||||
private final ActivityResultLauncher<Intent> mOpenDocument = registerForActivityResult(
|
|
||||||
new ActivityResultContracts.StartActivityForResult(),
|
|
||||||
result -> {
|
|
||||||
if (result.getResultCode() == RESULT_OK && result.getData() != null)
|
|
||||||
{
|
|
||||||
mCertificateUri = result.getData().getData();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate(Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
|
|
||||||
if (savedInstanceState != null)
|
|
||||||
{ /* do nothing when we are restoring */
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Intent intent = getIntent();
|
|
||||||
String action = intent.getAction();
|
|
||||||
if (Intent.ACTION_VIEW.equals(action))
|
|
||||||
{
|
|
||||||
importCertificate(intent.getData());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Intent openIntent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
|
||||||
openIntent.setType("*/*");
|
|
||||||
try
|
|
||||||
{
|
|
||||||
mOpenDocument.launch(openIntent);
|
|
||||||
}
|
|
||||||
catch (ActivityNotFoundException e)
|
|
||||||
{ /* some devices are unable to browse for files */
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onPostResume()
|
|
||||||
{
|
|
||||||
super.onPostResume();
|
|
||||||
if (mCertificateUri != null)
|
|
||||||
{
|
|
||||||
importCertificate(mCertificateUri);
|
|
||||||
mCertificateUri = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Import the file pointed to by the given URI as a certificate.
|
|
||||||
*
|
|
||||||
* @param uri
|
|
||||||
*/
|
|
||||||
private void importCertificate(Uri uri)
|
|
||||||
{
|
|
||||||
X509Certificate certificate = parseCertificate(uri);
|
|
||||||
if (certificate == null)
|
|
||||||
{
|
|
||||||
Toast.makeText(this, R.string.cert_import_failed, Toast.LENGTH_LONG).show();
|
|
||||||
finish();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
/* Ask the user whether to import the certificate. This is particularly
|
|
||||||
* necessary because the import activity can be triggered by any app on
|
|
||||||
* the system. Also, if our app is the only one that is registered to
|
|
||||||
* open certificate files by MIME type the user would have no idea really
|
|
||||||
* where the file was imported just by reading the Toast we display. */
|
|
||||||
ConfirmImportDialog dialog = new ConfirmImportDialog();
|
|
||||||
Bundle args = new Bundle();
|
|
||||||
args.putSerializable(VpnProfileDataSource.KEY_CERTIFICATE, certificate);
|
|
||||||
dialog.setArguments(args);
|
|
||||||
FragmentTransaction ft = getSupportFragmentManager().beginTransaction();
|
|
||||||
ft.add(dialog, DIALOG_TAG);
|
|
||||||
ft.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the file from the given URI and try to parse it as X.509 certificate.
|
|
||||||
*
|
|
||||||
* @param uri
|
|
||||||
* @return certificate or null
|
|
||||||
*/
|
|
||||||
private X509Certificate parseCertificate(Uri uri)
|
|
||||||
{
|
|
||||||
X509Certificate certificate = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
CertificateFactory factory = CertificateFactory.getInstance("X.509");
|
|
||||||
InputStream in = getContentResolver().openInputStream(uri);
|
|
||||||
certificate = (X509Certificate)factory.generateCertificate(in);
|
|
||||||
/* we don't check whether it's actually a CA certificate or not */
|
|
||||||
}
|
|
||||||
catch (CertificateException e)
|
|
||||||
{
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
catch (FileNotFoundException e)
|
|
||||||
{
|
|
||||||
e.printStackTrace();
|
|
||||||
}
|
|
||||||
return certificate;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try to store the given certificate in the KeyStore.
|
|
||||||
*
|
|
||||||
* @param certificate
|
|
||||||
* @return whether it was successfully stored
|
|
||||||
*/
|
|
||||||
private boolean storeCertificate(X509Certificate certificate)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
KeyStore store = KeyStore.getInstance("LocalCertificateStore");
|
|
||||||
store.load(null, null);
|
|
||||||
store.setCertificateEntry(null, certificate);
|
|
||||||
TrustedCertificateManager.getInstance().reset();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
catch (Exception e)
|
|
||||||
{
|
|
||||||
e.printStackTrace();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Class that displays a confirmation dialog when a certificate should get
|
|
||||||
* imported. If the user confirms the import we try to store it.
|
|
||||||
*/
|
|
||||||
public static class ConfirmImportDialog extends AppCompatDialogFragment
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public Dialog onCreateDialog(Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
final X509Certificate certificate;
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
|
||||||
{
|
|
||||||
certificate = getCertificateCompat(getArguments());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
certificate = getArguments().getSerializable(VpnProfileDataSource.KEY_CERTIFICATE, X509Certificate.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AlertDialog.Builder(getActivity())
|
|
||||||
.setIcon(R.mipmap.ic_app)
|
|
||||||
.setTitle(R.string.import_certificate)
|
|
||||||
.setMessage(certificate.getSubjectDN().toString())
|
|
||||||
.setPositiveButton(R.string.import_certificate, new DialogInterface.OnClickListener()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public void onClick(DialogInterface dialog, int whichButton)
|
|
||||||
{
|
|
||||||
TrustedCertificateImportActivity activity = (TrustedCertificateImportActivity)getActivity();
|
|
||||||
if (activity.storeCertificate(certificate))
|
|
||||||
{
|
|
||||||
Toast.makeText(getActivity(), R.string.cert_imported_successfully, Toast.LENGTH_LONG).show();
|
|
||||||
getActivity().setResult(RESULT_OK);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Toast.makeText(getActivity(), R.string.cert_import_failed, Toast.LENGTH_LONG).show();
|
|
||||||
}
|
|
||||||
getActivity().finish();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener()
|
|
||||||
{
|
|
||||||
@Override
|
|
||||||
public void onClick(DialogInterface dialog, int which)
|
|
||||||
{
|
|
||||||
getActivity().finish();
|
|
||||||
}
|
|
||||||
}).create();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCancel(DialogInterface dialog)
|
|
||||||
{
|
|
||||||
getActivity().finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
|
||||||
private static X509Certificate getCertificateCompat(Bundle bundle)
|
|
||||||
{
|
|
||||||
return (X509Certificate)bundle.getSerializable(VpnProfileDataSource.KEY_CERTIFICATE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2014 Tobias Brunner
|
||||||
|
*
|
||||||
|
* Copyright (C) secunet Security Networks AG
|
||||||
|
*
|
||||||
|
* This program is free software; you can redistribute it and/or modify it
|
||||||
|
* under the terms of the GNU General Public License as published by the
|
||||||
|
* Free Software Foundation; either version 2 of the License, or (at your
|
||||||
|
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful, but
|
||||||
|
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||||
|
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||||
|
* for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.ActivityNotFoundException
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.CertificateFactory
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxDialogButton
|
||||||
|
import org.strongswan.android.logic.TrustedCertificateManager
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
|
class TrustedCertificateImportActivity : AppCompatActivity()
|
||||||
|
{
|
||||||
|
private var certificateUri: Uri? = null
|
||||||
|
private var certificate by mutableStateOf<X509Certificate?>(null)
|
||||||
|
|
||||||
|
private val mOpenDocument = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode == RESULT_OK && result.data != null)
|
||||||
|
{
|
||||||
|
certificateUri = result.data!!.data
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
|
{
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
if (savedInstanceState != null)
|
||||||
|
{ /* do nothing when we are restoring */
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setContent {
|
||||||
|
ShxTheme {
|
||||||
|
certificate?.let { cert -> ConfirmImportUi(cert) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = getIntent()
|
||||||
|
val action = intent.getAction()
|
||||||
|
if (Intent.ACTION_VIEW == action)
|
||||||
|
{
|
||||||
|
intent.getData()?.let { importCertificate(it) }
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
val openIntent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||||
|
openIntent.setType("*/*")
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mOpenDocument.launch(openIntent)
|
||||||
|
}
|
||||||
|
catch (e: ActivityNotFoundException)
|
||||||
|
{ /* some devices are unable to browse for files */
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPostResume()
|
||||||
|
{
|
||||||
|
super.onPostResume()
|
||||||
|
certificateUri?.let {
|
||||||
|
importCertificate(it)
|
||||||
|
certificateUri = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import the file pointed to by the given URI as a certificate.
|
||||||
|
*/
|
||||||
|
private fun importCertificate(uri: Uri)
|
||||||
|
{
|
||||||
|
val certificate = parseCertificate(uri)
|
||||||
|
if (certificate == null)
|
||||||
|
{
|
||||||
|
Toast.makeText(this, R.string.cert_import_failed, Toast.LENGTH_LONG).show()
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
/* Ask the user whether to import the certificate. This is particularly
|
||||||
|
* necessary because the import activity can be triggered by any app on
|
||||||
|
* the system. Also, if our app is the only one that is registered to
|
||||||
|
* open certificate files by MIME type the user would have no idea really
|
||||||
|
* where the file was imported just by reading the Toast we display. */
|
||||||
|
this.certificate = certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the file from the given URI and try to parse it as X.509 certificate.
|
||||||
|
*
|
||||||
|
* @return certificate or null
|
||||||
|
*/
|
||||||
|
private fun parseCertificate(uri: Uri): X509Certificate?
|
||||||
|
{
|
||||||
|
var certificate: X509Certificate? = null
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val factory = CertificateFactory.getInstance("X.509")
|
||||||
|
val input = getContentResolver().openInputStream(uri)
|
||||||
|
certificate = factory.generateCertificate(input) as X509Certificate
|
||||||
|
/* we don't check whether it's actually a CA certificate or not */
|
||||||
|
}
|
||||||
|
catch (e: CertificateException)
|
||||||
|
{
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
catch (e: FileNotFoundException)
|
||||||
|
{
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
return certificate
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to store the given certificate in the KeyStore.
|
||||||
|
*
|
||||||
|
* @return whether it was successfully stored
|
||||||
|
*/
|
||||||
|
private fun storeCertificate(certificate: X509Certificate): Boolean
|
||||||
|
{
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val store = KeyStore.getInstance("LocalCertificateStore")
|
||||||
|
store.load(null, null)
|
||||||
|
store.setCertificateEntry(null, certificate)
|
||||||
|
TrustedCertificateManager.getInstance().reset()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
e.printStackTrace()
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation dialog shown before a certificate gets imported.
|
||||||
|
*/
|
||||||
|
@androidx.compose.runtime.Composable
|
||||||
|
private fun ConfirmImportUi(certificate: X509Certificate)
|
||||||
|
{
|
||||||
|
ShxAlertDialog(
|
||||||
|
onDismissRequest = { finish() },
|
||||||
|
title = { Text(stringResource(R.string.import_certificate)) },
|
||||||
|
text = { Text(certificate.getSubjectDN().toString()) },
|
||||||
|
confirmButton = {
|
||||||
|
ShxDialogButton(primary = true, onClick = {
|
||||||
|
if (storeCertificate(certificate))
|
||||||
|
{
|
||||||
|
Toast.makeText(this, R.string.cert_imported_successfully, Toast.LENGTH_LONG).show()
|
||||||
|
setResult(RESULT_OK)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Toast.makeText(this, R.string.cert_import_failed, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}) { Text(stringResource(R.string.import_certificate)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
ShxDialogButton(onClick = { finish() }) {
|
||||||
|
Text(stringResource(android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stringResource(res: Int): String = getString(res)
|
||||||
|
}
|
||||||
-285
@@ -1,285 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2012-2015 Tobias Brunner
|
|
||||||
*
|
|
||||||
* Copyright (C) secunet Security Networks AG
|
|
||||||
*
|
|
||||||
* This program is free software; you can redistribute it and/or modify it
|
|
||||||
* under the terms of the GNU General Public License as published by the
|
|
||||||
* Free Software Foundation; either version 2 of the License, or (at your
|
|
||||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful, but
|
|
||||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
|
||||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
|
||||||
* for more details.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.strongswan.android.ui;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.view.Menu;
|
|
||||||
import android.view.MenuInflater;
|
|
||||||
import android.view.MenuItem;
|
|
||||||
import android.view.View;
|
|
||||||
import android.widget.ListView;
|
|
||||||
import android.widget.SearchView;
|
|
||||||
import android.widget.SearchView.OnQueryTextListener;
|
|
||||||
|
|
||||||
import org.strongswan.android.R;
|
|
||||||
import org.strongswan.android.logic.TrustedCertificateManager;
|
|
||||||
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource;
|
|
||||||
import org.strongswan.android.security.TrustedCertificateEntry;
|
|
||||||
import org.strongswan.android.ui.adapter.TrustedCertificateAdapter;
|
|
||||||
|
|
||||||
import java.beans.PropertyChangeEvent;
|
|
||||||
import java.beans.PropertyChangeListener;
|
|
||||||
import java.security.cert.X509Certificate;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.Hashtable;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map.Entry;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.core.view.MenuProvider;
|
|
||||||
import androidx.fragment.app.ListFragment;
|
|
||||||
import androidx.lifecycle.Lifecycle;
|
|
||||||
import androidx.loader.app.LoaderManager;
|
|
||||||
import androidx.loader.app.LoaderManager.LoaderCallbacks;
|
|
||||||
import androidx.loader.content.AsyncTaskLoader;
|
|
||||||
import androidx.loader.content.Loader;
|
|
||||||
|
|
||||||
public class TrustedCertificateListFragment extends ListFragment implements MenuProvider, LoaderCallbacks<List<TrustedCertificateEntry>>, OnQueryTextListener
|
|
||||||
{
|
|
||||||
public static final String EXTRA_CERTIFICATE_SOURCE = "certificate_source";
|
|
||||||
private OnTrustedCertificateSelectedListener mListener;
|
|
||||||
private TrustedCertificateAdapter mAdapter;
|
|
||||||
private TrustedCertificateSource mSource = TrustedCertificateSource.SYSTEM;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The activity containing this fragment should implement this interface
|
|
||||||
*/
|
|
||||||
public interface OnTrustedCertificateSelectedListener
|
|
||||||
{
|
|
||||||
public void onTrustedCertificateSelected(TrustedCertificateEntry selected);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState)
|
|
||||||
{
|
|
||||||
super.onViewCreated(view, savedInstanceState);
|
|
||||||
requireActivity().addMenuProvider(this, getViewLifecycleOwner(), Lifecycle.State.RESUMED);
|
|
||||||
|
|
||||||
setEmptyText(getString(R.string.no_certificates));
|
|
||||||
|
|
||||||
mAdapter = new TrustedCertificateAdapter(getActivity());
|
|
||||||
setListAdapter(mAdapter);
|
|
||||||
|
|
||||||
setListShown(false);
|
|
||||||
|
|
||||||
Bundle arguments = getArguments();
|
|
||||||
if (arguments != null)
|
|
||||||
{
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
|
||||||
{
|
|
||||||
mSource = getCertificateSourceCompat(arguments);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
mSource = arguments.getSerializable(EXTRA_CERTIFICATE_SOURCE, TrustedCertificateSource.class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LoaderManager.getInstance(this).initLoader(0, null, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroy()
|
|
||||||
{
|
|
||||||
super.onDestroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onAttach(Context context)
|
|
||||||
{
|
|
||||||
super.onAttach(context);
|
|
||||||
|
|
||||||
if (context instanceof OnTrustedCertificateSelectedListener)
|
|
||||||
{
|
|
||||||
mListener = (OnTrustedCertificateSelectedListener)context;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreateMenu(@NonNull Menu menu, @NonNull MenuInflater menuInflater)
|
|
||||||
{
|
|
||||||
MenuItem item = menu.add(R.string.search);
|
|
||||||
item.setIcon(android.R.drawable.ic_menu_search);
|
|
||||||
item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
|
|
||||||
|
|
||||||
SearchView sv = new SearchView(getActivity());
|
|
||||||
sv.setOnQueryTextListener(this);
|
|
||||||
item.setActionView(sv);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onMenuItemSelected(@NonNull MenuItem menuItem)
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onQueryTextSubmit(String query)
|
|
||||||
{ /* already handled when the text changes */
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onQueryTextChange(String newText)
|
|
||||||
{
|
|
||||||
String search = TextUtils.isEmpty(newText) ? null : newText;
|
|
||||||
mAdapter.getFilter().filter(search);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Loader<List<TrustedCertificateEntry>> onCreateLoader(int id, Bundle args)
|
|
||||||
{ /* we don't need the id as we have only one loader */
|
|
||||||
return new CertificateListLoader(getActivity(), mSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoadFinished(Loader<List<TrustedCertificateEntry>> loader, List<TrustedCertificateEntry> data)
|
|
||||||
{
|
|
||||||
mAdapter.setData(data);
|
|
||||||
|
|
||||||
if (isResumed())
|
|
||||||
{
|
|
||||||
setListShown(true);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
setListShownNoAnimation(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onLoaderReset(Loader<List<TrustedCertificateEntry>> loader)
|
|
||||||
{
|
|
||||||
mAdapter.setData(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onListItemClick(ListView l, View v, int position, long id)
|
|
||||||
{
|
|
||||||
if (mListener != null)
|
|
||||||
{
|
|
||||||
mListener.onTrustedCertificateSelected(mAdapter.getItem(position));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static class CertificateListLoader extends AsyncTaskLoader<List<TrustedCertificateEntry>>
|
|
||||||
{
|
|
||||||
private List<TrustedCertificateEntry> mData;
|
|
||||||
private final TrustedCertificateSource mSource;
|
|
||||||
private TrustedCertificateManagerObserver mObserver;
|
|
||||||
|
|
||||||
public CertificateListLoader(Context context, TrustedCertificateSource source)
|
|
||||||
{
|
|
||||||
super(context);
|
|
||||||
mSource = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<TrustedCertificateEntry> loadInBackground()
|
|
||||||
{
|
|
||||||
TrustedCertificateManager certman = TrustedCertificateManager.getInstance().load();
|
|
||||||
Hashtable<String, X509Certificate> certificates = certman.getCACertificates(mSource);
|
|
||||||
List<TrustedCertificateEntry> selected;
|
|
||||||
|
|
||||||
selected = new ArrayList<TrustedCertificateEntry>();
|
|
||||||
for (Entry<String, X509Certificate> entry : certificates.entrySet())
|
|
||||||
{
|
|
||||||
selected.add(new TrustedCertificateEntry(entry.getKey(), entry.getValue()));
|
|
||||||
}
|
|
||||||
Collections.sort(selected);
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onStartLoading()
|
|
||||||
{
|
|
||||||
if (mData != null)
|
|
||||||
{ /* if we have data ready, deliver it directly */
|
|
||||||
deliverResult(mData);
|
|
||||||
}
|
|
||||||
if (takeContentChanged() || mData == null)
|
|
||||||
{
|
|
||||||
forceLoad();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void deliverResult(List<TrustedCertificateEntry> data)
|
|
||||||
{
|
|
||||||
if (isReset())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mData = data;
|
|
||||||
if (isStarted())
|
|
||||||
{ /* if it is started we deliver the data directly,
|
|
||||||
* otherwise this is handled in onStartLoading */
|
|
||||||
if (mObserver == null)
|
|
||||||
{
|
|
||||||
mObserver = new TrustedCertificateManagerObserver();
|
|
||||||
TrustedCertificateManager.getInstance().addObserver(mObserver);
|
|
||||||
}
|
|
||||||
super.deliverResult(data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onReset()
|
|
||||||
{
|
|
||||||
if (mObserver != null)
|
|
||||||
{
|
|
||||||
TrustedCertificateManager.getInstance().deleteObserver(mObserver);
|
|
||||||
mObserver = null;
|
|
||||||
}
|
|
||||||
mData = null;
|
|
||||||
super.onReset();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onAbandon()
|
|
||||||
{
|
|
||||||
if (mObserver != null)
|
|
||||||
{
|
|
||||||
TrustedCertificateManager.getInstance().deleteObserver(mObserver);
|
|
||||||
mObserver = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TrustedCertificateManagerObserver implements PropertyChangeListener
|
|
||||||
{
|
|
||||||
private ForceLoadContentObserver mContentObserver = new ForceLoadContentObserver();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void propertyChange(PropertyChangeEvent evt)
|
|
||||||
{
|
|
||||||
mContentObserver.onChange(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("deprecation")
|
|
||||||
private static TrustedCertificateSource getCertificateSourceCompat(Bundle bundle)
|
|
||||||
{
|
|
||||||
return (TrustedCertificateSource)bundle.getSerializable(EXTRA_CERTIFICATE_SOURCE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loaded 100 of 821 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user