Compare commits
71
Commits
8f21e16277
...
main
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: docs
|
||||||
|
description: Looks up API references and library documentation. Read-only.
|
||||||
|
model: claude-haiku-5-5
|
||||||
|
tools: Read, Grep, Glob, WebFetch, WebSearch
|
||||||
|
---
|
||||||
|
Find the relevant API reference and return signatures and short quotes with sources. Never modify anything.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: grep
|
||||||
|
description: Finds call sites and usages of a symbol. Read-only.
|
||||||
|
model: claude-haiku-5-5
|
||||||
|
tools: Read, Grep, Glob
|
||||||
|
---
|
||||||
|
List every call site of the given symbol as file:line with a short context. Never modify anything.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
name: search
|
||||||
|
description: Repo mapping. Finds files, modules and directory structure. Read-only.
|
||||||
|
model: claude-haiku-5-5
|
||||||
|
tools: Read, Glob, Grep
|
||||||
|
---
|
||||||
|
Map the repository: locate relevant files, modules and entry points. Return paths and one-line summaries. Never modify anything.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"model": "claude-sonnet-5-5"
|
||||||
|
}
|
||||||
@@ -1,6 +1,25 @@
|
|||||||
# Память проекта
|
# Память проекта
|
||||||
|
|
||||||
## Android-приложение: сборка и выкладка (src/frontends/android)
|
Основная работа в репозитории — 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`) ПРИВАТНЫЙ.** Публичного чтения нет и включать его не нужно:
|
- **Бакет S3 (Selectel, `shx-app`) ПРИВАТНЫЙ.** Публичного чтения нет и включать его не нужно:
|
||||||
`GET https://s3.ru-7.storage.selcloud.ru/shx-app/latest.json` всегда отдаёт `AccessDenied` — это норма, а не ошибка выкладки.
|
`GET https://s3.ru-7.storage.selcloud.ru/shx-app/latest.json` всегда отдаёт `AccessDenied` — это норма, а не ошибка выкладки.
|
||||||
@@ -8,9 +27,34 @@
|
|||||||
- Сборка и публикация — скриптом `build-local.sh` (Git Bash) или `build-local.ps1` в `src/frontends/android`:
|
- Сборка и публикация — скриптом `build-local.sh` (Git Bash) или `build-local.ps1` в `src/frontends/android`:
|
||||||
он читает `.env`, собирает `assembleRelease`, подписывает keystore'ом из `.env`, генерирует `latest.json`
|
он читает `.env`, собирает `assembleRelease`, подписывает keystore'ом из `.env`, генерирует `latest.json`
|
||||||
(sha256/size/versionCode) и заливает `shx-vpn.apk` + `latest.json` в корень бакета с `Cache-Control: no-cache`.
|
(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` содержат пробелы и
|
- **`.env` нельзя парсить через `source`/`eval`**: значения `NOTES_RU` и `AWS_CA_BUNDLE` содержат пробелы и
|
||||||
обратные слэши. Разбирать только построчно `KEY=...` (как в `build-local.sh`).
|
обратные слэши. Разбирать только построчно `KEY=...` (как в `build-local.sh`).
|
||||||
- aws cli установлен через pip: `%APPDATA%\Python\Python313\Scripts\aws` (скрипт сам добавляет путь в PATH).
|
- aws cli установлен через pip: `%APPDATA%\Python\Python313\Scripts\aws` (скрипт сам добавляет путь в PATH).
|
||||||
- `versionCode` растёт вручную в `app/build.gradle`; перед сборкой сверять с `versionCode` в `latest.json` бакета.
|
- `versionCode` растёт вручную в `app/build.gradle`; перед сборкой сверять с `versionCode` в `latest.json` бакета.
|
||||||
- `NOTES_RU` в `.env` — заметки релиза (попадают в `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 и подпись.
|
- Автообновление приложения: `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.*` и
|
||||||
|
требует отдельного подтверждения.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@AGENTS.md
|
||||||
|
|
||||||
|
# Advisor rule
|
||||||
|
Ping the advisor (Opus 5.5) before locking any plan, as soon as any error repeats a second time, and before the final task handoff.
|
||||||
@@ -259,6 +259,14 @@ charon.keep_alive_dpd_margin = 0s
|
|||||||
sent instead of a NAT keep alive (0 to disable). This is only useful if a
|
sent instead of a NAT keep alive (0 to disable). This is only useful if a
|
||||||
clock is used that includes time spent suspended (e.g. CLOCK_BOOTTIME).
|
clock is used that includes time spent suspended (e.g. CLOCK_BOOTTIME).
|
||||||
|
|
||||||
|
charon.keep_alive_dpd_inbound = 0s
|
||||||
|
Number of seconds without any inbound packet, while IPsec traffic was sent,
|
||||||
|
after which a DPD is sent from the NAT keep alive tick (0 to disable). It
|
||||||
|
detects a peer that dropped the SA silently without a separate DPD timer;
|
||||||
|
the first tick after a suspend or a lost path is skipped. While answered
|
||||||
|
probes are all that comes back (traffic nobody replies to), the time is
|
||||||
|
doubled after each probe, up to four times the configured value.
|
||||||
|
|
||||||
charon.leak_detective.detailed = yes
|
charon.leak_detective.detailed = yes
|
||||||
Includes source file names and line numbers in leak detective output.
|
Includes source file names and line numbers in leak detective output.
|
||||||
|
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ android {
|
|||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
|
|
||||||
versionCode 164
|
versionCode 180
|
||||||
versionName "2.17.1"
|
versionName "2.19.12"
|
||||||
|
|
||||||
/* Base URL of the update storage, trailing slash included */
|
/* Base URL of the update storage, trailing slash included */
|
||||||
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
buildConfigField "String", "UPDATE_BASE_URL", "\"${updateBaseUrl}\""
|
||||||
@@ -74,6 +74,17 @@ android {
|
|||||||
buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\""
|
buildConfigField "String", "UPDATE_S3_KEY_XOR", "\"${java.util.Base64.getEncoder().encodeToString(updateXorKey)}\""
|
||||||
buildConfigField "String", "UPDATE_S3_REGION", "\"${envOr('S3_REGION', 'us-east-1')}\""
|
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()
|
||||||
@@ -173,6 +184,10 @@ dependencies {
|
|||||||
implementation 'androidx.glance:glance-appwidget:1.2.0'
|
implementation 'androidx.glance:glance-appwidget:1.2.0'
|
||||||
implementation 'androidx.glance:glance-material3:1.2.0'
|
implementation 'androidx.glance:glance-material3:1.2.0'
|
||||||
implementation 'androidx.activity:activity-compose:1.11.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'
|
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||||
@@ -180,6 +195,8 @@ dependencies {
|
|||||||
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.ext:junit:1.3.0'
|
||||||
androidTestImplementation 'androidx.test:runner:1.7.0'
|
androidTestImplementation 'androidx.test:runner:1.7.0'
|
||||||
@@ -187,3 +204,32 @@ dependencies {
|
|||||||
androidTestImplementation composeBom
|
androidTestImplementation composeBom
|
||||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,3 +64,6 @@
|
|||||||
-keep class * extends androidx.room.RoomDatabase {
|
-keep class * extends androidx.room.RoomDatabase {
|
||||||
<init>();
|
<init>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# i2p eddsa (manifest signature check) references sun.security.x509 only for JDK interop, absent on Android
|
||||||
|
-dontwarn sun.security.x509.**
|
||||||
@@ -27,9 +27,14 @@
|
|||||||
<!-- SSID is redacted in the background on Android 10+ without this. -->
|
<!-- 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.ACCESS_BACKGROUND_LOCATION" />
|
||||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
<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"/>
|
||||||
|
<!-- «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"/>
|
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS"/>
|
||||||
<!-- required to install downloaded updates of the app itself -->
|
<!-- required to install downloaded updates of the app itself -->
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
@@ -75,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"
|
||||||
@@ -117,10 +134,6 @@
|
|||||||
android:name=".ui.SettingsActivity"
|
android:name=".ui.SettingsActivity"
|
||||||
android:label="@string/pref_title">
|
android:label="@string/pref_title">
|
||||||
</activity>
|
</activity>
|
||||||
<activity
|
|
||||||
android:name=".ui.TrustedNetworksActivity"
|
|
||||||
android:label="@string/auto_connect_title">
|
|
||||||
</activity>
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.ConnectivityCheckActivity"
|
android:name=".ui.ConnectivityCheckActivity"
|
||||||
android:label="@string/health_check_title">
|
android:label="@string/health_check_title">
|
||||||
@@ -129,6 +142,18 @@
|
|||||||
android:name=".ui.RouteCheckActivity"
|
android:name=".ui.RouteCheckActivity"
|
||||||
android:label="@string/route_check_title">
|
android:label="@string/route_check_title">
|
||||||
</activity>
|
</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" >
|
||||||
@@ -293,6 +318,18 @@
|
|||||||
<receiver
|
<receiver
|
||||||
android:name=".logic.WatchdogReceiver"
|
android:name=".logic.WatchdogReceiver"
|
||||||
android:exported="false" />
|
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
|
<!-- runs after the app has updated itself (the system kills the
|
||||||
process during the install): cleans up and lets auto-connect
|
process during the install): cleans up and lets auto-connect
|
||||||
restore the VPN -->
|
restore the VPN -->
|
||||||
@@ -303,6 +340,17 @@
|
|||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</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>
|
||||||
+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,78 @@
|
|||||||
|
/*
|
||||||
|
* 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.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.strongswan.android.R
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Block connections without VPN» as the system reports it. Only the running
|
||||||
|
* VpnService can ask (Android 10+), so the last answer is kept for the UI.
|
||||||
|
* When it was on and is off now, the user gets a warning.
|
||||||
|
*/
|
||||||
|
object AlwaysOnVpn
|
||||||
|
{
|
||||||
|
private const val PREF_LOCKDOWN = "pref_vpn_lockdown"
|
||||||
|
private const val EVENT_KEY = "lockdown-off"
|
||||||
|
|
||||||
|
/** Last value seen by the service; false before the first connection or below Android 10. */
|
||||||
|
@JvmStatic
|
||||||
|
fun isLockdown(context: Context): Boolean =
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).getBoolean(PREF_LOCKDOWN, false)
|
||||||
|
|
||||||
|
/** Called by the service when the tunnel is up. */
|
||||||
|
@JvmStatic
|
||||||
|
fun update(service: VpnService)
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val lockdown = try
|
||||||
|
{
|
||||||
|
service.isLockdownEnabled
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(service)
|
||||||
|
val was = prefs.getBoolean(PREF_LOCKDOWN, false)
|
||||||
|
if (lockdown != was)
|
||||||
|
{
|
||||||
|
prefs.edit().putBoolean(PREF_LOCKDOWN, lockdown).apply()
|
||||||
|
DiagLog.i("AlwaysOnVpn", "lockdown ${if (lockdown) "on" else "off"} (always-on ${service.isAlwaysOn})")
|
||||||
|
}
|
||||||
|
val events = EventLog.getInstance(service)
|
||||||
|
if (lockdown)
|
||||||
|
{
|
||||||
|
events.resolve(EVENT_KEY)
|
||||||
|
}
|
||||||
|
else if (was)
|
||||||
|
{
|
||||||
|
events.record(
|
||||||
|
EventKind.WARN, EventCategory.CONNECTION, service.getString(R.string.event_lockdown_off_title),
|
||||||
|
service.getString(R.string.event_lockdown_off_text), EventAction.OPEN_VPN_SETTINGS, EVENT_KEY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+215
-8
@@ -57,6 +57,7 @@ import org.strongswan.android.data.VpnType.VpnTypeFeature;
|
|||||||
import org.strongswan.android.logic.VpnStateService.ErrorState;
|
import org.strongswan.android.logic.VpnStateService.ErrorState;
|
||||||
import org.strongswan.android.logic.VpnStateService.State;
|
import org.strongswan.android.logic.VpnStateService.State;
|
||||||
import org.strongswan.android.logic.imc.ImcState;
|
import org.strongswan.android.logic.imc.ImcState;
|
||||||
|
import org.strongswan.android.logic.stats.TrafficHistory;
|
||||||
import org.strongswan.android.logic.imc.RemediationInstruction;
|
import org.strongswan.android.logic.imc.RemediationInstruction;
|
||||||
import org.strongswan.android.logic.routing.RouteCheck;
|
import org.strongswan.android.logic.routing.RouteCheck;
|
||||||
import org.strongswan.android.logic.routing.RoutingManager;
|
import org.strongswan.android.logic.routing.RoutingManager;
|
||||||
@@ -91,6 +92,7 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.SortedSet;
|
import java.util.SortedSet;
|
||||||
|
import java.util.TimeZone;
|
||||||
import java.util.TreeSet;
|
import java.util.TreeSet;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
@@ -170,6 +172,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
private volatile boolean mProfileUpdated;
|
private volatile boolean mProfileUpdated;
|
||||||
private volatile boolean mTerminate;
|
private volatile boolean mTerminate;
|
||||||
private volatile boolean mIsDisconnecting;
|
private volatile boolean mIsDisconnecting;
|
||||||
|
/* why-did-the-SA-go-down diagnostics (plan 8.1); no effect on behaviour */
|
||||||
|
private final DailyCounter mSaDropCounter = new DailyCounter();
|
||||||
|
private volatile long mSaUpSince;
|
||||||
|
private volatile long mLastNetworkChange;
|
||||||
|
private volatile boolean mRetransmitTimeout;
|
||||||
|
private volatile boolean mReauth;
|
||||||
private volatile boolean mShowNotification;
|
private volatile boolean mShowNotification;
|
||||||
/* start of the current CONNECTED session for the notification chronometer */
|
/* start of the current CONNECTED session for the notification chronometer */
|
||||||
private volatile long mConnectedSince;
|
private volatile long mConnectedSince;
|
||||||
@@ -218,6 +226,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
static final int STATE_UNREACHABLE_ERROR = 6;
|
static final int STATE_UNREACHABLE_ERROR = 6;
|
||||||
static final int STATE_CERTIFICATE_UNAVAILABLE = 7;
|
static final int STATE_CERTIFICATE_UNAVAILABLE = 7;
|
||||||
static final int STATE_GENERIC_ERROR = 8;
|
static final int STATE_GENERIC_ERROR = 8;
|
||||||
|
static final int STATE_RETRANSMIT_TIMEOUT = 9;
|
||||||
|
static final int STATE_REAUTH = 10;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int onStartCommand(Intent intent, int flags, int startId)
|
public int onStartCommand(Intent intent, int flags, int startId)
|
||||||
@@ -268,7 +278,19 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
VpnProfile profile = loadProfile(uuid);
|
VpnProfile profile = loadProfile(uuid);
|
||||||
if (profile != null)
|
if (profile != null)
|
||||||
{
|
{
|
||||||
profile.setPassword(bundle.getString(VpnProfileDataSource.KEY_PASSWORD));
|
String password = bundle.getString(VpnProfileDataSource.KEY_PASSWORD);
|
||||||
|
if (password != null)
|
||||||
|
{ /* an unattended start (watchdog, pause end, FGS retry) carries no
|
||||||
|
* password: keep the one saved with the profile instead of
|
||||||
|
* wiping it, or the tunnel never comes back («password unavailable») */
|
||||||
|
profile.setPassword(password);
|
||||||
|
}
|
||||||
|
/* a backup port of F_Profile, for this attempt only */
|
||||||
|
int port = bundle.getInt(org.strongswan.android.logic.profiles.Resilience.KEY_PORT_OVERRIDE, 0);
|
||||||
|
if (port > 0)
|
||||||
|
{
|
||||||
|
profile.setPort(port);
|
||||||
|
}
|
||||||
PreferenceManager.getDefaultSharedPreferences(this).edit()
|
PreferenceManager.getDefaultSharedPreferences(this).edit()
|
||||||
.putString(Constants.PREF_MRU_VPN_PROFILE, profile.getUUID().toString())
|
.putString(Constants.PREF_MRU_VPN_PROFILE, profile.getUUID().toString())
|
||||||
.apply();
|
.apply();
|
||||||
@@ -560,6 +582,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
writer.setValue("connection.local_id", mCurrentProfile.getLocalId());
|
writer.setValue("connection.local_id", mCurrentProfile.getLocalId());
|
||||||
writer.setValue("connection.remote_id", mCurrentProfile.getRemoteId());
|
writer.setValue("connection.remote_id", mCurrentProfile.getRemoteId());
|
||||||
writer.setValue("connection.certreq", (mCurrentProfile.getFlags() & VpnProfile.FLAGS_SUPPRESS_CERT_REQS) == 0);
|
writer.setValue("connection.certreq", (mCurrentProfile.getFlags() & VpnProfile.FLAGS_SUPPRESS_CERT_REQS) == 0);
|
||||||
|
writer.setValue("connection.fragmentation", org.strongswan.android.logic.profiles.ProfileExtras.get(CharonVpnService.this, mCurrentProfile.getUUID().toString()).getIkeFragmentation());
|
||||||
writer.setValue("connection.strict_revocation", (mCurrentProfile.getFlags() & VpnProfile.FLAGS_STRICT_REVOCATION) != 0);
|
writer.setValue("connection.strict_revocation", (mCurrentProfile.getFlags() & VpnProfile.FLAGS_STRICT_REVOCATION) != 0);
|
||||||
writer.setValue("connection.ike_proposal", mCurrentProfile.getIkeProposal());
|
writer.setValue("connection.ike_proposal", mCurrentProfile.getIkeProposal());
|
||||||
writer.setValue("connection.esp_proposal", mCurrentProfile.getEspProposal());
|
writer.setValue("connection.esp_proposal", mCurrentProfile.getEspProposal());
|
||||||
@@ -734,6 +757,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
/**
|
/**
|
||||||
* Build a notification matching the current state
|
* Build a notification matching the current state
|
||||||
*/
|
*/
|
||||||
|
/** Whether «RU sites direct» is on (any mode). */
|
||||||
|
private static boolean isRuSplitOn(Context context)
|
||||||
|
{
|
||||||
|
return PreferenceManager.getDefaultSharedPreferences(context).getBoolean(Constants.PREF_RUSPLIT_GEOIP_ENABLED, false);
|
||||||
|
}
|
||||||
|
|
||||||
private Notification buildNotification(boolean publicVersion)
|
private Notification buildNotification(boolean publicVersion)
|
||||||
{
|
{
|
||||||
/* the state service may not be bound yet when startForeground() is
|
/* the state service may not be bound yet when startForeground() is
|
||||||
@@ -818,7 +847,18 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
builder.setContentTitle(getString(s));
|
if (state == State.CONNECTED && error == ErrorState.NO_ERROR && !publicVersion && profile != null)
|
||||||
|
{ /* «Protected · Amsterdam» like the mockup shade */
|
||||||
|
builder.setContentTitle(getString(R.string.notif_protected, name));
|
||||||
|
}
|
||||||
|
else if (state == State.CONNECTING && error == ErrorState.NO_ERROR && !publicVersion && profile != null)
|
||||||
|
{ /* F_SysNotifs «Connecting to Helsinki…» */
|
||||||
|
builder.setContentTitle(getString(R.string.notif_connecting_to, name));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
builder.setContentTitle(getString(s));
|
||||||
|
}
|
||||||
|
|
||||||
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
|
int flags = PendingIntent.FLAG_UPDATE_CURRENT;
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||||
@@ -827,17 +867,33 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
}
|
}
|
||||||
if (!publicVersion)
|
if (!publicVersion)
|
||||||
{
|
{
|
||||||
|
if (state == State.CONNECTED && error == ErrorState.NO_ERROR && profile != null)
|
||||||
|
{ /* «Pause 15 min» and «Change» as on the mockup shade */
|
||||||
|
Intent pause = new Intent(getApplicationContext(), PauseReceiver.class).setAction(PauseManager.ACTION_PAUSE);
|
||||||
|
builder.addAction(R.drawable.ic_notification_connecting, getString(R.string.notif_pause),
|
||||||
|
PendingIntent.getBroadcast(getApplicationContext(), 1, pause, flags));
|
||||||
|
Intent change = new Intent(getApplicationContext(), MainActivity.class)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
.putExtra(MainActivity.EXTRA_OPEN_SERVERS, true);
|
||||||
|
builder.addAction(R.drawable.ic_notification, getString(R.string.notif_change),
|
||||||
|
PendingIntent.getActivity(getApplicationContext(), 2, change, flags));
|
||||||
|
}
|
||||||
if (add_action)
|
if (add_action)
|
||||||
{
|
{
|
||||||
Intent intent = new Intent(getApplicationContext(), VpnProfileControlActivity.class);
|
Intent intent = new Intent(getApplicationContext(), VpnProfileControlActivity.class);
|
||||||
intent.setAction(VpnProfileControlActivity.DISCONNECT);
|
intent.setAction(VpnProfileControlActivity.DISCONNECT);
|
||||||
PendingIntent pending = PendingIntent.getActivity(getApplicationContext(), 0, intent,
|
PendingIntent pending = PendingIntent.getActivity(getApplicationContext(), 0, intent,
|
||||||
flags);
|
flags);
|
||||||
builder.addAction(R.drawable.ic_notification_disconnect, getString(R.string.disconnect), pending);
|
builder.addAction(R.drawable.ic_notification_disconnect,
|
||||||
|
getString(state == State.CONNECTING ? R.string.notif_stop : R.string.disconnect), pending);
|
||||||
}
|
}
|
||||||
if (error == ErrorState.NO_ERROR)
|
if (error == ErrorState.NO_ERROR && state == State.CONNECTING)
|
||||||
{
|
{
|
||||||
builder.setContentText(name);
|
builder.setContentText(getString(R.string.notif_connecting_text));
|
||||||
|
}
|
||||||
|
else if (error == ErrorState.NO_ERROR)
|
||||||
|
{
|
||||||
|
builder.setContentText(state == State.CONNECTED && isRuSplitOn(this) ? getString(R.string.notif_ru_direct) : name);
|
||||||
}
|
}
|
||||||
builder.setPublicVersion(buildNotification(true));
|
builder.setPublicVersion(buildNotification(true));
|
||||||
}
|
}
|
||||||
@@ -895,6 +951,21 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
*/
|
*/
|
||||||
private void setState(State state)
|
private void setState(State state)
|
||||||
{
|
{
|
||||||
|
/* the daily traffic history samples the counters while the tunnel is up */
|
||||||
|
if (state == State.CONNECTED)
|
||||||
|
{
|
||||||
|
TrafficHistory.start(this);
|
||||||
|
AlwaysOnVpn.update(this);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
TrafficHistory.stop(this);
|
||||||
|
}
|
||||||
|
if (state == State.DISABLED)
|
||||||
|
{ /* the local DoH/DoT resolver lives only with a tunnel */
|
||||||
|
setDnsRedirect(null, 0);
|
||||||
|
org.strongswan.android.logic.protection.SecureDns.stop("disconnected");
|
||||||
|
}
|
||||||
synchronized (mServiceLock)
|
synchronized (mServiceLock)
|
||||||
{
|
{
|
||||||
if (mService != null)
|
if (mService != null)
|
||||||
@@ -969,12 +1040,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
switch (status)
|
switch (status)
|
||||||
{
|
{
|
||||||
case STATE_CHILD_SA_DOWN:
|
case STATE_CHILD_SA_DOWN:
|
||||||
|
logSaDown();
|
||||||
if (!mIsDisconnecting)
|
if (!mIsDisconnecting)
|
||||||
{
|
{
|
||||||
setState(State.CONNECTING);
|
setState(State.CONNECTING);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case STATE_CHILD_SA_UP:
|
case STATE_CHILD_SA_UP:
|
||||||
|
mSaUpSince = SystemClock.elapsedRealtime();
|
||||||
|
mRetransmitTimeout = false;
|
||||||
|
mReauth = false;
|
||||||
setState(State.CONNECTED);
|
setState(State.CONNECTED);
|
||||||
scheduleDnsDiagnostics();
|
scheduleDnsDiagnostics();
|
||||||
scheduleRouteDiagnostics();
|
scheduleRouteDiagnostics();
|
||||||
@@ -1003,12 +1078,49 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
case STATE_GENERIC_ERROR:
|
case STATE_GENERIC_ERROR:
|
||||||
setErrorDisconnect(ErrorState.GENERIC_ERROR);
|
setErrorDisconnect(ErrorState.GENERIC_ERROR);
|
||||||
break;
|
break;
|
||||||
|
case STATE_RETRANSMIT_TIMEOUT:
|
||||||
|
mRetransmitTimeout = true;
|
||||||
|
break;
|
||||||
|
case STATE_REAUTH:
|
||||||
|
mReauth = true;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
Log.e(TAG, "Unknown status code received");
|
Log.e(TAG, "Unknown status code received");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes why an established SA went down to the diagnostic log (plan 8.1).
|
||||||
|
* Does not touch the connection state.
|
||||||
|
*/
|
||||||
|
private void logSaDown()
|
||||||
|
{
|
||||||
|
long up = mSaUpSince;
|
||||||
|
boolean timeout = mRetransmitTimeout;
|
||||||
|
boolean reauth = mReauth;
|
||||||
|
mRetransmitTimeout = false;
|
||||||
|
mReauth = false;
|
||||||
|
if (up == 0)
|
||||||
|
{ /* no SA was up: a failed first connect is logged elsewhere */
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mSaUpSince = 0;
|
||||||
|
long now = SystemClock.elapsedRealtime();
|
||||||
|
long netAgo = mLastNetworkChange == 0 ? -1 : now - mLastNetworkChange;
|
||||||
|
SaClose why = SaClose.classify(mIsDisconnecting, reauth, timeout, netAgo);
|
||||||
|
String count = "";
|
||||||
|
if (why.getDrop())
|
||||||
|
{
|
||||||
|
long wall = System.currentTimeMillis();
|
||||||
|
count = ", drop #" + mSaDropCounter.next(wall, TimeZone.getDefault().getOffset(wall)) +
|
||||||
|
" today (resets with the process)";
|
||||||
|
}
|
||||||
|
DiagLog.i(TAG, "SA down: " + why.getLabel() + ", was up " + ((now - up) / 1000) + "s, network change " +
|
||||||
|
(netAgo < 0 ? "none" : (netAgo / 1000) + "s ago") + count +
|
||||||
|
(why == SaClose.OTHER ? " (no retransmit-timeout alert; MOBIKE path probing gives up without one)" : ""));
|
||||||
|
}
|
||||||
|
|
||||||
/** Delay before the DNS chain diagnostics run, so validation settles first. */
|
/** Delay before the DNS chain diagnostics run, so validation settles first. */
|
||||||
private static final long DNS_DIAG_DELAY_MS = 10_000;
|
private static final long DNS_DIAG_DELAY_MS = 10_000;
|
||||||
private static final String DNS_DIAG_PROBE = "one.one.one.one";
|
private static final String DNS_DIAG_PROBE = "one.one.one.one";
|
||||||
@@ -1022,6 +1134,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
sDnsDiagAt = SystemClock.elapsedRealtime();
|
||||||
mDiagExecutor.execute(() -> {
|
mDiagExecutor.execute(() -> {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1079,6 +1192,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
&& vpnCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
|
&& vpnCaps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
|
||||||
+ ", dns=" + (lp != null ? lp.getDnsServers() : "?")
|
+ ", dns=" + (lp != null ? lp.getDnsServers() : "?")
|
||||||
+ ", routes=" + (lp != null ? lp.getRoutes().size() : "?"));
|
+ ", routes=" + (lp != null ? lp.getRoutes().size() : "?"));
|
||||||
|
if (lp != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||||
|
{ /* whether Android validated the strict private DNS server over the tunnel */
|
||||||
|
DiagLog.i(TAG, "dns diag: vpn privateDns active=" + lp.isPrivateDnsActive()
|
||||||
|
+ ", name=" + lp.getPrivateDnsServerName());
|
||||||
|
}
|
||||||
if (underlying != null)
|
if (underlying != null)
|
||||||
{
|
{
|
||||||
NetworkCapabilities uCaps = cm.getNetworkCapabilities(underlying);
|
NetworkCapabilities uCaps = cm.getNetworkCapabilities(underlying);
|
||||||
@@ -1131,8 +1249,17 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* a flapping tunnel must not repeat the probes (three lookups and a TCP
|
||||||
|
* connect per DoT address) on every reconnect: the radio pays for each */
|
||||||
|
private static final long DNS_DIAG_MIN_GAP_MS = 10 * 60 * 1000;
|
||||||
|
private static volatile long sDnsDiagAt;
|
||||||
|
|
||||||
private void scheduleDnsDiagnostics()
|
private void scheduleDnsDiagnostics()
|
||||||
{
|
{
|
||||||
|
if (sDnsDiagAt != 0 && SystemClock.elapsedRealtime() - sDnsDiagAt < DNS_DIAG_MIN_GAP_MS)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
mHandler.removeCallbacks(mDnsDiagRunnable);
|
mHandler.removeCallbacks(mDnsDiagRunnable);
|
||||||
mHandler.postDelayed(mDnsDiagRunnable, DNS_DIAG_DELAY_MS);
|
mHandler.postDelayed(mDnsDiagRunnable, DNS_DIAG_DELAY_MS);
|
||||||
}
|
}
|
||||||
@@ -1170,6 +1297,29 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
RuIpListManager list = RuIpListManager.getInstance(context);
|
RuIpListManager list = RuIpListManager.getInstance(context);
|
||||||
List<IPRange> v4 = enabled ? list.getExcludedV4() : Collections.emptyList();
|
List<IPRange> v4 = enabled ? list.getExcludedV4() : Collections.emptyList();
|
||||||
List<IPRange> v6 = enabled ? list.getExcludedV6() : Collections.emptyList();
|
List<IPRange> v6 = enabled ? list.getExcludedV6() : Collections.emptyList();
|
||||||
|
if (enabled)
|
||||||
|
{ /* user rules: addresses and resolved sites that always go direct */
|
||||||
|
List<String> direct = org.strongswan.android.logic.routing.RoutingManager.getInstance(context).directCidrs();
|
||||||
|
if (!direct.isEmpty())
|
||||||
|
{
|
||||||
|
ArrayList<IPRange> d4 = new ArrayList<>(v4);
|
||||||
|
ArrayList<IPRange> d6 = new ArrayList<>(v6);
|
||||||
|
for (String cidr : direct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
IPRange range = new IPRange(cidr);
|
||||||
|
(range.getFrom() instanceof Inet4Address ? d4 : d6).add(range);
|
||||||
|
}
|
||||||
|
catch (Exception ignored)
|
||||||
|
{ /* stored prefixes are validated; skip anything stale */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* the engine expects sorted, non-overlapping ranges */
|
||||||
|
v4 = RuIpListManager.Companion.mergeRanges(d4);
|
||||||
|
v6 = RuIpListManager.Companion.mergeRanges(d6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
List<IPRange> force = new ArrayList<>();
|
List<IPRange> force = new ArrayList<>();
|
||||||
if (enabled)
|
if (enabled)
|
||||||
@@ -1308,6 +1458,19 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
+ ", ranges=" + sAppliedGeoExclusionsV4.size() + "v4/" + sAppliedGeoExclusionsV6.size() + "v6"
|
+ ", ranges=" + sAppliedGeoExclusionsV4.size() + "v4/" + sAppliedGeoExclusionsV6.size() + "v6"
|
||||||
+ ", forceTunnel=" + (sPrivateDnsServers.size() + sTunnelDnsServers.size() + sUserForcePrefixCount)
|
+ ", forceTunnel=" + (sPrivateDnsServers.size() + sTunnelDnsServers.size() + sUserForcePrefixCount)
|
||||||
: "none"));
|
: "none"));
|
||||||
|
/* inbound bytes that stop growing while outbound keeps going = a dead tunnel
|
||||||
|
* (the counters cover the current CHILD_SAs, so they restart at each rekey) */
|
||||||
|
try
|
||||||
|
{
|
||||||
|
long[] esp = queryTraffic();
|
||||||
|
if (esp != null && esp.length >= 2)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "route diag: esp in=" + esp[0] + " B, out=" + esp[1] + " B");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Throwable e)
|
||||||
|
{ /* native library not loaded: nothing to report */
|
||||||
|
}
|
||||||
|
|
||||||
long[] s = null;
|
long[] s = null;
|
||||||
try
|
try
|
||||||
@@ -1615,6 +1778,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
*/
|
*/
|
||||||
public native void initiate(String config);
|
public native void initiate(String config);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypted DNS: UDP/53 to vip (IPv4, 4 bytes) goes to 127.0.0.1:port;
|
||||||
|
* null or 0 turns it off.
|
||||||
|
*/
|
||||||
|
public native void setDnsRedirect(byte[] vip, int port);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sum inbound/outbound CHILD_SA bytes, provided by libandroidbridge.so.
|
* Sum inbound/outbound CHILD_SA bytes, provided by libandroidbridge.so.
|
||||||
* Static so it can be polled from the UI.
|
* Static so it can be polled from the UI.
|
||||||
@@ -1799,6 +1968,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mApplied = current;
|
mApplied = current;
|
||||||
|
mLastNetworkChange = SystemClock.elapsedRealtime();
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||||
{
|
{
|
||||||
@@ -2238,6 +2408,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
*/
|
*/
|
||||||
public class BuilderCache
|
public class BuilderCache
|
||||||
{
|
{
|
||||||
|
/* the user's DoH/DoT resolver when it answered the probe, else null */
|
||||||
|
private org.strongswan.android.logic.protection.SecureDnsConfig mSecureDns;
|
||||||
private final List<IPRange> mAddresses = new ArrayList<>();
|
private final List<IPRange> mAddresses = new ArrayList<>();
|
||||||
private final List<IPRange> mRoutesIPv4 = new ArrayList<>();
|
private final List<IPRange> mRoutesIPv4 = new ArrayList<>();
|
||||||
private final List<IPRange> mRoutesIPv6 = new ArrayList<>();
|
private final List<IPRange> mRoutesIPv6 = new ArrayList<>();
|
||||||
@@ -2338,9 +2510,25 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
sAppliedGeoExclusionsV6 = mGeoExcludedV6;
|
sAppliedGeoExclusionsV6 = mGeoExcludedV6;
|
||||||
|
|
||||||
|
|
||||||
if (profile.getDnsServers() != null)
|
/* F_Protection: encrypted DNS (own DoH/DoT) is used only if it answers
|
||||||
|
* now; otherwise the profile's DNS, never a silent dead resolver */
|
||||||
|
org.strongswan.android.logic.protection.SecureDnsConfig secure =
|
||||||
|
org.strongswan.android.logic.protection.Protection.secureDns(CharonVpnService.this);
|
||||||
|
if (secure != null)
|
||||||
{
|
{
|
||||||
for (String server : profile.getDnsServers().split("\\s+"))
|
mSecureDns = org.strongswan.android.logic.protection.SecureDns.ensureReady(secure) ? secure : null;
|
||||||
|
org.strongswan.android.logic.events.Events.secureDnsResult(CharonVpnService.this, mSecureDns != null);
|
||||||
|
if ("hostname".equals(Settings.Global.getString(getContentResolver(), "private_dns_mode")))
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "strict private DNS is on: the system resolver bypasses the tunnel DNS, own DoH/DoT has no effect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* F_Protection: a chosen resolver (filtering or not) wins over the profile's */
|
||||||
|
java.util.List<String> chosenDns = org.strongswan.android.logic.protection.Protection.dnsServers(CharonVpnService.this);
|
||||||
|
String dnsServers = chosenDns != null ? android.text.TextUtils.join(" ", chosenDns) : profile.getDnsServers();
|
||||||
|
if (dnsServers != null)
|
||||||
|
{
|
||||||
|
for (String server : dnsServers.split("\\s+"))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -2514,7 +2702,26 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
|||||||
{
|
{
|
||||||
builder.addAddress(address.getFrom(), address.getPrefix());
|
builder.addAddress(address.getFrom(), address.getPrefix());
|
||||||
}
|
}
|
||||||
if (includeDns)
|
/* encrypted DNS needs an IPv4 address in the tunnel to reach the
|
||||||
|
* virtual DNS address; otherwise the plain servers stay */
|
||||||
|
boolean secureDns = mSecureDns != null && mIPv4Seen;
|
||||||
|
if (mSecureDns != null && !mIPv4Seen)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "no IPv4 address in the tunnel, own DoH/DoT not used");
|
||||||
|
}
|
||||||
|
int securePort = secureDns ? org.strongswan.android.logic.protection.SecureDns.start(mSecureDns) : 0;
|
||||||
|
secureDns = securePort > 0;
|
||||||
|
if (!secureDns)
|
||||||
|
{
|
||||||
|
org.strongswan.android.logic.protection.SecureDns.stop("not used for this tunnel");
|
||||||
|
}
|
||||||
|
setDnsRedirect(secureDns ? org.strongswan.android.logic.protection.SecureDns.VIP_BYTES : null, securePort);
|
||||||
|
if (includeDns && secureDns)
|
||||||
|
{ /* the only server: any plain one would let the resolver bypass DoH/DoT */
|
||||||
|
builder.addDnsServer(org.strongswan.android.logic.protection.SecureDns.VIP);
|
||||||
|
builder.addRoute(org.strongswan.android.logic.protection.SecureDns.VIP, 32);
|
||||||
|
}
|
||||||
|
else if (includeDns)
|
||||||
{
|
{
|
||||||
if (mDnsServers.isEmpty())
|
if (mDnsServers.isEmpty())
|
||||||
{ /* neither the profile nor the server provided DNS servers:
|
{ /* neither the profile nor the server provided DNS servers:
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
/*
|
||||||
|
* 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.ActivityManager
|
||||||
|
import android.app.ApplicationExitInfo
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import org.strongswan.android.R
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notices that the previous process died by a crash and leaves an event with
|
||||||
|
* «Send a report». Android 11+ keeps the exit reasons itself, which also
|
||||||
|
* covers native crashes of the in-process daemon and ANRs; older versions only
|
||||||
|
* see Java exceptions, marked by the uncaught exception handler.
|
||||||
|
*/
|
||||||
|
object CrashWatch
|
||||||
|
{
|
||||||
|
private const val TAG = "CrashWatch"
|
||||||
|
private const val PREF_SEEN = "pref_crash_seen_at"
|
||||||
|
private const val PREF_JAVA_CRASH = "pref_java_crash_at"
|
||||||
|
const val EVENT_KEY = "crash"
|
||||||
|
|
||||||
|
/** From the uncaught exception handler: synchronous, the process dies right after. */
|
||||||
|
@JvmStatic
|
||||||
|
fun markJavaCrash(context: Context)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||||
|
.putLong(PREF_JAVA_CRASH, System.currentTimeMillis()).commit()
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At process start, off the main thread. */
|
||||||
|
@JvmStatic
|
||||||
|
fun check(context: Context)
|
||||||
|
{
|
||||||
|
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
var at = prefs.getLong(PREF_JAVA_CRASH, 0L)
|
||||||
|
var what = "java exception"
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val am = context.getSystemService(ActivityManager::class.java)
|
||||||
|
am?.getHistoricalProcessExitReasons(context.packageName, 0, 10)
|
||||||
|
?.filter { it.reason in setOf(ApplicationExitInfo.REASON_CRASH, ApplicationExitInfo.REASON_CRASH_NATIVE, ApplicationExitInfo.REASON_ANR) }
|
||||||
|
?.maxByOrNull { it.timestamp }
|
||||||
|
?.takeIf { it.timestamp > at }
|
||||||
|
?.let {
|
||||||
|
at = it.timestamp
|
||||||
|
what = "${reasonName(it.reason)}: ${it.description ?: ""}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "exit reasons unavailable: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val seen = prefs.getLong(PREF_SEEN, -1L)
|
||||||
|
if (seen < 0L)
|
||||||
|
{ /* first start with this feature: older history is not news */
|
||||||
|
prefs.edit().putLong(PREF_SEEN, maxOf(at, System.currentTimeMillis())).apply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (at <= seen)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prefs.edit().putLong(PREF_SEEN, at).apply()
|
||||||
|
DiagLog.i(TAG, "previous process crashed at $at ($what)")
|
||||||
|
EventLog.getInstance(context).record(
|
||||||
|
EventKind.WARN, EventCategory.APP, context.getString(R.string.event_crash_title),
|
||||||
|
context.getString(R.string.event_crash_text), EventAction.SEND_REPORT, EVENT_KEY,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reasonName(reason: Int): String = when (reason)
|
||||||
|
{
|
||||||
|
ApplicationExitInfo.REASON_CRASH_NATIVE -> "native crash"
|
||||||
|
ApplicationExitInfo.REASON_ANR -> "ANR"
|
||||||
|
else -> "java crash"
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-5
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -74,7 +77,11 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
|||||||
|
|
||||||
public void Register()
|
public void Register()
|
||||||
{
|
{
|
||||||
mEvents.clear();
|
synchronized (this)
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* 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
|
||||||
|
|
||||||
|
/** Why an established IKE SA went down; for the diagnostic log only (plan 8.1). */
|
||||||
|
enum class SaClose(val label: String, val drop: Boolean)
|
||||||
|
{
|
||||||
|
/* any deliberate disconnect: the user, a scenario, a pause or the trusted-network
|
||||||
|
* park (the line logged just before says which) */
|
||||||
|
USER("disconnect requested", false),
|
||||||
|
REAUTH("reauth", false),
|
||||||
|
RETRANSMIT_AFTER_NETWORK_CHANGE("retransmit-timeout after network change", true),
|
||||||
|
RETRANSMIT_TIMEOUT("retransmit-timeout", true),
|
||||||
|
NETWORK_CHANGE("network change", true),
|
||||||
|
OTHER("other (server close, rekey failure, MOBIKE probing)", true);
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** a network change this close before the SA went down is blamed first */
|
||||||
|
const val NETWORK_WINDOW_MS = 20_000L
|
||||||
|
|
||||||
|
/** sinceNetworkChangeMs is -1 when there was none */
|
||||||
|
@JvmStatic
|
||||||
|
fun classify(disconnecting: Boolean, reauth: Boolean, retransmitTimeout: Boolean,
|
||||||
|
sinceNetworkChangeMs: Long): SaClose
|
||||||
|
{
|
||||||
|
val network = sinceNetworkChangeMs in 0..NETWORK_WINDOW_MS
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
disconnecting -> USER
|
||||||
|
reauth -> REAUTH
|
||||||
|
retransmitTimeout && network -> RETRANSMIT_AFTER_NETWORK_CHANGE
|
||||||
|
retransmitTimeout -> RETRANSMIT_TIMEOUT
|
||||||
|
network -> NETWORK_CHANGE
|
||||||
|
else -> OTHER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Counts events per local calendar day; resets when the process restarts. */
|
||||||
|
class DailyCounter
|
||||||
|
{
|
||||||
|
private var day = Long.MIN_VALUE
|
||||||
|
private var count = 0
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun next(nowMs: Long, utcOffsetMs: Long): Int
|
||||||
|
{
|
||||||
|
val today = Math.floorDiv(nowMs + utcOffsetMs, 86_400_000L)
|
||||||
|
if (today != day)
|
||||||
|
{
|
||||||
|
day = today
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
return ++count
|
||||||
|
}
|
||||||
|
}
|
||||||
+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
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-51
@@ -24,10 +24,10 @@ 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 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;
|
||||||
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
private final String TAG = Scheduler.class.getSimpleName();
|
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);
|
||||||
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
* alarms instead of crashing with a SecurityException (connectivity then
|
* alarms instead of crashing with a SecurityException (connectivity then
|
||||||
* only degrades slightly while timers may be delayed in doze mode).
|
* only degrades slightly while timers may be delayed in doze mode).
|
||||||
*
|
*
|
||||||
* @param time wakeup time based on RTC
|
* @param time wakeup time based on elapsedRealtime
|
||||||
*/
|
*/
|
||||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||||
private void scheduleAlarm(long time)
|
private void scheduleAlarm(long time)
|
||||||
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
|
|||||||
{
|
{
|
||||||
exact = mManager.canScheduleExactAlarms();
|
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
|
try
|
||||||
{
|
{
|
||||||
if (exact)
|
if (exact)
|
||||||
{
|
{
|
||||||
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (SecurityException e)
|
catch (SecurityException e)
|
||||||
{ /* permission might have been revoked, or OEM restrictions apply */
|
{ /* permission might have been revoked, or OEM restrictions apply */
|
||||||
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
|
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
|
||||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,12 +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 */
|
|
||||||
scheduleAlarm(job.Time);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,30 +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)
|
|
||||||
{
|
|
||||||
scheduleAlarm(job.Time);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (ScheduledJob job : jobs)
|
for (String id : jobs)
|
||||||
{
|
{
|
||||||
executeJob(job.Id);
|
executeJob(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+9
@@ -99,6 +99,7 @@ public class StrongSwanApplication extends Application
|
|||||||
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
|
Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> {
|
||||||
DiagLog.i(TAG, "UNCAUGHT EXCEPTION on " + thread.getName() + ": " +
|
DiagLog.i(TAG, "UNCAUGHT EXCEPTION on " + thread.getName() + ": " +
|
||||||
Log.getStackTraceString(throwable));
|
Log.getStackTraceString(throwable));
|
||||||
|
CrashWatch.markJavaCrash(StrongSwanApplication.this);
|
||||||
if (previousHandler != null)
|
if (previousHandler != null)
|
||||||
{
|
{
|
||||||
previousHandler.uncaughtException(thread, throwable);
|
previousHandler.uncaughtException(thread, throwable);
|
||||||
@@ -107,6 +108,9 @@ public class StrongSwanApplication extends Application
|
|||||||
|
|
||||||
mDatabaseHelper = new DatabaseHelper(mContext);
|
mDatabaseHelper = new DatabaseHelper(mContext);
|
||||||
|
|
||||||
|
/* «the app crashed» event with a report button for the previous process */
|
||||||
|
mExecutorService.execute(() -> CrashWatch.check(mContext));
|
||||||
|
|
||||||
mManagedConfigurationService = new ManagedConfigurationService(mContext);
|
mManagedConfigurationService = new ManagedConfigurationService(mContext);
|
||||||
|
|
||||||
mTrustedCertificateManager = new ManagedTrustedCertificateManager(mContext, mExecutorService, mMainHandler,
|
mTrustedCertificateManager = new ManagedTrustedCertificateManager(mContext, mExecutorService, mMainHandler,
|
||||||
@@ -139,6 +143,11 @@ public class StrongSwanApplication extends Application
|
|||||||
|
|
||||||
/* checks the update storage for a newer version of the app (if enabled and due) */
|
/* checks the update storage for a newer version of the app (if enabled and due) */
|
||||||
UpdateManager.getInstance(this).start();
|
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()
|
||||||
|
|||||||
+69
@@ -34,6 +34,8 @@ import org.strongswan.android.data.VpnProfile;
|
|||||||
import org.strongswan.android.ui.widget.ShxWidgetState;
|
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.logic.connectivity.WhiteListManager;
|
||||||
@@ -88,6 +90,9 @@ public class VpnStateService extends Service
|
|||||||
private long mRetryIn;
|
private long mRetryIn;
|
||||||
/* true while the countdown is parked because the ISP whitelist blocks the server */
|
/* true while the countdown is parked because the ISP whitelist blocks the server */
|
||||||
private boolean mRetryFrozen;
|
private boolean mRetryFrozen;
|
||||||
|
/* when the pending retry is due in elapsedRealtime: the countdown itself
|
||||||
|
* runs in uptime and stands still while the device sleeps */
|
||||||
|
private long mRetryDue;
|
||||||
/* timestamp of the last network-change-triggered immediate retry */
|
/* timestamp of the last network-change-triggered immediate retry */
|
||||||
private long mLastNetworkRetry;
|
private long mLastNetworkRetry;
|
||||||
/* double-start guard: profile and issue time of the last connect() that
|
/* double-start guard: profile and issue time of the last connect() that
|
||||||
@@ -379,6 +384,7 @@ public class VpnStateService extends Service
|
|||||||
else
|
else
|
||||||
{ /* the user explicitly disconnected, so the watchdog must not restore
|
{ /* the user explicitly disconnected, so the watchdog must not restore
|
||||||
* the connection after this */
|
* the connection after this */
|
||||||
|
Events.connectionStopped(this);
|
||||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||||
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
||||||
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply();
|
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply();
|
||||||
@@ -431,6 +437,8 @@ public class VpnStateService extends Service
|
|||||||
{
|
{
|
||||||
/* 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)
|
||||||
{
|
{
|
||||||
@@ -444,6 +452,10 @@ public class VpnStateService extends Service
|
|||||||
{
|
{
|
||||||
uuid = mProfile.getUUID().toString();
|
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
|
/* double-start guard: while a healthy connect attempt for the same
|
||||||
* profile is in flight (or was just issued and the service has not
|
* profile is in flight (or was just issued and the service has not
|
||||||
* reached startConnection() yet) a second start intent only churns
|
* reached startConnection() yet) a second start intent only churns
|
||||||
@@ -452,6 +464,7 @@ public class VpnStateService extends Service
|
|||||||
* here within 20 seconds. Deliberate retries during an error backoff
|
* here within 20 seconds. Deliberate retries during an error backoff
|
||||||
* (mError set) and requests for another profile always pass. */
|
* (mError set) and requests for another profile always pass. */
|
||||||
if (uuid != null && uuid.equals(mLastConnectUuid) && mError == ErrorState.NO_ERROR
|
if (uuid != null && uuid.equals(mLastConnectUuid) && mError == ErrorState.NO_ERROR
|
||||||
|
&& !profileInfo.getBoolean(Resilience.KEY_INTERNAL, false)
|
||||||
&& (mState == State.CONNECTING
|
&& (mState == State.CONNECTING
|
||||||
|| ((mState == State.DISABLED || mState == State.DISCONNECTING)
|
|| ((mState == State.DISABLED || mState == State.DISCONNECTING)
|
||||||
&& SystemClock.uptimeMillis() - mLastConnectIssued < CONNECT_DEDUPE_MS)))
|
&& SystemClock.uptimeMillis() - mLastConnectIssued < CONNECT_DEDUPE_MS)))
|
||||||
@@ -600,6 +613,37 @@ public class VpnStateService extends Service
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether a retry countdown is pending (an error with an automatic retry). */
|
||||||
|
public boolean isRetryPending()
|
||||||
|
{
|
||||||
|
return mRetryTimeout > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the pending retry now if its time has passed on the wall clock.
|
||||||
|
* The countdown ticks in uptime, so it stands still while the device
|
||||||
|
* sleeps; the watchdog's wakeup alarm calls this so a tunnel that dropped
|
||||||
|
* overnight comes back without the user picking up the phone. Never
|
||||||
|
* shortens a backoff. Main thread only.
|
||||||
|
*
|
||||||
|
* @return true if a reconnect was started
|
||||||
|
*/
|
||||||
|
public boolean retryIfOverdue()
|
||||||
|
{
|
||||||
|
if (mRetryTimeout <= 0 || mProfile == null || SystemClock.elapsedRealtime() < mRetryDue ||
|
||||||
|
WhiteListManager.getInstance(this).shouldBlockReconnect())
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
DiagLog.i(TAG, "retry overdue (device slept through the countdown), reconnecting");
|
||||||
|
mRetryTimeout = 0;
|
||||||
|
mRetryIn = 0;
|
||||||
|
mRetryFrozen = false;
|
||||||
|
mHandler.removeMessages(RETRY_MSG);
|
||||||
|
connect(null, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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.
|
||||||
@@ -685,6 +729,11 @@ 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)
|
if (state == State.CONNECTING)
|
||||||
{ /* reentered while the daemon reestablishes the tunnel; bound
|
{ /* reentered while the daemon reestablishes the tunnel; bound
|
||||||
* the total time so a lost alert can't strand us here */
|
* the total time so a lost alert can't strand us here */
|
||||||
@@ -717,6 +766,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);
|
||||||
@@ -726,6 +783,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;
|
||||||
@@ -792,6 +860,7 @@ public class VpnStateService extends Service
|
|||||||
{
|
{
|
||||||
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
|
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
|
||||||
mRetryFrozen = false;
|
mRetryFrozen = false;
|
||||||
|
mRetryDue = SystemClock.elapsedRealtime() + mRetryTimeout;
|
||||||
DiagLog.i(TAG, "error " + error + ", retry in " + (mRetryTimeout / 1000) + "s");
|
DiagLog.i(TAG, "error " + error + ", retry in " + (mRetryTimeout / 1000) + "s");
|
||||||
/* never run two countdown chains in parallel (double-speed countdown,
|
/* never run two countdown chains in parallel (double-speed countdown,
|
||||||
* double notification updates and two reconnects at zero) */
|
* double notification updates and two reconnects at zero) */
|
||||||
|
|||||||
+54
-9
@@ -30,6 +30,8 @@ import android.util.Log;
|
|||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
import org.strongswan.android.data.VpnProfileDataSource;
|
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.autoconnect.AutoConnectManager;
|
||||||
import org.strongswan.android.logic.connectivity.WhiteListManager;
|
import org.strongswan.android.logic.connectivity.WhiteListManager;
|
||||||
import org.strongswan.android.utils.Constants;
|
import org.strongswan.android.utils.Constants;
|
||||||
@@ -152,15 +154,16 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether a check counts towards the steady-state backoff of a live
|
* Whether a check counts towards the steady-state backoff of a live
|
||||||
* process: connected, retrying/connecting, or held back by the ISP
|
* process: connected, connecting, or held back by the ISP whitelist. In
|
||||||
* whitelist. In all of these the in-process machinery owns recovery and
|
* all of these the in-process machinery owns recovery and the alarm can
|
||||||
* the alarm can only guard against the process dying - firing every
|
* only guard against the process dying - firing every minute (and waking
|
||||||
* minute (and waking Doze every 10 minutes) for hours of failed retries
|
* Doze every 10 minutes) for hours of whitelist blocking only burns
|
||||||
* or whitelist blocking only burns battery.
|
* battery.
|
||||||
*/
|
*/
|
||||||
static boolean isAliveSteady(Action action, boolean processAlive, boolean parked)
|
static boolean isAliveSteady(Action action, boolean processAlive, boolean parked, boolean retryPending)
|
||||||
{
|
{ /* a pending retry needs the fast wakeup alarm: its countdown stands
|
||||||
return action == Action.NONE && processAlive && !parked;
|
* still while the device sleeps and only this alarm resumes it */
|
||||||
|
return action == Action.NONE && processAlive && !parked && !retryPending;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -188,7 +191,13 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
Action action = decideAction(enabled, uuid != null, autoExpected, whitelistBlocks, serviceTracking,
|
Action action = decideAction(enabled, uuid != null, autoExpected, whitelistBlocks, serviceTracking,
|
||||||
processAlive, wifiPresent, autoProfileConfigured);
|
processAlive, wifiPresent, autoProfileConfigured);
|
||||||
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
|
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
|
||||||
boolean alive = isAliveSteady(action, processAlive, parked);
|
/* the whitelist parks retries on purpose, that state may back off */
|
||||||
|
boolean retryPending = serviceTracking && !whitelistBlocks && service.isRetryPending();
|
||||||
|
if (retryPending && service.retryIfOverdue())
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "resumed a retry the device slept through");
|
||||||
|
}
|
||||||
|
boolean alive = isAliveSteady(action, processAlive, parked, retryPending);
|
||||||
if (parked)
|
if (parked)
|
||||||
{
|
{
|
||||||
sParkedChecks++;
|
sParkedChecks++;
|
||||||
@@ -230,6 +239,7 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
return;
|
return;
|
||||||
case RESTART:
|
case RESTART:
|
||||||
{
|
{
|
||||||
|
boolean autoRestart = uuid == null;
|
||||||
if (uuid == null)
|
if (uuid == null)
|
||||||
{
|
{
|
||||||
uuid = resolveAutoProfile(context, prefs);
|
uuid = resolveAutoProfile(context, prefs);
|
||||||
@@ -239,12 +249,29 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
return;
|
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
|
/* either the process died (the receiver runs in a fresh one) or the
|
||||||
* service is idle while a connection is expected - restart it. In
|
* service is idle while a connection is expected - restart it. In
|
||||||
* the rare second case this may duplicate a pending in-process retry
|
* the rare second case this may duplicate a pending in-process retry
|
||||||
* once, which is harmless (retry flag set, same profile). */
|
* once, which is harmless (retry flag set, same profile). */
|
||||||
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
||||||
DiagLog.i(TAG, "restarting the VPN process");
|
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);
|
Intent start = new Intent(context, CharonVpnService.class);
|
||||||
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
||||||
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
||||||
@@ -269,6 +296,24 @@ public class WatchdogReceiver extends BroadcastReceiver
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
* Profile the trusted-networks auto session should use: the configured
|
||||||
* auto-connect profile, falling back to the most recently used one.
|
* auto-connect profile, falling back to the most recently used one.
|
||||||
|
|||||||
+76
-11
@@ -36,6 +36,8 @@ import org.json.JSONObject
|
|||||||
import org.strongswan.android.data.VpnProfileDataSource
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
import org.strongswan.android.logic.CharonVpnService
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
import org.strongswan.android.logic.DiagLog
|
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.VpnStateService
|
||||||
import org.strongswan.android.logic.WatchdogReceiver
|
import org.strongswan.android.logic.WatchdogReceiver
|
||||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||||
@@ -58,6 +60,10 @@ class AutoConnectManager private constructor(
|
|||||||
val enabled: Boolean = false,
|
val enabled: Boolean = false,
|
||||||
val profileUuid: String? = null,
|
val profileUuid: String? = null,
|
||||||
val trustedSsids: Set<String> = emptySet(),
|
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
|
enum class NetworkKind
|
||||||
@@ -85,6 +91,8 @@ class AutoConnectManager private constructor(
|
|||||||
private var currentNetwork: NetworkKind? = null
|
private var currentNetwork: NetworkKind? = null
|
||||||
/* marks the session this manager started itself */
|
/* marks the session this manager started itself */
|
||||||
private val autoSession = AtomicBoolean(false)
|
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) */
|
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
|
||||||
private var lastParkAt = 0L
|
private var lastParkAt = 0L
|
||||||
|
|
||||||
@@ -117,10 +125,28 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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()
|
fun start()
|
||||||
{
|
{
|
||||||
config = loadConfig()
|
config = loadConfig()
|
||||||
if (!config.enabled)
|
if (!config.enabled && !scenariosNeedNetwork())
|
||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -180,8 +206,8 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
stop()
|
|
||||||
autoSession.set(false)
|
autoSession.set(false)
|
||||||
|
scenariosChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,8 +234,8 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
stop()
|
|
||||||
autoSession.set(false)
|
autoSession.set(false)
|
||||||
|
scenariosChanged()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,25 +250,51 @@ class AutoConnectManager private constructor(
|
|||||||
}
|
}
|
||||||
currentNetwork = kind
|
currentNetwork = kind
|
||||||
evaluate()
|
evaluate()
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun stateChanged()
|
override fun stateChanged()
|
||||||
{
|
{
|
||||||
val state = service?.state ?: return
|
val state = service?.state ?: return
|
||||||
if (state == VpnStateService.State.DISABLED)
|
if (state != VpnStateService.State.DISABLED)
|
||||||
|
{
|
||||||
|
awaitingRestoredTunnel = false
|
||||||
|
}
|
||||||
|
else if (!awaitingRestoredTunnel)
|
||||||
{
|
{
|
||||||
autoSession.set(false)
|
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.
|
* Pure decision function, unit-testable.
|
||||||
*/
|
*/
|
||||||
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action =
|
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action
|
||||||
evaluateDecision(
|
{
|
||||||
|
val action = evaluateDecision(
|
||||||
config, network, vpnActive, autoSession,
|
config, network, vpnActive, autoSession,
|
||||||
whiteListBlocksReconnect = WhiteListManager.getInstance(context).shouldBlockReconnect(),
|
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. */
|
/** Re-runs the connect decision, e.g. when the white-list gate clears. */
|
||||||
fun reevaluate()
|
fun reevaluate()
|
||||||
@@ -252,8 +304,8 @@ class AutoConnectManager private constructor(
|
|||||||
|
|
||||||
private fun evaluate()
|
private fun evaluate()
|
||||||
{
|
{
|
||||||
if (!running.get())
|
if (!running.get() || !config.enabled)
|
||||||
{
|
{ /* running only for the scenarios: they decide, not the network rules */
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val svc = service ?: return
|
val svc = service ?: return
|
||||||
@@ -274,6 +326,7 @@ class AutoConnectManager private constructor(
|
|||||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||||
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, true).apply()
|
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, true).apply()
|
||||||
svc.disconnect(true)
|
svc.disconnect(true)
|
||||||
|
Events.autoConnect(context, parked = true)
|
||||||
}
|
}
|
||||||
Action.IGNORE ->
|
Action.IGNORE ->
|
||||||
{
|
{
|
||||||
@@ -332,6 +385,12 @@ class AutoConnectManager private constructor(
|
|||||||
Log.e(TAG, "VpnService.prepare failed", e)
|
Log.e(TAG, "VpnService.prepare failed", e)
|
||||||
true
|
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)
|
if (!needsConsent)
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "auto session starting, profile=$uuid")
|
DiagLog.i(TAG, "auto session starting, profile=$uuid")
|
||||||
@@ -341,9 +400,11 @@ class AutoConnectManager private constructor(
|
|||||||
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply()
|
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply()
|
||||||
val extras = Bundle()
|
val extras = Bundle()
|
||||||
extras.putString(VpnProfileDataSource.KEY_UUID, uuid)
|
extras.putString(VpnProfileDataSource.KEY_UUID, uuid)
|
||||||
|
livePassword?.let { extras.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||||
/* automatic start: keep the previous session's charon log */
|
/* automatic start: keep the previous session's charon log */
|
||||||
extras.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true)
|
extras.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true)
|
||||||
svc.connect(extras, true)
|
svc.connect(extras, true)
|
||||||
|
Events.autoConnect(context, parked = false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
||||||
@@ -375,6 +436,8 @@ class AutoConnectManager private constructor(
|
|||||||
arr.optString(i)?.takeIf { it.isNotBlank() }
|
arr.optString(i)?.takeIf { it.isNotBlank() }
|
||||||
}.toSet()
|
}.toSet()
|
||||||
} ?: emptySet(),
|
} ?: emptySet(),
|
||||||
|
parkOnTrusted = obj.optBoolean("parkOnTrusted", true),
|
||||||
|
connectOnUntrusted = obj.optBoolean("connectOnUntrusted", true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
catch (_: JSONException)
|
catch (_: JSONException)
|
||||||
@@ -389,6 +452,8 @@ class AutoConnectManager private constructor(
|
|||||||
obj.put("enabled", config.enabled)
|
obj.put("enabled", config.enabled)
|
||||||
obj.put("profileUuid", config.profileUuid ?: JSONObject.NULL)
|
obj.put("profileUuid", config.profileUuid ?: JSONObject.NULL)
|
||||||
obj.put("trustedSsids", JSONArray(config.trustedSsids.sorted()))
|
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()
|
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,13 +508,13 @@ class AutoConnectManager private constructor(
|
|||||||
{
|
{
|
||||||
null -> Action.IGNORE
|
null -> Action.IGNORE
|
||||||
NetworkKind.TRUSTED_WIFI ->
|
NetworkKind.TRUSTED_WIFI ->
|
||||||
if (vpnActive && autoSession) Action.DISCONNECT else Action.IGNORE
|
if (config.parkOnTrusted && vpnActive && autoSession) Action.DISCONNECT else Action.IGNORE
|
||||||
/* SSID is redacted in background until location is readable. Keep an
|
/* SSID is redacted in background until location is readable. Keep an
|
||||||
* existing tunnel (do not treat unknown as untrusted) and still
|
* existing tunnel (do not treat unknown as untrusted) and still
|
||||||
* auto-start if the VPN is down. */
|
* auto-start if the VPN is down. */
|
||||||
NetworkKind.UNKNOWN_WIFI -> if (!vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
NetworkKind.UNKNOWN_WIFI -> if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
||||||
NetworkKind.UNTRUSTED_WIFI, NetworkKind.CELLULAR ->
|
NetworkKind.UNTRUSTED_WIFI, NetworkKind.CELLULAR ->
|
||||||
if (!vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+74
-1
@@ -30,6 +30,7 @@ import android.os.Build
|
|||||||
import android.os.Handler
|
import android.os.Handler
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
|
import android.os.SystemClock
|
||||||
import android.os.PowerManager
|
import android.os.PowerManager
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -43,7 +44,9 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
import org.strongswan.android.logic.VpnStateService
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.logic.events.Events
|
||||||
|
|
||||||
/** What to do when a check fails. */
|
/** What to do when a check fails. */
|
||||||
enum class HealthAction
|
enum class HealthAction
|
||||||
@@ -129,6 +132,9 @@ class HealthCheckManager private constructor(
|
|||||||
private var tunnelAttempts = 0
|
private var tunnelAttempts = 0
|
||||||
private var fastPollShifts = 0
|
private var fastPollShifts = 0
|
||||||
private var powerState = PowerState()
|
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 */
|
/* tracks screen/battery state so probing can back off while the device sleeps */
|
||||||
private val powerReceiver = object : BroadcastReceiver()
|
private val powerReceiver = object : BroadcastReceiver()
|
||||||
@@ -359,7 +365,31 @@ class HealthCheckManager private constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val gateway = svc.profile?.gateway
|
val gateway = svc.profile?.gateway
|
||||||
val targets = config.targets
|
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 mode = config.serverCheckMode
|
||||||
val timeout = config.timeoutMs
|
val timeout = config.timeoutMs
|
||||||
|
|
||||||
@@ -476,6 +506,7 @@ class HealthCheckManager private constructor(
|
|||||||
serverAttempts++
|
serverAttempts++
|
||||||
Log.i(TAG, "Reconnecting, server unreachable (attempt $serverAttempts/${config.serverMaxAttempts})")
|
Log.i(TAG, "Reconnecting, server unreachable (attempt $serverAttempts/${config.serverMaxAttempts})")
|
||||||
service?.reconnectPreservingLog()
|
service?.reconnectPreservingLog()
|
||||||
|
Events.healthReconnect(context, serverDown = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Decision.RECONNECT_TUNNEL ->
|
Decision.RECONNECT_TUNNEL ->
|
||||||
@@ -485,6 +516,7 @@ class HealthCheckManager private constructor(
|
|||||||
tunnelAttempts++
|
tunnelAttempts++
|
||||||
Log.i(TAG, "Reconnecting, tunnel dead (attempt $tunnelAttempts/${config.tunnelDeadMaxAttempts})")
|
Log.i(TAG, "Reconnecting, tunnel dead (attempt $tunnelAttempts/${config.tunnelDeadMaxAttempts})")
|
||||||
service?.reconnectPreservingLog()
|
service?.reconnectPreservingLog()
|
||||||
|
Events.healthReconnect(context, serverDown = false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Decision.IGNORE ->
|
Decision.IGNORE ->
|
||||||
@@ -606,6 +638,21 @@ class HealthCheckManager private constructor(
|
|||||||
tunnelAttempts = 0
|
tunnelAttempts = 0
|
||||||
fastPollShifts = 0
|
fastPollShifts = 0
|
||||||
pendingDecision = null
|
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()
|
private fun registerVpnCallback()
|
||||||
@@ -702,6 +749,32 @@ class HealthCheckManager private constructor(
|
|||||||
private const val FAST_POLL_MAX_SHIFTS = 3
|
private const val FAST_POLL_MAX_SHIFTS = 3
|
||||||
private const val AGGRESSIVE_INTERVAL_SEC = 5
|
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. */
|
/** Minimum probe interval in adaptive mode while the screen is off. */
|
||||||
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
||||||
|
|
||||||
|
|||||||
+11
@@ -44,6 +44,7 @@ import org.json.JSONArray
|
|||||||
import org.json.JSONException
|
import org.json.JSONException
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import org.strongswan.android.logic.VpnStateService
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.logic.events.Events
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
/** Whether the ISP currently blocks direct access to foreign hosts. */
|
/** Whether the ISP currently blocks direct access to foreign hosts. */
|
||||||
@@ -438,6 +439,16 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
|
|||||||
val before = machine
|
val before = machine
|
||||||
machine = before.advance(candidate)
|
machine = before.advance(candidate)
|
||||||
publish(round)
|
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)
|
if (machine.status == WhiteListStatus.ACTIVE)
|
||||||
{ /* keep watching for the whitelist to clear, at a low rate */
|
{ /* keep watching for the whitelist to clear, at a low rate */
|
||||||
backoffAttempt++
|
backoffAttempt++
|
||||||
|
|||||||
+442
@@ -0,0 +1,442 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
/** share the logs with support (F_Log «Send») */
|
||||||
|
SEND_REPORT,
|
||||||
|
/** the Android VPN settings (always-on, block without VPN) */
|
||||||
|
OPEN_VPN_SETTINGS,
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
/*
|
||||||
|
* 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Own DoH/DoT: warn when the probe failed (plain DNS is used), clear it when it works. */
|
||||||
|
@JvmStatic
|
||||||
|
fun secureDnsResult(context: Context, ok: Boolean)
|
||||||
|
{
|
||||||
|
val events = EventLog.getInstance(context)
|
||||||
|
if (ok)
|
||||||
|
{
|
||||||
|
events.resolve("secure-dns")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events.record(
|
||||||
|
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_secure_dns_title),
|
||||||
|
context.getString(R.string.event_secure_dns_text), EventAction.NONE, "secure-dns",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+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()
|
||||||
|
}
|
||||||
+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.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)
|
||||||
|
|
||||||
|
/** CUSTOM with a DoH/DoT address instead of IPs */
|
||||||
|
val secure: SecureDnsConfig? get() = if (choice == DnsChoice.CUSTOM) SecureDnsConfig.parse(custom) else null
|
||||||
|
|
||||||
|
/** null: keep the profile's (or the server's) DNS; a DoH/DoT choice is [secure] instead */
|
||||||
|
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 user's DoH/DoT resolver, or null. */
|
||||||
|
@JvmStatic
|
||||||
|
fun secureDns(context: Context): SecureDnsConfig? = get(context).secure
|
||||||
|
|
||||||
|
/** For CharonVpnService: the servers replacing the profile's ones, or null. */
|
||||||
|
@JvmStatic
|
||||||
|
fun dnsServers(context: Context): List<String>? = get(context).servers()
|
||||||
|
}
|
||||||
+528
@@ -0,0 +1,528 @@
|
|||||||
|
/*
|
||||||
|
* 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.os.SystemClock
|
||||||
|
import org.strongswan.android.logic.DiagLog
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.io.DataInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.io.InputStream
|
||||||
|
import java.io.OutputStream
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URL
|
||||||
|
import java.util.concurrent.ArrayBlockingQueue
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
|
import javax.net.ssl.HttpsURLConnection
|
||||||
|
import javax.net.ssl.SSLSocket
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
|
||||||
|
/** The user's own encrypted resolver: DoH (`https://host/path`) or DoT (`tls://host[:port]`). */
|
||||||
|
data class SecureDnsConfig(val doh: Boolean, val host: String, val port: Int, val url: String)
|
||||||
|
{
|
||||||
|
/** For the diag log: never the queried names, only where queries go. */
|
||||||
|
val label: String get() = if (doh) "DoH $url" else "DoT $host:$port"
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
/** null when the text is not a DoH/DoT address (plain IPs are handled by [ProtectionState.parseCustom]). */
|
||||||
|
@JvmStatic
|
||||||
|
fun parse(text: String): SecureDnsConfig?
|
||||||
|
{
|
||||||
|
val t = text.trim()
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
when
|
||||||
|
{
|
||||||
|
t.startsWith("https://", true) ->
|
||||||
|
{
|
||||||
|
val uri = URI(t)
|
||||||
|
val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null
|
||||||
|
if (uri.userInfo != null || uri.query != null || uri.fragment != null) return null
|
||||||
|
val path = uri.rawPath?.takeIf { it.isNotEmpty() && it != "/" } ?: "/dns-query"
|
||||||
|
val port = if (uri.port > 0) uri.port else 443
|
||||||
|
val portPart = if (uri.port > 0) ":${uri.port}" else ""
|
||||||
|
SecureDnsConfig(true, host, port, "https://$host$portPart$path")
|
||||||
|
}
|
||||||
|
t.startsWith("tls://", true) ->
|
||||||
|
{
|
||||||
|
val uri = URI(t)
|
||||||
|
val host = uri.host?.takeIf { it.isNotEmpty() } ?: return null
|
||||||
|
if (uri.userInfo != null || (uri.rawPath ?: "").let { it.isNotEmpty() && it != "/" }) return null
|
||||||
|
val port = if (uri.port > 0) uri.port else 853
|
||||||
|
SecureDnsConfig(false, host, port, "tls://$host:$port")
|
||||||
|
}
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DNS wire bits the resolver needs; pure functions, unit tested. */
|
||||||
|
object SecureDnsCodec
|
||||||
|
{
|
||||||
|
/** A minimal query (RD set) for [name] and [type], e.g. 1 = A. */
|
||||||
|
@JvmStatic
|
||||||
|
fun query(id: Int, name: String, type: Int): ByteArray
|
||||||
|
{
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
out.write(byteArrayOf((id shr 8).toByte(), id.toByte(), 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0))
|
||||||
|
name.trimEnd('.').split('.').filter { it.isNotEmpty() }.forEach {
|
||||||
|
val label = it.toByteArray(Charsets.US_ASCII)
|
||||||
|
out.write(label.size)
|
||||||
|
out.write(label)
|
||||||
|
}
|
||||||
|
out.write(byteArrayOf(0, (type shr 8).toByte(), type.toByte(), 0, 1))
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SERVFAIL for [query]: header with QR and RA set, RCODE 2, the question
|
||||||
|
* kept and no records. null if the query is not even a header.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
fun servfail(query: ByteArray): ByteArray?
|
||||||
|
{
|
||||||
|
if (query.size < 12) return null
|
||||||
|
val end = questionEnd(query) ?: 12
|
||||||
|
val reply = query.copyOf(end)
|
||||||
|
reply[2] = ((query[2].toInt() and 0x79) or 0x80).toByte() /* QR, keep opcode and RD */
|
||||||
|
reply[3] = 0x82.toByte() /* RA, RCODE 2 */
|
||||||
|
val qd = if (end > 12) 1 else 0
|
||||||
|
reply[4] = 0; reply[5] = qd.toByte()
|
||||||
|
for (i in 6 until 12) reply[i] = 0
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End of the first question, or null if it is malformed / absent. */
|
||||||
|
private fun questionEnd(msg: ByteArray): Int?
|
||||||
|
{
|
||||||
|
if ((msg[4].toInt() or msg[5].toInt()) == 0) return null
|
||||||
|
var i = 12
|
||||||
|
while (i < msg.size)
|
||||||
|
{
|
||||||
|
val len = msg[i].toInt() and 0xff
|
||||||
|
if (len == 0) return (i + 5).takeIf { it <= msg.size }
|
||||||
|
if (len and 0xc0 != 0) return null
|
||||||
|
i += len + 1
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 7858: two-byte length prefix. */
|
||||||
|
@JvmStatic
|
||||||
|
fun frame(msg: ByteArray): ByteArray =
|
||||||
|
byteArrayOf((msg.size shr 8).toByte(), msg.size.toByte()) + msg
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun readFrame(input: InputStream): ByteArray
|
||||||
|
{
|
||||||
|
val data = DataInputStream(input)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val len = data.readUnsignedShort()
|
||||||
|
val msg = ByteArray(len)
|
||||||
|
data.readFully(msg)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
catch (e: java.io.EOFException)
|
||||||
|
{ /* some servers drop names they refuse to answer by closing the connection */
|
||||||
|
throw IOException("connection closed without a reply")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same transaction id: a reply that belongs to the query. */
|
||||||
|
@JvmStatic
|
||||||
|
fun sameId(a: ByteArray, b: ByteArray): Boolean = a.size >= 2 && b.size >= 2 && a[0] == b[0] && a[1] == b[1]
|
||||||
|
|
||||||
|
/** Throws unless [reply] is a DNS response (header, QR bit) to [query]. */
|
||||||
|
@JvmStatic
|
||||||
|
fun checkReply(query: ByteArray, reply: ByteArray)
|
||||||
|
{
|
||||||
|
if (reply.isEmpty()) throw IOException("empty reply")
|
||||||
|
if (reply.size < 12 || (reply[2].toInt() and 0x80) == 0) throw IOException("not a DNS reply")
|
||||||
|
if (!sameId(query, reply)) throw IOException("reply id mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local resolver behind the tunnel's DNS address [VIP]. The native DNS proxy
|
||||||
|
* hands UDP queries for [VIP] to 127.0.0.1:<port>; each one goes to the user's
|
||||||
|
* DoH/DoT server. Our own app is excluded from the VPN, so the upstream
|
||||||
|
* connection uses the physical network (encrypted) and its hostname resolves
|
||||||
|
* there, without looping through this resolver. A failed query is answered
|
||||||
|
* with SERVFAIL, never by plain DNS.
|
||||||
|
*/
|
||||||
|
object SecureDns
|
||||||
|
{
|
||||||
|
private const val TAG = "SecureDns"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tunnel's DNS address while encrypted DNS is on. 198.18.0.0/15 is
|
||||||
|
* the benchmarking range (RFC 2544): never routed on the internet and not
|
||||||
|
* used for VPN pools, so it cannot shadow a real server-pushed subnet.
|
||||||
|
*/
|
||||||
|
const val VIP = "198.18.0.53"
|
||||||
|
@JvmField
|
||||||
|
val VIP_BYTES = byteArrayOf(198.toByte(), 18, 0, 53)
|
||||||
|
|
||||||
|
private const val TIMEOUT_MS = 3000
|
||||||
|
private const val PROBE_TTL_MS = 10 * 60 * 1000L
|
||||||
|
private const val PROBE_FAIL_TTL_MS = 60 * 1000L
|
||||||
|
private const val SUMMARY_EVERY_MS = 15 * 60 * 1000L
|
||||||
|
private const val WORKERS = 4
|
||||||
|
|
||||||
|
private var server: DatagramSocket? = null
|
||||||
|
private var serverConfig: SecureDnsConfig? = null
|
||||||
|
private var pool: ThreadPoolExecutor? = null
|
||||||
|
private val dot = ThreadLocal<DotConnection?>()
|
||||||
|
|
||||||
|
private var probedConfig: SecureDnsConfig? = null
|
||||||
|
private var probedAt = 0L
|
||||||
|
private var probeOk = false
|
||||||
|
|
||||||
|
private val queries = AtomicLong()
|
||||||
|
private val failures = AtomicLong()
|
||||||
|
private val totalMs = AtomicLong()
|
||||||
|
@Volatile private var lastSummary = 0L
|
||||||
|
@Volatile private var failing = false
|
||||||
|
private val loggedErrors = java.util.Collections.synchronizedSet(HashSet<String>())
|
||||||
|
|
||||||
|
/** Result of a probe: round trip in ms, or the failure. */
|
||||||
|
class Probe(val ms: Long, val error: String?)
|
||||||
|
{
|
||||||
|
val ok: Boolean get() = error == null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Names for the probe; any well-formed reply (any rcode) means the server
|
||||||
|
* speaks DoH/DoT. Not example.com: some servers silently drop it.
|
||||||
|
*/
|
||||||
|
private val PROBE_NAMES = arrayOf("one.one.one.one", "ya.ru")
|
||||||
|
|
||||||
|
/** One query (a second name if the first gets no reply); for the connect decision and the «Save» check. */
|
||||||
|
@JvmStatic
|
||||||
|
fun probe(config: SecureDnsConfig): Probe
|
||||||
|
{
|
||||||
|
val start = SystemClock.elapsedRealtime()
|
||||||
|
var error: String? = null
|
||||||
|
for (name in PROBE_NAMES)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val q = SecureDnsCodec.query((SystemClock.elapsedRealtime() and 0xffff).toInt(), name, 1)
|
||||||
|
SecureDnsCodec.checkReply(q, resolve(config, q))
|
||||||
|
error = null
|
||||||
|
break
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
error = describe(e)
|
||||||
|
if (e is java.net.SocketTimeoutException) break /* a dead server: no point waiting twice */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val result = Probe(SystemClock.elapsedRealtime() - start, error)
|
||||||
|
remember(config, result.ok)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun remember(config: SecureDnsConfig, ok: Boolean)
|
||||||
|
{
|
||||||
|
probedConfig = config
|
||||||
|
probedAt = SystemClock.elapsedRealtime()
|
||||||
|
probeOk = ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether [config] works right now. The result of the last probe (also
|
||||||
|
* the one from «Save») is reused: a success for 10 min, a failure for
|
||||||
|
* 1 min, so one connect does not probe (and wait) several times. Logs
|
||||||
|
* the decision when it probes.
|
||||||
|
*/
|
||||||
|
@JvmStatic
|
||||||
|
@Synchronized
|
||||||
|
fun ensureReady(config: SecureDnsConfig): Boolean
|
||||||
|
{
|
||||||
|
val age = SystemClock.elapsedRealtime() - probedAt
|
||||||
|
if (config == probedConfig && age < if (probeOk) PROBE_TTL_MS else PROBE_FAIL_TTL_MS)
|
||||||
|
{
|
||||||
|
return probeOk
|
||||||
|
}
|
||||||
|
val result = probe(config)
|
||||||
|
DiagLog.i(TAG, if (result.ok) "probe ${config.label} ok in ${result.ms} ms"
|
||||||
|
else "probe ${config.label} failed after ${result.ms} ms: ${result.error}; falling back to plain DNS")
|
||||||
|
return result.ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Starts (or keeps) the local resolver for [config]; returns its port, 0 on failure. */
|
||||||
|
@JvmStatic
|
||||||
|
@Synchronized
|
||||||
|
fun start(config: SecureDnsConfig): Int
|
||||||
|
{
|
||||||
|
server?.let { if (config == serverConfig && !it.isClosed) return it.localPort }
|
||||||
|
stopLocked("restart")
|
||||||
|
return try
|
||||||
|
{
|
||||||
|
val socket = DatagramSocket(InetSocketAddress(InetAddress.getByName("127.0.0.1"), 0))
|
||||||
|
val executor = ThreadPoolExecutor(WORKERS, WORKERS, 60, TimeUnit.SECONDS, ArrayBlockingQueue(256),
|
||||||
|
{ r ->
|
||||||
|
/* a worker that times out or is shut down closes its DoT connection,
|
||||||
|
* otherwise the TLS socket lingers until the garbage collector finds it */
|
||||||
|
Thread({ try { r.run() } finally { dot.get()?.close(); dot.remove() } }, "SecureDns-worker").apply { isDaemon = true }
|
||||||
|
}, ThreadPoolExecutor.DiscardPolicy())
|
||||||
|
executor.allowCoreThreadTimeOut(true)
|
||||||
|
server = socket
|
||||||
|
serverConfig = config
|
||||||
|
pool = executor
|
||||||
|
lastSummary = SystemClock.elapsedRealtime()
|
||||||
|
Thread({ serve(socket, executor, config) }, "SecureDns").apply { isDaemon = true }.start()
|
||||||
|
DiagLog.i(TAG, "on: ${config.label}, tunnel DNS $VIP -> 127.0.0.1:${socket.localPort}")
|
||||||
|
socket.localPort
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "local resolver failed to start: ${describe(e)}")
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
@Synchronized
|
||||||
|
fun stop(reason: String) = stopLocked(reason)
|
||||||
|
|
||||||
|
private fun stopLocked(reason: String)
|
||||||
|
{
|
||||||
|
val socket = server ?: return
|
||||||
|
summary(force = true)
|
||||||
|
DiagLog.i(TAG, "off ($reason)")
|
||||||
|
socket.close()
|
||||||
|
pool?.shutdownNow()
|
||||||
|
server = null
|
||||||
|
serverConfig = null
|
||||||
|
pool = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serve(socket: DatagramSocket, executor: ThreadPoolExecutor, config: SecureDnsConfig)
|
||||||
|
{
|
||||||
|
val buffer = ByteArray(4096)
|
||||||
|
while (!socket.isClosed)
|
||||||
|
{
|
||||||
|
val packet = DatagramPacket(buffer, buffer.size)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
socket.receive(packet)
|
||||||
|
}
|
||||||
|
catch (_: IOException)
|
||||||
|
{
|
||||||
|
break
|
||||||
|
}
|
||||||
|
val query = packet.data.copyOfRange(packet.offset, packet.offset + packet.length)
|
||||||
|
val from = packet.socketAddress
|
||||||
|
executor.execute { answer(socket, config, query, from) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun answer(socket: DatagramSocket, config: SecureDnsConfig, query: ByteArray, to: java.net.SocketAddress)
|
||||||
|
{
|
||||||
|
val start = SystemClock.elapsedRealtime()
|
||||||
|
val reply = try
|
||||||
|
{
|
||||||
|
resolve(config, query).also {
|
||||||
|
SecureDnsCodec.checkReply(query, it)
|
||||||
|
totalMs.addAndGet(SystemClock.elapsedRealtime() - start)
|
||||||
|
if (failing)
|
||||||
|
{
|
||||||
|
failing = false
|
||||||
|
DiagLog.i(TAG, "upstream answers again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e: Exception)
|
||||||
|
{
|
||||||
|
failures.incrementAndGet()
|
||||||
|
failing = true
|
||||||
|
val error = describe(e)
|
||||||
|
/* the first error of each kind only: a dead upstream would flood the log */
|
||||||
|
if (loggedErrors.add(e.javaClass.simpleName))
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "query failed: $error (answered SERVFAIL)")
|
||||||
|
}
|
||||||
|
SecureDnsCodec.servfail(query)
|
||||||
|
}
|
||||||
|
queries.incrementAndGet()
|
||||||
|
if (reply != null)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
socket.send(DatagramPacket(reply, reply.size, to))
|
||||||
|
}
|
||||||
|
catch (_: IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary(force = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun summary(force: Boolean)
|
||||||
|
{
|
||||||
|
val now = SystemClock.elapsedRealtime()
|
||||||
|
if (!force && now - lastSummary < SUMMARY_EVERY_MS) return
|
||||||
|
lastSummary = now
|
||||||
|
val n = queries.getAndSet(0)
|
||||||
|
val failed = failures.getAndSet(0)
|
||||||
|
val ms = totalMs.getAndSet(0)
|
||||||
|
if (n == 0L) return
|
||||||
|
val ok = n - failed
|
||||||
|
DiagLog.i(TAG, "stats: $n queries, $failed failed, avg ${if (ok > 0) ms / ok else 0} ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One upstream round trip; a stale pooled connection gets one retry on a fresh one. */
|
||||||
|
private fun resolve(config: SecureDnsConfig, query: ByteArray): ByteArray = try
|
||||||
|
{
|
||||||
|
if (config.doh) doh(config, query) else dot(config, query)
|
||||||
|
}
|
||||||
|
catch (e: IOException)
|
||||||
|
{
|
||||||
|
if (e is java.net.SocketTimeoutException) throw e /* a slow upstream: retrying would only double the wait */
|
||||||
|
dot.get()?.close()
|
||||||
|
dot.set(null)
|
||||||
|
if (config.doh) doh(config, query) else dot(config, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 8484 POST; HttpsURLConnection verifies the certificate and the hostname. */
|
||||||
|
private fun doh(config: SecureDnsConfig, query: ByteArray): ByteArray
|
||||||
|
{
|
||||||
|
val connection = URL(config.url).openConnection() as HttpsURLConnection
|
||||||
|
try
|
||||||
|
{
|
||||||
|
connection.connectTimeout = TIMEOUT_MS
|
||||||
|
connection.readTimeout = TIMEOUT_MS
|
||||||
|
connection.requestMethod = "POST"
|
||||||
|
connection.doOutput = true
|
||||||
|
connection.useCaches = false
|
||||||
|
connection.setRequestProperty("Content-Type", "application/dns-message")
|
||||||
|
connection.setRequestProperty("Accept", "application/dns-message")
|
||||||
|
connection.setFixedLengthStreamingMode(query.size)
|
||||||
|
connection.outputStream.use { it.write(query) }
|
||||||
|
if (connection.responseCode != HttpURLConnection.HTTP_OK)
|
||||||
|
{
|
||||||
|
throw IOException("HTTP ${connection.responseCode}")
|
||||||
|
}
|
||||||
|
return connection.inputStream.use { readBounded(it) }
|
||||||
|
}
|
||||||
|
catch (e: IOException)
|
||||||
|
{
|
||||||
|
connection.disconnect()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readBounded(input: InputStream): ByteArray
|
||||||
|
{
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val buf = ByteArray(4096)
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
val n = input.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
out.write(buf, 0, n)
|
||||||
|
if (out.size() > 65535) throw IOException("reply too large")
|
||||||
|
}
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 7858 over a per-worker TLS connection. */
|
||||||
|
private fun dot(config: SecureDnsConfig, query: ByteArray): ByteArray
|
||||||
|
{
|
||||||
|
val conn = dot.get()?.takeIf { it.config == config } ?: DotConnection.open(config).also {
|
||||||
|
dot.get()?.close()
|
||||||
|
dot.set(it)
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
conn.out.write(SecureDnsCodec.frame(query))
|
||||||
|
conn.out.flush()
|
||||||
|
val reply = SecureDnsCodec.readFrame(conn.input)
|
||||||
|
SecureDnsCodec.checkReply(query, reply)
|
||||||
|
return reply
|
||||||
|
}
|
||||||
|
catch (e: IOException)
|
||||||
|
{ /* a late reply would stay in the stream and answer the next query: never reuse it */
|
||||||
|
conn.close()
|
||||||
|
dot.set(null)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class DotConnection(val config: SecureDnsConfig, val socket: SSLSocket)
|
||||||
|
{
|
||||||
|
val input: InputStream = socket.inputStream.buffered()
|
||||||
|
val out: OutputStream = socket.outputStream
|
||||||
|
|
||||||
|
fun close()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
catch (_: IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object
|
||||||
|
{
|
||||||
|
fun open(config: SecureDnsConfig): DotConnection
|
||||||
|
{
|
||||||
|
val raw = Socket()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
raw.connect(InetSocketAddress(config.host, config.port), TIMEOUT_MS)
|
||||||
|
raw.soTimeout = TIMEOUT_MS
|
||||||
|
/* the host name sets SNI; the factory does not check it against the certificate */
|
||||||
|
val tls = (SSLSocketFactory.getDefault() as SSLSocketFactory).createSocket(raw, config.host, config.port, true) as SSLSocket
|
||||||
|
tls.startHandshake()
|
||||||
|
if (!HttpsURLConnection.getDefaultHostnameVerifier().verify(config.host, tls.session))
|
||||||
|
{
|
||||||
|
tls.close()
|
||||||
|
throw javax.net.ssl.SSLPeerUnverifiedException("certificate does not match ${config.host}")
|
||||||
|
}
|
||||||
|
return DotConnection(config, tls)
|
||||||
|
}
|
||||||
|
catch (e: IOException)
|
||||||
|
{
|
||||||
|
raw.close()
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describe(e: Exception): String = "${e.javaClass.simpleName}: ${e.message ?: ""}".trimEnd(' ', ':')
|
||||||
|
}
|
||||||
+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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -102,6 +102,63 @@ object RouteCheck
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** Route verdict for one address under the applied exclusion set. */
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun classify(
|
fun classify(
|
||||||
|
|||||||
+284
-7
@@ -29,6 +29,9 @@ import android.util.Log
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
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.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -80,10 +83,29 @@ data class RoutingConfig(
|
|||||||
val enabledCategories: Set<String> = emptySet(),
|
val enabledCategories: Set<String> = emptySet(),
|
||||||
/** User package patterns, see [PackagePatterns]. */
|
/** User package patterns, see [PackagePatterns]. */
|
||||||
val packagePatterns: Set<String> = emptySet(),
|
val packagePatterns: Set<String> = emptySet(),
|
||||||
|
/** Patterns the user switched off without deleting them. */
|
||||||
|
val disabledPatterns: Set<String> = emptySet(),
|
||||||
val forceTunnelPrefixes: List<ForceTunnelPrefix> = emptyList(),
|
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,
|
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. */
|
/** Why a package is (not) direct, for the per-app reason line. */
|
||||||
data class TemplateMatch(
|
data class TemplateMatch(
|
||||||
/** Built-in category id (may be [RoutingPresets.NAMESPACE]) or null. */
|
/** Built-in category id (may be [RoutingPresets.NAMESPACE]) or null. */
|
||||||
@@ -132,11 +154,32 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
private val _state = MutableStateFlow(RoutingState())
|
private val _state = MutableStateFlow(RoutingState())
|
||||||
val state: StateFlow<RoutingState> = _state
|
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
|
private val connection = object : ServiceConnection
|
||||||
{
|
{
|
||||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||||
{
|
{
|
||||||
service = (binder as VpnStateService.LocalBinder).service
|
service = (binder as VpnStateService.LocalBinder).service
|
||||||
|
service?.registerListener(stateListener)
|
||||||
|
stateListener.stateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceDisconnected(name: ComponentName?)
|
override fun onServiceDisconnected(name: ComponentName?)
|
||||||
@@ -159,6 +202,10 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
{
|
{
|
||||||
override fun onReceive(context: Context?, intent: Intent?)
|
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()
|
invalidateCache()
|
||||||
/* a full PackageManager scan: never on the main thread */
|
/* a full PackageManager scan: never on the main thread */
|
||||||
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
|
scope.launch(Dispatchers.Default) { computeExcludedPackages() }
|
||||||
@@ -182,6 +229,8 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
addDataScheme("package")
|
addDataScheme("package")
|
||||||
}
|
}
|
||||||
context.registerReceiver(packagesReceiver, filter)
|
context.registerReceiver(packagesReceiver, filter)
|
||||||
|
/* resolve the site rules once so the Rules screen shows their addresses */
|
||||||
|
refreshSites()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
|
fun updateConfig(transform: (RoutingConfig) -> RoutingConfig)
|
||||||
@@ -207,6 +256,34 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ------------------------- */
|
/* ------------------------- UI-facing helpers ------------------------- */
|
||||||
|
|
||||||
fun setGlobalEnabled(enabled: Boolean)
|
fun setGlobalEnabled(enabled: Boolean)
|
||||||
@@ -284,7 +361,13 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
|
|
||||||
fun removePattern(pattern: String)
|
fun removePattern(pattern: String)
|
||||||
{
|
{
|
||||||
updateConfig { it.copy(packagePatterns = it.packagePatterns - pattern) }
|
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) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -359,10 +442,127 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Enabled force-tunnel prefixes as validated CIDR strings (userspace
|
* Enabled force-tunnel prefixes as validated CIDR strings (userspace
|
||||||
* routing feeds them into the native force set).
|
* routing feeds them into the native force set), including the
|
||||||
|
* addresses of "through the VPN" site rules.
|
||||||
*/
|
*/
|
||||||
fun forceTunnelCidrs(): List<String> =
|
fun forceTunnelCidrs(): List<String> =
|
||||||
config.forceTunnelPrefixes.filter { it.enabled }.map { it.prefix }
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends an existing site or address rule the other way, keeping its id and on/off state. */
|
||||||
|
fun setRuleDirect(id: String, direct: Boolean)
|
||||||
|
{
|
||||||
|
updateConfigNoReconnect { withRuleDirect(it, id, direct) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
* Like [updateConfig], but force prefixes apply on the fly through the
|
||||||
@@ -474,12 +674,26 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
tunnelOverrides = obj.optJSONArray("tunnelOverrides").toStringSet(),
|
tunnelOverrides = obj.optJSONArray("tunnelOverrides").toStringSet(),
|
||||||
enabledCategories = obj.optJSONArray("enabledCategories").toStringSet(),
|
enabledCategories = obj.optJSONArray("enabledCategories").toStringSet(),
|
||||||
packagePatterns = obj.optJSONArray("packagePatterns").toStringSet(),
|
packagePatterns = obj.optJSONArray("packagePatterns").toStringSet(),
|
||||||
|
disabledPatterns = obj.optJSONArray("disabledPatterns").toStringSet(),
|
||||||
forceTunnelPrefixes = parseForcePrefixes(obj),
|
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,
|
version = CONFIG_VERSION,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun parseForcePrefixes(obj: JSONObject): List<ForceTunnelPrefix> =
|
private fun parseForcePrefixes(obj: JSONObject, key: String = "forceTunnelPrefixes"): List<ForceTunnelPrefix> =
|
||||||
obj.optJSONArray("forceTunnelPrefixes")?.let { arr ->
|
obj.optJSONArray(key)?.let { arr ->
|
||||||
(0 until arr.length()).mapNotNull { i ->
|
(0 until arr.length()).mapNotNull { i ->
|
||||||
arr.optJSONObject(i)?.let { o ->
|
arr.optJSONObject(i)?.let { o ->
|
||||||
ForceTunnelPrefix(
|
ForceTunnelPrefix(
|
||||||
@@ -597,6 +811,7 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
obj.put("tunnelOverrides", JSONArray(config.tunnelOverrides.sorted()))
|
obj.put("tunnelOverrides", JSONArray(config.tunnelOverrides.sorted()))
|
||||||
obj.put("enabledCategories", JSONArray(config.enabledCategories.sorted()))
|
obj.put("enabledCategories", JSONArray(config.enabledCategories.sorted()))
|
||||||
obj.put("packagePatterns", JSONArray(config.packagePatterns.sorted()))
|
obj.put("packagePatterns", JSONArray(config.packagePatterns.sorted()))
|
||||||
|
obj.put("disabledPatterns", JSONArray(config.disabledPatterns.sorted()))
|
||||||
val forcePrefixes = JSONArray()
|
val forcePrefixes = JSONArray()
|
||||||
for (entry in config.forceTunnelPrefixes)
|
for (entry in config.forceTunnelPrefixes)
|
||||||
{
|
{
|
||||||
@@ -607,6 +822,18 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
forcePrefixes.put(o)
|
forcePrefixes.put(o)
|
||||||
}
|
}
|
||||||
obj.put("forceTunnelPrefixes", forcePrefixes)
|
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())
|
val editor = prefs.edit().putString(KEY_CONFIG, obj.toString())
|
||||||
if (commit) editor.commit() else editor.apply()
|
if (commit) editor.commit() else editor.apply()
|
||||||
}
|
}
|
||||||
@@ -623,10 +850,29 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
private const val KEY_PROFILES_MIGRATED = "profiles_migrated_v2"
|
private const val KEY_PROFILES_MIGRATED = "profiles_migrated_v2"
|
||||||
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
|
private const val KEY_NOTICE_ONLY = "notice_only_profiles"
|
||||||
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
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. */
|
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
|
||||||
const val CONFIG_VERSION = 2
|
const val CONFIG_VERSION = 2
|
||||||
|
|
||||||
|
/** [config] with the site or address rule [id] routed [direct] (or through the VPN). */
|
||||||
|
fun withRuleDirect(config: RoutingConfig, id: String, direct: Boolean): RoutingConfig
|
||||||
|
{
|
||||||
|
val (from, to) = if (direct) config.forceTunnelPrefixes to config.directPrefixes else config.directPrefixes to config.forceTunnelPrefixes
|
||||||
|
val moved = from.firstOrNull { it.id == id }
|
||||||
|
val newFrom = from.filter { it.id != id }
|
||||||
|
/* the same address may already be on the other side: the moved entry replaces it */
|
||||||
|
val newTo = if (moved == null) to else to.filter { it.prefix != moved.prefix } + moved
|
||||||
|
return config.copy(
|
||||||
|
siteRules = config.siteRules.map { if (it.id == id) it.copy(direct = direct) else it },
|
||||||
|
forceTunnelPrefixes = if (direct) newFrom else newTo,
|
||||||
|
directPrefixes = if (direct) newTo else newFrom,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Validates and canonicalizes a CIDR/address; null if invalid. */
|
/** Validates and canonicalizes a CIDR/address; null if invalid. */
|
||||||
fun normalizePrefix(cidr: String): String? = try
|
fun normalizePrefix(cidr: String): String? = try
|
||||||
{
|
{
|
||||||
@@ -637,6 +883,37 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
null
|
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. */
|
/** All installed apps that hold the INTERNET permission. */
|
||||||
fun loadInternetPackages(pm: PackageManager): Set<String> = try
|
fun loadInternetPackages(pm: PackageManager): Set<String> = try
|
||||||
{
|
{
|
||||||
@@ -673,7 +950,7 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
*/
|
*/
|
||||||
fun templateMatch(pkg: String, config: RoutingConfig): TemplateMatch?
|
fun templateMatch(pkg: String, config: RoutingConfig): TemplateMatch?
|
||||||
{
|
{
|
||||||
val pattern = config.packagePatterns.firstOrNull { PackagePatterns.matches(it, pkg) }
|
val pattern = (config.packagePatterns - config.disabledPatterns).firstOrNull { PackagePatterns.matches(it, pkg) }
|
||||||
val category = matchingCategory(pkg, config)
|
val category = matchingCategory(pkg, config)
|
||||||
return if (category == null && pattern == null) null else TemplateMatch(category, pattern)
|
return if (category == null && pattern == null) null else TemplateMatch(category, pattern)
|
||||||
}
|
}
|
||||||
@@ -688,7 +965,7 @@ class RoutingManager private constructor(private val context: Context)
|
|||||||
{
|
{
|
||||||
val result = HashSet<String>()
|
val result = HashSet<String>()
|
||||||
result.addAll(if (installed.isEmpty()) config.directPackages else config.directPackages.filter { it in installed })
|
result.addAll(if (installed.isEmpty()) config.directPackages else config.directPackages.filter { it in installed })
|
||||||
result.addAll(PackagePatterns.matchAll(config.packagePatterns, installed))
|
result.addAll(PackagePatterns.matchAll(config.packagePatterns - config.disabledPatterns, installed))
|
||||||
for (category in config.enabledCategories)
|
for (category in config.enabledCategories)
|
||||||
{
|
{
|
||||||
val presets = RoutingPresets.presetsOf(category)
|
val presets = RoutingPresets.presetsOf(category)
|
||||||
|
|||||||
+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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
+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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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")
|
||||||
|
}
|
||||||
+216
-25
@@ -52,6 +52,7 @@ import org.strongswan.android.logic.DiagLog
|
|||||||
import org.strongswan.android.logic.StrongSwanApplication
|
import org.strongswan.android.logic.StrongSwanApplication
|
||||||
import org.strongswan.android.logic.VpnStateService
|
import org.strongswan.android.logic.VpnStateService
|
||||||
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
|
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
|
||||||
|
import org.strongswan.android.logic.events.Events
|
||||||
import org.strongswan.android.ui.MainActivity
|
import org.strongswan.android.ui.MainActivity
|
||||||
import org.strongswan.android.utils.Constants
|
import org.strongswan.android.utils.Constants
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -61,6 +62,7 @@ import java.net.Proxy
|
|||||||
import java.net.URL
|
import java.net.URL
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
/** Categories of update failures, mapped to localized messages by the UI */
|
/** Categories of update failures, mapped to localized messages by the UI */
|
||||||
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
|
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
|
||||||
@@ -75,6 +77,10 @@ data class UpdateInfo(
|
|||||||
val sha256: String,
|
val sha256: String,
|
||||||
val size: Long,
|
val size: Long,
|
||||||
val notes: Map<String, String>,
|
val notes: Map<String, String>,
|
||||||
|
/* set for manifests from the SHX update API: the file URL is presigned and short-lived */
|
||||||
|
val fromApi: Boolean = false,
|
||||||
|
val issuedAt: Long = 0L,
|
||||||
|
val mandatory: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
sealed class UpdateState
|
sealed class UpdateState
|
||||||
@@ -116,6 +122,17 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
|
private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
|
||||||
private const val UPDATES_NOTIFICATION_ID = 1001
|
private const val UPDATES_NOTIFICATION_ID = 1001
|
||||||
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
|
private const val UPDATES_PROGRESS_NOTIFICATION_ID = 1002
|
||||||
|
private const val PREF_INSTALL_ID = "pref_update_install_id"
|
||||||
|
private const val PREF_UPDATE_CHANNEL = "pref_update_channel"
|
||||||
|
private const val PREF_BETA_TOKEN = "pref_update_beta_token"
|
||||||
|
private const val PREF_API_CHECK_HOURS = "pref_update_api_check_hours"
|
||||||
|
private const val PREF_API_WIFI_ONLY = "pref_update_api_wifi_only"
|
||||||
|
/* a mandatory update the last check found: keeps the blocking sheet up across restarts and failed checks */
|
||||||
|
private const val PREF_MANDATORY_CODE = "pref_update_mandatory_code"
|
||||||
|
private const val PREF_MANDATORY_NAME = "pref_update_mandatory_name"
|
||||||
|
private const val PREF_MANDATORY_SIZE = "pref_update_mandatory_size"
|
||||||
|
/* refresh the manifest when it is older than this before downloading (the URL lives 10 minutes) */
|
||||||
|
private const val URL_MAX_AGE_SECONDS = 8 * 60L
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var mInstance: UpdateManager? = null
|
private var mInstance: UpdateManager? = null
|
||||||
@@ -165,6 +182,11 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
* was known in this process: install as soon as the check finds it */
|
* was known in this process: install as soon as the check finds it */
|
||||||
@Volatile
|
@Volatile
|
||||||
private var mInstallPending = false
|
private var mInstallPending = false
|
||||||
|
/* set by cancelDownload(); the copy loop and the install step check it */
|
||||||
|
@Volatile
|
||||||
|
private var mCancelRequested = false
|
||||||
|
@Volatile
|
||||||
|
private var mDownloadConn: HttpURLConnection? = null
|
||||||
private var mVpnService: VpnStateService? = null
|
private var mVpnService: VpnStateService? = null
|
||||||
|
|
||||||
private val mConnection = object : ServiceConnection
|
private val mConnection = object : ServiceConnection
|
||||||
@@ -190,7 +212,8 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
{
|
{
|
||||||
cleanup()
|
cleanup()
|
||||||
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
|
context.bindService(Intent(context, VpnStateService::class.java), mConnection, Context.BIND_AUTO_CREATE)
|
||||||
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(12, TimeUnit.HOURS)
|
/* the interval comes from the server policy (signed manifest), 12 h until it is known */
|
||||||
|
val request = PeriodicWorkRequestBuilder<UpdateCheckWorker>(checkIntervalHours(), TimeUnit.HOURS)
|
||||||
.setConstraints(Constraints.Builder()
|
.setConstraints(Constraints.Builder()
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
/* background checks only run while charging - zero battery impact */
|
/* background checks only run while charging - zero battery impact */
|
||||||
@@ -223,7 +246,7 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
|
val last = mPrefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L)
|
||||||
if (System.currentTimeMillis() - last >= Constants.UPDATE_CHECK_INTERVAL_MS)
|
if (System.currentTimeMillis() - last >= TimeUnit.HOURS.toMillis(checkIntervalHours()))
|
||||||
{
|
{
|
||||||
return check()
|
return check()
|
||||||
}
|
}
|
||||||
@@ -243,6 +266,57 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
check()
|
check()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True while the update channel is "beta" */
|
||||||
|
fun isBetaChannel(): Boolean = mPrefs.getString(PREF_UPDATE_CHANNEL, "release") == "beta"
|
||||||
|
|
||||||
|
/** Switches the channel (leaving beta forgets the invitation code) and checks right away */
|
||||||
|
fun setChannel(beta: Boolean, token: String?)
|
||||||
|
{
|
||||||
|
val edit = mPrefs.edit().putString(PREF_UPDATE_CHANNEL, if (beta) "beta" else "release")
|
||||||
|
if (beta) edit.putString(PREF_BETA_TOKEN, token?.trim()) else edit.remove(PREF_BETA_TOKEN)
|
||||||
|
edit.apply()
|
||||||
|
check()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Version name and size of a mandatory update newer than the installed app, or null */
|
||||||
|
fun pendingMandatory(): Pair<String, Long>?
|
||||||
|
{
|
||||||
|
if (mPrefs.getLong(PREF_MANDATORY_CODE, 0L) <= currentVersionCode())
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (mPrefs.getString(PREF_MANDATORY_NAME, null) ?: "") to mPrefs.getLong(PREF_MANDATORY_SIZE, -1L)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Interrupts a running download, deletes the partial file and goes back to Idle */
|
||||||
|
@Synchronized
|
||||||
|
fun cancelDownload()
|
||||||
|
{
|
||||||
|
if (mDownloadJob?.isActive != true)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mInstallPending = false
|
||||||
|
mCancelRequested = true
|
||||||
|
mDownloadJob?.cancel()
|
||||||
|
/* unblocks a read waiting for the network */
|
||||||
|
try
|
||||||
|
{
|
||||||
|
mDownloadConn?.disconnect()
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onDownloadCancelled()
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "update: download cancelled")
|
||||||
|
cleanup()
|
||||||
|
cancelDownloadProgress()
|
||||||
|
mState.value = UpdateState.Idle
|
||||||
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
private fun check(): Job?
|
private fun check(): Job?
|
||||||
{
|
{
|
||||||
@@ -256,12 +330,18 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
mInstallPending = false
|
mInstallPending = false
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply()
|
val mandatory = info.mandatory && info.versionCode > currentVersionCode()
|
||||||
|
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis())
|
||||||
|
.putLong(PREF_MANDATORY_CODE, if (mandatory) info.versionCode else 0L)
|
||||||
|
.putString(PREF_MANDATORY_NAME, info.versionName)
|
||||||
|
.putLong(PREF_MANDATORY_SIZE, info.size)
|
||||||
|
.apply()
|
||||||
if (info.versionCode > currentVersionCode())
|
if (info.versionCode > currentVersionCode())
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
|
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
|
||||||
"installed ${currentVersionName()} (${currentVersionCode()})")
|
"installed ${currentVersionName()} (${currentVersionCode()})")
|
||||||
mState.value = UpdateState.Available(info)
|
mState.value = UpdateState.Available(info)
|
||||||
|
Events.updateAvailable(context, info.versionName, info.versionCode)
|
||||||
if (mInstallPending)
|
if (mInstallPending)
|
||||||
{
|
{
|
||||||
mInstallPending = false
|
mInstallPending = false
|
||||||
@@ -298,7 +378,8 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
|
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isNetworkMetered())
|
/* server policy "Wi-Fi only" (default true); a policy of false allows the silent download on metered networks too */
|
||||||
|
if (mPrefs.getBoolean(PREF_API_WIFI_ONLY, true) && isNetworkMetered())
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: silent download deferred (network metered)")
|
DiagLog.i(TAG, "update: silent download deferred (network metered)")
|
||||||
return
|
return
|
||||||
@@ -307,12 +388,17 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
{
|
{
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mCancelRequested = false
|
||||||
mDownloadJob = mScope.launch {
|
mDownloadJob = mScope.launch {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
|
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
|
||||||
val apk = downloadApk(info)
|
val apk = downloadApk(info)
|
||||||
verifySignature(apk)
|
verifySignature(apk)
|
||||||
|
if (mCancelRequested)
|
||||||
|
{
|
||||||
|
throw CancellationException("update download cancelled")
|
||||||
|
}
|
||||||
if (isVpnDown() && canInstallSilently())
|
if (isVpnDown() && canInstallSilently())
|
||||||
{
|
{
|
||||||
Log.i(TAG, "Applying the downloaded update silently")
|
Log.i(TAG, "Applying the downloaded update silently")
|
||||||
@@ -338,6 +424,10 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (e: CancellationException)
|
||||||
|
{
|
||||||
|
onDownloadCancelled()
|
||||||
|
}
|
||||||
catch (e: Exception)
|
catch (e: Exception)
|
||||||
{
|
{
|
||||||
Log.e(TAG, "Silent update download failed", e)
|
Log.e(TAG, "Silent update download failed", e)
|
||||||
@@ -348,6 +438,9 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check interval from the server policy, kept between 1 hour and a week */
|
||||||
|
fun checkIntervalHours(): Long = mPrefs.getInt(PREF_API_CHECK_HOURS, 12).coerceIn(1, 168).toLong()
|
||||||
|
|
||||||
private fun isSilentUpdatesEnabled(): Boolean
|
private fun isSilentUpdatesEnabled(): Boolean
|
||||||
{
|
{
|
||||||
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
|
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
|
||||||
@@ -406,7 +499,7 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
val pending = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
|
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA)
|
||||||
{
|
{
|
||||||
Notification.Builder(context, UPDATES_CHANNEL)
|
val builder = Notification.Builder(context, UPDATES_CHANNEL)
|
||||||
.setSmallIcon(R.drawable.ic_notification)
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText("$progress%")
|
.setContentText("$progress%")
|
||||||
@@ -414,11 +507,16 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
.setStyle(Notification.ProgressStyle()
|
.setStyle(Notification.ProgressStyle()
|
||||||
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
|
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
|
||||||
.setProgress(progress))
|
.setProgress(progress))
|
||||||
.setRequestPromotedOngoing(true)
|
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setContentIntent(pending)
|
.setContentIntent(pending)
|
||||||
.build()
|
/* setRequestPromotedOngoing appeared in Android 16 QPR2 (API 36.1): on 36.0 it is a
|
||||||
|
* NoSuchMethodError that killed the app as soon as an update download started */
|
||||||
|
if (Build.VERSION.SDK_INT_FULL >= Build.VERSION_CODES_FULL.BAKLAVA_1)
|
||||||
|
{
|
||||||
|
builder.setRequestPromotedOngoing(true)
|
||||||
|
}
|
||||||
|
builder.build()
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -451,6 +549,10 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
/** Posts a notification that the downloaded update is ready to install */
|
/** Posts a notification that the downloaded update is ready to install */
|
||||||
private fun notifyUpdateReady(info: UpdateInfo)
|
private fun notifyUpdateReady(info: UpdateInfo)
|
||||||
{
|
{
|
||||||
|
if (!org.strongswan.android.logic.events.NotifPrefs.enabled(context, org.strongswan.android.logic.events.NotifPrefs.UPDATES))
|
||||||
|
{ /* switched off in the notification settings; the update stays in Settings and Events */
|
||||||
|
return
|
||||||
|
}
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
|
||||||
if (manager == null)
|
if (manager == null)
|
||||||
{
|
{
|
||||||
@@ -475,6 +577,13 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||||
.setContentIntent(pending)
|
.setContentIntent(pending)
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
|
/* F_SysNotifs: «Update» / «What is new» */
|
||||||
|
.addAction(0, context.getString(R.string.notif_update), pending)
|
||||||
|
.addAction(0, context.getString(R.string.notif_whats_new), PendingIntent.getActivity(
|
||||||
|
context, info.versionCode.toInt() + 1,
|
||||||
|
Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_UPDATES, true)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||||
.build()
|
.build()
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -506,16 +615,25 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
check()
|
check()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
mCancelRequested = false
|
||||||
mDownloadJob = mScope.launch {
|
mDownloadJob = mScope.launch {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
|
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
|
||||||
val apk = downloadApk(info)
|
val apk = downloadApk(info)
|
||||||
verifySignature(apk)
|
verifySignature(apk)
|
||||||
|
if (mCancelRequested)
|
||||||
|
{
|
||||||
|
throw CancellationException("update download cancelled")
|
||||||
|
}
|
||||||
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
|
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
|
||||||
UpdateInstaller.install(context, apk)
|
UpdateInstaller.install(context, apk)
|
||||||
mState.value = UpdateState.Idle
|
mState.value = UpdateState.Idle
|
||||||
}
|
}
|
||||||
|
catch (e: CancellationException)
|
||||||
|
{
|
||||||
|
onDownloadCancelled()
|
||||||
|
}
|
||||||
catch (e: UpdateException)
|
catch (e: UpdateException)
|
||||||
{
|
{
|
||||||
Log.e(TAG, "Update failed: ${e.kind}", e)
|
Log.e(TAG, "Update failed: ${e.kind}", e)
|
||||||
@@ -547,6 +665,26 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
|
|
||||||
private fun fetchManifest(): UpdateInfo?
|
private fun fetchManifest(): UpdateInfo?
|
||||||
{
|
{
|
||||||
|
if (apiConfigured())
|
||||||
|
{
|
||||||
|
when (val r = callApi())
|
||||||
|
{
|
||||||
|
is UpdateApiResult.Update -> return r.info.copy(mandatory = r.mandatory)
|
||||||
|
is UpdateApiResult.NoUpdate -> return upToDateMarker()
|
||||||
|
is UpdateApiResult.Failure ->
|
||||||
|
{
|
||||||
|
/* only a network or server failure falls back to the legacy bucket path; a bad
|
||||||
|
* signature or a rate limit must not be bypassed by asking the bucket */
|
||||||
|
if (r.kind != UpdateError.NETWORK || !legacyConfigured())
|
||||||
|
{
|
||||||
|
DiagLog.i(TAG, "update: API check failed: ${r.kind} ${r.detail}")
|
||||||
|
mState.value = UpdateState.Failed(r.kind, r.detail)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
DiagLog.i(TAG, "update: API unreachable (${r.detail}), using the legacy manifest")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
|
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
|
||||||
@@ -579,6 +717,58 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** also signs report uploads (ReportUploader) */
|
||||||
|
internal fun apiHmacKey(): String = decodeUpdateSecret(BuildConfig.UPDATE_API_HMAC_KEY)
|
||||||
|
|
||||||
|
private fun apiConfigured(): Boolean =
|
||||||
|
UpdateApiClient.isConfigured(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY)
|
||||||
|
|
||||||
|
/* the legacy path needs the bucket URL; without it there is nothing to fall back to */
|
||||||
|
private fun legacyConfigured(): Boolean = BuildConfig.UPDATE_BASE_URL.isNotBlank()
|
||||||
|
|
||||||
|
/** Stable random installation id (no personal data); the server stores only its hash */
|
||||||
|
internal fun installId(): String
|
||||||
|
{
|
||||||
|
mPrefs.getString(PREF_INSTALL_ID, null)?.let { return it }
|
||||||
|
val bytes = ByteArray(18).also { java.security.SecureRandom().nextBytes(it) }
|
||||||
|
val id = android.util.Base64.encodeToString(bytes, android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING)
|
||||||
|
mPrefs.edit().putString(PREF_INSTALL_ID, id).apply()
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun callApi(): UpdateApiResult
|
||||||
|
{
|
||||||
|
val client = UpdateApiClient(BuildConfig.UPDATE_API_URL, apiHmacKey(), BuildConfig.UPDATE_API_PUBLIC_KEY, installId())
|
||||||
|
val abi = Build.SUPPORTED_ABIS.firstOrNull()
|
||||||
|
val result = client.check(context.packageName, currentVersionCode(), mPrefs.getString(PREF_UPDATE_CHANNEL, "release") ?: "release",
|
||||||
|
abi, Build.VERSION.SDK_INT, mPrefs.getString(PREF_BETA_TOKEN, null))
|
||||||
|
val policy = when (result)
|
||||||
|
{
|
||||||
|
is UpdateApiResult.Update -> result.policy
|
||||||
|
is UpdateApiResult.NoUpdate -> result.policy
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
/* server hints are kept for the scheduler; the user's own settings stay in charge */
|
||||||
|
policy?.let { mPrefs.edit().putInt(PREF_API_CHECK_HOURS, it.checkHours).putBoolean(PREF_API_WIFI_ONLY, it.wifiOnly).apply() }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An UpdateInfo that sends check() into the "up to date" branch */
|
||||||
|
private fun upToDateMarker() = UpdateInfo(currentVersionCode(), currentVersionName(), "", "", -1L, emptyMap(), fromApi = true)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The presigned file URL lives ~10 minutes while a download may wait for Wi-Fi or charging:
|
||||||
|
* ask the API again when the manifest is old
|
||||||
|
*/
|
||||||
|
private fun freshen(info: UpdateInfo, force: Boolean = false): UpdateInfo
|
||||||
|
{
|
||||||
|
if (!info.fromApi || info.apkUrl.isBlank()) return info
|
||||||
|
val age = System.currentTimeMillis() / 1000 - info.issuedAt
|
||||||
|
if (!force && age in 0..URL_MAX_AGE_SECONDS) return info
|
||||||
|
val r = callApi()
|
||||||
|
return if (r is UpdateApiResult.Update && r.info.versionCode == info.versionCode && r.info.sha256 == info.sha256) r.info.copy(mandatory = r.mandatory) else info
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseNotes(notes: JSONObject?): Map<String, String>
|
private fun parseNotes(notes: JSONObject?): Map<String, String>
|
||||||
{
|
{
|
||||||
if (notes == null)
|
if (notes == null)
|
||||||
@@ -593,8 +783,9 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun downloadApk(info: UpdateInfo): File
|
private fun downloadApk(original: UpdateInfo, retried: Boolean = false): File
|
||||||
{
|
{
|
||||||
|
val info = freshen(original, force = retried)
|
||||||
val dir = File(context.filesDir, UPDATE_DIR)
|
val dir = File(context.filesDir, UPDATE_DIR)
|
||||||
if (!dir.exists() && !dir.mkdirs())
|
if (!dir.exists() && !dir.mkdirs())
|
||||||
{
|
{
|
||||||
@@ -607,36 +798,32 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
apk.delete()
|
apk.delete()
|
||||||
|
|
||||||
val conn = openConnection(info.apkUrl)
|
/* a presigned URL already carries its own signature: adding S3 auth headers makes S3 reject it */
|
||||||
|
val conn = openConnection(info.apkUrl, signS3 = !info.fromApi)
|
||||||
conn.readTimeout = 30_000
|
conn.readTimeout = 30_000
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
val code = conn.responseCode
|
val code = conn.responseCode
|
||||||
|
if (code == HttpURLConnection.HTTP_FORBIDDEN && info.fromApi && !retried)
|
||||||
|
{ /* the link expired while waiting: fetch a fresh one once */
|
||||||
|
conn.disconnect()
|
||||||
|
return downloadApk(original, retried = true)
|
||||||
|
}
|
||||||
if (code != HttpURLConnection.HTTP_OK)
|
if (code != HttpURLConnection.HTTP_OK)
|
||||||
{
|
{
|
||||||
throw UpdateException(UpdateError.NETWORK, "HTTP $code")
|
throw UpdateException(UpdateError.NETWORK, "HTTP $code")
|
||||||
}
|
}
|
||||||
val expected = if (info.size > 0) info.size else -1L
|
val expected = if (info.size > 0) info.size else -1L
|
||||||
val digest = MessageDigest.getInstance("SHA-256")
|
val digest = MessageDigest.getInstance("SHA-256")
|
||||||
val buffer = ByteArray(64 * 1024)
|
|
||||||
var downloaded = 0L
|
|
||||||
var lastProgress = -1
|
var lastProgress = -1
|
||||||
|
|
||||||
conn.inputStream.use { input ->
|
mDownloadConn = conn
|
||||||
|
val downloaded = conn.inputStream.use { input ->
|
||||||
FileOutputStream(apk).use { output ->
|
FileOutputStream(apk).use { output ->
|
||||||
while (true)
|
copyDownload(input, output, digest, { mCancelRequested }) { done ->
|
||||||
{
|
|
||||||
val len = input.read(buffer)
|
|
||||||
if (len < 0)
|
|
||||||
{
|
|
||||||
break
|
|
||||||
}
|
|
||||||
output.write(buffer, 0, len)
|
|
||||||
digest.update(buffer, 0, len)
|
|
||||||
downloaded += len
|
|
||||||
if (expected > 0)
|
if (expected > 0)
|
||||||
{
|
{
|
||||||
val progress = (downloaded * 100 / expected).toInt().coerceIn(0, 100)
|
val progress = (done * 100 / expected).toInt().coerceIn(0, 100)
|
||||||
if (progress != lastProgress)
|
if (progress != lastProgress)
|
||||||
{
|
{
|
||||||
lastProgress = progress
|
lastProgress = progress
|
||||||
@@ -661,6 +848,7 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
mDownloadConn = null
|
||||||
conn.disconnect()
|
conn.disconnect()
|
||||||
cancelDownloadProgress()
|
cancelDownloadProgress()
|
||||||
}
|
}
|
||||||
@@ -758,14 +946,17 @@ class UpdateManager private constructor(private val context: Context)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openConnection(url: String): HttpURLConnection
|
private fun openConnection(url: String, signS3: Boolean = true): HttpURLConnection
|
||||||
{
|
{
|
||||||
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
|
val conn = URL(url).openConnection(Proxy.NO_PROXY) as HttpURLConnection
|
||||||
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
||||||
conn.readTimeout = READ_TIMEOUT_MS
|
conn.readTimeout = READ_TIMEOUT_MS
|
||||||
conn.setRequestProperty("Connection", "close")
|
conn.setRequestProperty("Connection", "close")
|
||||||
conn.setRequestProperty("Cache-Control", "no-cache")
|
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||||
applyS3Auth(conn, url)
|
if (signS3)
|
||||||
|
{
|
||||||
|
applyS3Auth(conn, url)
|
||||||
|
}
|
||||||
return conn
|
return conn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+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)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
/*
|
||||||
|
* 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))
|
||||||
|
EventAction.SEND_REPORT -> startActivity(Intent(this, LogActivity::class.java).putExtra(LogActivity.EXTRA_SEND, true))
|
||||||
|
EventAction.OPEN_VPN_SETTINGS -> try
|
||||||
|
{
|
||||||
|
startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||||
|
}
|
||||||
|
catch (_: android.content.ActivityNotFoundException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,11 @@
|
|||||||
|
|
||||||
package org.strongswan.android.ui
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.os.IBinder
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -20,11 +24,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Share
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -58,12 +58,31 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
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.R
|
||||||
import org.strongswan.android.data.LogContentProvider
|
import org.strongswan.android.data.LogContentProvider
|
||||||
import org.strongswan.android.logic.CharonVpnService
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
import org.strongswan.android.logic.DiagLog
|
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.SecondaryScaffold
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
|
||||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
import java.io.BufferedReader
|
import java.io.BufferedReader
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -73,18 +92,61 @@ class LogActivity : AppCompatActivity()
|
|||||||
{
|
{
|
||||||
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
|
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?)
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
{
|
{
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
bindService(Intent(this, VpnStateService::class.java), connection, Context.BIND_AUTO_CREATE)
|
||||||
|
/* «Send a report» from the crash event opens the report right away */
|
||||||
|
val autoSend = savedInstanceState == null && intent.getBooleanExtra(EXTRA_SEND, false)
|
||||||
setContent {
|
setContent {
|
||||||
ShxTheme {
|
ShxTheme {
|
||||||
LogScreen(onBack = { finish() }, onSend = { sendLog() }, messages = messages)
|
val report: ReportViewModel = viewModel()
|
||||||
|
/* without the panel API in this build the report goes by email, as before */
|
||||||
|
val send = { if (ReportViewModel.apiReady(this)) report.open() else sendLog("") }
|
||||||
|
LogScreen(onBack = { finish() }, onSend = send, messages = messages)
|
||||||
|
ReportSheet(report, profile = ::activeProfile, onMail = { comment -> report.close(); sendLog(comment) })
|
||||||
|
if (autoSend)
|
||||||
|
{
|
||||||
|
androidx.compose.runtime.LaunchedEffect(Unit) { send() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendLog()
|
companion object
|
||||||
|
{
|
||||||
|
const val EXTRA_SEND = "org.strongswan.android.extra.SEND_LOG"
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
/* the daemon log plus the lifecycle log; the latter survives process
|
||||||
* deaths and documents watchdog/reconnect decisions */
|
* deaths and documents watchdog/reconnect decisions */
|
||||||
@@ -110,6 +172,10 @@ class LogActivity : AppCompatActivity()
|
|||||||
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
|
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
|
||||||
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
|
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
|
||||||
intent.type = "text/plain"
|
intent.type = "text/plain"
|
||||||
|
if (comment.isNotBlank())
|
||||||
|
{
|
||||||
|
intent.putExtra(Intent.EXTRA_TEXT, comment)
|
||||||
|
}
|
||||||
if (uris.size > 1)
|
if (uris.size > 1)
|
||||||
{
|
{
|
||||||
intent.putExtra(Intent.EXTRA_STREAM, uris)
|
intent.putExtra(Intent.EXTRA_STREAM, uris)
|
||||||
@@ -222,18 +288,36 @@ class LogViewModel : ViewModel()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* F_Log: «Important» (default), «All», «Errors» */
|
||||||
private enum class LogLevelFilter(val labelRes: Int)
|
private enum class LogLevelFilter(val labelRes: Int)
|
||||||
{
|
{
|
||||||
ALL(R.string.log_filter_all),
|
IMPORTANT(R.string.log_filter_important),
|
||||||
WARN(R.string.log_filter_warn),
|
ALL(R.string.log_filter_everything),
|
||||||
ERROR(R.string.log_filter_error),
|
ERROR(R.string.log_filter_error),
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun String.isErrorLine() = contains(" 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 ")
|
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]
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LogScreen(
|
private fun LogScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
@@ -243,15 +327,16 @@ private fun LogScreen(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
val allLines by viewModel.lines.collectAsStateWithLifecycle()
|
val allLines by viewModel.lines.collectAsStateWithLifecycle()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
||||||
var filter by rememberSaveable { mutableStateOf(LogLevelFilter.ALL) }
|
var filter by rememberSaveable { mutableStateOf(LogLevelFilter.IMPORTANT) }
|
||||||
val lines = remember(allLines, filter) {
|
val lines = remember(allLines, filter) {
|
||||||
when (filter)
|
when (filter)
|
||||||
{
|
{
|
||||||
|
LogLevelFilter.IMPORTANT -> allLines.filter { it.isImportantLine() }
|
||||||
LogLevelFilter.ALL -> allLines
|
LogLevelFilter.ALL -> allLines
|
||||||
LogLevelFilter.WARN -> allLines.filter { it.isErrorLine() || it.isWarnLine() }
|
|
||||||
LogLevelFilter.ERROR -> allLines.filter { it.isErrorLine() }
|
LogLevelFilter.ERROR -> allLines.filter { it.isErrorLine() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,66 +378,83 @@ private fun LogScreen(
|
|||||||
followedSize = lines.size
|
followedSize = lines.size
|
||||||
followedFilter = filter
|
followedFilter = filter
|
||||||
}
|
}
|
||||||
|
val copied = stringResource(R.string.log_copied)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
SecondaryScaffold(
|
SecondaryScaffold(
|
||||||
title = stringResource(R.string.log_title),
|
title = stringResource(R.string.log_title),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = onSend) {
|
/* 44dp tonal circle of the mockup header */
|
||||||
Icon(Icons.Default.Share, contentDescription = stringResource(R.string.send_log))
|
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) },
|
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(Modifier.fillMaxSize().padding(padding))
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding).padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||||
|
)
|
||||||
{
|
{
|
||||||
Row(
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
LogLevelFilter.entries.forEach { level ->
|
LogLevelFilter.entries.forEach { level ->
|
||||||
androidx.compose.material3.FilterChip(
|
ShxChip(stringResource(level.labelRes), filter == level, { filter = level })
|
||||||
selected = filter == level,
|
|
||||||
onClick = { filter = level },
|
|
||||||
label = { Text(stringResource(level.labelRes)) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (lines.isEmpty())
|
/* the dark console: grey time, coloured message */
|
||||||
|
Box(Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(Color(0xFF1C1B20)))
|
||||||
{
|
{
|
||||||
Text(
|
if (lines.isEmpty())
|
||||||
stringResource(R.string.empty_log),
|
{
|
||||||
modifier = Modifier.padding(ShxSpacing.xl),
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
stringResource(R.string.empty_log), style = LOG_STYLE, color = Color(0xFF79767F),
|
||||||
)
|
modifier = Modifier.padding(14.dp),
|
||||||
}
|
)
|
||||||
else
|
}
|
||||||
{
|
else
|
||||||
LazyColumn(
|
{
|
||||||
state = listState,
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize().padding(horizontal = ShxSpacing.md),
|
state = listState,
|
||||||
) {
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
items(lines.size) { index ->
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(14.dp),
|
||||||
val line = lines[index]
|
modifier = Modifier.fillMaxSize(),
|
||||||
val color = when
|
) {
|
||||||
{
|
items(lines.size) { index ->
|
||||||
line.isErrorLine() -> MaterialTheme.colorScheme.error
|
val line = lines[index]
|
||||||
line.isWarnLine() -> MaterialTheme.colorScheme.tertiary
|
val (time, message) = splitLine(line)
|
||||||
else -> MaterialTheme.colorScheme.onSurface
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
line,
|
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
),
|
|
||||||
color = color,
|
|
||||||
modifier = Modifier.padding(vertical = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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)
|
||||||
@@ -23,6 +23,7 @@ import org.strongswan.android.logic.updates.UpdateManager;
|
|||||||
import org.strongswan.android.ui.compose.ShxAppKt;
|
import org.strongswan.android.ui.compose.ShxAppKt;
|
||||||
import org.strongswan.android.ui.compose.ShxNavigation;
|
import org.strongswan.android.ui.compose.ShxNavigation;
|
||||||
import org.strongswan.android.ui.compose.ShxTab;
|
import org.strongswan.android.ui.compose.ShxTab;
|
||||||
|
import android.service.quicksettings.TileService;
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.core.splashscreen.SplashScreen;
|
import androidx.core.splashscreen.SplashScreen;
|
||||||
@@ -42,6 +43,26 @@ public class MainActivity extends AppCompatActivity
|
|||||||
*/
|
*/
|
||||||
public static final String EXTRA_OPEN_ROUTING = "org.strongswan.android.extra.OPEN_ROUTING";
|
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
|
||||||
*/
|
*/
|
||||||
@@ -55,7 +76,12 @@ public class MainActivity extends AppCompatActivity
|
|||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
handleInstallUpdate(getIntent());
|
handleInstallUpdate(getIntent());
|
||||||
handleOpenRouting(getIntent());
|
handleOpenRouting(getIntent());
|
||||||
|
handleAddServer(getIntent());
|
||||||
ShxAppKt.installShxUi(this);
|
ShxAppKt.installShxUi(this);
|
||||||
|
if (savedInstanceState == null && OnboardingActivity.isDue(this))
|
||||||
|
{ /* a fresh install: what the app does and the permissions first */
|
||||||
|
startActivity(new Intent(this, OnboardingActivity.class));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -70,6 +96,7 @@ public class MainActivity extends AppCompatActivity
|
|||||||
@Override
|
@Override
|
||||||
protected void onNewIntent(Intent intent)
|
protected void onNewIntent(Intent intent)
|
||||||
{
|
{
|
||||||
|
handleAddServer(intent);
|
||||||
super.onNewIntent(intent);
|
super.onNewIntent(intent);
|
||||||
handleInstallUpdate(intent);
|
handleInstallUpdate(intent);
|
||||||
handleOpenRouting(intent);
|
handleOpenRouting(intent);
|
||||||
@@ -92,4 +119,33 @@ public class MainActivity extends AppCompatActivity
|
|||||||
UpdateManager.getInstance(this).downloadAndInstall();
|
UpdateManager.getInstance(this).downloadAndInstall();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleAddServer(Intent intent)
|
||||||
|
{
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-32
@@ -40,14 +40,10 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.CheckCircle
|
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
@@ -61,6 +57,10 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.strongswan.android.R
|
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.ui.compose.components.PredictiveBackScale
|
||||||
import org.strongswan.android.logic.imc.RemediationInstruction
|
import org.strongswan.android.logic.imc.RemediationInstruction
|
||||||
import org.strongswan.android.ui.compose.components.ShxCard
|
import org.strongswan.android.ui.compose.components.ShxCard
|
||||||
@@ -85,7 +85,7 @@ class RemediationInstructionsActivity : AppCompatActivity()
|
|||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ShxTheme {
|
ShxTheme {
|
||||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
Surface(Modifier.fillMaxSize(), color = LocalShxColors.current.background) {
|
||||||
RemediationUi(
|
RemediationUi(
|
||||||
instructions = loadInstructions(),
|
instructions = loadInstructions(),
|
||||||
selected = selected,
|
selected = selected,
|
||||||
@@ -132,25 +132,9 @@ private fun RemediationUi(
|
|||||||
{
|
{
|
||||||
Column(Modifier.fillMaxSize())
|
Column(Modifier.fillMaxSize())
|
||||||
{
|
{
|
||||||
TopAppBar(
|
ShxPageHeader(
|
||||||
title = {
|
selected?.getTitle() ?: stringResource(R.string.remediation_instructions_title),
|
||||||
Text(
|
onBack = { if (selected != null) onSelect(null) else onFinish() },
|
||||||
selected?.getTitle() ?: stringResource(R.string.remediation_instructions_title),
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = { if (selected != null) onSelect(null) else onFinish() }) {
|
|
||||||
Icon(
|
|
||||||
Icons.AutoMirrored.Rounded.ArrowBack,
|
|
||||||
contentDescription = stringResource(R.string.back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = selected,
|
targetState = selected,
|
||||||
@@ -167,7 +151,7 @@ private fun RemediationUi(
|
|||||||
if (instructions.isEmpty())
|
if (instructions.isEmpty())
|
||||||
{
|
{
|
||||||
ShxEmptyState(
|
ShxEmptyState(
|
||||||
icon = Icons.Rounded.CheckCircle,
|
icon = ShxIcons.Check,
|
||||||
message = stringResource(R.string.remediation_instructions_title),
|
message = stringResource(R.string.remediation_instructions_title),
|
||||||
modifier = Modifier.padding(ShxSpacing.lg),
|
modifier = Modifier.padding(ShxSpacing.lg),
|
||||||
)
|
)
|
||||||
@@ -190,14 +174,14 @@ private fun RemediationUi(
|
|||||||
.clickable { onSelect(item) }
|
.clickable { onSelect(item) }
|
||||||
.padding(ShxSpacing.lg),
|
.padding(ShxSpacing.lg),
|
||||||
) {
|
) {
|
||||||
Text(item.getTitle(), style = MaterialTheme.typography.titleMedium)
|
Text(item.getTitle(), style = ShxType.cardTitle)
|
||||||
if (!item.getDescription().isNullOrEmpty())
|
if (!item.getDescription().isNullOrEmpty())
|
||||||
{
|
{
|
||||||
Spacer(Modifier.height(ShxSpacing.xs))
|
Spacer(Modifier.height(ShxSpacing.xs))
|
||||||
Text(
|
Text(
|
||||||
item.getDescription(),
|
item.getDescription(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = ShxType.note,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = LocalShxColors.current.textSub,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,15 +200,15 @@ private fun RemediationUi(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
instruction.getDescription(),
|
instruction.getDescription(),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = ShxType.note,
|
||||||
modifier = Modifier.padding(top = ShxSpacing.sm),
|
modifier = Modifier.padding(top = ShxSpacing.sm),
|
||||||
)
|
)
|
||||||
if (!instruction.getHeader().isNullOrEmpty())
|
if (!instruction.getHeader().isNullOrEmpty())
|
||||||
{
|
{
|
||||||
Text(
|
Text(
|
||||||
instruction.getHeader(),
|
instruction.getHeader(),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = ShxType.section,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = LocalShxColors.current.textSub,
|
||||||
modifier = Modifier.padding(top = 20.dp, bottom = ShxSpacing.sm),
|
modifier = Modifier.padding(top = 20.dp, bottom = ShxSpacing.sm),
|
||||||
)
|
)
|
||||||
ShxCard {
|
ShxCard {
|
||||||
@@ -232,7 +216,7 @@ private fun RemediationUi(
|
|||||||
instruction.getItems().forEachIndexed { index, item ->
|
instruction.getItems().forEachIndexed { index, item ->
|
||||||
Text(
|
Text(
|
||||||
item,
|
item,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = ShxType.body,
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
||||||
)
|
)
|
||||||
if (index < instruction.getItems().lastIndex)
|
if (index < instruction.getItems().lastIndex)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-9
@@ -14,34 +14,46 @@
|
|||||||
|
|
||||||
package org.strongswan.android.ui
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import org.strongswan.android.ui.compose.RouteCheckMode
|
||||||
import org.strongswan.android.R
|
|
||||||
import org.strongswan.android.ui.compose.RouteCheckScreen
|
import org.strongswan.android.ui.compose.RouteCheckScreen
|
||||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
/** Standalone route diagnostics screen, opened from the Routing tab. */
|
/** «How traffic goes»: route of a site, an address or an app. */
|
||||||
class RouteCheckActivity : AppCompatActivity()
|
class RouteCheckActivity : AppCompatActivity()
|
||||||
{
|
{
|
||||||
override fun onCreate(savedInstanceState: Bundle?)
|
override fun onCreate(savedInstanceState: Bundle?)
|
||||||
{
|
{
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
val mode = RouteCheckMode.entries.getOrElse(intent.getIntExtra(EXTRA_MODE, 0)) { RouteCheckMode.SITE }
|
||||||
|
val query = intent.getStringExtra(EXTRA_QUERY).orEmpty()
|
||||||
setContent {
|
setContent {
|
||||||
ShxTheme {
|
ShxTheme {
|
||||||
SecondaryScaffold(title = stringResource(R.string.route_check_title), onBack = { finish() }) { padding ->
|
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background))
|
||||||
Box(Modifier.padding(padding)) {
|
{
|
||||||
RouteCheckScreen()
|
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() }
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,7 @@ class SettingsActivity : AppCompatActivity()
|
|||||||
Box(Modifier.padding(padding)) {
|
Box(Modifier.padding(padding)) {
|
||||||
SettingsScreen(profiles = profiles, showTitle = false)
|
SettingsScreen(profiles = profiles, showTitle = false)
|
||||||
}
|
}
|
||||||
|
org.strongswan.android.ui.compose.UpdateSheetHost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-5
@@ -25,9 +25,8 @@ import android.widget.Toast
|
|||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.material3.AlertDialog
|
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -38,6 +37,7 @@ import java.security.cert.CertificateException
|
|||||||
import java.security.cert.CertificateFactory
|
import java.security.cert.CertificateFactory
|
||||||
import java.security.cert.X509Certificate
|
import java.security.cert.X509Certificate
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxDialogButton
|
||||||
import org.strongswan.android.logic.TrustedCertificateManager
|
import org.strongswan.android.logic.TrustedCertificateManager
|
||||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
@@ -175,12 +175,12 @@ class TrustedCertificateImportActivity : AppCompatActivity()
|
|||||||
@androidx.compose.runtime.Composable
|
@androidx.compose.runtime.Composable
|
||||||
private fun ConfirmImportUi(certificate: X509Certificate)
|
private fun ConfirmImportUi(certificate: X509Certificate)
|
||||||
{
|
{
|
||||||
AlertDialog(
|
ShxAlertDialog(
|
||||||
onDismissRequest = { finish() },
|
onDismissRequest = { finish() },
|
||||||
title = { Text(stringResource(R.string.import_certificate)) },
|
title = { Text(stringResource(R.string.import_certificate)) },
|
||||||
text = { Text(certificate.getSubjectDN().toString()) },
|
text = { Text(certificate.getSubjectDN().toString()) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
ShxDialogButton(primary = true, onClick = {
|
||||||
if (storeCertificate(certificate))
|
if (storeCertificate(certificate))
|
||||||
{
|
{
|
||||||
Toast.makeText(this, R.string.cert_imported_successfully, Toast.LENGTH_LONG).show()
|
Toast.makeText(this, R.string.cert_imported_successfully, Toast.LENGTH_LONG).show()
|
||||||
@@ -194,7 +194,7 @@ class TrustedCertificateImportActivity : AppCompatActivity()
|
|||||||
}) { Text(stringResource(R.string.import_certificate)) }
|
}) { Text(stringResource(R.string.import_certificate)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { finish() }) {
|
ShxDialogButton(onClick = { finish() }) {
|
||||||
Text(stringResource(android.R.string.cancel))
|
Text(stringResource(android.R.string.cancel))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+217
-91
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
package org.strongswan.android.ui
|
package org.strongswan.android.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@@ -16,24 +17,23 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
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.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.SecondaryTabRow
|
|
||||||
import androidx.compose.material3.Tab
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -42,22 +42,40 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
import org.strongswan.android.data.VpnProfileDataSource
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
|
import org.strongswan.android.data.VpnProfileSource
|
||||||
import org.strongswan.android.logic.StrongSwanApplication
|
import org.strongswan.android.logic.StrongSwanApplication
|
||||||
import org.strongswan.android.logic.TrustedCertificateManager
|
import org.strongswan.android.logic.TrustedCertificateManager
|
||||||
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource
|
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource
|
||||||
import org.strongswan.android.security.TrustedCertificateEntry
|
import org.strongswan.android.security.TrustedCertificateEntry
|
||||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxDialog
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTabs
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
import java.beans.PropertyChangeListener
|
import java.beans.PropertyChangeListener
|
||||||
import java.security.KeyStore
|
import java.security.KeyStore
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class TrustedCertificatesActivity : AppCompatActivity()
|
class TrustedCertificatesActivity : AppCompatActivity()
|
||||||
{
|
{
|
||||||
@@ -86,7 +104,43 @@ class TrustedCertificatesActivity : AppCompatActivity()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
/** A CA certificate as a row of F_Certs: who uses it and how long it is valid. */
|
||||||
|
private data class CertRow(val entry: TrustedCertificateEntry, val usedBy: List<String>)
|
||||||
|
|
||||||
|
/* the three tabs of F_Certs */
|
||||||
|
private enum class CertTab { PROFILES, ADDED, SYSTEM }
|
||||||
|
|
||||||
|
/** Days a certificate counts as «expires soon» (amber). */
|
||||||
|
internal const val CERT_SOON_DAYS = 30L
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The CA certificates the profiles point to, with the names of those
|
||||||
|
* profiles. Profiles that pick the CA automatically are not listed.
|
||||||
|
*/
|
||||||
|
internal fun profileCertificates(context: Context): Map<String, List<String>>
|
||||||
|
{
|
||||||
|
val source = VpnProfileSource(context)
|
||||||
|
source.open()
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return source.allVpnProfiles.mapNotNull { p -> p.certificateAlias?.let { it to (p.name ?: p.gateway) } }
|
||||||
|
.groupBy({ it.first }, { it.second })
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** For the Settings list: how many CAs come from profiles and how many of them expire soon. */
|
||||||
|
internal fun certificateSummary(context: Context): Pair<Int, Int>
|
||||||
|
{
|
||||||
|
val manager = TrustedCertificateManager.getInstance().load()
|
||||||
|
val certs = profileCertificates(context).keys.mapNotNull { manager.getCACertificateFromAlias(it) }
|
||||||
|
val soon = certs.count { TimeUnit.MILLISECONDS.toDays(it.notAfter.time - System.currentTimeMillis()) <= CERT_SOON_DAYS }
|
||||||
|
return certs.size to soon
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CertificatesScreen(
|
private fun CertificatesScreen(
|
||||||
selectMode: Boolean,
|
selectMode: Boolean,
|
||||||
@@ -95,28 +149,28 @@ private fun CertificatesScreen(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val sources = remember {
|
val c = LocalShxColors.current
|
||||||
listOf(
|
|
||||||
TrustedCertificateSource.SYSTEM to context.getString(R.string.system_tab),
|
|
||||||
TrustedCertificateSource.USER to context.getString(R.string.user_tab),
|
|
||||||
TrustedCertificateSource.LOCAL to context.getString(R.string.local_tab),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
var tab by remember { mutableIntStateOf(0) }
|
var tab by remember { mutableIntStateOf(0) }
|
||||||
var entries by remember { mutableStateOf(listOf<TrustedCertificateEntry>()) }
|
var rows by remember { mutableStateOf(listOf<CertRow>()) }
|
||||||
|
var profileCount by remember { mutableIntStateOf(0) }
|
||||||
var pendingDelete by remember { mutableStateOf<TrustedCertificateEntry?>(null) }
|
var pendingDelete by remember { mutableStateOf<TrustedCertificateEntry?>(null) }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val allowImport = StrongSwanApplication.getInstance().managedConfigurationService.managedConfiguration.isAllowCertificateImport
|
val allowImport = StrongSwanApplication.getInstance().managedConfigurationService.managedConfiguration.isAllowCertificateImport
|
||||||
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
|
||||||
if (it.resultCode == android.app.Activity.RESULT_OK)
|
|
||||||
{
|
|
||||||
scope.launch { reload(sources[tab].first) { entries = it } }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun load()
|
fun load()
|
||||||
{
|
{
|
||||||
scope.launch { reload(sources[tab].first) { entries = it } }
|
scope.launch {
|
||||||
|
val (list, count) = withContext(Dispatchers.IO) { loadRows(context, CertTab.entries[tab]) }
|
||||||
|
rows = list
|
||||||
|
profileCount = count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||||
|
if (it.resultCode == android.app.Activity.RESULT_OK)
|
||||||
|
{
|
||||||
|
load()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(tab) {
|
DisposableEffect(tab) {
|
||||||
@@ -126,83 +180,155 @@ private fun CertificatesScreen(
|
|||||||
onDispose { TrustedCertificateManager.getInstance().deleteObserver(observer) }
|
onDispose { TrustedCertificateManager.getInstance().deleteObserver(observer) }
|
||||||
}
|
}
|
||||||
|
|
||||||
SecondaryScaffold(
|
SecondaryScaffold(title = stringResource(R.string.trusted_certs_title), onBack = onBack) { padding ->
|
||||||
title = stringResource(R.string.trusted_certs_title),
|
Column(
|
||||||
onBack = onBack,
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
actions = {
|
modifier = Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState())
|
||||||
|
.padding(start = 16.dp, end = 16.dp, bottom = 24.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.certs_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal),
|
||||||
|
color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
ShxTabs(
|
||||||
|
listOf(
|
||||||
|
stringResource(R.string.certs_tab_profiles, profileCount),
|
||||||
|
stringResource(R.string.certs_tab_added),
|
||||||
|
stringResource(R.string.certs_tab_system),
|
||||||
|
),
|
||||||
|
tab, { tab = it },
|
||||||
|
)
|
||||||
|
if (rows.isEmpty())
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(if (tab == 0) R.string.certs_none_profiles else R.string.certs_none),
|
||||||
|
style = ShxType.sub, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
rows.forEachIndexed { i, row ->
|
||||||
|
CertItem(row, onClick = {
|
||||||
|
when
|
||||||
|
{
|
||||||
|
selectMode -> onPicked(row.entry.alias)
|
||||||
|
CertTab.entries[tab] == CertTab.ADDED -> pendingDelete = row.entry
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (i < rows.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (allowImport)
|
if (allowImport)
|
||||||
{
|
{
|
||||||
IconButton(onClick = {
|
Row(
|
||||||
importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java))
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
}) { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.import_certificate)) }
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||||
}
|
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(18.dp))
|
||||||
IconButton(onClick = {
|
.border(1.dp, c.outline, RoundedCornerShape(18.dp))
|
||||||
scope.launch {
|
.clickable(role = Role.Button) { importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java)) },
|
||||||
withContext(Dispatchers.IO) {
|
)
|
||||||
TrustedCertificateManager.getInstance().reset().load()
|
{
|
||||||
}
|
Icon(ShxIcons.Plus, contentDescription = null, tint = c.text, modifier = Modifier.size(18.dp))
|
||||||
reload(sources[tab].first) { entries = it }
|
Text(stringResource(R.string.certs_add), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
|
||||||
}
|
|
||||||
}) { Icon(Icons.Default.Refresh, contentDescription = stringResource(R.string.reload_trusted_certs)) }
|
|
||||||
},
|
|
||||||
) { padding ->
|
|
||||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
|
||||||
SecondaryTabRow(selectedTabIndex = tab) {
|
|
||||||
sources.forEachIndexed { index, pair ->
|
|
||||||
Tab(selected = tab == index, onClick = { tab = index }, text = { Text(pair.second) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
LazyColumn(Modifier.fillMaxSize()) {
|
|
||||||
items(entries, key = { it.alias }) { entry ->
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(entry.subjectPrimary) },
|
|
||||||
supportingContent = { Text(entry.subjectSecondary.ifEmpty { entry.alias }) },
|
|
||||||
modifier = Modifier.clickable {
|
|
||||||
if (selectMode) onPicked(entry.alias)
|
|
||||||
else if (sources[tab].first == TrustedCertificateSource.LOCAL) pendingDelete = entry
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.certs_system_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp),
|
||||||
|
color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingDelete?.let { entry ->
|
pendingDelete?.let { entry ->
|
||||||
AlertDialog(
|
ShxDialog(
|
||||||
onDismissRequest = { pendingDelete = null },
|
title = stringResource(R.string.delete_certificate_question),
|
||||||
title = { Text(stringResource(R.string.delete_certificate_question)) },
|
paragraphs = listOf(entry.subjectPrimary),
|
||||||
text = { Text(entry.subjectPrimary) },
|
confirm = stringResource(R.string.server_delete_confirm),
|
||||||
confirmButton = {
|
dismiss = stringResource(android.R.string.cancel),
|
||||||
TextButton(onClick = {
|
onConfirm = {
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
val store = KeyStore.getInstance("LocalCertificateStore")
|
val store = KeyStore.getInstance("LocalCertificateStore")
|
||||||
store.load(null, null)
|
store.load(null, null)
|
||||||
store.deleteEntry(entry.alias)
|
store.deleteEntry(entry.alias)
|
||||||
TrustedCertificateManager.getInstance().reset()
|
TrustedCertificateManager.getInstance().reset()
|
||||||
}
|
|
||||||
catch (_: Exception)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
pendingDelete = null
|
|
||||||
load()
|
|
||||||
}) { Text(stringResource(R.string.delete_profile)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { pendingDelete = null }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
pendingDelete = null
|
||||||
|
load()
|
||||||
},
|
},
|
||||||
|
onDismiss = { pendingDelete = null },
|
||||||
|
icon = ShxIcons.Trash,
|
||||||
|
danger = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reload(source: TrustedCertificateSource, onResult: (List<TrustedCertificateEntry>) -> Unit)
|
@Composable
|
||||||
|
private fun CertItem(row: CertRow, onClick: () -> Unit)
|
||||||
{
|
{
|
||||||
val list = withContext(Dispatchers.IO) {
|
val c = LocalShxColors.current
|
||||||
val manager = TrustedCertificateManager.getInstance().load()
|
val notAfter = row.entry.certificate.notAfter
|
||||||
manager.getCACertificates(source).map { (alias, cert) -> TrustedCertificateEntry(alias, cert) }
|
val days = TimeUnit.MILLISECONDS.toDays(notAfter.time - System.currentTimeMillis())
|
||||||
.sorted()
|
val date = SimpleDateFormat("dd.MM.yyyy", Locale.getDefault()).format(notAfter)
|
||||||
|
/* green while valid, amber when it expires soon, red when expired */
|
||||||
|
val (bg, fg, expColor, expText) = when
|
||||||
|
{
|
||||||
|
days < 0 -> Look(c.errorContainer, c.error, c.error, stringResource(R.string.certs_expired, date))
|
||||||
|
days <= CERT_SOON_DAYS -> Look(c.warningStrong, c.onWarning, c.warning, stringResource(R.string.certs_expires_in, days.toInt(), date))
|
||||||
|
else -> Look(c.successContainer, c.onSuccess, c.onSuccess, stringResource(R.string.certs_valid_until, SimpleDateFormat("MM.yyyy", Locale.getDefault()).format(notAfter)))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(bg))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Cert, contentDescription = null, tint = fg, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(row.entry.subjectPrimary, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
|
||||||
|
val used = if (row.usedBy.isNotEmpty()) stringResource(R.string.certs_used_by, row.usedBy.joinToString(", "))
|
||||||
|
else row.entry.subjectSecondary
|
||||||
|
if (used.isNotEmpty())
|
||||||
|
{
|
||||||
|
Text(used, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
Text(expText, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = expColor)
|
||||||
|
}
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
}
|
}
|
||||||
onResult(list)
|
}
|
||||||
|
|
||||||
|
private data class Look(val bg: Color, val fg: Color, val exp: Color, val text: String)
|
||||||
|
|
||||||
|
/** Rows of [tab] and the number of CAs used by profiles (shown on the first tab). */
|
||||||
|
private fun loadRows(context: Context, tab: CertTab): Pair<List<CertRow>, Int>
|
||||||
|
{
|
||||||
|
val manager = TrustedCertificateManager.getInstance().load()
|
||||||
|
val usage = profileCertificates(context)
|
||||||
|
val fromProfiles = usage.mapNotNull { (alias, names) ->
|
||||||
|
manager.getCACertificateFromAlias(alias)?.let { CertRow(TrustedCertificateEntry(alias, it), names) }
|
||||||
|
}.sortedBy { it.entry }
|
||||||
|
val rows = when (tab)
|
||||||
|
{
|
||||||
|
CertTab.PROFILES -> fromProfiles
|
||||||
|
CertTab.ADDED -> manager.getCACertificates(TrustedCertificateSource.LOCAL)
|
||||||
|
.map { (alias, cert) -> CertRow(TrustedCertificateEntry(alias, cert), usage[alias].orEmpty()) }.sortedBy { it.entry }
|
||||||
|
CertTab.SYSTEM -> (manager.getCACertificates(TrustedCertificateSource.SYSTEM) + manager.getCACertificates(TrustedCertificateSource.USER))
|
||||||
|
.map { (alias, cert) -> CertRow(TrustedCertificateEntry(alias, cert), usage[alias].orEmpty()) }.sortedBy { it.entry }
|
||||||
|
}
|
||||||
|
return rows to fromProfiles.size
|
||||||
}
|
}
|
||||||
-70
@@ -1,70 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.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.logic.autoconnect.AutoConnectManager
|
|
||||||
import org.strongswan.android.logic.autoconnect.SsidReader
|
|
||||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
|
||||||
import org.strongswan.android.ui.compose.TrustedNetworksScreen
|
|
||||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
|
||||||
|
|
||||||
class TrustedNetworksActivity : AppCompatActivity()
|
|
||||||
{
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?)
|
|
||||||
{
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
enableEdgeToEdge()
|
|
||||||
AutoConnectManager.getInstance(this).reloadConfig()
|
|
||||||
val currentSsid = SsidReader.from(this)
|
|
||||||
setContent {
|
|
||||||
ShxTheme {
|
|
||||||
SecondaryScaffold(title = stringResource(R.string.auto_connect_title), onBack = { finish() }) { padding ->
|
|
||||||
Box(Modifier.padding(padding)) {
|
|
||||||
/* profile list comes from SQLite, load off the main thread */
|
|
||||||
val profiles by produceState<List<VpnProfile>>(emptyList()) {
|
|
||||||
value = withContext(Dispatchers.IO) {
|
|
||||||
val source = VpnProfileSource(applicationContext).open()
|
|
||||||
try
|
|
||||||
{
|
|
||||||
source.allVpnProfiles
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
source.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TrustedNetworksScreen(profiles = profiles, currentSsid = currentSsid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-1
@@ -102,6 +102,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
|||||||
new ActivityResultContracts.StartActivityForResult(),
|
new ActivityResultContracts.StartActivityForResult(),
|
||||||
result -> {
|
result -> {
|
||||||
mWaitingForResult = false;
|
mWaitingForResult = false;
|
||||||
|
VpnDialogBus.INSTANCE.setConsentHint(false);
|
||||||
if (result.getResultCode() == RESULT_OK && mProfileInfo != null)
|
if (result.getResultCode() == RESULT_OK && mProfileInfo != null)
|
||||||
{
|
{
|
||||||
onVpnServicePrepared();
|
onVpnServicePrepared();
|
||||||
@@ -224,6 +225,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
mWaitingForResult = true;
|
mWaitingForResult = true;
|
||||||
|
VpnDialogBus.INSTANCE.setConsentHint(true);
|
||||||
mPrepareVpnService.launch(intent);
|
mPrepareVpnService.launch(intent);
|
||||||
}
|
}
|
||||||
catch (ActivityNotFoundException ex)
|
catch (ActivityNotFoundException ex)
|
||||||
@@ -232,6 +234,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
|||||||
* don't have the VPN components built into the system image.
|
* don't have the VPN components built into the system image.
|
||||||
* com.android.vpndialogs/com.android.vpndialogs.ConfirmDialog
|
* com.android.vpndialogs/com.android.vpndialogs.ConfirmDialog
|
||||||
* will not be found then */
|
* will not be found then */
|
||||||
|
VpnDialogBus.INSTANCE.setConsentHint(false);
|
||||||
showNotSupported(R.string.vpn_not_supported);
|
showNotSupported(R.string.vpn_not_supported);
|
||||||
mWaitingForResult = false;
|
mWaitingForResult = false;
|
||||||
}
|
}
|
||||||
@@ -533,11 +536,13 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
|||||||
String action = normalizeAction(intent);
|
String action = normalizeAction(intent);
|
||||||
|
|
||||||
if (START_PROFILE.equals(action))
|
if (START_PROFILE.equals(action))
|
||||||
{
|
{ /* app, tile, widget, shortcut: a hand action wins over a scenario */
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(this);
|
||||||
startVpnProfile(intent);
|
startVpnProfile(intent);
|
||||||
}
|
}
|
||||||
else if (DISCONNECT.equals(action))
|
else if (DISCONNECT.equals(action))
|
||||||
{
|
{
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(this);
|
||||||
disconnect(intent);
|
disconnect(intent);
|
||||||
}
|
}
|
||||||
else if (TOGGLE_VPN.equals(action))
|
else if (TOGGLE_VPN.equals(action))
|
||||||
|
|||||||
+1
@@ -95,6 +95,7 @@ class VpnProfileDetailActivity : AppCompatActivity()
|
|||||||
val profile = checked.toProfile(existing)
|
val profile = checked.toProfile(existing)
|
||||||
if (existing != null) dataSource.updateVpnProfile(profile)
|
if (existing != null) dataSource.updateVpnProfile(profile)
|
||||||
else dataSource.insertProfile(profile)
|
else dataSource.insertProfile(profile)
|
||||||
|
if (existing == null) org.strongswan.android.logic.profiles.ProfileExtras.recordManual(this@VpnProfileDetailActivity, profile.getUUID().toString())
|
||||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, profile.getUUID().toString())
|
intent.putExtra(Constants.VPN_PROFILES_SINGLE, profile.getUUID().toString())
|
||||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||||
|
|||||||
+39
-11
@@ -34,6 +34,7 @@ import org.json.JSONArray;
|
|||||||
import org.json.JSONException;
|
import org.json.JSONException;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
import org.strongswan.android.R;
|
import org.strongswan.android.R;
|
||||||
|
import org.strongswan.android.logic.profiles.ProfileExtras;
|
||||||
import org.strongswan.android.data.VpnProfile;
|
import org.strongswan.android.data.VpnProfile;
|
||||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
|
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
|
||||||
import org.strongswan.android.data.VpnProfileDataSource;
|
import org.strongswan.android.data.VpnProfileDataSource;
|
||||||
@@ -179,7 +180,7 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
|
|
||||||
ImportScreenKt.installImportScreen(this, mUi,
|
ImportScreenKt.installImportScreen(this, mUi,
|
||||||
this::saveProfile, this::finish,
|
this::saveProfile, this::finish,
|
||||||
this::importPkcs12, this::chooseUserCertificate);
|
this::importPkcs12, this::chooseUserCertificate, this::retryLoad);
|
||||||
|
|
||||||
Intent intent = getIntent();
|
Intent intent = getIntent();
|
||||||
String action = intent.getAction();
|
String action = intent.getAction();
|
||||||
@@ -246,13 +247,29 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
mImportPKCS12.launch(intent);
|
mImportPKCS12.launch(intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Uri mLastUri;
|
||||||
|
|
||||||
private void loadProfile(Uri uri)
|
private void loadProfile(Uri uri)
|
||||||
{
|
{
|
||||||
mUi.setLoading(true);
|
mUi.setLoading(true);
|
||||||
|
|
||||||
|
/* shown in the preview: "link" for downloads, "file" otherwise */
|
||||||
|
mUi.setSource(uri.getScheme());
|
||||||
|
mUi.setSourceUri(uri.toString());
|
||||||
|
mUi.setLoadError(null);
|
||||||
|
mLastUri = uri;
|
||||||
Bundle args = new Bundle();
|
Bundle args = new Bundle();
|
||||||
args.putParcelable(PROFILE_URI, uri);
|
args.putParcelable(PROFILE_URI, uri);
|
||||||
LoaderManager.getInstance(this).initLoader(PROFILE_LOADER, args, mProfileLoaderCallbacks);
|
LoaderManager.getInstance(this).restartLoader(PROFILE_LOADER, args, mProfileLoaderCallbacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Try again» after a failed download. */
|
||||||
|
private void retryLoad()
|
||||||
|
{
|
||||||
|
if (mLastUri != null)
|
||||||
|
{
|
||||||
|
loadProfile(mLastUri);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void handleProfile(ProfileLoadResult data)
|
public void handleProfile(ProfileLoadResult data)
|
||||||
@@ -300,15 +317,8 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (error != null)
|
/* the sheet shows the failure with the likely reasons and a retry */
|
||||||
{
|
mUi.setLoadError(error != null ? error : getString(R.string.profile_import_failed));
|
||||||
Toast.makeText(this, getString(R.string.profile_import_failed_detail, error), Toast.LENGTH_LONG).show();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Toast.makeText(this, R.string.profile_import_failed, Toast.LENGTH_LONG).show();
|
|
||||||
}
|
|
||||||
finish();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
mExisting = mDataSource.getVpnProfile(mProfile.getUUID());
|
mExisting = mDataSource.getVpnProfile(mProfile.getUUID());
|
||||||
@@ -659,6 +669,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
e.printStackTrace();
|
e.printStackTrace();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/* remember where it came from (F_Profile «Profile source») */
|
||||||
|
ProfileExtras.recordImport(this, mProfile.getUUID().toString(),
|
||||||
|
mLastUri != null ? mLastUri.getScheme() : null,
|
||||||
|
mLastUri != null ? mLastUri.toString() : null,
|
||||||
|
mLastUri != null && mLastUri.getPath() != null && mLastUri.getPath().contains("/qr/"));
|
||||||
Intent intent = new Intent(Constants.VPN_PROFILES_CHANGED);
|
Intent intent = new Intent(Constants.VPN_PROFILES_CHANGED);
|
||||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, mProfile.getUUID().toString());
|
intent.putExtra(Constants.VPN_PROFILES_SINGLE, mProfile.getUUID().toString());
|
||||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
|
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
|
||||||
@@ -666,6 +681,13 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
intent = new Intent(this, MainActivity.class);
|
intent = new Intent(this, MainActivity.class);
|
||||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
startActivity(intent);
|
startActivity(intent);
|
||||||
|
if (mUi.getConnectNow())
|
||||||
|
{ /* «Connect right away» in the preview */
|
||||||
|
Intent start = new Intent(this, VpnProfileControlActivity.class);
|
||||||
|
start.setAction(VpnProfileControlActivity.START_PROFILE);
|
||||||
|
start.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, mProfile.getUUID().toString());
|
||||||
|
startActivity(start);
|
||||||
|
}
|
||||||
|
|
||||||
setResult(RESULT_OK, new Intent().putExtra(VpnProfileDataSource.KEY_UUID, mProfile.getUUID().toString()));
|
setResult(RESULT_OK, new Intent().putExtra(VpnProfileDataSource.KEY_UUID, mProfile.getUUID().toString()));
|
||||||
finish();
|
finish();
|
||||||
@@ -701,6 +723,12 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
|||||||
*/
|
*/
|
||||||
private void updateProfileData()
|
private void updateProfileData()
|
||||||
{
|
{
|
||||||
|
/* the name can be changed in the preview */
|
||||||
|
String name = mUi.getName().trim();
|
||||||
|
if (!name.isEmpty())
|
||||||
|
{
|
||||||
|
mProfile.setName(name);
|
||||||
|
}
|
||||||
if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS))
|
if (mProfile.getVpnType().has(VpnTypeFeature.USER_PASS))
|
||||||
{
|
{
|
||||||
mProfile.setUsername(mUi.getUsername().trim());
|
mProfile.setUsername(mUi.getUsername().trim());
|
||||||
|
|||||||
+53
-14
@@ -17,9 +17,6 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -33,6 +30,28 @@ import androidx.core.graphics.drawable.IconCompat
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.home.serverCode
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import org.strongswan.android.data.VpnProfile
|
import org.strongswan.android.data.VpnProfile
|
||||||
import org.strongswan.android.data.VpnProfileSource
|
import org.strongswan.android.data.VpnProfileSource
|
||||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||||
@@ -90,20 +109,40 @@ private fun ProfileSelectScreen(
|
|||||||
onSelect: (VpnProfile) -> Unit,
|
onSelect: (VpnProfile) -> Unit,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
SecondaryScaffold(title = stringResource(R.string.strongswan_shortcut), onBack = onBack) { padding ->
|
SecondaryScaffold(title = stringResource(R.string.strongswan_shortcut), onBack = onBack) { padding ->
|
||||||
if (profiles.isEmpty())
|
Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 24.dp))
|
||||||
{
|
{
|
||||||
Text(stringResource(R.string.no_profiles), modifier = Modifier.padding(padding).padding(24.dp))
|
if (profiles.isEmpty())
|
||||||
}
|
{
|
||||||
else
|
Text(stringResource(R.string.no_profiles), style = ShxType.body, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp))
|
||||||
{
|
return@Column
|
||||||
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
|
}
|
||||||
items(profiles, key = { it.getUUID().toString() }) { profile ->
|
/* the servers as on F_Servers: code circle, name, address */
|
||||||
ListItem(
|
ShxPanel()
|
||||||
headlineContent = { Text(profile.name ?: "") },
|
{
|
||||||
supportingContent = { Text(profile.gateway ?: "") },
|
profiles.forEachIndexed { i, profile ->
|
||||||
modifier = Modifier.clickable { onSelect(profile) },
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(68.dp).clickable(role = Role.Button) { onSelect(profile) }.padding(horizontal = 14.dp),
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(c.tile))
|
||||||
|
{
|
||||||
|
Text(serverCode(profile, null), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp))
|
||||||
|
{
|
||||||
|
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(profile.gateway ?: "", style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1)
|
||||||
|
}
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
if (i < profiles.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-17
@@ -152,7 +152,12 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
|||||||
{ /* always get the plain profile without cached password */
|
{ /* always get the plain profile without cached password */
|
||||||
profile = mDataSource.getVpnProfile(profile.getUUID());
|
profile = mDataSource.getVpnProfile(profile.getUUID());
|
||||||
}
|
}
|
||||||
/* reconnect the profile in case of an error */
|
/* a problem: the tap opens the app, which explains it (F_QsTile) */
|
||||||
|
if (mService.getErrorState() != VpnStateService.ErrorState.NO_ERROR)
|
||||||
|
{
|
||||||
|
openApp(new Intent(this, MainActivity.class));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (mService.getErrorState() == VpnStateService.ErrorState.NO_ERROR)
|
if (mService.getErrorState() == VpnStateService.ErrorState.NO_ERROR)
|
||||||
{
|
{
|
||||||
switch (mService.getState())
|
switch (mService.getState())
|
||||||
@@ -212,11 +217,16 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Intent intent = new Intent(this, MainActivity.class);
|
/* no servers yet: straight to «Add server» */
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
openApp(new Intent(this, MainActivity.class).putExtra(MainActivity.EXTRA_ADD_SERVER, getProfile() == null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openApp(Intent intent)
|
||||||
|
{
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
|
||||||
{
|
{
|
||||||
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE));
|
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -255,11 +265,28 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* F_QsTile: always «SHX VPN», the state in the subtitle */
|
||||||
|
tile.setLabel(getString(R.string.tile_label));
|
||||||
|
String subtitle;
|
||||||
if (error != VpnStateService.ErrorState.NO_ERROR)
|
if (error != VpnStateService.ErrorState.NO_ERROR)
|
||||||
{
|
{
|
||||||
tile.setState(Tile.STATE_INACTIVE);
|
tile.setState(Tile.STATE_INACTIVE);
|
||||||
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_warning));
|
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_warning));
|
||||||
tile.setLabel(getString(R.string.tile_connect));
|
switch (error)
|
||||||
|
{
|
||||||
|
case UNREACHABLE:
|
||||||
|
case LOOKUP_FAILED:
|
||||||
|
subtitle = getString(R.string.tile_no_answer);
|
||||||
|
break;
|
||||||
|
case AUTH_FAILED:
|
||||||
|
case PEER_AUTH_FAILED:
|
||||||
|
case PASSWORD_MISSING:
|
||||||
|
subtitle = getString(R.string.tile_auth_error);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
subtitle = getString(R.string.tile_problem);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -269,30 +296,25 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
|||||||
case DISABLED:
|
case DISABLED:
|
||||||
tile.setState(Tile.STATE_INACTIVE);
|
tile.setState(Tile.STATE_INACTIVE);
|
||||||
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_disconnected));
|
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_disconnected));
|
||||||
tile.setLabel(getString(R.string.tile_connect));
|
subtitle = getString(R.string.tile_off);
|
||||||
break;
|
break;
|
||||||
case CONNECTING:
|
case CONNECTING:
|
||||||
tile.setState(Tile.STATE_ACTIVE);
|
tile.setState(Tile.STATE_ACTIVE);
|
||||||
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_connecting));
|
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_connecting));
|
||||||
tile.setLabel(getString(R.string.tile_disconnect));
|
subtitle = getString(R.string.tile_connecting);
|
||||||
break;
|
break;
|
||||||
case CONNECTED:
|
case CONNECTED:
|
||||||
|
default:
|
||||||
tile.setState(Tile.STATE_ACTIVE);
|
tile.setState(Tile.STATE_ACTIVE);
|
||||||
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification));
|
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification));
|
||||||
tile.setLabel(getString(R.string.tile_disconnect));
|
/* the server name, unless the screen is locked */
|
||||||
|
subtitle = profile != null && !isSecure() ? profile.getName() : getString(R.string.status_protected);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (profile != null && !isSecure())
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||||
{
|
{
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
tile.setSubtitle(subtitle);
|
||||||
{ /* keep the action label, show the profile as the tile subtitle */
|
|
||||||
tile.setSubtitle(profile.getName());
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
tile.setLabel(profile.getName());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
tile.updateTile();
|
tile.updateTile();
|
||||||
}
|
}
|
||||||
|
|||||||
+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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
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.height
|
||||||
|
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.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.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.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
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.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.VpnProfileDetailActivity
|
||||||
|
import org.strongswan.android.ui.VpnProfileImportActivity
|
||||||
|
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.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** QR codes of profiles: what a scan turns into, and a profile turned into a QR image. */
|
||||||
|
object ProfileQr
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The import intent for a scanned text: a link opens the import screen on
|
||||||
|
* that URL, a .sswan document is handed over as a temporary file.
|
||||||
|
*/
|
||||||
|
fun importIntent(context: Context, text: String): Intent?
|
||||||
|
{
|
||||||
|
val trimmed = text.trim()
|
||||||
|
val intent = Intent(context, VpnProfileImportActivity::class.java).setAction(Intent.ACTION_VIEW)
|
||||||
|
if (trimmed.startsWith("https://", true) || trimmed.startsWith("http://", true))
|
||||||
|
{
|
||||||
|
return intent.setData(Uri.parse(trimmed))
|
||||||
|
}
|
||||||
|
if (!trimmed.startsWith("{"))
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val dir = File(context.cacheDir, "qr").apply { mkdirs() }
|
||||||
|
val file = File(dir, "scanned.sswan")
|
||||||
|
file.writeText(trimmed)
|
||||||
|
val uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file)
|
||||||
|
return intent.setDataAndType(uri, "application/vnd.strongswan.profile").addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A QR bitmap of [text], or null when it does not fit into a QR code. */
|
||||||
|
fun bitmap(text: String, size: Int = 720, highCorrection: Boolean = false): Bitmap? = try
|
||||||
|
{
|
||||||
|
val matrix = QRCodeWriter().encode(
|
||||||
|
text, BarcodeFormat.QR_CODE, size, size,
|
||||||
|
mapOf(
|
||||||
|
EncodeHintType.ERROR_CORRECTION to if (highCorrection) ErrorCorrectionLevel.H else ErrorCorrectionLevel.L,
|
||||||
|
EncodeHintType.MARGIN to 1,
|
||||||
|
EncodeHintType.CHARACTER_SET to "UTF-8",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val pixels = IntArray(matrix.width * matrix.height) { i ->
|
||||||
|
if (matrix.get(i % matrix.width, i / matrix.width)) android.graphics.Color.BLACK else android.graphics.Color.WHITE
|
||||||
|
}
|
||||||
|
Bitmap.createBitmap(pixels, matrix.width, matrix.height, Bitmap.Config.ARGB_8888)
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Add server» sheet (F_Import): QR first, then a link, then a file or manual entry. */
|
||||||
|
@Composable
|
||||||
|
fun AddServerSheet(onDismiss: () -> Unit)
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
var link by remember { mutableStateOf("") }
|
||||||
|
val scanner = rememberLauncherForActivityResult(ScanContract()) { result ->
|
||||||
|
val text = result.contents ?: return@rememberLauncherForActivityResult
|
||||||
|
val intent = ProfileQr.importIntent(context, text)
|
||||||
|
if (intent != null)
|
||||||
|
{
|
||||||
|
onDismiss()
|
||||||
|
context.startActivity(intent)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Toast.makeText(context, R.string.add_qr_not_profile, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fun openLink()
|
||||||
|
{
|
||||||
|
val url = link.trim()
|
||||||
|
if (!url.startsWith("http://", true) && !url.startsWith("https://", true))
|
||||||
|
{
|
||||||
|
Toast.makeText(context, R.string.add_link_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onDismiss()
|
||||||
|
context.startActivity(Intent(context, VpnProfileImportActivity::class.java).setAction(Intent.ACTION_VIEW).setData(Uri.parse(url)))
|
||||||
|
}
|
||||||
|
ShxSheet(onDismiss = onDismiss)
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.add_server_title), style = ShxType.sheetTitle, color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onDismiss))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.add_close), tint = c.textSoft, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(c.accent)
|
||||||
|
.clickable(role = Role.Button) {
|
||||||
|
scanner.launch(
|
||||||
|
ScanOptions()
|
||||||
|
.setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||||
|
.setPrompt(context.getString(R.string.add_qr_prompt))
|
||||||
|
.setBeepEnabled(false)
|
||||||
|
.setOrientationLocked(false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(18.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(48.dp).clip(RoundedCornerShape(16.dp)).background(Color.White.copy(alpha = 0.18f)))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Scan, contentDescription = null, tint = Color.White, modifier = Modifier.size(26.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.add_qr), style = ShxType.cardHeading, color = Color.White)
|
||||||
|
Text(stringResource(R.string.add_qr_sub), style = ShxType.sub, color = Color(0xFFE7DFFF))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.add_link_title), style = ShxType.cardTitle, color = c.text)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.CenterStart,
|
||||||
|
modifier = Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(14.dp)).background(c.tile).padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (link.isEmpty())
|
||||||
|
{
|
||||||
|
Text("https://…/profile.sswan", style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium), color = c.textSub, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
BasicTextField(
|
||||||
|
value = link,
|
||||||
|
onValueChange = { link = it },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium, color = c.text),
|
||||||
|
cursorBrush = SolidColor(c.accent),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Go),
|
||||||
|
keyboardActions = KeyboardActions(onGo = { openLink() }),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.height(48.dp).clip(RoundedCornerShape(14.dp)).background(c.accentMuted)
|
||||||
|
.clickable(role = Role.Button) {
|
||||||
|
if (link.isBlank())
|
||||||
|
{
|
||||||
|
val clip = (context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip
|
||||||
|
link = clip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.coerceToText(context)?.toString()?.trim().orEmpty()
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
openLink()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(if (link.isBlank()) R.string.add_link_paste else R.string.add_link_open),
|
||||||
|
style = ShxType.body.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = c.textTonal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.add_link_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.add_file),
|
||||||
|
{
|
||||||
|
onDismiss()
|
||||||
|
context.startActivity(Intent(context, VpnProfileImportActivity::class.java))
|
||||||
|
},
|
||||||
|
style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.add_manual),
|
||||||
|
{
|
||||||
|
onDismiss()
|
||||||
|
context.startActivity(Intent(context, VpnProfileDetailActivity::class.java))
|
||||||
|
},
|
||||||
|
style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-30
@@ -22,25 +22,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||||
import androidx.compose.material.icons.rounded.Add
|
|
||||||
import androidx.compose.material.icons.rounded.BatterySaver
|
|
||||||
import androidx.compose.material.icons.rounded.HealthAndSafety
|
|
||||||
import androidx.compose.material.icons.rounded.NetworkCheck
|
|
||||||
import androidx.compose.material.icons.rounded.Remove
|
|
||||||
import androidx.compose.material.icons.rounded.Public
|
|
||||||
import androidx.compose.material.icons.rounded.Speed
|
|
||||||
import androidx.compose.material.icons.rounded.Timer
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -52,6 +41,10 @@ import androidx.compose.ui.platform.LocalContext
|
|||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.strongswan.android.R
|
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.ShxDialogButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
import org.strongswan.android.ui.compose.components.ShxSection
|
import org.strongswan.android.ui.compose.components.ShxSection
|
||||||
import org.strongswan.android.ui.compose.components.ShxSectionDivider
|
import org.strongswan.android.ui.compose.components.ShxSectionDivider
|
||||||
import org.strongswan.android.ui.compose.components.ShxSettingRow
|
import org.strongswan.android.ui.compose.components.ShxSettingRow
|
||||||
@@ -92,7 +85,7 @@ fun ConnectivityCheckScreen()
|
|||||||
ShxSection(title = stringResource(R.string.settings_section_general))
|
ShxSection(title = stringResource(R.string.settings_section_general))
|
||||||
{
|
{
|
||||||
ShxSettingToggleRow(
|
ShxSettingToggleRow(
|
||||||
icon = Icons.Rounded.NetworkCheck,
|
icon = ShxIcons.Pulse,
|
||||||
title = stringResource(R.string.health_check_title),
|
title = stringResource(R.string.health_check_title),
|
||||||
subtitle = stringResource(R.string.health_check_summary),
|
subtitle = stringResource(R.string.health_check_summary),
|
||||||
checked = config.enabled,
|
checked = config.enabled,
|
||||||
@@ -100,7 +93,7 @@ fun ConnectivityCheckScreen()
|
|||||||
)
|
)
|
||||||
ShxSectionDivider()
|
ShxSectionDivider()
|
||||||
ShxSettingRow(
|
ShxSettingRow(
|
||||||
icon = Icons.Rounded.Timer,
|
icon = ShxIcons.Clock,
|
||||||
title = stringResource(R.string.health_check_interval),
|
title = stringResource(R.string.health_check_interval),
|
||||||
subtitle = intervalLabel(config.intervalSec, config.aggressiveMode),
|
subtitle = intervalLabel(config.intervalSec, config.aggressiveMode),
|
||||||
enabled = config.enabled && !config.aggressiveMode,
|
enabled = config.enabled && !config.aggressiveMode,
|
||||||
@@ -108,7 +101,7 @@ fun ConnectivityCheckScreen()
|
|||||||
)
|
)
|
||||||
ShxSectionDivider()
|
ShxSectionDivider()
|
||||||
ShxSettingToggleRow(
|
ShxSettingToggleRow(
|
||||||
icon = Icons.Rounded.Speed,
|
icon = ShxIcons.Bolt,
|
||||||
title = stringResource(R.string.health_check_aggressive_title),
|
title = stringResource(R.string.health_check_aggressive_title),
|
||||||
subtitle = stringResource(R.string.health_check_aggressive_summary),
|
subtitle = stringResource(R.string.health_check_aggressive_summary),
|
||||||
checked = config.aggressiveMode,
|
checked = config.aggressiveMode,
|
||||||
@@ -116,7 +109,7 @@ fun ConnectivityCheckScreen()
|
|||||||
)
|
)
|
||||||
ShxSectionDivider()
|
ShxSectionDivider()
|
||||||
ShxSettingToggleRow(
|
ShxSettingToggleRow(
|
||||||
icon = Icons.Rounded.BatterySaver,
|
icon = ShxIcons.Battery,
|
||||||
title = stringResource(R.string.health_check_adaptive_title),
|
title = stringResource(R.string.health_check_adaptive_title),
|
||||||
subtitle = stringResource(R.string.health_check_adaptive_summary),
|
subtitle = stringResource(R.string.health_check_adaptive_summary),
|
||||||
checked = config.adaptiveMode,
|
checked = config.adaptiveMode,
|
||||||
@@ -127,7 +120,7 @@ fun ConnectivityCheckScreen()
|
|||||||
ShxSection(title = stringResource(R.string.health_check_targets_title))
|
ShxSection(title = stringResource(R.string.health_check_targets_title))
|
||||||
{
|
{
|
||||||
ShxSettingToggleRow(
|
ShxSettingToggleRow(
|
||||||
icon = Icons.Rounded.HealthAndSafety,
|
icon = ShxIcons.Shield,
|
||||||
title = stringResource(R.string.health_check_server_title),
|
title = stringResource(R.string.health_check_server_title),
|
||||||
subtitle = stringResource(R.string.health_check_server_summary),
|
subtitle = stringResource(R.string.health_check_server_summary),
|
||||||
checked = config.serverCheckMode != ServerCheckMode.OFF,
|
checked = config.serverCheckMode != ServerCheckMode.OFF,
|
||||||
@@ -140,7 +133,7 @@ fun ConnectivityCheckScreen()
|
|||||||
listOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE).forEach { target ->
|
listOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE).forEach { target ->
|
||||||
ShxSectionDivider()
|
ShxSectionDivider()
|
||||||
ShxSettingToggleRow(
|
ShxSettingToggleRow(
|
||||||
icon = Icons.Rounded.Public,
|
icon = ShxIcons.Globe,
|
||||||
title = targetLabel(target),
|
title = targetLabel(target),
|
||||||
subtitle = null,
|
subtitle = null,
|
||||||
checked = config.targets.contains(target),
|
checked = config.targets.contains(target),
|
||||||
@@ -157,8 +150,8 @@ fun ConnectivityCheckScreen()
|
|||||||
{
|
{
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.health_check_mode),
|
stringResource(R.string.health_check_mode),
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = ShxType.body,
|
||||||
color = if (config.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (config.enabled) LocalShxColors.current.text else LocalShxColors.current.textSub,
|
||||||
)
|
)
|
||||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(top = ShxSpacing.sm))
|
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(top = ShxSpacing.sm))
|
||||||
{
|
{
|
||||||
@@ -175,7 +168,7 @@ fun ConnectivityCheckScreen()
|
|||||||
}
|
}
|
||||||
ShxSectionDivider()
|
ShxSectionDivider()
|
||||||
ShxSettingRow(
|
ShxSettingRow(
|
||||||
icon = Icons.Rounded.NetworkCheck,
|
icon = ShxIcons.Pulse,
|
||||||
title = stringResource(R.string.health_check_action),
|
title = stringResource(R.string.health_check_action),
|
||||||
subtitle = actionLabel(config.serverAction),
|
subtitle = actionLabel(config.serverAction),
|
||||||
enabled = config.enabled,
|
enabled = config.enabled,
|
||||||
@@ -203,7 +196,7 @@ fun ConnectivityCheckScreen()
|
|||||||
ShxSection(title = stringResource(R.string.health_check_tunnel_dead_title))
|
ShxSection(title = stringResource(R.string.health_check_tunnel_dead_title))
|
||||||
{
|
{
|
||||||
ShxSettingRow(
|
ShxSettingRow(
|
||||||
icon = Icons.Rounded.NetworkCheck,
|
icon = ShxIcons.Pulse,
|
||||||
title = stringResource(R.string.health_check_action),
|
title = stringResource(R.string.health_check_action),
|
||||||
subtitle = actionLabel(config.tunnelDeadAction),
|
subtitle = actionLabel(config.tunnelDeadAction),
|
||||||
enabled = config.enabled,
|
enabled = config.enabled,
|
||||||
@@ -231,7 +224,7 @@ fun ConnectivityCheckScreen()
|
|||||||
ShxSection(title = stringResource(R.string.health_check_partial_title))
|
ShxSection(title = stringResource(R.string.health_check_partial_title))
|
||||||
{
|
{
|
||||||
ShxSettingRow(
|
ShxSettingRow(
|
||||||
icon = Icons.Rounded.NetworkCheck,
|
icon = ShxIcons.Pulse,
|
||||||
title = stringResource(R.string.health_check_action),
|
title = stringResource(R.string.health_check_action),
|
||||||
subtitle = actionLabel(config.partialDownAction),
|
subtitle = actionLabel(config.partialDownAction),
|
||||||
enabled = config.enabled,
|
enabled = config.enabled,
|
||||||
@@ -252,7 +245,7 @@ fun ConnectivityCheckScreen()
|
|||||||
if (showIntervalPicker)
|
if (showIntervalPicker)
|
||||||
{
|
{
|
||||||
val options = listOf(15, 30, 60, 300)
|
val options = listOf(15, 30, 60, 300)
|
||||||
AlertDialog(
|
ShxAlertDialog(
|
||||||
onDismissRequest = { showIntervalPicker = false },
|
onDismissRequest = { showIntervalPicker = false },
|
||||||
title = { Text(stringResource(R.string.health_check_interval)) },
|
title = { Text(stringResource(R.string.health_check_interval)) },
|
||||||
text = {
|
text = {
|
||||||
@@ -276,7 +269,7 @@ fun ConnectivityCheckScreen()
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = { showIntervalPicker = false }) {
|
ShxDialogButton(onClick = { showIntervalPicker = false }) {
|
||||||
Text(stringResource(android.R.string.cancel))
|
Text(stringResource(android.R.string.cancel))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -336,20 +329,20 @@ private fun AttemptsRow(
|
|||||||
{
|
{
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = ShxType.body,
|
||||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (enabled) LocalShxColors.current.text else LocalShxColors.current.textSub,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
IconButton(onClick = onDecrement, enabled = enabled && attempts > 1) {
|
IconButton(onClick = onDecrement, enabled = enabled && attempts > 1) {
|
||||||
Icon(Icons.Rounded.Remove, contentDescription = stringResource(R.string.health_check_attempt_minus))
|
Icon(ShxIcons.Minus, contentDescription = stringResource(R.string.health_check_attempt_minus))
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = attempts.toString(),
|
text = attempts.toString(),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = ShxType.cardTitle,
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.sm),
|
modifier = Modifier.padding(horizontal = ShxSpacing.sm),
|
||||||
)
|
)
|
||||||
IconButton(onClick = onIncrement, enabled = enabled && attempts < 5) {
|
IconButton(onClick = onIncrement, enabled = enabled && attempts < 5) {
|
||||||
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.health_check_attempt_plus))
|
Icon(ShxIcons.Plus, contentDescription = stringResource(R.string.health_check_attempt_plus))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+407
@@ -0,0 +1,407 @@
|
|||||||
|
/*
|
||||||
|
* 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.compose
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
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.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.statusBarsPadding
|
||||||
|
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.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.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.graphics.SolidColor
|
||||||
|
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.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
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.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
|
import org.strongswan.android.logic.connectivity.ConnectivityProbe
|
||||||
|
import org.strongswan.android.logic.connectivity.ProbeTarget
|
||||||
|
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||||
|
import org.strongswan.android.logic.connectivity.WhiteListStatus
|
||||||
|
import org.strongswan.android.logic.routing.AppTraffic
|
||||||
|
import org.strongswan.android.logic.routing.RoutingManager
|
||||||
|
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.design.ShxPageHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.routing.AppIcon
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
|
||||||
|
/** One line of the check list. */
|
||||||
|
data class DiagStep(val title: String, val value: String, val ok: Boolean?)
|
||||||
|
|
||||||
|
/** Result of a diagnostics round. */
|
||||||
|
data class DiagReport(val steps: List<DiagStep>, val at: Long, val vpnUp: Boolean)
|
||||||
|
{
|
||||||
|
/** null = still checking, true = everything answered */
|
||||||
|
val allOk: Boolean? get() = if (steps.any { it.ok == null }) null else steps.all { it.ok == true }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal suspend fun runDiagnostics(context: Context): DiagReport = coroutineScope {
|
||||||
|
val vpn = VpnStateService.getLiveInstance()
|
||||||
|
val vpnUp = vpn?.state == VpnStateService.State.CONNECTED
|
||||||
|
val gateway = (vpn?.profile)?.gateway
|
||||||
|
val whiteList = WhiteListManager.getInstance(context)
|
||||||
|
whiteList.checkNow()
|
||||||
|
val server = async { if (gateway != null) ConnectivityProbe.probe(ProbeTarget.SERVER, 3000, context, gateway) else null }
|
||||||
|
val foreign = async { ConnectivityProbe.probe(ProbeTarget.GOOGLE, 3000, context, null) }
|
||||||
|
val network = withContext(Dispatchers.Default) { networkLabel(context) }
|
||||||
|
/* the whitelist check runs its own direct round; give it a moment */
|
||||||
|
delay(2500)
|
||||||
|
val wl = whiteList.state.value
|
||||||
|
val serverResult = server.await()
|
||||||
|
val foreignResult = foreign.await()
|
||||||
|
val steps = listOf(
|
||||||
|
DiagStep(context.getString(R.string.diag_step_internet), network ?: context.getString(R.string.diag_none), network != null),
|
||||||
|
DiagStep(
|
||||||
|
context.getString(R.string.diag_step_ru),
|
||||||
|
when (wl.domesticDirectOk)
|
||||||
|
{
|
||||||
|
true -> wl.domesticDirectRttMs?.let { context.getString(R.string.diag_ms, it) } ?: context.getString(R.string.diag_ok)
|
||||||
|
false -> context.getString(R.string.diag_fail)
|
||||||
|
null -> context.getString(R.string.diag_unknown)
|
||||||
|
},
|
||||||
|
wl.domesticDirectOk ?: (network != null),
|
||||||
|
),
|
||||||
|
DiagStep(
|
||||||
|
context.getString(R.string.diag_step_foreign),
|
||||||
|
when
|
||||||
|
{
|
||||||
|
!foreignResult.ok -> if (wl.status == WhiteListStatus.ACTIVE) context.getString(R.string.diag_whitelist) else context.getString(R.string.diag_fail)
|
||||||
|
vpnUp -> context.getString(R.string.diag_via_vpn_ms, foreignResult.rttMs ?: 0)
|
||||||
|
else -> context.getString(R.string.diag_direct_ms, foreignResult.rttMs ?: 0)
|
||||||
|
},
|
||||||
|
foreignResult.ok,
|
||||||
|
),
|
||||||
|
DiagStep(
|
||||||
|
context.getString(R.string.diag_step_server),
|
||||||
|
when
|
||||||
|
{
|
||||||
|
gateway == null -> context.getString(R.string.diag_no_server)
|
||||||
|
serverResult?.ok == true -> context.getString(R.string.diag_ms, serverResult.rttMs ?: 0)
|
||||||
|
else -> context.getString(R.string.diag_no_answer)
|
||||||
|
},
|
||||||
|
if (gateway == null) network != null else serverResult?.ok == true,
|
||||||
|
),
|
||||||
|
DiagStep(
|
||||||
|
context.getString(R.string.diag_step_dns),
|
||||||
|
context.getString(if (vpnUp) R.string.diag_dns_tunnel else R.string.diag_dns_provider),
|
||||||
|
if (vpnUp) foreignResult.ok else network != null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DiagReport(steps, System.currentTimeMillis(), vpnUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun networkLabel(context: Context): String?
|
||||||
|
{
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null
|
||||||
|
var wifi = false
|
||||||
|
var cell = false
|
||||||
|
for (n in cm.allNetworks)
|
||||||
|
{
|
||||||
|
val caps = runCatching { cm.getNetworkCapabilities(n) }.getOrNull() ?: continue
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||||
|
{
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) wifi = true
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) cell = true
|
||||||
|
}
|
||||||
|
return when
|
||||||
|
{
|
||||||
|
wifi -> context.getString(R.string.network_wifi)
|
||||||
|
cell -> context.getString(R.string.network_cellular)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Diagnostics» (F_Diag): one look at the whole path, what does not open, unreachable hosts. */
|
||||||
|
@Composable
|
||||||
|
fun DiagnosticsScreen(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onLog: () -> Unit,
|
||||||
|
onCheckSite: (String) -> Unit,
|
||||||
|
onCheckApp: (String) -> Unit,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
var round by remember { mutableIntStateOf(0) }
|
||||||
|
val report by produceState<DiagReport?>(null, round) {
|
||||||
|
value = null
|
||||||
|
value = runDiagnostics(context)
|
||||||
|
}
|
||||||
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
|
val dead by produceState(emptyList<DeadTargetUi>(), lifecycle) {
|
||||||
|
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
val raw = runCatching { withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() } }.getOrNull()
|
||||||
|
value = HomeViewModel.parseDeadTargets(raw).filter { !it.viaTunnel }
|
||||||
|
delay(5_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val topApps by produceState(emptyList<String>()) {
|
||||||
|
value = withContext(Dispatchers.IO) {
|
||||||
|
AppTraffic.since(context, System.currentTimeMillis() - 60L * 60 * 1000).entries
|
||||||
|
.sortedByDescending { it.value }.map { it.key }.take(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var site by remember { mutableStateOf("") }
|
||||||
|
val manager = remember { RoutingManager.getInstance(context) }
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
|
||||||
|
{
|
||||||
|
ShxPageHeader(stringResource(R.string.diag_title), onBack)
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.diag_log), onLog, style = ShxButtonStyle.TONAL, height = 40.dp)
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||||
|
{
|
||||||
|
Verdict(report, onRepeat = { round++ })
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 4.dp))
|
||||||
|
{
|
||||||
|
val steps = report?.steps ?: listOf(
|
||||||
|
R.string.diag_step_internet, R.string.diag_step_ru, R.string.diag_step_foreign, R.string.diag_step_server, R.string.diag_step_dns,
|
||||||
|
).map { DiagStep(context.getString(it), context.getString(R.string.diag_checking), null) }
|
||||||
|
steps.forEachIndexed { i, step ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().height(50.dp))
|
||||||
|
{
|
||||||
|
val (bg, fg, mark) = when (step.ok)
|
||||||
|
{
|
||||||
|
true -> Triple(c.successContainer, c.onSuccess, "✓")
|
||||||
|
false -> Triple(c.errorContainer, c.onErrorContainer, "×")
|
||||||
|
null -> Triple(c.tile, c.iconMuted, "…")
|
||||||
|
}
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(22.dp).clip(CircleShape).background(bg))
|
||||||
|
{
|
||||||
|
Text(mark, style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = fg)
|
||||||
|
}
|
||||||
|
Text(step.title, style = ShxType.body.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
Text(step.value, style = ShxType.sub, color = c.textSub)
|
||||||
|
}
|
||||||
|
if (i < steps.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShxSectionHeader(stringResource(R.string.diag_not_opening))
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(14.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||||
|
{
|
||||||
|
if (topApps.isNotEmpty())
|
||||||
|
{
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
topApps.forEach { pkg ->
|
||||||
|
val label = remember(pkg) {
|
||||||
|
runCatching { context.packageManager.let { pm -> pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString() } }.getOrDefault(pkg)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(5.dp),
|
||||||
|
modifier = Modifier.weight(1f).clip(RoundedCornerShape(15.dp)).clickable(role = Role.Button) { onCheckApp(pkg) },
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(Modifier.size(48.dp).clip(RoundedCornerShape(15.dp))) { AppIcon(pkg, 48.dp) }
|
||||||
|
Text(label, style = ShxType.tag.copy(fontWeight = FontWeight.SemiBold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.weight(1f).height(48.dp).clip(RoundedCornerShape(16.dp)).background(c.tile).padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
|
Box(Modifier.weight(1f))
|
||||||
|
{
|
||||||
|
if (site.isEmpty())
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.diag_site_hint), style = ShxType.body.copy(fontSize = 15.sp), color = c.textSub)
|
||||||
|
}
|
||||||
|
BasicTextField(
|
||||||
|
value = site,
|
||||||
|
onValueChange = { site = it },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = ShxType.body.copy(fontSize = 15.sp, color = c.text),
|
||||||
|
cursorBrush = SolidColor(c.accent),
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { if (site.isNotBlank()) onCheckSite(site.trim()) }),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxButton(stringResource(R.string.route_check_button), { onCheckSite(site.trim()) }, height = 48.dp, enabled = site.isNotBlank())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dead.isNotEmpty())
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
ShxSectionHeader(stringResource(R.string.dead_targets_title))
|
||||||
|
Text(
|
||||||
|
dead.size.toString(),
|
||||||
|
style = ShxType.tag,
|
||||||
|
color = c.onWarning,
|
||||||
|
modifier = Modifier.padding(top = 6.dp).clip(RoundedCornerShape(6.dp)).background(c.warningStrong).padding(horizontal = 7.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
dead.forEachIndexed { i, target ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(60.dp).padding(start = 16.dp, end = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(target.dst, style = ShxType.mono.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.diag_dead_meta, target.fails, formatDuration(target.leftMs / 1000)),
|
||||||
|
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
|
||||||
|
color = c.textSub,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(c.warningStrong)
|
||||||
|
.clickable(role = Role.Button) {
|
||||||
|
val ip = target.dst.substringBeforeLast(':').removeSurrounding("[", "]")
|
||||||
|
manager.addForcePrefix(ip + if (ip.contains(':')) "/128" else "/32")
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.dead_targets_force), style = ShxType.chip, color = c.onWarningStrong)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i < dead.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxButton(stringResource(R.string.diag_send_report), onLog, style = ShxButtonStyle.OUTLINE, height = 52.dp, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Green / amber / grey verdict card with a big round mark. */
|
||||||
|
@Composable
|
||||||
|
private fun Verdict(report: DiagReport?, onRepeat: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val ok = report?.allOk
|
||||||
|
val (bg, mark, markFg, titleColor, subColor) = when (ok)
|
||||||
|
{
|
||||||
|
true -> Five(c.successContainer, c.success, androidx.compose.ui.graphics.Color.White, c.onSuccessStrong, c.onSuccess)
|
||||||
|
false -> Five(c.warningStrong, c.warning, androidx.compose.ui.graphics.Color.White, c.onWarningStrong, c.onWarningContainer)
|
||||||
|
null -> Five(c.tile, c.iconMuted, androidx.compose.ui.graphics.Color.White, c.text, c.textSub)
|
||||||
|
}
|
||||||
|
val title = when (ok)
|
||||||
|
{
|
||||||
|
true -> stringResource(if (report?.vpnUp == true) R.string.diag_verdict_ok else R.string.diag_verdict_ok_no_vpn)
|
||||||
|
false -> stringResource(R.string.diag_verdict_problem, report?.steps?.firstOrNull { it.ok == false }?.title ?: "")
|
||||||
|
null -> stringResource(R.string.diag_checking_title)
|
||||||
|
}
|
||||||
|
val sub = when (ok)
|
||||||
|
{
|
||||||
|
null -> stringResource(R.string.diag_checking_sub)
|
||||||
|
else -> stringResource(R.string.diag_checked_at, formatTimeAgo(context, report!!.at))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(bg)
|
||||||
|
.clickable(enabled = ok != null, role = Role.Button, onClick = onRepeat).padding(16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(mark))
|
||||||
|
{
|
||||||
|
Icon(if (ok == false) ShxIcons.Alert else if (ok == null) ShxIcons.Refresh else ShxIcons.Check, contentDescription = null, tint = markFg, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.cardHeading, color = titleColor)
|
||||||
|
Text(sub, style = ShxType.sub, color = subColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Five<A, B, C, D, E>(val a: A, val b: B, val c: C, val d: D, val e: E)
|
||||||
|
|
||||||
|
private fun formatTimeAgo(context: Context, at: Long): String
|
||||||
|
{
|
||||||
|
val sec = ((System.currentTimeMillis() - at) / 1000).coerceAtLeast(0)
|
||||||
|
return if (sec < 60) context.getString(R.string.diag_ago_sec, sec) else context.getString(R.string.diag_ago_min, sec / 60)
|
||||||
|
}
|
||||||
+10
-6
@@ -12,13 +12,16 @@ package org.strongswan.android.ui.compose
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/* «1,2 ГБ» in Russian, «1.2 GB» otherwise */
|
||||||
|
private fun russian() = Locale.getDefault().language == "ru"
|
||||||
|
|
||||||
fun formatBytes(bytes: Long): String
|
fun formatBytes(bytes: Long): String
|
||||||
{
|
{
|
||||||
|
val units = if (russian()) arrayOf("Б", "КБ", "МБ", "ГБ", "ТБ") else arrayOf("B", "KB", "MB", "GB", "TB")
|
||||||
if (bytes <= 0L)
|
if (bytes <= 0L)
|
||||||
{
|
{
|
||||||
return "0 B"
|
return "0 " + units[0]
|
||||||
}
|
}
|
||||||
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
|
||||||
var value = bytes.toDouble()
|
var value = bytes.toDouble()
|
||||||
var unit = 0
|
var unit = 0
|
||||||
while (value >= 1024 && unit < units.lastIndex)
|
while (value >= 1024 && unit < units.lastIndex)
|
||||||
@@ -26,17 +29,18 @@ fun formatBytes(bytes: Long): String
|
|||||||
value /= 1024
|
value /= 1024
|
||||||
unit++
|
unit++
|
||||||
}
|
}
|
||||||
return if (unit == 0) String.format(Locale.US, "%d %s", bytes, units[unit])
|
return if (unit == 0) String.format(Locale.getDefault(), "%d %s", bytes, units[unit])
|
||||||
else String.format(Locale.US, "%.1f %s", value, units[unit])
|
else String.format(Locale.getDefault(), "%.1f %s", value, units[unit])
|
||||||
}
|
}
|
||||||
|
|
||||||
fun formatRate(bytesPerSecond: Long): String
|
fun formatRate(bytesPerSecond: Long): String
|
||||||
{
|
{
|
||||||
|
val perSecond = if (russian()) "/с" else "/s"
|
||||||
if (abs(bytesPerSecond) < 32)
|
if (abs(bytesPerSecond) < 32)
|
||||||
{
|
{
|
||||||
return "0 B/s"
|
return formatBytes(0) + perSecond
|
||||||
}
|
}
|
||||||
return formatBytes(bytesPerSecond) + "/s"
|
return formatBytes(bytesPerSecond) + perSecond
|
||||||
}
|
}
|
||||||
|
|
||||||
fun formatDuration(seconds: Long): String
|
fun formatDuration(seconds: Long): String
|
||||||
|
|||||||
+215
-513
@@ -52,26 +52,7 @@ import androidx.compose.foundation.lazy.LazyListState
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||||
import androidx.compose.material.icons.rounded.Add
|
|
||||||
import androidx.compose.material.icons.rounded.AltRoute
|
|
||||||
import androidx.compose.material.icons.rounded.ArrowDownward
|
|
||||||
import androidx.compose.material.icons.rounded.ArrowUpward
|
|
||||||
import androidx.compose.material.icons.rounded.ChevronRight
|
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
|
||||||
import androidx.compose.material.icons.rounded.Dns
|
|
||||||
import androidx.compose.material.icons.rounded.ExpandLess
|
|
||||||
import androidx.compose.material.icons.rounded.GppMaybe
|
|
||||||
import androidx.compose.material.icons.rounded.Language
|
|
||||||
import androidx.compose.material.icons.rounded.PlayArrow
|
|
||||||
import androidx.compose.material.icons.rounded.Public
|
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
|
||||||
import androidx.compose.material.icons.rounded.Schedule
|
|
||||||
import androidx.compose.material.icons.rounded.Share
|
|
||||||
import androidx.compose.material.icons.rounded.SignalCellularAlt
|
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
|
||||||
import androidx.compose.material.icons.rounded.Wifi
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
@@ -118,7 +99,29 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import androidx.compose.foundation.lazy.LazyListScope
|
import androidx.compose.foundation.lazy.LazyListScope
|
||||||
import androidx.compose.ui.platform.LocalConfiguration
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.strongswan.android.ui.compose.home.CertExpiry
|
||||||
|
import org.strongswan.android.ui.compose.home.certExpiry
|
||||||
|
import org.strongswan.android.ui.compose.home.snoozeCertExpiry
|
||||||
|
import org.strongswan.android.ui.compose.home.connectProgress
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import org.strongswan.android.logic.routing.RuIpListManager
|
||||||
|
import org.strongswan.android.ui.DiagnosticsActivity
|
||||||
|
import org.strongswan.android.ui.compose.home.HomeActions
|
||||||
|
import org.strongswan.android.ui.compose.home.HomeModel
|
||||||
|
import org.strongswan.android.ui.compose.home.HomeContent
|
||||||
import org.strongswan.android.data.VpnProfile
|
import org.strongswan.android.data.VpnProfile
|
||||||
import org.strongswan.android.logic.VpnStateService.ErrorState
|
import org.strongswan.android.logic.VpnStateService.ErrorState
|
||||||
import org.strongswan.android.logic.VpnStateService.State
|
import org.strongswan.android.logic.VpnStateService.State
|
||||||
@@ -130,19 +133,27 @@ import org.strongswan.android.ui.compose.components.ShxCard
|
|||||||
import org.strongswan.android.ui.compose.components.ShxEmptyState
|
import org.strongswan.android.ui.compose.components.ShxEmptyState
|
||||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
||||||
import org.strongswan.android.ui.compose.theme.ScreenPadding
|
import org.strongswan.android.ui.compose.theme.ScreenPadding
|
||||||
import androidx.compose.material.icons.rounded.Check
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.semantics.LiveRegionMode
|
import androidx.compose.ui.semantics.LiveRegionMode
|
||||||
import androidx.compose.ui.semantics.heading
|
import androidx.compose.ui.semantics.heading
|
||||||
import androidx.compose.ui.semantics.liveRegion
|
import androidx.compose.ui.semantics.liveRegion
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import org.strongswan.android.ui.compose.home.AvailabilityCard
|
import org.strongswan.android.ui.compose.home.DEFAULT_PAUSE_MINUTES
|
||||||
import org.strongswan.android.ui.compose.home.HeroConnect
|
import org.strongswan.android.ui.compose.home.HomeProblem
|
||||||
import org.strongswan.android.ui.compose.home.HomeBannerSlot
|
import org.strongswan.android.ui.compose.home.PauseSheet
|
||||||
import org.strongswan.android.ui.compose.home.ProfileChip
|
import org.strongswan.android.ui.compose.home.formatClock
|
||||||
|
import org.strongswan.android.ui.compose.home.formatCountdown
|
||||||
|
import org.strongswan.android.ui.compose.home.homeProblem
|
||||||
|
import org.strongswan.android.ui.compose.home.rememberNow
|
||||||
|
import org.strongswan.android.ui.compose.home.titleRes
|
||||||
|
import org.strongswan.android.ui.RouteCheckActivity
|
||||||
|
import org.strongswan.android.ui.InboxActivity
|
||||||
|
import org.strongswan.android.logic.events.EventLog
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
import org.strongswan.android.ui.compose.home.RTT_HISTORY
|
import org.strongswan.android.ui.compose.home.RTT_HISTORY
|
||||||
import org.strongswan.android.ui.compose.home.TrafficTiles
|
|
||||||
import org.strongswan.android.ui.compose.home.labelRes
|
import org.strongswan.android.ui.compose.home.labelRes
|
||||||
import org.strongswan.android.ui.compose.home.rttText
|
import org.strongswan.android.ui.compose.home.rttText
|
||||||
import org.strongswan.android.ui.compose.home.sortedForDisplay
|
import org.strongswan.android.ui.compose.home.sortedForDisplay
|
||||||
@@ -169,6 +180,10 @@ fun HomeScreen(
|
|||||||
val whiteList by viewModel.whiteList.collectAsStateWithLifecycle()
|
val whiteList by viewModel.whiteList.collectAsStateWithLifecycle()
|
||||||
val exit by viewModel.exit.collectAsStateWithLifecycle()
|
val exit by viewModel.exit.collectAsStateWithLifecycle()
|
||||||
val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle()
|
val deadTargets by viewModel.deadTargets.collectAsStateWithLifecycle()
|
||||||
|
val pauseUntil by viewModel.pauseUntil.collectAsStateWithLifecycle()
|
||||||
|
val networkAvailable by viewModel.networkAvailable.collectAsStateWithLifecycle()
|
||||||
|
val events by remember { EventLog.getInstance(context).events }.collectAsStateWithLifecycle()
|
||||||
|
val unreadEvents = events.count { !it.read }
|
||||||
/* rolling RTT history per target, rendered as sparklines in the availability card */
|
/* rolling RTT history per target, rendered as sparklines in the availability card */
|
||||||
val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() }
|
val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() }
|
||||||
/* the map is updated one target at a time as the parallel probes finish:
|
/* the map is updated one target at a time as the parallel probes finish:
|
||||||
@@ -203,177 +218,138 @@ fun HomeScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var pendingDelete by remember { mutableStateOf<VpnProfile?>(null) }
|
|
||||||
var sheetProfile by remember { mutableStateOf<VpnProfile?>(null) }
|
|
||||||
var pickerOpen by remember { mutableStateOf(false) }
|
var pickerOpen by remember { mutableStateOf(false) }
|
||||||
|
var pauseSheetOpen by remember { mutableStateOf(false) }
|
||||||
|
val networkKinds by viewModel.networkKinds.collectAsStateWithLifecycle()
|
||||||
|
val findingFastest by viewModel.findingFastest.collectAsStateWithLifecycle()
|
||||||
|
val ruState by remember { RuIpListManager.getInstance(context).state }.collectAsStateWithLifecycle()
|
||||||
val connected = state.vpnState == State.CONNECTED
|
val connected = state.vpnState == State.CONNECTED
|
||||||
val connecting = state.vpnState == State.CONNECTING || state.vpnState == State.DISCONNECTING
|
|
||||||
val hasError = state.error != ErrorState.NO_ERROR
|
|
||||||
val selected = state.selected
|
val selected = state.selected
|
||||||
val durationSec = if (connected) traffic.durationSec else 0L
|
val durationSec = if (connected) traffic.durationSec else 0L
|
||||||
val status = LocalShxStatusColors.current
|
/* a pause only matters while the tunnel is actually down */
|
||||||
val statusText = when
|
val pauseActive = pauseUntil > 0L && state.vpnState == State.DISABLED
|
||||||
{
|
val now = if (pauseActive) rememberNow() else System.currentTimeMillis()
|
||||||
hasError -> stringResource(state.errorText)
|
val problem = if (pauseActive && pauseUntil > now) null else homeProblem(state, networkAvailable)
|
||||||
connected -> stringResource(R.string.status_protected)
|
|
||||||
connecting && state.vpnState == State.DISCONNECTING -> stringResource(R.string.state_disconnecting)
|
|
||||||
connecting -> stringResource(R.string.status_connecting)
|
|
||||||
else -> stringResource(R.string.status_unprotected)
|
|
||||||
}
|
|
||||||
val statusColor = when
|
|
||||||
{
|
|
||||||
hasError -> MaterialTheme.colorScheme.error
|
|
||||||
connected -> status.connected
|
|
||||||
connecting -> status.connecting
|
|
||||||
else -> status.disconnected
|
|
||||||
}
|
|
||||||
val subtitle = when
|
|
||||||
{
|
|
||||||
connected -> "${selected?.name ?: ""} · ${formatDuration(durationSec)}"
|
|
||||||
connecting -> selected?.name ?: ""
|
|
||||||
else -> stringResource(R.string.status_choose_profile)
|
|
||||||
}
|
|
||||||
val whiteListActive = whiteList.config.enabled && whiteList.status == WhiteListStatus.ACTIVE
|
val whiteListActive = whiteList.config.enabled && whiteList.status == WhiteListStatus.ACTIVE
|
||||||
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
|
val lockdown = remember { context.isVpnLockdownEnabled() }
|
||||||
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<IntSize>()
|
/* F_CertExpiry: the CA of the shown profile runs out soon */
|
||||||
|
val certAlias = (state.activeProfile ?: selected)?.certificateAlias
|
||||||
Box(Modifier.fillMaxSize())
|
var certSnoozes by remember { mutableStateOf(0) }
|
||||||
{
|
val certExpiry by produceState<CertExpiry?>(null, certAlias, certSnoozes) {
|
||||||
StatusGlowBackground(color = statusColor, active = connected || connecting)
|
value = withContext(Dispatchers.IO) { certExpiry(context, certAlias) }
|
||||||
val wide = LocalConfiguration.current.screenWidthDp >= 840
|
}
|
||||||
val leftItems: LazyListScope.() -> Unit = {
|
val connectingNow = state.vpnState == State.CONNECTING
|
||||||
item(key = "hero") {
|
val connectStep by produceState(1, connectingNow) {
|
||||||
Column(
|
while (connectingNow)
|
||||||
modifier = Modifier
|
{
|
||||||
.fillMaxWidth()
|
value = withContext(Dispatchers.IO) { connectProgress(context) }
|
||||||
.padding(top = ShxSpacing.xl),
|
kotlinx.coroutines.delay(600)
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
}
|
||||||
) {
|
}
|
||||||
HeroConnect(
|
val historyChanges by org.strongswan.android.logic.stats.TrafficHistory.changes.collectAsStateWithLifecycle()
|
||||||
state = state.vpnState,
|
val today by produceState<org.strongswan.android.logic.stats.DayTraffic?>(null, historyChanges) {
|
||||||
enabled = selected != null,
|
value = withContext(Dispatchers.IO) { org.strongswan.android.logic.stats.TrafficHistory.today(context) }
|
||||||
onConnect = { selected?.let { viewModel.connect(context, it) } },
|
}
|
||||||
onDisconnect = { viewModel.disconnect() },
|
val ssid by viewModel.ssid.collectAsStateWithLifecycle()
|
||||||
onCancel = { viewModel.disconnect() },
|
var hintTick by remember { mutableStateOf(0) }
|
||||||
)
|
val hint by produceState<org.strongswan.android.ui.compose.home.ScenarioHint?>(null, ssid, state.vpnState, hintTick) {
|
||||||
Spacer(Modifier.height(ShxSpacing.lg))
|
value = withContext(Dispatchers.IO) { org.strongswan.android.ui.compose.home.ScenarioHints.hint(context, ssid) }
|
||||||
/* the status is announced to TalkBack whenever it changes */
|
}
|
||||||
Column(
|
val extras by remember { org.strongswan.android.logic.profiles.ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
val shownUuid = (state.activeProfile ?: selected)?.getUUID()?.toString()
|
||||||
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
|
val hasLink = shownUuid != null && extras[shownUuid]?.source == org.strongswan.android.logic.profiles.ProfileSourceType.LINK
|
||||||
) {
|
val model = HomeModel(
|
||||||
AnimatedContent(
|
state = state,
|
||||||
targetState = statusText,
|
traffic = traffic,
|
||||||
transitionSpec = { fadeIn(effects) togetherWith fadeOut(effects) },
|
exit = exit,
|
||||||
label = "statusText",
|
serverRttMs = probes[ProbeTarget.SERVER]?.takeIf { it.ok }?.rttMs,
|
||||||
) { text ->
|
problem = problem,
|
||||||
Text(
|
whiteListActive = whiteListActive,
|
||||||
text = text,
|
pausedUntil = if (pauseActive) pauseUntil else 0L,
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
now = now,
|
||||||
color = statusColor,
|
ruDirect = ruState.enabled,
|
||||||
)
|
lockdown = lockdown,
|
||||||
}
|
networkKinds = networkKinds,
|
||||||
Spacer(Modifier.height(ShxSpacing.xs))
|
unread = unreadEvents,
|
||||||
Text(
|
findingFastest = findingFastest,
|
||||||
text = subtitle,
|
certExpiry = certExpiry,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
connectStep = connectStep,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
today = today,
|
||||||
)
|
hint = hint,
|
||||||
}
|
hasLink = hasLink,
|
||||||
}
|
)
|
||||||
}
|
val actions = HomeActions(
|
||||||
if (state.profiles.isEmpty())
|
connect = { selected?.let { viewModel.connect(context, it) } },
|
||||||
{
|
disconnect = { viewModel.disconnect() },
|
||||||
item(key = "empty") {
|
retry = { viewModel.reconnect() },
|
||||||
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) {
|
resume = { viewModel.resumeNow() },
|
||||||
ShxEmptyState(
|
extendPause = { viewModel.extendPause(DEFAULT_PAUSE_MINUTES) },
|
||||||
icon = Icons.Rounded.Language,
|
openPause = { pauseSheetOpen = true },
|
||||||
message = stringResource(R.string.no_profiles),
|
fastest = { viewModel.connectFastest(context) },
|
||||||
actionLabel = stringResource(R.string.add_profile_cta),
|
pickServer = { pickerOpen = true },
|
||||||
onAction = onAddProfile,
|
addServer = onAddProfile,
|
||||||
)
|
editProfile = { selected?.let(onEditProfile) },
|
||||||
}
|
showLog = onShowLog,
|
||||||
}
|
inbox = { context.startActivity(Intent(context, InboxActivity::class.java)) },
|
||||||
}
|
diagnostics = { context.startActivity(Intent(context, DiagnosticsActivity::class.java)) },
|
||||||
|
routeCheck = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) },
|
||||||
|
networkSettings = {
|
||||||
|
val panel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||||
|
Intent(Settings.Panel.ACTION_INTERNET_CONNECTIVITY)
|
||||||
else
|
else
|
||||||
|
Intent(Settings.ACTION_WIRELESS_SETTINGS)
|
||||||
|
try
|
||||||
{
|
{
|
||||||
item(key = "chip") {
|
context.startActivity(panel)
|
||||||
ProfileChip(
|
|
||||||
profile = selected,
|
|
||||||
exit = exit,
|
|
||||||
connected = connected,
|
|
||||||
onClick = { pickerOpen = true },
|
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg).animateItem(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
item(key = "traffic") {
|
catch (_: android.content.ActivityNotFoundException)
|
||||||
AnimatedVisibility(
|
|
||||||
visible = connected,
|
|
||||||
enter = fadeIn(effects) + expandVertically(spatial),
|
|
||||||
exit = fadeOut(effects) + shrinkVertically(spatial),
|
|
||||||
) {
|
|
||||||
TrafficTiles(traffic = traffic, onClick = { viewModel.openDetails(true) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val rightItems: LazyListScope.() -> Unit = {
|
|
||||||
item(key = "banner") {
|
|
||||||
HomeBannerSlot(
|
|
||||||
error = hasError,
|
|
||||||
whiteListActive = whiteListActive,
|
|
||||||
onRetry = { viewModel.reconnect() },
|
|
||||||
onShowLog = onShowLog,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (connected)
|
|
||||||
{
|
{
|
||||||
item(key = "availability") {
|
context.startActivity(Intent(Settings.ACTION_SETTINGS))
|
||||||
AvailabilityCard(
|
|
||||||
probes = probes,
|
|
||||||
probing = probing,
|
|
||||||
whiteList = whiteList,
|
|
||||||
history = probeHistory,
|
|
||||||
deadTargets = deadTargets,
|
|
||||||
onOpenRouting = { viewModel.setTab(ShxTab.ROUTING) },
|
|
||||||
onForceTunnel = viewModel::forceTunnelDeadTarget,
|
|
||||||
onRefresh = { viewModel.refreshProbes() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
item(key = "space") { Spacer(Modifier.height(ShxSpacing.xl)) }
|
},
|
||||||
}
|
vpnSettings = {
|
||||||
if (wide)
|
try
|
||||||
{
|
|
||||||
/* expanded width: hero and profile on the left, status blocks on the right */
|
|
||||||
Row(Modifier.fillMaxSize().statusBarsPadding())
|
|
||||||
{
|
{
|
||||||
LazyColumn(
|
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||||
state = listState,
|
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(ShxSpacing.lg),
|
|
||||||
) { leftItems() }
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.weight(1f).fillMaxHeight().padding(top = ShxSpacing.xl),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(ShxSpacing.lg),
|
|
||||||
) { rightItems() }
|
|
||||||
}
|
}
|
||||||
}
|
catch (_: android.content.ActivityNotFoundException)
|
||||||
else
|
|
||||||
{
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
/* the adaptive navigation scaffold no longer pads the content area,
|
|
||||||
* so the hero offsets itself below the status bar */
|
|
||||||
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(ShxSpacing.lg),
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
leftItems()
|
}
|
||||||
rightItems()
|
},
|
||||||
|
details = { viewModel.openDetails(true) },
|
||||||
|
certUpdate = {
|
||||||
|
if (hasLink && shownUuid != null) context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, shownUuid, checkUpdate = true))
|
||||||
|
else ShxNavigation.requestAddServer()
|
||||||
|
},
|
||||||
|
updateProfile = { shownUuid?.let { context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, it, checkUpdate = true)) } },
|
||||||
|
stats = { context.startActivity(Intent(context, org.strongswan.android.ui.StatsActivity::class.java)) },
|
||||||
|
scenarios = { context.startActivity(Intent(context, org.strongswan.android.ui.ScenariosActivity::class.java)) },
|
||||||
|
suggestYes = {
|
||||||
|
ssid?.let { org.strongswan.android.ui.compose.home.ScenarioHints.accept(context, it, selected?.getUUID()?.toString()) }
|
||||||
|
hintTick++
|
||||||
|
},
|
||||||
|
suggestNo = {
|
||||||
|
ssid?.let { org.strongswan.android.ui.compose.home.ScenarioHints.decline(context, it) }
|
||||||
|
hintTick++
|
||||||
|
},
|
||||||
|
certLater = {
|
||||||
|
certAlias?.let { snoozeCertExpiry(context, it) }
|
||||||
|
certSnoozes++
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
/* the adaptive navigation scaffold no longer pads the content area,
|
||||||
|
* so the header offsets itself below the status bar */
|
||||||
|
modifier = Modifier.fillMaxSize().statusBarsPadding(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
item(key = "home") {
|
||||||
|
Box(Modifier.widthIn(max = 560.dp))
|
||||||
|
{
|
||||||
|
HomeContent(model, actions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -396,7 +372,7 @@ fun HomeScreen(
|
|||||||
},
|
},
|
||||||
onLongPick = { profile ->
|
onLongPick = { profile ->
|
||||||
pickerOpen = false
|
pickerOpen = false
|
||||||
sheetProfile = profile
|
context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString()))
|
||||||
},
|
},
|
||||||
onManage = {
|
onManage = {
|
||||||
pickerOpen = false
|
pickerOpen = false
|
||||||
@@ -409,69 +385,29 @@ fun HomeScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.detailsOpen)
|
if (pauseSheetOpen)
|
||||||
{
|
{
|
||||||
ConnectionDetailsSheet(
|
PauseSheet(
|
||||||
profile = state.activeProfile ?: state.selected,
|
onDismiss = { pauseSheetOpen = false },
|
||||||
traffic = traffic,
|
onPick = { minutes ->
|
||||||
durationSec = durationSec,
|
pauseSheetOpen = false
|
||||||
probes = probes,
|
viewModel.pause(minutes)
|
||||||
exit = exit,
|
},
|
||||||
onDismiss = { viewModel.openDetails(false) },
|
onMorning = {
|
||||||
onDisconnect = {
|
pauseSheetOpen = false
|
||||||
viewModel.openDetails(false)
|
viewModel.pauseUntilMorning()
|
||||||
viewModel.disconnect()
|
},
|
||||||
|
onNetworkChange = {
|
||||||
|
pauseSheetOpen = false
|
||||||
|
viewModel.pauseUntilNetworkChange()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
sheetProfile?.let { profile ->
|
|
||||||
ProfileActionsSheet(
|
|
||||||
profile = profile,
|
|
||||||
active = connected && profile.getUUID() == state.activeProfile?.getUUID(),
|
|
||||||
onDismiss = { sheetProfile = null },
|
|
||||||
onConnect = {
|
|
||||||
sheetProfile = null
|
|
||||||
viewModel.select(profile)
|
|
||||||
viewModel.connect(context, profile)
|
|
||||||
},
|
|
||||||
onEdit = {
|
|
||||||
sheetProfile = null
|
|
||||||
onEditProfile(profile)
|
|
||||||
},
|
|
||||||
onShare = {
|
|
||||||
sheetProfile = null
|
|
||||||
onShareProfile(profile)
|
|
||||||
},
|
|
||||||
onDelete = {
|
|
||||||
sheetProfile = null
|
|
||||||
pendingDelete = profile
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingDelete?.let { profile ->
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { pendingDelete = null },
|
|
||||||
title = { Text(stringResource(R.string.delete_profile)) },
|
|
||||||
text = { Text(profile.name ?: "") },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
viewModel.delete(profile)
|
|
||||||
pendingDelete = null
|
|
||||||
}) { Text(stringResource(R.string.delete_profile)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { pendingDelete = null }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Bottom sheet to switch the profile without leaving the Connect tab. */
|
/** «Server» sheet of Home: the servers as on F_Servers, manage / add below. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProfilePickerSheet(
|
private fun ProfilePickerSheet(
|
||||||
profiles: List<VpnProfile>,
|
profiles: List<VpnProfile>,
|
||||||
@@ -484,292 +420,58 @@ private fun ProfilePickerSheet(
|
|||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
/* the current profile first, the rest in list order */
|
/* the current profile first, the rest in list order */
|
||||||
val ordered = remember(profiles, selected) {
|
val ordered = remember(profiles, selected) {
|
||||||
profiles.sortedByDescending { it.getUUID() == selected?.getUUID() }
|
profiles.sortedByDescending { it.getUUID() == selected?.getUUID() }
|
||||||
}
|
}
|
||||||
ModalBottomSheet(
|
ShxSheet(onDismiss = onDismiss)
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Column(Modifier.fillMaxWidth())
|
ShxSheetTitle(stringResource(R.string.picker_title))
|
||||||
|
ShxPanel()
|
||||||
{
|
{
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.home_profile_picker_title),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.sm).semantics { heading() },
|
|
||||||
)
|
|
||||||
ordered.forEachIndexed { index, profile ->
|
ordered.forEachIndexed { index, profile ->
|
||||||
ShxProfileRow(
|
val active = profile.getUUID() == activeUuid
|
||||||
profile = profile,
|
val chosen = profile.getUUID() == selected?.getUUID()
|
||||||
active = profile.getUUID() == activeUuid,
|
Row(
|
||||||
onClick = { onPick(profile) },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
onLongClick = { onLongPick(profile) },
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
onLongClickLabel = stringResource(R.string.profile_actions),
|
modifier = Modifier.fillMaxWidth().height(68.dp)
|
||||||
trailing = if (profile.getUUID() == selected?.getUUID() && profile.getUUID() != activeUuid)
|
.combinedClickable(
|
||||||
{
|
onClick = { onPick(profile) },
|
||||||
{ Icon(Icons.Rounded.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }
|
onLongClick = { onLongPick(profile) },
|
||||||
}
|
onLongClickLabel = stringResource(R.string.profile_actions),
|
||||||
else null,
|
)
|
||||||
|
.padding(horizontal = 14.dp),
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(if (active) c.accent else c.tile))
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
org.strongswan.android.ui.compose.home.serverCode(profile, null),
|
||||||
|
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = if (active) Color.White else c.onAccentContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp))
|
||||||
|
{
|
||||||
|
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle, color = c.text, maxLines = 1)
|
||||||
|
Text(profile.gateway ?: "", style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1)
|
||||||
|
}
|
||||||
|
if (chosen)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
if (index < ordered.lastIndex)
|
if (index < ordered.lastIndex)
|
||||||
{
|
{
|
||||||
ShxDivider()
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
TextButton(onClick = onManage) { Text(stringResource(R.string.home_manage_profiles)) }
|
|
||||||
TextButton(onClick = onAdd) {
|
|
||||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
||||||
Spacer(Modifier.size(ShxSpacing.xs))
|
|
||||||
Text(stringResource(R.string.add_profile_cta))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(ShxSpacing.lg))
|
|
||||||
}
|
}
|
||||||
}
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
}
|
|
||||||
|
|
||||||
/** Soft state-colored glow behind the hero; the pulse is skipped when system animations are off. */
|
|
||||||
@Composable
|
|
||||||
private fun StatusGlowBackground(color: Color, active: Boolean)
|
|
||||||
{
|
|
||||||
val context = LocalContext.current
|
|
||||||
val animationsOn = remember {
|
|
||||||
android.provider.Settings.Global.getFloat(
|
|
||||||
context.contentResolver,
|
|
||||||
android.provider.Settings.Global.ANIMATOR_DURATION_SCALE,
|
|
||||||
1f,
|
|
||||||
) != 0f
|
|
||||||
}
|
|
||||||
val animated by animateColorAsState(color, label = "glow")
|
|
||||||
if (!active)
|
|
||||||
{ /* nothing to draw: no canvas and, above all, no infinite transition
|
|
||||||
* redrawing a transparent circle every frame while disconnected */
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (animationsOn)
|
|
||||||
{
|
|
||||||
val transition = rememberInfiniteTransition(label = "pulse")
|
|
||||||
/* read inside the draw scope below so the pulse only invalidates drawing,
|
|
||||||
* instead of recomposing this composable on every frame */
|
|
||||||
val pulse by transition.animateFloat(
|
|
||||||
initialValue = 0.30f,
|
|
||||||
targetValue = 0.55f,
|
|
||||||
animationSpec = infiniteRepeatable(tween(1600, easing = LinearEasing), RepeatMode.Reverse),
|
|
||||||
label = "a",
|
|
||||||
)
|
|
||||||
Canvas(Modifier.fillMaxSize()) { drawGlow(animated, pulse) }
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Canvas(Modifier.fillMaxSize()) { drawGlow(animated, 0.42f) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawGlow(color: Color, alpha: Float)
|
|
||||||
{
|
|
||||||
val center = Offset(size.width / 2f, size.height * 0.16f)
|
|
||||||
val radius = size.minDimension * 0.62f
|
|
||||||
drawCircle(
|
|
||||||
brush = Brush.radialGradient(
|
|
||||||
colors = listOf(color.copy(alpha = alpha * 0.30f), Color.Transparent),
|
|
||||||
center = center,
|
|
||||||
radius = radius,
|
|
||||||
),
|
|
||||||
radius = radius,
|
|
||||||
center = center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun ConnectionDetailsSheet(
|
|
||||||
profile: VpnProfile?,
|
|
||||||
traffic: TrafficUi,
|
|
||||||
durationSec: Long,
|
|
||||||
probes: Map<ProbeTarget, ProbeResult>,
|
|
||||||
exit: ExitUi?,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onDisconnect: () -> Unit,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Column(Modifier.padding(horizontal = 24.dp))
|
|
||||||
{
|
{
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
|
ShxButton(stringResource(R.string.picker_all), onManage, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f))
|
||||||
{
|
ShxButton(stringResource(R.string.picker_add), onAdd, style = ShxButtonStyle.OUTLINE, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.Plus)
|
||||||
ProfileAvatar(icon = Icons.Rounded.Language, connected = true)
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
profile?.name ?: "",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
profile?.gateway ?: "",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
DetailRow(icon = Icons.Rounded.Language, label = stringResource(R.string.profile_gateway_label), value = profile?.gateway ?: "—")
|
|
||||||
DetailRow(icon = Icons.Rounded.Schedule, label = stringResource(R.string.duration_label), value = formatDuration(durationSec))
|
|
||||||
DetailRow(
|
|
||||||
icon = Icons.Rounded.ArrowDownward,
|
|
||||||
label = stringResource(R.string.traffic_down),
|
|
||||||
value = "${formatBytes(traffic.bytesIn)} · ${formatRate(traffic.rateIn)}",
|
|
||||||
)
|
|
||||||
DetailRow(
|
|
||||||
icon = Icons.Rounded.ArrowUpward,
|
|
||||||
label = stringResource(R.string.traffic_up),
|
|
||||||
value = "${formatBytes(traffic.bytesOut)} · ${formatRate(traffic.rateOut)}",
|
|
||||||
)
|
|
||||||
/* RU-split session breakdown; null in the system-routes mode where
|
|
||||||
* the engine never sees the direct traffic */
|
|
||||||
traffic.split?.let { split ->
|
|
||||||
DetailRow(
|
|
||||||
icon = Icons.Rounded.AltRoute,
|
|
||||||
label = stringResource(R.string.traffic_split_tunnel),
|
|
||||||
value = "${formatBytes(split.tunnelBytes)} · ${formatRate(split.tunnelRate)}",
|
|
||||||
)
|
|
||||||
DetailRow(
|
|
||||||
icon = Icons.Rounded.AltRoute,
|
|
||||||
label = stringResource(R.string.traffic_split_direct),
|
|
||||||
value = "${formatBytes(split.directBytes)} · ${formatRate(split.directRate)}",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
exit?.let { e ->
|
|
||||||
e.ip?.let { ip ->
|
|
||||||
DetailRow(
|
|
||||||
icon = Icons.Rounded.Public,
|
|
||||||
label = stringResource(R.string.exit_ip_label),
|
|
||||||
value = e.country?.let { "$ip · $it" } ?: ip,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
e.network?.let { network ->
|
|
||||||
DetailRow(
|
|
||||||
icon = if (e.ssid != null) Icons.Rounded.Wifi else Icons.Rounded.SignalCellularAlt,
|
|
||||||
label = stringResource(R.string.network_label),
|
|
||||||
value = e.ssid?.let { "$network · $it" } ?: network,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (probes.isNotEmpty())
|
|
||||||
{
|
|
||||||
probes.sortedForDisplay().forEach { result ->
|
|
||||||
DetailRow(
|
|
||||||
icon = Icons.Rounded.Dns,
|
|
||||||
label = stringResource(result.target.labelRes()),
|
|
||||||
value = result.rttText(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = onDisconnect,
|
|
||||||
colors = ButtonDefaults.filledTonalButtonColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.disconnect))
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(32.dp))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DetailRow(icon: ImageVector, label: String, value: String)
|
|
||||||
{
|
|
||||||
ListItem(
|
|
||||||
leadingContent = {
|
|
||||||
Surface(
|
|
||||||
shape = CircleShape,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(8.dp)
|
|
||||||
.size(18.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
headlineContent = { Text(label) },
|
|
||||||
supportingContent = { Text(value) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun ProfileActionsSheet(
|
|
||||||
profile: VpnProfile,
|
|
||||||
active: Boolean,
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConnect: () -> Unit,
|
|
||||||
onEdit: () -> Unit,
|
|
||||||
onShare: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Column(Modifier.fillMaxWidth())
|
|
||||||
{
|
|
||||||
ShxProfileRow(profile = profile, active = active, onClick = {}, onLongClick = {})
|
|
||||||
ShxDivider(indent = false)
|
|
||||||
SheetAction(icon = Icons.Rounded.PlayArrow, label = stringResource(R.string.connect), onClick = onConnect)
|
|
||||||
ShxDivider(indent = false)
|
|
||||||
SheetAction(icon = Icons.Rounded.Edit, label = stringResource(R.string.edit_profile), onClick = onEdit)
|
|
||||||
ShxDivider(indent = false)
|
|
||||||
SheetAction(icon = Icons.Rounded.Share, label = stringResource(R.string.share_profile), onClick = onShare)
|
|
||||||
ShxDivider(indent = false)
|
|
||||||
SheetAction(
|
|
||||||
icon = Icons.Rounded.Delete,
|
|
||||||
label = stringResource(R.string.delete_profile),
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
onClick = onDelete,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SheetAction(icon: ImageVector, label: String, onClick: () -> Unit, tint: Color = Color.Unspecified)
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.combinedClickable(onClick = onClick)
|
|
||||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(22.dp))
|
|
||||||
Spacer(Modifier.size(16.dp))
|
|
||||||
Text(label, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+187
-1
@@ -24,7 +24,9 @@ import android.content.Intent
|
|||||||
import android.content.IntentFilter
|
import android.content.IntentFilter
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -35,6 +37,7 @@ import androidx.lifecycle.repeatOnLifecycle
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -55,6 +58,7 @@ import org.strongswan.android.data.ProfileExportProvider
|
|||||||
import org.strongswan.android.data.VpnProfile
|
import org.strongswan.android.data.VpnProfile
|
||||||
import org.strongswan.android.data.VpnProfileSource
|
import org.strongswan.android.data.VpnProfileSource
|
||||||
import org.strongswan.android.logic.CharonVpnService
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import org.strongswan.android.logic.PauseManager
|
||||||
import org.strongswan.android.logic.VpnProfileExporter
|
import org.strongswan.android.logic.VpnProfileExporter
|
||||||
import org.strongswan.android.logic.VpnStateService
|
import org.strongswan.android.logic.VpnStateService
|
||||||
import org.strongswan.android.logic.VpnStateService.ErrorState
|
import org.strongswan.android.logic.VpnStateService.ErrorState
|
||||||
@@ -123,6 +127,8 @@ data class HomeUiState(
|
|||||||
val vpnState: State = State.DISABLED,
|
val vpnState: State = State.DISABLED,
|
||||||
val error: ErrorState = ErrorState.NO_ERROR,
|
val error: ErrorState = ErrorState.NO_ERROR,
|
||||||
val errorText: Int = 0,
|
val errorText: Int = 0,
|
||||||
|
/** seconds until the next automatic retry while an error is shown, 0 if none */
|
||||||
|
val retryIn: Int = 0,
|
||||||
val activeProfile: VpnProfile? = null,
|
val activeProfile: VpnProfile? = null,
|
||||||
val profiles: List<VpnProfile> = emptyList(),
|
val profiles: List<VpnProfile> = emptyList(),
|
||||||
val selected: VpnProfile? = null,
|
val selected: VpnProfile? = null,
|
||||||
@@ -164,6 +170,75 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
|||||||
private val _deadTargets = MutableStateFlow<List<DeadTargetUi>>(emptyList())
|
private val _deadTargets = MutableStateFlow<List<DeadTargetUi>>(emptyList())
|
||||||
val deadTargets: StateFlow<List<DeadTargetUi>> = _deadTargets
|
val deadTargets: StateFlow<List<DeadTargetUi>> = _deadTargets
|
||||||
|
|
||||||
|
/* wall-clock end of a running "pause VPN", 0 if none */
|
||||||
|
val pauseUntil: StateFlow<Long> = PauseManager.until(app)
|
||||||
|
|
||||||
|
/* whether any non-VPN network is up; tells "no internet" apart from a server problem */
|
||||||
|
private val _networkAvailable = MutableStateFlow(true)
|
||||||
|
val networkAvailable: StateFlow<Boolean> = _networkAvailable
|
||||||
|
/* (Wi-Fi up, mobile data up) for the "no internet" checklist */
|
||||||
|
private val _networkKinds = MutableStateFlow(false to false)
|
||||||
|
val networkKinds: StateFlow<Pair<Boolean, Boolean>> = _networkKinds
|
||||||
|
|
||||||
|
/* the current Wi-Fi network (null off Wi-Fi or without location access) */
|
||||||
|
private val _ssid = MutableStateFlow<String?>(null)
|
||||||
|
val ssid: StateFlow<String?> = _ssid
|
||||||
|
private val underlyingNetworks = HashMap<Network, NetworkCapabilities?>()
|
||||||
|
private val networkCallback = object : ConnectivityManager.NetworkCallback()
|
||||||
|
{
|
||||||
|
override fun onAvailable(network: Network)
|
||||||
|
{
|
||||||
|
synchronized(underlyingNetworks)
|
||||||
|
{
|
||||||
|
underlyingNetworks[network] = null
|
||||||
|
publishNetworks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
|
||||||
|
{
|
||||||
|
synchronized(underlyingNetworks)
|
||||||
|
{
|
||||||
|
val previous = underlyingNetworks.put(network, capabilities)
|
||||||
|
/* fired on every signal-strength tick: the published state (network
|
||||||
|
* kinds, SSID) only depends on the transports, and reading the SSID
|
||||||
|
* is a system call, so unchanged transports end here */
|
||||||
|
if (previous != null && sameTransports(previous, capabilities))
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publishNetworks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network)
|
||||||
|
{
|
||||||
|
synchronized(underlyingNetworks)
|
||||||
|
{
|
||||||
|
underlyingNetworks.remove(network)
|
||||||
|
publishNetworks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
private fun publishNetworks()
|
||||||
|
{
|
||||||
|
_networkAvailable.value = underlyingNetworks.isNotEmpty()
|
||||||
|
val caps = underlyingNetworks.values.filterNotNull()
|
||||||
|
_networkKinds.value = caps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } to
|
||||||
|
caps.any { it.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) }
|
||||||
|
val wifi = caps.firstOrNull { it.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) }
|
||||||
|
_ssid.value = wifi?.let { org.strongswan.android.logic.autoconnect.SsidReader.from(getApplication(), it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* true while «Fastest» pings the servers */
|
||||||
|
private val _findingFastest = MutableStateFlow(false)
|
||||||
|
val findingFastest: StateFlow<Boolean> = _findingFastest
|
||||||
|
|
||||||
private var service: VpnStateService? = null
|
private var service: VpnStateService? = null
|
||||||
|
|
||||||
/* written from the main thread (pullState) and the poll coroutine (Default) */
|
/* written from the main thread (pullState) and the poll coroutine (Default) */
|
||||||
@@ -210,6 +285,18 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
|||||||
)
|
)
|
||||||
reloadProfiles()
|
reloadProfiles()
|
||||||
WhiteListManager.getInstance(ctx).refreshIfStale()
|
WhiteListManager.getInstance(ctx).refreshIfStale()
|
||||||
|
/* the default request excludes VPN networks: this tracks the real uplinks */
|
||||||
|
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
try
|
||||||
|
{
|
||||||
|
cm?.registerNetworkCallback(
|
||||||
|
NetworkRequest.Builder().addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET).build(),
|
||||||
|
networkCallback,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
catch (_: RuntimeException)
|
||||||
|
{ /* too many callbacks registered app-wide; keep assuming a network */
|
||||||
|
}
|
||||||
/* poll traffic only while the Home screen is shown (not on the other
|
/* poll traffic only while the Home screen is shown (not on the other
|
||||||
* tabs or behind another activity), the VPN is up, and off the main thread */
|
* tabs or behind another activity), the VPN is up, and off the main thread */
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -334,6 +421,13 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
LocalBroadcastManager.getInstance(ctx).unregisterReceiver(profilesChanged)
|
LocalBroadcastManager.getInstance(ctx).unregisterReceiver(profilesChanged)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
(ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)?.unregisterNetworkCallback(networkCallback)
|
||||||
|
}
|
||||||
|
catch (_: IllegalArgumentException)
|
||||||
|
{
|
||||||
|
}
|
||||||
super.onCleared()
|
super.onCleared()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +453,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
|||||||
|
|
||||||
fun connect(context: Context, profile: VpnProfile)
|
fun connect(context: Context, profile: VpnProfile)
|
||||||
{
|
{
|
||||||
|
org.strongswan.android.ui.compose.home.ScenarioHints.manualOn(context)
|
||||||
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
||||||
intent.action = VpnProfileControlActivity.START_PROFILE
|
intent.action = VpnProfileControlActivity.START_PROFILE
|
||||||
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profile.getUUID().toString())
|
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profile.getUUID().toString())
|
||||||
@@ -367,6 +462,9 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
|||||||
|
|
||||||
fun disconnect()
|
fun disconnect()
|
||||||
{
|
{
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
|
||||||
|
/* counted per Wi-Fi for the «turn it off here automatically?» suggestion */
|
||||||
|
org.strongswan.android.ui.compose.home.ScenarioHints.manualOff(getApplication(), if (_networkKinds.value.first) _ssid.value else null)
|
||||||
service?.disconnect()
|
service?.disconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,6 +473,91 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
|||||||
service?.reconnect()
|
service?.reconnect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Disconnects for [minutes]; the tunnel comes back by itself when the pause ends. */
|
||||||
|
fun pause(minutes: Int)
|
||||||
|
{
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
|
||||||
|
val svc = service ?: return
|
||||||
|
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
|
||||||
|
PauseManager.pause(getApplication(), svc, profile.getUUID().toString(), minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun extendPause(minutes: Int)
|
||||||
|
{
|
||||||
|
PauseManager.extend(getApplication(), minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pause until 07:00 (today or tomorrow). */
|
||||||
|
fun pauseUntilMorning()
|
||||||
|
{
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
|
||||||
|
val svc = service ?: return
|
||||||
|
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
|
||||||
|
PauseManager.pauseUntil(getApplication(), svc, profile.getUUID().toString(), PauseManager.nextMorning(System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pause until the device moves to another network. */
|
||||||
|
fun pauseUntilNetworkChange()
|
||||||
|
{
|
||||||
|
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(getApplication())
|
||||||
|
val svc = service ?: return
|
||||||
|
val profile = _ui.value.activeProfile ?: _ui.value.selected ?: return
|
||||||
|
PauseManager.pauseUntilNetworkChange(getApplication(), svc, profile.getUUID().toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Fastest»: pings every profile's server directly (outside the tunnel)
|
||||||
|
* and connects the one that answers first; a message if none answers.
|
||||||
|
*/
|
||||||
|
fun connectFastest(context: Context)
|
||||||
|
{
|
||||||
|
if (_findingFastest.value)
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val profiles = _ui.value.profiles
|
||||||
|
if (profiles.isEmpty())
|
||||||
|
{
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_findingFastest.value = true
|
||||||
|
viewModelScope.launch {
|
||||||
|
try
|
||||||
|
{
|
||||||
|
val rtts = coroutineScope {
|
||||||
|
profiles.map { profile ->
|
||||||
|
async {
|
||||||
|
profile to ConnectivityProbe.pingServerDirect(getApplication(), profile.gateway, FASTEST_TIMEOUT_MS)?.first
|
||||||
|
}
|
||||||
|
}.map { it.await() }
|
||||||
|
}
|
||||||
|
val best = rtts.filter { it.second != null }.minByOrNull { it.second!! }?.first
|
||||||
|
if (best == null)
|
||||||
|
{
|
||||||
|
_messages.tryEmit(R.string.home_fastest_none)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val current = _ui.value.activeProfile
|
||||||
|
if (_ui.value.vpnState == State.CONNECTED && current?.getUUID() == best.getUUID())
|
||||||
|
{
|
||||||
|
_messages.tryEmit(R.string.home_fastest_already)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
select(best)
|
||||||
|
connect(context, best)
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_findingFastest.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resumeNow()
|
||||||
|
{
|
||||||
|
PauseManager.resumeNow(getApplication())
|
||||||
|
}
|
||||||
|
|
||||||
fun delete(profile: VpnProfile)
|
fun delete(profile: VpnProfile)
|
||||||
{
|
{
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
@@ -493,6 +676,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
|||||||
vpnState = state,
|
vpnState = state,
|
||||||
error = svc.errorState,
|
error = svc.errorState,
|
||||||
errorText = svc.errorText,
|
errorText = svc.errorText,
|
||||||
|
retryIn = if (svc.errorState != ErrorState.NO_ERROR) svc.retryIn else 0,
|
||||||
activeProfile = svc.profile,
|
activeProfile = svc.profile,
|
||||||
selected = svc.profile ?: it.selected,
|
selected = svc.profile ?: it.selected,
|
||||||
)
|
)
|
||||||
@@ -673,9 +857,11 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
|||||||
companion object
|
companion object
|
||||||
{
|
{
|
||||||
private const val TRAFFIC_POLL_MS = 1000L
|
private const val TRAFFIC_POLL_MS = 1000L
|
||||||
private const val PROBE_INTERVAL_MS = 10_000L
|
/* 10 s kept the cellular radio out of its idle state for as long as Home was open */
|
||||||
|
private const val PROBE_INTERVAL_MS = 30_000L
|
||||||
private const val EXIT_REFRESH_MS = 60_000L
|
private const val EXIT_REFRESH_MS = 60_000L
|
||||||
private const val EXIT_TIMEOUT_MS = 5000
|
private const val EXIT_TIMEOUT_MS = 5000
|
||||||
|
private const val FASTEST_TIMEOUT_MS = 1500
|
||||||
|
|
||||||
/** Parses "ip:port|fails|window_ms|left_ms|via_tunnel" entries of the native snapshot. */
|
/** Parses "ip:port|fails|window_ms|left_ms|via_tunnel" entries of the native snapshot. */
|
||||||
fun parseDeadTargets(raw: Array<String>?): List<DeadTargetUi> =
|
fun parseDeadTargets(raw: Array<String>?): List<DeadTargetUi> =
|
||||||
|
|||||||
+255
-222
@@ -11,53 +11,59 @@
|
|||||||
|
|
||||||
package org.strongswan.android.ui.compose
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.rounded.Close
|
|
||||||
import androidx.compose.material.icons.rounded.Dns
|
|
||||||
import androidx.compose.material.icons.rounded.FileDownload
|
|
||||||
import androidx.compose.material.icons.rounded.Language
|
|
||||||
import androidx.compose.material.icons.rounded.VpnKey
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.LoadingIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
import org.strongswan.android.ui.compose.components.ShxCard
|
import org.strongswan.android.ui.LogActivity
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
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.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSwitch
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Observable UI state of the profile import screen. Owned by the (Java)
|
* Observable UI state of the profile import screen. Owned by the (Java)
|
||||||
@@ -89,21 +95,28 @@ class ImportUiModel
|
|||||||
var remoteCertPrimary by mutableStateOf<String?>(null)
|
var remoteCertPrimary by mutableStateOf<String?>(null)
|
||||||
var remoteCertSecondary by mutableStateOf<String?>(null)
|
var remoteCertSecondary by mutableStateOf<String?>(null)
|
||||||
var hideImport by mutableStateOf(false)
|
var hideImport by mutableStateOf(false)
|
||||||
|
/** uri scheme of the profile source (https, content, file) */
|
||||||
|
var source by mutableStateOf<String?>(null)
|
||||||
|
var sourceUri by mutableStateOf<String?>(null)
|
||||||
|
/** set when the profile could not be read; the sheet shows it with a retry */
|
||||||
|
var loadError by mutableStateOf<String?>(null)
|
||||||
|
/** «Connect right away» after adding */
|
||||||
|
var connectNow by mutableStateOf(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders the import UI as a bottom sheet in the transparent import activity. */
|
/** Renders the import UI as the mockup sheet (F_ImportPreview / F_ErrImport) in the transparent import activity. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
fun ComponentActivity.installImportScreen(
|
||||||
fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnable, onClose: Runnable,
|
model: ImportUiModel,
|
||||||
onImportPkcs12: Runnable, onSelectUserCert: Runnable)
|
onImport: Runnable,
|
||||||
|
onClose: Runnable,
|
||||||
|
onImportPkcs12: Runnable,
|
||||||
|
onSelectUserCert: Runnable,
|
||||||
|
onRetry: Runnable,
|
||||||
|
)
|
||||||
{
|
{
|
||||||
setContent {
|
setContent {
|
||||||
ShxTheme {
|
ShxTheme {
|
||||||
/* the activity is a transparent host: the import UI is an Expressive bottom sheet */
|
ShxSheet(onDismiss = onClose::run)
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onClose::run,
|
|
||||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
ImportScreen(
|
ImportScreen(
|
||||||
model = model,
|
model = model,
|
||||||
@@ -111,13 +124,13 @@ fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnab
|
|||||||
onClose = onClose::run,
|
onClose = onClose::run,
|
||||||
onImportPkcs12 = onImportPkcs12::run,
|
onImportPkcs12 = onImportPkcs12::run,
|
||||||
onSelectUserCert = onSelectUserCert::run,
|
onSelectUserCert = onSelectUserCert::run,
|
||||||
|
onRetry = onRetry::run,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImportScreen(
|
fun ImportScreen(
|
||||||
model: ImportUiModel,
|
model: ImportUiModel,
|
||||||
@@ -125,221 +138,241 @@ fun ImportScreen(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
onImportPkcs12: () -> Unit,
|
onImportPkcs12: () -> Unit,
|
||||||
onSelectUserCert: () -> Unit,
|
onSelectUserCert: () -> Unit,
|
||||||
|
onRetry: () -> Unit,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Column(Modifier.fillMaxSize())
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
when
|
||||||
{
|
{
|
||||||
TopAppBar(
|
model.loadError != null || (model.hideImport && model.existsWarning != null) ->
|
||||||
title = { Text(stringResource(R.string.profile_import), fontWeight = FontWeight.SemiBold) },
|
{
|
||||||
navigationIcon = {
|
/* F_ErrImport */
|
||||||
IconButton(onClick = onClose) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
|
||||||
Icon(Icons.Rounded.Close, contentDescription = stringResource(android.R.string.cancel))
|
{
|
||||||
}
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(CircleShape).background(c.errorContainer))
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
if (model.showForm && !model.hideImport)
|
|
||||||
{
|
{
|
||||||
TextButton(onClick = onImport) { Text(stringResource(R.string.profile_edit_import)) }
|
Icon(ShxIcons.Alert, contentDescription = null, tint = c.error, modifier = Modifier.size(26.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(if (model.source?.startsWith("http") == true) R.string.import_failed_download else R.string.import_failed_read),
|
||||||
|
style = ShxType.pageTitle.copy(letterSpacing = (-0.02).sp), color = c.text,
|
||||||
|
)
|
||||||
|
model.sourceUri?.let {
|
||||||
|
Text(it.substringAfter("://"), style = ShxType.sub.copy(fontFamily = FontFamily.Monospace), color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if (model.loading)
|
|
||||||
{
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
LoadingIndicator()
|
|
||||||
}
|
}
|
||||||
return
|
val shape = RoundedCornerShape(22.dp)
|
||||||
}
|
Column(
|
||||||
Column(
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
Modifier
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
.fillMaxSize()
|
)
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = ShxSpacing.lg)
|
|
||||||
.padding(bottom = ShxSpacing.xl),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (model.existsWarning != null)
|
|
||||||
{
|
{
|
||||||
ImportBanner(
|
Text(stringResource(R.string.import_reasons).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
text = model.existsWarning ?: "",
|
val reasons = if (model.source?.startsWith("http") == true) listOf(
|
||||||
container = MaterialTheme.colorScheme.errorContainer,
|
R.string.import_reason_blocked to R.string.import_reason_blocked_d,
|
||||||
content = MaterialTheme.colorScheme.onErrorContainer,
|
R.string.import_reason_offline to R.string.import_reason_offline_d,
|
||||||
)
|
R.string.import_reason_tls to R.string.import_reason_tls_d,
|
||||||
}
|
) else listOf(R.string.import_reason_format to R.string.import_reason_format_d)
|
||||||
ShxCard(modifier = Modifier.padding(top = ShxSpacing.md))
|
reasons.forEach { (t, d) ->
|
||||||
{
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
ImportInfoRow(Icons.Rounded.Language, stringResource(R.string.profile_name_label_simple), model.name)
|
{
|
||||||
ImportInfoRow(Icons.Rounded.Dns, stringResource(R.string.profile_gateway_label), model.gateway)
|
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.error))
|
||||||
ImportInfoRow(Icons.Rounded.VpnKey, stringResource(R.string.profile_vpn_type_label), model.vpnType, divider = false)
|
Text(
|
||||||
}
|
buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(t)) }
|
||||||
if (model.requireUserPass)
|
append(" — ")
|
||||||
{
|
withStyle(SpanStyle(color = c.textSub)) { append(stringResource(d)) }
|
||||||
Text(
|
},
|
||||||
stringResource(R.string.profile_username_label).uppercase().replace(":", ""),
|
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
|
|
||||||
)
|
|
||||||
ShxCard {
|
|
||||||
Column(Modifier.padding(ShxSpacing.lg)) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = model.username,
|
|
||||||
onValueChange = { model.username = it },
|
|
||||||
label = { Text(stringResource(R.string.profile_username_label)) },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = model.usernameEditable,
|
|
||||||
isError = model.usernameError,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(ShxSpacing.md))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = model.password,
|
|
||||||
onValueChange = { model.password = it },
|
|
||||||
label = { Text(stringResource(R.string.profile_password_label)) },
|
|
||||||
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
|
|
||||||
singleLine = true,
|
|
||||||
enabled = model.passwordEditable,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (model.sharedSecretWarning)
|
(model.loadError ?: model.existsWarning)?.let {
|
||||||
|
Text(it, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.import_ask_admin))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
if (model.source?.startsWith("http") == true)
|
||||||
{
|
{
|
||||||
ImportBanner(
|
ShxButton(stringResource(R.string.retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
text = stringResource(R.string.profile_import_shared_secret),
|
}
|
||||||
container = MaterialTheme.colorScheme.surfaceContainerHigh,
|
ShxButton(stringResource(R.string.add_close), onClose, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
content = MaterialTheme.colorScheme.onSurfaceVariant,
|
}
|
||||||
modifier = Modifier.padding(top = ShxSpacing.sm),
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.import_support_details),
|
||||||
|
style = ShxType.chip, color = c.accentText,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(12.dp))
|
||||||
|
.clickable(role = Role.Button) { context.startActivity(Intent(context, LogActivity::class.java)) }.padding(8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
model.loading ->
|
||||||
|
{
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth().padding(vertical = 40.dp))
|
||||||
|
{
|
||||||
|
LoadingIndicator(Modifier.size(48.dp))
|
||||||
|
Text(stringResource(R.string.import_reading), style = ShxType.body, color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else ->
|
||||||
|
{
|
||||||
|
/* F_ImportPreview */
|
||||||
|
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(56.dp).clip(CircleShape).background(c.accentContainer))
|
||||||
|
{
|
||||||
|
Text(model.name.filter { it.isLetterOrDigit() }.take(2).uppercase().ifEmpty { "VPN" }, style = ShxType.cardHeading.copy(fontSize = 18.sp), color = c.onAccentContainer)
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.import_add_title), style = ShxType.sheetTitle, color = c.text)
|
||||||
|
Text(stringResource(R.string.import_read_ok), style = ShxType.sub, color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
Column(Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape))
|
||||||
|
{
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.import_name), style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.textSub)
|
||||||
|
BasicTextField(
|
||||||
|
value = model.name,
|
||||||
|
onValueChange = { model.name = it },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = ShxType.rowTitle.copy(color = c.text),
|
||||||
|
cursorBrush = SolidColor(c.accent),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
val rows = buildList {
|
||||||
|
add(stringResource(R.string.import_server) to (model.gateway to c.text))
|
||||||
if (model.requireUserCert)
|
add(stringResource(R.string.import_type) to (model.vpnType to c.text))
|
||||||
{
|
add(stringResource(R.string.import_login) to ((when
|
||||||
Text(
|
{
|
||||||
stringResource(R.string.profile_user_certificate_label).uppercase().replace(":", ""),
|
model.requireUserCert && model.requireUserPass -> stringResource(R.string.import_login_both)
|
||||||
style = MaterialTheme.typography.labelMedium,
|
model.requireUserCert -> stringResource(R.string.import_login_cert)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
else -> stringResource(R.string.import_login_password)
|
||||||
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
|
}) to c.text))
|
||||||
)
|
add(stringResource(R.string.import_source) to (stringResource(if (model.source?.startsWith("http") == true) R.string.import_source_link else R.string.import_source_file) to c.text))
|
||||||
ShxCard {
|
if (model.showRemoteCert)
|
||||||
Column {
|
{
|
||||||
Row(
|
add(stringResource(R.string.import_ca) to (stringResource(R.string.import_ca_included) to c.onSuccess))
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
}
|
||||||
modifier = Modifier
|
}
|
||||||
.fillMaxWidth()
|
rows.forEach { (k, v) ->
|
||||||
.clickable(onClick = onSelectUserCert)
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 16.dp))
|
||||||
) {
|
{
|
||||||
Column(Modifier.weight(1f)) {
|
Text(k, style = ShxType.body, color = c.textSoft, maxLines = 1, softWrap = false)
|
||||||
Text(
|
Text(
|
||||||
model.userCertAlias ?: stringResource(R.string.profile_user_select_certificate_label),
|
v.first, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = v.second, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
|
||||||
color = if (model.userCertError) MaterialTheme.colorScheme.error
|
)
|
||||||
else MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
model.userCertSubject
|
|
||||||
?: stringResource(R.string.profile_user_select_certificate),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (model.userCertLoading)
|
|
||||||
{
|
|
||||||
LoadingIndicator(Modifier.size(18.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (model.showImportUserCert)
|
|
||||||
{
|
|
||||||
TextButton(onClick = onImportPkcs12, modifier = Modifier.padding(start = ShxSpacing.sm, bottom = ShxSpacing.sm)) {
|
|
||||||
Icon(Icons.Rounded.FileDownload, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
||||||
Spacer(Modifier.size(ShxSpacing.sm))
|
|
||||||
Text(stringResource(R.string.profile_cert_import))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (model.requireUserPass)
|
||||||
if (model.showRemoteCert)
|
{
|
||||||
|
ImportField(stringResource(R.string.profile_username_label).trimEnd(':'), model.username, model.usernameEditable, model.usernameError) { model.username = it }
|
||||||
|
ImportField(stringResource(R.string.profile_password_label).trimEnd(':'), model.password, model.passwordEditable, false, secret = true) { model.password = it }
|
||||||
|
if (model.sharedSecretWarning)
|
||||||
|
{
|
||||||
|
ShxNote(stringResource(R.string.profile_import_shared_secret), tone = ShxTone.NEUTRAL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (model.requireUserCert)
|
||||||
|
{
|
||||||
|
val certShape = RoundedCornerShape(24.dp)
|
||||||
|
Column(Modifier.fillMaxWidth().clip(certShape).background(c.card).border(1.dp, if (model.userCertError) c.error else c.cardBorder, certShape))
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onSelectUserCert).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(model.userCertAlias ?: stringResource(R.string.profile_user_select_certificate_label), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = if (model.userCertError) c.error else c.text)
|
||||||
|
Text(model.userCertSubject ?: stringResource(R.string.profile_user_select_certificate), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
if (model.userCertLoading) LoadingIndicator(Modifier.size(20.dp))
|
||||||
|
else Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
if (model.showImportUserCert)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.profile_cert_import), style = ShxType.chip, color = c.accentText,
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onImportPkcs12).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val toggleShape = RoundedCornerShape(24.dp)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(toggleShape).background(c.card).border(1.dp, c.cardBorder, toggleShape).padding(start = 16.dp, end = 14.dp, top = 12.dp, bottom = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.import_connect_now), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
ShxSwitch(checked = model.connectNow, onCheckedChange = { model.connectNow = it })
|
||||||
|
}
|
||||||
|
if (model.existsWarning != null)
|
||||||
{
|
{
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.profile_ca_label).uppercase().replace(":", ""),
|
buildAnnotatedString {
|
||||||
style = MaterialTheme.typography.labelMedium,
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.import_exists_title)) }
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
append(" ")
|
||||||
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
|
append(stringResource(R.string.import_exists_text))
|
||||||
|
},
|
||||||
|
style = ShxType.note, color = c.textTonal,
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.accentSoft).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(android.R.string.cancel), onClose, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(
|
||||||
|
stringResource(if (model.existsWarning != null) R.string.import_update_server else R.string.import_add_server),
|
||||||
|
onImport, modifier = Modifier.weight(2f), enabled = !model.hideImport,
|
||||||
)
|
)
|
||||||
ShxCard {
|
|
||||||
ImportInfoRow(Icons.Rounded.Dns, model.remoteCertPrimary ?: "", model.remoteCertSecondary ?: "", divider = false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Field of the manual/import forms: white box, radius 16, label 12/700, value 16/600. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun ImportBanner(
|
private fun ImportField(label: String, value: String, editable: Boolean, error: Boolean, secret: Boolean = false, onChange: (String) -> Unit)
|
||||||
text: String,
|
|
||||||
container: androidx.compose.ui.graphics.Color,
|
|
||||||
content: androidx.compose.ui.graphics.Color,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Surface(
|
val c = LocalShxColors.current
|
||||||
shape = MaterialTheme.shapes.medium,
|
val shape = RoundedCornerShape(16.dp)
|
||||||
color = container,
|
Column(
|
||||||
contentColor = content,
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
modifier = modifier
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(if (error) 2.dp else 1.dp, if (error) c.error else c.cardBorder, shape).padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
.fillMaxWidth()
|
)
|
||||||
.padding(top = ShxSpacing.sm),
|
{
|
||||||
) {
|
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = if (error) c.error else c.textSub)
|
||||||
Text(
|
BasicTextField(
|
||||||
text,
|
value = value,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
onValueChange = onChange,
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
enabled = editable,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = ShxType.body.copy(fontSize = 16.sp, color = if (editable) c.text else c.textSub),
|
||||||
|
cursorBrush = SolidColor(c.accent),
|
||||||
|
visualTransformation = if (secret) androidx.compose.ui.text.input.PasswordVisualTransformation() else androidx.compose.ui.text.input.VisualTransformation.None,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ImportInfoRow(icon: ImageVector, label: String, value: String, divider: Boolean = true)
|
|
||||||
{
|
|
||||||
Column {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
Column(Modifier.padding(start = ShxSpacing.lg)) {
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(value, style = MaterialTheme.typography.bodyLarge)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (divider)
|
|
||||||
{
|
|
||||||
androidx.compose.material3.HorizontalDivider(
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
|
||||||
modifier = Modifier.padding(start = 52.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+315
@@ -0,0 +1,315 @@
|
|||||||
|
/*
|
||||||
|
* 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.compose
|
||||||
|
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.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.alpha
|
||||||
|
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.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.strongswan.android.R
|
||||||
|
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.logic.events.ShxEvent
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxChip
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxChipTone
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxHeaderAction
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.home.formatClock
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import java.util.Calendar
|
||||||
|
|
||||||
|
/** Inbox filters, in chip order. */
|
||||||
|
internal enum class InboxFilter
|
||||||
|
{
|
||||||
|
ALL,
|
||||||
|
WAITING,
|
||||||
|
CONNECTION,
|
||||||
|
ROUTING,
|
||||||
|
APP;
|
||||||
|
|
||||||
|
fun matches(event: ShxEvent): Boolean = when (this)
|
||||||
|
{
|
||||||
|
ALL -> true
|
||||||
|
WAITING -> event.waiting
|
||||||
|
CONNECTION -> event.category == EventCategory.CONNECTION
|
||||||
|
ROUTING -> event.category == EventCategory.ROUTING
|
||||||
|
APP -> event.category == EventCategory.APP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Groups events by calendar day, newest first: (day start, events). */
|
||||||
|
internal fun groupByDay(events: List<ShxEvent>): List<Pair<Long, List<ShxEvent>>> =
|
||||||
|
events.sortedByDescending { it.at }.groupBy { dayStart(it.at) }.toList()
|
||||||
|
|
||||||
|
internal fun dayStart(at: Long): Long
|
||||||
|
{
|
||||||
|
val c = Calendar.getInstance()
|
||||||
|
c.timeInMillis = at
|
||||||
|
c.set(Calendar.HOUR_OF_DAY, 0)
|
||||||
|
c.set(Calendar.MINUTE, 0)
|
||||||
|
c.set(Calendar.SECOND, 0)
|
||||||
|
c.set(Calendar.MILLISECOND, 0)
|
||||||
|
return c.timeInMillis
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Events» (F_Inbox). */
|
||||||
|
@Composable
|
||||||
|
fun InboxScreen(onBack: () -> Unit, onAction: (ShxEvent) -> Unit, onNotificationSettings: () -> Unit)
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val log = remember { EventLog.getInstance(context) }
|
||||||
|
val events by log.events.collectAsStateWithLifecycle()
|
||||||
|
var filter by rememberSaveable { mutableStateOf(InboxFilter.ALL) }
|
||||||
|
val waiting = events.count { it.waiting }
|
||||||
|
val shown = remember(events, filter) { groupByDay(events.filter(filter::matches)) }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize().background(c.background).statusBarsPadding(),
|
||||||
|
contentPadding = PaddingValues(bottom = 28.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
item(key = "header") {
|
||||||
|
ShxPageHeader(stringResource(R.string.events_title), onBack)
|
||||||
|
{
|
||||||
|
if (events.any { !it.read })
|
||||||
|
{
|
||||||
|
ShxHeaderAction(stringResource(R.string.events_read_all)) { log.markAllRead() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "filters") {
|
||||||
|
FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 4.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ShxChip(stringResource(R.string.events_filter_all), filter == InboxFilter.ALL, { filter = InboxFilter.ALL })
|
||||||
|
ShxChip(
|
||||||
|
if (waiting > 0) stringResource(R.string.events_filter_waiting_n, waiting) else stringResource(R.string.events_filter_waiting),
|
||||||
|
filter == InboxFilter.WAITING, { filter = InboxFilter.WAITING },
|
||||||
|
tone = if (waiting > 0) ShxChipTone.WARNING else ShxChipTone.NORMAL,
|
||||||
|
)
|
||||||
|
ShxChip(stringResource(R.string.events_filter_connection), filter == InboxFilter.CONNECTION, { filter = InboxFilter.CONNECTION })
|
||||||
|
ShxChip(stringResource(R.string.events_filter_routing), filter == InboxFilter.ROUTING, { filter = InboxFilter.ROUTING })
|
||||||
|
ShxChip(stringResource(R.string.events_filter_app), filter == InboxFilter.APP, { filter = InboxFilter.APP })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (shown.isEmpty())
|
||||||
|
{
|
||||||
|
item(key = "empty") {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp, vertical = 48.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(56.dp).clip(RoundedCornerShape(18.dp)).background(c.accentContainer))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Bell, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(26.dp))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(if (events.isEmpty()) R.string.events_empty else R.string.events_empty_filter),
|
||||||
|
style = ShxType.note.copy(fontSize = 14.sp),
|
||||||
|
color = c.textSoft,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shown.forEach { (day, dayEvents) ->
|
||||||
|
item(key = "day-$day") {
|
||||||
|
ShxSectionHeader(dayLabel(day), Modifier.padding(horizontal = 16.dp))
|
||||||
|
}
|
||||||
|
items(dayEvents, key = { it.id }) { event ->
|
||||||
|
EventCard(event = event, onAction = { onAction(event) }, onDecline = { log.answer(event.id) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item(key = "footer") {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 22.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.events_footer), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, textAlign = TextAlign.Center)
|
||||||
|
ShxHeaderAction(stringResource(R.string.events_notification_settings), onNotificationSettings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun dayLabel(day: Long): String
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val today = dayStart(System.currentTimeMillis())
|
||||||
|
return when (day)
|
||||||
|
{
|
||||||
|
today -> stringResource(R.string.events_today)
|
||||||
|
dayStart(today - 12 * 60 * 60 * 1000L) -> stringResource(R.string.events_yesterday)
|
||||||
|
else -> DateUtils.formatDateTime(context, day, DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_NO_YEAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Event card of the mockup: radius 22, 36dp icon well, title 15/800 + time, text 13, small buttons. */
|
||||||
|
@Composable
|
||||||
|
private fun EventCard(event: ShxEvent, onAction: () -> Unit, onDecline: () -> Unit)
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val warn = event.kind == EventKind.WARN && !event.resolved
|
||||||
|
val ask = event.kind == EventKind.ASK && !event.resolved
|
||||||
|
val (bg, border) = when
|
||||||
|
{
|
||||||
|
warn -> c.warningContainer to c.warningBorder
|
||||||
|
ask -> c.card to c.accentBorder
|
||||||
|
else -> c.card to c.cardBorder
|
||||||
|
}
|
||||||
|
val (icon: ImageVector, iconBg: Color, iconFg: Color) = when (event.kind)
|
||||||
|
{
|
||||||
|
EventKind.WARN -> Triple(ShxIcons.Alert, c.warning, Color.White)
|
||||||
|
EventKind.ASK -> Triple(if (event.category == EventCategory.ROUTING) ShxIcons.Apps else ShxIcons.Help, c.accent, Color.White)
|
||||||
|
EventKind.INFO -> Triple(
|
||||||
|
when (event.category)
|
||||||
|
{
|
||||||
|
EventCategory.APP -> ShxIcons.Download
|
||||||
|
EventCategory.ROUTING -> ShxIcons.Route
|
||||||
|
EventCategory.CONNECTION -> ShxIcons.Swap
|
||||||
|
},
|
||||||
|
c.tile, c.onAccentContainer,
|
||||||
|
)
|
||||||
|
EventKind.OK -> Triple(ShxIcons.Shield, c.successContainer, c.onSuccess)
|
||||||
|
}
|
||||||
|
val actionBg = if (warn) c.warning else c.accent
|
||||||
|
val shape = RoundedCornerShape(22.dp)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.alpha(if (event.resolved) 0.7f else 1f)
|
||||||
|
.clip(shape)
|
||||||
|
.background(bg)
|
||||||
|
.border(1.dp, border, shape)
|
||||||
|
.padding(14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(iconBg))
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = iconFg, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
if (event.count > 1) stringResource(R.string.events_title_count, event.title, event.count) else event.title,
|
||||||
|
style = ShxType.cardTitle.copy(fontWeight = if (event.read) FontWeight.Bold else FontWeight.ExtraBold),
|
||||||
|
color = c.text,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(formatClock(context, event.at), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
if (event.text.isNotEmpty())
|
||||||
|
{
|
||||||
|
Text(event.text, style = ShxType.note, color = c.textSoft)
|
||||||
|
}
|
||||||
|
if (event.resolved)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.events_resolved), style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.success)
|
||||||
|
}
|
||||||
|
else if (event.action != EventAction.NONE)
|
||||||
|
{
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 6.dp))
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(actionBg)
|
||||||
|
.clickable(role = Role.Button, onClick = onAction).padding(horizontal = 14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(event.action.labelRes()), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
|
||||||
|
}
|
||||||
|
if (event.kind == EventKind.ASK)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).border(1.dp, c.outline, RoundedCornerShape(12.dp))
|
||||||
|
.clickable(role = Role.Button, onClick = onDecline).padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.events_action_no), style = ShxType.chip, color = c.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun EventAction.labelRes(): Int = when (this)
|
||||||
|
{
|
||||||
|
EventAction.OPEN_HOME -> R.string.events_action_open
|
||||||
|
EventAction.OPEN_ROUTING -> R.string.events_action_routing
|
||||||
|
EventAction.OPEN_ROUTE_CHECK -> R.string.events_action_check
|
||||||
|
EventAction.OPEN_LOG -> R.string.show_log
|
||||||
|
EventAction.INSTALL_UPDATE -> R.string.events_action_update
|
||||||
|
EventAction.SET_APP_DIRECT -> R.string.events_action_direct
|
||||||
|
EventAction.PROFILE_UPDATE -> R.string.events_action_profile_update
|
||||||
|
EventAction.OPEN_SERVER -> R.string.events_action_open_server
|
||||||
|
EventAction.SEND_REPORT -> R.string.diag_send_report
|
||||||
|
EventAction.OPEN_VPN_SETTINGS -> R.string.home_lockdown_enable
|
||||||
|
EventAction.NONE -> R.string.events_action_open
|
||||||
|
}
|
||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
/*
|
||||||
|
* 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.compose
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.Uri
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
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.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
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.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.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.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.routing.AppTraffic
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
|
||||||
|
/** How much a permission matters, as the tag on its row. */
|
||||||
|
enum class PermissionLevel { REQUIRED, IMPORTANT, OPTIONAL }
|
||||||
|
|
||||||
|
/** Current state of the five permissions the VPN works best with. */
|
||||||
|
data class PermissionStatus(
|
||||||
|
val vpn: Boolean,
|
||||||
|
val notifications: Boolean,
|
||||||
|
val battery: Boolean,
|
||||||
|
val usage: Boolean,
|
||||||
|
val location: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
|
fun permissionStatus(context: Context): PermissionStatus
|
||||||
|
{
|
||||||
|
val pm = context.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||||
|
return PermissionStatus(
|
||||||
|
vpn = runCatching { VpnService.prepare(context) == null }.getOrDefault(false),
|
||||||
|
notifications = NotificationManagerCompat.from(context).areNotificationsEnabled(),
|
||||||
|
battery = pm?.isIgnoringBatteryOptimizations(context.packageName) ?: true,
|
||||||
|
usage = AppTraffic.hasAccess(context),
|
||||||
|
location = ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «For the VPN to work reliably» (F_Permissions): one card per permission
|
||||||
|
* with its tag (required / important / optional), why it is needed, and
|
||||||
|
* a green check or an «Allow» button. Re-checked whenever the screen resumes.
|
||||||
|
*/
|
||||||
|
@SuppressLint("BatteryLife")
|
||||||
|
@Composable
|
||||||
|
fun PermissionsList(modifier: Modifier = Modifier)
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
var resumes by remember { mutableIntStateOf(0) }
|
||||||
|
val owner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(owner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
|
||||||
|
owner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
val status = remember(resumes) { permissionStatus(context) }
|
||||||
|
val vpnLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { resumes++ }
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { resumes++ }
|
||||||
|
fun open(intent: Intent) = runCatching { context.startActivity(intent) }
|
||||||
|
|
||||||
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
PermissionCard(
|
||||||
|
ShxIcons.Shield, stringResource(R.string.perm_vpn), PermissionLevel.REQUIRED, stringResource(R.string.perm_vpn_why), status.vpn,
|
||||||
|
) { runCatching { VpnService.prepare(context) }.getOrNull()?.let { vpnLauncher.launch(it) } }
|
||||||
|
PermissionCard(
|
||||||
|
ShxIcons.Bell, stringResource(R.string.perm_notifications), PermissionLevel.REQUIRED, stringResource(R.string.perm_notifications_why), status.notifications,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED)
|
||||||
|
{
|
||||||
|
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
{
|
||||||
|
open(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PermissionCard(
|
||||||
|
ShxIcons.Phone, stringResource(R.string.perm_battery), PermissionLevel.IMPORTANT, stringResource(R.string.perm_battery_why), status.battery,
|
||||||
|
/* the vendor switches (step 2 of F_Battery) matter even when Android allows it */
|
||||||
|
openWhenDone = true,
|
||||||
|
) { open(Intent(context, org.strongswan.android.ui.BatteryActivity::class.java)) }
|
||||||
|
PermissionCard(
|
||||||
|
ShxIcons.Stats, stringResource(R.string.perm_usage), PermissionLevel.OPTIONAL, stringResource(R.string.perm_usage_why), status.usage,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (open(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS, Uri.fromParts("package", context.packageName, null))).isFailure)
|
||||||
|
{
|
||||||
|
open(Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PermissionCard(
|
||||||
|
ShxIcons.Pin, stringResource(R.string.perm_location), PermissionLevel.OPTIONAL, stringResource(R.string.perm_location_why), status.location,
|
||||||
|
) { permissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionCard(icon: ImageVector, title: String, level: PermissionLevel, why: String, done: Boolean, openWhenDone: Boolean = false, onAllow: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val (tagBg, tagFg, tag) = when (level)
|
||||||
|
{
|
||||||
|
PermissionLevel.REQUIRED -> Triple(c.accent, Color.White, stringResource(R.string.perm_tag_required))
|
||||||
|
PermissionLevel.IMPORTANT -> Triple(c.warningStrong, c.onWarning, stringResource(R.string.perm_tag_important))
|
||||||
|
PermissionLevel.OPTIONAL -> Triple(c.tile, c.textSoft, stringResource(R.string.perm_tag_optional))
|
||||||
|
}
|
||||||
|
val border = if (done || level == PermissionLevel.OPTIONAL) c.cardBorder else c.accentBorder
|
||||||
|
val shape = RoundedCornerShape(22.dp)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, border, shape)
|
||||||
|
.then(if (done && openWhenDone) Modifier.clickable(role = Role.Button, onClick = onAllow) else Modifier).padding(14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(if (done) c.successContainer else c.tile))
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = if (done) c.onSuccess else c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(2.dp), itemVerticalAlignment = Alignment.CenterVertically)
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.cardTitle, color = c.text)
|
||||||
|
Text(
|
||||||
|
tag.uppercase(), style = ShxType.tag.copy(fontSize = 10.sp), color = tagFg, maxLines = 1, softWrap = false,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(tagBg).padding(horizontal = 6.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(why, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub)
|
||||||
|
}
|
||||||
|
if (done)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(32.dp).clip(CircleShape).background(c.success))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Check, contentDescription = stringResource(R.string.perm_done), tint = Color.White, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
val optional = level == PermissionLevel.OPTIONAL
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(12.dp)).background(if (optional) c.tile else c.accent)
|
||||||
|
.clickable(role = Role.Button, onClick = onAllow).padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.perm_allow), style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = if (optional) c.onAccentContainer else Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
@@ -70,6 +70,7 @@ fun ProfileEditorPane(
|
|||||||
val saved = checked.toProfile(profile)
|
val saved = checked.toProfile(profile)
|
||||||
if (profile != null) dataSource.updateVpnProfile(saved)
|
if (profile != null) dataSource.updateVpnProfile(saved)
|
||||||
else dataSource.insertProfile(saved)
|
else dataSource.insertProfile(saved)
|
||||||
|
if (profile == null) org.strongswan.android.logic.profiles.ProfileExtras.recordManual(context, saved.getUUID().toString())
|
||||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, saved.getUUID().toString())
|
intent.putExtra(Constants.VPN_PROFILES_SINGLE, saved.getUUID().toString())
|
||||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||||
|
|||||||
+209
-250
@@ -39,12 +39,6 @@ import androidx.compose.foundation.rememberScrollState
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
|
||||||
import androidx.compose.material.icons.filled.Visibility
|
|
||||||
import androidx.compose.material.icons.filled.VisibilityOff
|
|
||||||
import androidx.compose.material.icons.rounded.AltRoute
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
@@ -86,6 +80,27 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
|||||||
import androidx.compose.ui.text.input.VisualTransformation
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxRow
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTabs
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
import org.strongswan.android.data.VpnProfile
|
import org.strongswan.android.data.VpnProfile
|
||||||
import org.strongswan.android.data.VpnProfileDataSource
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
import org.strongswan.android.data.VpnType
|
import org.strongswan.android.data.VpnType
|
||||||
@@ -282,6 +297,9 @@ fun ProfileFormState.isValid(): Boolean
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The three sign-in variants of the mockup tabs; the rarer types stay in «More». */
|
||||||
|
private val LOGIN_TYPES = listOf(VpnType.IKEV2_EAP, VpnType.IKEV2_CERT, VpnType.IKEV2_CERT_EAP)
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun ProfileFormScreen(
|
fun ProfileFormScreen(
|
||||||
@@ -295,6 +313,7 @@ fun ProfileFormScreen(
|
|||||||
)
|
)
|
||||||
{
|
{
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
||||||
androidx.compose.runtime.LaunchedEffect(messages)
|
androidx.compose.runtime.LaunchedEffect(messages)
|
||||||
{
|
{
|
||||||
@@ -313,304 +332,244 @@ fun ProfileFormScreen(
|
|||||||
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
|
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Scaffold(
|
Box(Modifier.fillMaxSize().background(c.background))
|
||||||
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
|
{
|
||||||
modifier = Modifier.windowInsetsPadding(
|
|
||||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
|
|
||||||
),
|
|
||||||
contentWindowInsets = WindowInsets.safeDrawing.only(
|
|
||||||
WindowInsetsSides.Horizontal + WindowInsetsSides.Top,
|
|
||||||
),
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(title, maxLines = 1) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onCancel) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
bottomBar = {
|
|
||||||
if (enabled)
|
|
||||||
{
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.background,
|
|
||||||
tonalElevation = 0.dp,
|
|
||||||
modifier = Modifier.windowInsetsPadding(
|
|
||||||
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 20.dp, vertical = ShxSpacing.md),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
|
||||||
) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onCancel,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(48.dp),
|
|
||||||
) { Text(stringResource(R.string.profile_edit_cancel)) }
|
|
||||||
Button(
|
|
||||||
onClick = onSave,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(2f)
|
|
||||||
.height(48.dp),
|
|
||||||
) { Text(stringResource(R.string.profile_edit_save)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
) { padding ->
|
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(padding)
|
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState()),
|
||||||
.padding(20.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (state.readOnly)
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 16.dp, bottom = 8.dp))
|
||||||
{
|
{
|
||||||
Text(stringResource(R.string.alert_text_vpn_profile_read_only), color = MaterialTheme.colorScheme.error)
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onCancel))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.back), tint = c.text, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
Text(title, style = ShxType.pageTitle, color = c.text, maxLines = 1, modifier = Modifier.weight(1f))
|
||||||
}
|
}
|
||||||
ShxSectionCard(title = stringResource(R.string.profile_section_basic))
|
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
{
|
{
|
||||||
OutlinedTextField(
|
if (state.readOnly)
|
||||||
|
{
|
||||||
|
ShxNote(stringResource(R.string.alert_text_vpn_profile_read_only), tone = ShxTone.ERROR)
|
||||||
|
}
|
||||||
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.profile_name_label).trimEnd(':'),
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = { onChange(state.copy(name = it)) },
|
||||||
|
placeholder = stringResource(R.string.form_name_placeholder),
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.profile_gateway_label).trimEnd(':'),
|
||||||
value = state.gateway,
|
value = state.gateway,
|
||||||
onValueChange = { onChange(state.copy(gateway = it, gatewayError = null)) },
|
onValueChange = { onChange(state.copy(gateway = it, gatewayError = null)) },
|
||||||
label = { Text(stringResource(R.string.profile_gateway_label)) },
|
placeholder = stringResource(R.string.form_gateway_placeholder),
|
||||||
supportingText = { Text(state.gatewayError ?: stringResource(R.string.profile_gateway_hint)) },
|
error = state.gatewayError,
|
||||||
isError = state.gatewayError != null,
|
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
singleLine = true,
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it }) {
|
ShxSectionHeader(stringResource(R.string.form_login))
|
||||||
OutlinedTextField(
|
if (state.vpnType in LOGIN_TYPES)
|
||||||
value = types.getOrElse(state.vpnType.ordinal) { "" },
|
{
|
||||||
onValueChange = {},
|
ShxTabs(
|
||||||
readOnly = true,
|
options = listOf(stringResource(R.string.form_login_password), stringResource(R.string.form_login_cert), stringResource(R.string.form_login_both)),
|
||||||
enabled = enabled,
|
selected = LOGIN_TYPES.indexOf(state.vpnType),
|
||||||
label = { Text(stringResource(R.string.profile_vpn_type_label)) },
|
onSelect = { if (enabled) onChange(state.copy(vpnType = LOGIN_TYPES[it])) },
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) },
|
|
||||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
|
||||||
)
|
)
|
||||||
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false }) {
|
}
|
||||||
VpnType.values().forEachIndexed { index, type ->
|
else
|
||||||
DropdownMenuItem(
|
{
|
||||||
text = { Text(types.getOrElse(index) { type.identifier }) },
|
ShxNote(types.getOrElse(state.vpnType.ordinal) { state.vpnType.identifier }, tone = ShxTone.NEUTRAL)
|
||||||
onClick = {
|
|
||||||
onChange(state.copy(vpnType = type))
|
|
||||||
typeExpanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (state.vpnType.has(VpnTypeFeature.USER_PASS))
|
if (state.vpnType.has(VpnTypeFeature.USER_PASS))
|
||||||
{
|
{
|
||||||
OutlinedTextField(
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.profile_username_label).trimEnd(':'),
|
||||||
value = state.username,
|
value = state.username,
|
||||||
onValueChange = { onChange(state.copy(username = it, usernameError = null)) },
|
onValueChange = { onChange(state.copy(username = it, usernameError = null)) },
|
||||||
label = { Text(stringResource(R.string.profile_username_label)) },
|
error = state.usernameError,
|
||||||
isError = state.usernameError != null,
|
|
||||||
supportingText = state.usernameError?.let { { Text(it) } },
|
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.profile_password_label).trimEnd(':'),
|
||||||
value = state.password,
|
value = state.password,
|
||||||
onValueChange = { onChange(state.copy(password = it)) },
|
onValueChange = { onChange(state.copy(password = it)) },
|
||||||
label = { Text(stringResource(R.string.profile_password_label)) },
|
supporting = stringResource(R.string.profile_password_hint),
|
||||||
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
|
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
singleLine = true,
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
trailingIcon = {
|
trailing = {
|
||||||
IconButton(onClick = { showPassword = !showPassword }) {
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable { showPassword = !showPassword })
|
||||||
Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null)
|
{
|
||||||
|
Icon(if (showPassword) ShxIcons.EyeOff else ShxIcons.Eye, null, tint = c.textSub, modifier = Modifier.size(20.dp))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (state.vpnType.has(VpnTypeFeature.CERTIFICATE))
|
if (state.vpnType.has(VpnTypeFeature.CERTIFICATE))
|
||||||
{
|
{
|
||||||
TextButton(onClick = {
|
FormPickRow(
|
||||||
|
title = state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate),
|
||||||
|
subtitle = stringResource(R.string.profile_user_certificate_label).trimEnd(':'),
|
||||||
|
enabled = enabled,
|
||||||
|
)
|
||||||
|
{
|
||||||
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
|
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
|
||||||
onChange(latest.value.copy(userCertAlias = alias))
|
onChange(latest.value.copy(userCertAlias = alias))
|
||||||
}, arrayOf("RSA", "EC"), null, null, -1, state.userCertAlias)
|
}, arrayOf("RSA", "EC"), null, null, -1, state.userCertAlias)
|
||||||
}, enabled = enabled) {
|
|
||||||
Text(state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
ShxPanel()
|
||||||
Checkbox(
|
|
||||||
checked = state.caAuto,
|
|
||||||
onCheckedChange = { onChange(state.copy(caAuto = it)) },
|
|
||||||
enabled = enabled,
|
|
||||||
)
|
|
||||||
Text(stringResource(R.string.profile_ca_auto_label), modifier = Modifier.clickable(enabled = enabled) {
|
|
||||||
onChange(state.copy(caAuto = !state.caAuto))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (!state.caAuto)
|
|
||||||
{
|
{
|
||||||
TextButton(onClick = {
|
ShxToggleRow(stringResource(R.string.profile_ca_auto_label), state.caAuto, { onChange(state.copy(caAuto = it)) }, enabled = enabled, divider = !state.caAuto)
|
||||||
val intent = Intent(context, TrustedCertificatesActivity::class.java)
|
if (!state.caAuto)
|
||||||
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
|
{
|
||||||
pickCa.launch(intent)
|
FormPickRow(
|
||||||
}, enabled = enabled) {
|
title = state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) },
|
||||||
Text(state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) })
|
subtitle = stringResource(R.string.profile_ca_label).trimEnd(':'),
|
||||||
|
enabled = enabled,
|
||||||
|
bordered = false,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val intent = Intent(context, TrustedCertificatesActivity::class.java)
|
||||||
|
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
|
||||||
|
pickCa.launch(intent)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
OutlinedTextField(
|
/* «More: port, MTU, DNS, identities» */
|
||||||
value = state.name,
|
val chevron by animateFloatAsState(if (state.advanced) 90f else 0f, label = "adv")
|
||||||
onValueChange = { onChange(state.copy(name = it)) },
|
Row(
|
||||||
label = { Text(stringResource(R.string.profile_name_label)) },
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
supportingText = { Text(stringResource(R.string.profile_name_hint)) },
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
enabled = enabled,
|
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(16.dp)).background(c.card)
|
||||||
singleLine = true,
|
.border(1.dp, c.cardBorder, RoundedCornerShape(16.dp))
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.clickable(role = Role.Button) { onChange(state.copy(advanced = !state.advanced)) }.padding(horizontal = 16.dp),
|
||||||
)
|
|
||||||
HorizontalDivider()
|
|
||||||
val chevron by animateFloatAsState(if (state.advanced) 180f else 0f, label = "adv")
|
|
||||||
ListItem(
|
|
||||||
headlineContent = { Text(stringResource(R.string.profile_advanced_label)) },
|
|
||||||
supportingContent = { Text(stringResource(R.string.profile_advanced_hint)) },
|
|
||||||
trailingContent = {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.ExpandMore,
|
|
||||||
contentDescription = stringResource(R.string.profile_show_advanced_label),
|
|
||||||
modifier = Modifier.rotate(chevron),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable { onChange(state.copy(advanced = !state.advanced)) },
|
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.form_more), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f), maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = stringResource(R.string.profile_show_advanced_label), tint = c.iconMuted, modifier = Modifier.size(18.dp).rotate(chevron))
|
||||||
|
}
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = state.advanced,
|
visible = state.advanced,
|
||||||
enter = expandVertically() + fadeIn(),
|
enter = expandVertically() + fadeIn(),
|
||||||
exit = shrinkVertically() + fadeOut(),
|
exit = shrinkVertically() + fadeOut(),
|
||||||
) {
|
) {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
OutlinedTextField(state.remoteId, { onChange(state.copy(remoteId = it)) },
|
{
|
||||||
label = { Text(stringResource(R.string.profile_remote_id_label)) },
|
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it })
|
||||||
supportingText = { Text(stringResource(R.string.profile_remote_id_hint)) },
|
{
|
||||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
FormPickRow(
|
||||||
OutlinedTextField(state.localId, { onChange(state.copy(localId = it)) },
|
title = types.getOrElse(state.vpnType.ordinal) { "" },
|
||||||
label = { Text(stringResource(R.string.profile_local_id_label)) },
|
subtitle = stringResource(R.string.profile_vpn_type_label).trimEnd(':'),
|
||||||
supportingText = { Text(stringResource(R.string.profile_local_id_hint_user)) },
|
enabled = enabled,
|
||||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||||
OutlinedTextField(state.dns, { onChange(state.copy(dns = it)) },
|
) { typeExpanded = true }
|
||||||
label = { Text(stringResource(R.string.profile_dns_servers_label)) },
|
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false })
|
||||||
supportingText = { Text(stringResource(R.string.profile_dns_servers_hint)) },
|
{
|
||||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
VpnType.values().forEachIndexed { index, type ->
|
||||||
OutlinedTextField(state.mtu, { onChange(state.copy(mtu = it)) },
|
DropdownMenuItem(
|
||||||
label = { Text(stringResource(R.string.profile_mtu_label)) },
|
text = { Text(types.getOrElse(index) { type.identifier }, style = ShxType.body) },
|
||||||
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
onClick = {
|
||||||
modifier = Modifier.fillMaxWidth())
|
onChange(state.copy(vpnType = type))
|
||||||
OutlinedTextField(state.port, { onChange(state.copy(port = it)) },
|
typeExpanded = false
|
||||||
label = { Text(stringResource(R.string.profile_port_label)) },
|
},
|
||||||
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
)
|
||||||
modifier = Modifier.fillMaxWidth())
|
}
|
||||||
OutlinedTextField(state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
|
}
|
||||||
label = { Text(stringResource(R.string.profile_nat_keepalive_label)) },
|
}
|
||||||
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
ShxFormField(stringResource(R.string.profile_remote_id_label).trimEnd(':'), state.remoteId, { onChange(state.copy(remoteId = it)) },
|
||||||
modifier = Modifier.fillMaxWidth())
|
supporting = stringResource(R.string.profile_remote_id_hint), enabled = enabled)
|
||||||
SwitchRow(stringResource(R.string.profile_cert_req_label), state.certReq, enabled) { onChange(state.copy(certReq = it)) }
|
ShxFormField(stringResource(R.string.profile_local_id_label).trimEnd(':'), state.localId, { onChange(state.copy(localId = it)) },
|
||||||
SwitchRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, enabled) { onChange(state.copy(useOcsp = it)) }
|
supporting = stringResource(R.string.profile_local_id_hint_user), enabled = enabled)
|
||||||
SwitchRow(stringResource(R.string.profile_use_crl_label), state.useCrl, enabled) { onChange(state.copy(useCrl = it)) }
|
ShxFormField(stringResource(R.string.profile_dns_servers_label).trimEnd(':'), state.dns, { onChange(state.copy(dns = it)) },
|
||||||
SwitchRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, enabled) { onChange(state.copy(strictRevocation = it)) }
|
supporting = stringResource(R.string.profile_dns_servers_hint), enabled = enabled, mono = true)
|
||||||
SwitchRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, enabled) { onChange(state.copy(rsaPss = it)) }
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
SwitchRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, enabled) { onChange(state.copy(ipv6Transport = it)) }
|
{
|
||||||
OutlinedTextField(state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
|
ShxFormField(stringResource(R.string.profile_port_label).trimEnd(':'), state.port, { onChange(state.copy(port = it)) },
|
||||||
label = { Text(stringResource(R.string.profile_included_subnets_label)) },
|
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
|
||||||
isError = state.includedSubnetsError != null,
|
ShxFormField(stringResource(R.string.profile_mtu_label).trimEnd(':'), state.mtu, { onChange(state.copy(mtu = it)) },
|
||||||
supportingText = { state.includedSubnetsError?.let { Text(it) } },
|
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
}
|
||||||
OutlinedTextField(state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
|
ShxFormField(stringResource(R.string.profile_nat_keepalive_label).trimEnd(':'), state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
|
||||||
label = { Text(stringResource(R.string.profile_excluded_subnets_label)) },
|
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||||
isError = state.excludedSubnetsError != null,
|
ShxPanel()
|
||||||
supportingText = { state.excludedSubnetsError?.let { Text(it) } },
|
{
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
ShxToggleRow(stringResource(R.string.profile_cert_req_label), state.certReq, { onChange(state.copy(certReq = it)) }, enabled = enabled, divider = true)
|
||||||
SwitchRow(
|
ShxToggleRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, { onChange(state.copy(useOcsp = it)) }, enabled = enabled, divider = true)
|
||||||
label = stringResource(R.string.profile_split_tunnelingv4_title),
|
ShxToggleRow(stringResource(R.string.profile_use_crl_label), state.useCrl, { onChange(state.copy(useCrl = it)) }, enabled = enabled, divider = true)
|
||||||
checked = state.blockIpv4,
|
ShxToggleRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, { onChange(state.copy(strictRevocation = it)) }, enabled = enabled, divider = true)
|
||||||
enabled = enabled,
|
ShxToggleRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, { onChange(state.copy(rsaPss = it)) }, enabled = enabled, divider = true)
|
||||||
) { onChange(state.copy(blockIpv4 = it)) }
|
ShxToggleRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, { onChange(state.copy(ipv6Transport = it)) }, enabled = enabled, divider = true)
|
||||||
SwitchRow(
|
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv4_title), state.blockIpv4, { onChange(state.copy(blockIpv4 = it)) }, enabled = enabled, divider = true)
|
||||||
label = stringResource(R.string.profile_split_tunnelingv6_title),
|
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv6_title), state.blockIpv6, { onChange(state.copy(blockIpv6 = it)) },
|
||||||
supporting = stringResource(R.string.profile_split_tunnelingv6_hint),
|
subtitle = stringResource(R.string.profile_split_tunnelingv6_hint), enabled = enabled)
|
||||||
checked = state.blockIpv6,
|
}
|
||||||
enabled = enabled,
|
ShxFormField(stringResource(R.string.profile_included_subnets_label).trimEnd(':'), state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
|
||||||
) { onChange(state.copy(blockIpv6 = it)) }
|
error = state.includedSubnetsError, enabled = enabled, mono = true, singleLine = false)
|
||||||
ShxSettingRow(
|
ShxFormField(stringResource(R.string.profile_excluded_subnets_label).trimEnd(':'), state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
|
||||||
icon = Icons.Rounded.AltRoute,
|
error = state.excludedSubnetsError, enabled = enabled, mono = true, singleLine = false)
|
||||||
title = stringResource(R.string.profile_routing_link_title),
|
ShxPanel()
|
||||||
subtitle = stringResource(R.string.profile_routing_link_summary),
|
{
|
||||||
onClick = {
|
ShxRow(
|
||||||
context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
|
title = stringResource(R.string.profile_routing_link_title),
|
||||||
},
|
subtitle = stringResource(R.string.profile_routing_link_summary),
|
||||||
)
|
icon = ShxIcons.Route,
|
||||||
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
|
chevron = true,
|
||||||
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
|
onClick = { context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true)) },
|
||||||
isError = state.ikeProposalError != null,
|
)
|
||||||
supportingText = { state.ikeProposalError?.let { Text(it) } },
|
}
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
ShxFormField(stringResource(R.string.profile_proposals_ike_label).trimEnd(':'), state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
|
||||||
OutlinedTextField(state.espProposal, { onChange(state.copy(espProposal = it)) },
|
error = state.ikeProposalError, enabled = enabled, mono = true)
|
||||||
label = { Text(stringResource(R.string.profile_proposals_esp_label)) },
|
ShxFormField(stringResource(R.string.profile_proposals_esp_label).trimEnd(':'), state.espProposal, { onChange(state.copy(espProposal = it)) },
|
||||||
isError = state.espProposalError != null,
|
error = state.espProposalError, enabled = enabled, mono = true)
|
||||||
supportingText = { state.espProposalError?.let { Text(it) } },
|
ShxFormField(stringResource(R.string.profile_proxy_host_label).trimEnd(':'), state.proxyHost, { onChange(state.copy(proxyHost = it)) }, enabled = enabled)
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
ShxFormField(stringResource(R.string.profile_proxy_port_label).trimEnd(':'), state.proxyPort, { onChange(state.copy(proxyPort = it)) },
|
||||||
OutlinedTextField(state.proxyHost, { onChange(state.copy(proxyHost = it)) },
|
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||||
label = { Text(stringResource(R.string.profile_proxy_host_label)) },
|
ShxFormField(stringResource(R.string.profile_proxy_exclusions_label).trimEnd(':'), state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) }, enabled = enabled)
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.proxyPort, { onChange(state.copy(proxyPort = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_proxy_port_label)) },
|
|
||||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
|
||||||
modifier = Modifier.fillMaxWidth())
|
|
||||||
OutlinedTextField(state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) },
|
|
||||||
label = { Text(stringResource(R.string.profile_proxy_exclusions_label)) },
|
|
||||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (enabled)
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.profile_edit_save), onSave, modifier = Modifier.fillMaxWidth().padding(top = 8.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** White row that opens a picker: 12 label on top, 15/700 value, chevron. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SwitchRow(
|
private fun FormPickRow(
|
||||||
label: String,
|
title: String,
|
||||||
checked: Boolean,
|
subtitle: String,
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
supporting: String? = null,
|
modifier: Modifier = Modifier,
|
||||||
onChange: (Boolean) -> Unit,
|
bordered: Boolean = true,
|
||||||
|
onClick: () -> Unit,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
ListItem(
|
val c = LocalShxColors.current
|
||||||
headlineContent = { Text(label) },
|
val shape = RoundedCornerShape(16.dp)
|
||||||
supportingContent = supporting?.let { { Text(it) } },
|
Row(
|
||||||
trailingContent = {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
},
|
modifier = modifier
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
.fillMaxWidth()
|
||||||
modifier = Modifier.fillMaxWidth(),
|
.then(if (bordered) Modifier.clip(shape).background(c.card).border(1.dp, c.cardBorder, shape) else Modifier)
|
||||||
|
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = c.textSub)
|
||||||
|
Text(title, style = ShxType.body.copy(fontSize = 16.sp), color = if (enabled) c.text else c.textSub, maxLines = 1, overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis)
|
||||||
|
}
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
-135
@@ -1,135 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.compose
|
|
||||||
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxConnectedPill
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
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.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Language
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import org.strongswan.android.R
|
|
||||||
import org.strongswan.android.data.VpnProfile
|
|
||||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ProfileAvatar(icon: ImageVector, connected: Boolean, modifier: Modifier = Modifier)
|
|
||||||
{
|
|
||||||
val status = LocalShxStatusColors.current
|
|
||||||
val container = if (connected) status.connectedContainer else MaterialTheme.colorScheme.primaryContainer
|
|
||||||
val tint = if (connected) status.connected else MaterialTheme.colorScheme.primary
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.background(container, CircleShape),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = tint,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
|
||||||
@Composable
|
|
||||||
fun ShxProfileRow(
|
|
||||||
profile: VpnProfile,
|
|
||||||
active: Boolean,
|
|
||||||
leadingIcon: ImageVector = Icons.Rounded.Language,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onLongClick: () -> Unit = {},
|
|
||||||
onLongClickLabel: String? = null,
|
|
||||||
trailing: (@Composable () -> Unit)? = null,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.combinedClickable(
|
|
||||||
onClick = onClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
onLongClickLabel = onLongClickLabel,
|
|
||||||
)
|
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
|
||||||
) {
|
|
||||||
ProfileAvatar(icon = leadingIcon, connected = active)
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(horizontal = ShxSpacing.md),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = profile.name ?: "",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = profile.gateway ?: "",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (trailing != null)
|
|
||||||
{
|
|
||||||
trailing()
|
|
||||||
}
|
|
||||||
else if (active)
|
|
||||||
{
|
|
||||||
ActiveBadge()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ActiveBadge(modifier: Modifier = Modifier)
|
|
||||||
{
|
|
||||||
ShxConnectedPill(text = stringResource(R.string.badge_connected), modifier = modifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ShxDivider(modifier: Modifier = Modifier, indent: Boolean = true)
|
|
||||||
{
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = if (indent) 68.dp else 0.dp, end = if (indent) ShxSpacing.lg else 0.dp)
|
|
||||||
.height(1.dp),
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
+242
-179
@@ -17,18 +17,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Add
|
|
||||||
import androidx.compose.material.icons.rounded.Close
|
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
|
||||||
import androidx.compose.material.icons.rounded.Language
|
|
||||||
import androidx.compose.material.icons.rounded.MoreVert
|
|
||||||
import androidx.compose.material.icons.rounded.PlayArrow
|
|
||||||
import androidx.compose.material.icons.rounded.Search
|
|
||||||
import androidx.compose.material.icons.rounded.SearchOff
|
|
||||||
import androidx.compose.material.icons.rounded.Share
|
|
||||||
import androidx.compose.material.icons.rounded.Stop
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -71,6 +59,42 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
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.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import org.strongswan.android.logic.connectivity.ConnectivityProbe
|
||||||
|
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.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxRow
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxScreenTitle
|
||||||
|
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.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
import org.strongswan.android.data.VpnProfile
|
import org.strongswan.android.data.VpnProfile
|
||||||
import org.strongswan.android.logic.VpnStateService.State
|
import org.strongswan.android.logic.VpnStateService.State
|
||||||
import org.strongswan.android.ui.compose.components.ShxCard
|
import org.strongswan.android.ui.compose.components.ShxCard
|
||||||
@@ -92,11 +116,12 @@ fun ProfilesScreen(
|
|||||||
onRestore: (VpnProfile) -> Unit,
|
onRestore: (VpnProfile) -> Unit,
|
||||||
onShare: (VpnProfile) -> Unit,
|
onShare: (VpnProfile) -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
|
onFastest: () -> Unit = {},
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (LocalConfiguration.current.screenWidthDp < 840)
|
if (LocalConfiguration.current.screenWidthDp < 840)
|
||||||
{
|
{
|
||||||
ProfilesList(state, onConnect, onDisconnect, onEdit, onDelete, onRestore, onShare, onAdd)
|
ProfilesList(state, onConnect, onDisconnect, onEdit, onDelete, onRestore, onShare, onAdd, onFastest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
/* null = nothing open, "" = new profile, otherwise the edited profile's uuid */
|
/* null = nothing open, "" = new profile, otherwise the edited profile's uuid */
|
||||||
@@ -135,7 +160,7 @@ fun ProfilesScreen(
|
|||||||
{
|
{
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center)
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center)
|
||||||
{
|
{
|
||||||
ShxEmptyState(icon = Icons.Rounded.Language, message = stringResource(R.string.profile_detail_placeholder))
|
ShxEmptyState(icon = ShxIcons.Globe, message = stringResource(R.string.profile_detail_placeholder))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,7 +168,9 @@ fun ProfilesScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
/** Server status line: green "connected · 38 ms", green "available · 29 ms" or amber "no answer". */
|
||||||
|
private data class ServerPing(val rttMs: Long?)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ProfilesList(
|
private fun ProfilesList(
|
||||||
state: HomeUiState,
|
state: HomeUiState,
|
||||||
@@ -154,21 +181,33 @@ private fun ProfilesList(
|
|||||||
onRestore: (VpnProfile) -> Unit,
|
onRestore: (VpnProfile) -> Unit,
|
||||||
onShare: (VpnProfile) -> Unit,
|
onShare: (VpnProfile) -> Unit,
|
||||||
onAdd: () -> Unit,
|
onAdd: () -> Unit,
|
||||||
|
onFastest: () -> Unit = {},
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var query by remember { mutableStateOf("") }
|
|
||||||
var menuFor by remember { mutableStateOf<VpnProfile?>(null) }
|
var menuFor by remember { mutableStateOf<VpnProfile?>(null) }
|
||||||
|
var deleteFor by remember { mutableStateOf<VpnProfile?>(null) }
|
||||||
val activeUuid = state.activeProfile?.getUUID()
|
val activeUuid = state.activeProfile?.getUUID()
|
||||||
val connected = state.vpnState == State.CONNECTED
|
val connected = state.vpnState == State.CONNECTED
|
||||||
val profiles = state.profiles
|
val profiles = state.profiles
|
||||||
val filtered = remember(query, profiles)
|
/* direct pings of every server (outside the tunnel), refreshed while the tab is shown */
|
||||||
{
|
val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle
|
||||||
if (query.isBlank()) profiles
|
val pings by produceState(emptyMap<String, ServerPing>(), profiles, lifecycle) {
|
||||||
else profiles.filter {
|
lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
|
||||||
(it.name ?: "").contains(query, ignoreCase = true) || (it.gateway ?: "").contains(query, ignoreCase = true)
|
while (true)
|
||||||
|
{
|
||||||
|
value = coroutineScope {
|
||||||
|
profiles.map { p ->
|
||||||
|
async {
|
||||||
|
p.getUUID().toString() to ServerPing(ConnectivityProbe.pingServerDirect(context, p.gateway, 1500)?.first)
|
||||||
|
}
|
||||||
|
}.map { it.await() }.toMap()
|
||||||
|
}
|
||||||
|
delay(30_000)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,183 +227,207 @@ private fun ProfilesList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
deleteFor?.let { profile ->
|
||||||
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
|
val current = profile.getUUID() == activeUuid && state.vpnState != State.DISABLED
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
ShxDialog(
|
||||||
) { _ ->
|
title = stringResource(R.string.server_delete_title, profile.name ?: profile.gateway),
|
||||||
Column(Modifier.fillMaxSize())
|
paragraphs = listOfNotNull(
|
||||||
|
if (current) 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 = {
|
||||||
|
deleteFor = null
|
||||||
|
if (current)
|
||||||
|
{
|
||||||
|
onDisconnect()
|
||||||
|
}
|
||||||
|
deleteWithUndo(profile)
|
||||||
|
},
|
||||||
|
onDismiss = { deleteFor = null },
|
||||||
|
icon = ShxIcons.Trash,
|
||||||
|
danger = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize().background(c.background))
|
||||||
|
{
|
||||||
|
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
|
||||||
{
|
{
|
||||||
TopAppBar(
|
ShxScreenTitle(stringResource(R.string.nav_profiles))
|
||||||
title = { Text(stringResource(R.string.nav_profiles), fontWeight = FontWeight.SemiBold) },
|
|
||||||
actions = {
|
|
||||||
IconButton(onClick = onAdd) {
|
|
||||||
Icon(Icons.Rounded.Add, contentDescription = stringResource(R.string.add_profile_cta))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
SearchBar(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.xs),
|
|
||||||
inputField = {
|
|
||||||
SearchBarDefaults.InputField(
|
|
||||||
query = query,
|
|
||||||
onQueryChange = { query = it },
|
|
||||||
onSearch = {},
|
|
||||||
expanded = false,
|
|
||||||
onExpandedChange = {},
|
|
||||||
placeholder = { Text(stringResource(R.string.search_profiles)) },
|
|
||||||
leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) },
|
|
||||||
trailingIcon = {
|
|
||||||
if (query.isNotEmpty())
|
|
||||||
{
|
|
||||||
IconButton(onClick = { query = "" }) {
|
|
||||||
Icon(Icons.Rounded.Close, contentDescription = stringResource(R.string.action_clear_search))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
expanded = false,
|
|
||||||
onExpandedChange = {},
|
|
||||||
) {}
|
|
||||||
if (filtered.isEmpty())
|
|
||||||
{
|
{
|
||||||
EmptyProfilesState(query = query, onAdd = onAdd)
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.accent)
|
||||||
|
.clickable(role = Role.Button, onClickLabel = stringResource(R.string.add_profile_cta), onClick = onAdd),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Plus, contentDescription = stringResource(R.string.add_profile_cta), tint = Color.White, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
{
|
{
|
||||||
Surface(
|
if (profiles.isEmpty())
|
||||||
shape = MaterialTheme.shapes.large,
|
{
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
EmptyProfilesState(onAdd)
|
||||||
modifier = Modifier
|
return@Column
|
||||||
.fillMaxWidth()
|
}
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
if (profiles.size > 1)
|
||||||
) {
|
{
|
||||||
LazyColumn {
|
AutoServerCard(onFastest)
|
||||||
itemsIndexed(filtered, key = { _, p -> p.getUUID().toString() }) { index, profile ->
|
}
|
||||||
val isActive = connected && profile.getUUID() == activeUuid
|
ShxSectionHeader(stringResource(R.string.servers_mine, profiles.size), Modifier.padding(top = 8.dp))
|
||||||
Column(Modifier.animateItem()) {
|
ShxPanel()
|
||||||
Box {
|
{
|
||||||
ShxProfileRow(
|
profiles.forEachIndexed { index, profile ->
|
||||||
profile = profile,
|
val isActive = connected && profile.getUUID() == activeUuid
|
||||||
active = isActive,
|
ServerRow(
|
||||||
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
|
profile = profile,
|
||||||
onLongClick = { menuFor = profile },
|
active = isActive,
|
||||||
trailing = {
|
ping = pings[profile.getUUID().toString()],
|
||||||
IconButton(onClick = { menuFor = profile }) {
|
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
|
||||||
Icon(
|
onMore = { menuFor = profile },
|
||||||
Icons.Rounded.MoreVert,
|
)
|
||||||
contentDescription = stringResource(R.string.action_more_options),
|
if (index < profiles.lastIndex)
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
{
|
||||||
)
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
}
|
|
||||||
if (isActive) ActiveBadge()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenu(expanded = menuFor == profile, onDismissRequest = { menuFor = null })
|
|
||||||
{
|
|
||||||
if (isActive)
|
|
||||||
{
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.disconnect)) },
|
|
||||||
leadingIcon = { Icon(Icons.Rounded.Stop, contentDescription = null) },
|
|
||||||
onClick = {
|
|
||||||
menuFor = null
|
|
||||||
onDisconnect()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.connect)) },
|
|
||||||
leadingIcon = { Icon(Icons.Rounded.PlayArrow, contentDescription = null) },
|
|
||||||
onClick = {
|
|
||||||
menuFor = null
|
|
||||||
onConnect(profile)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.edit_profile)) },
|
|
||||||
leadingIcon = { Icon(Icons.Rounded.Edit, contentDescription = null) },
|
|
||||||
onClick = {
|
|
||||||
menuFor = null
|
|
||||||
onEdit(profile)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.share_profile)) },
|
|
||||||
leadingIcon = { Icon(Icons.Rounded.Share, contentDescription = null) },
|
|
||||||
onClick = {
|
|
||||||
menuFor = null
|
|
||||||
onShare(profile)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.delete_profile),
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.Delete,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
menuFor = null
|
|
||||||
deleteWithUndo(profile)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (index < filtered.lastIndex)
|
|
||||||
{
|
|
||||||
ShxDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Text(stringResource(R.string.servers_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ShxSnackbarHost(snackbarHostState, Modifier.align(Alignment.BottomCenter))
|
||||||
|
}
|
||||||
|
|
||||||
|
menuFor?.let { profile ->
|
||||||
|
val isActive = connected && profile.getUUID() == activeUuid
|
||||||
|
ShxSheet(onDismiss = { menuFor = null })
|
||||||
|
{
|
||||||
|
ShxSheetTitle(profile.name ?: profile.gateway ?: "")
|
||||||
|
Text(profile.gateway ?: "", style = ShxType.note.copy(fontSize = 14.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
ShxRow(
|
||||||
|
title = stringResource(if (isActive) R.string.disconnect else R.string.connect),
|
||||||
|
icon = if (isActive) ShxIcons.Close else ShxIcons.Power,
|
||||||
|
divider = true, minHeight = 64.dp,
|
||||||
|
onClick = {
|
||||||
|
menuFor = null
|
||||||
|
if (isActive) onDisconnect() else onConnect(profile)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ShxRow(
|
||||||
|
title = stringResource(R.string.edit_profile),
|
||||||
|
icon = ShxIcons.Sliders, iconBackground = c.tile,
|
||||||
|
divider = true, minHeight = 64.dp,
|
||||||
|
onClick = {
|
||||||
|
menuFor = null
|
||||||
|
onEdit(profile)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ShxRow(
|
||||||
|
title = stringResource(R.string.share_profile),
|
||||||
|
icon = ShxIcons.Download, iconBackground = c.tile,
|
||||||
|
minHeight = 64.dp,
|
||||||
|
onClick = {
|
||||||
|
menuFor = null
|
||||||
|
onShare(profile)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.delete_profile),
|
||||||
|
{
|
||||||
|
menuFor = null
|
||||||
|
deleteFor = profile
|
||||||
|
},
|
||||||
|
style = ShxButtonStyle.DANGER,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
icon = ShxIcons.Trash,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val SEARCH_THRESHOLD = 5
|
/** «Automatically»: the fastest server that connects (2px purple border, bolt circle). */
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun EmptyProfilesState(query: String, onAdd: () -> Unit)
|
private fun AutoServerCard(onClick: () -> Unit)
|
||||||
{
|
{
|
||||||
ShxCard(
|
val c = LocalShxColors.current
|
||||||
modifier = Modifier
|
val shape = RoundedCornerShape(24.dp)
|
||||||
.fillMaxWidth()
|
Row(
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.accentSoft).border(2.dp, c.accent, shape)
|
||||||
|
.clickable(role = Role.Button, onClick = onClick).padding(horizontal = 16.dp, vertical = 14.dp),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (query.isBlank())
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(c.accent))
|
||||||
{
|
{
|
||||||
ShxEmptyState(
|
Icon(ShxIcons.Bolt, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||||
icon = Icons.Rounded.Language,
|
|
||||||
message = stringResource(R.string.no_profiles),
|
|
||||||
actionLabel = stringResource(R.string.add_profile_cta),
|
|
||||||
onAction = onAdd,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
else
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
{
|
{
|
||||||
ShxEmptyState(
|
Text(stringResource(R.string.servers_auto), style = ShxType.rowTitle.copy(fontWeight = FontWeight.ExtraBold), color = c.text)
|
||||||
icon = Icons.Rounded.SearchOff,
|
Text(stringResource(R.string.servers_auto_sub), style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.onAccentContainer)
|
||||||
message = stringResource(R.string.nothing_found),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Server row of the mockup: 68dp, code circle, name 16/700, status dot line, "more" button. */
|
||||||
|
@Composable
|
||||||
|
private fun ServerRow(profile: VpnProfile, active: Boolean, ping: ServerPing?, onClick: () -> Unit, onMore: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val code = org.strongswan.android.ui.compose.home.serverCode(profile, null)
|
||||||
|
val (dot, meta) = when
|
||||||
|
{
|
||||||
|
ping == null -> c.iconMuted to stringResource(R.string.servers_checking)
|
||||||
|
ping.rttMs == null -> c.warning to stringResource(R.string.servers_no_answer)
|
||||||
|
active -> c.success to stringResource(R.string.servers_connected_ms, ping.rttMs)
|
||||||
|
else -> c.success to stringResource(R.string.servers_available_ms, ping.rttMs)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(68.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 14.dp, end = 8.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(40.dp).clip(CircleShape).background(if (active) c.accent else c.tile))
|
||||||
|
{
|
||||||
|
Text(code, style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = if (active) Color.White else c.onAccentContainer)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp))
|
||||||
|
{
|
||||||
|
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle, color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
Box(Modifier.size(7.dp).clip(CircleShape).background(dot))
|
||||||
|
Text(meta, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onMore),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.More, contentDescription = stringResource(R.string.action_more_options), tint = c.textSub, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptyProfilesState(onAdd: () -> Unit)
|
||||||
|
{
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
ShxEmptyState(
|
||||||
|
icon = ShxIcons.Globe,
|
||||||
|
message = stringResource(R.string.no_profiles),
|
||||||
|
actionLabel = stringResource(R.string.add_profile_cta),
|
||||||
|
onAction = onAdd,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+684
-111
@@ -9,175 +9,748 @@
|
|||||||
|
|
||||||
package org.strongswan.android.ui.compose
|
package org.strongswan.android.ui.compose
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
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.Column
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import kotlinx.coroutines.withContext
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxStatusPill
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxSectionCard
|
|
||||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
import org.strongswan.android.logic.CharonVpnService
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import org.strongswan.android.logic.VpnStateService
|
||||||
import org.strongswan.android.logic.routing.RouteCheck
|
import org.strongswan.android.logic.routing.RouteCheck
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
import org.strongswan.android.logic.routing.RoutingManager
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
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.ShxTabs
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import org.strongswan.android.ui.compose.routing.AppIcon
|
||||||
|
import org.strongswan.android.ui.compose.routing.DirectApps
|
||||||
|
import org.strongswan.android.ui.compose.routing.InstalledApp
|
||||||
|
import org.strongswan.android.ui.compose.routing.templateLabel
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
|
||||||
/** Diagnostic: how a domain resolves and which route its addresses take. */
|
/** What the route check looks at. */
|
||||||
|
enum class RouteCheckMode { SITE, IP, APP }
|
||||||
|
|
||||||
|
/** TCP 443 reachability over one network: rtt in ms, or null if it did not open. */
|
||||||
|
data class Reach(val viaDefault: Long?, val direct: Long?, val vpn: Long?)
|
||||||
|
|
||||||
|
private sealed interface CheckResult
|
||||||
|
{
|
||||||
|
data class Site(val report: RouteCheck.Report, val verdicts: Map<String, RouteCheck.Verdict>, val reach: Reach) : CheckResult
|
||||||
|
data class Ip(val ip: String, val verdict: RouteCheck.Verdict, val reach: Reach) : CheckResult
|
||||||
|
data class App(val app: InstalledApp) : CheckResult
|
||||||
|
data object BadInput : CheckResult
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PREF_RECENT = "route_check_recent"
|
||||||
|
|
||||||
|
/** «How traffic goes» (F_Check): route of a site, an address or an app, why, and a one-tap change. */
|
||||||
@Composable
|
@Composable
|
||||||
fun RouteCheckScreen()
|
fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteCheckMode.SITE, initialQuery: String = "")
|
||||||
{
|
{
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var domain by remember { mutableStateOf("") }
|
val manager = remember { RoutingManager.getInstance(context) }
|
||||||
|
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
|
||||||
|
var mode by rememberSaveable { mutableStateOf(initialMode) }
|
||||||
|
var query by rememberSaveable { mutableStateOf(initialQuery) }
|
||||||
var checking by remember { mutableStateOf(false) }
|
var checking by remember { mutableStateOf(false) }
|
||||||
var report by remember { mutableStateOf<RouteCheck.Report?>(null) }
|
var result by remember { mutableStateOf<CheckResult?>(null) }
|
||||||
|
var recent by remember { mutableStateOf(prefs.getString(PREF_RECENT, "")!!.split('\n').filter { it.isNotBlank() }) }
|
||||||
|
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
|
||||||
|
var apps by remember { mutableStateOf<List<InstalledApp>>(emptyList()) }
|
||||||
|
|
||||||
Column(
|
/** The installed apps; loads them on first use, so a check never runs against a list that is still empty. */
|
||||||
Modifier
|
suspend fun loadApps(): List<InstalledApp>
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Text(
|
if (apps.isEmpty())
|
||||||
text = stringResource(R.string.route_check_hint),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = domain,
|
|
||||||
onValueChange = { domain = it },
|
|
||||||
label = { Text(stringResource(R.string.route_check_field)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 10.dp),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
)
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
checking = true
|
|
||||||
scope.launch {
|
|
||||||
report = RouteCheck.check(
|
|
||||||
context,
|
|
||||||
domain.trim(),
|
|
||||||
CharonVpnService.sAppliedGeoExclusionsV4,
|
|
||||||
CharonVpnService.sAppliedGeoExclusionsV6,
|
|
||||||
CharonVpnService.sDnsPunchIps,
|
|
||||||
)
|
|
||||||
checking = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = domain.isNotBlank() && !checking,
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
modifier = Modifier.padding(top = 10.dp),
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
Text(stringResource(if (checking) R.string.route_check_checking else R.string.route_check_button))
|
apps = withContext(Dispatchers.IO) { DirectApps.loadInstalledApps(context.packageManager, context.packageName) }
|
||||||
}
|
}
|
||||||
report?.let { rep -> RouteCheckResult(rep) }
|
return apps
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
LaunchedEffect(mode)
|
||||||
private fun RouteCheckResult(report: RouteCheck.Report)
|
|
||||||
{
|
|
||||||
val status = LocalShxStatusColors.current
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(top = ShxSpacing.lg),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
if (!report.vpnNetworkFound)
|
if (mode == RouteCheckMode.APP)
|
||||||
{
|
{
|
||||||
ShxStatusPill(
|
loadApps()
|
||||||
text = stringResource(R.string.route_check_no_vpn),
|
|
||||||
containerColor = status.disconnectedContainer,
|
|
||||||
contentColor = status.onDisconnectedContainer,
|
|
||||||
shape = MaterialTheme.shapes.small,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ShxSectionCard(title = stringResource(R.string.route_check_dns_section))
|
}
|
||||||
|
|
||||||
|
fun rememberQuery(q: String)
|
||||||
|
{
|
||||||
|
recent = (listOf(q) + recent.filter { it != q }).take(4)
|
||||||
|
prefs.edit().putString(PREF_RECENT, recent.joinToString("\n")).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun run()
|
||||||
|
{
|
||||||
|
val input = query.trim()
|
||||||
|
if (input.isEmpty())
|
||||||
{
|
{
|
||||||
RouteCheckRow(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
|
return
|
||||||
RouteCheckRow(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
|
|
||||||
}
|
}
|
||||||
ShxSectionCard(title = stringResource(R.string.route_check_routes))
|
checking = true
|
||||||
{
|
scope.launch {
|
||||||
for (route in report.routes)
|
result = when (mode)
|
||||||
{
|
{
|
||||||
val label = stringResource(
|
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
|
||||||
when (route.kind)
|
RouteCheckMode.IP -> checkIp(context, manager, input, rulesActive)
|
||||||
|
RouteCheckMode.APP -> loadApps().firstOrNull { it.packageName == input || it.label.equals(input, ignoreCase = true) }
|
||||||
|
?.let { CheckResult.App(it) } ?: CheckResult.BadInput
|
||||||
|
}
|
||||||
|
if (result !is CheckResult.BadInput)
|
||||||
|
{
|
||||||
|
rememberQuery(if (result is CheckResult.App) (result as CheckResult.App).app.label else input)
|
||||||
|
}
|
||||||
|
checking = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun rerun(wait: Long)
|
||||||
|
{
|
||||||
|
scope.launch {
|
||||||
|
delay(wait)
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit)
|
||||||
|
{
|
||||||
|
if (initialQuery.isNotBlank())
|
||||||
|
{
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()))
|
||||||
|
{
|
||||||
|
ShxPageHeader(stringResource(R.string.route_check_title), onBack)
|
||||||
|
Column(Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
|
{
|
||||||
|
ShxTabs(
|
||||||
|
options = listOf(
|
||||||
|
stringResource(R.string.route_check_mode_site),
|
||||||
|
stringResource(R.string.route_check_mode_ip),
|
||||||
|
stringResource(R.string.route_check_mode_app),
|
||||||
|
),
|
||||||
|
selected = mode.ordinal,
|
||||||
|
onSelect = {
|
||||||
|
val m = RouteCheckMode.entries[it]
|
||||||
|
if (m != mode)
|
||||||
{
|
{
|
||||||
RouteCheck.RouteKind.TUNNEL -> R.string.route_verdict_tunnel
|
mode = m
|
||||||
RouteCheck.RouteKind.DIRECT_RU -> R.string.route_verdict_direct
|
query = ""
|
||||||
RouteCheck.RouteKind.TUNNEL_DNS -> R.string.route_verdict_dns
|
result = null
|
||||||
},
|
}
|
||||||
)
|
},
|
||||||
val direct = route.kind == RouteCheck.RouteKind.DIRECT_RU
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
|
||||||
|
{
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier.weight(1f).height(52.dp).clip(shape).background(c.card).border(2.dp, c.accent, shape).padding(horizontal = 14.dp),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Text(
|
Icon(
|
||||||
text = route.ip,
|
when (mode)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
{
|
||||||
fontFamily = FontFamily.Monospace,
|
RouteCheckMode.SITE -> ShxIcons.Globe
|
||||||
modifier = Modifier.weight(1f),
|
RouteCheckMode.IP -> ShxIcons.Route
|
||||||
|
RouteCheckMode.APP -> ShxIcons.Apps
|
||||||
|
},
|
||||||
|
contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp),
|
||||||
)
|
)
|
||||||
ShxStatusPill(
|
Box(Modifier.weight(1f))
|
||||||
text = label,
|
{
|
||||||
containerColor = if (direct) status.connectingContainer else status.connectedContainer,
|
if (query.isEmpty())
|
||||||
contentColor = if (direct) status.onConnectingContainer else status.onConnectedContainer,
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
when (mode)
|
||||||
|
{
|
||||||
|
RouteCheckMode.SITE -> R.string.route_check_field
|
||||||
|
RouteCheckMode.IP -> R.string.route_check_field_ip
|
||||||
|
RouteCheckMode.APP -> R.string.route_check_field_app
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style = ShxType.body.copy(fontSize = 16.sp),
|
||||||
|
color = c.textSub,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
BasicTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = {
|
||||||
|
query = it
|
||||||
|
if (mode == RouteCheckMode.APP)
|
||||||
|
{
|
||||||
|
result = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Bold, color = c.text),
|
||||||
|
cursorBrush = SolidColor(c.accent),
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = when (mode)
|
||||||
|
{
|
||||||
|
RouteCheckMode.SITE -> KeyboardType.Uri
|
||||||
|
RouteCheckMode.IP -> KeyboardType.Ascii
|
||||||
|
RouteCheckMode.APP -> KeyboardType.Text
|
||||||
|
},
|
||||||
|
imeAction = ImeAction.Search,
|
||||||
|
),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { run() }),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mode != RouteCheckMode.APP)
|
||||||
|
{
|
||||||
|
ShxButton(
|
||||||
|
stringResource(if (checking) R.string.route_check_checking else R.string.route_check_button),
|
||||||
|
::run, height = 52.dp, enabled = query.isNotBlank() && !checking,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (report.routes.isEmpty())
|
if (recent.isNotEmpty() && result == null && query.isEmpty())
|
||||||
|
{
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
recent.forEach { item ->
|
||||||
|
val shape = RoundedCornerShape(10.dp)
|
||||||
|
Text(
|
||||||
|
item,
|
||||||
|
style = ShxType.meta,
|
||||||
|
color = c.textSoft,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(shape)
|
||||||
|
.border(1.dp, c.outline, shape)
|
||||||
|
.clickable(role = Role.Button) {
|
||||||
|
query = item
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mode == RouteCheckMode.APP && result == null && query.isNotBlank())
|
||||||
|
{
|
||||||
|
AppSuggestions(apps, query) { app ->
|
||||||
|
query = app.label
|
||||||
|
result = CheckResult.App(app)
|
||||||
|
rememberQuery(app.label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
when (val r = result)
|
||||||
|
{
|
||||||
|
is CheckResult.Site -> SiteResult(r, rulesActive, onAlways = { direct ->
|
||||||
|
manager.addSiteRule(r.report.domain, direct)
|
||||||
|
rerun(1500)
|
||||||
|
})
|
||||||
|
is CheckResult.Ip -> IpResult(r, rulesActive, onAlways = { direct ->
|
||||||
|
if (direct) manager.addDirectPrefix(r.ip) else manager.addForcePrefix(r.ip)
|
||||||
|
rerun(300)
|
||||||
|
})
|
||||||
|
is CheckResult.App -> AppResult(r.app, manager)
|
||||||
|
CheckResult.BadInput -> ShxNote(
|
||||||
|
stringResource(
|
||||||
|
when (mode)
|
||||||
|
{
|
||||||
|
RouteCheckMode.IP -> R.string.route_check_bad_ip
|
||||||
|
RouteCheckMode.APP -> R.string.route_check_bad_app
|
||||||
|
RouteCheckMode.SITE -> R.string.route_check_resolve_failed
|
||||||
|
}
|
||||||
|
),
|
||||||
|
tone = ShxTone.ERROR,
|
||||||
|
)
|
||||||
|
null -> if (query.isEmpty()) Text(stringResource(R.string.route_check_hint), style = ShxType.note, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ checks ------------------------------ */
|
||||||
|
|
||||||
|
private fun networks(context: Context): Pair<Network?, Network?>
|
||||||
|
{
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return null to null
|
||||||
|
var vpn: Network? = null
|
||||||
|
var direct: Network? = null
|
||||||
|
for (n in cm.allNetworks)
|
||||||
|
{
|
||||||
|
val caps = runCatching { cm.getNetworkCapabilities(n) }.getOrNull() ?: continue
|
||||||
|
if (caps.hasTransport(NetworkCapabilities.TRANSPORT_VPN))
|
||||||
|
{
|
||||||
|
vpn = n
|
||||||
|
}
|
||||||
|
else if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && direct == null)
|
||||||
|
{
|
||||||
|
direct = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vpn to direct
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TCP connect to ip:443; through [network] when given, else the app's default route. */
|
||||||
|
private fun connectMs(ip: String, network: Network?): Long? = try
|
||||||
|
{
|
||||||
|
val socket = network?.socketFactory?.createSocket() ?: Socket()
|
||||||
|
socket.use {
|
||||||
|
val start = System.nanoTime()
|
||||||
|
it.connect(InetSocketAddress(InetAddress.getByName(ip), 443), 3000)
|
||||||
|
(System.nanoTime() - start) / 1_000_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: Exception)
|
||||||
|
{
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun reach(context: Context, ip: String?): Reach = coroutineScope {
|
||||||
|
if (ip == null)
|
||||||
|
{
|
||||||
|
return@coroutineScope Reach(null, null, null)
|
||||||
|
}
|
||||||
|
val (vpn, direct) = networks(context)
|
||||||
|
val d = async(Dispatchers.IO) { connectMs(ip, null) }
|
||||||
|
val dir = async(Dispatchers.IO) { direct?.let { connectMs(ip, it) } }
|
||||||
|
val v = async(Dispatchers.IO) { vpn?.let { connectMs(ip, it) } }
|
||||||
|
Reach(d.await(), dir.await(), v.await())
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkSite(context: Context, manager: RoutingManager, input: String, rulesActive: Boolean): CheckResult
|
||||||
|
{
|
||||||
|
val domain = RoutingManager.normalizeDomain(input) ?: return CheckResult.BadInput
|
||||||
|
val report = RouteCheck.check(
|
||||||
|
context, domain,
|
||||||
|
CharonVpnService.sAppliedGeoExclusionsV4,
|
||||||
|
CharonVpnService.sAppliedGeoExclusionsV6,
|
||||||
|
CharonVpnService.sDnsPunchIps,
|
||||||
|
)
|
||||||
|
val force = RouteCheck.ranges(manager.forceTunnelCidrs())
|
||||||
|
val direct = RouteCheck.ranges(manager.directCidrs())
|
||||||
|
val verdicts = withContext(Dispatchers.Default) {
|
||||||
|
report.routes.associate { route ->
|
||||||
|
route.ip to RouteCheck.verdict(
|
||||||
|
InetAddress.getByName(route.ip),
|
||||||
|
CharonVpnService.sAppliedGeoExclusionsV4, CharonVpnService.sAppliedGeoExclusionsV6,
|
||||||
|
CharonVpnService.sDnsPunchIps, force, direct, rulesActive,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CheckResult.Site(report, verdicts, reach(context, report.vpnDnsIps.firstOrNull() ?: report.routes.firstOrNull()?.ip))
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkIp(context: Context, manager: RoutingManager, input: String, rulesActive: Boolean): CheckResult
|
||||||
|
{
|
||||||
|
val ip = input.substringBefore('/').trim()
|
||||||
|
/* only literal addresses: getByName would otherwise resolve a host name */
|
||||||
|
if (!ip.matches(Regex("[0-9.]+")) && !ip.contains(':'))
|
||||||
|
{
|
||||||
|
return CheckResult.BadInput
|
||||||
|
}
|
||||||
|
val address = withContext(Dispatchers.IO) { runCatching { InetAddress.getByName(ip) }.getOrNull() } ?: return CheckResult.BadInput
|
||||||
|
val verdict = RouteCheck.verdict(
|
||||||
|
address,
|
||||||
|
CharonVpnService.sAppliedGeoExclusionsV4, CharonVpnService.sAppliedGeoExclusionsV6,
|
||||||
|
CharonVpnService.sDnsPunchIps,
|
||||||
|
RouteCheck.ranges(manager.forceTunnelCidrs()), RouteCheck.ranges(manager.directCidrs()), rulesActive,
|
||||||
|
)
|
||||||
|
val host = address.hostAddress ?: ip
|
||||||
|
return CheckResult.Ip(host, verdict, reach(context, host))
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------ results ------------------------------ */
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun reasonText(reason: RouteCheck.Reason): String = stringResource(
|
||||||
|
when (reason)
|
||||||
|
{
|
||||||
|
RouteCheck.Reason.DNS -> R.string.route_reason_dns
|
||||||
|
RouteCheck.Reason.RULE_TUNNEL -> R.string.route_reason_rule_vpn
|
||||||
|
RouteCheck.Reason.RULE_DIRECT -> R.string.route_reason_rule_direct
|
||||||
|
RouteCheck.Reason.RU_LIST -> R.string.route_reason_ru
|
||||||
|
RouteCheck.Reason.DEFAULT -> R.string.route_reason_default
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The verdict card of the mockup: label, big route, reachability pill, path diagram, reason. */
|
||||||
|
@Composable
|
||||||
|
private fun VerdictCard(direct: Boolean, mixed: Boolean, subject: String, reason: String, reach: Long?, checkedReach: Boolean)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val bg = if (direct) c.successContainer else c.accentContainer
|
||||||
|
val strong = if (direct) c.onSuccessStrong else c.onAccentContainerStrong
|
||||||
|
val soft = if (direct) c.onSuccess else c.onAccentContainer
|
||||||
|
val line = if (direct) c.success else c.accent
|
||||||
|
val country = remember { VpnStateService.getLiveInstance()?.profile?.name?.take(12) }
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(bg).padding(16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.route_check_result).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = soft)
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
when
|
||||||
|
{
|
||||||
|
mixed -> R.string.route_check_verdict_mixed
|
||||||
|
direct -> R.string.route_check_verdict_direct
|
||||||
|
else -> R.string.route_check_verdict_vpn
|
||||||
|
}
|
||||||
|
),
|
||||||
|
style = ShxType.pageTitle,
|
||||||
|
color = strong,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (checkedReach)
|
||||||
{
|
{
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.route_check_resolve_failed),
|
if (reach != null) stringResource(R.string.route_check_opens_ms, reach) else stringResource(R.string.route_check_not_opens),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold),
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = if (reach != null) soft else c.onErrorContainer,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(c.card).padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
PathNode(stringResource(R.string.route_check_node_phone), filled = false, color = line)
|
||||||
|
Box(Modifier.weight(1f).height(3.dp).clip(RoundedCornerShape(2.dp)).background(line))
|
||||||
|
PathNode(
|
||||||
|
if (direct) stringResource(R.string.route_check_node_provider)
|
||||||
|
else listOfNotNull(stringResource(R.string.route_check_node_vpn), country).joinToString(" · "),
|
||||||
|
filled = true, color = line,
|
||||||
|
)
|
||||||
|
Box(Modifier.weight(1f).height(3.dp).clip(RoundedCornerShape(2.dp)).background(line))
|
||||||
|
PathNode(subject, filled = false, color = line)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.route_check_why)) }
|
||||||
|
append(' ')
|
||||||
|
append(reason)
|
||||||
|
},
|
||||||
|
style = ShxType.note,
|
||||||
|
color = soft,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PathNode(text: String, filled: Boolean, color: Color)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold),
|
||||||
|
color = if (filled) Color.White else c.text,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(12.dp)).background(if (filled) color else c.card).padding(horizontal = 10.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Direct path for comparison": TCP 443 directly and through the tunnel. */
|
||||||
|
@Composable
|
||||||
|
private fun CompareCard(reach: Reach)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
ShxSectionHeader(stringResource(R.string.route_check_compare))
|
||||||
|
ShxPanel(padding = PaddingValues(horizontal = 16.dp, vertical = 4.dp))
|
||||||
|
{
|
||||||
|
val rows = listOf(
|
||||||
|
stringResource(R.string.route_check_verdict_direct) to reach.direct,
|
||||||
|
stringResource(R.string.route_check_verdict_vpn) to reach.vpn,
|
||||||
|
)
|
||||||
|
rows.forEachIndexed { i, (title, ms) ->
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().height(52.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.body, color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
if (ms != null) stringResource(R.string.diag_ms, ms) else stringResource(R.string.route_check_not_opens),
|
||||||
|
style = ShxType.sub.copy(fontWeight = FontWeight.ExtraBold),
|
||||||
|
color = if (ms != null) c.onSuccess else c.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (i < rows.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RouteCheckRow(label: String, ips: List<String>)
|
private fun SiteResult(result: CheckResult.Site, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
|
||||||
{
|
{
|
||||||
Text(
|
val c = LocalShxColors.current
|
||||||
text = label,
|
val report = result.report
|
||||||
style = MaterialTheme.typography.titleSmall,
|
if (report.routes.isEmpty())
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
{
|
||||||
|
ShxNote(stringResource(R.string.route_check_resolve_failed), tone = ShxTone.ERROR)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val verdicts = result.verdicts.values
|
||||||
|
val anyDirect = verdicts.any { it.direct }
|
||||||
|
val allDirect = verdicts.all { it.direct }
|
||||||
|
val main = verdicts.firstOrNull { it.reason == RouteCheck.Reason.RULE_TUNNEL || it.reason == RouteCheck.Reason.RULE_DIRECT } ?: verdicts.first()
|
||||||
|
VerdictCard(allDirect, anyDirect && !allDirect, report.domain, reasonText(main.reason), result.reach.viaDefault, checkedReach = true)
|
||||||
|
if (!report.vpnNetworkFound)
|
||||||
|
{
|
||||||
|
ShxNote(stringResource(R.string.route_check_no_vpn), tone = ShxTone.WARNING)
|
||||||
|
}
|
||||||
|
else if (result.reach.direct != null || result.reach.vpn != null)
|
||||||
|
{
|
||||||
|
CompareCard(result.reach)
|
||||||
|
}
|
||||||
|
ShxSectionHeader(stringResource(R.string.route_check_dns_section))
|
||||||
|
ShxPanel(padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
DnsBlock(stringResource(R.string.route_check_via_vpn_dns), report.vpnDnsIps)
|
||||||
|
DnsBlock(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
|
||||||
|
if (report.vpnDnsIps.isNotEmpty() && report.directDnsIps.isNotEmpty() && report.vpnDnsIps.toSet() != report.directDnsIps.toSet())
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.route_check_dns_differ_title)) }
|
||||||
|
append(' ')
|
||||||
|
append(stringResource(R.string.route_check_dns_differ))
|
||||||
|
},
|
||||||
|
style = ShxType.note,
|
||||||
|
color = c.onWarningContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)).background(c.warningContainer).padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxSectionHeader(stringResource(R.string.route_check_addresses, report.routes.size))
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
report.routes.forEachIndexed { i, route ->
|
||||||
|
val verdict = result.verdicts[route.ip] ?: return@forEachIndexed
|
||||||
|
AddressRow(route.ip, verdict)
|
||||||
|
if (i < report.routes.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ChangeButtons(report.domain, rulesActive, onAlways)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun IpResult(result: CheckResult.Ip, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
|
||||||
|
{
|
||||||
|
VerdictCard(result.verdict.direct, false, result.ip, reasonText(result.verdict.reason), result.reach.viaDefault, checkedReach = true)
|
||||||
|
if (result.reach.direct != null || result.reach.vpn != null)
|
||||||
|
{
|
||||||
|
CompareCard(result.reach)
|
||||||
|
}
|
||||||
|
ChangeButtons(result.ip, rulesActive, onAlways)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppResult(app: InstalledApp, manager: RoutingManager)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val state by manager.state.collectAsStateWithLifecycle()
|
||||||
|
val config = state.config
|
||||||
|
val excluded by produceState(emptySet<String>(), config) {
|
||||||
|
value = withContext(Dispatchers.IO) { manager.computeExcludedPackages() }
|
||||||
|
}
|
||||||
|
val direct = config.enabled && app.packageName in excluded
|
||||||
|
val match = RoutingManager.templateMatch(app.packageName, config)
|
||||||
|
val reason = when
|
||||||
|
{
|
||||||
|
!config.enabled -> stringResource(R.string.route_reason_app_routing_off)
|
||||||
|
app.packageName in config.tunnelOverrides -> stringResource(R.string.route_reason_app_override)
|
||||||
|
app.packageName in config.directPackages -> stringResource(R.string.route_reason_app_added)
|
||||||
|
match != null -> stringResource(R.string.route_reason_app_template, templateLabel(match))
|
||||||
|
else -> stringResource(R.string.route_reason_app_default)
|
||||||
|
}
|
||||||
|
VerdictCard(direct, false, app.label, reason, null, checkedReach = false)
|
||||||
|
ShxNote(stringResource(R.string.route_check_app_note), tone = ShxTone.NEUTRAL)
|
||||||
|
Text(stringResource(R.string.route_check_change_for, app.label), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
ChangeButton(stringResource(R.string.route_check_always_direct), c.successContainer, c.onSuccess, Modifier.weight(1f)) { manager.setAppDirect(app.packageName, true) }
|
||||||
|
ChangeButton(stringResource(R.string.route_check_always_vpn), c.accentMuted, c.textTonal, Modifier.weight(1f)) { manager.setAppDirect(app.packageName, false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChangeButtons(subject: String, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.route_check_change_for, subject), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
ChangeButton(stringResource(R.string.route_check_always_direct), c.successContainer, c.onSuccess, Modifier.weight(1f)) { onAlways(true) }
|
||||||
|
ChangeButton(stringResource(R.string.route_check_always_vpn), c.accentMuted, c.textTonal, Modifier.weight(1f)) { onAlways(false) }
|
||||||
|
}
|
||||||
|
if (!rulesActive)
|
||||||
|
{
|
||||||
|
ShxNote(stringResource(R.string.route_rules_need_flexible), tone = ShxTone.WARNING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChangeButton(text: String, bg: Color, fg: Color, modifier: Modifier, onClick: () -> Unit)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = modifier.height(52.dp).clip(RoundedCornerShape(16.dp)).background(bg).clickable(role = Role.Button, onClick = onClick),
|
||||||
)
|
)
|
||||||
Text(
|
{
|
||||||
text = ips.joinToString(", ").ifEmpty { stringResource(R.string.route_check_none) },
|
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = fg)
|
||||||
style = MaterialTheme.typography.bodySmall,
|
}
|
||||||
fontFamily = FontFamily.Monospace,
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddressRow(ip: String, verdict: RouteCheck.Verdict)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
|
||||||
)
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(ip, style = ShxType.mono.copy(fontSize = 14.sp, fontWeight = FontWeight.Bold), color = c.text)
|
||||||
|
Text(reasonText(verdict.reason), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, maxLines = 2)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
stringResource(if (verdict.direct) R.string.route_verdict_direct else R.string.route_verdict_tunnel),
|
||||||
|
style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold),
|
||||||
|
color = if (verdict.direct) c.onSuccess else c.onAccentContainer,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(999.dp)).background(if (verdict.direct) c.successContainer else c.accentContainer).padding(horizontal = 10.dp, vertical = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DnsBlock(label: String, ips: List<String>)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
|
||||||
|
{
|
||||||
|
Text(label, style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft)
|
||||||
|
Text(ips.joinToString(" · ").ifEmpty { stringResource(R.string.route_check_none) }, style = ShxType.mono.copy(fontSize = 14.sp), color = c.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AppSuggestions(apps: List<InstalledApp>, query: String, onPick: (InstalledApp) -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val matches = remember(apps, query) {
|
||||||
|
val q = query.trim()
|
||||||
|
apps.filter { it.label.contains(q, ignoreCase = true) || it.packageName.contains(q, ignoreCase = true) }.take(6)
|
||||||
|
}
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
if (matches.isEmpty())
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.route_check_bad_app), style = ShxType.body, color = c.textSub, modifier = Modifier.padding(16.dp))
|
||||||
|
}
|
||||||
|
matches.forEachIndexed { i, app ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable { onPick(app) }.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
AppIcon(app.packageName, 36.dp)
|
||||||
|
Text(app.label, style = ShxType.rowTitle, color = c.text)
|
||||||
|
}
|
||||||
|
if (i < matches.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+162
-183
@@ -21,18 +21,29 @@ import androidx.compose.animation.fadeOut
|
|||||||
import androidx.compose.animation.slideInHorizontally
|
import androidx.compose.animation.slideInHorizontally
|
||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.rounded.Apps
|
||||||
|
import androidx.compose.material.icons.rounded.CloudOff
|
||||||
import androidx.compose.material.icons.rounded.Route
|
import androidx.compose.material.icons.rounded.Route
|
||||||
|
import androidx.compose.material.icons.rounded.Rule
|
||||||
import androidx.compose.material.icons.rounded.Shield
|
import androidx.compose.material.icons.rounded.Shield
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
@@ -40,11 +51,11 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHostState
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.produceState
|
import androidx.compose.runtime.produceState
|
||||||
@@ -54,7 +65,9 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.semantics.heading
|
import androidx.compose.ui.semantics.heading
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
@@ -69,28 +82,42 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.strongswan.android.logic.routing.AppTraffic
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxRow
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxScreenTitle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTileButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import org.strongswan.android.ui.compose.routing.ExceptionsPanel
|
||||||
|
import org.strongswan.android.ui.compose.routing.RecentAppsCard
|
||||||
|
import org.strongswan.android.ui.compose.routing.RecentPreview
|
||||||
|
import org.strongswan.android.ui.compose.routing.RuSitesPanel
|
||||||
|
import org.strongswan.android.ui.compose.routing.SplitTunnelHero
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
import org.strongswan.android.logic.CharonVpnService
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
import org.strongswan.android.logic.routing.RoutingManager
|
import org.strongswan.android.logic.routing.RoutingManager
|
||||||
import org.strongswan.android.logic.routing.RuIpListManager
|
import org.strongswan.android.logic.routing.RuIpListManager
|
||||||
import org.strongswan.android.ui.RouteCheckActivity
|
import org.strongswan.android.ui.RouteCheckActivity
|
||||||
import org.strongswan.android.ui.compose.components.PredictiveBackScale
|
import org.strongswan.android.ui.compose.components.PredictiveBackScale
|
||||||
|
import org.strongswan.android.ui.compose.components.ShxCard
|
||||||
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
|
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
|
||||||
import org.strongswan.android.ui.compose.routing.AdvancedPage
|
|
||||||
import org.strongswan.android.ui.compose.routing.AdvancedSection
|
|
||||||
import org.strongswan.android.ui.compose.routing.DeadHostsPage
|
import org.strongswan.android.ui.compose.routing.DeadHostsPage
|
||||||
import org.strongswan.android.ui.compose.routing.DirectAppsCard
|
|
||||||
import org.strongswan.android.ui.compose.routing.DirectAppsScreen
|
import org.strongswan.android.ui.compose.routing.DirectAppsScreen
|
||||||
import org.strongswan.android.ui.compose.routing.ForcePrefixDialog
|
import org.strongswan.android.ui.compose.routing.RecentAppsPage
|
||||||
import org.strongswan.android.ui.compose.routing.ForcePrefixPage
|
import org.strongswan.android.ui.compose.routing.RuleRow
|
||||||
import org.strongswan.android.ui.compose.routing.PatternDialog
|
import org.strongswan.android.ui.compose.routing.RulesPage
|
||||||
import org.strongswan.android.ui.compose.routing.PatternsPage
|
import org.strongswan.android.ui.compose.routing.buildRuleRows
|
||||||
import org.strongswan.android.ui.compose.routing.RoutingHeroCard
|
|
||||||
import org.strongswan.android.ui.compose.routing.RuSitesCard
|
|
||||||
import org.strongswan.android.ui.compose.routing.routingSummary
|
import org.strongswan.android.ui.compose.routing.routingSummary
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||||
|
|
||||||
/** In-tab pages of the Routing tab; "back" returns to the main page, not out of the tab. */
|
/** In-tab pages of the Routing tab; "back" returns to the main page, not out of the tab. */
|
||||||
private enum class RoutingPage { MAIN, APPS, FORCE, PATTERNS, DEAD }
|
private enum class RoutingPage { MAIN, APPS, RULES, RECENT, DEAD }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RoutingScreen()
|
fun RoutingScreen()
|
||||||
@@ -102,13 +129,13 @@ fun RoutingScreen()
|
|||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
|
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = LocalShxColors.current.background,
|
||||||
) { _ ->
|
) { _ ->
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter)
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter)
|
||||||
{
|
{
|
||||||
PredictiveBackScale(
|
PredictiveBackScale(
|
||||||
enabled = page != RoutingPage.MAIN,
|
enabled = page != RoutingPage.MAIN,
|
||||||
onBack = { page = RoutingPage.MAIN },
|
onBack = { page = if (page == RoutingPage.DEAD) RoutingPage.RECENT else RoutingPage.MAIN },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 640.dp)
|
.widthIn(max = 640.dp)
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
@@ -130,7 +157,6 @@ fun RoutingScreen()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RoutingPageContent(
|
private fun RoutingPageContent(
|
||||||
page: RoutingPage,
|
page: RoutingPage,
|
||||||
@@ -147,10 +173,6 @@ private fun RoutingPageContent(
|
|||||||
val config = state.config
|
val config = state.config
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
/* force-prefix dialog: null = hidden, "" = add, otherwise the entry id */
|
|
||||||
var forceDialogId by remember { mutableStateOf<String?>(null) }
|
|
||||||
var patternDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
/* real packages that bypass the tunnel: categories and templates expanded
|
/* real packages that bypass the tunnel: categories and templates expanded
|
||||||
* against installed apps (cached per config version inside the manager) */
|
* against installed apps (cached per config version inside the manager) */
|
||||||
val directPackages by produceState(initialValue = emptyList<String>(), config) {
|
val directPackages by produceState(initialValue = emptyList<String>(), config) {
|
||||||
@@ -164,8 +186,8 @@ private fun RoutingPageContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* dead direct destinations the engine keeps refusing; polled only while
|
/* dead direct destinations the engine keeps refusing and the split of the
|
||||||
* the screen is visible (not while the activity is stopped) */
|
* session traffic; polled only while the screen is visible */
|
||||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>(), lifecycle)
|
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>(), lifecycle)
|
||||||
{
|
{
|
||||||
@@ -186,6 +208,43 @@ private fun RoutingPageContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val split by produceState<Pair<Long, Long>?>(initialValue = null, lifecycle)
|
||||||
|
{
|
||||||
|
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
val stats = try
|
||||||
|
{
|
||||||
|
withContext(Dispatchers.Default) {
|
||||||
|
if (CharonVpnService.isUserspaceRoutingEnabled(context)) CharonVpnService.getRoutingStats() else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (_: Throwable)
|
||||||
|
{
|
||||||
|
null
|
||||||
|
}
|
||||||
|
value = if (stats != null && stats.size >= 4 && stats[1] + stats[3] > 0) stats[1] to stats[3] else null
|
||||||
|
delay(5_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the four busiest apps of the last hour for the «Recent apps» preview */
|
||||||
|
val recent by produceState(initialValue = emptyList<RecentPreview>(), directPackages, deadTargets)
|
||||||
|
{
|
||||||
|
value = withContext(Dispatchers.IO) {
|
||||||
|
val pm = context.packageManager
|
||||||
|
val failingOwners = deadTargets.filter { !it.viaTunnel }.mapNotNull { it.owner }.toSet()
|
||||||
|
AppTraffic.since(context, System.currentTimeMillis() - 60L * 60 * 1000).entries
|
||||||
|
.sortedByDescending { it.value }
|
||||||
|
.mapNotNull { (pkg, _) ->
|
||||||
|
val label = runCatching { pm.getApplicationInfo(pkg, 0).loadLabel(pm).toString() }.getOrNull() ?: return@mapNotNull null
|
||||||
|
RecentPreview(pkg, label, pkg in directPackages, pkg in failingOwners)
|
||||||
|
}
|
||||||
|
.take(4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val refreshGeoip = {
|
val refreshGeoip = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -202,209 +261,129 @@ private fun RoutingPageContent(
|
|||||||
snackbarHostState.showSnackbar(message)
|
snackbarHostState.showSnackbar(message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val rules = remember(config) { buildRuleRows(config, emptyMap()) }
|
||||||
|
/* the hero says what bypasses the VPN: only the working "direct" rules count there */
|
||||||
|
val directRuleCount = rules.count { it.direct && it.enabled }
|
||||||
|
|
||||||
when (page)
|
when (page)
|
||||||
{
|
{
|
||||||
RoutingPage.APPS -> DirectAppsScreen(onBack = onBack)
|
RoutingPage.APPS -> DirectAppsScreen(onBack = onBack)
|
||||||
RoutingPage.FORCE -> ForcePrefixPage(
|
RoutingPage.RULES -> RulesPage(onBack = onBack, snackbarHostState = snackbarHostState)
|
||||||
prefixes = config.forceTunnelPrefixes,
|
RoutingPage.RECENT -> RecentAppsPage(
|
||||||
onToggle = manager::setForcePrefixEnabled,
|
deadTargets = deadTargets,
|
||||||
onEdit = { forceDialogId = it },
|
onForceTunnel = { dst -> forceTunnel(manager, dst) },
|
||||||
onDelete = manager::deleteForcePrefix,
|
onOpenDead = { onNavigate(RoutingPage.DEAD) },
|
||||||
onAdd = { forceDialogId = "" },
|
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
)
|
)
|
||||||
RoutingPage.PATTERNS -> PatternsPage(
|
RoutingPage.DEAD -> DeadHostsPage(
|
||||||
patterns = config.packagePatterns.sorted(),
|
targets = deadTargets,
|
||||||
onDelete = manager::removePattern,
|
onForceTunnel = { dst -> forceTunnel(manager, dst) },
|
||||||
onAdd = { patternDialog = true },
|
onBack = { onNavigate(RoutingPage.RECENT) },
|
||||||
onBack = onBack,
|
|
||||||
)
|
)
|
||||||
RoutingPage.DEAD -> DeadHostsPage(targets = deadTargets, onBack = onBack)
|
|
||||||
RoutingPage.MAIN -> MainPage(
|
RoutingPage.MAIN -> MainPage(
|
||||||
enabled = config.enabled,
|
enabled = config.enabled,
|
||||||
ruEnabled = ruState.enabled,
|
|
||||||
ruState = ruState,
|
ruState = ruState,
|
||||||
directPackages = directPackages,
|
directPackages = directPackages,
|
||||||
forceCount = config.forceTunnelPrefixes.size,
|
rules = rules,
|
||||||
patternCount = config.packagePatterns.size,
|
directRuleCount = directRuleCount,
|
||||||
deadCount = deadTargets.size,
|
deadCount = deadTargets.count { !it.viaTunnel },
|
||||||
|
split = split,
|
||||||
|
recent = recent,
|
||||||
onMasterToggle = manager::setGlobalEnabled,
|
onMasterToggle = manager::setGlobalEnabled,
|
||||||
onRuToggle = ruList::setEnabled,
|
onRuToggle = ruList::setEnabled,
|
||||||
onRuMode = ruList::setMode,
|
onRuMode = ruList::setMode,
|
||||||
onRefresh = { refreshGeoip() },
|
onRefresh = { refreshGeoip() },
|
||||||
onOpenApps = { onNavigate(RoutingPage.APPS) },
|
onOpenApps = { onNavigate(RoutingPage.APPS) },
|
||||||
onAddAllRf = manager::enableAllRfApps,
|
onOpen = onNavigate,
|
||||||
onOpenAdvanced = {
|
|
||||||
onNavigate(
|
|
||||||
when (it)
|
|
||||||
{
|
|
||||||
AdvancedPage.FORCE -> RoutingPage.FORCE
|
|
||||||
AdvancedPage.PATTERNS -> RoutingPage.PATTERNS
|
|
||||||
AdvancedPage.DEAD -> RoutingPage.DEAD
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- dialogs, hosted outside the scrolling pages ---- */
|
|
||||||
if (forceDialogId != null)
|
|
||||||
{
|
|
||||||
val initial = if (forceDialogId == "")
|
|
||||||
{
|
|
||||||
""
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
config.forceTunnelPrefixes.firstOrNull { it.id == forceDialogId }?.prefix ?: ""
|
|
||||||
}
|
|
||||||
ForcePrefixDialog(
|
|
||||||
initial = initial,
|
|
||||||
onConfirm = { cidr ->
|
|
||||||
if (forceDialogId == "")
|
|
||||||
{
|
|
||||||
manager.addForcePrefix(cidr) != null
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
manager.updateForcePrefix(forceDialogId!!, cidr)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDismiss = { forceDialogId = null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (patternDialog)
|
|
||||||
{
|
|
||||||
PatternDialog(onConfirm = { manager.addPattern(it) }, onDismiss = { patternDialog = false })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
/** "ip:port" of a dead destination -> a /32 (or /128) "always through the VPN" rule. */
|
||||||
|
private fun forceTunnel(manager: RoutingManager, dst: String)
|
||||||
|
{
|
||||||
|
val ip = dst.substringBeforeLast(':').removeSurrounding("[", "]")
|
||||||
|
manager.addForcePrefix(ip + if (ip.contains(':')) "/128" else "/32")
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MainPage(
|
private fun MainPage(
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
ruEnabled: Boolean,
|
|
||||||
ruState: org.strongswan.android.logic.routing.RuIpListState,
|
ruState: org.strongswan.android.logic.routing.RuIpListState,
|
||||||
directPackages: List<String>,
|
directPackages: List<String>,
|
||||||
forceCount: Int,
|
rules: List<RuleRow>,
|
||||||
patternCount: Int,
|
directRuleCount: Int,
|
||||||
deadCount: Int,
|
deadCount: Int,
|
||||||
|
split: Pair<Long, Long>?,
|
||||||
|
recent: List<RecentPreview>,
|
||||||
onMasterToggle: (Boolean) -> Unit,
|
onMasterToggle: (Boolean) -> Unit,
|
||||||
onRuToggle: (Boolean) -> Unit,
|
onRuToggle: (Boolean) -> Unit,
|
||||||
onRuMode: (String) -> Unit,
|
onRuMode: (String) -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
onOpenApps: () -> Unit,
|
onOpenApps: () -> Unit,
|
||||||
onAddAllRf: () -> Unit,
|
onOpen: (RoutingPage) -> Unit,
|
||||||
onOpenAdvanced: (AdvancedPage) -> Unit,
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val openCheck = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) }
|
||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.statusBarsPadding()
|
||||||
.padding(bottom = ShxSpacing.xl),
|
.verticalScroll(rememberScrollState()),
|
||||||
verticalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
TopAppBar(
|
ShxScreenTitle(stringResource(R.string.routing_title))
|
||||||
title = { Text(stringResource(R.string.routing_title), fontWeight = FontWeight.SemiBold) },
|
Column(
|
||||||
actions = {
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
IconButton(onClick = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) })
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 4.dp, bottom = 24.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
SplitTunnelHero(
|
||||||
|
enabled = enabled,
|
||||||
|
summary = routingSummary(enabled, ruState.enabled, directPackages.size, directRuleCount),
|
||||||
|
split = split,
|
||||||
|
onToggle = onMasterToggle,
|
||||||
|
)
|
||||||
|
|
||||||
|
ShxSectionHeader(stringResource(R.string.routing_section_ru))
|
||||||
|
RuSitesPanel(state = ruState, onToggle = onRuToggle, onModeChange = onRuMode, onRefresh = onRefresh)
|
||||||
|
|
||||||
|
ShxSectionHeader(stringResource(R.string.routing_section_exceptions))
|
||||||
|
ExceptionsPanel(
|
||||||
|
directPackages = directPackages,
|
||||||
|
rules = rules,
|
||||||
|
onApps = onOpenApps,
|
||||||
|
onRules = { onOpen(RoutingPage.RULES) },
|
||||||
|
)
|
||||||
|
/* kill switch + direct apps: the direct apps are added to the VPN's disallowed list
|
||||||
|
* (CharonVpnService.addDisallowedApplication), so the system lockdown does not cover
|
||||||
|
* them and they keep using the normal network when the tunnel drops. The RU-sites part
|
||||||
|
* is routed by IP *inside* the VPN's scope (the RU ranges are simply left out of its
|
||||||
|
* routes), so under lockdown it is blocked together with everything else. */
|
||||||
|
if (enabled && directPackages.isNotEmpty() && remember { context.isVpnLockdownEnabled() })
|
||||||
|
{
|
||||||
|
ShxNote(stringResource(R.string.routing_killswitch_note), tone = ShxTone.NEUTRAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
ShxSectionHeader(stringResource(R.string.routing_section_check))
|
||||||
|
ShxTileButton(stringResource(R.string.routing_check_row), ShxIcons.Search, openCheck)
|
||||||
|
RecentAppsCard(apps = recent, failing = deadCount, onClick = { onOpen(RoutingPage.RECENT) })
|
||||||
|
if (deadCount > 0)
|
||||||
|
{
|
||||||
|
ShxPanel()
|
||||||
{
|
{
|
||||||
Icon(
|
ShxRow(
|
||||||
Icons.Rounded.Route,
|
title = stringResource(R.string.dead_targets_title),
|
||||||
contentDescription = stringResource(R.string.route_check_title),
|
subtitle = stringResource(R.string.routing_adv_dead_sub),
|
||||||
|
icon = ShxIcons.Alert,
|
||||||
|
iconBackground = LocalShxColors.current.warningStrong,
|
||||||
|
iconTint = LocalShxColors.current.onWarning,
|
||||||
|
chevron = true,
|
||||||
|
onClick = { onOpen(RoutingPage.DEAD) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ---- hero: master switch + one-line summary ---- */
|
|
||||||
RoutingHeroCard(
|
|
||||||
enabled = enabled,
|
|
||||||
summary = routingSummary(enabled, ruEnabled, directPackages.size),
|
|
||||||
onToggle = onMasterToggle,
|
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ---- Russian sites ---- */
|
|
||||||
SectionTitle(stringResource(R.string.routing_section_ru))
|
|
||||||
RuSitesCard(
|
|
||||||
state = ruState,
|
|
||||||
onToggle = onRuToggle,
|
|
||||||
onModeChange = onRuMode,
|
|
||||||
onRefresh = onRefresh,
|
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
|
|
||||||
)
|
|
||||||
|
|
||||||
/* ---- apps ---- */
|
|
||||||
SectionTitle(stringResource(R.string.routing_section_apps))
|
|
||||||
DirectAppsCard(
|
|
||||||
count = directPackages.size,
|
|
||||||
previewPackages = directPackages,
|
|
||||||
onOpen = onOpenApps,
|
|
||||||
onAddAllRf = onAddAllRf,
|
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
|
|
||||||
)
|
|
||||||
|
|
||||||
/* kill switch + direct apps: the direct apps are added to the VPN's disallowed list
|
|
||||||
* (CharonVpnService.addDisallowedApplication), so the system lockdown does not cover
|
|
||||||
* them and they keep using the normal network when the tunnel drops. The RU-sites part
|
|
||||||
* is routed by IP *inside* the VPN's scope (the RU ranges are simply left out of its
|
|
||||||
* routes), so under lockdown it is blocked together with everything else. */
|
|
||||||
if (enabled && directPackages.isNotEmpty() && remember { context.isVpnLockdownEnabled() })
|
|
||||||
{
|
|
||||||
KillSwitchNote()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AdvancedSection(
|
|
||||||
forceCount = forceCount,
|
|
||||||
patternCount = patternCount,
|
|
||||||
deadCount = deadCount,
|
|
||||||
onOpen = onOpenAdvanced,
|
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SectionTitle(text: String)
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
text = text.uppercase(),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = ShxSpacing.lg + ShxSpacing.lg, top = ShxSpacing.md)
|
|
||||||
.semantics { heading() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun KillSwitchNote()
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(horizontal = ShxSpacing.lg)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.Shield,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.routing_killswitch_note),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+9
-12
@@ -24,6 +24,10 @@ import androidx.compose.material3.TopAppBarDefaults
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
||||||
@@ -34,24 +38,17 @@ fun SecondaryScaffold(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
actions: @Composable RowScope.() -> Unit = {},
|
actions: @Composable RowScope.() -> Unit = {},
|
||||||
snackbarHost: @Composable () -> Unit = {},
|
snackbarHost: @Composable () -> Unit = {},
|
||||||
|
floatingActionButton: @Composable () -> Unit = {},
|
||||||
content: @Composable (PaddingValues) -> Unit,
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
/* header of the mockup: 44dp back button + 22/800 title, no tinted bar */
|
||||||
Scaffold(
|
Scaffold(
|
||||||
snackbarHost = snackbarHost,
|
snackbarHost = snackbarHost,
|
||||||
|
floatingActionButton = floatingActionButton,
|
||||||
|
containerColor = LocalShxColors.current.background,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
ShxPageHeader(title = title, onBack = onBack, modifier = Modifier.statusBarsPadding(), actions = actions)
|
||||||
title = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, fontWeight = FontWeight.SemiBold) },
|
|
||||||
navigationIcon = {
|
|
||||||
IconButton(onClick = onBack) {
|
|
||||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = actions,
|
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|||||||
+643
@@ -0,0 +1,643 @@
|
|||||||
|
/*
|
||||||
|
* 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.compose
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.Settings
|
||||||
|
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.fillMaxHeight
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.selection.selectable
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.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.rotate
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
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.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.strongswan.android.R
|
||||||
|
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.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSwitch
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||||
|
import org.strongswan.android.logic.events.NotifPrefs
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxThemeSettings
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import org.strongswan.android.ui.compose.theme.ThemeMode
|
||||||
|
|
||||||
|
/** «Appearance» (F_Appearance): three theme cards with a preview, wallpaper colors, language. */
|
||||||
|
@Composable
|
||||||
|
fun AppearancePage()
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val mode by remember { ShxThemeSettings.load(context); ShxThemeSettings.themeMode }.collectAsStateWithLifecycle()
|
||||||
|
val dynamic by remember { ShxThemeSettings.useDynamicColors }.collectAsStateWithLifecycle()
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
|
{
|
||||||
|
ShxSectionHeader(stringResource(R.string.appearance_theme))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
listOf(ThemeMode.SYSTEM to R.string.theme_system, ThemeMode.LIGHT to R.string.theme_light, ThemeMode.DARK to R.string.theme_dark).forEach { (m, label) ->
|
||||||
|
val on = mode == m
|
||||||
|
val shape = RoundedCornerShape(20.dp)
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.clip(shape)
|
||||||
|
.background(c.card)
|
||||||
|
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, shape)
|
||||||
|
.selectable(selected = on, role = Role.RadioButton) { ShxThemeSettings.setThemeMode(context, m) }
|
||||||
|
.padding(10.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
ThemePreview(m)
|
||||||
|
Text(stringResource(label), style = ShxType.chip, color = c.text, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxPanel(Modifier.padding(top = 4.dp))
|
||||||
|
{
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
||||||
|
{
|
||||||
|
ShxToggleRow(
|
||||||
|
stringResource(R.string.appearance_wallpaper),
|
||||||
|
dynamic,
|
||||||
|
{ ShxThemeSettings.setUseDynamicColors(context, it) },
|
||||||
|
subtitle = stringResource(R.string.appearance_wallpaper_sub),
|
||||||
|
divider = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(60.dp)
|
||||||
|
.clickable(role = Role.Button) {
|
||||||
|
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||||
|
Intent(Settings.ACTION_APP_LOCALE_SETTINGS, Uri.fromParts("package", context.packageName, null))
|
||||||
|
else Intent(Settings.ACTION_LOCALE_SETTINGS)
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
}
|
||||||
|
.padding(start = 16.dp, end = 14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.appearance_language), style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
Text(stringResource(R.string.theme_system), style = ShxType.body, color = c.textSub)
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mini screen of a theme: background, the hero blob, two text lines. */
|
||||||
|
@Composable
|
||||||
|
private fun ThemePreview(mode: ThemeMode)
|
||||||
|
{
|
||||||
|
val light = Color(0xFFFCF8FF)
|
||||||
|
val dark = Color(0xFF141318)
|
||||||
|
val bg = when (mode)
|
||||||
|
{
|
||||||
|
ThemeMode.SYSTEM -> Brush.horizontalGradient(0f to light, 0.5f to light, 0.5f to dark, 1f to dark)
|
||||||
|
ThemeMode.LIGHT -> Brush.linearGradient(listOf(light, light))
|
||||||
|
ThemeMode.DARK -> Brush.linearGradient(listOf(dark, dark))
|
||||||
|
}
|
||||||
|
val accent = if (mode == ThemeMode.DARK) Color(0xFF9C7CFF) else Color(0xFF6D4AFF)
|
||||||
|
val line = when (mode)
|
||||||
|
{
|
||||||
|
ThemeMode.SYSTEM -> Color(0xFFC9C5D0)
|
||||||
|
ThemeMode.LIGHT -> Color(0xFFE5E1E9)
|
||||||
|
ThemeMode.DARK -> Color(0xFF36333D)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(120.dp).clip(RoundedCornerShape(14.dp)).background(bg).border(1.dp, Color(0xFFE5E1E9), RoundedCornerShape(14.dp)),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(Modifier.size(34.dp).clip(RoundedCornerShape(topStartPercent = 45, topEndPercent = 55, bottomEndPercent = 50, bottomStartPercent = 50)).background(accent))
|
||||||
|
Box(Modifier.width(56.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(line))
|
||||||
|
Box(Modifier.width(40.dp).height(8.dp).clip(RoundedCornerShape(4.dp)).background(line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «No VPN, no network» (F_KillSwitch): status, three steps, note, button to the Android settings. */
|
||||||
|
@Composable
|
||||||
|
fun KillSwitchPage(lockdown: Boolean)
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp).padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||||
|
{
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape)
|
||||||
|
.background(if (lockdown) c.successContainer else c.warningContainer)
|
||||||
|
.border(1.dp, if (lockdown) c.successContainer else c.warningBorder, shape).padding(16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(if (lockdown) c.success else c.warning))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Lock, contentDescription = null, tint = Color.White, modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(if (lockdown) R.string.ks_on_title else R.string.ks_off_title),
|
||||||
|
style = ShxType.cardHeading, color = if (lockdown) c.onSuccessStrong else c.onWarningStrong,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(if (lockdown) R.string.ks_on_text else R.string.ks_off_text),
|
||||||
|
style = ShxType.note, color = if (lockdown) c.onSuccess else c.onWarningContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.ks_intro), style = ShxType.note.copy(fontSize = 15.sp, lineHeight = 22.sp), color = c.textTonal, modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
|
listOf(R.string.ks_step1, R.string.ks_step2, R.string.ks_step3).forEachIndexed { i, step ->
|
||||||
|
val s = RoundedCornerShape(20.dp)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(s).background(c.card).border(1.dp, c.cardBorder, s).padding(14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(28.dp).clip(CircleShape).background(c.accent))
|
||||||
|
{
|
||||||
|
Text("${i + 1}", style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold), color = Color.White)
|
||||||
|
}
|
||||||
|
Text(stringResource(step), style = ShxType.note.copy(fontSize = 14.sp), color = c.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.ks_note))
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.ks_open),
|
||||||
|
{ runCatching { context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) } },
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Help» (F_Help): two big buttons (check the connection, contact support),
|
||||||
|
* the frequent questions as an accordion, and «About the app».
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun HelpPage(onUpdates: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val info = remember { runCatching { context.packageManager.getPackageInfo(context.packageName, 0) }.getOrNull() }
|
||||||
|
val build = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info?.longVersionCode?.toInt() ?: 0 else @Suppress("DEPRECATION") (info?.versionCode ?: 0)
|
||||||
|
fun open(url: String) = runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
|
{
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.height(androidx.compose.foundation.layout.IntrinsicSize.Min))
|
||||||
|
{
|
||||||
|
HelpTile(ShxIcons.Pulse, stringResource(R.string.help_check), c.accent, Color.White, Modifier.weight(1f).fillMaxHeight()) {
|
||||||
|
context.startActivity(Intent(context, org.strongswan.android.ui.DiagnosticsActivity::class.java))
|
||||||
|
}
|
||||||
|
HelpTile(ShxIcons.Doc, stringResource(R.string.help_write), c.accentContainer, c.onAccentContainer, Modifier.weight(1f).fillMaxHeight()) {
|
||||||
|
context.startActivity(Intent(context, org.strongswan.android.ui.LogActivity::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxSectionHeader(stringResource(R.string.help_faq).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
|
||||||
|
val faq = listOf(
|
||||||
|
R.string.faq_q1 to R.string.faq_a1, R.string.faq_q2 to R.string.faq_a2, R.string.faq_q_bridge to R.string.faq_a_bridge, R.string.faq_q3 to R.string.faq_a3,
|
||||||
|
R.string.faq_q4 to R.string.faq_a4, R.string.faq_q5 to R.string.faq_a5,
|
||||||
|
)
|
||||||
|
var open by remember { mutableStateOf(0) }
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
faq.forEachIndexed { i, (q, a) ->
|
||||||
|
val expanded = open == i
|
||||||
|
Column(Modifier.fillMaxWidth())
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 58.dp)
|
||||||
|
.clickable(role = Role.Button) { open = if (expanded) -1 else i }.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(q), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
Icon(
|
||||||
|
ShxIcons.Chevron, contentDescription = null, tint = c.textSub,
|
||||||
|
modifier = Modifier.size(18.dp).rotate(if (expanded) 270f else 90f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (expanded)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(a), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 21.sp, fontWeight = FontWeight.Normal), color = c.textSoft,
|
||||||
|
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (i < faq.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxSectionHeader(stringResource(R.string.help_about).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.padding(16.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(RoundedCornerShape(15.dp)).background(c.accent))
|
||||||
|
{
|
||||||
|
Icon(painterResource(R.drawable.ic_widget_logo_accent), contentDescription = null, tint = Color.Unspecified, modifier = Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text("SHX VPN " + (info?.versionName ?: ""), style = ShxType.cardHeading, color = c.text)
|
||||||
|
Text(stringResource(R.string.help_build, build), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HelpLink(stringResource(R.string.help_updates), onUpdates)
|
||||||
|
HelpLink(stringResource(R.string.help_licenses)) { open("https://www.gnu.org/licenses/old-licenses/gpl-2.0.html") }
|
||||||
|
HelpLink(stringResource(R.string.help_source)) { open("https://github.com/strongswan/strongswan") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HelpTile(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String, bg: Color, fg: Color, modifier: Modifier, onClick: () -> Unit)
|
||||||
|
{
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = modifier.heightIn(min = 84.dp).clip(RoundedCornerShape(22.dp)).background(bg).clickable(role = Role.Button, onClick = onClick).padding(14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
|
||||||
|
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = fg, maxLines = 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HelpLink(text: String, onClick: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(text, style = ShxType.body.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class NotifTag { ALWAYS, IMPORTANT, QUIET }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* «Notifications» (F_NotifSettings): what is pushed besides the «Events»
|
||||||
|
* list, in three groups; the status notification cannot be turned off.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun NotifSettingsPage()
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.notif_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal), color = c.textSoft,
|
||||||
|
modifier = Modifier.padding(horizontal = 4.dp),
|
||||||
|
)
|
||||||
|
NotifGroup(stringResource(R.string.notif_group_connection))
|
||||||
|
{
|
||||||
|
NotifRow(stringResource(R.string.notif_status), stringResource(R.string.notif_status_sub), NotifTag.ALWAYS, null, divider = true)
|
||||||
|
NotifRow(stringResource(R.string.notif_problems), stringResource(R.string.notif_problems_sub), NotifTag.IMPORTANT, NotifPrefs.PROBLEMS)
|
||||||
|
}
|
||||||
|
NotifGroup(stringResource(R.string.notif_group_routing))
|
||||||
|
{
|
||||||
|
NotifRow(stringResource(R.string.notif_new_apps), stringResource(R.string.notif_new_apps_sub), NotifTag.QUIET, NotifPrefs.NEW_APPS)
|
||||||
|
}
|
||||||
|
NotifGroup(stringResource(R.string.notif_group_other))
|
||||||
|
{
|
||||||
|
NotifRow(stringResource(R.string.notif_updates), stringResource(R.string.notif_updates_sub), NotifTag.QUIET, NotifPrefs.UPDATES, divider = true)
|
||||||
|
NotifRow(stringResource(R.string.notif_weekly), stringResource(R.string.notif_weekly_sub), NotifTag.QUIET, NotifPrefs.WEEKLY)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(18.dp)).background(c.tile)
|
||||||
|
.clickable(role = Role.Button) {
|
||||||
|
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||||
|
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||||
|
else Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.fromParts("package", context.packageName, null))
|
||||||
|
runCatching { context.startActivity(intent) }
|
||||||
|
}
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.notif_android), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.textTonal, modifier = Modifier.weight(1f))
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NotifGroup(title: String, content: @Composable androidx.compose.foundation.layout.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 NotifRow(title: String, sub: String, tag: NotifTag, key: String?, divider: Boolean = false)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
var on by remember { mutableStateOf(key == null || NotifPrefs.enabled(context, key)) }
|
||||||
|
val (tagBg, tagFg, tagText) = when (tag)
|
||||||
|
{
|
||||||
|
NotifTag.ALWAYS -> Triple(c.accentContainer, c.onAccentContainer, stringResource(R.string.notif_tag_always))
|
||||||
|
NotifTag.IMPORTANT -> Triple(c.warningStrong, c.onWarning, stringResource(R.string.notif_tag_important))
|
||||||
|
NotifTag.QUIET -> Triple(c.tile, c.textSoft, stringResource(R.string.notif_tag_quiet))
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 66.dp).padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
androidx.compose.foundation.layout.FlowRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
itemVerticalAlignment = Alignment.CenterVertically,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
|
||||||
|
Text(
|
||||||
|
tagText.uppercase(), style = ShxType.tag.copy(fontSize = 10.sp), color = tagFg, maxLines = 1, softWrap = false,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(tagBg).padding(horizontal = 7.dp, vertical = 1.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(sub, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub)
|
||||||
|
}
|
||||||
|
ShxSwitch(
|
||||||
|
checked = on,
|
||||||
|
onCheckedChange = if (key == null) null else { value ->
|
||||||
|
on = value
|
||||||
|
NotifPrefs.set(context, key, value)
|
||||||
|
if (key == NotifPrefs.WEEKLY) org.strongswan.android.logic.stats.WeeklySummary.schedule(context)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (divider)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Allow installing updates» sheet (F_UpdateInstall), before the system page opens. */
|
||||||
|
@Composable
|
||||||
|
internal fun InstallPermissionSheet(onOpen: () -> Unit, onDismiss: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
org.strongswan.android.ui.compose.design.ShxSheet(onDismiss = onDismiss)
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.install_perm_title), style = ShxType.sheetTitle, color = c.text)
|
||||||
|
Text(stringResource(R.string.install_perm_text), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 21.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
|
||||||
|
}
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = 16.dp, vertical = 14.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.install_perm_preview).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(c.tile).padding(horizontal = 12.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.install_perm_switch), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
|
||||||
|
Text("SHX VPN", style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
/* a picture of the system switch, not a control */
|
||||||
|
Box(Modifier.size(44.dp, 26.dp).clip(RoundedCornerShape(13.dp)).background(c.accent).padding(3.dp), contentAlignment = Alignment.CenterEnd)
|
||||||
|
{
|
||||||
|
Box(Modifier.size(20.dp).clip(CircleShape).background(Color.White))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.install_perm_hint), style = ShxType.note.copy(lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UpdateSafeNote(stringResource(R.string.install_perm_safe))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.install_perm_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.install_perm_open), onOpen, modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun dnsName(choice: org.strongswan.android.logic.protection.DnsChoice): String = when (choice)
|
||||||
|
{
|
||||||
|
org.strongswan.android.logic.protection.DnsChoice.PROFILE -> stringResource(R.string.prot_profile)
|
||||||
|
org.strongswan.android.logic.protection.DnsChoice.CLOUDFLARE -> "Cloudflare"
|
||||||
|
org.strongswan.android.logic.protection.DnsChoice.ADGUARD -> "AdGuard DNS"
|
||||||
|
org.strongswan.android.logic.protection.DnsChoice.CUSTOM -> stringResource(R.string.prot_custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Cloudflare · blocking on» line of the settings list. */
|
||||||
|
@Composable
|
||||||
|
internal fun protectionSub(state: org.strongswan.android.logic.protection.ProtectionState?): String
|
||||||
|
{
|
||||||
|
state ?: return ""
|
||||||
|
val blocking = state.blocksAds || state.blocksPhishing
|
||||||
|
val status = stringResource(if (blocking) R.string.prot_on else R.string.prot_off)
|
||||||
|
/* German nouns keep their capital */
|
||||||
|
val locale = androidx.compose.ui.platform.LocalConfiguration.current.locales[0]
|
||||||
|
return dnsName(state.choice) + " · " + if (locale.language == "de") status else status.lowercase(locale)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «DNS and ads» (F_Protection): what is blocked, the blocking switches, the DNS server. */
|
||||||
|
@Composable
|
||||||
|
internal fun ProtectionPage()
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val stateNullable by remember { org.strongswan.android.logic.protection.Protection.flow(context) }.collectAsStateWithLifecycle()
|
||||||
|
val state = stateNullable ?: return
|
||||||
|
var custom by remember { mutableStateOf(state.custom) }
|
||||||
|
/* IPs, or an own DoH/DoT server */
|
||||||
|
fun customValid(text: String) = org.strongswan.android.logic.protection.ProtectionState.parseCustom(text).isNotEmpty() ||
|
||||||
|
org.strongswan.android.logic.protection.SecureDnsConfig.parse(text) != null
|
||||||
|
/* result of the check on «Save» for DoH/DoT: (ok, text) */
|
||||||
|
var probe by remember { mutableStateOf<Pair<Boolean, String>?>(null) }
|
||||||
|
var probing by remember { mutableStateOf(false) }
|
||||||
|
val scope = androidx.compose.runtime.rememberCoroutineScope()
|
||||||
|
val probeOk = stringResource(R.string.prot_secure_ok)
|
||||||
|
val probeFail = stringResource(R.string.prot_secure_fail)
|
||||||
|
val applies = stringResource(R.string.prot_applies)
|
||||||
|
fun update(next: org.strongswan.android.logic.protection.ProtectionState)
|
||||||
|
{
|
||||||
|
if (next == state) return
|
||||||
|
org.strongswan.android.logic.protection.Protection.set(context, next)
|
||||||
|
/* the TUN device gets its DNS servers when it is built */
|
||||||
|
val service = org.strongswan.android.logic.VpnStateService.getLiveInstance()
|
||||||
|
if (service != null && service.state == org.strongswan.android.logic.VpnStateService.State.CONNECTED) service.reconnect()
|
||||||
|
else android.widget.Toast.makeText(context, applies, android.widget.Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp).padding(top = 4.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
|
{
|
||||||
|
/* what the resolver blocks (the app cannot count the blocked queries: the resolver answers them) */
|
||||||
|
val blocked = listOfNotNull(
|
||||||
|
if (state.blocksAds) stringResource(R.string.prot_list_ads) else null,
|
||||||
|
if (state.blocksTrackers) stringResource(R.string.prot_list_trackers) else null,
|
||||||
|
if (state.blocksPhishing) stringResource(R.string.prot_list_phishing) else null,
|
||||||
|
)
|
||||||
|
val on = blocked.isNotEmpty()
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(if (on) c.successContainer else c.tile).padding(18.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(if (on) R.string.prot_on else R.string.prot_off), style = ShxType.chip, color = if (on) c.onSuccess else c.textSoft)
|
||||||
|
Text(dnsName(state.choice), style = ShxType.number.copy(fontSize = 30.sp, lineHeight = 34.sp), color = c.text)
|
||||||
|
Text(
|
||||||
|
if (on) stringResource(R.string.prot_what, blocked.joinToString(" · ")) else stringResource(R.string.prot_none),
|
||||||
|
style = ShxType.chip.copy(fontWeight = FontWeight.SemiBold), color = if (on) c.onSuccess else c.textSub,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ShxSectionHeader(stringResource(R.string.prot_block).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
ShxToggleRow(stringResource(R.string.prot_ads), state.blocksAds, { update(state.withAds(it)) }, subtitle = stringResource(R.string.prot_ads_sub), divider = true)
|
||||||
|
ShxToggleRow(stringResource(R.string.prot_trackers), state.blocksTrackers, { update(state.withAds(it)) }, subtitle = stringResource(R.string.prot_trackers_sub), divider = true)
|
||||||
|
ShxToggleRow(stringResource(R.string.prot_phishing), state.blocksPhishing, { update(state.withPhishing(it)) }, subtitle = stringResource(R.string.prot_phishing_sub))
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.prot_together), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
|
ShxSectionHeader(stringResource(R.string.prot_dns).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp))
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
val rows = listOf(
|
||||||
|
org.strongswan.android.logic.protection.DnsChoice.PROFILE to (stringResource(R.string.prot_profile) to stringResource(R.string.prot_profile_sub)),
|
||||||
|
org.strongswan.android.logic.protection.DnsChoice.CLOUDFLARE to ("Cloudflare" to stringResource(R.string.prot_cloudflare_sub)),
|
||||||
|
org.strongswan.android.logic.protection.DnsChoice.ADGUARD to ("AdGuard DNS" to stringResource(R.string.prot_adguard_sub)),
|
||||||
|
org.strongswan.android.logic.protection.DnsChoice.CUSTOM to (stringResource(R.string.prot_custom) to stringResource(R.string.prot_custom_sub)),
|
||||||
|
)
|
||||||
|
rows.forEachIndexed { i, (choice, texts) ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(62.dp).selectable(selected = state.choice == choice, role = Role.RadioButton) {
|
||||||
|
if (choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM && !customValid(custom))
|
||||||
|
{ /* the field below takes the address first */
|
||||||
|
org.strongswan.android.logic.protection.Protection.set(context, state.copy(choice = choice))
|
||||||
|
}
|
||||||
|
else update(state.copy(choice = choice, phishingFilter = if (choice == org.strongswan.android.logic.protection.DnsChoice.CLOUDFLARE) state.phishingFilter else false))
|
||||||
|
}.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
androidx.compose.material3.RadioButton(selected = state.choice == choice, onClick = null, colors = androidx.compose.material3.RadioButtonDefaults.colors(selectedColor = c.accent))
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp))
|
||||||
|
{
|
||||||
|
Text(texts.first, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text)
|
||||||
|
Text(texts.second, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i < rows.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.choice == org.strongswan.android.logic.protection.DnsChoice.CUSTOM)
|
||||||
|
{
|
||||||
|
Column(Modifier.padding(start = 16.dp, end = 16.dp, bottom = 14.dp), verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
org.strongswan.android.ui.compose.design.ShxFormField(label = stringResource(R.string.prot_custom), value = custom, onValueChange = { custom = it; probe = null }, placeholder = "9.9.9.9")
|
||||||
|
val secure = org.strongswan.android.logic.protection.SecureDnsConfig.parse(custom)
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.server_save),
|
||||||
|
{
|
||||||
|
if (secure == null) update(state.copy(custom = custom))
|
||||||
|
else
|
||||||
|
{ /* check the server first, so a typo shows here and not as a dead tunnel */
|
||||||
|
probing = true
|
||||||
|
scope.launch {
|
||||||
|
val result = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { org.strongswan.android.logic.protection.SecureDns.probe(secure) }
|
||||||
|
org.strongswan.android.logic.DiagLog.i("SecureDns", "check on save: ${secure.label} " +
|
||||||
|
if (result.ok) "ok in ${result.ms} ms" else "failed: ${result.error}")
|
||||||
|
probing = false
|
||||||
|
probe = if (result.ok) true to probeOk.format(result.ms) else false to probeFail.format(result.error ?: "")
|
||||||
|
update(state.copy(custom = custom))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = ShxButtonStyle.TONAL, height = 44.dp, modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = customValid(custom) && !probing,
|
||||||
|
)
|
||||||
|
probe?.let { (ok, text) -> ShxNote(text, tone = if (ok) ShxTone.SUCCESS else ShxTone.WARNING) }
|
||||||
|
if (secure != null)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.prot_secure_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.prot_note), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
+297
-316
@@ -24,32 +24,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||||
import androidx.compose.material.icons.rounded.Autorenew
|
|
||||||
import androidx.compose.material.icons.rounded.BatteryChargingFull
|
|
||||||
import androidx.compose.material.icons.rounded.Bedtime
|
|
||||||
import androidx.compose.material.icons.rounded.Bolt
|
|
||||||
import androidx.compose.material.icons.rounded.BugReport
|
|
||||||
import androidx.compose.material.icons.rounded.Cached
|
|
||||||
import androidx.compose.material.icons.rounded.CloudDownload
|
|
||||||
import androidx.compose.material.icons.rounded.CloudSync
|
|
||||||
import androidx.compose.material.icons.rounded.Description
|
|
||||||
import androidx.compose.material.icons.rounded.FileDownload
|
|
||||||
import androidx.compose.material.icons.rounded.GppGood
|
|
||||||
import androidx.compose.material.icons.rounded.GppMaybe
|
|
||||||
import androidx.compose.material.icons.rounded.History
|
|
||||||
import androidx.compose.material.icons.rounded.NetworkCheck
|
|
||||||
import androidx.compose.material.icons.rounded.Palette
|
|
||||||
import androidx.compose.material.icons.rounded.RestartAlt
|
|
||||||
import androidx.compose.material.icons.rounded.Security
|
|
||||||
import androidx.compose.material.icons.rounded.SystemUpdateAlt
|
|
||||||
import androidx.compose.material.icons.rounded.VerifiedUser
|
|
||||||
import androidx.compose.material.icons.rounded.Wifi
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
@@ -77,7 +55,6 @@ import androidx.compose.animation.slideInHorizontally
|
|||||||
import androidx.compose.animation.slideOutHorizontally
|
import androidx.compose.animation.slideOutHorizontally
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
|
||||||
import androidx.compose.material.icons.rounded.Brightness6
|
import androidx.compose.material.icons.rounded.Brightness6
|
||||||
import androidx.compose.material3.SegmentedButton
|
import androidx.compose.material3.SegmentedButton
|
||||||
import androidx.compose.material3.SegmentedButtonDefaults
|
import androidx.compose.material3.SegmentedButtonDefaults
|
||||||
@@ -97,19 +74,41 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import org.strongswan.android.ui.compose.components.PredictiveBackScale
|
import org.strongswan.android.ui.compose.components.PredictiveBackScale
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxDialogButton
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.strongswan.android.logic.TrustedCertificateManager
|
||||||
|
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.ShxScreenTitle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
import org.strongswan.android.data.VpnProfile
|
import org.strongswan.android.data.VpnProfile
|
||||||
import org.strongswan.android.logic.StrongSwanApplication
|
import org.strongswan.android.logic.StrongSwanApplication
|
||||||
import org.strongswan.android.logic.WatchdogReceiver
|
import org.strongswan.android.logic.WatchdogReceiver
|
||||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||||
import org.strongswan.android.logic.shortcuts.VpnShortcutManager
|
import org.strongswan.android.logic.shortcuts.VpnShortcutManager
|
||||||
import org.strongswan.android.logic.updates.UpdateError
|
|
||||||
import org.strongswan.android.logic.updates.UpdateInstaller
|
|
||||||
import org.strongswan.android.logic.updates.UpdateManager
|
import org.strongswan.android.logic.updates.UpdateManager
|
||||||
import org.strongswan.android.logic.updates.UpdateState
|
import org.strongswan.android.logic.updates.UpdateState
|
||||||
import org.strongswan.android.ui.LogActivity
|
import org.strongswan.android.ui.LogActivity
|
||||||
import org.strongswan.android.ui.TrustedCertificatesActivity
|
import org.strongswan.android.ui.TrustedCertificatesActivity
|
||||||
import org.strongswan.android.ui.TrustedNetworksActivity
|
|
||||||
import org.strongswan.android.ui.ConnectivityCheckActivity
|
import org.strongswan.android.ui.ConnectivityCheckActivity
|
||||||
import org.strongswan.android.ui.VpnProfileImportActivity
|
import org.strongswan.android.ui.VpnProfileImportActivity
|
||||||
import org.strongswan.android.ui.compose.components.ShxSection
|
import org.strongswan.android.ui.compose.components.ShxSection
|
||||||
@@ -150,34 +149,20 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
|
|
||||||
val updateManager = remember { UpdateManager.getInstance(context) }
|
val updateManager = remember { UpdateManager.getInstance(context) }
|
||||||
val updateState by updateManager.state.collectAsStateWithLifecycle()
|
val updateState by updateManager.state.collectAsStateWithLifecycle()
|
||||||
var showUpdateConfirm by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val unknownSourcesLauncher = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.StartActivityForResult()
|
|
||||||
) {
|
|
||||||
if (UpdateInstaller.canInstall(context))
|
|
||||||
{
|
|
||||||
updateManager.downloadAndInstall()
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.update_permission_denied)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val startUpdate: () -> Unit = {
|
|
||||||
if (UpdateInstaller.canInstall(context))
|
|
||||||
{
|
|
||||||
updateManager.downloadAndInstall()
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
unknownSourcesLauncher.launch(UpdateInstaller.createInstallPermissionIntent(context))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
|
var pageState by rememberSaveable { mutableStateOf(SettingsPage.ROOT) }
|
||||||
|
val updatesRequested by ShxNavigation.updatesPage.collectAsStateWithLifecycle()
|
||||||
|
androidx.compose.runtime.LaunchedEffect(updatesRequested)
|
||||||
|
{
|
||||||
|
if (updatesRequested)
|
||||||
|
{
|
||||||
|
pageState = SettingsPage.UPDATES
|
||||||
|
ShxNavigation.consumeUpdatesPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
/* from a sub page "back" returns to the settings list, not out of the tab */
|
/* from a sub page "back" returns to the settings list, not out of the tab */
|
||||||
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
|
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
|
||||||
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
|
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
|
||||||
@@ -202,63 +187,34 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
Column(
|
Column(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.background(LocalShxColors.current.background)
|
||||||
|
.statusBarsPadding()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(bottom = ShxSpacing.xl),
|
.padding(bottom = ShxSpacing.xl),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (showTitle || page != SettingsPage.ROOT)
|
if (page != SettingsPage.ROOT)
|
||||||
{
|
{
|
||||||
TopAppBar(
|
ShxPageHeader(stringResource(page.titleRes), onBack = { pageState = SettingsPage.ROOT })
|
||||||
title = {
|
}
|
||||||
Text(
|
else if (showTitle)
|
||||||
stringResource(page.titleRes),
|
{
|
||||||
fontWeight = FontWeight.SemiBold,
|
ShxScreenTitle(stringResource(R.string.pref_title))
|
||||||
modifier = Modifier.semantics { heading() },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
if (page != SettingsPage.ROOT)
|
|
||||||
{
|
|
||||||
IconButton(onClick = { pageState = SettingsPage.ROOT }) {
|
|
||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page == SettingsPage.ROOT)
|
if (page == SettingsPage.ROOT)
|
||||||
{
|
{
|
||||||
SettingsRoot(onOpen = { pageState = it })
|
SettingsRoot(onOpen = { pageState = it }, updateState = updateState, versionName = versionName)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page == SettingsPage.APPEARANCE)
|
if (page == SettingsPage.APPEARANCE)
|
||||||
{
|
{
|
||||||
ShxSection(title = stringResource(R.string.settings_page_appearance))
|
AppearancePage()
|
||||||
{
|
|
||||||
ThemeModeRow()
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
|
|
||||||
{
|
|
||||||
var dynamicColors by remember { ShxThemeSettings.load(context); mutableStateOf(ShxThemeSettings.useDynamicColors.value) }
|
|
||||||
ShxSettingToggleRow(
|
|
||||||
icon = Icons.Rounded.Palette,
|
|
||||||
title = stringResource(R.string.pref_dynamic_colors_title),
|
|
||||||
subtitle = stringResource(R.string.pref_dynamic_colors_summary),
|
|
||||||
checked = dynamicColors,
|
|
||||||
onCheckedChange = {
|
|
||||||
dynamicColors = it
|
|
||||||
ShxThemeSettings.setUseDynamicColors(context, it)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page == SettingsPage.AUTOMATION)
|
if (page == SettingsPage.STABILITY)
|
||||||
{
|
{
|
||||||
|
PermissionsList(Modifier.padding(horizontal = 16.dp).padding(top = 4.dp))
|
||||||
ShxSection(title = stringResource(R.string.settings_section_general))
|
ShxSection(title = stringResource(R.string.settings_section_general))
|
||||||
{
|
{
|
||||||
var addedRow = false
|
var addedRow = false
|
||||||
@@ -269,14 +225,14 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
ShxSectionDivider()
|
ShxSectionDivider()
|
||||||
}
|
}
|
||||||
ShxSettingRow(
|
ShxSettingRow(
|
||||||
icon = Icons.Rounded.History,
|
icon = ShxIcons.Clock,
|
||||||
title = stringResource(R.string.pref_default_vpn_profile),
|
title = stringResource(R.string.pref_default_vpn_profile),
|
||||||
subtitle = defaultLabel,
|
subtitle = defaultLabel,
|
||||||
onClick = { showDefaultPicker = true },
|
onClick = { showDefaultPicker = true },
|
||||||
)
|
)
|
||||||
ShxSectionDivider()
|
ShxSectionDivider()
|
||||||
ShxSettingToggleRow(
|
ShxSettingToggleRow(
|
||||||
icon = Icons.Rounded.BatteryChargingFull,
|
icon = ShxIcons.Battery,
|
||||||
title = stringResource(R.string.pref_power_whitelist_title),
|
title = stringResource(R.string.pref_power_whitelist_title),
|
||||||
subtitle = stringResource(R.string.pref_power_whitelist_summary),
|
subtitle = stringResource(R.string.pref_power_whitelist_summary),
|
||||||
checked = ignorePower,
|
checked = ignorePower,
|
||||||
@@ -291,7 +247,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
* disabled, which breaks watchdog-based recovery of the tunnel */
|
* disabled, which breaks watchdog-based recovery of the tunnel */
|
||||||
ShxSectionDivider()
|
ShxSectionDivider()
|
||||||
ShxSettingRow(
|
ShxSettingRow(
|
||||||
icon = Icons.Rounded.Bedtime,
|
icon = ShxIcons.Moon,
|
||||||
title = stringResource(R.string.samsung_sleep_title),
|
title = stringResource(R.string.samsung_sleep_title),
|
||||||
subtitle = stringResource(R.string.samsung_sleep_summary),
|
subtitle = stringResource(R.string.samsung_sleep_summary),
|
||||||
onClick = { openSamsungBatterySettings(context) },
|
onClick = { openSamsungBatterySettings(context) },
|
||||||
@@ -300,33 +256,15 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page == SettingsPage.AUTOMATION)
|
if (page == SettingsPage.STABILITY)
|
||||||
{
|
{
|
||||||
ShxSection(title = stringResource(R.string.settings_section_automation))
|
ShxSection(title = stringResource(R.string.settings_section_automation))
|
||||||
{
|
{
|
||||||
ShxSettingRow(
|
|
||||||
icon = Icons.Rounded.Wifi,
|
|
||||||
title = stringResource(R.string.auto_connect_title),
|
|
||||||
subtitle = stringResource(R.string.auto_connect_summary),
|
|
||||||
onClick = {
|
|
||||||
context.startActivity(Intent(context, TrustedNetworksActivity::class.java))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ShxSectionDivider()
|
|
||||||
ShxSettingRow(
|
|
||||||
icon = Icons.Rounded.NetworkCheck,
|
|
||||||
title = stringResource(R.string.health_check_title),
|
|
||||||
subtitle = stringResource(R.string.health_check_summary),
|
|
||||||
onClick = {
|
|
||||||
context.startActivity(Intent(context, ConnectivityCheckActivity::class.java))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ShxSectionDivider()
|
|
||||||
var whiteListEnabled by remember {
|
var whiteListEnabled by remember {
|
||||||
mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled)
|
mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled)
|
||||||
}
|
}
|
||||||
ShxSettingToggleRow(
|
ShxSettingToggleRow(
|
||||||
icon = Icons.Rounded.GppMaybe,
|
icon = ShxIcons.Alert,
|
||||||
title = stringResource(R.string.whitelist_analysis_title),
|
title = stringResource(R.string.whitelist_analysis_title),
|
||||||
subtitle = stringResource(R.string.whitelist_analysis_summary),
|
subtitle = stringResource(R.string.whitelist_analysis_summary),
|
||||||
checked = whiteListEnabled,
|
checked = whiteListEnabled,
|
||||||
@@ -340,7 +278,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
mutableStateOf(VpnShortcutManager.getInstance(context).isEnabled())
|
mutableStateOf(VpnShortcutManager.getInstance(context).isEnabled())
|
||||||
}
|
}
|
||||||
ShxSettingToggleRow(
|
ShxSettingToggleRow(
|
||||||
icon = Icons.Rounded.Bolt,
|
icon = ShxIcons.Bolt,
|
||||||
title = stringResource(R.string.pref_routines_shortcuts_title),
|
title = stringResource(R.string.pref_routines_shortcuts_title),
|
||||||
subtitle = stringResource(R.string.pref_routines_shortcuts_summary),
|
subtitle = stringResource(R.string.pref_routines_shortcuts_summary),
|
||||||
checked = shortcutsEnabled,
|
checked = shortcutsEnabled,
|
||||||
@@ -354,7 +292,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
mutableStateOf(prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true))
|
mutableStateOf(prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true))
|
||||||
}
|
}
|
||||||
ShxSettingToggleRow(
|
ShxSettingToggleRow(
|
||||||
icon = Icons.Rounded.RestartAlt,
|
icon = ShxIcons.Refresh,
|
||||||
title = stringResource(R.string.watchdog_title),
|
title = stringResource(R.string.watchdog_title),
|
||||||
subtitle = stringResource(R.string.watchdog_summary),
|
subtitle = stringResource(R.string.watchdog_summary),
|
||||||
checked = watchdogEnabled,
|
checked = watchdogEnabled,
|
||||||
@@ -380,7 +318,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false))
|
mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false))
|
||||||
}
|
}
|
||||||
ShxSettingToggleRow(
|
ShxSettingToggleRow(
|
||||||
icon = Icons.Rounded.GppGood,
|
icon = ShxIcons.Shield,
|
||||||
title = stringResource(R.string.safe_ipv6_title),
|
title = stringResource(R.string.safe_ipv6_title),
|
||||||
subtitle = stringResource(R.string.safe_ipv6_summary),
|
subtitle = stringResource(R.string.safe_ipv6_summary),
|
||||||
checked = safeIpv6,
|
checked = safeIpv6,
|
||||||
@@ -397,7 +335,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
ShxSection(title = stringResource(R.string.settings_section_certs))
|
ShxSection(title = stringResource(R.string.settings_section_certs))
|
||||||
{
|
{
|
||||||
ShxSettingRow(
|
ShxSettingRow(
|
||||||
icon = Icons.Rounded.VerifiedUser,
|
icon = ShxIcons.Cert,
|
||||||
title = stringResource(R.string.trusted_certs_title),
|
title = stringResource(R.string.trusted_certs_title),
|
||||||
onClick = {
|
onClick = {
|
||||||
context.startActivity(Intent(context, TrustedCertificatesActivity::class.java))
|
context.startActivity(Intent(context, TrustedCertificatesActivity::class.java))
|
||||||
@@ -405,47 +343,33 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
)
|
)
|
||||||
ShxSectionDivider()
|
ShxSectionDivider()
|
||||||
ShxSettingRow(
|
ShxSettingRow(
|
||||||
icon = Icons.Rounded.Cached,
|
icon = ShxIcons.Refresh,
|
||||||
title = stringResource(R.string.crl_cache),
|
title = stringResource(R.string.crl_cache),
|
||||||
onClick = { showCrl = true },
|
onClick = { showCrl = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page == SettingsPage.SUPPORT)
|
if (page == SettingsPage.PROTECTION)
|
||||||
{
|
{
|
||||||
if (managed.isAllowProfileImport)
|
ProtectionPage()
|
||||||
{
|
|
||||||
ShxSection(title = stringResource(R.string.settings_section_profiles))
|
|
||||||
{
|
|
||||||
ShxSettingRow(
|
|
||||||
icon = Icons.Rounded.FileDownload,
|
|
||||||
title = stringResource(R.string.profile_import),
|
|
||||||
onClick = {
|
|
||||||
context.startActivity(Intent(context, VpnProfileImportActivity::class.java))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page == SettingsPage.SUPPORT)
|
if (page == SettingsPage.NOTIFICATIONS)
|
||||||
{
|
{
|
||||||
ShxSection(title = stringResource(R.string.settings_section_support))
|
NotifSettingsPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page == SettingsPage.HELP)
|
||||||
|
{
|
||||||
|
HelpPage(onUpdates = { pageState = SettingsPage.UPDATES })
|
||||||
|
ShxSection(title = stringResource(R.string.help_for_support))
|
||||||
{
|
{
|
||||||
ShxSettingRow(
|
|
||||||
icon = Icons.Rounded.Description,
|
|
||||||
title = stringResource(R.string.show_log),
|
|
||||||
onClick = {
|
|
||||||
context.startActivity(Intent(context, LogActivity::class.java))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ShxSectionDivider()
|
|
||||||
var extendedDiag by remember {
|
var extendedDiag by remember {
|
||||||
mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false))
|
mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false))
|
||||||
}
|
}
|
||||||
ShxSettingToggleRow(
|
ShxSettingToggleRow(
|
||||||
icon = Icons.Rounded.BugReport,
|
icon = ShxIcons.Bug,
|
||||||
title = stringResource(R.string.extended_diag_title),
|
title = stringResource(R.string.extended_diag_title),
|
||||||
subtitle = stringResource(R.string.extended_diag_summary),
|
subtitle = stringResource(R.string.extended_diag_summary),
|
||||||
checked = extendedDiag,
|
checked = extendedDiag,
|
||||||
@@ -459,76 +383,25 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
|
|
||||||
if (page == SettingsPage.UPDATES)
|
if (page == SettingsPage.UPDATES)
|
||||||
{
|
{
|
||||||
ShxSection(title = stringResource(R.string.settings_section_updates))
|
UpdatesPage()
|
||||||
{
|
|
||||||
var autoUpdate by remember {
|
|
||||||
mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true))
|
|
||||||
}
|
|
||||||
ShxSettingToggleRow(
|
|
||||||
icon = Icons.Rounded.Autorenew,
|
|
||||||
title = stringResource(R.string.update_auto_check_title),
|
|
||||||
subtitle = stringResource(R.string.update_auto_check_summary),
|
|
||||||
checked = autoUpdate,
|
|
||||||
onCheckedChange = {
|
|
||||||
autoUpdate = it
|
|
||||||
prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ShxSectionDivider()
|
|
||||||
var silentUpdates by remember {
|
|
||||||
mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false))
|
|
||||||
}
|
|
||||||
ShxSettingToggleRow(
|
|
||||||
icon = Icons.Rounded.CloudSync,
|
|
||||||
title = stringResource(R.string.update_silent_title),
|
|
||||||
subtitle = stringResource(R.string.update_silent_summary),
|
|
||||||
checked = silentUpdates,
|
|
||||||
onCheckedChange = {
|
|
||||||
silentUpdates = it
|
|
||||||
prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ShxSectionDivider()
|
|
||||||
ShxSettingRow(
|
|
||||||
icon = Icons.Rounded.SystemUpdateAlt,
|
|
||||||
title = stringResource(R.string.update_check_title),
|
|
||||||
subtitle = updateStatusText(updateState),
|
|
||||||
onClick = {
|
|
||||||
when (updateState)
|
|
||||||
{
|
|
||||||
is UpdateState.Checking, is UpdateState.Downloading -> Unit
|
|
||||||
else -> updateManager.checkNow()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
val updateInfo = (updateState as? UpdateState.Available)?.info
|
|
||||||
?: (updateState as? UpdateState.Downloading)?.info
|
|
||||||
if (updateInfo != null)
|
|
||||||
{
|
|
||||||
ShxSectionDivider()
|
|
||||||
ShxSettingRow(
|
|
||||||
icon = Icons.Rounded.CloudDownload,
|
|
||||||
title = stringResource(R.string.update_download_install),
|
|
||||||
subtitle = when (val state = updateState)
|
|
||||||
{
|
|
||||||
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
|
|
||||||
else -> stringResource(R.string.update_available_fmt, updateInfo.versionName)
|
|
||||||
},
|
|
||||||
onClick = {
|
|
||||||
if (updateState !is UpdateState.Downloading)
|
|
||||||
{
|
|
||||||
showUpdateConfirm = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page == SettingsPage.ROOT || page == SettingsPage.UPDATES) Text(
|
if (page == SettingsPage.KILLSWITCH)
|
||||||
|
{
|
||||||
|
var resumes by remember { mutableStateOf(0) }
|
||||||
|
val owner = LocalLifecycleOwner.current
|
||||||
|
DisposableEffect(owner) {
|
||||||
|
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
|
||||||
|
owner.lifecycle.addObserver(observer)
|
||||||
|
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||||
|
}
|
||||||
|
KillSwitchPage(remember(resumes) { context.isVpnLockdownEnabled() })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page == SettingsPage.ROOT) Text(
|
||||||
text = stringResource(R.string.app_version_fmt, versionName),
|
text = stringResource(R.string.app_version_fmt, versionName),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = ShxType.meta,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = LocalShxColors.current.textSub,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(top = ShxSpacing.lg),
|
.padding(top = ShxSpacing.lg),
|
||||||
@@ -547,7 +420,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
{
|
{
|
||||||
val options = listOf(Constants.PREF_DEFAULT_VPN_PROFILE_MRU to context.getString(R.string.pref_default_vpn_profile_mru)) +
|
val options = listOf(Constants.PREF_DEFAULT_VPN_PROFILE_MRU to context.getString(R.string.pref_default_vpn_profile_mru)) +
|
||||||
profiles.map { it.getUUID().toString() to (it.name ?: "") }
|
profiles.map { it.getUUID().toString() to (it.name ?: "") }
|
||||||
AlertDialog(
|
ShxAlertDialog(
|
||||||
onDismissRequest = { showDefaultPicker = false },
|
onDismissRequest = { showDefaultPicker = false },
|
||||||
title = { Text(stringResource(R.string.pref_default_vpn_profile)) },
|
title = { Text(stringResource(R.string.pref_default_vpn_profile)) },
|
||||||
text = {
|
text = {
|
||||||
@@ -572,7 +445,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = { showDefaultPicker = false }) {
|
ShxDialogButton(onClick = { showDefaultPicker = false }) {
|
||||||
Text(stringResource(android.R.string.cancel))
|
Text(stringResource(android.R.string.cancel))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -582,7 +455,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
if (showCrl)
|
if (showCrl)
|
||||||
{
|
{
|
||||||
val files = remember(showCrl) { context.fileList().filter { it.startsWith("crl-") } }
|
val files = remember(showCrl) { context.fileList().filter { it.startsWith("crl-") } }
|
||||||
AlertDialog(
|
ShxAlertDialog(
|
||||||
onDismissRequest = { showCrl = false },
|
onDismissRequest = { showCrl = false },
|
||||||
title = { Text(stringResource(R.string.clear_crl_cache_title)) },
|
title = { Text(stringResource(R.string.clear_crl_cache_title)) },
|
||||||
text = {
|
text = {
|
||||||
@@ -592,7 +465,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
ShxDialogButton(primary = true, danger = true, onClick = {
|
||||||
if (files.isEmpty())
|
if (files.isEmpty())
|
||||||
{
|
{
|
||||||
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.clear_crl_cache_msg_none)) }
|
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.clear_crl_cache_msg_none)) }
|
||||||
@@ -605,89 +478,20 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
|||||||
}) { Text(stringResource(R.string.clear)) }
|
}) { Text(stringResource(R.string.clear)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showCrl = false }) {
|
ShxDialogButton(onClick = { showCrl = false }) {
|
||||||
Text(stringResource(android.R.string.cancel))
|
Text(stringResource(android.R.string.cancel))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showUpdateConfirm && updateState is UpdateState.Available)
|
|
||||||
{
|
|
||||||
val info = (updateState as UpdateState.Available).info
|
|
||||||
val notes = info.notes["ru"] ?: info.notes["en"] ?: info.notes.values.firstOrNull() ?: ""
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showUpdateConfirm = false },
|
|
||||||
title = { Text(stringResource(R.string.update_confirm_title, info.versionName)) },
|
|
||||||
text = { if (notes.isNotEmpty()) { Text(notes) } },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
showUpdateConfirm = false
|
|
||||||
startUpdate()
|
|
||||||
}) { Text(stringResource(R.string.update_action)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showUpdateConfirm = false }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun updateStatusText(state: UpdateState): String
|
|
||||||
{
|
|
||||||
return when (state)
|
|
||||||
{
|
|
||||||
is UpdateState.Checking -> stringResource(R.string.update_checking)
|
|
||||||
is UpdateState.Idle -> stringResource(R.string.update_status_idle)
|
|
||||||
is UpdateState.UpToDate -> stringResource(R.string.update_status_uptodate)
|
|
||||||
is UpdateState.Available ->
|
|
||||||
{
|
|
||||||
if (state.info.size > 0)
|
|
||||||
{
|
|
||||||
stringResource(R.string.update_available_fmt, state.info.versionName) + " • " +
|
|
||||||
stringResource(R.string.update_size_mb_fmt, (state.info.size / (1024 * 1024)).toInt())
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
stringResource(R.string.update_available_fmt, state.info.versionName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
is UpdateState.Downloading -> stringResource(R.string.update_downloading_fmt, state.progress)
|
|
||||||
is UpdateState.Failed -> stringResource(
|
|
||||||
when (state.error)
|
|
||||||
{
|
|
||||||
UpdateError.NETWORK -> R.string.update_error_network
|
|
||||||
UpdateError.HASH -> R.string.update_error_hash
|
|
||||||
UpdateError.SIGNATURE -> R.string.update_error_signature
|
|
||||||
UpdateError.INSTALL -> R.string.update_error_install
|
|
||||||
UpdateError.OTHER -> R.string.update_error_generic
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether the system-level lockdown ("Block connections without VPN") is
|
* Whether the system-level lockdown ("Block connections without VPN") is on for
|
||||||
* active for our package. Returns false if not determinable.
|
* us, as the VPN service last saw it (false before the first connection).
|
||||||
*/
|
*/
|
||||||
internal fun Context.isVpnLockdownEnabled(): Boolean
|
internal fun Context.isVpnLockdownEnabled(): Boolean = org.strongswan.android.logic.AlwaysOnVpn.isLockdown(this)
|
||||||
{
|
|
||||||
return try
|
|
||||||
{
|
|
||||||
Settings.Global.getInt(
|
|
||||||
contentResolver,
|
|
||||||
"vpn_lockdown_$packageName",
|
|
||||||
0
|
|
||||||
) != 0
|
|
||||||
}
|
|
||||||
catch (_: Exception)
|
|
||||||
{
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the Samsung battery settings where the user can exclude the app from
|
* Opens the Samsung battery settings where the user can exclude the app from
|
||||||
@@ -721,38 +525,215 @@ enum class SettingsPage(val titleRes: Int)
|
|||||||
ROOT(R.string.pref_title),
|
ROOT(R.string.pref_title),
|
||||||
APPEARANCE(R.string.settings_page_appearance),
|
APPEARANCE(R.string.settings_page_appearance),
|
||||||
AUTOMATION(R.string.settings_section_automation),
|
AUTOMATION(R.string.settings_section_automation),
|
||||||
|
STABILITY(R.string.settings_stability),
|
||||||
SECURITY(R.string.settings_section_security),
|
SECURITY(R.string.settings_section_security),
|
||||||
UPDATES(R.string.settings_section_updates),
|
UPDATES(R.string.settings_section_updates),
|
||||||
SUPPORT(R.string.settings_section_support),
|
KILLSWITCH(R.string.settings_killswitch),
|
||||||
|
HELP(R.string.settings_help),
|
||||||
|
NOTIFICATIONS(R.string.settings_notifications),
|
||||||
|
PROTECTION(R.string.prot_title),
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Settings landing list: two quick rows on top, then the five groups. */
|
/** One row of the settings list (F_Settings): 62dp, 36dp grey icon well, 15/700 title, 12 sub. */
|
||||||
|
private data class SettingsEntry(
|
||||||
|
val title: String,
|
||||||
|
val sub: String,
|
||||||
|
val icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
val warn: Boolean = false,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Settings landing list of the mockup: search, then the four groups. */
|
||||||
@Composable
|
@Composable
|
||||||
private fun SettingsRoot(onOpen: (SettingsPage) -> Unit)
|
private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateState, versionName: String)
|
||||||
{
|
{
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
ShxSection(title = stringResource(R.string.settings_quick))
|
val c = LocalShxColors.current
|
||||||
{
|
var query by rememberSaveable { mutableStateOf("") }
|
||||||
KillSwitchRow()
|
/* re-read on every ON_RESUME: the kill switch and the trusted networks change outside */
|
||||||
ShxSectionDivider()
|
var resumes by remember { mutableStateOf(0) }
|
||||||
ShxSettingRow(
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
icon = Icons.Rounded.Wifi,
|
DisposableEffect(lifecycleOwner) {
|
||||||
title = stringResource(R.string.auto_connect_title),
|
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
|
||||||
subtitle = stringResource(R.string.auto_connect_summary),
|
lifecycleOwner.lifecycle.addObserver(observer)
|
||||||
onClick = { context.startActivity(Intent(context, TrustedNetworksActivity::class.java)) },
|
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ShxSection(title = stringResource(R.string.pref_title))
|
val lockdown = remember(resumes) { context.isVpnLockdownEnabled() }
|
||||||
|
val trusted = remember(resumes) { org.strongswan.android.logic.autoconnect.AutoConnectManager.getInstance(context).currentConfig }
|
||||||
|
val health = remember(resumes) { org.strongswan.android.logic.connectivity.HealthCheckManager.getInstance(context).currentConfig }
|
||||||
|
val themeMode by ShxThemeSettings.themeMode.collectAsStateWithLifecycle()
|
||||||
|
var showKillSwitchDialog by remember { mutableStateOf(false) }
|
||||||
|
val certs = remember { runCatching { TrustedCertificateManager.getInstance().load().getAllCACertificates().size }.getOrDefault(0) }
|
||||||
|
val scenarioList by remember { org.strongswan.android.logic.scenarios.ScenarioEngine.flow(context) }.collectAsStateWithLifecycle()
|
||||||
|
val scenarioCount = scenarioList.count { it.enabled }
|
||||||
|
val protection by remember { org.strongswan.android.logic.protection.Protection.flow(context) }.collectAsStateWithLifecycle()
|
||||||
|
/* «2 from profiles · 1 expires soon» */
|
||||||
|
val certSummary by produceState(0 to 0) {
|
||||||
|
value = withContext(Dispatchers.IO) { runCatching { org.strongswan.android.ui.certificateSummary(context) }.getOrDefault(0 to 0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val groups = listOf(
|
||||||
|
stringResource(R.string.settings_section_automation) to listOf(
|
||||||
|
SettingsEntry(
|
||||||
|
stringResource(R.string.scenarios_title),
|
||||||
|
stringResource(R.string.scenarios_sub, scenarioCount + (if (trusted.enabled && trusted.parkOnTrusted) 1 else 0) + (if (trusted.enabled && trusted.connectOnUntrusted) 1 else 0)),
|
||||||
|
ShxIcons.Bolt,
|
||||||
|
) { context.startActivity(Intent(context, org.strongswan.android.ui.ScenariosActivity::class.java)) },
|
||||||
|
SettingsEntry(
|
||||||
|
stringResource(R.string.settings_trusted_networks),
|
||||||
|
if (!trusted.enabled) stringResource(R.string.settings_off)
|
||||||
|
else trusted.trustedSsids.sorted().joinToString(", ").ifEmpty { stringResource(R.string.settings_trusted_none) },
|
||||||
|
ShxIcons.Wifi,
|
||||||
|
) { context.startActivity(Intent(context, org.strongswan.android.ui.ScenariosActivity::class.java)) },
|
||||||
|
SettingsEntry(
|
||||||
|
stringResource(R.string.health_check_title),
|
||||||
|
if (health.enabled) stringResource(R.string.settings_health_sub, health.intervalSec) else stringResource(R.string.settings_off),
|
||||||
|
ShxIcons.Pulse,
|
||||||
|
) { context.startActivity(Intent(context, ConnectivityCheckActivity::class.java)) },
|
||||||
|
),
|
||||||
|
stringResource(R.string.settings_protection) to listOf(
|
||||||
|
SettingsEntry(
|
||||||
|
stringResource(R.string.prot_title),
|
||||||
|
protectionSub(protection),
|
||||||
|
ShxIcons.Shield,
|
||||||
|
) { onOpen(SettingsPage.PROTECTION) },
|
||||||
|
SettingsEntry(
|
||||||
|
stringResource(R.string.settings_killswitch),
|
||||||
|
stringResource(if (lockdown) R.string.settings_killswitch_on else R.string.settings_killswitch_off),
|
||||||
|
ShxIcons.Lock,
|
||||||
|
warn = !lockdown,
|
||||||
|
) { onOpen(SettingsPage.KILLSWITCH) },
|
||||||
|
SettingsEntry(stringResource(R.string.settings_section_security), stringResource(R.string.settings_security_sub), ShxIcons.Shield) { onOpen(SettingsPage.SECURITY) },
|
||||||
|
),
|
||||||
|
stringResource(R.string.settings_app) to listOf(
|
||||||
|
SettingsEntry(stringResource(R.string.settings_stability), stringResource(R.string.settings_stability_sub), ShxIcons.Phone) { onOpen(SettingsPage.STABILITY) },
|
||||||
|
SettingsEntry(stringResource(R.string.settings_notifications), stringResource(R.string.settings_notifications_sub), ShxIcons.Bell) {
|
||||||
|
onOpen(SettingsPage.NOTIFICATIONS)
|
||||||
|
},
|
||||||
|
SettingsEntry(
|
||||||
|
stringResource(R.string.settings_section_updates),
|
||||||
|
if (updateState is UpdateState.Available) stringResource(R.string.settings_updates_available, (updateState as UpdateState.Available).info.versionName)
|
||||||
|
else stringResource(R.string.settings_updates_sub, versionName),
|
||||||
|
ShxIcons.Refresh,
|
||||||
|
warn = updateState is UpdateState.Available,
|
||||||
|
) { onOpen(SettingsPage.UPDATES) },
|
||||||
|
SettingsEntry(
|
||||||
|
stringResource(R.string.settings_page_appearance),
|
||||||
|
stringResource(
|
||||||
|
when (themeMode)
|
||||||
|
{
|
||||||
|
ThemeMode.SYSTEM -> R.string.theme_system
|
||||||
|
ThemeMode.LIGHT -> R.string.theme_light
|
||||||
|
ThemeMode.DARK -> R.string.theme_dark
|
||||||
|
}
|
||||||
|
),
|
||||||
|
ShxIcons.Moon,
|
||||||
|
) { onOpen(SettingsPage.APPEARANCE) },
|
||||||
|
),
|
||||||
|
stringResource(R.string.settings_section_support) to listOf(
|
||||||
|
SettingsEntry(stringResource(R.string.settings_help), stringResource(R.string.settings_help_sub), ShxIcons.Help) { onOpen(SettingsPage.HELP) },
|
||||||
|
SettingsEntry(stringResource(R.string.settings_log), stringResource(R.string.settings_log_sub), ShxIcons.Doc) {
|
||||||
|
context.startActivity(Intent(context, LogActivity::class.java))
|
||||||
|
},
|
||||||
|
SettingsEntry(
|
||||||
|
stringResource(R.string.trusted_certs_title),
|
||||||
|
when
|
||||||
|
{
|
||||||
|
certSummary.second > 0 -> stringResource(R.string.settings_certs_soon, certSummary.first, certSummary.second)
|
||||||
|
certSummary.first > 0 -> stringResource(R.string.settings_certs_profiles, certSummary.first)
|
||||||
|
else -> pluralStringResource(R.plurals.settings_certs_n, certs, certs)
|
||||||
|
},
|
||||||
|
ShxIcons.Cert,
|
||||||
|
) { context.startActivity(Intent(context, TrustedCertificatesActivity::class.java)) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||||
{
|
{
|
||||||
ShxSettingRow(icon = Icons.Rounded.Palette, title = stringResource(R.string.settings_page_appearance), onClick = { onOpen(SettingsPage.APPEARANCE) })
|
Row(
|
||||||
ShxSectionDivider()
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
ShxSettingRow(icon = Icons.Rounded.Bolt, title = stringResource(R.string.settings_section_automation), onClick = { onOpen(SettingsPage.AUTOMATION) })
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
ShxSectionDivider()
|
modifier = Modifier.padding(top = 4.dp).fillMaxWidth().height(48.dp).clip(RoundedCornerShape(24.dp)).background(c.tile).padding(horizontal = 16.dp),
|
||||||
ShxSettingRow(icon = Icons.Rounded.Security, title = stringResource(R.string.settings_section_security), onClick = { onOpen(SettingsPage.SECURITY) })
|
)
|
||||||
ShxSectionDivider()
|
{
|
||||||
ShxSettingRow(icon = Icons.Rounded.SystemUpdateAlt, title = stringResource(R.string.settings_section_updates), onClick = { onOpen(SettingsPage.UPDATES) })
|
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
ShxSectionDivider()
|
Box(Modifier.weight(1f))
|
||||||
ShxSettingRow(icon = Icons.Rounded.Description, title = stringResource(R.string.settings_section_support), onClick = { onOpen(SettingsPage.SUPPORT) })
|
{
|
||||||
|
if (query.isEmpty())
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.settings_search), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium), color = c.textSub)
|
||||||
|
}
|
||||||
|
BasicTextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = { query = it },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Medium, color = c.text),
|
||||||
|
cursorBrush = SolidColor(c.accent),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
groups.forEach { (title, rows) ->
|
||||||
|
val shown = rows.filter { query.isBlank() || it.title.contains(query.trim(), true) || it.sub.contains(query.trim(), true) }
|
||||||
|
if (shown.isNotEmpty())
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
ShxSectionHeader(title)
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
shown.forEachIndexed { i, row ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().heightIn(min = 62.dp).clickable(onClick = row.onClick).padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(c.tile))
|
||||||
|
{
|
||||||
|
Icon(row.icon, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp))
|
||||||
|
{
|
||||||
|
Text(row.title, style = ShxType.cardTitle.copy(fontWeight = FontWeight.Bold), color = c.text)
|
||||||
|
Text(
|
||||||
|
row.sub,
|
||||||
|
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
|
||||||
|
color = if (row.warn) c.warning else c.textSub,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
if (i < shown.lastIndex)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showKillSwitchDialog)
|
||||||
|
{
|
||||||
|
ShxAlertDialog(
|
||||||
|
onDismissRequest = { showKillSwitchDialog = false },
|
||||||
|
title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
|
||||||
|
text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
|
||||||
|
confirmButton = {
|
||||||
|
ShxDialogButton(primary = true, onClick = {
|
||||||
|
showKillSwitchDialog = false
|
||||||
|
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||||
|
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
ShxDialogButton(onClick = { showKillSwitchDialog = false }) {
|
||||||
|
Text(stringResource(android.R.string.cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,7 +746,7 @@ private fun ThemeModeRow()
|
|||||||
remember { ShxThemeSettings.load(context) }
|
remember { ShxThemeSettings.load(context) }
|
||||||
Column(Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md))
|
Column(Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md))
|
||||||
{
|
{
|
||||||
Text(stringResource(R.string.pref_theme_title), style = MaterialTheme.typography.bodyLarge)
|
Text(stringResource(R.string.pref_theme_title), style = ShxType.body)
|
||||||
Spacer(Modifier.height(ShxSpacing.sm))
|
Spacer(Modifier.height(ShxSpacing.sm))
|
||||||
val options = listOf(
|
val options = listOf(
|
||||||
ThemeMode.SYSTEM to R.string.theme_system,
|
ThemeMode.SYSTEM to R.string.theme_system,
|
||||||
@@ -806,25 +787,25 @@ private fun KillSwitchRow()
|
|||||||
}
|
}
|
||||||
if (showKillSwitchDialog)
|
if (showKillSwitchDialog)
|
||||||
{
|
{
|
||||||
AlertDialog(
|
ShxAlertDialog(
|
||||||
onDismissRequest = { showKillSwitchDialog = false },
|
onDismissRequest = { showKillSwitchDialog = false },
|
||||||
title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
|
title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
|
||||||
text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
|
text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
ShxDialogButton(primary = true, onClick = {
|
||||||
showKillSwitchDialog = false
|
showKillSwitchDialog = false
|
||||||
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||||
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
|
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = { showKillSwitchDialog = false }) {
|
ShxDialogButton(onClick = { showKillSwitchDialog = false }) {
|
||||||
Text(stringResource(android.R.string.cancel))
|
Text(stringResource(android.R.string.cancel))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ShxSettingRow(
|
ShxSettingRow(
|
||||||
icon = Icons.Rounded.Security,
|
icon = ShxIcons.Shield,
|
||||||
title = stringResource(R.string.kill_switch_title),
|
title = stringResource(R.string.kill_switch_title),
|
||||||
subtitle = stringResource(
|
subtitle = stringResource(
|
||||||
if (lockdownActive) R.string.kill_switch_summary_on
|
if (lockdownActive) R.string.kill_switch_summary_on
|
||||||
|
|||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
/*
|
||||||
|
* 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.compose
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
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.getValue
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
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.asImageBitmap
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
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 kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.data.VpnProfile
|
||||||
|
import org.strongswan.android.logic.VpnProfileExporter
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxNote
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import org.strongswan.android.ui.compose.home.serverCode
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
|
||||||
|
/** The profile as .sswan text without the password, compacted for a QR code. */
|
||||||
|
private suspend fun profileText(context: Context, profile: VpnProfile): String? = withContext(Dispatchers.IO) {
|
||||||
|
val uri = VpnProfileExporter.exportProfile(context, profile, false) ?: return@withContext null
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.openInputStream(uri)?.use { it.readBytes().toString(Charsets.UTF_8) }?.let { JSONObject(it).toString() }
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Share server» (F_Share): QR of the profile, who it is, and file / copy actions. */
|
||||||
|
@Composable
|
||||||
|
fun ShareProfileScreen(profile: VpnProfile, onDismiss: () -> Unit, onSendFile: () -> Unit)
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val text by produceState<String?>(null, profile) { value = profileText(context, profile) }
|
||||||
|
/* the logo in the middle needs the high error correction; a bigger profile falls back to a plain code */
|
||||||
|
val qr by produceState<Pair<android.graphics.Bitmap, Boolean>?>(null, text) {
|
||||||
|
val t = text ?: return@produceState
|
||||||
|
value = withContext(Dispatchers.Default) {
|
||||||
|
ProfileQr.bitmap(t, highCorrection = true)?.let { it to true } ?: ProfileQr.bitmap(t)?.let { it to false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false))
|
||||||
|
{
|
||||||
|
Column(Modifier.fillMaxSize().background(c.background).statusBarsPadding().navigationBarsPadding().verticalScroll(rememberScrollState()))
|
||||||
|
{
|
||||||
|
ShxPageHeader(stringResource(R.string.share_title), onDismiss)
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 28.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.share_hint), style = ShxType.note.copy(fontSize = 15.sp), color = c.textSoft, textAlign = TextAlign.Center)
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.shadow(14.dp, RoundedCornerShape(32.dp), ambientColor = c.onAccentContainer.copy(alpha = 0.2f), spotColor = c.onAccentContainer.copy(alpha = 0.2f))
|
||||||
|
.clip(RoundedCornerShape(32.dp))
|
||||||
|
.background(Color.White)
|
||||||
|
.border(1.dp, c.cardBorder, RoundedCornerShape(32.dp))
|
||||||
|
.padding(22.dp)
|
||||||
|
.size(252.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val code = qr
|
||||||
|
if (code != null)
|
||||||
|
{
|
||||||
|
Image(code.first.asImageBitmap(), contentDescription = stringResource(R.string.share_qr_desc, profile.name ?: ""), modifier = Modifier.fillMaxSize())
|
||||||
|
if (code.second)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(52.dp).clip(RoundedCornerShape(14.dp)).background(Color.White).padding(4.dp).clip(RoundedCornerShape(11.dp)).background(c.accent),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = Color.White, modifier = Modifier.size(32.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (text != null)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.share_too_big), style = ShxType.note, color = c.textSub, textAlign = TextAlign.Center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(CircleShape).background(c.accentContainer))
|
||||||
|
{
|
||||||
|
Text(serverCode(profile, null), style = ShxType.chip.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(1.dp))
|
||||||
|
{
|
||||||
|
Text(profile.name ?: profile.gateway ?: "", style = ShxType.rowTitle.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.ExtraBold), color = c.text)
|
||||||
|
Text(stringResource(R.string.share_without_secrets), style = ShxType.meta.copy(fontWeight = androidx.compose.ui.text.font.FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.size(12.dp))
|
||||||
|
ShxNote(stringResource(R.string.share_cert_note), tone = ShxTone.WARNING)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.share_send_file), onSendFile, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.share_copy),
|
||||||
|
{
|
||||||
|
text?.let {
|
||||||
|
(context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.setPrimaryClip(ClipData.newPlainText("sswan", it))
|
||||||
|
Toast.makeText(context, R.string.share_copied, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f), enabled = text != null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
-72
@@ -37,7 +37,6 @@ import androidx.compose.material.icons.outlined.AltRoute
|
|||||||
import androidx.compose.material.icons.outlined.Home
|
import androidx.compose.material.icons.outlined.Home
|
||||||
import androidx.compose.material.icons.outlined.Settings
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
import androidx.compose.material.icons.outlined.Storage
|
import androidx.compose.material.icons.outlined.Storage
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Checkbox
|
import androidx.compose.material3.Checkbox
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -71,6 +70,13 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteDefaults
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import org.strongswan.android.data.VpnProfile
|
import org.strongswan.android.data.VpnProfile
|
||||||
import org.strongswan.android.data.VpnProfileDataSource
|
import org.strongswan.android.data.VpnProfileDataSource
|
||||||
import org.strongswan.android.logic.StrongSwanApplication
|
import org.strongswan.android.logic.StrongSwanApplication
|
||||||
@@ -101,13 +107,32 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
|||||||
{
|
{
|
||||||
val state by viewModel.ui.collectAsStateWithLifecycle()
|
val state by viewModel.ui.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val addProfile = {
|
/* «Add server» sheet: QR, link, file or manual entry */
|
||||||
context.startActivity(Intent(context, VpnProfileDetailActivity::class.java))
|
var addSheetOpen by remember { mutableStateOf(false) }
|
||||||
|
val addProfile = { addSheetOpen = true }
|
||||||
|
val addRequested by ShxNavigation.addServer.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(addRequested)
|
||||||
|
{
|
||||||
|
if (addRequested)
|
||||||
|
{
|
||||||
|
viewModel.setTab(ShxTab.HOME)
|
||||||
|
addSheetOpen = true
|
||||||
|
ShxNavigation.consumeAddServer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val fastestRequested by ShxNavigation.fastest.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(fastestRequested, state.profiles.isNotEmpty())
|
||||||
|
{
|
||||||
|
if (fastestRequested && state.profiles.isNotEmpty())
|
||||||
|
{
|
||||||
|
viewModel.setTab(ShxTab.HOME)
|
||||||
|
viewModel.connectFastest(context)
|
||||||
|
ShxNavigation.consumeFastest()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val editProfile: (VpnProfile) -> Unit = { profile ->
|
val editProfile: (VpnProfile) -> Unit = { profile ->
|
||||||
val intent = Intent(context, VpnProfileDetailActivity::class.java)
|
/* F_Profile: the server page, editing goes from there */
|
||||||
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
|
context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString()))
|
||||||
context.startActivity(intent)
|
|
||||||
}
|
}
|
||||||
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
|
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
|
||||||
val shareProfile: (VpnProfile) -> Unit = { pendingShare = it }
|
val shareProfile: (VpnProfile) -> Unit = { pendingShare = it }
|
||||||
@@ -167,11 +192,15 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
|||||||
navigationSuiteItems = {
|
navigationSuiteItems = {
|
||||||
ShxNavigationSuiteItems(selected = state.tab, onSelect = viewModel::setTab, colors = navItemColors)
|
ShxNavigationSuiteItems(selected = state.tab, onSelect = viewModel::setTab, colors = navItemColors)
|
||||||
},
|
},
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
containerColor = LocalShxColors.current.background,
|
||||||
|
navigationSuiteColors = NavigationSuiteDefaults.colors(
|
||||||
|
navigationBarContainerColor = LocalShxColors.current.tile,
|
||||||
|
navigationRailContainerColor = LocalShxColors.current.tile,
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background,
|
color = LocalShxColors.current.background,
|
||||||
) {
|
) {
|
||||||
Box(Modifier.fillMaxSize())
|
Box(Modifier.fillMaxSize())
|
||||||
{
|
{
|
||||||
@@ -209,6 +238,7 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
|||||||
onRestore = viewModel::restore,
|
onRestore = viewModel::restore,
|
||||||
onShare = shareProfile,
|
onShare = shareProfile,
|
||||||
onAdd = addProfile,
|
onAdd = addProfile,
|
||||||
|
onFastest = { viewModel.connectFastest(context) },
|
||||||
)
|
)
|
||||||
ShxTab.ROUTING -> RoutingScreen()
|
ShxTab.ROUTING -> RoutingScreen()
|
||||||
ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles)
|
ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles)
|
||||||
@@ -220,81 +250,49 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
pendingShare?.let { profile ->
|
pendingShare?.let { profile ->
|
||||||
ShareProfileDialog(
|
ShareProfileScreen(
|
||||||
profile = profile,
|
profile = profile,
|
||||||
onDismiss = { pendingShare = null },
|
onDismiss = { pendingShare = null },
|
||||||
onConfirm = { includePassword ->
|
onSendFile = { viewModel.shareProfile(context, profile, false) },
|
||||||
pendingShare = null
|
|
||||||
viewModel.shareProfile(context, profile, includePassword)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (addSheetOpen)
|
||||||
|
{
|
||||||
|
AddServerSheet(onDismiss = { addSheetOpen = false })
|
||||||
|
}
|
||||||
|
/* update sheets, the mandatory update included, over every tab */
|
||||||
|
UpdateSheetHost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
/** Item colors of the mockup nav bar: #E7DFFF pill, #1C1B20 selected, #49454F the rest. */
|
||||||
private fun ShareProfileDialog(profile: VpnProfile, onDismiss: () -> Unit, onConfirm: (Boolean) -> Unit)
|
|
||||||
{
|
|
||||||
var includePassword by remember { mutableStateOf(false) }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(stringResource(R.string.share_profile)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
Text(stringResource(R.string.share_profile_password_warning), style = MaterialTheme.typography.bodyMedium)
|
|
||||||
Spacer(Modifier.height(ShxSpacing.md))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.clickable { includePassword = !includePassword },
|
|
||||||
) {
|
|
||||||
Checkbox(checked = includePassword, onCheckedChange = { includePassword = it })
|
|
||||||
Spacer(Modifier.width(ShxSpacing.xs))
|
|
||||||
Text(stringResource(R.string.share_profile_include_password))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { onConfirm(includePassword) }) {
|
|
||||||
Text(stringResource(R.string.share_profile))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Item colors for the adaptive navigation suite, keeping the brand palette. */
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun shxNavigationItemColors(): NavigationSuiteItemColors
|
private fun shxNavigationItemColors(): NavigationSuiteItemColors
|
||||||
{
|
{
|
||||||
val bg = MaterialTheme.colorScheme.surfaceContainer
|
val c = LocalShxColors.current
|
||||||
val indicator = MaterialTheme.colorScheme.primary.copy(alpha = 0.32f).compositeOver(bg)
|
|
||||||
return NavigationSuiteItemColors(
|
return NavigationSuiteItemColors(
|
||||||
navigationBarItemColors = NavigationBarItemDefaults.colors(
|
navigationBarItemColors = NavigationBarItemDefaults.colors(
|
||||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
selectedIconColor = c.text,
|
||||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
unselectedIconColor = c.textSoft,
|
||||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
selectedTextColor = c.text,
|
||||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
unselectedTextColor = c.textSoft,
|
||||||
indicatorColor = indicator,
|
indicatorColor = c.accentContainer,
|
||||||
),
|
),
|
||||||
navigationRailItemColors = NavigationRailItemDefaults.colors(
|
navigationRailItemColors = NavigationRailItemDefaults.colors(
|
||||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
selectedIconColor = c.text,
|
||||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
unselectedIconColor = c.textSoft,
|
||||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
selectedTextColor = c.text,
|
||||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
unselectedTextColor = c.textSoft,
|
||||||
indicatorColor = indicator,
|
indicatorColor = c.accentContainer,
|
||||||
),
|
),
|
||||||
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
|
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
|
||||||
selectedContainerColor = indicator,
|
selectedContainerColor = c.accentContainer,
|
||||||
unselectedContainerColor = Color.Transparent,
|
unselectedContainerColor = Color.Transparent,
|
||||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
selectedIconColor = c.text,
|
||||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
unselectedIconColor = c.textSoft,
|
||||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
selectedTextColor = c.text,
|
||||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
unselectedTextColor = c.textSoft,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -316,22 +314,30 @@ private fun NavigationSuiteScope.ShxNavigationSuiteItems(
|
|||||||
onClick = { onSelect(tab) },
|
onClick = { onSelect(tab) },
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = tab.icon(selected = tab == selected),
|
imageVector = tab.icon(),
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
stringResource(tab.label()),
|
||||||
|
style = ShxType.nav.copy(fontWeight = if (tab == selected) FontWeight.ExtraBold else FontWeight.SemiBold),
|
||||||
|
maxLines = 1,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
label = { Text(stringResource(tab.label())) },
|
|
||||||
colors = colors,
|
colors = colors,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ShxTab.icon(selected: Boolean): ImageVector = when (this)
|
/* the line icons of the mockup tab bar */
|
||||||
|
private fun ShxTab.icon(): ImageVector = when (this)
|
||||||
{
|
{
|
||||||
ShxTab.HOME -> if (selected) Icons.Filled.Home else Icons.Outlined.Home
|
ShxTab.HOME -> ShxIcons.Power
|
||||||
ShxTab.PROFILES -> if (selected) Icons.Filled.Storage else Icons.Outlined.Storage
|
ShxTab.PROFILES -> ShxIcons.Globe
|
||||||
ShxTab.ROUTING -> if (selected) Icons.Filled.AltRoute else Icons.Outlined.AltRoute
|
ShxTab.ROUTING -> ShxIcons.Route
|
||||||
ShxTab.SETTINGS -> if (selected) Icons.Filled.Settings else Icons.Outlined.Settings
|
ShxTab.SETTINGS -> ShxIcons.Sliders
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ShxTab.label(): Int = when (this)
|
private fun ShxTab.label(): Int = when (this)
|
||||||
|
|||||||
+43
@@ -35,4 +35,47 @@ object ShxNavigation
|
|||||||
{
|
{
|
||||||
_requestedTab.value = null
|
_requestedTab.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* «open the Add server sheet» request (end of the first-run screens) */
|
||||||
|
private val _addServer = MutableStateFlow(false)
|
||||||
|
val addServer: StateFlow<Boolean> = _addServer
|
||||||
|
|
||||||
|
fun requestAddServer()
|
||||||
|
{
|
||||||
|
_addServer.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeAddServer()
|
||||||
|
{
|
||||||
|
_addServer.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/* «connect the fastest server» request (widget) */
|
||||||
|
private val _fastest = MutableStateFlow(false)
|
||||||
|
val fastest: StateFlow<Boolean> = _fastest
|
||||||
|
|
||||||
|
fun requestFastest()
|
||||||
|
{
|
||||||
|
_fastest.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeFastest()
|
||||||
|
{
|
||||||
|
_fastest.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/* «What is new» of the update notification: the Updates page of Settings */
|
||||||
|
private val _updatesPage = MutableStateFlow(false)
|
||||||
|
val updatesPage: StateFlow<Boolean> = _updatesPage
|
||||||
|
|
||||||
|
fun requestUpdatesPage()
|
||||||
|
{
|
||||||
|
request(ShxTab.SETTINGS)
|
||||||
|
_updatesPage.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consumeUpdatesPage()
|
||||||
|
{
|
||||||
|
_updatesPage.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
-460
@@ -1,460 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 android.Manifest
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.provider.Settings
|
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
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.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Add
|
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
|
||||||
import androidx.compose.material.icons.rounded.Language
|
|
||||||
import androidx.compose.material.icons.rounded.Wifi
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.Lifecycle
|
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
|
||||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.ui.semantics.heading
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import org.strongswan.android.R
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxSection
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxSectionDivider
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxSettingRow
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxSettingToggleRow
|
|
||||||
import org.strongswan.android.data.VpnProfile
|
|
||||||
import org.strongswan.android.logic.autoconnect.AutoConnectManager
|
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Trusted networks settings: when enabled, the VPN is started on cellular and untrusted
|
|
||||||
* Wi-Fi, and auto sessions are stopped on trusted Wi-Fi.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun TrustedNetworksScreen(
|
|
||||||
profiles: List<VpnProfile>,
|
|
||||||
currentSsid: String?,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
val context = LocalContext.current
|
|
||||||
val manager = remember { AutoConnectManager.getInstance(context) }
|
|
||||||
var config by remember { mutableStateOf(manager.currentConfig) }
|
|
||||||
var showProfilePicker by remember { mutableStateOf(false) }
|
|
||||||
var showAddSsid by remember { mutableStateOf(false) }
|
|
||||||
var pendingDeleteSsid by remember { mutableStateOf<String?>(null) }
|
|
||||||
var hasBackgroundLocation by remember { mutableStateOf(isBackgroundLocationGranted(context)) }
|
|
||||||
var askedBackground by remember { mutableStateOf(false) }
|
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
|
||||||
|
|
||||||
fun refreshPermissions()
|
|
||||||
{
|
|
||||||
hasBackgroundLocation = isBackgroundLocationGranted(context)
|
|
||||||
if (isFineLocationGranted(context))
|
|
||||||
{
|
|
||||||
manager.reloadConfig()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val requestBackground = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestPermission()
|
|
||||||
) { refreshPermissions() }
|
|
||||||
val requestForeground = rememberLauncherForActivityResult(
|
|
||||||
ActivityResultContracts.RequestMultiplePermissions()
|
|
||||||
) {
|
|
||||||
refreshPermissions()
|
|
||||||
if (isFineLocationGranted(context) && !isBackgroundLocationGranted(context) &&
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
|
||||||
{
|
|
||||||
askedBackground = true
|
|
||||||
requestBackground.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requestSsidPermissions()
|
|
||||||
{
|
|
||||||
if (!isFineLocationGranted(context))
|
|
||||||
{
|
|
||||||
requestForeground.launch(wifiSsidPermissions())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!isBackgroundLocationGranted(context) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
|
||||||
{
|
|
||||||
if (askedBackground)
|
|
||||||
{
|
|
||||||
openAppDetails(context)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
askedBackground = true
|
|
||||||
requestBackground.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DisposableEffect(lifecycleOwner)
|
|
||||||
{
|
|
||||||
val observer = LifecycleEventObserver { _, event ->
|
|
||||||
if (event == Lifecycle.Event.ON_RESUME)
|
|
||||||
{
|
|
||||||
refreshPermissions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lifecycleOwner.lifecycle.addObserver(observer)
|
|
||||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
|
||||||
}
|
|
||||||
val selectedProfile = profiles.firstOrNull { it.getUUID().toString() == config.profileUuid }
|
|
||||||
val enabled = config.enabled
|
|
||||||
|
|
||||||
fun update(transform: (AutoConnectManager.Config) -> AutoConnectManager.Config)
|
|
||||||
{
|
|
||||||
val updated = transform(config)
|
|
||||||
manager.updateConfig { updated }
|
|
||||||
config = updated
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(bottom = ShxSpacing.xl),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
ShxSection(title = stringResource(R.string.settings_section_general))
|
|
||||||
{
|
|
||||||
ShxSettingToggleRow(
|
|
||||||
icon = Icons.Rounded.Wifi,
|
|
||||||
title = stringResource(R.string.auto_connect_title),
|
|
||||||
subtitle = stringResource(R.string.auto_connect_summary),
|
|
||||||
checked = enabled,
|
|
||||||
onCheckedChange = { checked ->
|
|
||||||
/* the permission is requested from the explainer card below, not
|
|
||||||
* straight from the toggle */
|
|
||||||
update { it.copy(enabled = checked) }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ShxSectionDivider()
|
|
||||||
ShxSettingRow(
|
|
||||||
icon = Icons.Rounded.Language,
|
|
||||||
title = stringResource(R.string.auto_connect_profile),
|
|
||||||
subtitle = selectedProfile?.name
|
|
||||||
?: stringResource(R.string.auto_connect_profile_none),
|
|
||||||
enabled = enabled,
|
|
||||||
onClick = { showProfilePicker = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enabled && !hasBackgroundLocation)
|
|
||||||
{
|
|
||||||
Spacer(Modifier.size(ShxSpacing.md))
|
|
||||||
Surface(
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
color = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = ShxSpacing.lg),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(ShxSpacing.lg),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md))
|
|
||||||
{
|
|
||||||
Icon(Icons.Rounded.Wifi, contentDescription = null)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.auto_connect_location_title),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
modifier = Modifier.semantics { heading() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.auto_connect_location_why),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.auto_connect_location_banner),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
Button(
|
|
||||||
onClick = { requestSsidPermissions() },
|
|
||||||
modifier = Modifier.align(Alignment.End),
|
|
||||||
) { Text(stringResource(R.string.auto_connect_location_grant)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ShxSection(title = stringResource(R.string.auto_connect_trusted_title))
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.auto_connect_trusted_hint),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm),
|
|
||||||
)
|
|
||||||
if (config.trustedSsids.isEmpty())
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.auto_connect_no_networks),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
val sortedSsids = remember(config.trustedSsids) { config.trustedSsids.sorted() }
|
|
||||||
sortedSsids.forEachIndexed { index, ssid ->
|
|
||||||
if (index > 0)
|
|
||||||
{
|
|
||||||
ShxSectionDivider()
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = ShxSpacing.lg),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.Wifi,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = ssid,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
|
|
||||||
)
|
|
||||||
IconButton(onClick = { pendingDeleteSsid = ssid }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.Delete,
|
|
||||||
contentDescription = stringResource(R.string.delete_profile),
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ShxSectionDivider()
|
|
||||||
TrustedAddNetworkRow(
|
|
||||||
currentSsid = currentSsid,
|
|
||||||
onAddCurrent = { ssid -> update { it.copy(trustedSsids = it.trustedSsids + ssid) } },
|
|
||||||
onAddManual = { showAddSsid = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showProfilePicker)
|
|
||||||
{
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showProfilePicker = false },
|
|
||||||
title = { Text(stringResource(R.string.auto_connect_profile)) },
|
|
||||||
text = {
|
|
||||||
Column {
|
|
||||||
profiles.forEach { profile ->
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable {
|
|
||||||
update { it.copy(profileUuid = profile.getUUID().toString()) }
|
|
||||||
showProfilePicker = false
|
|
||||||
}
|
|
||||||
.padding(vertical = 10.dp),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
RadioButton(
|
|
||||||
selected = config.profileUuid == profile.getUUID().toString(),
|
|
||||||
onClick = null,
|
|
||||||
)
|
|
||||||
Text(profile.name ?: "", modifier = Modifier.padding(start = ShxSpacing.md))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = { showProfilePicker = false }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAddSsid)
|
|
||||||
{
|
|
||||||
var ssid by remember { mutableStateOf("") }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showAddSsid = false },
|
|
||||||
title = { Text(stringResource(R.string.auto_connect_add_network)) },
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = ssid,
|
|
||||||
onValueChange = { ssid = it },
|
|
||||||
singleLine = true,
|
|
||||||
label = { Text(stringResource(R.string.auto_connect_ssid_label)) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
val trimmed = ssid.trim()
|
|
||||||
if (trimmed.isNotEmpty())
|
|
||||||
{
|
|
||||||
update { it.copy(trustedSsids = it.trustedSsids + trimmed) }
|
|
||||||
}
|
|
||||||
showAddSsid = false
|
|
||||||
},
|
|
||||||
enabled = ssid.isNotBlank(),
|
|
||||||
) { Text(stringResource(R.string.add_profile_cta)) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showAddSsid = false }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingDeleteSsid?.let { ssid ->
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { pendingDeleteSsid = null },
|
|
||||||
title = { Text(stringResource(R.string.auto_connect_remove_network)) },
|
|
||||||
text = { Text(ssid) },
|
|
||||||
confirmButton = {
|
|
||||||
TextButton(onClick = {
|
|
||||||
update { it.copy(trustedSsids = it.trustedSsids - ssid) }
|
|
||||||
pendingDeleteSsid = null
|
|
||||||
}) { Text(stringResource(R.string.delete_profile), color = MaterialTheme.colorScheme.error) }
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { pendingDeleteSsid = null }) {
|
|
||||||
Text(stringResource(android.R.string.cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TrustedAddNetworkRow(
|
|
||||||
currentSsid: String?,
|
|
||||||
onAddCurrent: (String) -> Unit,
|
|
||||||
onAddManual: () -> Unit,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.Center,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (currentSsid != null)
|
|
||||||
{
|
|
||||||
TextButton(onClick = { onAddCurrent(currentSsid) })
|
|
||||||
{
|
|
||||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
||||||
Spacer(Modifier.size(ShxSpacing.sm))
|
|
||||||
Text(stringResource(R.string.auto_connect_add_current, currentSsid))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
TextButton(onClick = onAddManual)
|
|
||||||
{
|
|
||||||
Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp))
|
|
||||||
Spacer(Modifier.size(ShxSpacing.sm))
|
|
||||||
Text(stringResource(R.string.auto_connect_add_network))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isFineLocationGranted(context: Context): Boolean =
|
|
||||||
ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) ==
|
|
||||||
PackageManager.PERMISSION_GRANTED
|
|
||||||
|
|
||||||
private fun isBackgroundLocationGranted(context: Context): Boolean
|
|
||||||
{
|
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q)
|
|
||||||
{
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return ContextCompat.checkSelfPermission(
|
|
||||||
context, Manifest.permission.ACCESS_BACKGROUND_LOCATION
|
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun wifiSsidPermissions(): Array<String> = buildList {
|
|
||||||
add(Manifest.permission.ACCESS_FINE_LOCATION)
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
|
||||||
{
|
|
||||||
add(Manifest.permission.NEARBY_WIFI_DEVICES)
|
|
||||||
}
|
|
||||||
}.toTypedArray()
|
|
||||||
|
|
||||||
private fun openAppDetails(context: Context)
|
|
||||||
{
|
|
||||||
context.startActivity(
|
|
||||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
|
||||||
data = Uri.fromParts("package", context.packageName, null)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+658
@@ -0,0 +1,658 @@
|
|||||||
|
/*
|
||||||
|
* 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.compose
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.compose.LocalActivity
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
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.height
|
||||||
|
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.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.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.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
|
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.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.preference.PreferenceManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import org.strongswan.android.BuildConfig
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.updates.UpdateError
|
||||||
|
import org.strongswan.android.logic.updates.UpdateInfo
|
||||||
|
import org.strongswan.android.logic.updates.UpdateInstaller
|
||||||
|
import org.strongswan.android.logic.updates.UpdateManager
|
||||||
|
import org.strongswan.android.logic.updates.UpdateState
|
||||||
|
import org.strongswan.android.logic.updates.noteLines
|
||||||
|
import org.strongswan.android.logic.updates.pickNotes
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButton
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxChoiceCard
|
||||||
|
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.ShxSectionHeader
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxSheetTitle
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxTone
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxToggleRow
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import org.strongswan.android.utils.Constants
|
||||||
|
|
||||||
|
/** Which update sheet the user opened: «What is new» (App2) or the install attempt (App3 / App5). */
|
||||||
|
enum class UpdateSheet { REVIEW, ATTEMPT }
|
||||||
|
|
||||||
|
/** Shared between the Updates page and the one sheet host of the activity. */
|
||||||
|
object UpdateSheets
|
||||||
|
{
|
||||||
|
val shown = MutableStateFlow<UpdateSheet?>(null)
|
||||||
|
|
||||||
|
fun show(sheet: UpdateSheet?)
|
||||||
|
{
|
||||||
|
shown.value = sheet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All update sheets of an activity (ShxApp and SettingsActivity call it once):
|
||||||
|
* «What is new» (App2), the download (App3), the mandatory update (App4),
|
||||||
|
* the failure (App5) and the install permission (App6).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UpdateSheetHost()
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val manager = remember { UpdateManager.getInstance(context) }
|
||||||
|
val state by manager.state.collectAsStateWithLifecycle()
|
||||||
|
val sheet by UpdateSheets.shown.collectAsStateWithLifecycle()
|
||||||
|
val mandatory = remember(state) { manager.pendingMandatory() }
|
||||||
|
var askPermission by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val install = {
|
||||||
|
UpdateSheets.show(UpdateSheet.ATTEMPT)
|
||||||
|
manager.downloadAndInstall()
|
||||||
|
}
|
||||||
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult())
|
||||||
|
{
|
||||||
|
if (UpdateInstaller.canInstall(context)) install()
|
||||||
|
else Toast.makeText(context, R.string.update_permission_denied, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
val start: () -> Unit = {
|
||||||
|
if (UpdateInstaller.canInstall(context)) install() else askPermission = true
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit)
|
||||||
|
{ /* a mandatory update found earlier (e.g. before a restart): make sure it is still current */
|
||||||
|
if (manager.pendingMandatory() != null && state !is UpdateState.Available && state !is UpdateState.Downloading)
|
||||||
|
{
|
||||||
|
manager.checkNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state)
|
||||||
|
{ /* the installer took over, or the download was cancelled */
|
||||||
|
if (sheet == UpdateSheet.ATTEMPT && (state is UpdateState.Idle || state is UpdateState.UpToDate))
|
||||||
|
{
|
||||||
|
UpdateSheets.show(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when
|
||||||
|
{
|
||||||
|
askPermission -> InstallPermissionSheet(
|
||||||
|
onOpen = {
|
||||||
|
askPermission = false
|
||||||
|
launcher.launch(UpdateInstaller.createInstallPermissionIntent(context))
|
||||||
|
},
|
||||||
|
onDismiss = { askPermission = false },
|
||||||
|
)
|
||||||
|
sheet == UpdateSheet.ATTEMPT -> ShxSheet(onDismiss = { UpdateSheets.show(null) })
|
||||||
|
{
|
||||||
|
when (val s = state)
|
||||||
|
{
|
||||||
|
is UpdateState.Failed -> UpdateFailedContent(s, onRetry = start)
|
||||||
|
else -> UpdateProgressContent(s, onCancel = { manager.cancelDownload() }, onInstall = start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mandatory != null -> MandatoryUpdateSheet(mandatory.first, mandatory.second, onUpdate = start)
|
||||||
|
sheet == UpdateSheet.REVIEW ->
|
||||||
|
{
|
||||||
|
val info = (state as? UpdateState.Available)?.info
|
||||||
|
if (info != null) UpdateReviewSheet(info, onInstall = start, onDismiss = { UpdateSheets.show(null) })
|
||||||
|
else androidx.compose.runtime.SideEffect { UpdateSheets.show(null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Updates» page (App1): the available version, automatic mode, channel, safety note, manual check. */
|
||||||
|
@Composable
|
||||||
|
fun UpdatesPage()
|
||||||
|
{
|
||||||
|
val context = LocalContext.current
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val manager = remember { UpdateManager.getInstance(context) }
|
||||||
|
val prefs = remember { PreferenceManager.getDefaultSharedPreferences(context) }
|
||||||
|
val state by manager.state.collectAsStateWithLifecycle()
|
||||||
|
val info = (state as? UpdateState.Available)?.info ?: (state as? UpdateState.Downloading)?.info
|
||||||
|
val busy = state is UpdateState.Checking || state is UpdateState.Downloading
|
||||||
|
var beta by remember { mutableStateOf(manager.isBetaChannel()) }
|
||||||
|
var askCode by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Column(Modifier.padding(horizontal = ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
|
||||||
|
{
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(24.dp)).background(c.accentContainer).padding(ShxSpacing.lg),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp))
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(52.dp).clip(RoundedCornerShape(16.dp)).background(c.accent))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Refresh, contentDescription = null, tint = c.onAccent, modifier = Modifier.size(26.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
when
|
||||||
|
{
|
||||||
|
info != null -> stringResource(R.string.update_available_fmt, info.versionName)
|
||||||
|
state is UpdateState.UpToDate -> stringResource(R.string.update_card_uptodate)
|
||||||
|
else -> "SHX VPN ${BuildConfig.VERSION_NAME}"
|
||||||
|
},
|
||||||
|
style = ShxType.cardHeading, color = c.onAccentContainerStrong,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
when
|
||||||
|
{
|
||||||
|
info != null -> stringResource(R.string.updates_have_size, BuildConfig.VERSION_NAME, sizeText(info.size))
|
||||||
|
state is UpdateState.Checking -> stringResource(R.string.update_card_checking)
|
||||||
|
state is UpdateState.Failed -> stringResource(R.string.update_card_failed)
|
||||||
|
state is UpdateState.UpToDate -> stringResource(R.string.update_card_version, BuildConfig.VERSION_NAME)
|
||||||
|
else -> stringResource(R.string.update_status_idle)
|
||||||
|
},
|
||||||
|
style = ShxType.sub, color = c.onAccentContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (info != null)
|
||||||
|
{
|
||||||
|
val downloading = state as? UpdateState.Downloading
|
||||||
|
ShxButton(
|
||||||
|
if (downloading != null) stringResource(R.string.update_downloading_fmt, downloading.progress) else stringResource(R.string.updates_review),
|
||||||
|
{ UpdateSheets.show(if (downloading != null) UpdateSheet.ATTEMPT else UpdateSheet.REVIEW) },
|
||||||
|
height = 52.dp, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ShxSectionHeader(stringResource(R.string.updates_section_auto))
|
||||||
|
ShxPanel()
|
||||||
|
{
|
||||||
|
var autoCheck by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_AUTO_UPDATE_CHECK, true)) }
|
||||||
|
var silent by remember { mutableStateOf(prefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)) }
|
||||||
|
val hours = remember { manager.checkIntervalHours().toInt() }
|
||||||
|
ShxToggleRow(
|
||||||
|
stringResource(R.string.updates_auto_check), autoCheck,
|
||||||
|
{ autoCheck = it; prefs.edit().putBoolean(Constants.PREF_AUTO_UPDATE_CHECK, it).apply() },
|
||||||
|
subtitle = pluralStringResource(R.plurals.updates_auto_check_sub, hours, hours), divider = true,
|
||||||
|
)
|
||||||
|
ShxToggleRow(
|
||||||
|
stringResource(R.string.updates_silent), silent,
|
||||||
|
{ silent = it; prefs.edit().putBoolean(Constants.PREF_SILENT_UPDATES, it).apply() },
|
||||||
|
subtitle = stringResource(R.string.updates_silent_sub),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ShxSectionHeader(stringResource(R.string.updates_section_channel))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxChoiceCard(
|
||||||
|
stringResource(R.string.updates_channel_stable), stringResource(R.string.updates_channel_stable_sub), !beta,
|
||||||
|
{
|
||||||
|
if (beta)
|
||||||
|
{
|
||||||
|
beta = false
|
||||||
|
manager.setChannel(beta = false, token = null)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
ShxChoiceCard(
|
||||||
|
stringResource(R.string.updates_channel_beta), stringResource(R.string.updates_channel_beta_sub), beta,
|
||||||
|
{ if (!beta) askCode = true },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(stringResource(R.string.updates_channel_hint), style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp))
|
||||||
|
|
||||||
|
UpdateSafeNote(stringResource(R.string.install_perm_safe))
|
||||||
|
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.updates_check_now), { manager.checkNow() },
|
||||||
|
style = ShxButtonStyle.SOFT, height = 52.dp, enabled = !busy, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
val last = remember(state) { prefs.getLong(Constants.PREF_LAST_UPDATE_CHECK, 0L) }
|
||||||
|
Text(
|
||||||
|
when (val s = state)
|
||||||
|
{
|
||||||
|
is UpdateState.Checking -> stringResource(R.string.update_card_checking)
|
||||||
|
is UpdateState.Failed -> stringResource(updateErrorText(s.error))
|
||||||
|
else -> if (last > 0) stringResource(R.string.updates_last_check, lastCheckText(context, last))
|
||||||
|
else stringResource(R.string.updates_never_checked)
|
||||||
|
},
|
||||||
|
style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub,
|
||||||
|
textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (askCode)
|
||||||
|
{
|
||||||
|
BetaCodeSheet(
|
||||||
|
onEnable = { code ->
|
||||||
|
askCode = false
|
||||||
|
beta = true
|
||||||
|
manager.setChannel(beta = true, token = code)
|
||||||
|
},
|
||||||
|
onDismiss = { askCode = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Invitation code of the beta channel. */
|
||||||
|
@Composable
|
||||||
|
private fun BetaCodeSheet(onEnable: (String) -> Unit, onDismiss: () -> Unit)
|
||||||
|
{
|
||||||
|
var code by remember { mutableStateOf("") }
|
||||||
|
ShxSheet(onDismiss = onDismiss)
|
||||||
|
{
|
||||||
|
ShxSheetTitle(stringResource(R.string.updates_beta_title))
|
||||||
|
ShxIntro(stringResource(R.string.updates_beta_text))
|
||||||
|
ShxFormField(label = stringResource(R.string.updates_beta_code), value = code, onValueChange = { code = it }, mono = true)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.updates_beta_enable), { onEnable(code.trim()) }, enabled = code.isNotBlank(), modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «A new version is available» (App2): version, what is new from the release notes, Later / Download and install. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateReviewSheet(info: UpdateInfo, onInstall: () -> Unit, onDismiss: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val locale = LocalConfiguration.current.locales[0]
|
||||||
|
val lines = noteLines(pickNotes(info.notes, "${locale.language}-${locale.country}", locale.language))
|
||||||
|
ShxSheet(onDismiss = onDismiss)
|
||||||
|
{
|
||||||
|
UpdateSheetHeader(
|
||||||
|
"SHX VPN ${info.versionName}",
|
||||||
|
stringResource(R.string.updates_review_sub, BuildConfig.VERSION_NAME, sizeText(info.size)),
|
||||||
|
)
|
||||||
|
if (lines.isNotEmpty())
|
||||||
|
{
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(ShxSpacing.lg))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.update_card_whats_new).uppercase(), style = ShxType.section, color = c.accentText)
|
||||||
|
lines.forEach { Bullet(it, c.accent) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UpdateSafeNote(stringResource(R.string.updates_review_safe))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.cert_later), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(stringResource(R.string.update_download_install), onInstall, modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download (App3): progress and the four steps of the update. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateProgressContent(state: UpdateState, onCancel: () -> Unit, onInstall: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val info = (state as? UpdateState.Downloading)?.info ?: (state as? UpdateState.Available)?.info
|
||||||
|
val progress = (state as? UpdateState.Downloading)?.progress ?: 0
|
||||||
|
val downloading = state is UpdateState.Downloading
|
||||||
|
UpdateSheetHeader(
|
||||||
|
stringResource(R.string.updates_downloading_title, info?.versionName ?: ""),
|
||||||
|
stringResource(R.string.updates_downloading_sub),
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||||
|
{
|
||||||
|
Row(Modifier.fillMaxWidth())
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.update_card_downloading), style = ShxType.chip, color = c.textSoft, modifier = Modifier.weight(1f))
|
||||||
|
if (info != null && info.size > 0)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.updates_progress, progress, sizeText(info.size * progress / 100), sizeText(info.size)),
|
||||||
|
style = ShxType.chip, color = c.textSoft,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.cardBorder))
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth(progress / 100f).height(8.dp).clip(RoundedCornerShape(4.dp)).background(c.accent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* steps: the list is known once a version is shown; at 100% the file is being checked */
|
||||||
|
val verifying = downloading && progress >= 100
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 6.dp))
|
||||||
|
{
|
||||||
|
UpdateStep(StepState.DONE, stringResource(R.string.updates_step_list), stringResource(R.string.updates_step_list_sub), divider = true)
|
||||||
|
UpdateStep(
|
||||||
|
if (verifying) StepState.DONE else StepState.RUNNING, stringResource(R.string.updates_step_download),
|
||||||
|
if (info != null && info.size > 0) sizeText(info.size) else stringResource(R.string.update_card_downloading), divider = true,
|
||||||
|
)
|
||||||
|
UpdateStep(if (verifying) StepState.RUNNING else StepState.WAITING, stringResource(R.string.updates_step_verify), stringResource(R.string.updates_step_verify_sub), divider = true)
|
||||||
|
UpdateStep(StepState.WAITING, stringResource(R.string.updates_step_install), stringResource(R.string.updates_step_install_sub), divider = false)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.updates_cancel), onCancel, style = ShxButtonStyle.OUTLINE, enabled = downloading, modifier = Modifier.weight(1f))
|
||||||
|
/* while downloading the app installs on its own; Available here means a background download
|
||||||
|
* finished or failed without an install request, so the button starts it */
|
||||||
|
ShxButton(stringResource(R.string.update_card_install), onInstall, enabled = state is UpdateState.Available, modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum class StepState { DONE, RUNNING, WAITING }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UpdateStep(step: StepState, title: String, sub: String, divider: Boolean)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column()
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = ShxSpacing.md),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val shape = CircleShape
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(24.dp).clip(shape).then(
|
||||||
|
when (step)
|
||||||
|
{
|
||||||
|
StepState.DONE -> Modifier.background(c.success)
|
||||||
|
StepState.RUNNING -> Modifier.background(c.accentContainer).border(2.dp, c.accent, shape)
|
||||||
|
StepState.WAITING -> Modifier.background(c.card).border(1.dp, c.outline, shape)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
when (step)
|
||||||
|
{
|
||||||
|
StepState.DONE -> Icon(ShxIcons.Check, contentDescription = null, tint = Color.White, modifier = Modifier.size(14.dp))
|
||||||
|
StepState.RUNNING -> Text("…", style = ShxType.meta.copy(fontWeight = FontWeight.ExtraBold), color = c.onAccentContainer)
|
||||||
|
StepState.WAITING -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(1.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (step == StepState.WAITING) c.iconMuted else c.text)
|
||||||
|
Text(sub, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (divider)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mandatory update (App4): cannot be closed; update now or leave the app. */
|
||||||
|
@Composable
|
||||||
|
private fun MandatoryUpdateSheet(versionName: String, size: Long, onUpdate: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val activity = LocalActivity.current
|
||||||
|
ShxSheet(onDismiss = {}, dismissible = false)
|
||||||
|
{
|
||||||
|
UpdateSheetHeader(
|
||||||
|
stringResource(R.string.updates_mandatory_title),
|
||||||
|
stringResource(R.string.updates_mandatory_sub, BuildConfig.VERSION_NAME),
|
||||||
|
icon = ShxIcons.Alert, iconBackground = c.warningContainer, iconTint = c.onWarningContainer, round = true,
|
||||||
|
)
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.updates_mandatory_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
Bullet(stringResource(R.string.updates_mandatory_reason), c.warning)
|
||||||
|
Bullet(stringResource(R.string.updates_mandatory_install, versionName, sizeText(size)), c.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.updates_mandatory_locked), tone = ShxTone.WARNING)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.cert_update_now), onUpdate, modifier = Modifier.fillMaxWidth())
|
||||||
|
ShxButton(stringResource(R.string.updates_exit), { activity?.finishAndRemoveTask() }, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update not installed (App5): reasons by error kind, retry, the download page, details for support. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateFailedContent(state: UpdateState.Failed, onRetry: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
val checkFailed = state.error == UpdateError.HASH || state.error == UpdateError.SIGNATURE
|
||||||
|
UpdateSheetHeader(
|
||||||
|
stringResource(R.string.updates_failed_title),
|
||||||
|
stringResource(
|
||||||
|
when (state.error)
|
||||||
|
{
|
||||||
|
UpdateError.HASH, UpdateError.SIGNATURE -> R.string.updates_failed_sub_check
|
||||||
|
UpdateError.NETWORK -> R.string.updates_failed_sub_network
|
||||||
|
UpdateError.INSTALL -> R.string.updates_failed_sub_install
|
||||||
|
UpdateError.OTHER -> R.string.updates_failed_sub_other
|
||||||
|
}
|
||||||
|
),
|
||||||
|
icon = ShxIcons.Shield, iconBackground = c.errorContainer, iconTint = c.error, round = true,
|
||||||
|
)
|
||||||
|
val (title, detail) = when (state.error)
|
||||||
|
{
|
||||||
|
UpdateError.HASH -> R.string.updates_reason_hash to R.string.updates_reason_hash_d
|
||||||
|
UpdateError.SIGNATURE -> R.string.updates_reason_signature to R.string.updates_reason_signature_d
|
||||||
|
UpdateError.NETWORK -> R.string.updates_reason_network to R.string.updates_reason_network_d
|
||||||
|
UpdateError.INSTALL -> R.string.updates_reason_install to R.string.updates_reason_install_d
|
||||||
|
UpdateError.OTHER -> R.string.updates_reason_other to R.string.updates_reason_other_d
|
||||||
|
}
|
||||||
|
ShxPanel(padding = androidx.compose.foundation.layout.PaddingValues(horizontal = ShxSpacing.lg, vertical = 14.dp))
|
||||||
|
{
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.sm))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.updates_failed_what).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||||
|
Reason(stringResource(title), stringResource(detail), if (checkFailed) c.error else c.accent)
|
||||||
|
Reason(stringResource(R.string.updates_reason_same), stringResource(R.string.updates_reason_same_d, BuildConfig.VERSION_NAME), c.accent)
|
||||||
|
Reason(stringResource(R.string.updates_reason_later), stringResource(R.string.updates_reason_later_d), c.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxNote(stringResource(R.string.updates_manual_note))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
ShxButton(stringResource(R.string.updates_retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||||
|
ShxButton(
|
||||||
|
stringResource(R.string.updates_from_site),
|
||||||
|
{ runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(BuildConfig.UPDATE_DOWNLOAD_PAGE_URL))) } },
|
||||||
|
style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.updates_support),
|
||||||
|
style = ShxType.chip, color = c.accentText,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable(role = Role.Button) { copySupportDetails(context, state) }.padding(horizontal = ShxSpacing.sm, vertical = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error kind, message and versions for support; the beta code stays out. */
|
||||||
|
private fun copySupportDetails(context: Context, state: UpdateState.Failed)
|
||||||
|
{
|
||||||
|
val manager = UpdateManager.getInstance(context)
|
||||||
|
val text = buildString {
|
||||||
|
appendLine("SHX VPN update error: ${state.error}")
|
||||||
|
appendLine("Detail: ${state.detail ?: "-"}")
|
||||||
|
appendLine("Installed: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
|
||||||
|
appendLine("Channel: ${if (manager.isBetaChannel()) "beta" else "release"}")
|
||||||
|
appendLine("Android: ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT}), ${Build.MANUFACTURER} ${Build.MODEL}, ${Build.SUPPORTED_ABIS.firstOrNull() ?: "?"}")
|
||||||
|
}
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("SHX VPN", text))
|
||||||
|
/* Android 13+ confirms the copy itself */
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU)
|
||||||
|
{
|
||||||
|
Toast.makeText(context, R.string.updates_copied, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Icon well, sheet title and subtitle of the update sheets. */
|
||||||
|
@Composable
|
||||||
|
private fun UpdateSheetHeader(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
iconBackground: Color = LocalShxColors.current.accent,
|
||||||
|
iconTint: Color = Color.White,
|
||||||
|
round: Boolean = false,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val size = if (round) 52.dp else 56.dp
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(size).clip(if (round) CircleShape else RoundedCornerShape(16.dp)).background(iconBackground))
|
||||||
|
{
|
||||||
|
if (icon != null) Icon(icon, contentDescription = null, tint = iconTint, modifier = Modifier.size(24.dp))
|
||||||
|
else Icon(painterResource(R.drawable.ic_notification), contentDescription = null, tint = iconTint, modifier = Modifier.size(36.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.pageTitle, color = c.text)
|
||||||
|
Text(subtitle, style = ShxType.sub, color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Green «safe» box with a shield (App1, App2, App6). */
|
||||||
|
@Composable
|
||||||
|
internal fun UpdateSafeNote(text: String)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.successContainer).padding(horizontal = 14.dp, vertical = ShxSpacing.md),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Shield, contentDescription = null, tint = c.onSuccess, modifier = Modifier.size(20.dp))
|
||||||
|
Text(text, style = ShxType.note, color = c.onSuccess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Bullet(text: String, dot: Color)
|
||||||
|
{
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||||
|
Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Title — detail» line of App5. */
|
||||||
|
@Composable
|
||||||
|
private fun Reason(title: String, detail: String, dot: Color)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||||
|
{
|
||||||
|
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(dot))
|
||||||
|
Text(
|
||||||
|
buildAnnotatedString {
|
||||||
|
withStyle(SpanStyle(fontWeight = FontWeight.Bold, color = c.text)) { append(title) }
|
||||||
|
append(" — ")
|
||||||
|
withStyle(SpanStyle(color = c.textSoft)) { append(detail) }
|
||||||
|
},
|
||||||
|
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sizeText(bytes: Long): String = if (bytes > 0) formatBytes(bytes) else "—"
|
||||||
|
|
||||||
|
private fun lastCheckText(context: Context, time: Long): String =
|
||||||
|
DateUtils.getRelativeDateTimeString(context, time, DateUtils.DAY_IN_MILLIS, DateUtils.WEEK_IN_MILLIS, 0).toString()
|
||||||
|
|
||||||
|
internal fun updateErrorText(error: UpdateError): Int = when (error)
|
||||||
|
{
|
||||||
|
UpdateError.NETWORK -> R.string.update_error_network
|
||||||
|
UpdateError.HASH -> R.string.update_error_hash
|
||||||
|
UpdateError.SIGNATURE -> R.string.update_error_signature
|
||||||
|
UpdateError.INSTALL -> R.string.update_error_install
|
||||||
|
UpdateError.OTHER -> R.string.update_error_generic
|
||||||
|
}
|
||||||
+89
-32
@@ -19,16 +19,12 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.material.icons.Icons
|
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||||
import androidx.compose.material.icons.rounded.Visibility
|
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -44,7 +40,24 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
import org.strongswan.android.R
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.design.ShxDialogButton
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||||
|
|
||||||
@@ -105,6 +118,16 @@ object VpnDialogBus
|
|||||||
{
|
{
|
||||||
_dialog.value = null
|
_dialog.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* F_VpnConsent: the hint behind the system VPN consent window */
|
||||||
|
private val _consentHint = MutableStateFlow(false)
|
||||||
|
val consentHint: StateFlow<Boolean> = _consentHint
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun setConsentHint(shown: Boolean)
|
||||||
|
{
|
||||||
|
_consentHint.value = shown
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders the connect-flow dialogs in the app theme inside the transparent activity. */
|
/** Renders the connect-flow dialogs in the app theme inside the transparent activity. */
|
||||||
@@ -121,6 +144,11 @@ fun ComponentActivity.installVpnDialogHost()
|
|||||||
fun VpnDialogHost()
|
fun VpnDialogHost()
|
||||||
{
|
{
|
||||||
val dialog by VpnDialogBus.dialog.collectAsStateWithLifecycle()
|
val dialog by VpnDialogBus.dialog.collectAsStateWithLifecycle()
|
||||||
|
val consentHint by VpnDialogBus.consentHint.collectAsStateWithLifecycle()
|
||||||
|
if (consentHint)
|
||||||
|
{
|
||||||
|
ConsentHint()
|
||||||
|
}
|
||||||
when (val d = dialog)
|
when (val d = dialog)
|
||||||
{
|
{
|
||||||
is VpnDialog.Confirmation -> ConfirmationUi(d)
|
is VpnDialog.Confirmation -> ConfirmationUi(d)
|
||||||
@@ -142,7 +170,7 @@ private fun cancelDialog()
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
|
private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
|
||||||
{
|
{
|
||||||
AlertDialog(
|
ShxAlertDialog(
|
||||||
onDismissRequest = ::cancelDialog,
|
onDismissRequest = ::cancelDialog,
|
||||||
title = { Text(dialog.title) },
|
title = { Text(dialog.title) },
|
||||||
text = { Text(stringResource(dialog.message)) },
|
text = { Text(stringResource(dialog.message)) },
|
||||||
@@ -150,17 +178,17 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
|
|||||||
Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs)) {
|
||||||
if (dialog.reconnect)
|
if (dialog.reconnect)
|
||||||
{
|
{
|
||||||
TextButton(onClick = {
|
ShxDialogButton(onClick = {
|
||||||
VpnDialogBus.dismiss()
|
VpnDialogBus.dismiss()
|
||||||
VpnDialogBus.listener?.onVpnDialogDisconnect()
|
VpnDialogBus.listener?.onVpnDialogDisconnect()
|
||||||
}) {
|
}) {
|
||||||
Text(
|
Text(
|
||||||
stringResource(R.string.disconnect),
|
stringResource(R.string.disconnect),
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = LocalShxColors.current.error,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TextButton(onClick = {
|
ShxDialogButton(primary = true, onClick = {
|
||||||
VpnDialogBus.dismiss()
|
VpnDialogBus.dismiss()
|
||||||
val listener = VpnDialogBus.listener
|
val listener = VpnDialogBus.listener
|
||||||
if (dialog.disconnect)
|
if (dialog.disconnect)
|
||||||
@@ -175,7 +203,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = ::cancelDialog) {
|
ShxDialogButton(onClick = ::cancelDialog) {
|
||||||
Text(stringResource(android.R.string.cancel))
|
Text(stringResource(android.R.string.cancel))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -189,52 +217,48 @@ private fun LoginUi(dialog: VpnDialog.Login)
|
|||||||
var password by rememberSaveable { mutableStateOf("") }
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
var showPassword by rememberSaveable { mutableStateOf(false) }
|
var showPassword by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
AlertDialog(
|
ShxAlertDialog(
|
||||||
onDismissRequest = ::cancelDialog,
|
onDismissRequest = ::cancelDialog,
|
||||||
title = { Text(stringResource(R.string.login_title)) },
|
title = { Text(stringResource(R.string.login_title)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) {
|
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) {
|
||||||
OutlinedTextField(
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.login_username),
|
||||||
value = username,
|
value = username,
|
||||||
onValueChange = { username = it },
|
onValueChange = { username = it },
|
||||||
label = { Text(stringResource(R.string.login_username)) },
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
|
||||||
enabled = false,
|
enabled = false,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
)
|
)
|
||||||
OutlinedTextField(
|
ShxFormField(
|
||||||
|
label = stringResource(R.string.login_password),
|
||||||
value = password,
|
value = password,
|
||||||
onValueChange = { password = it },
|
onValueChange = { password = it },
|
||||||
label = { Text(stringResource(R.string.login_password)) },
|
|
||||||
singleLine = true,
|
|
||||||
keyboardOptions = KeyboardOptions(
|
keyboardOptions = KeyboardOptions(
|
||||||
keyboardType = KeyboardType.Password,
|
keyboardType = KeyboardType.Password,
|
||||||
imeAction = ImeAction.Done,
|
imeAction = ImeAction.Done,
|
||||||
),
|
),
|
||||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
trailingIcon = {
|
trailing = {
|
||||||
IconButton(onClick = { showPassword = !showPassword }) {
|
IconButton(onClick = { showPassword = !showPassword }) {
|
||||||
Icon(
|
Icon(
|
||||||
if (showPassword) Icons.Rounded.Visibility else Icons.Rounded.VisibilityOff,
|
if (showPassword) ShxIcons.Eye else ShxIcons.EyeOff,
|
||||||
contentDescription = stringResource(
|
contentDescription = stringResource(
|
||||||
if (showPassword) R.string.login_hide_password else R.string.login_show_password
|
if (showPassword) R.string.login_hide_password else R.string.login_show_password
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
ShxDialogButton(primary = true, onClick = {
|
||||||
VpnDialogBus.dismiss()
|
VpnDialogBus.dismiss()
|
||||||
VpnDialogBus.listener?.onVpnDialogLogin(dialog.profileInfo, password)
|
VpnDialogBus.listener?.onVpnDialogLogin(dialog.profileInfo, password)
|
||||||
}) { Text(stringResource(R.string.login_confirm)) }
|
}) { Text(stringResource(R.string.login_confirm)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = ::cancelDialog) {
|
ShxDialogButton(onClick = ::cancelDialog) {
|
||||||
Text(stringResource(android.R.string.cancel))
|
Text(stringResource(android.R.string.cancel))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -244,18 +268,18 @@ private fun LoginUi(dialog: VpnDialog.Login)
|
|||||||
@Composable
|
@Composable
|
||||||
private fun CertificateRequiredUi(dialog: VpnDialog.CertificateRequired)
|
private fun CertificateRequiredUi(dialog: VpnDialog.CertificateRequired)
|
||||||
{
|
{
|
||||||
AlertDialog(
|
ShxAlertDialog(
|
||||||
onDismissRequest = ::cancelDialog,
|
onDismissRequest = ::cancelDialog,
|
||||||
title = { Text(stringResource(R.string.certificate_required_title)) },
|
title = { Text(stringResource(R.string.certificate_required_title)) },
|
||||||
text = { Text(stringResource(R.string.certificate_required_text)) },
|
text = { Text(stringResource(R.string.certificate_required_text)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
ShxDialogButton(primary = true, onClick = {
|
||||||
VpnDialogBus.dismiss()
|
VpnDialogBus.dismiss()
|
||||||
VpnDialogBus.listener?.onVpnDialogEditProfile(dialog.uuid)
|
VpnDialogBus.listener?.onVpnDialogEditProfile(dialog.uuid)
|
||||||
}) { Text(stringResource(R.string.edit_profile)) }
|
}) { Text(stringResource(R.string.edit_profile)) }
|
||||||
},
|
},
|
||||||
dismissButton = {
|
dismissButton = {
|
||||||
TextButton(onClick = ::cancelDialog) {
|
ShxDialogButton(onClick = ::cancelDialog) {
|
||||||
Text(stringResource(android.R.string.cancel))
|
Text(stringResource(android.R.string.cancel))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -265,12 +289,12 @@ private fun CertificateRequiredUi(dialog: VpnDialog.CertificateRequired)
|
|||||||
@Composable
|
@Composable
|
||||||
private fun PowerWhitelistUi()
|
private fun PowerWhitelistUi()
|
||||||
{
|
{
|
||||||
AlertDialog(
|
ShxAlertDialog(
|
||||||
onDismissRequest = ::cancelDialog,
|
onDismissRequest = ::cancelDialog,
|
||||||
title = { Text(stringResource(R.string.power_whitelist_title)) },
|
title = { Text(stringResource(R.string.power_whitelist_title)) },
|
||||||
text = { Text(stringResource(R.string.power_whitelist_text)) },
|
text = { Text(stringResource(R.string.power_whitelist_text)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = {
|
ShxDialogButton(primary = true, onClick = {
|
||||||
VpnDialogBus.dismiss()
|
VpnDialogBus.dismiss()
|
||||||
VpnDialogBus.listener?.onVpnDialogPowerWhitelist()
|
VpnDialogBus.listener?.onVpnDialogPowerWhitelist()
|
||||||
}) { Text(stringResource(android.R.string.ok)) }
|
}) { Text(stringResource(android.R.string.ok)) }
|
||||||
@@ -281,14 +305,47 @@ private fun PowerWhitelistUi()
|
|||||||
@Composable
|
@Composable
|
||||||
private fun NotSupportedUi(dialog: VpnDialog.NotSupported)
|
private fun NotSupportedUi(dialog: VpnDialog.NotSupported)
|
||||||
{
|
{
|
||||||
AlertDialog(
|
ShxAlertDialog(
|
||||||
onDismissRequest = ::cancelDialog,
|
onDismissRequest = ::cancelDialog,
|
||||||
title = { Text(stringResource(R.string.vpn_not_supported_title)) },
|
title = { Text(stringResource(R.string.vpn_not_supported_title)) },
|
||||||
text = { Text(stringResource(dialog.message)) },
|
text = { Text(stringResource(dialog.message)) },
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = ::cancelDialog) {
|
ShxDialogButton(primary = true, onClick = ::cancelDialog) {
|
||||||
Text(stringResource(android.R.string.ok))
|
Text(stringResource(android.R.string.ok))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown by the transparent host while Android's own consent dialog is on
|
||||||
|
* top (F_VpnConsent): a dimmed screen with a card at the top explaining it.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun ConsentHint()
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Box(
|
||||||
|
Modifier.fillMaxSize().background(Color(0x8C1C1B20)).statusBarsPadding().padding(start = 20.dp, end = 20.dp, top = 24.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(c.background).padding(16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(36.dp).clip(RoundedCornerShape(12.dp)).background(c.accent))
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Shield, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(3.dp))
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.consent_hint_title), style = ShxType.cardTitle, color = c.text)
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.consent_hint_text),
|
||||||
|
style = ShxType.note.copy(fontSize = 13.sp, lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.textSoft,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
-72
@@ -44,19 +44,31 @@ import androidx.compose.ui.graphics.vector.ImageVector
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
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.design.ShxSwitch
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
|
||||||
/** Uppercase section header + grouped card, the standard grouped-list building block. */
|
/** Section header (13/800 purple, +0.04em) + white card: the grouped-list block of the mockup. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ShxSection(title: String, content: @Composable () -> Unit)
|
fun ShxSection(title: String, content: @Composable () -> Unit)
|
||||||
{
|
{
|
||||||
Text(
|
Text(
|
||||||
text = title.uppercase(),
|
text = title.uppercase(),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = ShxType.section,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = LocalShxColors.current.accentText,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(start = ShxSpacing.lg + ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm)
|
.padding(start = 22.dp, end = 22.dp, top = 20.dp, bottom = 8.dp)
|
||||||
.semantics { heading() },
|
.semantics { heading() },
|
||||||
)
|
)
|
||||||
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) {
|
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) {
|
||||||
@@ -64,57 +76,41 @@ fun ShxSection(title: String, content: @Composable () -> Unit)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 1px divider between rows of a card (#F0ECF4). */
|
||||||
@Composable
|
@Composable
|
||||||
fun ShxSectionDivider(indent: Dp = 68.dp)
|
fun ShxSectionDivider(indent: Dp = 0.dp)
|
||||||
{
|
{
|
||||||
HorizontalDivider(
|
Box(Modifier.padding(start = indent).fillMaxWidth().height(1.dp).background(LocalShxColors.current.divider))
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
|
||||||
modifier = Modifier.padding(start = indent),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Rounded-square icon badge shared by all setting rows. */
|
/** Rounded-square icon well of the rows: 40dp, radius 14, #E7DFFF. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ShxIconBadge(icon: ImageVector, tint: Color = MaterialTheme.colorScheme.primary)
|
fun ShxIconBadge(icon: ImageVector, tint: Color = LocalShxColors.current.onAccentContainer)
|
||||||
{
|
{
|
||||||
Surface(
|
Box(
|
||||||
shape = MaterialTheme.shapes.small,
|
contentAlignment = Alignment.Center,
|
||||||
color = MaterialTheme.colorScheme.primaryContainer,
|
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(LocalShxColors.current.accentContainer),
|
||||||
modifier = Modifier.size(36.dp),
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Icon(
|
Icon(imageVector = icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = tint,
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(ShxSpacing.sm)
|
|
||||||
.size(20.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RowScope.RowLabels(title: String, subtitle: String?, enabled: Boolean)
|
private fun RowScope.RowLabels(title: String, subtitle: String?, enabled: Boolean)
|
||||||
{
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
Column(
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.padding(horizontal = ShxSpacing.lg),
|
.padding(end = 10.dp),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Text(
|
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = if (enabled) c.text else c.textSub)
|
||||||
title,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
if (subtitle != null)
|
if (subtitle != null)
|
||||||
{
|
{
|
||||||
Text(
|
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub)
|
||||||
subtitle,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,35 +118,33 @@ private fun RowScope.RowLabels(title: String, subtitle: String?, enabled: Boolea
|
|||||||
/** Setting row with a chevron; tap opens a target screen or dialog. */
|
/** Setting row with a chevron; tap opens a target screen or dialog. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ShxSettingRow(
|
fun ShxSettingRow(
|
||||||
icon: ImageVector,
|
@Suppress("UNUSED_PARAMETER") icon: ImageVector,
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(enabled = enabled, onClick = onClick)
|
.clickable(enabled = enabled, onClick = onClick)
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
|
.heightIn(min = 58.dp)
|
||||||
|
.padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
ShxIconBadge(icon = icon, tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
/* the sub-page rows of the mockup carry no icon (the icon stays in the API) */
|
||||||
RowLabels(title, subtitle, enabled)
|
RowLabels(title, subtitle, enabled)
|
||||||
Icon(
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Setting row with a trailing switch; the whole row toggles. */
|
/** Setting row with the mockup switch; the whole row toggles. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ShxSettingToggleRow(
|
fun ShxSettingToggleRow(
|
||||||
icon: ImageVector,
|
@Suppress("UNUSED_PARAMETER") icon: ImageVector,
|
||||||
title: String,
|
title: String,
|
||||||
subtitle: String? = null,
|
subtitle: String? = null,
|
||||||
checked: Boolean,
|
checked: Boolean,
|
||||||
@@ -158,6 +152,7 @@ fun ShxSettingToggleRow(
|
|||||||
onCheckedChange: (Boolean) -> Unit,
|
onCheckedChange: (Boolean) -> Unit,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -168,29 +163,28 @@ fun ShxSettingToggleRow(
|
|||||||
role = Role.Switch,
|
role = Role.Switch,
|
||||||
onValueChange = onCheckedChange,
|
onValueChange = onCheckedChange,
|
||||||
)
|
)
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
|
.heightIn(min = 58.dp)
|
||||||
|
.padding(start = 16.dp, end = 14.dp, top = 8.dp, bottom = 8.dp),
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
ShxIconBadge(icon = icon, tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
|
/* the sub-page rows of the mockup carry no icon (the icon stays in the API) */
|
||||||
RowLabels(title, subtitle, enabled)
|
RowLabels(title, subtitle, enabled)
|
||||||
Switch(checked = checked, onCheckedChange = null, enabled = enabled)
|
ShxSwitch(checked = checked, onCheckedChange = null, enabled = enabled)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The standard rounded card: low container, optional hairline border. */
|
/** The card of the mockup: white, 1px #EDE8F2 border, radius 24. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ShxCard(
|
fun ShxCard(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
bordered: Boolean = false,
|
@Suppress("UNUSED_PARAMETER") bordered: Boolean = true,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Surface(
|
val c = LocalShxColors.current
|
||||||
shape = MaterialTheme.shapes.large,
|
val shape = RoundedCornerShape(24.dp)
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
Box(modifier.clip(shape).background(c.card).border(1.dp, c.cardBorder, shape))
|
||||||
border = if (bordered) BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) else null,
|
{
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,38 +199,26 @@ fun ShxEmptyState(
|
|||||||
onAction: (() -> Unit)? = null,
|
onAction: (() -> Unit)? = null,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(ShxSpacing.xl),
|
.padding(ShxSpacing.xl),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier.size(64.dp).clip(RoundedCornerShape(20.dp)).background(c.tile),
|
||||||
.size(64.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(icon, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(30.dp))
|
||||||
icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(30.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(14.dp))
|
Spacer(Modifier.height(14.dp))
|
||||||
Text(
|
Text(message, style = ShxType.body, color = c.textSub, textAlign = TextAlign.Center)
|
||||||
message,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
)
|
|
||||||
if (actionLabel != null && onAction != null)
|
if (actionLabel != null && onAction != null)
|
||||||
{
|
{
|
||||||
Spacer(Modifier.height(ShxSpacing.lg))
|
Spacer(Modifier.height(ShxSpacing.lg))
|
||||||
OutlinedButton(onClick = onAction, shape = MaterialTheme.shapes.medium) { Text(actionLabel) }
|
ShxButton(actionLabel, onAction, style = ShxButtonStyle.TONAL, height = 48.dp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
-80
@@ -1,80 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.compose.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generic list row: optional leading slot, headline + supporting text, optional trailing slot.
|
|
||||||
* Minimum height 56dp keeps touch targets above 48dp. Clickable when [onClick] is set.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun ShxListRow(
|
|
||||||
headline: String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
supporting: String? = null,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
leading: (@Composable () -> Unit)? = null,
|
|
||||||
trailing: (@Composable () -> Unit)? = null,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
val clickable = if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) else Modifier
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.then(clickable)
|
|
||||||
.heightIn(min = 56.dp)
|
|
||||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
if (leading != null)
|
|
||||||
{
|
|
||||||
leading()
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(horizontal = if (leading != null || trailing != null) ShxSpacing.lg else 0.dp),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
headline,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
if (supporting != null)
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
supporting,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (trailing != null)
|
|
||||||
{
|
|
||||||
trailing()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+14
-9
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
package org.strongswan.android.ui.compose.components
|
package org.strongswan.android.ui.compose.components
|
||||||
|
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -22,27 +24,30 @@ import androidx.compose.ui.semantics.heading
|
|||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||||
|
|
||||||
/** Card with an uppercase caption and a padded content column (form groups, detail blocks). */
|
/** Card with an optional uppercase caption and a padded content column (form groups, detail blocks). */
|
||||||
@Composable
|
@Composable
|
||||||
fun ShxSectionCard(
|
fun ShxSectionCard(
|
||||||
title: String,
|
title: String?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
Surface(
|
Surface(
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
color = LocalShxColors.current.card,
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.padding(ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
|
Column(Modifier.padding(ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
|
||||||
{
|
{
|
||||||
Text(
|
if (title != null)
|
||||||
text = title.uppercase(),
|
{
|
||||||
style = MaterialTheme.typography.labelMedium,
|
Text(
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
text = title.uppercase(),
|
||||||
modifier = Modifier.semantics { heading() },
|
style = ShxType.chip,
|
||||||
)
|
color = LocalShxColors.current.textSub,
|
||||||
|
modifier = Modifier.semantics { heading() },
|
||||||
|
)
|
||||||
|
}
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-62
@@ -1,62 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.compose.components
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
|
||||||
|
|
||||||
/** Small rounded label with explicit colors, e.g. "Connected". */
|
|
||||||
@Composable
|
|
||||||
fun ShxStatusPill(
|
|
||||||
text: String,
|
|
||||||
containerColor: Color,
|
|
||||||
contentColor: Color,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
shape: Shape = CircleShape,
|
|
||||||
style: TextStyle = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Surface(
|
|
||||||
color = containerColor,
|
|
||||||
contentColor = contentColor,
|
|
||||||
shape = shape,
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = style,
|
|
||||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = ShxSpacing.xs),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pill in the "connected" status palette (independent of dynamic color). */
|
|
||||||
@Composable
|
|
||||||
fun ShxConnectedPill(text: String, modifier: Modifier = Modifier)
|
|
||||||
{
|
|
||||||
val status = LocalShxStatusColors.current
|
|
||||||
ShxStatusPill(
|
|
||||||
text = text,
|
|
||||||
containerColor = status.connectedContainer,
|
|
||||||
contentColor = status.connected,
|
|
||||||
modifier = modifier,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+86
@@ -0,0 +1,86 @@
|
|||||||
|
/*
|
||||||
|
* 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.compose.design
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.addPathNodes
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The line icons of the mockup: the same 24x24 SVG paths, stroked with
|
||||||
|
* round caps and joins (Icon() tints them).
|
||||||
|
*/
|
||||||
|
object ShxIcons
|
||||||
|
{
|
||||||
|
private fun line(name: String, path: String, width: Float = 2f): ImageVector =
|
||||||
|
ImageVector.Builder(name = name, defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f)
|
||||||
|
.addPath(
|
||||||
|
pathData = addPathNodes(path),
|
||||||
|
fill = null,
|
||||||
|
stroke = SolidColor(Color.Black),
|
||||||
|
strokeLineWidth = width,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round,
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val Power = line("power", "M12 3v9M6.3 6.3a8 8 0 1 0 11.4 0", 2.2f)
|
||||||
|
val Globe = line("globe", "M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18")
|
||||||
|
val Route = line("route", "M4 12h6l4-6h6M10 12l4 6h6")
|
||||||
|
val Sliders = line("sliders", "M4 7h10M18 7h2M4 17h4M12 17h8M14 5v4M8 15v4")
|
||||||
|
val Bell = line("bell", "M6 16V11a6 6 0 0 1 12 0v5l1.5 2h-15zM10 20a2 2 0 0 0 4 0")
|
||||||
|
val Pulse = line("pulse", "M3 12h4l3-8 4 16 3-8h4")
|
||||||
|
val Pause = line("pause", "M9 5v14M15 5v14", 2.2f)
|
||||||
|
val Bolt = line("bolt", "M13 2L4 14h7l-1 8 9-12h-7z", 2.2f)
|
||||||
|
val Help = line("help", "M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6M12 17v.5", 2.2f)
|
||||||
|
val Swap = line("swap", "M4 8h13l-3-3M20 16H7l3 3", 2.2f)
|
||||||
|
val Chevron = line("chevron", "M9 6l6 6-6 6")
|
||||||
|
val Back = line("back", "M15 6l-6 6 6 6")
|
||||||
|
val Search = line("search", "M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14M20 20l-3.5-3.5", 2.2f)
|
||||||
|
val Refresh = line("refresh", "M20 12a8 8 0 1 1-2.3-5.7M20 4v5h-5", 2.2f)
|
||||||
|
val Apps = line("apps", "M6 4h2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM16 4h2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM6 14h2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2zM16 14h2a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2h-2a2 2 0 0 1-2-2v-2a2 2 0 0 1 2-2z")
|
||||||
|
val Lines = line("lines", "M5 7h14M5 12h14M5 17h9", 2.2f)
|
||||||
|
val Alert = line("alert", "M12 4l9 16H3zM12 10v4M12 17.5v.5", 2.2f)
|
||||||
|
val Shield = line("shield", "M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z", 2.2f)
|
||||||
|
val Download = line("download", "M12 4v12M7 11l5 5 5-5M5 20h14", 2.2f)
|
||||||
|
val Database = line("db", "M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zM4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6", 2.2f)
|
||||||
|
val Check = line("check", "M5 12l5 5 9-10", 2.4f)
|
||||||
|
val Plus = line("plus", "M12 5v14M5 12h14", 2.4f)
|
||||||
|
val Close = line("close", "M6 6l12 12M18 6L6 18", 2.2f)
|
||||||
|
val Trash = line("trash", "M5 7h14M10 11v6M14 11v6M7 7l1 13h8l1-13M9 7V4h6v3", 2f)
|
||||||
|
val WifiOff = line("wifiOff", "M2 8.8a15 15 0 0 1 4.4-2.7M10 5.1A15 15 0 0 1 22 8.8M5 12.9a10 10 0 0 1 4.6-2.6M14.6 10.5A10 10 0 0 1 19 12.9M8.5 16.4a5 5 0 0 1 7 0M12 20h.01M3 3l18 18", 2.2f)
|
||||||
|
val Key = line("key", "M15 7a4 4 0 1 0-3.9 5L4 19v2h3v-2h2v-2h2l1.9-1.9A4 4 0 0 0 15 7z", 2f)
|
||||||
|
val Info = line("info", "M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M12 11v6M12 7.5v.5", 2.2f)
|
||||||
|
val Wifi = line("wifi", "M2 9a15 15 0 0 1 20 0M5 13a10 10 0 0 1 14 0M9 17a4 4 0 0 1 6 0", 2f)
|
||||||
|
val Lock = line("lock", "M6 11V8a6 6 0 0 1 12 0v3M5 11h14v10H5z", 2f)
|
||||||
|
val Phone = line("phone", "M7 4h10v17H7zM10 2h4M10 9h4M10 13h4", 2f)
|
||||||
|
val Moon = line("moon", "M12 3a9 9 0 1 0 9 9a7 7 0 0 1-9-9", 2f)
|
||||||
|
val Doc = line("doc", "M6 3h9l4 4v14H6zM9 12h6M9 16h6", 2f)
|
||||||
|
val Cert = line("cert", "M4 5h16v10H4zM8 19l4-2 4 2v-4H8z", 2f)
|
||||||
|
val Play = line("play", "M5 4l14 8-14 8z", 2f)
|
||||||
|
val Scan = line("scan", "M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3M4 12h16", 2f)
|
||||||
|
val Pin = line("pin", "M12 21s-7-6.1-7-11a7 7 0 0 1 14 0c0 4.9-7 11-7 11zM12 12.5a2.5 2.5 0 1 0 0-5a2.5 2.5 0 0 0 0 5", 2f)
|
||||||
|
val Stats = line("stats", "M5 20V10M12 20V4M19 20v-7", 2.4f)
|
||||||
|
val Clock = line("clock", "M12 7v5l3 2M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18", 2.2f)
|
||||||
|
val Eye = line("eye", "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12zM12 9a3 3 0 1 0 0 6a3 3 0 1 0 0-6", 2f)
|
||||||
|
val EyeOff = line("eyeOff", "M3 3l18 18M10.6 5.1A10 10 0 0 1 12 5c6.4 0 10 7 10 7a17 17 0 0 1-3.2 4.2M6.6 6.6C3.9 8.4 2 12 2 12s3.6 7 10 7a9.6 9.6 0 0 0 5.4-1.6M9.9 9.9a3 3 0 0 0 4.2 4.2", 2f)
|
||||||
|
val More = line("more", "M12 5h.01M12 12h.01M12 19h.01", 3f)
|
||||||
|
val Battery = line("battery", "M3 8h15v8H3zM21 11v2M6 11v2", 2f)
|
||||||
|
val Bug = line("bug", "M9 9h6v8a3 3 0 0 1-6 0zM9 13H5M19 13h-4M8 5l2 2M16 5l-2 2M10 7h4", 2f)
|
||||||
|
val Edit = line("edit", "M4 20h4l10-10-4-4L4 16zM13 7l4 4", 2f)
|
||||||
|
val ArrowUp = line("arrowUp", "M12 19V5M6 11l6-6 6 6", 2.2f)
|
||||||
|
val ArrowDown = line("arrowDown", "M12 5v14M6 13l6 6 6-6", 2.2f)
|
||||||
|
val Minus = line("minus", "M5 12h14", 2.4f)
|
||||||
|
val Share = line("share", "M18 8a3 3 0 1 0 0-6a3 3 0 0 0 0 6zM6 15a3 3 0 1 0 0-6a3 3 0 0 0 0 6zM18 22a3 3 0 1 0 0-6a3 3 0 0 0 0 6zM8.6 13.5l6.8 4M15.4 6.5l-6.8 4", 2f)
|
||||||
|
}
|
||||||
+936
@@ -0,0 +1,936 @@
|
|||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
||||||
|
package org.strongswan.android.ui.compose.design
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
|
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.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
|
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.selection.selectable
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
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.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.focus.onFocusChanged
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
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.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Building blocks of the «Final 2026» mockup. Every size, radius and color
|
||||||
|
* here is taken from the mockup boards; screens should be composed from
|
||||||
|
* these instead of default Material components so they match the canvas.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Tab screen title: 26/800, padding 18/20/8. */
|
||||||
|
@Composable
|
||||||
|
fun ShxScreenTitle(text: String, modifier: Modifier = Modifier, actions: @Composable RowScope.() -> Unit = {})
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 18.dp, bottom = 8.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(text, style = ShxType.screenTitle, color = LocalShxColors.current.text, modifier = Modifier.weight(1f).semantics { heading() })
|
||||||
|
actions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header of a secondary screen: 44dp back button and a 22/800 title. */
|
||||||
|
@Composable
|
||||||
|
fun ShxPageHeader(title: String, onBack: () -> Unit, modifier: Modifier = Modifier, actions: @Composable RowScope.() -> Unit = {})
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style = ShxType.pageTitle,
|
||||||
|
color = c.text,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f).semantics { heading() },
|
||||||
|
)
|
||||||
|
actions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text action in a header: 13/700 purple, 40dp high. */
|
||||||
|
@Composable
|
||||||
|
fun ShxHeaderAction(text: String, onClick: () -> Unit)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.height(40.dp).clip(RoundedCornerShape(20.dp)).clickable(onClick = onClick).padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(text, style = ShxType.chip, color = LocalShxColors.current.accentText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section header above a card: 13/800 purple, +0.04em, margin 6/6/0. */
|
||||||
|
@Composable
|
||||||
|
fun ShxSectionHeader(text: String, modifier: Modifier = Modifier)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = text.uppercase(),
|
||||||
|
style = ShxType.section,
|
||||||
|
color = LocalShxColors.current.accentText,
|
||||||
|
modifier = modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp).semantics { heading() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** White card, 1px border, radius 24. */
|
||||||
|
@Composable
|
||||||
|
fun ShxPanel(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
padding: PaddingValues = PaddingValues(0.dp),
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.background(c.card)
|
||||||
|
.border(1.dp, c.cardBorder, shape)
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.padding(padding),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Colored box (alerts, notes, hero cards): no border unless given. */
|
||||||
|
@Composable
|
||||||
|
fun ShxBox(
|
||||||
|
background: Color,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
radius: Dp = 24.dp,
|
||||||
|
border: Color? = null,
|
||||||
|
padding: PaddingValues = PaddingValues(16.dp),
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val shape = RoundedCornerShape(radius)
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.background(background)
|
||||||
|
.then(if (border != null) Modifier.border(1.dp, border, shape) else Modifier)
|
||||||
|
.padding(padding),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rounded square icon well (40dp, radius 14 by default). */
|
||||||
|
@Composable
|
||||||
|
fun ShxIconWell(icon: ImageVector, background: Color, tint: Color, size: Dp = 40.dp, radius: Dp = 14.dp, iconSize: Dp = 20.dp)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(size).clip(RoundedCornerShape(radius)).background(background),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.size(iconSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List row inside a card: 76dp, 40dp icon well, 16/700 title, 13 sub,
|
||||||
|
* optional trailing; a 1px divider below unless it is the last row.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ShxRow(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
subtitle: String? = null,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
iconBackground: Color? = null,
|
||||||
|
iconTint: Color? = null,
|
||||||
|
subtitleColor: Color? = null,
|
||||||
|
divider: Boolean = false,
|
||||||
|
chevron: Boolean = false,
|
||||||
|
minHeight: Dp = 76.dp,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
leading: (@Composable () -> Unit)? = null,
|
||||||
|
trailing: (@Composable RowScope.() -> Unit)? = null,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column(modifier.fillMaxWidth())
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
|
||||||
|
.heightIn(min = minHeight)
|
||||||
|
.padding(start = 16.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (leading != null)
|
||||||
|
{
|
||||||
|
leading()
|
||||||
|
}
|
||||||
|
else if (icon != null)
|
||||||
|
{
|
||||||
|
ShxIconWell(icon, iconBackground ?: c.accentContainer, iconTint ?: c.onAccentContainer)
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.rowTitle, color = c.text)
|
||||||
|
if (subtitle != null)
|
||||||
|
{
|
||||||
|
Text(subtitle, style = ShxType.sub, color = subtitleColor ?: c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailing?.invoke(this)
|
||||||
|
if (chevron)
|
||||||
|
{
|
||||||
|
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (divider)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle of the mockup: 52x32 track, 24dp white knob. */
|
||||||
|
@Composable
|
||||||
|
fun ShxSwitch(checked: Boolean, onCheckedChange: ((Boolean) -> Unit)?, modifier: Modifier = Modifier, enabled: Boolean = true)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val track by animateColorAsState(if (checked) c.accent else c.outline, label = "switchTrack")
|
||||||
|
val offset by animateDpAsState(if (checked) 20.dp else 0.dp, label = "switchKnob")
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(52.dp, 32.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(if (enabled) track else track.copy(alpha = 0.4f))
|
||||||
|
.then(
|
||||||
|
if (onCheckedChange != null)
|
||||||
|
Modifier.toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = onCheckedChange)
|
||||||
|
else Modifier
|
||||||
|
)
|
||||||
|
.padding(4.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(Modifier.offset(x = offset).size(24.dp).clip(CircleShape).background(Color.White))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ShxButtonStyle { PRIMARY, TONAL, SOFT, OUTLINE, WARNING, DANGER }
|
||||||
|
|
||||||
|
/** Buttons of the mockup: 56 (large), 48 (medium) or 36 (small) high. */
|
||||||
|
@Composable
|
||||||
|
fun ShxButton(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
style: ShxButtonStyle = ShxButtonStyle.PRIMARY,
|
||||||
|
height: Dp = 56.dp,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val radius = when
|
||||||
|
{
|
||||||
|
height >= 56.dp -> 18.dp
|
||||||
|
height >= 44.dp -> 16.dp
|
||||||
|
else -> 12.dp
|
||||||
|
}
|
||||||
|
val shape = RoundedCornerShape(radius)
|
||||||
|
val (bg, fg) = when (style)
|
||||||
|
{
|
||||||
|
ShxButtonStyle.PRIMARY -> c.accent to c.onAccent
|
||||||
|
ShxButtonStyle.TONAL -> c.tile to c.textTonal
|
||||||
|
ShxButtonStyle.SOFT -> c.accentMuted to c.textTonal
|
||||||
|
ShxButtonStyle.OUTLINE -> Color.Transparent to c.text
|
||||||
|
ShxButtonStyle.WARNING -> c.warning to Color.White
|
||||||
|
ShxButtonStyle.DANGER -> c.errorContainer to c.onErrorContainer
|
||||||
|
}
|
||||||
|
val textStyle = if (height >= 44.dp) ShxType.button else ShxType.chip.copy(fontWeight = FontWeight.ExtraBold)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = modifier
|
||||||
|
.height(height)
|
||||||
|
.clip(shape)
|
||||||
|
.background(if (enabled) bg else bg.copy(alpha = 0.38f))
|
||||||
|
.then(if (style == ShxButtonStyle.OUTLINE) Modifier.border(1.dp, c.outline, shape) else Modifier)
|
||||||
|
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||||
|
.padding(horizontal = if (height >= 44.dp) 10.dp else 14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (icon != null)
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
/* two lines on large buttons: on 320dp screens «Скачать и установить» beside «Позже» was cut to «Скачать и установ…» */
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style = textStyle,
|
||||||
|
color = if (enabled) fg else fg.copy(alpha = 0.6f),
|
||||||
|
/* a single word must not wrap mid-word («Отмени / ть»): it shrinks instead */
|
||||||
|
maxLines = if (height >= 52.dp && text.trim().contains(' ')) 2 else 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
||||||
|
autoSize = androidx.compose.foundation.text.TextAutoSize.StepBased(minFontSize = 11.sp, maxFontSize = textStyle.fontSize),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ShxChipTone { NORMAL, WARNING }
|
||||||
|
|
||||||
|
/** Filter chip: 34dp, radius 12; selected = deep purple fill. */
|
||||||
|
@Composable
|
||||||
|
fun ShxChip(text: String, selected: Boolean, onClick: () -> Unit, tone: ShxChipTone = ShxChipTone.NORMAL)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(12.dp)
|
||||||
|
val (bg, fg) = when
|
||||||
|
{
|
||||||
|
selected -> c.chipSelected to (if (c.chipSelected.luminanceIsDark()) Color.White else Color(0xFF1C1B20))
|
||||||
|
tone == ShxChipTone.WARNING -> c.warningStrong to c.onWarning
|
||||||
|
else -> Color.Transparent to c.text
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.height(34.dp)
|
||||||
|
.clip(shape)
|
||||||
|
.background(bg)
|
||||||
|
.then(if (!selected && tone == ShxChipTone.NORMAL) Modifier.border(1.dp, c.outline, shape) else Modifier)
|
||||||
|
.selectable(selected = selected, role = Role.Tab, onClick = onClick)
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(text, style = if (selected) ShxType.chip else ShxType.chip.copy(fontWeight = FontWeight.SemiBold), color = fg, maxLines = 1, softWrap = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Color.luminanceIsDark(): Boolean = (0.299f * red + 0.587f * green + 0.114f * blue) < 0.5f
|
||||||
|
|
||||||
|
/** Segmented tabs: grey track (radius 16, padding 4), white selected tab (radius 12, 40dp). */
|
||||||
|
@Composable
|
||||||
|
fun ShxTabs(options: List<String>, selected: Int, onSelect: (Int) -> Unit, modifier: Modifier = Modifier)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
modifier = modifier.fillMaxWidth().clip(RoundedCornerShape(16.dp)).background(c.tile).padding(4.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
options.forEachIndexed { index, label ->
|
||||||
|
val on = index == selected
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.height(40.dp)
|
||||||
|
.then(if (on) Modifier.shadow(1.dp, RoundedCornerShape(12.dp)) else Modifier)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(if (on) c.card else Color.Transparent)
|
||||||
|
.selectable(selected = on, role = Role.Tab, onClick = { onSelect(index) }),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = ShxType.body.copy(fontWeight = if (on) FontWeight.ExtraBold else FontWeight.SemiBold),
|
||||||
|
color = if (on) c.text else c.textSoft,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ShxChoiceTone { ACCENT, SUCCESS }
|
||||||
|
|
||||||
|
/** Selectable choice card (radius 18): 2px colored border when selected. */
|
||||||
|
@Composable
|
||||||
|
fun ShxChoiceCard(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
selected: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
tone: ShxChoiceTone = ShxChoiceTone.ACCENT,
|
||||||
|
badge: (@Composable () -> Unit)? = null,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(18.dp)
|
||||||
|
val (border, bg, titleColor, subColor) = when
|
||||||
|
{
|
||||||
|
!selected -> Quad(c.outline, c.card, c.text, c.textSoft)
|
||||||
|
tone == ShxChoiceTone.SUCCESS -> Quad(c.success, c.successSoft, c.onSuccessStrong, c.onSuccess)
|
||||||
|
else -> Quad(c.accent, c.accentSoft, c.text, c.textSoft)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.clip(shape)
|
||||||
|
.background(bg)
|
||||||
|
.border(if (selected) 2.dp else 1.dp, border, shape)
|
||||||
|
.selectable(selected = selected, role = Role.RadioButton, onClick = onClick)
|
||||||
|
.padding(14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||||
|
{
|
||||||
|
Text(title, style = ShxType.cardTitle, color = titleColor)
|
||||||
|
badge?.invoke()
|
||||||
|
}
|
||||||
|
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = subColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Quad(val a: Color, val b: Color, val c: Color, val d: Color)
|
||||||
|
|
||||||
|
/** Text field of the mockup: white box, radius 14, 2px purple border, label inside. */
|
||||||
|
@Composable
|
||||||
|
fun ShxField(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
mono: Boolean = false,
|
||||||
|
isError: Boolean = false,
|
||||||
|
errorText: String? = null,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(14.dp)
|
||||||
|
val tint = if (isError) c.error else c.accent
|
||||||
|
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.background(c.card)
|
||||||
|
.border(2.dp, tint, shape)
|
||||||
|
.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = if (isError) c.error else c.accentText)
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = (if (mono) ShxType.mono.copy(fontSize = 16.sp) else ShxType.body.copy(fontSize = 16.sp)).copy(color = c.text),
|
||||||
|
cursorBrush = SolidColor(c.accent),
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
keyboardActions = keyboardActions,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isError && errorText != null)
|
||||||
|
{
|
||||||
|
Text(errorText, style = ShxType.meta, color = c.error, modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ShxTone { ACCENT, WARNING, SUCCESS, NEUTRAL, ERROR }
|
||||||
|
|
||||||
|
/** Soft note box: 12/14 padding, radius 18, 13/1.45 text. */
|
||||||
|
@Composable
|
||||||
|
fun ShxNote(text: String, modifier: Modifier = Modifier, tone: ShxTone = ShxTone.ACCENT)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val (bg, fg) = when (tone)
|
||||||
|
{
|
||||||
|
ShxTone.ACCENT -> c.accentSoft to c.textTonal
|
||||||
|
ShxTone.WARNING -> c.warningContainer to c.onWarningContainer
|
||||||
|
ShxTone.SUCCESS -> c.successSoft to c.onSuccess
|
||||||
|
ShxTone.NEUTRAL -> c.tile to c.textSoft
|
||||||
|
ShxTone.ERROR -> c.errorContainer to c.onErrorContainer
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = ShxType.note,
|
||||||
|
color = fg,
|
||||||
|
modifier = modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(bg).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pill badge: 11/800, radius 999. */
|
||||||
|
@Composable
|
||||||
|
fun ShxBadge(text: String, tone: ShxTone = ShxTone.WARNING, modifier: Modifier = Modifier)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val (bg, fg) = when (tone)
|
||||||
|
{
|
||||||
|
ShxTone.WARNING -> c.warningStrong to c.onWarning
|
||||||
|
ShxTone.ACCENT -> c.accentContainer to c.onAccentContainer
|
||||||
|
ShxTone.SUCCESS -> c.successContainer to c.onSuccess
|
||||||
|
ShxTone.NEUTRAL -> c.tile to c.textSoft
|
||||||
|
ShxTone.ERROR -> c.errorContainer to c.onErrorContainer
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = ShxType.tag,
|
||||||
|
color = fg,
|
||||||
|
modifier = modifier.clip(RoundedCornerShape(999.dp)).background(bg).padding(horizontal = 8.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small square tag on rule rows: radius 6. */
|
||||||
|
@Composable
|
||||||
|
fun ShxTag(text: String, background: Color, color: Color)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
text = text.uppercase(),
|
||||||
|
style = ShxType.tag.copy(fontSize = 10.sp),
|
||||||
|
color = color,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(6.dp)).background(background).padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet of the mockup: radius 32, 36x4 handle, page background.
|
||||||
|
* [dismissible] = false: swipe, scrim tap and «Back» do not close it (a mandatory update).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ShxSheet(onDismiss: () -> Unit, dismissible: Boolean = true, content: @Composable ColumnScope.() -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { if (dismissible) onDismiss() },
|
||||||
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true, confirmValueChange = { dismissible || it != androidx.compose.material3.SheetValue.Hidden }),
|
||||||
|
properties = androidx.compose.material3.ModalBottomSheetProperties(shouldDismissOnBackPress = dismissible, shouldDismissOnClickOutside = dismissible),
|
||||||
|
containerColor = c.background,
|
||||||
|
scrimColor = c.scrim,
|
||||||
|
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
||||||
|
dragHandle = {
|
||||||
|
Box(Modifier.padding(top = 12.dp, bottom = 4.dp).size(36.dp, 4.dp).clip(RoundedCornerShape(2.dp)).background(c.outline))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
{
|
||||||
|
/* belt and braces for a mandatory sheet: «Back» is swallowed even if the dialog forwards it */
|
||||||
|
androidx.activity.compose.BackHandler(enabled = !dismissible) {}
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(start = 16.dp, end = 16.dp, bottom = 28.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sheet title: 24/800 with 4dp side inset. */
|
||||||
|
@Composable
|
||||||
|
fun ShxSheetTitle(text: String)
|
||||||
|
{
|
||||||
|
Text(text, style = ShxType.sheetTitle, color = LocalShxColors.current.text, modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp).semantics { heading() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Grey info line/tile button: 52dp, radius 18, 15/600. */
|
||||||
|
@Composable
|
||||||
|
fun ShxTileButton(text: String, icon: ImageVector?, onClick: () -> Unit, modifier: Modifier = Modifier)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(52.dp)
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(c.tile)
|
||||||
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (icon != null)
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||||
|
}
|
||||||
|
Text(text, style = ShxType.body.copy(fontSize = 15.sp), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Body text of a screen intro: 14, sub color, 6dp inset. */
|
||||||
|
@Composable
|
||||||
|
fun ShxIntro(text: String, modifier: Modifier = Modifier)
|
||||||
|
{
|
||||||
|
Text(text, style = ShxType.note.copy(fontSize = 14.sp), color = LocalShxColors.current.textSub, modifier = modifier.padding(horizontal = 4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form field of the mockup (F_ManualAdd): white box, radius 16, label 12/700
|
||||||
|
* inside, value 16/600; 2px purple border while focused, red on error.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ShxFormField(
|
||||||
|
label: String,
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
placeholder: String? = null,
|
||||||
|
supporting: String? = null,
|
||||||
|
error: String? = null,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
mono: Boolean = false,
|
||||||
|
singleLine: Boolean = true,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
visualTransformation: androidx.compose.ui.text.input.VisualTransformation = androidx.compose.ui.text.input.VisualTransformation.None,
|
||||||
|
trailing: (@Composable () -> Unit)? = null,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(16.dp)
|
||||||
|
var focused by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||||
|
val borderColor = when
|
||||||
|
{
|
||||||
|
error != null -> c.error
|
||||||
|
focused -> c.accent
|
||||||
|
else -> c.cardBorder
|
||||||
|
}
|
||||||
|
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(shape)
|
||||||
|
.background(c.card)
|
||||||
|
.border(if (focused || error != null) 2.dp else 1.dp, borderColor, shape)
|
||||||
|
.padding(start = 14.dp, end = if (trailing != null) 4.dp else 14.dp, top = 10.dp, bottom = 10.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style = ShxType.meta.copy(fontWeight = FontWeight.Bold),
|
||||||
|
color = when
|
||||||
|
{
|
||||||
|
error != null -> c.error
|
||||||
|
focused -> c.accentText
|
||||||
|
else -> c.textSub
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Box()
|
||||||
|
{
|
||||||
|
if (value.isEmpty() && placeholder != null)
|
||||||
|
{
|
||||||
|
Text(placeholder, style = ShxType.body.copy(fontSize = 16.sp), color = c.iconMuted, maxLines = 1)
|
||||||
|
}
|
||||||
|
BasicTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
enabled = enabled,
|
||||||
|
singleLine = singleLine,
|
||||||
|
textStyle = (if (mono) ShxType.mono.copy(fontSize = 16.sp) else ShxType.body.copy(fontSize = 16.sp)).copy(color = if (enabled) c.text else c.textSub),
|
||||||
|
cursorBrush = SolidColor(c.accent),
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
visualTransformation = visualTransformation,
|
||||||
|
modifier = Modifier.fillMaxWidth().onFocusChanged { focused = it.isFocused },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
trailing?.invoke()
|
||||||
|
}
|
||||||
|
val note = error ?: supporting
|
||||||
|
if (note != null)
|
||||||
|
{
|
||||||
|
Text(note, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = if (error != null) c.error else c.textSub, modifier = Modifier.padding(horizontal = 4.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle row inside a white card: 15/700 title, optional 12 sub, the mockup switch. */
|
||||||
|
@Composable
|
||||||
|
fun ShxToggleRow(title: String, checked: Boolean, onChange: (Boolean) -> Unit, subtitle: String? = null, enabled: Boolean = true, divider: Boolean = false)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
Column()
|
||||||
|
{
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.heightIn(min = 58.dp)
|
||||||
|
.toggleable(value = checked, enabled = enabled, role = Role.Switch, onValueChange = onChange)
|
||||||
|
.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.cardTitle.copy(fontWeight = FontWeight.Bold), color = if (enabled) c.text else c.textSub)
|
||||||
|
if (subtitle != null)
|
||||||
|
{
|
||||||
|
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Normal), color = c.textSub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ShxSwitch(checked = checked, onCheckedChange = null, enabled = enabled)
|
||||||
|
}
|
||||||
|
if (divider)
|
||||||
|
{
|
||||||
|
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirmation dialog of the mockup (F_ServerDelete): 28dp card, a 48dp round
|
||||||
|
* icon, 22/800 title, 14sp paragraphs, a text «Cancel» and a filled action.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ShxDialog(
|
||||||
|
title: String,
|
||||||
|
paragraphs: List<String>,
|
||||||
|
confirm: String,
|
||||||
|
dismiss: String,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
icon: ImageVector? = null,
|
||||||
|
danger: Boolean = false,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss)
|
||||||
|
{
|
||||||
|
DialogScrim()
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(28.dp)).background(c.background).padding(24.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (icon != null)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.size(48.dp).clip(CircleShape).background(if (danger) c.errorContainer else c.accentContainer),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Icon(icon, contentDescription = null, tint = if (danger) c.error else c.onAccentContainer, modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(title, style = ShxType.pageTitle, color = c.text)
|
||||||
|
paragraphs.forEach {
|
||||||
|
Text(it, style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 21.sp, fontWeight = FontWeight.Normal), color = c.textSoft)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.height(44.dp).clip(RoundedCornerShape(22.dp))
|
||||||
|
.clickable(role = Role.Button, onClick = onDismiss).padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(dismiss, style = ShxType.button.copy(fontWeight = FontWeight.Bold), color = c.accentText)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.height(44.dp).clip(RoundedCornerShape(22.dp))
|
||||||
|
.background(if (danger) c.error else c.accent)
|
||||||
|
.clickable(role = Role.Button, onClick = onConfirm).padding(horizontal = 20.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(confirm, style = ShxType.button, color = if (danger) c.background else Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop-in for the Material AlertDialog in the mockup look (as [ShxDialog]):
|
||||||
|
* 28dp card on the page colour, 22/800 title, 14sp soft text, buttons at the end.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ShxAlertDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
confirmButton: @Composable () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
dismissButton: (@Composable () -> Unit)? = null,
|
||||||
|
icon: (@Composable () -> Unit)? = null,
|
||||||
|
title: (@Composable () -> Unit)? = null,
|
||||||
|
text: (@Composable () -> Unit)? = null,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
androidx.compose.ui.window.Dialog(onDismissRequest = onDismissRequest)
|
||||||
|
{
|
||||||
|
DialogScrim()
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
modifier = modifier.fillMaxWidth().clip(RoundedCornerShape(28.dp)).background(c.background).padding(24.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
icon?.let {
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides c.onAccentContainer) { it() }
|
||||||
|
}
|
||||||
|
title?.let {
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides c.text) {
|
||||||
|
androidx.compose.material3.ProvideTextStyle(ShxType.pageTitle) { it() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text?.let {
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides c.textSoft) {
|
||||||
|
androidx.compose.material3.ProvideTextStyle(ShxType.note.copy(fontSize = 14.sp, lineHeight = 21.sp, fontWeight = FontWeight.Normal)) {
|
||||||
|
Box(Modifier.weight(1f, fill = false)) { it() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
androidx.compose.material3.ProvideTextStyle(ShxType.button.copy(fontWeight = FontWeight.Bold))
|
||||||
|
{
|
||||||
|
dismissButton?.invoke()
|
||||||
|
confirmButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 50% scrim of the mockup dialogs. Set on the dialog window itself:
|
||||||
|
* hosts with a translucent theme (VpnProfileControlActivity) turn the
|
||||||
|
* platform dim off.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun DialogScrim()
|
||||||
|
{
|
||||||
|
val view = androidx.compose.ui.platform.LocalView.current
|
||||||
|
androidx.compose.runtime.SideEffect {
|
||||||
|
(view.parent as? androidx.compose.ui.window.DialogWindowProvider)?.window?.let {
|
||||||
|
it.addFlags(android.view.WindowManager.LayoutParams.FLAG_DIM_BEHIND)
|
||||||
|
it.setDimAmount(0.5f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog button of the mockup: the action as a filled 44dp pill (red when
|
||||||
|
* [danger]), «Cancel» and the like as accent text. Same shape of call as
|
||||||
|
* the Material TextButton it replaces.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ShxDialogButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
primary: Boolean = false,
|
||||||
|
danger: Boolean = false,
|
||||||
|
content: @Composable RowScope.() -> Unit,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val bg = when
|
||||||
|
{
|
||||||
|
!primary -> Color.Transparent
|
||||||
|
danger -> c.error
|
||||||
|
else -> c.accent
|
||||||
|
}
|
||||||
|
val fg = when
|
||||||
|
{
|
||||||
|
!primary -> if (danger) c.error else c.accentText
|
||||||
|
danger -> c.background
|
||||||
|
else -> Color.White
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.Center,
|
||||||
|
modifier = modifier.height(44.dp).clip(RoundedCornerShape(22.dp)).background(if (enabled) bg else bg.copy(alpha = 0.4f))
|
||||||
|
.clickable(enabled = enabled, role = Role.Button, onClick = onClick)
|
||||||
|
.padding(horizontal = if (primary) 20.dp else 16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
androidx.compose.runtime.CompositionLocalProvider(androidx.compose.material3.LocalContentColor provides if (enabled) fg else fg.copy(alpha = 0.5f))
|
||||||
|
{
|
||||||
|
androidx.compose.material3.ProvideTextStyle(if (primary) ShxType.button else ShxType.button.copy(fontWeight = FontWeight.Bold))
|
||||||
|
{
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-575
@@ -1,575 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
|
||||||
|
|
||||||
package org.strongswan.android.ui.compose.home
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.Spring
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.spring
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
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.statusBarsPadding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Add
|
|
||||||
import androidx.compose.material.icons.rounded.AltRoute
|
|
||||||
import androidx.compose.material.icons.rounded.ArrowDownward
|
|
||||||
import androidx.compose.material.icons.rounded.ArrowUpward
|
|
||||||
import androidx.compose.material.icons.rounded.ChevronRight
|
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
|
||||||
import androidx.compose.material.icons.rounded.Dns
|
|
||||||
import androidx.compose.material.icons.rounded.ExpandLess
|
|
||||||
import androidx.compose.material.icons.rounded.GppMaybe
|
|
||||||
import androidx.compose.material.icons.rounded.Language
|
|
||||||
import androidx.compose.material.icons.rounded.PlayArrow
|
|
||||||
import androidx.compose.material.icons.rounded.Public
|
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
|
||||||
import androidx.compose.material.icons.rounded.Schedule
|
|
||||||
import androidx.compose.material.icons.rounded.Share
|
|
||||||
import androidx.compose.material.icons.rounded.SignalCellularAlt
|
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
|
||||||
import androidx.compose.material.icons.rounded.Wifi
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
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.compose.collectAsStateWithLifecycle
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import org.strongswan.android.R
|
|
||||||
import org.strongswan.android.data.VpnProfile
|
|
||||||
import org.strongswan.android.logic.VpnStateService.ErrorState
|
|
||||||
import org.strongswan.android.logic.VpnStateService.State
|
|
||||||
import org.strongswan.android.logic.connectivity.ProbeResult
|
|
||||||
import org.strongswan.android.logic.connectivity.ProbeTarget
|
|
||||||
import org.strongswan.android.logic.connectivity.WhiteListState
|
|
||||||
import org.strongswan.android.logic.connectivity.WhiteListStatus
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxCard
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxEmptyState
|
|
||||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
|
||||||
import org.strongswan.android.ui.compose.theme.ScreenPadding
|
|
||||||
import org.strongswan.android.ui.compose.DeadTargetUi
|
|
||||||
import org.strongswan.android.ui.compose.ExitUi
|
|
||||||
import org.strongswan.android.ui.compose.ShxDivider
|
|
||||||
import org.strongswan.android.ui.compose.ProfileAvatar
|
|
||||||
import org.strongswan.android.ui.compose.TrafficUi
|
|
||||||
import org.strongswan.android.ui.compose.formatBytes
|
|
||||||
import org.strongswan.android.ui.compose.formatDuration
|
|
||||||
import org.strongswan.android.ui.compose.formatRate
|
|
||||||
|
|
||||||
import androidx.compose.animation.animateContentSize
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
|
||||||
|
|
||||||
/* ---------------- availability card ---------------- */
|
|
||||||
|
|
||||||
internal val PROBE_ORDER = listOf(ProbeTarget.SERVER, ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE)
|
|
||||||
|
|
||||||
/** Server first, then external targets in fixed order. */
|
|
||||||
internal fun Map<ProbeTarget, ProbeResult>.sortedForDisplay(): List<ProbeResult> =
|
|
||||||
PROBE_ORDER.filter { containsKey(it) }.mapNotNull { this[it] }
|
|
||||||
|
|
||||||
internal fun ProbeTarget.labelRes(): Int = when (this)
|
|
||||||
{
|
|
||||||
ProbeTarget.SERVER -> R.string.probe_target_server
|
|
||||||
ProbeTarget.GOOGLE -> R.string.probe_target_google
|
|
||||||
ProbeTarget.YOUTUBE -> R.string.probe_target_youtube
|
|
||||||
ProbeTarget.CLOUDFLARE -> R.string.probe_target_cloudflare
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun ProbeResult.rttText(): String = when
|
|
||||||
{
|
|
||||||
ok && rttMs != null -> stringResource(R.string.probe_rtt_fmt, rttMs)
|
|
||||||
else -> stringResource(R.string.value_no_data)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A dead direct destination: address, the app hammering it, refusal stats,
|
|
||||||
* and a one-tap action to force the host through the tunnel. */
|
|
||||||
@Composable
|
|
||||||
internal fun DeadTargetRow(target: DeadTargetUi, onForceTunnel: (String) -> Unit)
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 18.dp, top = 6.dp, bottom = 2.dp),
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = target.dst,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = listOfNotNull(
|
|
||||||
target.owner,
|
|
||||||
stringResource(R.string.dead_targets_fails_fmt, target.fails),
|
|
||||||
stringResource(R.string.dead_targets_cooldown_fmt, formatDuration(target.leftMs / 1000)),
|
|
||||||
).joinToString(" · "),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (target.viaTunnel)
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.dead_targets_via_tunnel),
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
TextButton(
|
|
||||||
onClick = { onForceTunnel(target.dst) },
|
|
||||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.dead_targets_force),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun DirectProbeRow(label: String, ok: Boolean?, rttMs: Long?)
|
|
||||||
{ Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
val status = LocalShxStatusColors.current
|
|
||||||
/* tri-state: null = not checked yet, false = checked and unreachable */
|
|
||||||
val dotColor = when (ok)
|
|
||||||
{
|
|
||||||
true -> status.connected
|
|
||||||
false -> status.disconnected
|
|
||||||
null -> MaterialTheme.colorScheme.outlineVariant
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.background(dotColor, CircleShape),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = when
|
|
||||||
{
|
|
||||||
ok == true && rttMs != null -> stringResource(R.string.probe_rtt_fmt, rttMs)
|
|
||||||
ok == false -> stringResource(R.string.value_unreachable)
|
|
||||||
else -> stringResource(R.string.value_no_data)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = when (ok)
|
|
||||||
{
|
|
||||||
true -> MaterialTheme.colorScheme.onSurface
|
|
||||||
false -> status.disconnected
|
|
||||||
null -> MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun ProbeRow(result: ProbeResult, measuring: Boolean, history: List<ProbeResult?> = emptyList())
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
val status = LocalShxStatusColors.current
|
|
||||||
val dotColor = when
|
|
||||||
{
|
|
||||||
!result.ok -> status.disconnected
|
|
||||||
result.rttMs != null && result.rttMs < WARN_RTT_MS -> status.connected
|
|
||||||
result.rttMs != null && result.rttMs < FAIL_RTT_MS -> status.connecting
|
|
||||||
else -> status.disconnected
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.background(dotColor, CircleShape),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(result.target.labelRes()),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
)
|
|
||||||
RttSparkline(samples = history)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
|
||||||
if (result.ok && result.rttMs == null && measuring)
|
|
||||||
{
|
|
||||||
LoadingIndicator(Modifier.size(12.dp))
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
text = result.rttText(),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (result.ok) MaterialTheme.colorScheme.onSurface else status.disconnected,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal const val RTT_HISTORY = 16
|
|
||||||
|
|
||||||
/** Tiny bar chart of the last RTT samples; missing samples render as dim ticks. */
|
|
||||||
@Composable
|
|
||||||
internal fun RttSparkline(samples: List<ProbeResult?>)
|
|
||||||
{
|
|
||||||
val status = LocalShxStatusColors.current
|
|
||||||
val idle = MaterialTheme.colorScheme.outlineVariant
|
|
||||||
Canvas(modifier = Modifier.size(width = 52.dp, height = 16.dp))
|
|
||||||
{
|
|
||||||
val barW = size.width / RTT_HISTORY
|
|
||||||
val barActual = barW * 0.6f
|
|
||||||
val shown = samples.takeLast(RTT_HISTORY)
|
|
||||||
shown.forEachIndexed { index, sample ->
|
|
||||||
val x = size.width - (shown.size - index) * barW
|
|
||||||
val rtt = sample?.rttMs
|
|
||||||
if (sample == null || rtt == null)
|
|
||||||
{
|
|
||||||
drawRoundRect(
|
|
||||||
color = idle.copy(alpha = 0.5f),
|
|
||||||
topLeft = Offset(x + (barW - barActual) / 2, size.height - 2.dp.toPx()),
|
|
||||||
size = Size(barActual, 2.dp.toPx()),
|
|
||||||
cornerRadius = CornerRadius(1.dp.toPx()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
val fraction = (rtt.toFloat() / FAIL_RTT_MS).coerceIn(0.12f, 1f)
|
|
||||||
val barH = size.height * fraction
|
|
||||||
val color = when
|
|
||||||
{
|
|
||||||
!sample.ok -> status.disconnected
|
|
||||||
rtt < WARN_RTT_MS -> status.connected
|
|
||||||
else -> status.connecting
|
|
||||||
}
|
|
||||||
drawRoundRect(
|
|
||||||
color = color,
|
|
||||||
topLeft = Offset(x + (barW - barActual) / 2, size.height - barH),
|
|
||||||
size = Size(barActual, barH),
|
|
||||||
cornerRadius = CornerRadius(2.dp.toPx()),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal const val WARN_RTT_MS = 150L
|
|
||||||
internal const val FAIL_RTT_MS = 400L
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Availability probes. Collapsed by default into one summary row (colored dots + worst
|
|
||||||
* RTT); expands in place with the per-target rows, dead-host list and white-list probes.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun AvailabilityCard(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
probes: Map<ProbeTarget, ProbeResult>,
|
|
||||||
probing: Boolean,
|
|
||||||
whiteList: WhiteListState,
|
|
||||||
history: Map<ProbeTarget, List<ProbeResult?>>,
|
|
||||||
deadTargets: List<DeadTargetUi>,
|
|
||||||
onOpenRouting: () -> Unit,
|
|
||||||
onForceTunnel: (String) -> Unit,
|
|
||||||
onRefresh: () -> Unit,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
|
||||||
val rows = probes.sortedForDisplay()
|
|
||||||
Surface(
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = ShxSpacing.lg)
|
|
||||||
.animateContentSize(MaterialTheme.motionScheme.defaultSpatialSpec()),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
PullToRefreshBox(isRefreshing = probing, onRefresh = onRefresh)
|
|
||||||
{
|
|
||||||
Column(Modifier.padding(ShxSpacing.lg))
|
|
||||||
{
|
|
||||||
val toggleLabel = stringResource(if (expanded) R.string.availability_collapse else R.string.availability_expand)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClickLabel = toggleLabel) { expanded = !expanded },
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.availability_title),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
if (probing)
|
|
||||||
{
|
|
||||||
LoadingIndicator(Modifier.size(24.dp))
|
|
||||||
Spacer(Modifier.size(ShxSpacing.sm))
|
|
||||||
}
|
|
||||||
if (!expanded)
|
|
||||||
{
|
|
||||||
AvailabilitySummary(rows = rows, deadCount = deadTargets.size)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
IconButton(onClick = onRefresh, enabled = !probing) {
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.Refresh,
|
|
||||||
contentDescription = stringResource(R.string.availability_refresh),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
AnimatedVisibility(visible = expanded)
|
|
||||||
{
|
|
||||||
Column {
|
|
||||||
Spacer(Modifier.height(ShxSpacing.xs))
|
|
||||||
if (rows.isEmpty())
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
modifier = Modifier.padding(top = ShxSpacing.xs),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
LoadingIndicator(Modifier.size(24.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.availability_measuring),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
rows.forEachIndexed { index, result ->
|
|
||||||
ProbeRow(result = result, measuring = probing, history = history[result.target].orEmpty())
|
|
||||||
if (index < rows.lastIndex)
|
|
||||||
{
|
|
||||||
ShxDivider()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (deadTargets.isNotEmpty())
|
|
||||||
{
|
|
||||||
DeadTargetsSection(deadTargets, onOpenRouting, onForceTunnel)
|
|
||||||
}
|
|
||||||
if (whiteList.config.enabled)
|
|
||||||
{
|
|
||||||
WhiteListProbes(whiteList)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AvailabilitySummary(rows: List<ProbeResult>, deadCount: Int)
|
|
||||||
{
|
|
||||||
val status = LocalShxStatusColors.current
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs))
|
|
||||||
{
|
|
||||||
rows.forEach { result ->
|
|
||||||
val dot = when
|
|
||||||
{
|
|
||||||
!result.ok -> status.disconnected
|
|
||||||
result.rttMs != null && result.rttMs < WARN_RTT_MS -> status.connected
|
|
||||||
result.rttMs != null && result.rttMs < FAIL_RTT_MS -> status.connecting
|
|
||||||
else -> status.disconnected
|
|
||||||
}
|
|
||||||
Box(Modifier.size(8.dp).background(dot, CircleShape))
|
|
||||||
}
|
|
||||||
if (deadCount > 0)
|
|
||||||
{
|
|
||||||
Box(Modifier.size(8.dp).background(MaterialTheme.colorScheme.error, CircleShape))
|
|
||||||
}
|
|
||||||
val worst = rows.filter { it.ok }.mapNotNull { it.rttMs }.maxOrNull()
|
|
||||||
val anyFailed = rows.any { !it.ok }
|
|
||||||
Text(
|
|
||||||
text = when
|
|
||||||
{
|
|
||||||
anyFailed -> stringResource(R.string.value_unreachable)
|
|
||||||
worst != null -> stringResource(R.string.probe_rtt_fmt, worst)
|
|
||||||
else -> stringResource(R.string.value_no_data)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (anyFailed) status.disconnected else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = ShxSpacing.xs),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DeadTargetsSection(deadTargets: List<DeadTargetUi>, onOpenRouting: () -> Unit, onForceTunnel: (String) -> Unit)
|
|
||||||
{
|
|
||||||
Spacer(Modifier.height(ShxSpacing.sm))
|
|
||||||
ShxDivider()
|
|
||||||
Spacer(Modifier.height(ShxSpacing.sm))
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onOpenRouting)
|
|
||||||
.padding(vertical = ShxSpacing.xs),
|
|
||||||
) {
|
|
||||||
Box(Modifier.size(8.dp).background(MaterialTheme.colorScheme.error, CircleShape))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.dead_targets_title),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.ChevronRight,
|
|
||||||
contentDescription = stringResource(R.string.dead_targets_open),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
deadTargets.take(3).forEach { target ->
|
|
||||||
DeadTargetRow(target = target, onForceTunnel = onForceTunnel)
|
|
||||||
}
|
|
||||||
if (deadTargets.size > 3)
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.dead_targets_more_fmt, deadTargets.size - 3),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = 18.dp, top = ShxSpacing.xs),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun WhiteListProbes(whiteList: WhiteListState)
|
|
||||||
{
|
|
||||||
Spacer(Modifier.height(ShxSpacing.sm))
|
|
||||||
ShxDivider()
|
|
||||||
Spacer(Modifier.height(ShxSpacing.sm))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.whitelist_direct_section),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
DirectProbeRow(
|
|
||||||
label = stringResource(R.string.whitelist_foreign_direct),
|
|
||||||
ok = whiteList.foreignDirectOk,
|
|
||||||
rttMs = whiteList.foreignDirectRttMs,
|
|
||||||
)
|
|
||||||
ShxDivider()
|
|
||||||
DirectProbeRow(
|
|
||||||
label = stringResource(R.string.whitelist_domestic_direct),
|
|
||||||
ok = whiteList.domesticDirectOk,
|
|
||||||
rttMs = whiteList.domesticDirectRttMs,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* 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.compose.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
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.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
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.logic.TrustedCertificateManager
|
||||||
|
import org.strongswan.android.ui.CERT_SOON_DAYS
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import org.strongswan.android.ui.compose.theme.ShxType
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** The CA certificate of the current profile runs out soon (F_CertExpiry). */
|
||||||
|
internal data class CertExpiry(val alias: String, val days: Long, val notAfter: Long)
|
||||||
|
|
||||||
|
private const val SNOOZE_PREFIX = "pref_cert_snooze_"
|
||||||
|
private const val SNOOZE_MS = 24L * 60 * 60 * 1000
|
||||||
|
|
||||||
|
/** The expiry of [alias] when it is within [CERT_SOON_DAYS] and not snoozed; blocking. */
|
||||||
|
internal fun certExpiry(context: Context, alias: String?): CertExpiry?
|
||||||
|
{
|
||||||
|
alias ?: return null
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (PreferenceManager.getDefaultSharedPreferences(context).getLong(SNOOZE_PREFIX + alias, 0L) > now)
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val cert = runCatching { TrustedCertificateManager.getInstance().load().getCACertificateFromAlias(alias) }.getOrNull() ?: return null
|
||||||
|
val days = TimeUnit.MILLISECONDS.toDays(cert.notAfter.time - now)
|
||||||
|
return if (days <= CERT_SOON_DAYS) CertExpiry(alias, days, cert.notAfter.time) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Later»: the card comes back tomorrow. */
|
||||||
|
internal fun snoozeCertExpiry(context: Context, alias: String)
|
||||||
|
{
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||||
|
.putLong(SNOOZE_PREFIX + alias, System.currentTimeMillis() + SNOOZE_MS).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amber card of F_CertExpiry: title, what happens when, «Update now» / «Later». */
|
||||||
|
@Composable
|
||||||
|
internal fun CertExpiryCard(expiry: CertExpiry, server: String, onUpdate: () -> Unit, onLater: () -> Unit)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val shape = RoundedCornerShape(24.dp)
|
||||||
|
val date = SimpleDateFormat("d MMMM", Locale.getDefault()).format(Date(expiry.notAfter))
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(shape).background(c.warningContainer).border(1.dp, c.warningBorder, shape).padding(16.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(
|
||||||
|
if (expiry.days < 0) stringResource(R.string.cert_expired_title) else stringResource(R.string.cert_expiry_title, expiry.days.toInt()),
|
||||||
|
style = ShxType.cardTitle, color = c.onWarningStrong,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
stringResource(if (expiry.days < 0) R.string.cert_expired_text else R.string.cert_expiry_text, server, date),
|
||||||
|
style = ShxType.note.copy(fontSize = 13.sp, lineHeight = 19.sp, fontWeight = FontWeight.Normal), color = c.onWarningContainer,
|
||||||
|
)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||||
|
{
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.weight(2f).height(44.dp).clip(RoundedCornerShape(14.dp)).background(Color(0xFFB45309))
|
||||||
|
.clickable(role = Role.Button, onClick = onUpdate).padding(horizontal = 14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.cert_update_now), style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = Color.White, maxLines = 1)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
modifier = Modifier.weight(1f).height(44.dp).clip(RoundedCornerShape(14.dp)).border(1.dp, Color(0xFFC7A27A), RoundedCornerShape(14.dp))
|
||||||
|
.clickable(role = Role.Button, onClick = onLater).padding(horizontal = 14.dp),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Text(stringResource(R.string.cert_later), style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = c.onWarningStrong, maxLines = 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* 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.compose.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.strongswan.android.R
|
||||||
|
import org.strongswan.android.logic.CharonVpnService
|
||||||
|
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||||
|
import java.io.File
|
||||||
|
import java.io.RandomAccessFile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the running connection got (F_HomeConnecting), read from the
|
||||||
|
* daemon log, which starts fresh for every connection:
|
||||||
|
* 1 the network is there, 2 the server answered, 3 sign-in passed,
|
||||||
|
* 4 the tunnel is up. Returns the number of finished steps.
|
||||||
|
*/
|
||||||
|
internal fun connectProgress(context: Context): Int
|
||||||
|
{
|
||||||
|
val text = tail(File(context.filesDir, CharonVpnService.LOG_FILE)) ?: return 1
|
||||||
|
var done = 1
|
||||||
|
if (text.contains("received packet"))
|
||||||
|
{
|
||||||
|
done = 2
|
||||||
|
}
|
||||||
|
if (Regex("authentication of .* successful").containsMatchIn(text) || Regex("IKE_SA .* established").containsMatchIn(text))
|
||||||
|
{
|
||||||
|
done = 3
|
||||||
|
}
|
||||||
|
if (Regex("CHILD_SA .* established").containsMatchIn(text))
|
||||||
|
{
|
||||||
|
done = 4
|
||||||
|
}
|
||||||
|
return done
|
||||||
|
}
|
||||||
|
|
||||||
|
/* the last 64 KiB are plenty for one connection attempt */
|
||||||
|
private fun tail(file: File): String?
|
||||||
|
{
|
||||||
|
if (!file.exists())
|
||||||
|
{
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return runCatching {
|
||||||
|
RandomAccessFile(file, "r").use { f ->
|
||||||
|
val start = (f.length() - 65536).coerceAtLeast(0)
|
||||||
|
f.seek(start)
|
||||||
|
val bytes = ByteArray((f.length() - start).toInt())
|
||||||
|
f.readFully(bytes)
|
||||||
|
String(bytes, Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The four steps: ✓ done, … current (purple), empty grey ones ahead. */
|
||||||
|
@Composable
|
||||||
|
internal fun ConnectStepsPanel(done: Int, network: String, serverRtt: String)
|
||||||
|
{
|
||||||
|
val c = LocalShxColors.current
|
||||||
|
val steps = listOf(
|
||||||
|
stringResource(R.string.connect_step_network) to network,
|
||||||
|
stringResource(R.string.connect_step_server) to serverRtt,
|
||||||
|
stringResource(R.string.connect_step_auth) to "",
|
||||||
|
stringResource(R.string.connect_step_tunnel) to "",
|
||||||
|
)
|
||||||
|
CheckPanel {
|
||||||
|
steps.forEachIndexed { i, (title, value) ->
|
||||||
|
when
|
||||||
|
{
|
||||||
|
i < done -> CheckLine("✓", c.successContainer, c.onSuccess, title, value)
|
||||||
|
i == done -> CheckLine("…", c.accent, androidx.compose.ui.graphics.Color.White, title, "", strong = true)
|
||||||
|
else -> CheckLine("", c.tile, c.iconMuted, title, "", muted = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
-244
@@ -1,244 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
|
|
||||||
package org.strongswan.android.ui.compose.home
|
|
||||||
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Language
|
|
||||||
import androidx.compose.material.icons.rounded.PowerSettingsNew
|
|
||||||
import androidx.compose.material.icons.rounded.UnfoldMore
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.MaterialShapes
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.semantics.Role
|
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
|
||||||
import androidx.compose.ui.semantics.role
|
|
||||||
import androidx.compose.ui.semantics.semantics
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Outline
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.asComposePath
|
|
||||||
import androidx.compose.ui.unit.Density
|
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
|
||||||
import androidx.graphics.shapes.Morph
|
|
||||||
import androidx.graphics.shapes.toPath
|
|
||||||
import org.strongswan.android.R
|
|
||||||
import org.strongswan.android.data.VpnProfile
|
|
||||||
import org.strongswan.android.logic.VpnStateService.State
|
|
||||||
import org.strongswan.android.ui.compose.ExitUi
|
|
||||||
import org.strongswan.android.ui.compose.ProfileAvatar
|
|
||||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The main connect control: a large expressive shape that morphs between a circle
|
|
||||||
* (disconnected), a scalloped cookie (connecting) and the full cookie (connected),
|
|
||||||
* with the state color and a loading indicator while the tunnel is coming up.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun HeroConnect(
|
|
||||||
state: State,
|
|
||||||
enabled: Boolean,
|
|
||||||
onConnect: () -> Unit,
|
|
||||||
onDisconnect: () -> Unit,
|
|
||||||
onCancel: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
val status = LocalShxStatusColors.current
|
|
||||||
val connected = state == State.CONNECTED
|
|
||||||
val connecting = state == State.CONNECTING || state == State.DISCONNECTING
|
|
||||||
val haptics = LocalHapticFeedback.current
|
|
||||||
|
|
||||||
val container by animateColorAsState(
|
|
||||||
targetValue = when
|
|
||||||
{
|
|
||||||
connected -> status.connectedContainer
|
|
||||||
connecting -> status.connectingContainer
|
|
||||||
else -> MaterialTheme.colorScheme.primary
|
|
||||||
},
|
|
||||||
animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
|
|
||||||
label = "heroContainer",
|
|
||||||
)
|
|
||||||
val content by animateColorAsState(
|
|
||||||
targetValue = when
|
|
||||||
{
|
|
||||||
connected -> status.connected
|
|
||||||
connecting -> status.connecting
|
|
||||||
else -> MaterialTheme.colorScheme.onPrimary
|
|
||||||
},
|
|
||||||
animationSpec = MaterialTheme.motionScheme.defaultEffectsSpec(),
|
|
||||||
label = "heroContent",
|
|
||||||
)
|
|
||||||
val progress by animateFloatAsState(
|
|
||||||
targetValue = when
|
|
||||||
{
|
|
||||||
connected -> 1f
|
|
||||||
connecting -> 0.5f
|
|
||||||
else -> 0f
|
|
||||||
},
|
|
||||||
animationSpec = MaterialTheme.motionScheme.slowSpatialSpec(),
|
|
||||||
label = "heroMorph",
|
|
||||||
)
|
|
||||||
val morph = remember { Morph(MaterialShapes.Circle, MaterialShapes.Cookie9Sided) }
|
|
||||||
val shape = remember(progress) { MorphShape(morph, progress) }
|
|
||||||
|
|
||||||
val label = stringResource(
|
|
||||||
when
|
|
||||||
{
|
|
||||||
connected -> R.string.disconnect
|
|
||||||
connecting -> android.R.string.cancel
|
|
||||||
else -> R.string.connect
|
|
||||||
}
|
|
||||||
)
|
|
||||||
Surface(
|
|
||||||
onClick = {
|
|
||||||
when
|
|
||||||
{
|
|
||||||
connected ->
|
|
||||||
{
|
|
||||||
haptics.performHapticFeedback(HapticFeedbackType.Reject)
|
|
||||||
onDisconnect()
|
|
||||||
}
|
|
||||||
connecting -> onCancel()
|
|
||||||
else ->
|
|
||||||
{
|
|
||||||
haptics.performHapticFeedback(HapticFeedbackType.Confirm)
|
|
||||||
onConnect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = enabled || connected || connecting,
|
|
||||||
shape = shape,
|
|
||||||
color = container,
|
|
||||||
contentColor = content,
|
|
||||||
modifier = modifier
|
|
||||||
.size(176.dp)
|
|
||||||
.semantics {
|
|
||||||
contentDescription = label
|
|
||||||
role = Role.Button
|
|
||||||
},
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Box(contentAlignment = Alignment.Center)
|
|
||||||
{
|
|
||||||
if (connecting)
|
|
||||||
{
|
|
||||||
LoadingIndicator(Modifier.size(72.dp), color = content)
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.PowerSettingsNew,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(72.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Current profile as a chip: name, and exit country + IP while connected; tap opens the picker. */
|
|
||||||
@Composable
|
|
||||||
internal fun ProfileChip(
|
|
||||||
profile: VpnProfile?,
|
|
||||||
exit: ExitUi?,
|
|
||||||
connected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
val secondary = when
|
|
||||||
{
|
|
||||||
connected && exit?.ip != null -> exit.country?.let { "$it · ${exit.ip}" } ?: exit.ip
|
|
||||||
else -> profile?.gateway ?: ""
|
|
||||||
}
|
|
||||||
Surface(
|
|
||||||
onClick = onClick,
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
|
||||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
ProfileAvatar(icon = Icons.Rounded.Language, connected = connected)
|
|
||||||
Column(Modifier.weight(1f))
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
text = profile?.name ?: stringResource(R.string.no_profiles),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
if (!secondary.isNullOrEmpty())
|
|
||||||
{
|
|
||||||
Text(
|
|
||||||
text = secondary,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
Icons.Rounded.UnfoldMore,
|
|
||||||
contentDescription = stringResource(R.string.home_profile_picker_title),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compose Shape drawing a [Morph] at [progress], scaled to fill the layout bounds. */
|
|
||||||
private class MorphShape(private val morph: Morph, private val progress: Float) : Shape
|
|
||||||
{
|
|
||||||
override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline
|
|
||||||
{
|
|
||||||
val bounds = morph.calculateBounds()
|
|
||||||
val width = bounds[2] - bounds[0]
|
|
||||||
val height = bounds[3] - bounds[1]
|
|
||||||
val matrix = android.graphics.Matrix()
|
|
||||||
matrix.setScale(size.width / width, size.height / height)
|
|
||||||
matrix.preTranslate(-bounds[0], -bounds[1])
|
|
||||||
val path = morph.toPath(progress)
|
|
||||||
path.transform(matrix)
|
|
||||||
return Outline.Generic(path.asComposePath())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-234
@@ -1,234 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
|
|
||||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
|
||||||
|
|
||||||
package org.strongswan.android.ui.compose.home
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.animateColorAsState
|
|
||||||
import androidx.compose.animation.core.LinearEasing
|
|
||||||
import androidx.compose.animation.core.RepeatMode
|
|
||||||
import androidx.compose.animation.core.Spring
|
|
||||||
import androidx.compose.animation.core.animateFloat
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.infiniteRepeatable
|
|
||||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
|
||||||
import androidx.compose.animation.core.spring
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.expandVertically
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.shrinkVertically
|
|
||||||
import androidx.compose.animation.slideInVertically
|
|
||||||
import androidx.compose.animation.slideOutVertically
|
|
||||||
import androidx.compose.animation.togetherWith
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
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.statusBarsPadding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.rounded.Add
|
|
||||||
import androidx.compose.material.icons.rounded.AltRoute
|
|
||||||
import androidx.compose.material.icons.rounded.ArrowDownward
|
|
||||||
import androidx.compose.material.icons.rounded.ArrowUpward
|
|
||||||
import androidx.compose.material.icons.rounded.ChevronRight
|
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
|
||||||
import androidx.compose.material.icons.rounded.Dns
|
|
||||||
import androidx.compose.material.icons.rounded.ExpandLess
|
|
||||||
import androidx.compose.material.icons.rounded.GppMaybe
|
|
||||||
import androidx.compose.material.icons.rounded.Language
|
|
||||||
import androidx.compose.material.icons.rounded.PlayArrow
|
|
||||||
import androidx.compose.material.icons.rounded.Public
|
|
||||||
import androidx.compose.material.icons.rounded.Refresh
|
|
||||||
import androidx.compose.material.icons.rounded.Schedule
|
|
||||||
import androidx.compose.material.icons.rounded.Share
|
|
||||||
import androidx.compose.material.icons.rounded.SignalCellularAlt
|
|
||||||
import androidx.compose.material.icons.rounded.Edit
|
|
||||||
import androidx.compose.material.icons.rounded.Wifi
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.LoadingIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FilledTonalButton
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.ListItem
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
|
||||||
import androidx.compose.runtime.mutableStateMapOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
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.compose.collectAsStateWithLifecycle
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import org.strongswan.android.R
|
|
||||||
import org.strongswan.android.data.VpnProfile
|
|
||||||
import org.strongswan.android.logic.VpnStateService.ErrorState
|
|
||||||
import org.strongswan.android.logic.VpnStateService.State
|
|
||||||
import org.strongswan.android.logic.connectivity.ProbeResult
|
|
||||||
import org.strongswan.android.logic.connectivity.ProbeTarget
|
|
||||||
import org.strongswan.android.logic.connectivity.WhiteListState
|
|
||||||
import org.strongswan.android.logic.connectivity.WhiteListStatus
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxCard
|
|
||||||
import org.strongswan.android.ui.compose.components.ShxEmptyState
|
|
||||||
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
|
|
||||||
import org.strongswan.android.ui.compose.theme.ScreenPadding
|
|
||||||
import org.strongswan.android.ui.compose.DeadTargetUi
|
|
||||||
import org.strongswan.android.ui.compose.ExitUi
|
|
||||||
import org.strongswan.android.ui.compose.ShxDivider
|
|
||||||
import org.strongswan.android.ui.compose.ProfileAvatar
|
|
||||||
import org.strongswan.android.ui.compose.TrafficUi
|
|
||||||
import org.strongswan.android.ui.compose.formatBytes
|
|
||||||
import org.strongswan.android.ui.compose.formatDuration
|
|
||||||
import org.strongswan.android.ui.compose.formatRate
|
|
||||||
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun ErrorBanner(modifier: Modifier = Modifier, onRetry: () -> Unit, onShowLog: () -> Unit)
|
|
||||||
{
|
|
||||||
Surface(
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.connection_failed),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
maxLines = 2,
|
|
||||||
)
|
|
||||||
TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
|
|
||||||
TextButton(onClick = onShowLog) { Text(stringResource(R.string.show_log)) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun WhiteListBanner(modifier: Modifier = Modifier)
|
|
||||||
{
|
|
||||||
val status = LocalShxStatusColors.current
|
|
||||||
Surface(
|
|
||||||
shape = MaterialTheme.shapes.large,
|
|
||||||
color = status.connectingContainer,
|
|
||||||
contentColor = status.onConnectingContainer,
|
|
||||||
modifier = modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
)
|
|
||||||
{
|
|
||||||
Icon(Icons.Rounded.GppMaybe, contentDescription = null)
|
|
||||||
Spacer(Modifier.size(12.dp))
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.whitelist_active_title),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.whitelist_active_subtitle),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** At most one status banner at a time: connection error beats the white-list notice. */
|
|
||||||
@Composable
|
|
||||||
internal fun HomeBannerSlot(
|
|
||||||
error: Boolean,
|
|
||||||
whiteListActive: Boolean,
|
|
||||||
onRetry: () -> Unit,
|
|
||||||
onShowLog: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
)
|
|
||||||
{
|
|
||||||
val kind = when
|
|
||||||
{
|
|
||||||
error -> 1
|
|
||||||
whiteListActive -> 2
|
|
||||||
else -> 0
|
|
||||||
}
|
|
||||||
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
|
|
||||||
val fastEffects = MaterialTheme.motionScheme.fastEffectsSpec<Float>()
|
|
||||||
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<IntSize>()
|
|
||||||
val fastSpatial = MaterialTheme.motionScheme.fastSpatialSpec<IntSize>()
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = kind,
|
|
||||||
modifier = modifier,
|
|
||||||
transitionSpec = {
|
|
||||||
(fadeIn(effects) + expandVertically(spatial)) togetherWith
|
|
||||||
(fadeOut(fastEffects) + shrinkVertically(fastSpatial))
|
|
||||||
},
|
|
||||||
label = "homeBanner",
|
|
||||||
) { k ->
|
|
||||||
when (k)
|
|
||||||
{
|
|
||||||
1 -> ErrorBanner(Modifier.padding(horizontal = ShxSpacing.lg), onRetry = onRetry, onShowLog = onShowLog)
|
|
||||||
2 -> WhiteListBanner(Modifier.padding(horizontal = ShxSpacing.lg))
|
|
||||||
else -> Spacer(Modifier.height(0.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loaded 100 of 336 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user