Compare commits
77
Commits
No files matched your search
@@ -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`) ПРИВАТНЫЙ.** Публичного чтения нет и включать его не нужно:
|
||||
`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`:
|
||||
он читает `.env`, собирает `assembleRelease`, подписывает keystore'ом из `.env`, генерирует `latest.json`
|
||||
(sha256/size/versionCode) и заливает `shx-vpn.apk` + `latest.json` в корень бакета с `Cache-Control: no-cache`.
|
||||
- **Порядок релиза:** `--skip-upload` → smoke-тест APK на эмуляторе (AVD `shx36`, все вкладки, `adb logcat -b crash`) →
|
||||
выгрузка именно проверенного `build/publish/shx-vpn.apk` и `latest.json` через `aws s3 cp` → сверка `latest.json` в бакете.
|
||||
Выгрузка в S3 — только с подтверждения владельца.
|
||||
- **`.env` нельзя парсить через `source`/`eval`**: значения `NOTES_RU` и `AWS_CA_BUNDLE` содержат пробелы и
|
||||
обратные слэши. Разбирать только построчно `KEY=...` (как в `build-local.sh`).
|
||||
- aws cli установлен через pip: `%APPDATA%\Python\Python313\Scripts\aws` (скрипт сам добавляет путь в PATH).
|
||||
- `versionCode` растёт вручную в `app/build.gradle`; перед сборкой сверять с `versionCode` в `latest.json` бакета.
|
||||
- `NOTES_RU` в `.env` — заметки релиза (попадают в `latest.json` → диалог обновления в приложении); обновлять под релиз.
|
||||
`.env` не в git: в worktree лежит своя копия, после релиза перенести `NOTES_RU` в `.env` основного checkout.
|
||||
- **Сборка из worktree:** скопировать игнорируемые входы из основного checkout (`.env` с абсолютным `KEYSTORE_FILE`,
|
||||
`local.properties`, `Android.common.mk`, `app/src/main/jni/openssl/`, сгенерированные исходники libstrongswan — список в `docs/README.md`)
|
||||
и собирать с `ORG_GRADLE_PROJECT_shortNdkCommands=true` (иначе линковка упирается в лимит длины команды Windows).
|
||||
- Автообновление приложения: `logic/updates/UpdateManager.kt` качает манифест/APK, сверяет SHA-256 и подпись.
|
||||
|
||||
## Android-приложение: выкладка через панель SHX VPN
|
||||
|
||||
Когда владелец просит **«собери и выложи»** (или «собери и загрузи в панель»), выкладка идёт через панель
|
||||
`https://apk.shnt.top`, а не через `aws s3 cp`. Сама просьба и есть подтверждение владельца на загрузку.
|
||||
|
||||
- Команда (из `src/frontends/android`, с `ORG_GRADLE_PROJECT_shortNdkCommands=true` при сборке из worktree):
|
||||
`./gradlew uploadShx` — собирает `assembleRelease` и загружает APK **черновиком** в канал `beta`, тип `phone`.
|
||||
- Опубликовать сразу — только если владелец сказал «опубликуй»: `./gradlew uploadShx -PshxPublish -PshxRollout=<процент>`.
|
||||
Без явного процента спросить его. Канал релиза — `-PshxChannel=release`, другой тип — `-PshxType=<id>`.
|
||||
- Заметки «Что нового» берутся из `NOTES_RU` в `.env`; другие — `-PshxNotes="..."`. Перед выкладкой проверить, что
|
||||
`NOTES_RU` и `versionCode` в `app/build.gradle` относятся к этому релизу (панель отклонит уже загруженный код версии).
|
||||
- Нужны `SHX_URL` и `SHX_TOKEN` (заданы у пользователя Windows; скрипт `scripts/upload-apk.mjs` читает их и из реестра,
|
||||
если процесс запущен раньше). Не печатать значение токена.
|
||||
- Подпись: release APK подписывается keystore из `.env` (`KEYSTORE_FILE`); неподписанный APK задача не загружает.
|
||||
- После загрузки сообщить владельцу ссылку на версию из вывода скрипта и статус (черновик или текущая с процентом).
|
||||
- Сборка для старых приложений 2.19.x (`latest.json` в корне бакета) по-прежнему идёт через `build-local.*` и
|
||||
требует отдельного подтверждения.
|
||||
@@ -45,8 +45,8 @@ android {
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
|
||||
versionCode 160
|
||||
versionName "2.15.0"
|
||||
versionCode 172
|
||||
versionName "2.19.4"
|
||||
|
||||
/* Base URL of the update storage, trailing slash included */
|
||||
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_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 {
|
||||
ndkBuild {
|
||||
arguments '-j' + Runtime.runtime.availableProcessors()
|
||||
@@ -173,6 +184,10 @@ dependencies {
|
||||
implementation 'androidx.glance:glance-appwidget:1.2.0'
|
||||
implementation 'androidx.glance:glance-material3:1.2.0'
|
||||
implementation 'androidx.activity:activity-compose:1.11.0'
|
||||
/* QR: scanning profiles (camera) and showing a profile as a QR code */
|
||||
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||
/* Ed25519 verification of the signed update manifest (java.security Ed25519 needs API 33, minSdk is 24) */
|
||||
implementation 'net.i2p.crypto:eddsa:0.3.0'
|
||||
|
||||
debugImplementation 'androidx.compose.ui:ui-tooling'
|
||||
implementation 'androidx.compose.ui:ui-tooling-preview'
|
||||
@@ -180,6 +195,8 @@ dependencies {
|
||||
testImplementation 'junit:junit:4.13.2'
|
||||
testImplementation 'org.assertj:assertj-core:3.27.6'
|
||||
testImplementation 'org.mockito:mockito-core:5.20.0'
|
||||
/* the android.jar org.json stubs throw on the JVM; the real implementation is needed for the update API tests */
|
||||
testImplementation 'org.json:json:20240303'
|
||||
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.3.0'
|
||||
androidTestImplementation 'androidx.test:runner:1.7.0'
|
||||
@@ -187,3 +204,32 @@ dependencies {
|
||||
androidTestImplementation composeBom
|
||||
androidTestImplementation 'androidx.compose.ui:ui-test-junit4'
|
||||
}
|
||||
|
||||
/* Publish to the SHX VPN panel (https://apk.shnt.top): build the release APK and upload it in one step.
|
||||
./gradlew uploadShx -> draft in the beta channel, type phone
|
||||
./gradlew uploadShx -PshxPublish -PshxRollout=25
|
||||
./gradlew uploadShx -PshxChannel=release -PshxType=tv -PshxNotes="..."
|
||||
Needs Node 18+ and SHX_URL, SHX_TOKEN (a CI token from the panel's Settings -> Tokens).
|
||||
Release notes default to NOTES_RU from .env; the version comes from the build's output-metadata.json. */
|
||||
tasks.register('uploadShx', Exec) {
|
||||
group = 'publishing'
|
||||
description = 'Build the release APK and upload it to the SHX VPN panel'
|
||||
dependsOn 'assembleRelease'
|
||||
def apkFile = layout.buildDirectory.file('outputs/apk/release/app-release.apk')
|
||||
def script = rootProject.file('scripts/upload-apk.mjs')
|
||||
doFirst {
|
||||
def apk = apkFile.get().asFile
|
||||
if (!apk.exists()) {
|
||||
throw new GradleException("${apk} not found: the release APK is unsigned (set KEYSTORE_FILE in .env)")
|
||||
}
|
||||
def args = ['node', script.absolutePath, apk.absolutePath,
|
||||
'--type', (findProperty('shxType') ?: envOr('SHX_TYPE', 'phone')).toString(),
|
||||
'--channel', (findProperty('shxChannel') ?: 'beta').toString()]
|
||||
def notes = (findProperty('shxNotes') ?: envOr('NOTES_RU', '')).toString()
|
||||
if (notes) args += ['--notes', notes]
|
||||
if (project.hasProperty('shxPublish')) {
|
||||
args += ['--publish', '--rollout', (findProperty('shxRollout') ?: '100').toString()]
|
||||
}
|
||||
commandLine args
|
||||
}
|
||||
}
|
||||
@@ -58,3 +58,12 @@
|
||||
# --- squash warnings for optional dependencies referenced only from XML ---
|
||||
|
||||
-dontwarn org.bouncycastle.jsse.util.**
|
||||
|
||||
# --- Room databases are instantiated reflectively (WorkManager's WorkDatabase_Impl) ---
|
||||
|
||||
-keep class * extends androidx.room.RoomDatabase {
|
||||
<init>();
|
||||
}
|
||||
|
||||
# i2p eddsa (manifest signature check) references sun.security.x509 only for JDK interop, absent on Android
|
||||
-dontwarn sun.security.x509.**
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.routing.TemplateMatch
|
||||
import org.strongswan.android.ui.compose.routing.AppReason
|
||||
import org.strongswan.android.ui.compose.routing.AppsFilter
|
||||
import org.strongswan.android.ui.compose.routing.DirectAppRow
|
||||
import org.strongswan.android.ui.compose.routing.DirectAppsContent
|
||||
import org.strongswan.android.ui.compose.routing.DirectAppsGroup
|
||||
import org.strongswan.android.ui.compose.routing.DirectAppsUiState
|
||||
import org.strongswan.android.ui.compose.routing.QuickChipState
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
/** Smoke test of the stateless "Apps direct" screen: filter chips, quick-add chips and an app row. */
|
||||
class DirectAppsScreenTest
|
||||
{
|
||||
@get:Rule
|
||||
val composeRule = createComposeRule()
|
||||
|
||||
private fun string(id: Int, vararg args: Any): String =
|
||||
InstrumentationRegistry.getInstrumentation().targetContext.getString(id, *args)
|
||||
|
||||
private val state = DirectAppsUiState(
|
||||
loading = false,
|
||||
groups = listOf(
|
||||
DirectAppsGroup(
|
||||
category = "banks",
|
||||
rows = listOf(
|
||||
DirectAppRow("com.example.bank", "Example Bank", true, AppReason.Template(TemplateMatch(category = "banks"))),
|
||||
),
|
||||
),
|
||||
DirectAppsGroup(
|
||||
category = null,
|
||||
rows = listOf(DirectAppRow("com.example.chat", "Example Chat", false, AppReason.Tunnel)),
|
||||
),
|
||||
),
|
||||
directCount = 1,
|
||||
tunnelCount = 1,
|
||||
filter = AppsFilter.ALL,
|
||||
quickChips = listOf(QuickChipState("banks", true), QuickChipState("shop", false)),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun showsFiltersQuickChipsAndRows()
|
||||
{
|
||||
var toggled: Pair<String, Boolean>? = null
|
||||
composeRule.setContent {
|
||||
ShxTheme {
|
||||
DirectAppsContent(
|
||||
state = state,
|
||||
onBack = {},
|
||||
onQuery = {},
|
||||
onFilter = {},
|
||||
onShowSystem = {},
|
||||
onToggleApp = { pkg, direct -> toggled = pkg to direct },
|
||||
onToggleCategory = { _, _ -> },
|
||||
onAllRf = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
composeRule.waitForIdle()
|
||||
composeRule.onNodeWithText(string(R.string.apps_filter_direct, 1)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(string(R.string.apps_filter_vpn, 1)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText(string(R.string.routing_apps_add_all_rf)).assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Example Bank").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Example Chat").performClick()
|
||||
assertEquals("com.example.chat" to true, toggled)
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,14 @@
|
||||
<!-- SSID is redacted in the background on Android 10+ without this. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<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"/>
|
||||
<!-- required to install downloaded updates of the app itself -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
@@ -39,7 +44,8 @@
|
||||
<!-- required to claim update ownership (setRequestUpdateOwnership) so our
|
||||
own updates keep being applied silently on Android 14+ -->
|
||||
<uses-permission android:name="android.permission.ENFORCE_UPDATE_OWNERSHIP" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<!-- Required to reliably schedule NAT keepalives/rekeyings via the AlarmManager.
|
||||
@@ -74,6 +80,18 @@
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
</intent-filter>
|
||||
</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
|
||||
android:name=".ui.VpnProfileControlActivity"
|
||||
android:theme="@style/TransparentActivity"
|
||||
@@ -108,10 +126,6 @@
|
||||
android:name=".ui.TrustedCertificatesActivity"
|
||||
android:label="@string/trusted_certs_title" >
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.SelectedApplicationsActivity"
|
||||
android:label="@string/profile_select_apps" >
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.LogActivity"
|
||||
android:label="@string/log_title" >
|
||||
@@ -120,10 +134,6 @@
|
||||
android:name=".ui.SettingsActivity"
|
||||
android:label="@string/pref_title">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.TrustedNetworksActivity"
|
||||
android:label="@string/auto_connect_title">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.ConnectivityCheckActivity"
|
||||
android:label="@string/health_check_title">
|
||||
@@ -132,6 +142,18 @@
|
||||
android:name=".ui.RouteCheckActivity"
|
||||
android:label="@string/route_check_title">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.InboxActivity"
|
||||
android:label="@string/events_title">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.DiagnosticsActivity"
|
||||
android:label="@string/diag_title">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.OnboardingActivity"
|
||||
android:label="@string/app_name">
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".ui.RemediationInstructionsActivity"
|
||||
android:label="@string/remediation_instructions_title" >
|
||||
@@ -296,6 +318,18 @@
|
||||
<receiver
|
||||
android:name=".logic.WatchdogReceiver"
|
||||
android:exported="false" />
|
||||
<!-- «Yes» / «No» of the suggestion notifications -->
|
||||
<receiver
|
||||
android:name=".logic.events.EventAnswerReceiver"
|
||||
android:exported="false" />
|
||||
<!-- edges of the time windows of the user scenarios -->
|
||||
<receiver
|
||||
android:name=".logic.scenarios.ScenarioReceiver"
|
||||
android:exported="false" />
|
||||
<!-- ends a "pause VPN for N minutes" and reconnects the paused profile -->
|
||||
<receiver
|
||||
android:name=".logic.PauseReceiver"
|
||||
android:exported="false" />
|
||||
<!-- runs after the app has updated itself (the system kills the
|
||||
process during the install): cleans up and lets auto-connect
|
||||
restore the VPN -->
|
||||
@@ -306,6 +340,17 @@
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- after a reboot or a clock change: re-arm the pause and scenario alarms, evaluate the rules again -->
|
||||
<receiver
|
||||
android:name=".logic.BootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.TIME_SET" />
|
||||
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
+13
-3
@@ -54,6 +54,16 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
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
|
||||
public void close()
|
||||
{
|
||||
@@ -67,7 +77,7 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
public VpnProfile insertProfile(VpnProfile 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)
|
||||
{
|
||||
return null;
|
||||
@@ -82,14 +92,14 @@ public class VpnProfileSqlDataSource implements VpnProfileDataSource
|
||||
{
|
||||
final UUID uuid = profile.getUUID();
|
||||
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
|
||||
public boolean deleteVpnProfile(VpnProfile profile)
|
||||
{
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+313
-116
@@ -51,13 +51,13 @@ import android.util.Log;
|
||||
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.data.VpnProfile;
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
import org.strongswan.android.data.VpnProfileSource;
|
||||
import org.strongswan.android.data.VpnType.VpnTypeFeature;
|
||||
import org.strongswan.android.logic.VpnStateService.ErrorState;
|
||||
import org.strongswan.android.logic.VpnStateService.State;
|
||||
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.routing.RouteCheck;
|
||||
import org.strongswan.android.logic.routing.RoutingManager;
|
||||
@@ -92,8 +92,10 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.SortedSet;
|
||||
import java.util.TreeSet;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
@@ -156,8 +158,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
private VpnProfileDataSource mDataSource;
|
||||
/* profile lookups are SQLite queries and must not run on the main thread */
|
||||
private final ExecutorService mProfileExecutor = Executors.newSingleThreadExecutor();
|
||||
/* DNS/route diagnostics and private DNS lookups can block for many seconds
|
||||
* on a bad network: keep them off the profile executor, or a connect
|
||||
* queued behind them is delayed by that much */
|
||||
private final ExecutorService mDiagExecutor = Executors.newSingleThreadExecutor();
|
||||
private Thread mConnectionHandler;
|
||||
private VpnProfile mCurrentProfile;
|
||||
/* read by the ConnectivityThread (UnderlyingNetworkTracker) too */
|
||||
private volatile VpnProfile mCurrentProfile;
|
||||
private volatile String mCurrentCertificateAlias;
|
||||
private volatile String mCurrentUserCertificateAlias;
|
||||
private VpnProfile mNextProfile;
|
||||
@@ -165,10 +172,17 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
private volatile boolean mTerminate;
|
||||
private volatile boolean mIsDisconnecting;
|
||||
private volatile boolean mShowNotification;
|
||||
/* start of the current CONNECTED session for the notification chronometer */
|
||||
private volatile long mConnectedSince;
|
||||
/* rate limit for countdown-only notification updates (main thread only) */
|
||||
private static final long NOTIFICATION_MIN_INTERVAL_MS = 5000;
|
||||
private String mLastNotifiedKey;
|
||||
private long mLastNotifiedAt;
|
||||
private final BuilderAdapter mBuilderAdapter = new BuilderAdapter();
|
||||
private UnderlyingNetworkTracker mUnderlyingTracker;
|
||||
private Handler mHandler;
|
||||
private VpnStateService mService;
|
||||
private boolean mBound;
|
||||
private final Object mServiceLock = new Object();
|
||||
private final ServiceConnection mServiceConnection = new ServiceConnection()
|
||||
{
|
||||
@@ -217,8 +231,14 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
* first millisecond, and it avoids the system killing a fresh process
|
||||
* when the profile fails to load before addNotification() runs. The
|
||||
* state-specific notification replaces this one (via stateChanged()). */
|
||||
mShowNotification = true;
|
||||
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
|
||||
boolean disconnect = intent != null && DISCONNECT_ACTION.equals(intent.getAction());
|
||||
if (!disconnect)
|
||||
{ /* DISCONNECT arrives via plain startService(): promoting to the
|
||||
* foreground from the background is not allowed there (Android 12+)
|
||||
* and would crash, and there is nothing to keep alive anyway */
|
||||
mShowNotification = true;
|
||||
startForegroundSafe();
|
||||
}
|
||||
if (intent != null)
|
||||
{
|
||||
if (VPN_SERVICE_ACTION.equals(intent.getAction()))
|
||||
@@ -249,7 +269,19 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
VpnProfile profile = loadProfile(uuid);
|
||||
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()
|
||||
.putString(Constants.PREF_MRU_VPN_PROFILE, profile.getUUID().toString())
|
||||
.apply();
|
||||
@@ -282,7 +314,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return mDataSource.getVpnProfile(uuid);
|
||||
try
|
||||
{
|
||||
return mDataSource.getVpnProfile(uuid);
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{ /* e.g. the service is being destroyed and closed the database;
|
||||
* an uncaught exception on the executor would kill the process */
|
||||
DiagLog.i(TAG, "loading profile failed: " + e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -391,8 +432,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
/* use a separate thread as main thread for charon */
|
||||
mConnectionHandler = new Thread(this);
|
||||
/* the thread is started when the service is bound */
|
||||
bindService(new Intent(this, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
mBound = bindService(new Intent(this, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
|
||||
createNotificationChannel();
|
||||
}
|
||||
@@ -418,12 +459,29 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
{
|
||||
e.printStackTrace();
|
||||
}
|
||||
if (mService != null)
|
||||
synchronized (mServiceLock)
|
||||
{
|
||||
mService.unregisterListener(this);
|
||||
if (mService != null)
|
||||
{
|
||||
mService.unregisterListener(this);
|
||||
}
|
||||
}
|
||||
if (mBound)
|
||||
{ /* unbind even if onServiceConnected() never ran, or the binding leaks */
|
||||
unbindService(mServiceConnection);
|
||||
mBound = false;
|
||||
}
|
||||
mDiagExecutor.shutdownNow();
|
||||
/* queued profile loads must not run against the closed database */
|
||||
mProfileExecutor.shutdownNow();
|
||||
try
|
||||
{
|
||||
mProfileExecutor.awaitTermination(2, TimeUnit.SECONDS);
|
||||
}
|
||||
catch (InterruptedException e)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
mProfileExecutor.shutdown();
|
||||
mDataSource.close();
|
||||
}
|
||||
|
||||
@@ -461,6 +519,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
if (mNextProfile == null)
|
||||
{
|
||||
setState(State.DISABLED);
|
||||
/* nothing was running (e.g. DISCONNECT while already
|
||||
* disconnected, or the profile failed to load): drop the
|
||||
* placeholder notification so the service is not kept
|
||||
* in the foreground with a stale "Disabled" state */
|
||||
removeNotification();
|
||||
if (mTerminate)
|
||||
{
|
||||
break;
|
||||
@@ -510,6 +573,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
writer.setValue("connection.local_id", mCurrentProfile.getLocalId());
|
||||
writer.setValue("connection.remote_id", mCurrentProfile.getRemoteId());
|
||||
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.ike_proposal", mCurrentProfile.getIkeProposal());
|
||||
writer.setValue("connection.esp_proposal", mCurrentProfile.getEspProposal());
|
||||
@@ -537,7 +601,25 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
* and let the regular retry machinery handle it. */
|
||||
Log.e(TAG, "Exception in the connection thread", ex);
|
||||
DiagLog.i(TAG, "exception in connection thread: " + ex);
|
||||
stopCurrentConnection();
|
||||
try
|
||||
{
|
||||
stopCurrentConnection();
|
||||
}
|
||||
catch (Exception again)
|
||||
{ /* the exception may have come from stopCurrentConnection()
|
||||
* itself (e.g. a broken next profile): drop that profile
|
||||
* so this guard cannot throw again and kill the thread */
|
||||
DiagLog.i(TAG, "cleanup after exception failed: " + again);
|
||||
mNextProfile = null;
|
||||
if (mCurrentProfile != null)
|
||||
{
|
||||
SimpleFetcher.disable();
|
||||
deinitializeCharon();
|
||||
sUserspacePushActive = false;
|
||||
}
|
||||
removeNotification();
|
||||
mBuilderAdapter.closeBlocking();
|
||||
}
|
||||
setError(ErrorState.GENERIC_ERROR);
|
||||
setState(State.DISABLED);
|
||||
mCurrentProfile = null;
|
||||
@@ -593,11 +675,28 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
public void run()
|
||||
{
|
||||
mShowNotification = true;
|
||||
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
|
||||
startForegroundSafe();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote the service to the foreground. Never throws: on Android 12+ the
|
||||
* system may refuse the promotion while the app is in the background, which
|
||||
* must not crash the process.
|
||||
*/
|
||||
private void startForegroundSafe()
|
||||
{
|
||||
try
|
||||
{
|
||||
startForeground(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
DiagLog.i(TAG, "startForeground refused: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the permanent notification.
|
||||
*/
|
||||
@@ -649,6 +748,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
/* the state service may not be bound yet when startForeground() is
|
||||
@@ -669,6 +774,10 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
.setVisibility(publicVersion ? NotificationCompat.VISIBILITY_PUBLIC
|
||||
: NotificationCompat.VISIBILITY_PRIVATE);
|
||||
int s = R.string.state_disabled;
|
||||
if (state != State.CONNECTED || error != ErrorState.NO_ERROR)
|
||||
{
|
||||
mConnectedSince = 0;
|
||||
}
|
||||
if (error != ErrorState.NO_ERROR)
|
||||
{
|
||||
s = mService.getErrorText();
|
||||
@@ -715,6 +824,13 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
s = R.string.state_connected;
|
||||
builder.setColor(ContextCompat.getColor(this, R.color.success_text));
|
||||
builder.setUsesChronometer(true);
|
||||
/* without an explicit start every re-post would reset the
|
||||
* chronometer to 0:00 */
|
||||
if (mConnectedSince == 0)
|
||||
{
|
||||
mConnectedSince = System.currentTimeMillis();
|
||||
}
|
||||
builder.setWhen(mConnectedSince);
|
||||
add_action = true;
|
||||
break;
|
||||
case DISCONNECTING:
|
||||
@@ -722,7 +838,18 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
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;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
@@ -731,17 +858,33 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
}
|
||||
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)
|
||||
{
|
||||
Intent intent = new Intent(getApplicationContext(), VpnProfileControlActivity.class);
|
||||
intent.setAction(VpnProfileControlActivity.DISCONNECT);
|
||||
PendingIntent pending = PendingIntent.getActivity(getApplicationContext(), 0, intent,
|
||||
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));
|
||||
}
|
||||
@@ -758,6 +901,17 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
{
|
||||
if (mShowNotification)
|
||||
{
|
||||
/* the retry countdown notifies every 1-5 s; each re-post builds two
|
||||
* notifications and several PendingIntents (IPC). Countdown-only
|
||||
* updates are rate-limited, real state/error changes post at once */
|
||||
String key = (mService != null ? mService.getState() + "/" + mService.getErrorState() : "");
|
||||
long now = SystemClock.elapsedRealtime();
|
||||
if (key.equals(mLastNotifiedKey) && now - mLastNotifiedAt < NOTIFICATION_MIN_INTERVAL_MS)
|
||||
{
|
||||
return;
|
||||
}
|
||||
mLastNotifiedKey = key;
|
||||
mLastNotifiedAt = now;
|
||||
NotificationManager manager = (NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE);
|
||||
manager.notify(VPN_STATE_NOTIFICATION_ID, buildNotification(false));
|
||||
}
|
||||
@@ -788,6 +942,15 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
*/
|
||||
private void setState(State state)
|
||||
{
|
||||
/* the daily traffic history samples the counters while the tunnel is up */
|
||||
if (state == State.CONNECTED)
|
||||
{
|
||||
TrafficHistory.start(this);
|
||||
}
|
||||
else
|
||||
{
|
||||
TrafficHistory.stop(this);
|
||||
}
|
||||
synchronized (mServiceLock)
|
||||
{
|
||||
if (mService != null)
|
||||
@@ -873,7 +1036,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
scheduleRouteDiagnostics();
|
||||
/* feed the userspace router for this connection (also picks
|
||||
* up private DNS changes since the last push) */
|
||||
mProfileExecutor.execute(() -> {
|
||||
mDiagExecutor.execute(() -> {
|
||||
getPrivateDnsServers();
|
||||
pushUserspaceRoutingData(this);
|
||||
});
|
||||
@@ -915,7 +1078,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
{
|
||||
return;
|
||||
}
|
||||
mProfileExecutor.execute(() -> {
|
||||
mDiagExecutor.execute(() -> {
|
||||
try
|
||||
{
|
||||
logDnsDiagnostics();
|
||||
@@ -1063,6 +1226,29 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
RuIpListManager list = RuIpListManager.getInstance(context);
|
||||
List<IPRange> v4 = enabled ? list.getExcludedV4() : 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<>();
|
||||
if (enabled)
|
||||
@@ -1165,7 +1351,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
{
|
||||
return;
|
||||
}
|
||||
mProfileExecutor.execute(() -> {
|
||||
mDiagExecutor.execute(() -> {
|
||||
try
|
||||
{
|
||||
logRouteDiagnostics();
|
||||
@@ -1591,7 +1777,9 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
private ConnectivityManager mConnectivityManager;
|
||||
private boolean mRegistered;
|
||||
/* the set of networks last reported to the system, to skip redundant
|
||||
* setUnderlyingNetworks() calls (the callbacks can be chatty) */
|
||||
* setUnderlyingNetworks() calls (the callbacks can be chatty).
|
||||
* Guarded by mNetworks: written by the ConnectivityThread and by
|
||||
* charon's thread around establish() */
|
||||
private Network[] mApplied;
|
||||
|
||||
private final ConnectivityManager.NetworkCallback mCallback = new ConnectivityManager.NetworkCallback()
|
||||
@@ -1599,19 +1787,20 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
@Override
|
||||
public void onAvailable(Network network)
|
||||
{
|
||||
update(network);
|
||||
update(network, mConnectivityManager.getNetworkCapabilities(network));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCapabilitiesChanged(Network network, NetworkCapabilities networkCapabilities)
|
||||
{
|
||||
update(network);
|
||||
{ /* use the capabilities passed in: querying them again is an IPC
|
||||
* on every signal-strength update */
|
||||
update(network, networkCapabilities);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLost(Network network)
|
||||
{
|
||||
update(network);
|
||||
update(network, null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1661,8 +1850,8 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
synchronized (mNetworks)
|
||||
{
|
||||
mNetworks.clear();
|
||||
mApplied = null;
|
||||
}
|
||||
mApplied = null;
|
||||
}
|
||||
|
||||
private boolean isUsableUnderlying(NetworkCapabilities capabilities)
|
||||
@@ -1673,48 +1862,49 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN);
|
||||
}
|
||||
|
||||
private void update(Network network)
|
||||
private void update(Network network, NetworkCapabilities capabilities)
|
||||
{
|
||||
Network[] current;
|
||||
synchronized (mNetworks)
|
||||
{
|
||||
mNetworks.remove(network);
|
||||
if (isUsableUnderlying(mConnectivityManager.getNetworkCapabilities(network)))
|
||||
if (isUsableUnderlying(capabilities))
|
||||
{
|
||||
mNetworks.add(network);
|
||||
}
|
||||
current = mNetworks.toArray(new Network[0]);
|
||||
}
|
||||
if (!sameNetworks(current, mApplied))
|
||||
{
|
||||
if (sameNetworks(current, mApplied))
|
||||
{
|
||||
return;
|
||||
}
|
||||
mApplied = current;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
||||
{
|
||||
StringBuilder handles = new StringBuilder("underlying networks changed:");
|
||||
for (Network underlying : current)
|
||||
{
|
||||
StringBuilder handles = new StringBuilder("underlying networks changed:");
|
||||
for (Network underlying : current)
|
||||
{
|
||||
handles.append(" #").append(underlying.getNetworkHandle());
|
||||
}
|
||||
DiagLog.i(TAG, handles.toString());
|
||||
handles.append(" #").append(underlying.getNetworkHandle());
|
||||
}
|
||||
if (mCurrentProfile != null &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
|
||||
{ /* reflect roaming in the system's VPN routing right away
|
||||
* instead of waiting for the next establish(). Passing
|
||||
* null falls back to the system default, matching the
|
||||
* establish-time behavior; the call is a no-op for the
|
||||
* system if no tunnel is currently up. */
|
||||
setUnderlyingNetworks(current.length > 0 ? current : null);
|
||||
}
|
||||
/* let the state service react to the change (e.g. retry
|
||||
* immediately instead of waiting out a backoff that was sized
|
||||
* for the previous network) */
|
||||
synchronized (mServiceLock)
|
||||
DiagLog.i(TAG, handles.toString());
|
||||
}
|
||||
if (mCurrentProfile != null &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
|
||||
{ /* reflect roaming in the system's VPN routing right away
|
||||
* instead of waiting for the next establish(). Passing
|
||||
* null falls back to the system default, matching the
|
||||
* establish-time behavior; the call is a no-op for the
|
||||
* system if no tunnel is currently up. */
|
||||
setUnderlyingNetworks(current.length > 0 ? current : null);
|
||||
}
|
||||
/* let the state service react to the change (e.g. retry
|
||||
* immediately instead of waiting out a backoff that was sized
|
||||
* for the previous network) */
|
||||
synchronized (mServiceLock)
|
||||
{
|
||||
if (mService != null)
|
||||
{
|
||||
if (mService != null)
|
||||
{
|
||||
mService.onUnderlyingNetworksChanged();
|
||||
}
|
||||
mService.onUnderlyingNetworksChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1764,24 +1954,61 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
return mNetworks.isEmpty() ? null : mNetworks.toArray(new Network[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot for a builder that is about to establish(), remembered as
|
||||
* applied so the tracker doesn't immediately repeat it.
|
||||
*/
|
||||
Network[] snapshotForEstablish()
|
||||
{
|
||||
synchronized (mNetworks)
|
||||
{
|
||||
Network[] current = mNetworks.isEmpty() ? null : mNetworks.toArray(new Network[0]);
|
||||
mApplied = current;
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After establish(): a network change that raced with it was applied
|
||||
* via setUnderlyingNetworks() before the tunnel existed (a no-op), so
|
||||
* the new tunnel may carry a stale set - re-apply the current one.
|
||||
*/
|
||||
void afterEstablish(Network[] used)
|
||||
{
|
||||
Network[] current;
|
||||
synchronized (mNetworks)
|
||||
{
|
||||
current = mNetworks.toArray(new Network[0]);
|
||||
if (sameNetworks(current, used))
|
||||
{
|
||||
return;
|
||||
}
|
||||
mApplied = current;
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
|
||||
{
|
||||
DiagLog.i(TAG, "underlying networks changed during establish, re-applying");
|
||||
setUnderlyingNetworks(current.length > 0 ? current : null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the physical networks below the tunnel to the builder (API 22+).
|
||||
*/
|
||||
private void applyUnderlyingNetworks(VpnService.Builder builder)
|
||||
private Network[] applyUnderlyingNetworks(VpnService.Builder builder)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1)
|
||||
{
|
||||
Network[] networks = mUnderlyingTracker.getNetworks();
|
||||
Network[] networks = mUnderlyingTracker.snapshotForEstablish();
|
||||
if (networks != null)
|
||||
{
|
||||
builder.setUnderlyingNetworks(networks);
|
||||
}
|
||||
/* remember what was reported so the tracker doesn't immediately
|
||||
* repeat the call via setUnderlyingNetworks() */
|
||||
mUnderlyingTracker.mApplied = networks;
|
||||
return networks;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public class BuilderAdapter
|
||||
@@ -1920,10 +2147,11 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
try
|
||||
{
|
||||
mCache.applyData(mBuilder);
|
||||
applyUnderlyingNetworks(mBuilder);
|
||||
Network[] underlying = applyUnderlyingNetworks(mBuilder);
|
||||
fd = mBuilder.establish();
|
||||
if (fd != null)
|
||||
{
|
||||
mUnderlyingTracker.afterEstablish(underlying);
|
||||
closeBlocking();
|
||||
}
|
||||
}
|
||||
@@ -1980,8 +2208,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
{
|
||||
Builder builder = createBuilder(mProfile.getName());
|
||||
mEstablishedCache.applyData(builder, false);
|
||||
applyUnderlyingNetworks(builder);
|
||||
Network[] underlying = applyUnderlyingNetworks(builder);
|
||||
fd = builder.establish();
|
||||
if (fd != null)
|
||||
{
|
||||
mUnderlyingTracker.afterEstablish(underlying);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2098,7 +2330,6 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
* the OS routes here */
|
||||
private final boolean mUserspaceRouting;
|
||||
private final int mSplitTunneling;
|
||||
private final SelectedAppsHandling mAppHandling;
|
||||
private final SortedSet<String> mSelectedApps;
|
||||
private final List<InetAddress> mDnsServers = new ArrayList<>();
|
||||
/* addresses of the system's private DNS server (empty if none is
|
||||
@@ -2145,26 +2376,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
mExcludedSubnets = IPRangeSet.fromString(profile.getExcludedSubnets());
|
||||
Integer splitTunneling = profile.getSplitTunneling();
|
||||
mSplitTunneling = splitTunneling != null ? splitTunneling : 0;
|
||||
SelectedAppsHandling appHandling = profile.getSelectedAppsHandling();
|
||||
mSelectedApps = profile.getSelectedAppsSet();
|
||||
/* exclude our own app, otherwise the fetcher is blocked */
|
||||
switch (appHandling)
|
||||
{
|
||||
case SELECTED_APPS_DISABLE:
|
||||
appHandling = SelectedAppsHandling.SELECTED_APPS_EXCLUDE;
|
||||
mSelectedApps.clear();
|
||||
/* fall-through */
|
||||
case SELECTED_APPS_EXCLUDE:
|
||||
mSelectedApps.add(getPackageName());
|
||||
break;
|
||||
case SELECTED_APPS_ONLY:
|
||||
mSelectedApps.remove(getPackageName());
|
||||
break;
|
||||
}
|
||||
/* global routing rules: packages that must bypass the tunnel */
|
||||
Set<String> globalExcludes = RoutingManager.getInstance(CharonVpnService.this).computeExcludedPackages();
|
||||
appHandling = RoutingManager.applyGlobalExcludes(appHandling, mSelectedApps, globalExcludes);
|
||||
mAppHandling = appHandling;
|
||||
/* the only per-app routing is the global one (Routing tab): its
|
||||
* packages bypass the tunnel. The old per-profile handling is gone
|
||||
* (migrated by RoutingManager); our own app is always excluded,
|
||||
* otherwise the fetchers are blocked */
|
||||
mSelectedApps = new TreeSet<>(RoutingManager.getInstance(CharonVpnService.this).computeExcludedPackages());
|
||||
mSelectedApps.add(getPackageName());
|
||||
|
||||
/* RU geoip exclusions: a large presorted list. It is never passed
|
||||
* to VpnService's excludeRoute() - on some devices (One UI was
|
||||
@@ -2200,9 +2417,12 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
sAppliedGeoExclusionsV6 = mGeoExcludedV6;
|
||||
|
||||
|
||||
if (profile.getDnsServers() != null)
|
||||
/* 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 : profile.getDnsServers().split("\\s+"))
|
||||
for (String server : dnsServers.split("\\s+"))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -2512,39 +2732,16 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe
|
||||
{
|
||||
applyRoutesWithGeo(builder, rangesV6, mUserspaceRouting ? Collections.<IPRange>emptyList() : geoV6);
|
||||
}
|
||||
/* apply selected applications */
|
||||
if (mSelectedApps.size() > 0)
|
||||
/* apply the directly routed applications (never empty: our own package) */
|
||||
for (String app : mSelectedApps)
|
||||
{
|
||||
switch (mAppHandling)
|
||||
try
|
||||
{
|
||||
case SELECTED_APPS_EXCLUDE:
|
||||
for (String app : mSelectedApps)
|
||||
{
|
||||
try
|
||||
{
|
||||
builder.addDisallowedApplication(app);
|
||||
}
|
||||
catch (PackageManager.NameNotFoundException e)
|
||||
{
|
||||
// possible if not configured via GUI or app was uninstalled
|
||||
}
|
||||
}
|
||||
break;
|
||||
case SELECTED_APPS_ONLY:
|
||||
for (String app : mSelectedApps)
|
||||
{
|
||||
try
|
||||
{
|
||||
builder.addAllowedApplication(app);
|
||||
}
|
||||
catch (PackageManager.NameNotFoundException e)
|
||||
{
|
||||
// possible if not configured via GUI or app was uninstalled
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
builder.addDisallowedApplication(app);
|
||||
}
|
||||
catch (PackageManager.NameNotFoundException e)
|
||||
{
|
||||
// app was uninstalled since the list was built
|
||||
}
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && mProxyServer != null)
|
||||
|
||||
@@ -62,9 +62,10 @@ object DiagLog
|
||||
val base = dir ?: return
|
||||
try
|
||||
{
|
||||
val line = "${format.format(Date())} $tag: $message\n"
|
||||
synchronized(this)
|
||||
{
|
||||
/* SimpleDateFormat is not thread-safe: format under the lock */
|
||||
val line = "${format.format(Date())} $tag: $message\n"
|
||||
if (written > MAX_SIZE)
|
||||
{
|
||||
rotate(base)
|
||||
@@ -79,7 +80,7 @@ object DiagLog
|
||||
}
|
||||
w.write(line)
|
||||
w.flush()
|
||||
written += line.length + 1
|
||||
written += line.toByteArray(Charsets.UTF_8).size
|
||||
}
|
||||
}
|
||||
catch (_: Exception)
|
||||
|
||||
+18
-5
@@ -25,6 +25,7 @@ import android.net.Network;
|
||||
import android.net.NetworkRequest;
|
||||
import android.os.Build;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
|
||||
public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
@@ -33,7 +34,9 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
private volatile boolean mRegistered;
|
||||
private ConnectivityManager.NetworkCallback mCallback;
|
||||
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<>();
|
||||
|
||||
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
|
||||
* callback registration */
|
||||
mConnectedNetworks += 1;
|
||||
mConnectedNetworks.add(network);
|
||||
mEvents.addLast(true);
|
||||
NetworkManager.this.notifyAll();
|
||||
}
|
||||
@@ -63,8 +66,8 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
synchronized (NetworkManager.this)
|
||||
{
|
||||
/* in particular mobile connections are disconnected overlapping with WiFi */
|
||||
mConnectedNetworks -= 1;
|
||||
mEvents.addLast(mConnectedNetworks > 0);
|
||||
mConnectedNetworks.remove(network);
|
||||
mEvents.addLast(!mConnectedNetworks.isEmpty());
|
||||
NetworkManager.this.notifyAll();
|
||||
}
|
||||
}
|
||||
@@ -74,7 +77,11 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
|
||||
public void Register()
|
||||
{
|
||||
mEvents.clear();
|
||||
synchronized (this)
|
||||
{
|
||||
mEvents.clear();
|
||||
mConnectedNetworks.clear();
|
||||
}
|
||||
mRegistered = true;
|
||||
mEventNotifier = new Thread(this);
|
||||
mEventNotifier.start();
|
||||
@@ -174,6 +181,12 @@ public class NetworkManager extends BroadcastReceiver implements Runnable
|
||||
break;
|
||||
}
|
||||
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 */
|
||||
networkChanged(!connected);
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.VpnService
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.logic.events.Events
|
||||
|
||||
/**
|
||||
* "Pause VPN for N minutes": the tunnel is disconnected like a manual
|
||||
* disconnect (the watchdog stands down) and an alarm brings the same profile
|
||||
* back when the pause ends. Auto-connect does not start the tunnel while a
|
||||
* pause is running, and any connect request cancels the pause.
|
||||
*/
|
||||
object PauseManager
|
||||
{
|
||||
private const val TAG = "PauseManager"
|
||||
private const val PREF_UNTIL = "pref_pause_until"
|
||||
private const val PREF_PROFILE = "pref_pause_profile"
|
||||
private const val REQUEST_CODE = 45
|
||||
/** «Pause 15 min» from the VPN notification */
|
||||
const val ACTION_PAUSE = "org.strongswan.android.action.PAUSE_15"
|
||||
/* safety net of "until the network changes" */
|
||||
private const val NETWORK_PAUSE_MAX_MS = 12L * 60 * 60 * 1000
|
||||
|
||||
/* wall-clock end of the running pause, 0 if none; read by the Home screen */
|
||||
private val _until = MutableStateFlow(0L)
|
||||
val until: StateFlow<Long> = _until
|
||||
@Volatile private var loaded = false
|
||||
|
||||
private fun load(context: Context)
|
||||
{
|
||||
if (!loaded)
|
||||
{
|
||||
val until = PreferenceManager.getDefaultSharedPreferences(context).getLong(PREF_UNTIL, 0L)
|
||||
_until.value = if (until > System.currentTimeMillis()) until else 0L
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
/** The flow of the pause end, initialised from the stored state. */
|
||||
@JvmStatic
|
||||
fun until(context: Context): StateFlow<Long>
|
||||
{
|
||||
load(context.applicationContext)
|
||||
return until
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isPaused(context: Context): Boolean
|
||||
{
|
||||
load(context.applicationContext)
|
||||
return _until.value > System.currentTimeMillis()
|
||||
}
|
||||
|
||||
/** Disconnects [service] and schedules the reconnect of [profileUuid] in [minutes]. */
|
||||
@JvmStatic
|
||||
fun pause(context: Context, service: VpnStateService, profileUuid: String, minutes: Int)
|
||||
{
|
||||
pauseUntil(context, service, profileUuid, System.currentTimeMillis() + minutes * 60_000L)
|
||||
}
|
||||
|
||||
/** Disconnects [service] until the wall-clock time [until]. */
|
||||
@JvmStatic
|
||||
fun pauseUntil(context: Context, service: VpnStateService, profileUuid: String, until: Long)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
unwatchNetwork(ctx)
|
||||
service.disconnect()
|
||||
store(ctx, until, profileUuid)
|
||||
DiagLog.i(TAG, "VPN paused until $until")
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnects until the device moves to another network (e.g. leaves this
|
||||
* Wi-Fi). A [NETWORK_PAUSE_MAX_MS] alarm is the safety net in case the
|
||||
* process dies meanwhile and the change is never seen.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun pauseUntilNetworkChange(context: Context, service: VpnStateService, profileUuid: String)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
pauseUntil(ctx, service, profileUuid, System.currentTimeMillis() + NETWORK_PAUSE_MAX_MS)
|
||||
watchNetwork(ctx)
|
||||
}
|
||||
|
||||
/** Next 07:00 after [now], local time. */
|
||||
@JvmStatic
|
||||
fun nextMorning(now: Long): Long
|
||||
{
|
||||
val c = java.util.Calendar.getInstance()
|
||||
c.timeInMillis = now
|
||||
c.set(java.util.Calendar.HOUR_OF_DAY, 7)
|
||||
c.set(java.util.Calendar.MINUTE, 0)
|
||||
c.set(java.util.Calendar.SECOND, 0)
|
||||
c.set(java.util.Calendar.MILLISECOND, 0)
|
||||
if (c.timeInMillis <= now)
|
||||
{
|
||||
c.add(java.util.Calendar.DAY_OF_YEAR, 1)
|
||||
}
|
||||
return c.timeInMillis
|
||||
}
|
||||
|
||||
private var networkCallback: android.net.ConnectivityManager.NetworkCallback? = null
|
||||
|
||||
private fun watchNetwork(ctx: Context)
|
||||
{
|
||||
val cm = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager ?: return
|
||||
var first: android.net.Network? = null
|
||||
val callback = object : android.net.ConnectivityManager.NetworkCallback()
|
||||
{
|
||||
override fun onAvailable(network: android.net.Network)
|
||||
{
|
||||
/* right after the disconnect the default network may still be the
|
||||
* tunnel itself: only real uplinks count */
|
||||
val caps = try
|
||||
{
|
||||
cm.getNetworkCapabilities(network)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
}
|
||||
if (caps == null || caps.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN))
|
||||
{
|
||||
return
|
||||
}
|
||||
/* the first uplink reported is the current one; any other one is a change */
|
||||
if (first == null)
|
||||
{
|
||||
first = network
|
||||
}
|
||||
else if (network != first)
|
||||
{
|
||||
DiagLog.i(TAG, "network changed, ending the pause")
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { resumeNow(ctx) }
|
||||
}
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
cm.registerDefaultNetworkCallback(callback)
|
||||
networkCallback = callback
|
||||
}
|
||||
catch (_: RuntimeException)
|
||||
{ /* too many callbacks: the safety alarm still ends the pause */
|
||||
}
|
||||
}
|
||||
|
||||
private fun unwatchNetwork(ctx: Context)
|
||||
{
|
||||
val callback = networkCallback ?: return
|
||||
networkCallback = null
|
||||
try
|
||||
{
|
||||
(ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? android.net.ConnectivityManager)?.unregisterNetworkCallback(callback)
|
||||
}
|
||||
catch (_: IllegalArgumentException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves the end of the running pause by [minutes]. */
|
||||
@JvmStatic
|
||||
fun extend(context: Context, minutes: Int)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
val profile = prefs.getString(PREF_PROFILE, null) ?: return
|
||||
if (!isPaused(ctx))
|
||||
{
|
||||
return
|
||||
}
|
||||
store(ctx, _until.value + minutes * 60_000L, profile)
|
||||
}
|
||||
|
||||
/** Ends the pause right away and reconnects the paused profile. */
|
||||
@JvmStatic
|
||||
fun resumeNow(context: Context)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
val profile = PreferenceManager.getDefaultSharedPreferences(ctx).getString(PREF_PROFILE, null)
|
||||
cancel(ctx)
|
||||
if (profile != null)
|
||||
{
|
||||
reconnect(ctx, profile)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the pause without connecting. Called for every connect request,
|
||||
* so a manual connect (app, tile, widget, shortcut) wins over the pause.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun cancel(context: Context)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
load(ctx)
|
||||
if (_until.value == 0L)
|
||||
{
|
||||
return
|
||||
}
|
||||
_until.value = 0L
|
||||
unwatchNetwork(ctx)
|
||||
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
||||
.remove(PREF_UNTIL).remove(PREF_PROFILE).apply()
|
||||
alarmManager(ctx).cancel(alarmIntent(ctx))
|
||||
}
|
||||
|
||||
/**
|
||||
* After a reboot: alarms do not survive it, so a pause that was running would
|
||||
* never end. Re-arms the alarm, or ends the pause right away if its time is up.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun rearmAfterRestart(context: Context)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(ctx)
|
||||
val until = prefs.getLong(PREF_UNTIL, 0L)
|
||||
val profile = prefs.getString(PREF_PROFILE, null)
|
||||
if (profile == null || until == 0L)
|
||||
{
|
||||
return
|
||||
}
|
||||
if (until > System.currentTimeMillis())
|
||||
{
|
||||
DiagLog.i(TAG, "pause survives the restart, re-arming its alarm")
|
||||
store(ctx, until, profile)
|
||||
}
|
||||
else
|
||||
{
|
||||
onAlarm(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
private fun store(ctx: Context, until: Long, profileUuid: String)
|
||||
{
|
||||
PreferenceManager.getDefaultSharedPreferences(ctx).edit()
|
||||
.putLong(PREF_UNTIL, until).putString(PREF_PROFILE, profileUuid).apply()
|
||||
_until.value = until
|
||||
loaded = true
|
||||
val am = alarmManager(ctx)
|
||||
val pi = alarmIntent(ctx)
|
||||
try
|
||||
{
|
||||
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S || am.canScheduleExactAlarms()
|
||||
if (exact)
|
||||
{
|
||||
am.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
|
||||
}
|
||||
else
|
||||
{
|
||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
|
||||
}
|
||||
}
|
||||
catch (_: SecurityException)
|
||||
{ /* the exact-alarm grant was revoked in between; a late resume is fine */
|
||||
am.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, until, pi)
|
||||
}
|
||||
}
|
||||
|
||||
private fun alarmManager(ctx: Context) = ctx.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
private fun alarmIntent(ctx: Context): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
ctx, REQUEST_CODE, Intent(ctx, PauseReceiver::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** Called by [PauseReceiver] when the alarm fires. */
|
||||
internal fun onAlarm(context: Context)
|
||||
{
|
||||
val ctx = context.applicationContext
|
||||
val profile = PreferenceManager.getDefaultSharedPreferences(ctx).getString(PREF_PROFILE, null)
|
||||
cancel(ctx)
|
||||
if (profile == null)
|
||||
{
|
||||
return
|
||||
}
|
||||
DiagLog.i(TAG, "pause over, reconnecting")
|
||||
reconnect(ctx, profile)
|
||||
Events.pauseOver(ctx)
|
||||
}
|
||||
|
||||
private fun reconnect(ctx: Context, profileUuid: String)
|
||||
{
|
||||
val needsConsent = try
|
||||
{
|
||||
VpnService.prepare(ctx) != null
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
true
|
||||
}
|
||||
if (needsConsent)
|
||||
{ /* the VPN permission went to another app meanwhile; only the UI can ask again */
|
||||
DiagLog.i(TAG, "pause over, but the VPN permission is gone")
|
||||
return
|
||||
}
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
if (service != null)
|
||||
{ /* a live process: the regular connect path (keeps retries and state) */
|
||||
if (service.state == VpnStateService.State.DISABLED)
|
||||
{
|
||||
val extras = Bundle()
|
||||
extras.putString(VpnProfileDataSource.KEY_UUID, profileUuid)
|
||||
/* a password typed for the paused session goes along with it */
|
||||
service.profile?.takeIf { it.getUUID().toString() == profileUuid }?.password
|
||||
?.let { extras.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||
service.connect(extras, true)
|
||||
}
|
||||
return
|
||||
}
|
||||
/* the process is gone (the alarm started a fresh one): start the
|
||||
* service directly, like the watchdog does */
|
||||
val start = Intent(ctx, CharonVpnService::class.java)
|
||||
start.putExtra(VpnProfileDataSource.KEY_UUID, profileUuid)
|
||||
try
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
ctx.startForegroundService(start)
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.startService(start)
|
||||
}
|
||||
}
|
||||
catch (_: IllegalStateException)
|
||||
{
|
||||
FgsStartRetry.schedule(ctx, start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fires when a pause ends and brings the tunnel back. */
|
||||
class PauseReceiver : BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent?)
|
||||
{
|
||||
if (intent?.action == PauseManager.ACTION_PAUSE)
|
||||
{
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(context)
|
||||
val service = VpnStateService.getLiveInstance() ?: return
|
||||
val profile = service.profile ?: return
|
||||
PauseManager.pause(context, service, profile.getUUID().toString(), 15)
|
||||
return
|
||||
}
|
||||
PauseManager.onAlarm(context)
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic
|
||||
|
||||
import java.util.PriorityQueue
|
||||
|
||||
/**
|
||||
* The job queue behind [Scheduler]: charon's timers, ordered by due time, and the
|
||||
* bookkeeping of the single alarm that wakes the device for the earliest one.
|
||||
* Times are on any monotonic clock (the scheduler passes elapsedRealtime).
|
||||
*
|
||||
* Jobs due within [coalesceMs] of each other share one alarm: a job may run up to
|
||||
* [coalesceMs] early, never late. charon only schedules jobs longer than 3 s ahead,
|
||||
* so 2 s is harmless, and every alarm saved is one more slot in the Doze quota of
|
||||
* allow-while-idle alarms that the keepalive needs.
|
||||
*
|
||||
* Not thread-safe; the caller synchronises.
|
||||
*/
|
||||
class ScheduledJobQueue(private val coalesceMs: Long = COALESCE_MS)
|
||||
{
|
||||
private class Job(val id: String, val time: Long) : Comparable<Job>
|
||||
{
|
||||
override fun compareTo(other: Job): Int = time.compareTo(other.time)
|
||||
}
|
||||
|
||||
private val jobs = PriorityQueue<Job>()
|
||||
private var armed = NONE
|
||||
|
||||
/** Due time the alarm is currently set for, or [NONE]. */
|
||||
val armedAt: Long get() = armed
|
||||
|
||||
val size: Int get() = jobs.size
|
||||
|
||||
/**
|
||||
* Queues a job.
|
||||
*
|
||||
* @return true if the alarm has to be (re)armed for [nextAlarm]; false if the
|
||||
* armed alarm already covers the job
|
||||
*/
|
||||
fun add(id: String, time: Long): Boolean
|
||||
{
|
||||
jobs.add(Job(id, time))
|
||||
if (armed == NONE || time < armed - coalesceMs)
|
||||
{
|
||||
armed = time
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the jobs that are due at [now] (or within the coalescing window).
|
||||
* The alarm is considered fired: [nextAlarm] says whether to arm another one.
|
||||
*/
|
||||
fun popDue(now: Long): List<String>
|
||||
{
|
||||
val limit = now + coalesceMs
|
||||
val due = ArrayList<String>()
|
||||
while (true)
|
||||
{
|
||||
val head = jobs.peek() ?: break
|
||||
if (head.time > limit)
|
||||
{
|
||||
break
|
||||
}
|
||||
due.add(jobs.remove().id)
|
||||
}
|
||||
armed = jobs.peek()?.time ?: NONE
|
||||
return due
|
||||
}
|
||||
|
||||
/** Due time of the earliest queued job, or [NONE]; the time to arm after [popDue]. */
|
||||
fun nextAlarm(): Long = jobs.peek()?.time ?: NONE
|
||||
|
||||
fun clear()
|
||||
{
|
||||
jobs.clear()
|
||||
armed = NONE
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
const val NONE = -1L
|
||||
const val COALESCE_MS = 2000L
|
||||
}
|
||||
}
|
||||
+28
-51
@@ -24,10 +24,10 @@ import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.os.Build;
|
||||
import android.os.SystemClock;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.PriorityQueue;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
@@ -38,14 +38,18 @@ public class Scheduler extends BroadcastReceiver
|
||||
private final String TAG = Scheduler.class.getSimpleName();
|
||||
private final Context mContext;
|
||||
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")
|
||||
public Scheduler(Context context)
|
||||
{
|
||||
mContext = context;
|
||||
mManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
|
||||
mJobs = new PriorityQueue<>();
|
||||
mJobs = new ScheduledJobQueue(ScheduledJobQueue.COALESCE_MS);
|
||||
|
||||
IntentFilter filter = new IntentFilter();
|
||||
filter.addAction(EXECUTE_JOB);
|
||||
@@ -108,7 +112,7 @@ public class Scheduler extends BroadcastReceiver
|
||||
* alarms instead of crashing with a SecurityException (connectivity then
|
||||
* only degrades slightly while timers may be delayed in doze mode).
|
||||
*
|
||||
* @param time wakeup time based on RTC
|
||||
* @param time wakeup time based on elapsedRealtime
|
||||
*/
|
||||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
private void scheduleAlarm(long time)
|
||||
@@ -119,21 +123,26 @@ public class Scheduler extends BroadcastReceiver
|
||||
{
|
||||
exact = mManager.canScheduleExactAlarms();
|
||||
}
|
||||
if (!mExactLogged)
|
||||
{ /* once per run, for support reports */
|
||||
mExactLogged = true;
|
||||
DiagLog.i(TAG, "keepalive alarms are " + (exact ? "exact" : "inexact (exact alarms not allowed)"));
|
||||
}
|
||||
try
|
||||
{
|
||||
if (exact)
|
||||
{
|
||||
mManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
||||
mManager.setExactAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||
}
|
||||
else
|
||||
{
|
||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
||||
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||
}
|
||||
}
|
||||
catch (SecurityException e)
|
||||
{ /* permission might have been revoked, or OEM restrictions apply */
|
||||
Log.w(TAG, "Falling back to inexact alarm after SecurityException");
|
||||
mManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, time, pending);
|
||||
mManager.setAndAllowWhileIdle(AlarmManager.ELAPSED_REALTIME_WAKEUP, time, pending);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,12 +158,10 @@ public class Scheduler extends BroadcastReceiver
|
||||
{
|
||||
synchronized (this)
|
||||
{
|
||||
ScheduledJob job = new ScheduledJob(id, System.currentTimeMillis() + ms);
|
||||
mJobs.add(job);
|
||||
|
||||
if (job == mJobs.peek())
|
||||
{ /* update the alarm if the job has to be executed before all others */
|
||||
scheduleAlarm(job.Time);
|
||||
long time = SystemClock.elapsedRealtime() + ms;
|
||||
if (mJobs.add(id, time))
|
||||
{ /* the job is due before the armed alarm: move the alarm */
|
||||
scheduleAlarm(time);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,30 +170,21 @@ public class Scheduler extends BroadcastReceiver
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent)
|
||||
{
|
||||
ArrayList<ScheduledJob> jobs = new ArrayList<>();
|
||||
long now = System.currentTimeMillis();
|
||||
List<String> jobs;
|
||||
|
||||
synchronized (this)
|
||||
{
|
||||
ScheduledJob job = mJobs.peek();
|
||||
while (job != null)
|
||||
jobs = mJobs.popDue(SystemClock.elapsedRealtime());
|
||||
long next = mJobs.nextAlarm();
|
||||
if (next != ScheduledJobQueue.NONE)
|
||||
{
|
||||
if (job.Time > now)
|
||||
{
|
||||
break;
|
||||
}
|
||||
jobs.add(mJobs.remove());
|
||||
job = mJobs.peek();
|
||||
}
|
||||
if (job != null)
|
||||
{
|
||||
scheduleAlarm(job.Time);
|
||||
scheduleAlarm(next);
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
@@ -139,6 +139,11 @@ public class StrongSwanApplication extends Application
|
||||
|
||||
/* checks the update storage for a newer version of the app (if enabled and due) */
|
||||
UpdateManager.getInstance(this).start();
|
||||
/* daily re-read of the profiles that came from a link */
|
||||
org.strongswan.android.logic.profiles.ProfileUpdater.schedule(this);
|
||||
/* user scenarios of F_Automation */
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.start(this);
|
||||
org.strongswan.android.logic.stats.WeeklySummary.schedule(this);
|
||||
}
|
||||
|
||||
private void reloadManagedConfigurationAndNotifyListeners()
|
||||
|
||||
+73
-14
@@ -34,6 +34,8 @@ import org.strongswan.android.data.VpnProfile;
|
||||
import org.strongswan.android.ui.widget.ShxWidgetState;
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
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.RemediationInstruction;
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager;
|
||||
@@ -41,6 +43,7 @@ import org.strongswan.android.ui.VpnProfileControlActivity;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.Collections;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
@@ -199,6 +202,27 @@ public class VpnStateService extends Service
|
||||
mListeners.remove(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all registered listeners about a state change. Iterates over a
|
||||
* copy (a listener may unregister itself from its callback) and isolates
|
||||
* the listeners from each other: one failing listener must neither skip
|
||||
* the others nor crash the main thread.
|
||||
*/
|
||||
private void notifyStateListeners()
|
||||
{
|
||||
for (VpnStateListener listener : new ArrayList<>(mListeners))
|
||||
{
|
||||
try
|
||||
{
|
||||
listener.stateChanged();
|
||||
}
|
||||
catch (RuntimeException e)
|
||||
{
|
||||
DiagLog.i(TAG, "state listener failed: " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current VPN profile.
|
||||
*
|
||||
@@ -357,6 +381,7 @@ public class VpnStateService extends Service
|
||||
else
|
||||
{ /* the user explicitly disconnected, so the watchdog must not restore
|
||||
* the connection after this */
|
||||
Events.connectionStopped(this);
|
||||
SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
||||
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply();
|
||||
@@ -373,7 +398,15 @@ public class VpnStateService extends Service
|
||||
Context context = getApplicationContext();
|
||||
Intent intent = new Intent(context, CharonVpnService.class);
|
||||
intent.setAction(CharonVpnService.DISCONNECT_ACTION);
|
||||
context.startService(intent);
|
||||
try
|
||||
{
|
||||
context.startService(intent);
|
||||
}
|
||||
catch (IllegalStateException e)
|
||||
{ /* background start of a service that is not running (Android 8+):
|
||||
* without the service there is no tunnel to tear down */
|
||||
DiagLog.i(TAG, "disconnect: VPN service not running (" + e.getMessage() + ")");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -401,6 +434,8 @@ public class VpnStateService extends Service
|
||||
{
|
||||
/* we assume we have the necessary permission */
|
||||
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);
|
||||
if (profileInfo == null)
|
||||
{
|
||||
@@ -414,6 +449,10 @@ public class VpnStateService extends Service
|
||||
{
|
||||
uuid = mProfile.getUUID().toString();
|
||||
}
|
||||
if (fromScratch)
|
||||
{ /* a user's connect starts a new resilience attempt */
|
||||
Resilience.onConnect(uuid, profileInfo);
|
||||
}
|
||||
/* double-start guard: while a healthy connect attempt for the same
|
||||
* profile is in flight (or was just issued and the service has not
|
||||
* reached startConnection() yet) a second start intent only churns
|
||||
@@ -422,6 +461,7 @@ public class VpnStateService extends Service
|
||||
* here within 20 seconds. Deliberate retries during an error backoff
|
||||
* (mError set) and requests for another profile always pass. */
|
||||
if (uuid != null && uuid.equals(mLastConnectUuid) && mError == ErrorState.NO_ERROR
|
||||
&& !profileInfo.getBoolean(Resilience.KEY_INTERNAL, false)
|
||||
&& (mState == State.CONNECTING
|
||||
|| ((mState == State.DISABLED || mState == State.DISCONNECTING)
|
||||
&& SystemClock.uptimeMillis() - mLastConnectIssued < CONNECT_DEDUPE_MS)))
|
||||
@@ -589,10 +629,7 @@ public class VpnStateService extends Service
|
||||
{
|
||||
if (change.call())
|
||||
{ /* otherwise there is no need to notify the listeners */
|
||||
for (VpnStateListener listener : mListeners)
|
||||
{
|
||||
listener.stateChanged();
|
||||
}
|
||||
notifyStateListeners();
|
||||
/* the home screen widget mirrors the state */
|
||||
VpnProfile widgetProfile = mProfile;
|
||||
ShxWidgetState.publish(VpnStateService.this, mState, getErrorState(),
|
||||
@@ -602,7 +639,7 @@ public class VpnStateService extends Service
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
e.printStackTrace();
|
||||
DiagLog.i(TAG, "state change failed: " + e);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -658,6 +695,11 @@ public class VpnStateService extends Service
|
||||
if (VpnStateService.this.mState != state)
|
||||
{
|
||||
VpnStateService.this.mState = state;
|
||||
if (state == State.CONNECTED)
|
||||
{
|
||||
Events.connectionRestored(VpnStateService.this);
|
||||
Resilience.onConnected(VpnStateService.this);
|
||||
}
|
||||
if (state == State.CONNECTING)
|
||||
{ /* reentered while the daemon reestablishes the tunnel; bound
|
||||
* the total time so a lost alert can't strand us here */
|
||||
@@ -690,6 +732,14 @@ public class VpnStateService extends Service
|
||||
{
|
||||
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)
|
||||
{
|
||||
setRetryTimer(error);
|
||||
@@ -699,6 +749,17 @@ public class VpnStateService extends Service
|
||||
resetRetryTimer();
|
||||
}
|
||||
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 false;
|
||||
@@ -766,6 +827,9 @@ public class VpnStateService extends Service
|
||||
mRetryTimeout = mRetryIn = mTimeoutProvider.getTimeout(error);
|
||||
mRetryFrozen = false;
|
||||
DiagLog.i(TAG, "error " + error + ", retry in " + (mRetryTimeout / 1000) + "s");
|
||||
/* never run two countdown chains in parallel (double-speed countdown,
|
||||
* double notification updates and two reconnects at zero) */
|
||||
mHandler.removeMessages(RETRY_MSG);
|
||||
if (mRetryTimeout <= 0)
|
||||
{
|
||||
return;
|
||||
@@ -778,6 +842,7 @@ public class VpnStateService extends Service
|
||||
*/
|
||||
private void resetRetryTimer()
|
||||
{
|
||||
mHandler.removeMessages(RETRY_MSG);
|
||||
mRetryTimeout = 0;
|
||||
mRetryIn = 0;
|
||||
mRetryFrozen = false;
|
||||
@@ -858,10 +923,7 @@ public class VpnStateService extends Service
|
||||
/* calculate next interval before notifying listeners */
|
||||
long next = SystemClock.uptimeMillis() + retryStep(service.mRetryIn);
|
||||
|
||||
for (VpnStateListener listener : service.mListeners)
|
||||
{
|
||||
listener.stateChanged();
|
||||
}
|
||||
service.notifyStateListeners();
|
||||
sendMessageAtTime(obtainMessage(RETRY_MSG), next);
|
||||
}
|
||||
else
|
||||
@@ -872,10 +934,7 @@ public class VpnStateService extends Service
|
||||
* ticking every second */
|
||||
service.mRetryFrozen = true;
|
||||
service.mRetryIn = RETRY_SUPPRESS_MS;
|
||||
for (VpnStateListener listener : service.mListeners)
|
||||
{
|
||||
listener.stateChanged();
|
||||
}
|
||||
service.notifyStateListeners();
|
||||
sendMessageAtTime(obtainMessage(RETRY_MSG), SystemClock.uptimeMillis() + RETRY_SUPPRESS_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
+60
-12
@@ -30,6 +30,8 @@ import android.util.Log;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
import org.strongswan.android.data.VpnProfileSource;
|
||||
import org.strongswan.android.data.VpnProfile;
|
||||
import org.strongswan.android.logic.autoconnect.AutoConnectManager;
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager;
|
||||
import org.strongswan.android.utils.Constants;
|
||||
@@ -150,6 +152,19 @@ public class WatchdogReceiver extends BroadcastReceiver
|
||||
return Action.RESTART;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a check counts towards the steady-state backoff of a live
|
||||
* process: connected, retrying/connecting, or held back by the ISP
|
||||
* whitelist. In all of these the in-process machinery owns recovery and
|
||||
* the alarm can only guard against the process dying - firing every
|
||||
* minute (and waking Doze every 10 minutes) for hours of failed retries
|
||||
* or whitelist blocking only burns battery.
|
||||
*/
|
||||
static boolean isAliveSteady(Action action, boolean processAlive, boolean parked)
|
||||
{
|
||||
return action == Action.NONE && processAlive && !parked;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onReceive(Context context, Intent intent)
|
||||
{
|
||||
@@ -164,27 +179,24 @@ public class WatchdogReceiver extends BroadcastReceiver
|
||||
return;
|
||||
}
|
||||
reschedule(context);
|
||||
if (WhiteListManager.getInstance(context).shouldBlockReconnect())
|
||||
{ /* the ISP whitelist blocks the server; the in-process retry
|
||||
* machinery parks itself in that case, so should we */
|
||||
DiagLog.i(TAG, "whitelist blocks the server, not restarting");
|
||||
return;
|
||||
}
|
||||
/* the ISP whitelist blocks the server; the in-process retry
|
||||
* machinery parks itself in that case, so should we */
|
||||
boolean whitelistBlocks = WhiteListManager.getInstance(context).shouldBlockReconnect();
|
||||
VpnStateService service = VpnStateService.getLiveInstance();
|
||||
boolean processAlive = service != null;
|
||||
boolean serviceTracking = processAlive && service.getState() != VpnStateService.State.DISABLED;
|
||||
boolean wifiPresent = isWifiPresent(context);
|
||||
boolean autoProfileConfigured = AutoConnectManager.getInstance(context).getCurrentConfig().getProfileUuid() != null;
|
||||
Action action = decideAction(enabled, uuid != null, autoExpected, false, serviceTracking,
|
||||
Action action = decideAction(enabled, uuid != null, autoExpected, whitelistBlocks, serviceTracking,
|
||||
processAlive, wifiPresent, autoProfileConfigured);
|
||||
boolean connected = serviceTracking && service.getState() == VpnStateService.State.CONNECTED;
|
||||
boolean parked = uuid == null && autoExpected && processAlive && !serviceTracking && action == Action.NONE;
|
||||
boolean alive = isAliveSteady(action, processAlive, parked);
|
||||
if (parked)
|
||||
{
|
||||
sParkedChecks++;
|
||||
sConnectedChecks = 0;
|
||||
}
|
||||
else if (connected && action == Action.NONE)
|
||||
else if (alive)
|
||||
{
|
||||
sConnectedChecks++;
|
||||
sParkedChecks = 0;
|
||||
@@ -195,8 +207,8 @@ public class WatchdogReceiver extends BroadcastReceiver
|
||||
sConnectedChecks = 0;
|
||||
}
|
||||
boolean parkedSteady = parked && sParkedChecks >= PARKED_BACKOFF_AFTER;
|
||||
boolean connectedSteady = connected && sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
|
||||
String cadence = parkedSteady ? "parked" : connectedSteady ? "connected" : null;
|
||||
boolean connectedSteady = alive && sConnectedChecks >= CONNECTED_BACKOFF_AFTER;
|
||||
String cadence = parkedSteady ? "parked" : connectedSteady ? "alive" : null;
|
||||
if (cadence != null)
|
||||
{ /* steady state: log the transition once, then stay silent - a
|
||||
* per-fire line only floods diag.log and burns flash I/O */
|
||||
@@ -210,7 +222,7 @@ public class WatchdogReceiver extends BroadcastReceiver
|
||||
{
|
||||
DiagLog.i(TAG, "check: uuid=" + (uuid != null) + " autoExpected=" + autoExpected +
|
||||
" processAlive=" + processAlive + " tracking=" + serviceTracking +
|
||||
" wifi=" + wifiPresent + " -> " + action);
|
||||
" whitelist=" + whitelistBlocks + " wifi=" + wifiPresent + " -> " + action);
|
||||
}
|
||||
sLastCadence = cadence;
|
||||
switch (action)
|
||||
@@ -220,6 +232,7 @@ public class WatchdogReceiver extends BroadcastReceiver
|
||||
return;
|
||||
case RESTART:
|
||||
{
|
||||
boolean autoRestart = uuid == null;
|
||||
if (uuid == null)
|
||||
{
|
||||
uuid = resolveAutoProfile(context, prefs);
|
||||
@@ -229,12 +242,29 @@ public class WatchdogReceiver extends BroadcastReceiver
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* a profile that signs in with an unsaved password would only end
|
||||
* in «password missing» after every restore: drop the expectation
|
||||
* and say so instead */
|
||||
VpnProfile expected = loadProfile(context, uuid);
|
||||
if (expected != null && org.strongswan.android.logic.profiles.ProfileExtrasKt.needsPassword(expected))
|
||||
{
|
||||
DiagLog.i(TAG, "expected profile needs a password, not restoring");
|
||||
prefs.edit().remove(Constants.PREF_WATCHDOG_EXPECTED_PROFILE)
|
||||
.remove(Constants.PREF_WATCHDOG_AUTO_EXPECTED).apply();
|
||||
org.strongswan.android.logic.events.Events.restoreNeedsPassword(context, uuid,
|
||||
expected.getName() != null ? expected.getName() : expected.getGateway());
|
||||
return;
|
||||
}
|
||||
/* either the process died (the receiver runs in a fresh one) or the
|
||||
* service is idle while a connection is expected - restart it. In
|
||||
* the rare second case this may duplicate a pending in-process retry
|
||||
* once, which is harmless (retry flag set, same profile). */
|
||||
Log.i(TAG, "A connection was expected but the VPN process is gone, restarting it");
|
||||
DiagLog.i(TAG, "restarting the VPN process");
|
||||
if (autoRestart)
|
||||
{ /* this process does not remember the session was auto-started */
|
||||
AutoConnectManager.getInstance(context).markAutoSessionRestored();
|
||||
}
|
||||
Intent start = new Intent(context, CharonVpnService.class);
|
||||
start.putExtra(VpnProfileDataSource.KEY_UUID, uuid);
|
||||
start.putExtra(CharonVpnService.KEY_IS_RETRY, true);
|
||||
@@ -259,6 +289,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
|
||||
* auto-connect profile, falling back to the most recently used one.
|
||||
|
||||
+79
-11
@@ -36,6 +36,8 @@ import org.json.JSONObject
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.PauseManager
|
||||
import org.strongswan.android.logic.events.Events
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.WatchdogReceiver
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||
@@ -58,6 +60,10 @@ class AutoConnectManager private constructor(
|
||||
val enabled: Boolean = false,
|
||||
val profileUuid: String? = null,
|
||||
val trustedSsids: Set<String> = emptySet(),
|
||||
/** F_Automation «In a trusted Wi-Fi → turn the VPN off» */
|
||||
val parkOnTrusted: Boolean = true,
|
||||
/** F_Automation «Mobile network or a foreign Wi-Fi → turn the VPN on» */
|
||||
val connectOnUntrusted: Boolean = true,
|
||||
)
|
||||
|
||||
enum class NetworkKind
|
||||
@@ -85,6 +91,8 @@ class AutoConnectManager private constructor(
|
||||
private var currentNetwork: NetworkKind? = null
|
||||
/* marks the session this manager started itself */
|
||||
private val autoSession = AtomicBoolean(false)
|
||||
/* an auto session was restored by the watchdog and its tunnel is not up yet */
|
||||
@Volatile private var awaitingRestoredTunnel = false
|
||||
/* elapsedRealtime of the last park caused by a trusted Wi-Fi (flap guard) */
|
||||
private var lastParkAt = 0L
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
config = loadConfig()
|
||||
if (!config.enabled)
|
||||
if (!config.enabled && !scenariosNeedNetwork())
|
||||
{
|
||||
return
|
||||
}
|
||||
@@ -145,6 +171,9 @@ class AutoConnectManager private constructor(
|
||||
return
|
||||
}
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
/* the runnable that clears this flag was just removed with the
|
||||
* callbacks: clear it here or every later connect is swallowed */
|
||||
pendingEvaluate.set(false)
|
||||
service?.unregisterListener(this)
|
||||
try
|
||||
{
|
||||
@@ -177,8 +206,8 @@ class AutoConnectManager private constructor(
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
autoSession.set(false)
|
||||
scenariosChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +234,8 @@ class AutoConnectManager private constructor(
|
||||
}
|
||||
else
|
||||
{
|
||||
stop()
|
||||
autoSession.set(false)
|
||||
scenariosChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,25 +250,51 @@ class AutoConnectManager private constructor(
|
||||
}
|
||||
currentNetwork = kind
|
||||
evaluate()
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context)
|
||||
}
|
||||
|
||||
override fun stateChanged()
|
||||
{
|
||||
val state = service?.state ?: return
|
||||
if (state == VpnStateService.State.DISABLED)
|
||||
if (state != VpnStateService.State.DISABLED)
|
||||
{
|
||||
awaitingRestoredTunnel = false
|
||||
}
|
||||
else if (!awaitingRestoredTunnel)
|
||||
{
|
||||
autoSession.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The watchdog brought an auto session back in a fresh process, whose memory
|
||||
* knows nothing of it. Mark it as auto-started again, so it parks on a trusted
|
||||
* Wi-Fi instead of running all night; the DISABLED state of the not yet
|
||||
* started service must not clear the mark.
|
||||
*/
|
||||
fun markAutoSessionRestored()
|
||||
{
|
||||
awaitingRestoredTunnel = true
|
||||
autoSession.set(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision function, unit-testable.
|
||||
*/
|
||||
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action =
|
||||
evaluateDecision(
|
||||
private fun evaluate(config: Config, network: NetworkKind?, vpnActive: Boolean, autoSession: Boolean): Action
|
||||
{
|
||||
val action = evaluateDecision(
|
||||
config, network, vpnActive, autoSession,
|
||||
whiteListBlocksReconnect = WhiteListManager.getInstance(context).shouldBlockReconnect(),
|
||||
)
|
||||
/* the user paused the VPN: the pause alarm brings it back, not a network change */
|
||||
if (action == Action.CONNECT && PauseManager.isPaused(context))
|
||||
{
|
||||
return Action.IGNORE
|
||||
}
|
||||
/* a user scenario above the network rules is in force */
|
||||
return if (action != Action.IGNORE && org.strongswan.android.logic.scenarios.ScenarioEngine.inControl(context)) Action.IGNORE else action
|
||||
}
|
||||
|
||||
/** Re-runs the connect decision, e.g. when the white-list gate clears. */
|
||||
fun reevaluate()
|
||||
@@ -249,8 +304,8 @@ class AutoConnectManager private constructor(
|
||||
|
||||
private fun evaluate()
|
||||
{
|
||||
if (!running.get())
|
||||
{
|
||||
if (!running.get() || !config.enabled)
|
||||
{ /* running only for the scenarios: they decide, not the network rules */
|
||||
return
|
||||
}
|
||||
val svc = service ?: return
|
||||
@@ -271,6 +326,7 @@ class AutoConnectManager private constructor(
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, true).apply()
|
||||
svc.disconnect(true)
|
||||
Events.autoConnect(context, parked = true)
|
||||
}
|
||||
Action.IGNORE ->
|
||||
{
|
||||
@@ -329,6 +385,12 @@ class AutoConnectManager private constructor(
|
||||
Log.e(TAG, "VpnService.prepare failed", e)
|
||||
true
|
||||
}
|
||||
val livePassword = svc.profile?.takeIf { it.getUUID().toString() == uuid }?.password
|
||||
if (!needsConsent && livePassword == null && org.strongswan.android.logic.profiles.blockedByPassword(context, uuid))
|
||||
{ /* nobody is there to type the password */
|
||||
autoSession.set(false)
|
||||
return
|
||||
}
|
||||
if (!needsConsent)
|
||||
{
|
||||
DiagLog.i(TAG, "auto session starting, profile=$uuid")
|
||||
@@ -338,9 +400,11 @@ class AutoConnectManager private constructor(
|
||||
.putBoolean(Constants.PREF_WATCHDOG_AUTO_EXPECTED, false).apply()
|
||||
val extras = Bundle()
|
||||
extras.putString(VpnProfileDataSource.KEY_UUID, uuid)
|
||||
livePassword?.let { extras.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||
/* automatic start: keep the previous session's charon log */
|
||||
extras.putBoolean(CharonVpnService.KEY_PRESERVE_LOG, true)
|
||||
svc.connect(extras, true)
|
||||
Events.autoConnect(context, parked = false)
|
||||
return
|
||||
}
|
||||
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
||||
@@ -372,6 +436,8 @@ class AutoConnectManager private constructor(
|
||||
arr.optString(i)?.takeIf { it.isNotBlank() }
|
||||
}.toSet()
|
||||
} ?: emptySet(),
|
||||
parkOnTrusted = obj.optBoolean("parkOnTrusted", true),
|
||||
connectOnUntrusted = obj.optBoolean("connectOnUntrusted", true),
|
||||
)
|
||||
}
|
||||
catch (_: JSONException)
|
||||
@@ -386,6 +452,8 @@ class AutoConnectManager private constructor(
|
||||
obj.put("enabled", config.enabled)
|
||||
obj.put("profileUuid", config.profileUuid ?: JSONObject.NULL)
|
||||
obj.put("trustedSsids", JSONArray(config.trustedSsids.sorted()))
|
||||
obj.put("parkOnTrusted", config.parkOnTrusted)
|
||||
obj.put("connectOnUntrusted", config.connectOnUntrusted)
|
||||
prefs.edit().putString(KEY_CONFIG, obj.toString()).apply()
|
||||
}
|
||||
|
||||
@@ -440,13 +508,13 @@ class AutoConnectManager private constructor(
|
||||
{
|
||||
null -> Action.IGNORE
|
||||
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
|
||||
* existing tunnel (do not treat unknown as untrusted) and still
|
||||
* 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 ->
|
||||
if (!vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
||||
if (config.connectOnUntrusted && !vpnActive && !whiteListBlocksReconnect) Action.CONNECT else Action.IGNORE
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -94,8 +94,12 @@ object ConnectivityProbe
|
||||
* throw (e.g. EPERM when the caller is disallowed from that
|
||||
* network), so it must stay inside the try */
|
||||
val socket = if (network != null) network.socketFactory.createSocket() else Socket()
|
||||
socket.tcpNoDelay = true
|
||||
socket.connect(InetSocketAddress(host, port), timeoutMs)
|
||||
/* close right away: a leaked socket holds an fd until GC and
|
||||
* the peer's later FIN/RST wakes the radio a second time */
|
||||
socket.use {
|
||||
it.tcpNoDelay = true
|
||||
it.connect(InetSocketAddress(host, port), timeoutMs)
|
||||
}
|
||||
ProbeResult(target = target, ok = true, rttMs = (System.nanoTime() - started) / 1_000_000)
|
||||
}
|
||||
catch (_: Exception)
|
||||
|
||||
+143
-23
@@ -25,9 +25,12 @@ import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -41,7 +44,9 @@ import kotlinx.coroutines.sync.Mutex
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.Events
|
||||
|
||||
/** What to do when a check fails. */
|
||||
enum class HealthAction
|
||||
@@ -116,6 +121,9 @@ class HealthCheckManager private constructor(
|
||||
private val cycleMutex = Mutex()
|
||||
private var config = HealthConfig()
|
||||
private var running = false
|
||||
/* last state seen by onStateChanged(): VpnStateService also notifies on
|
||||
* error/IMC/rebind changes, which must not restart a pending cycle */
|
||||
private var lastState: VpnStateService.State? = null
|
||||
private var service: VpnStateService? = null
|
||||
private var cycleJob: Job? = null
|
||||
private var confirmJob: Job? = null
|
||||
@@ -124,6 +132,9 @@ class HealthCheckManager private constructor(
|
||||
private var tunnelAttempts = 0
|
||||
private var fastPollShifts = 0
|
||||
private var powerState = PowerState()
|
||||
/* tunnel inbound bytes at the previous cycle (-1: unknown) and when the last real probe ran */
|
||||
private var lastCycleInbound = -1L
|
||||
private var lastProbeAt = 0L
|
||||
|
||||
/* tracks screen/battery state so probing can back off while the device sleeps */
|
||||
private val powerReceiver = object : BroadcastReceiver()
|
||||
@@ -230,6 +241,7 @@ class HealthCheckManager private constructor(
|
||||
{
|
||||
}
|
||||
service = null
|
||||
lastState = null
|
||||
resetCounters()
|
||||
}
|
||||
|
||||
@@ -239,6 +251,8 @@ class HealthCheckManager private constructor(
|
||||
if (config.enabled)
|
||||
{
|
||||
start()
|
||||
/* re-arm with the new settings even if the state is unchanged */
|
||||
lastState = null
|
||||
onStateChanged()
|
||||
}
|
||||
else
|
||||
@@ -265,6 +279,8 @@ class HealthCheckManager private constructor(
|
||||
if (updated.enabled)
|
||||
{
|
||||
start()
|
||||
/* re-arm with the new settings even if the state is unchanged */
|
||||
lastState = null
|
||||
onStateChanged()
|
||||
}
|
||||
else
|
||||
@@ -284,12 +300,17 @@ class HealthCheckManager private constructor(
|
||||
{
|
||||
return
|
||||
}
|
||||
when (service?.state)
|
||||
val state = service?.state
|
||||
val previous = lastState
|
||||
lastState = state
|
||||
when (state)
|
||||
{
|
||||
VpnStateService.State.CONNECTED ->
|
||||
{
|
||||
/* let the fresh tunnel settle before the first probe */
|
||||
scheduleCycle(INITIAL_DELAY_MS)
|
||||
if (previous != VpnStateService.State.CONNECTED)
|
||||
{ /* let the fresh tunnel settle before the first probe */
|
||||
scheduleCycle(INITIAL_DELAY_MS)
|
||||
}
|
||||
}
|
||||
VpnStateService.State.DISABLED ->
|
||||
{
|
||||
@@ -344,30 +365,52 @@ class HealthCheckManager private constructor(
|
||||
return
|
||||
}
|
||||
val gateway = svc.profile?.gateway
|
||||
val targets = config.targets
|
||||
val checkServer = config.serverCheckMode != ServerCheckMode.OFF
|
||||
val adapted = adaptToPowerState(config, powerState)
|
||||
val nowMs = SystemClock.elapsedRealtime()
|
||||
/* passive liveness: payload that came back through the tunnel since the
|
||||
* previous cycle proves it works without waking the radio for probes */
|
||||
val inbound = tunnelInboundBytes()
|
||||
val inboundDelta = if (lastCycleInbound >= 0 && inbound >= 0) inbound - lastCycleInbound else -1L
|
||||
lastCycleInbound = inbound
|
||||
if (trafficProvesAlive(
|
||||
adapted, serverAttempts + tunnelAttempts, fastPollShifts, pendingDecision != null,
|
||||
inboundDelta, nowMs - lastProbeAt,
|
||||
))
|
||||
{
|
||||
scheduleCycle(effectiveIntervalSec(adapted) * 1000L)
|
||||
return
|
||||
}
|
||||
lastProbeAt = nowMs
|
||||
/* screen off: one target is enough (the confirmation probe uses it too) */
|
||||
val targets = if (adapted.adaptiveMode && !powerState.screenOn && config.targets.size > 1)
|
||||
{
|
||||
setOf(config.targets.first())
|
||||
}
|
||||
else
|
||||
{
|
||||
config.targets
|
||||
}
|
||||
val mode = config.serverCheckMode
|
||||
val timeout = config.timeoutMs
|
||||
|
||||
val results = coroutineScope {
|
||||
val serverDeferred = if (checkServer)
|
||||
{
|
||||
async { ConnectivityProbe.probe(ProbeTarget.SERVER, timeout, context, gateway) }
|
||||
}
|
||||
else
|
||||
{
|
||||
null
|
||||
}
|
||||
val external = targets.map { target ->
|
||||
val externalResults = coroutineScope {
|
||||
targets.map { target ->
|
||||
async { ConnectivityProbe.probe(target, timeout, context, gateway) }
|
||||
}
|
||||
(listOfNotNull(serverDeferred) + external).map { it.await() }
|
||||
}.map { it.await() }
|
||||
}
|
||||
|
||||
val serverResult = results.firstOrNull { it.target == ProbeTarget.SERVER }
|
||||
val externalResults = results.filter { it.target != ProbeTarget.SERVER }
|
||||
val externalOk = externalResults.count { it.ok }
|
||||
val externalTotal = externalResults.size
|
||||
val serverDown = serverResult?.let { !it.ok }
|
||||
/* the direct server ping (a fork of /system/bin/ping plus a DNS lookup
|
||||
* outside the tunnel) only runs when its result can matter: always in
|
||||
* ALWAYS mode, in WITH_TUNNEL mode only once the tunnel looks dead */
|
||||
val serverDown = if (shouldProbeServer(mode, externalOk, externalTotal))
|
||||
{
|
||||
!ConnectivityProbe.probe(ProbeTarget.SERVER, timeout, context, gateway).ok
|
||||
}
|
||||
else
|
||||
{
|
||||
null
|
||||
}
|
||||
|
||||
val decision = decide(config, serverDown, externalOk, externalTotal, serverAttempts, tunnelAttempts)
|
||||
val healthy = serverDown != true && (externalTotal == 0 || externalOk == externalTotal)
|
||||
@@ -463,6 +506,7 @@ class HealthCheckManager private constructor(
|
||||
serverAttempts++
|
||||
Log.i(TAG, "Reconnecting, server unreachable (attempt $serverAttempts/${config.serverMaxAttempts})")
|
||||
service?.reconnectPreservingLog()
|
||||
Events.healthReconnect(context, serverDown = true)
|
||||
}
|
||||
}
|
||||
Decision.RECONNECT_TUNNEL ->
|
||||
@@ -472,6 +516,7 @@ class HealthCheckManager private constructor(
|
||||
tunnelAttempts++
|
||||
Log.i(TAG, "Reconnecting, tunnel dead (attempt $tunnelAttempts/${config.tunnelDeadMaxAttempts})")
|
||||
service?.reconnectPreservingLog()
|
||||
Events.healthReconnect(context, serverDown = false)
|
||||
}
|
||||
}
|
||||
Decision.IGNORE ->
|
||||
@@ -523,9 +568,10 @@ class HealthCheckManager private constructor(
|
||||
powerState = PowerState(
|
||||
screenOn = pm?.isInteractive ?: true,
|
||||
powerSaveMode = pm?.isPowerSaveMode ?: false,
|
||||
batteryLow = false,
|
||||
batteryLow = isBatteryLowNow(),
|
||||
)
|
||||
/* BATTERY_LOW/OKAY are sticky, so registration immediately reflects the current state */
|
||||
/* BATTERY_LOW/OKAY are not sticky: the initial state comes from the
|
||||
* sticky ACTION_BATTERY_CHANGED, later changes from the broadcasts */
|
||||
val filter = IntentFilter().apply {
|
||||
addAction(Intent.ACTION_SCREEN_ON)
|
||||
addAction(Intent.ACTION_SCREEN_OFF)
|
||||
@@ -543,6 +589,29 @@ class HealthCheckManager private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isBatteryLowNow(): Boolean
|
||||
{
|
||||
return try
|
||||
{
|
||||
val sticky = context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))
|
||||
?: return false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
{
|
||||
sticky.getBooleanExtra(BatteryManager.EXTRA_BATTERY_LOW, false)
|
||||
}
|
||||
else
|
||||
{ /* the platform's default low-battery threshold */
|
||||
val level = sticky.getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = sticky.getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||
level >= 0 && scale > 0 && level * 100 / scale <= 15
|
||||
}
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterPowerReceiver()
|
||||
{
|
||||
try
|
||||
@@ -569,6 +638,21 @@ class HealthCheckManager private constructor(
|
||||
tunnelAttempts = 0
|
||||
fastPollShifts = 0
|
||||
pendingDecision = null
|
||||
lastCycleInbound = -1L
|
||||
lastProbeAt = 0L
|
||||
}
|
||||
|
||||
/** Inbound byte counter of the tunnel's CHILD_SAs, -1 when unavailable. */
|
||||
private fun tunnelInboundBytes(): Long
|
||||
{
|
||||
return try
|
||||
{
|
||||
CharonVpnService.queryTraffic()?.getOrNull(0) ?: -1L
|
||||
}
|
||||
catch (_: Throwable)
|
||||
{
|
||||
-1L
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerVpnCallback()
|
||||
@@ -665,6 +749,32 @@ class HealthCheckManager private constructor(
|
||||
private const val FAST_POLL_MAX_SHIFTS = 3
|
||||
private const val AGGRESSIVE_INTERVAL_SEC = 5
|
||||
|
||||
/** Inbound tunnel payload since the last cycle that counts as proof of life. */
|
||||
internal const val PASSIVE_MIN_INBOUND_BYTES = 4096L
|
||||
|
||||
/** Even with traffic flowing a real probe runs at least this often. */
|
||||
internal const val PASSIVE_MAX_PROBE_AGE_MS = 5 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Pure function: whether the cycle may skip its probes because the
|
||||
* tunnel carried inbound data since the previous cycle. Only in
|
||||
* adaptive mode, never in aggressive mode, never while a failure is
|
||||
* being observed, and never for longer than [PASSIVE_MAX_PROBE_AGE_MS].
|
||||
* Outbound-only traffic proves nothing, so it does not count.
|
||||
*/
|
||||
fun trafficProvesAlive(
|
||||
adapted: HealthConfig,
|
||||
failedAttempts: Int,
|
||||
fastPollShifts: Int,
|
||||
decisionPending: Boolean,
|
||||
inboundDelta: Long,
|
||||
sinceLastProbeMs: Long,
|
||||
): Boolean =
|
||||
adapted.adaptiveMode && !adapted.aggressiveMode &&
|
||||
failedAttempts == 0 && fastPollShifts == 0 && !decisionPending &&
|
||||
inboundDelta >= PASSIVE_MIN_INBOUND_BYTES &&
|
||||
sinceLastProbeMs in 0 until PASSIVE_MAX_PROBE_AGE_MS
|
||||
|
||||
/** Minimum probe interval in adaptive mode while the screen is off. */
|
||||
internal const val SCREEN_OFF_MIN_INTERVAL_SEC = 60
|
||||
|
||||
@@ -710,6 +820,16 @@ class HealthCheckManager private constructor(
|
||||
* @param serverAttempts reconnect decisions already taken for the server rule
|
||||
* @param tunnelAttempts reconnect decisions already taken for the tunnel rules
|
||||
*/
|
||||
/** Whether the server probe can influence [decide] this cycle. */
|
||||
@JvmStatic
|
||||
fun shouldProbeServer(mode: ServerCheckMode, externalOk: Int, externalTotal: Int): Boolean =
|
||||
when (mode)
|
||||
{
|
||||
ServerCheckMode.OFF -> false
|
||||
ServerCheckMode.ALWAYS -> true
|
||||
ServerCheckMode.WITH_TUNNEL -> externalTotal > 0 && externalOk == 0
|
||||
}
|
||||
|
||||
fun decide(
|
||||
config: HealthConfig,
|
||||
serverDown: Boolean?,
|
||||
|
||||
+48
-4
@@ -44,6 +44,8 @@ import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.Events
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** Whether the ISP currently blocks direct access to foreign hosts. */
|
||||
enum class WhiteListStatus
|
||||
@@ -177,6 +179,9 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
|
||||
private val _state = MutableStateFlow(WhiteListState())
|
||||
val state: StateFlow<WhiteListState> = _state
|
||||
|
||||
/* last classification-relevant capabilities per network (see capabilityKey) */
|
||||
private val lastCapabilities = ConcurrentHashMap<Network, String>()
|
||||
|
||||
/* event trigger: the underlying network changed, the ISP may classify differently now */
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback()
|
||||
{
|
||||
@@ -186,12 +191,19 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities)
|
||||
{
|
||||
onNetworkEvent()
|
||||
{ /* fired on every signal-strength change too: only a change that
|
||||
* can alter the ISP's classification counts as an event (it also
|
||||
* resets the recheck backoff) */
|
||||
val key = capabilityKey(capabilities)
|
||||
if (lastCapabilities.put(network, key) != key)
|
||||
{
|
||||
onNetworkEvent()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network)
|
||||
{
|
||||
lastCapabilities.remove(network)
|
||||
onNetworkEvent()
|
||||
}
|
||||
}
|
||||
@@ -238,6 +250,7 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
|
||||
recheckJob?.cancel()
|
||||
recheckJob = null
|
||||
unregisterNetworkCallback()
|
||||
lastCapabilities.clear()
|
||||
service?.unregisterListener(this)
|
||||
try
|
||||
{
|
||||
@@ -364,7 +377,10 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
|
||||
return
|
||||
}
|
||||
backoffAttempt = 0
|
||||
scope.launch { runCheck(force = true) }
|
||||
/* a flapping tunnel (CONNECTING -> DISCONNECTING -> DISABLED -> ...)
|
||||
* must not trigger a fresh direct probe round on every transition */
|
||||
val force = SystemClock.elapsedRealtime() - lastCheckElapsed >= TRANSITION_MIN_GAP_MS
|
||||
scope.launch { runCheck(force = force) }
|
||||
}
|
||||
|
||||
private fun onNetworkEvent()
|
||||
@@ -423,6 +439,16 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
|
||||
val before = machine
|
||||
machine = before.advance(candidate)
|
||||
publish(round)
|
||||
if (before.status != WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.ACTIVE)
|
||||
{
|
||||
Events.whitelist(context, true)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, true); org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context) }
|
||||
}
|
||||
else if (before.status == WhiteListStatus.ACTIVE && machine.status == WhiteListStatus.INACTIVE)
|
||||
{
|
||||
Events.whitelist(context, false)
|
||||
android.os.Handler(android.os.Looper.getMainLooper()).post { org.strongswan.android.logic.profiles.Resilience.onWhitelist(context, false); org.strongswan.android.logic.scenarios.ScenarioEngine.evaluate(context) }
|
||||
}
|
||||
if (machine.status == WhiteListStatus.ACTIVE)
|
||||
{ /* keep watching for the whitelist to clear, at a low rate */
|
||||
backoffAttempt++
|
||||
@@ -453,7 +479,9 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
|
||||
recheckJob?.cancel()
|
||||
recheckJob = scope.launch {
|
||||
delay(delayMs)
|
||||
runCheck(force = false)
|
||||
/* forced: the first backoff steps are shorter than FRESH_MS, an
|
||||
* unforced recheck returned early and ended the chain */
|
||||
runCheck(force = true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -598,6 +626,22 @@ class WhiteListManager private constructor(private val context: Context) : VpnSt
|
||||
/** Cached results younger than this are not re-probed. */
|
||||
private const val FRESH_MS = 60_000L
|
||||
|
||||
/** State transitions force a new round only if the last one is at least this old. */
|
||||
private const val TRANSITION_MIN_GAP_MS = 15_000L
|
||||
|
||||
/** The parts of the capabilities that matter for the classification. */
|
||||
@JvmStatic
|
||||
internal fun capabilityKey(caps: NetworkCapabilities): String
|
||||
{
|
||||
val transports = intArrayOf(NetworkCapabilities.TRANSPORT_WIFI, NetworkCapabilities.TRANSPORT_CELLULAR,
|
||||
NetworkCapabilities.TRANSPORT_ETHERNET, NetworkCapabilities.TRANSPORT_VPN)
|
||||
.filter { caps.hasTransport(it) }
|
||||
val capabilities = intArrayOf(NetworkCapabilities.NET_CAPABILITY_INTERNET,
|
||||
NetworkCapabilities.NET_CAPABILITY_VALIDATED, NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
.filter { caps.hasCapability(it) }
|
||||
return "$transports/$capabilities"
|
||||
}
|
||||
|
||||
/**
|
||||
* A cached ACTIVE status stops suppressing reconnects once no recheck
|
||||
* has confirmed it for this long (fail open). Kept above the backoff
|
||||
|
||||
+438
@@ -0,0 +1,438 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.events
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ProcessLifecycleOwner
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.InboxActivity
|
||||
import java.io.File
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/** How an event looks and whether it waits for the user. */
|
||||
enum class EventKind
|
||||
{
|
||||
/** something is wrong right now (pushed on the "problems" channel) */
|
||||
WARN,
|
||||
/** the app suggests something and waits for a decision */
|
||||
ASK,
|
||||
/** the app did something by itself */
|
||||
INFO,
|
||||
/** something got better */
|
||||
OK,
|
||||
}
|
||||
|
||||
enum class EventCategory
|
||||
{
|
||||
CONNECTION,
|
||||
ROUTING,
|
||||
APP,
|
||||
}
|
||||
|
||||
/** What the event's button does; the Inbox maps these to screens. */
|
||||
enum class EventAction
|
||||
{
|
||||
NONE,
|
||||
OPEN_HOME,
|
||||
OPEN_ROUTING,
|
||||
OPEN_ROUTE_CHECK,
|
||||
OPEN_LOG,
|
||||
INSTALL_UPDATE,
|
||||
/** let the app in [ShxEvent.target] bypass the VPN */
|
||||
SET_APP_DIRECT,
|
||||
/** show the link update of the profile [ShxEvent.target] (F_ProfileUpdate) */
|
||||
PROFILE_UPDATE,
|
||||
/** open the server page of the profile [ShxEvent.target] */
|
||||
OPEN_SERVER,
|
||||
}
|
||||
|
||||
data class ShxEvent(
|
||||
val id: Long,
|
||||
val at: Long,
|
||||
val kind: EventKind,
|
||||
val category: EventCategory,
|
||||
val title: String,
|
||||
val text: String,
|
||||
val action: EventAction = EventAction.NONE,
|
||||
/** groups repeats of the same event; null for one-offs */
|
||||
val key: String? = null,
|
||||
/** how many times it happened within the merge window */
|
||||
val count: Int = 1,
|
||||
val read: Boolean = false,
|
||||
/** the problem went away by itself; shown dimmed, no longer "waiting" */
|
||||
val resolved: Boolean = false,
|
||||
/** what the action applies to, e.g. a package name */
|
||||
val target: String? = null,
|
||||
)
|
||||
{
|
||||
/** still needs the user's attention */
|
||||
val waiting: Boolean
|
||||
get() = !resolved && (kind == EventKind.WARN || kind == EventKind.ASK)
|
||||
}
|
||||
|
||||
/**
|
||||
* What the app did and noticed by itself, kept for [RETENTION_MS] on the
|
||||
* device. Problems are additionally pushed while the app is in the
|
||||
* background, so the user learns about them without opening it.
|
||||
*/
|
||||
class EventLog private constructor(private val context: Context)
|
||||
{
|
||||
private val file = File(context.filesDir, FILE_NAME)
|
||||
private val io = Executors.newSingleThreadExecutor { r -> Thread(r, "EventLog").apply { isDaemon = true } }
|
||||
private val _events = MutableStateFlow(load())
|
||||
val events: StateFlow<List<ShxEvent>> = _events
|
||||
|
||||
/**
|
||||
* Adds an event. A repeat of an unresolved event with the same [key]
|
||||
* within [MERGE_WINDOW_MS] bumps the existing entry instead of adding a
|
||||
* new one (retries would otherwise flood the list).
|
||||
*/
|
||||
fun record(
|
||||
kind: EventKind,
|
||||
category: EventCategory,
|
||||
title: String,
|
||||
text: String,
|
||||
action: EventAction = EventAction.NONE,
|
||||
key: String? = null,
|
||||
target: String? = null,
|
||||
)
|
||||
{
|
||||
val now = System.currentTimeMillis()
|
||||
var posted: ShxEvent? = null
|
||||
_events.update { list ->
|
||||
val merged = key?.let { k ->
|
||||
list.indexOfFirst { it.key == k && !it.resolved && now - it.at < MERGE_WINDOW_MS }
|
||||
} ?: -1
|
||||
val next = if (merged >= 0)
|
||||
{
|
||||
val old = list[merged]
|
||||
val bumped = old.copy(at = now, title = title, text = text, action = action, target = target, count = old.count + 1, read = false)
|
||||
posted = bumped
|
||||
listOf(bumped) + list.filterIndexed { i, _ -> i != merged }
|
||||
}
|
||||
else
|
||||
{
|
||||
val event = ShxEvent(
|
||||
id = now * 1000 + (list.size % 1000), at = now, kind = kind, category = category,
|
||||
title = title, text = text, action = action, key = key, target = target,
|
||||
)
|
||||
posted = event
|
||||
listOf(event) + list
|
||||
}
|
||||
prune(next, now)
|
||||
}
|
||||
persist()
|
||||
posted?.let {
|
||||
when
|
||||
{
|
||||
it.kind == EventKind.WARN && NotifPrefs.enabled(context, NotifPrefs.PROBLEMS) -> pushIfBackground(it, PROBLEMS_CHANNEL)
|
||||
it.kind == EventKind.ASK && NotifPrefs.enabled(context, NotifPrefs.NEW_APPS) -> pushIfBackground(it, HINTS_CHANNEL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Records once per [key] for the whole retention time (e.g. one update notice per version). */
|
||||
fun recordOnce(kind: EventKind, category: EventCategory, title: String, text: String, action: EventAction, key: String, target: String? = null)
|
||||
{
|
||||
if (_events.value.none { it.key == key })
|
||||
{
|
||||
record(kind, category, title, text, action, key, target)
|
||||
}
|
||||
}
|
||||
|
||||
/** The user answered an event (did the action or said no): it no longer waits. */
|
||||
fun answer(id: Long)
|
||||
{
|
||||
_events.update { list -> list.map { if (it.id == id) it.copy(resolved = true, read = true) else it } }
|
||||
persist()
|
||||
}
|
||||
|
||||
/** Marks the unresolved events whose key starts with [keyPrefix] as resolved. */
|
||||
fun resolve(keyPrefix: String)
|
||||
{
|
||||
var changed = false
|
||||
_events.update { list ->
|
||||
list.map {
|
||||
if (!it.resolved && it.key?.startsWith(keyPrefix) == true)
|
||||
{
|
||||
changed = true
|
||||
NotificationManagerCompat.from(context).cancel(TAG, notificationId(it))
|
||||
it.copy(resolved = true)
|
||||
}
|
||||
else it
|
||||
}
|
||||
}
|
||||
if (changed)
|
||||
{
|
||||
persist()
|
||||
}
|
||||
}
|
||||
|
||||
fun markAllRead()
|
||||
{
|
||||
if (_events.value.none { !it.read })
|
||||
{
|
||||
return
|
||||
}
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
_events.value.filter { !it.read }.forEach { manager.cancel(TAG, notificationId(it)) }
|
||||
_events.update { list -> list.map { if (it.read) it else it.copy(read = true) } }
|
||||
persist()
|
||||
}
|
||||
|
||||
/** Hides an event the user acted on or dismissed. */
|
||||
fun dismiss(id: Long)
|
||||
{
|
||||
_events.update { list -> list.filterNot { it.id == id } }
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun pushIfBackground(event: ShxEvent, channel: String)
|
||||
{
|
||||
val foreground = try
|
||||
{
|
||||
ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
false
|
||||
}
|
||||
if (foreground)
|
||||
{ /* the Home screen shows the problem already */
|
||||
return
|
||||
}
|
||||
val manager = NotificationManagerCompat.from(context)
|
||||
if (!manager.areNotificationsEnabled())
|
||||
{
|
||||
return
|
||||
}
|
||||
ensureChannel(context)
|
||||
val open = PendingIntent.getActivity(
|
||||
context, 0, Intent(context, InboxActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val builder = NotificationCompat.Builder(context, channel)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(event.title)
|
||||
.setContentText(event.text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(event.text))
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
/* F_SysNotifs: answers right in the notification */
|
||||
if (event.action == EventAction.SET_APP_DIRECT)
|
||||
{
|
||||
builder.addAction(0, context.getString(R.string.notif_yes), answerIntent(event, true))
|
||||
builder.addAction(0, context.getString(R.string.notif_no), answerIntent(event, false))
|
||||
}
|
||||
else if (event.kind == EventKind.WARN)
|
||||
{
|
||||
builder.addAction(0, context.getString(R.string.notif_details), open)
|
||||
}
|
||||
val notification = builder.build()
|
||||
try
|
||||
{
|
||||
manager.notify(TAG, notificationId(event), notification)
|
||||
}
|
||||
catch (e: SecurityException)
|
||||
{ /* POST_NOTIFICATIONS was revoked in between */
|
||||
Log.w(TAG, "Problem notification not allowed", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun answerIntent(event: ShxEvent, yes: Boolean): PendingIntent = PendingIntent.getBroadcast(
|
||||
context, (event.id % 100000).toInt() * 2 + if (yes) 1 else 0,
|
||||
Intent(context, EventAnswerReceiver::class.java).putExtra(EXTRA_ID, event.id).putExtra(EXTRA_YES, yes),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
/** «Yes» / «No» of a suggestion pushed as a notification. */
|
||||
internal fun answerFromNotification(id: Long, yes: Boolean)
|
||||
{
|
||||
val event = _events.value.firstOrNull { it.id == id } ?: return
|
||||
if (yes && event.action == EventAction.SET_APP_DIRECT)
|
||||
{
|
||||
event.target?.let { org.strongswan.android.logic.routing.RoutingManager.getInstance(context).setAppDirect(it, true) }
|
||||
}
|
||||
answer(id)
|
||||
NotificationManagerCompat.from(context).cancel(TAG, notificationId(event))
|
||||
}
|
||||
|
||||
/* repeats of a keyed event replace their notification instead of stacking */
|
||||
private fun notificationId(event: ShxEvent): Int = (event.key ?: event.id.toString()).hashCode()
|
||||
|
||||
private fun prune(list: List<ShxEvent>, now: Long): List<ShxEvent> =
|
||||
list.filter { now - it.at < RETENTION_MS }.take(MAX_EVENTS)
|
||||
|
||||
private fun persist()
|
||||
{
|
||||
val snapshot = _events.value
|
||||
io.execute {
|
||||
try
|
||||
{
|
||||
val array = JSONArray()
|
||||
snapshot.forEach { array.put(toJson(it)) }
|
||||
val tmp = File(file.path + ".tmp")
|
||||
tmp.writeText(array.toString())
|
||||
if (!tmp.renameTo(file))
|
||||
{
|
||||
file.writeText(array.toString())
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Saving events failed", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun load(): List<ShxEvent>
|
||||
{
|
||||
return try
|
||||
{
|
||||
if (!file.exists())
|
||||
{
|
||||
return emptyList()
|
||||
}
|
||||
val array = JSONArray(file.readText())
|
||||
val list = (0 until array.length()).mapNotNull { fromJson(array.optJSONObject(it)) }
|
||||
prune(list, System.currentTimeMillis())
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Loading events failed", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val TAG = "EventLog"
|
||||
private const val FILE_NAME = "events.json"
|
||||
const val PROBLEMS_CHANNEL = "org.strongswan.android.problems"
|
||||
const val HINTS_CHANNEL = "org.strongswan.android.hints"
|
||||
internal const val EXTRA_ID = "org.strongswan.android.extra.EVENT_ID"
|
||||
internal const val EXTRA_YES = "org.strongswan.android.extra.EVENT_YES"
|
||||
const val RETENTION_MS = 30L * 24 * 60 * 60 * 1000
|
||||
const val MERGE_WINDOW_MS = 30L * 60 * 1000
|
||||
private const val MAX_EVENTS = 300
|
||||
|
||||
@Volatile private var instance: EventLog? = null
|
||||
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): EventLog =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: EventLog(context.applicationContext).also { instance = it }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun ensureChannel(context: Context)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
val channel = NotificationChannel(
|
||||
PROBLEMS_CHANNEL, context.getString(R.string.events_channel_problems),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
)
|
||||
channel.description = context.getString(R.string.events_channel_problems_desc)
|
||||
manager.createNotificationChannel(channel)
|
||||
/* suggestions arrive quietly (F_NotifSettings «Quiet») */
|
||||
val hints = NotificationChannel(
|
||||
HINTS_CHANNEL, context.getString(R.string.events_channel_hints),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
)
|
||||
hints.description = context.getString(R.string.events_channel_hints_desc)
|
||||
manager.createNotificationChannel(hints)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun toJson(e: ShxEvent): JSONObject = JSONObject()
|
||||
.put("id", e.id).put("at", e.at).put("kind", e.kind.name).put("category", e.category.name)
|
||||
.put("title", e.title).put("text", e.text).put("action", e.action.name)
|
||||
.put("key", e.key ?: JSONObject.NULL).put("count", e.count).put("read", e.read).put("resolved", e.resolved)
|
||||
.put("target", e.target ?: JSONObject.NULL)
|
||||
|
||||
internal fun fromJson(o: JSONObject?): ShxEvent?
|
||||
{
|
||||
o ?: return null
|
||||
return try
|
||||
{
|
||||
ShxEvent(
|
||||
id = o.getLong("id"),
|
||||
at = o.getLong("at"),
|
||||
kind = EventKind.valueOf(o.getString("kind")),
|
||||
category = EventCategory.valueOf(o.getString("category")),
|
||||
title = o.getString("title"),
|
||||
text = o.optString("text"),
|
||||
action = runCatching { EventAction.valueOf(o.optString("action")) }.getOrDefault(EventAction.NONE),
|
||||
key = if (o.isNull("key")) null else o.optString("key"),
|
||||
count = o.optInt("count", 1),
|
||||
read = o.optBoolean("read"),
|
||||
resolved = o.optBoolean("resolved"),
|
||||
target = if (o.isNull("target")) null else o.optString("target"),
|
||||
)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{ /* an entry from an older/newer format: skip it rather than lose all */
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Which events are also pushed (F_NotifSettings); all of them stay in «Events» anyway. */
|
||||
object NotifPrefs
|
||||
{
|
||||
const val PROBLEMS = "pref_notif_problems"
|
||||
const val NEW_APPS = "pref_notif_new_apps"
|
||||
const val UPDATES = "pref_notif_updates"
|
||||
/** «Weekly summary», the only one off by default */
|
||||
const val WEEKLY = "pref_notif_weekly"
|
||||
|
||||
@JvmStatic
|
||||
fun enabled(context: Context, key: String): Boolean =
|
||||
androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, key != WEEKLY)
|
||||
|
||||
@JvmStatic
|
||||
fun set(context: Context, key: String, value: Boolean) =
|
||||
androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).edit().putBoolean(key, value).apply()
|
||||
}
|
||||
|
||||
/** Receives «Yes» / «No» from a suggestion notification. */
|
||||
class EventAnswerReceiver : android.content.BroadcastReceiver()
|
||||
{
|
||||
override fun onReceive(context: Context, intent: Intent)
|
||||
{
|
||||
val id = intent.getLongExtra(EventLog.EXTRA_ID, 0L)
|
||||
EventLog.getInstance(context).answerFromNotification(id, intent.getBooleanExtra(EventLog.EXTRA_YES, false))
|
||||
}
|
||||
}
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.events
|
||||
|
||||
import android.content.Context
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.VpnStateService.ErrorState
|
||||
|
||||
/**
|
||||
* The events the app records, one function per occasion, so the call sites
|
||||
* in the service code stay one-liners.
|
||||
*/
|
||||
object Events
|
||||
{
|
||||
private const val KEY_ERROR = "error:"
|
||||
private const val KEY_WHITELIST = "whitelist"
|
||||
|
||||
/** The connection failed; repeats while retrying are merged. */
|
||||
@JvmStatic
|
||||
fun connectionError(context: Context, error: ErrorState)
|
||||
{
|
||||
val (title, text, action) = when (error)
|
||||
{
|
||||
ErrorState.UNREACHABLE -> Triple(R.string.home_problem_unreachable_title, R.string.home_problem_unreachable_text, EventAction.OPEN_HOME)
|
||||
ErrorState.LOOKUP_FAILED -> Triple(R.string.home_problem_lookup_title, R.string.home_problem_lookup_text, EventAction.OPEN_HOME)
|
||||
ErrorState.AUTH_FAILED, ErrorState.PASSWORD_MISSING ->
|
||||
Triple(R.string.home_problem_auth_title, R.string.home_problem_auth_text, EventAction.OPEN_HOME)
|
||||
ErrorState.PEER_AUTH_FAILED, ErrorState.CERTIFICATE_UNAVAILABLE ->
|
||||
Triple(R.string.home_problem_cert_title, R.string.home_problem_cert_text, EventAction.OPEN_LOG)
|
||||
ErrorState.NO_ERROR -> return
|
||||
else -> Triple(R.string.home_problem_generic_title, R.string.home_problem_generic_text, EventAction.OPEN_LOG)
|
||||
}
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.WARN, EventCategory.CONNECTION, context.getString(title), context.getString(text),
|
||||
action, KEY_ERROR + error.name,
|
||||
)
|
||||
}
|
||||
|
||||
/** The tunnel is up again after a recorded failure. */
|
||||
@JvmStatic
|
||||
fun connectionRestored(context: Context)
|
||||
{
|
||||
val log = EventLog.getInstance(context)
|
||||
if (log.events.value.none { !it.resolved && it.key?.startsWith(KEY_ERROR) == true })
|
||||
{
|
||||
return
|
||||
}
|
||||
log.resolve(KEY_ERROR)
|
||||
log.record(
|
||||
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_restored_title),
|
||||
context.getString(R.string.event_restored_text), key = "restored",
|
||||
)
|
||||
}
|
||||
|
||||
/** The user stopped the VPN: pending connection problems no longer matter. */
|
||||
@JvmStatic
|
||||
fun connectionStopped(context: Context)
|
||||
{
|
||||
EventLog.getInstance(context).resolve(KEY_ERROR)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun whitelist(context: Context, active: Boolean)
|
||||
{
|
||||
val log = EventLog.getInstance(context)
|
||||
if (active)
|
||||
{
|
||||
log.record(
|
||||
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_whitelist_on_title),
|
||||
context.getString(R.string.event_whitelist_on_text), EventAction.OPEN_HOME, KEY_WHITELIST,
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
log.resolve(KEY_WHITELIST)
|
||||
log.record(
|
||||
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_whitelist_off_title),
|
||||
context.getString(R.string.event_whitelist_off_text), key = "whitelist-off",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The health check restarted the tunnel. */
|
||||
@JvmStatic
|
||||
fun healthReconnect(context: Context, serverDown: Boolean)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_health_reconnect_title),
|
||||
context.getString(if (serverDown) R.string.event_health_reconnect_server else R.string.event_health_reconnect_tunnel),
|
||||
key = "health-reconnect",
|
||||
)
|
||||
}
|
||||
|
||||
/** Auto-connect started the tunnel ([parked] = false) or parked it on a trusted Wi-Fi. */
|
||||
@JvmStatic
|
||||
fun autoConnect(context: Context, parked: Boolean)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION,
|
||||
context.getString(if (parked) R.string.event_auto_parked_title else R.string.event_auto_started_title),
|
||||
context.getString(if (parked) R.string.event_auto_parked_text else R.string.event_auto_started_text),
|
||||
key = if (parked) "auto-parked" else "auto-started",
|
||||
)
|
||||
}
|
||||
|
||||
/** A newly installed app looks like a Russian service that is not direct yet. */
|
||||
@JvmStatic
|
||||
fun newRuApp(context: Context, packageName: String, label: String)
|
||||
{
|
||||
EventLog.getInstance(context).recordOnce(
|
||||
EventKind.ASK, EventCategory.ROUTING, context.getString(R.string.event_new_app_title, label),
|
||||
context.getString(R.string.event_new_app_text), EventAction.SET_APP_DIRECT, "new-app:$packageName", packageName,
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun pauseOver(context: Context)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_pause_over_title),
|
||||
context.getString(R.string.event_pause_over_text),
|
||||
)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun updateAvailable(context: Context, versionName: String, versionCode: Long)
|
||||
{
|
||||
EventLog.getInstance(context).recordOnce(
|
||||
EventKind.INFO, EventCategory.APP, context.getString(R.string.event_update_title, versionName),
|
||||
context.getString(R.string.event_update_text), EventAction.INSTALL_UPDATE, "update:$versionCode",
|
||||
)
|
||||
}
|
||||
|
||||
/** A report went to the panel: the code stays here so the user can find it later. */
|
||||
@JvmStatic
|
||||
fun reportSent(context: Context, code: String)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.OK, EventCategory.APP, context.getString(R.string.report_event_title, code),
|
||||
context.getString(R.string.report_event_text), key = "report:$code",
|
||||
)
|
||||
}
|
||||
|
||||
/** The watchdog did not bring back [name]: a password would be needed. */
|
||||
@JvmStatic
|
||||
fun restoreNeedsPassword(context: Context, uuid: String, name: String)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_restore_pw_title, name),
|
||||
context.getString(R.string.event_restore_pw_text), EventAction.OPEN_SERVER, "restore-pw:$uuid", uuid,
|
||||
)
|
||||
}
|
||||
|
||||
/** The reserve or bridge [target] of [origin] has no saved password, so no switch. */
|
||||
@JvmStatic
|
||||
fun switchNeedsPassword(context: Context, targetUuid: String, target: String, origin: String, bridge: Boolean)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_switch_pw_title, target),
|
||||
context.getString(if (bridge) R.string.event_switch_pw_bridge else R.string.event_switch_pw_reserve, origin),
|
||||
EventAction.OPEN_SERVER, "switch-pw:$targetUuid", targetUuid,
|
||||
)
|
||||
}
|
||||
|
||||
/** A network rule or a scenario could not start [name]: no saved password. */
|
||||
@JvmStatic
|
||||
fun autoNeedsPassword(context: Context, uuid: String, name: String)
|
||||
{
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.WARN, EventCategory.CONNECTION, context.getString(R.string.event_auto_pw_title, name),
|
||||
context.getString(R.string.event_auto_pw_text), EventAction.OPEN_SERVER, "auto-pw:$uuid", uuid,
|
||||
)
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.profiles
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/** Where a profile came from (F_Profile «Profile source»). */
|
||||
enum class ProfileSourceType { LINK, FILE, QR, MANUAL, UNKNOWN }
|
||||
|
||||
/**
|
||||
* What the app keeps about a profile besides the strongSwan profile itself:
|
||||
* its source (and the update link), and the resilience settings of F_Profile.
|
||||
*/
|
||||
data class ProfileExtra(
|
||||
val uuid: String,
|
||||
val source: ProfileSourceType = ProfileSourceType.UNKNOWN,
|
||||
/** https link for LINK, the file name for FILE */
|
||||
val sourceValue: String? = null,
|
||||
val addedAt: Long = 0L,
|
||||
val autoUpdate: Boolean = true,
|
||||
val checkedAt: Long = 0L,
|
||||
/** number of changes found by the last check, -1 when it failed */
|
||||
val lastChanges: Int = 0,
|
||||
/** ports tried in turn when the main one does not answer */
|
||||
val backupPorts: List<Int> = emptyList(),
|
||||
/** profile used when this one does not connect in [RESERVE_TIMEOUT_S] */
|
||||
val reserveUuid: String? = null,
|
||||
/** profile switched to while the operator whitelists are on */
|
||||
val bridgeUuid: String? = null,
|
||||
val bridgeEnabled: Boolean = false,
|
||||
/** IKE fragmentation (charon «fragmentation = yes»), on unless switched off */
|
||||
val ikeFragmentation: Boolean = true,
|
||||
)
|
||||
|
||||
const val RESERVE_TIMEOUT_S = 15
|
||||
|
||||
/** Store of [ProfileExtra] (profile_extras.json), observable. */
|
||||
object ProfileExtras
|
||||
{
|
||||
private const val TAG = "ProfileExtras"
|
||||
private const val FILE_NAME = "profile_extras.json"
|
||||
|
||||
private val _all = MutableStateFlow<Map<String, ProfileExtra>>(emptyMap())
|
||||
val all: StateFlow<Map<String, ProfileExtra>> = _all
|
||||
@Volatile private var loaded = false
|
||||
|
||||
@JvmStatic
|
||||
fun get(context: Context, uuid: String): ProfileExtra
|
||||
{
|
||||
load(context)
|
||||
return _all.value[uuid] ?: ProfileExtra(uuid)
|
||||
}
|
||||
|
||||
fun flow(context: Context): StateFlow<Map<String, ProfileExtra>>
|
||||
{
|
||||
load(context)
|
||||
return all
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun update(context: Context, uuid: String, transform: (ProfileExtra) -> ProfileExtra)
|
||||
{
|
||||
load(context)
|
||||
_all.update { it + (uuid to transform(it[uuid] ?: ProfileExtra(uuid))) }
|
||||
save(context)
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile was imported: remember where from. A re-import from the
|
||||
* same kind of source keeps the user's settings (auto update, resilience).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun recordImport(context: Context, uuid: String, scheme: String?, uri: String?, qr: Boolean)
|
||||
{
|
||||
val type = when
|
||||
{
|
||||
qr -> ProfileSourceType.QR
|
||||
scheme == "http" || scheme == "https" -> ProfileSourceType.LINK
|
||||
else -> ProfileSourceType.FILE
|
||||
}
|
||||
val value = when (type)
|
||||
{
|
||||
ProfileSourceType.LINK -> uri
|
||||
ProfileSourceType.FILE -> uri?.substringAfterLast('/')?.substringAfterLast("%2F")?.takeIf { it.isNotBlank() }
|
||||
else -> null
|
||||
}
|
||||
update(context, uuid) {
|
||||
/* a link stays the update source even when the new copy came from a file */
|
||||
if (it.source == ProfileSourceType.LINK && type != ProfileSourceType.LINK) it.copy(addedAt = System.currentTimeMillis())
|
||||
else it.copy(source = type, sourceValue = value, addedAt = System.currentTimeMillis(), checkedAt = if (type == ProfileSourceType.LINK) System.currentTimeMillis() else it.checkedAt, lastChanges = 0)
|
||||
}
|
||||
}
|
||||
|
||||
/** A profile created by hand (when nothing is recorded for it yet). */
|
||||
@JvmStatic
|
||||
fun recordManual(context: Context, uuid: String)
|
||||
{
|
||||
load(context)
|
||||
if (_all.value[uuid]?.source.let { it == null || it == ProfileSourceType.UNKNOWN })
|
||||
{
|
||||
update(context, uuid) { it.copy(source = ProfileSourceType.MANUAL, addedAt = System.currentTimeMillis()) }
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun remove(context: Context, uuid: String)
|
||||
{
|
||||
load(context)
|
||||
_all.update { it - uuid }
|
||||
save(context)
|
||||
}
|
||||
|
||||
private fun load(context: Context)
|
||||
{
|
||||
if (loaded)
|
||||
{
|
||||
return
|
||||
}
|
||||
synchronized(this)
|
||||
{
|
||||
if (loaded)
|
||||
{
|
||||
return
|
||||
}
|
||||
try
|
||||
{
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
if (file.exists())
|
||||
{
|
||||
val array = JSONArray(file.readText())
|
||||
_all.value = (0 until array.length()).map { fromJson(array.getJSONObject(it)) }.associateBy { it.uuid }
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Loading failed", e)
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
}
|
||||
|
||||
private fun save(context: Context)
|
||||
{
|
||||
try
|
||||
{
|
||||
val array = JSONArray()
|
||||
_all.value.values.forEach { array.put(toJson(it)) }
|
||||
val file = File(context.filesDir, FILE_NAME)
|
||||
val tmp = File(file.path + ".tmp")
|
||||
tmp.writeText(array.toString())
|
||||
if (!tmp.renameTo(file))
|
||||
{
|
||||
file.writeText(array.toString())
|
||||
tmp.delete()
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "Saving failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun toJson(e: ProfileExtra): JSONObject = JSONObject()
|
||||
.put("uuid", e.uuid).put("source", e.source.name).put("value", e.sourceValue ?: JSONObject.NULL)
|
||||
.put("added", e.addedAt).put("auto", e.autoUpdate).put("checked", e.checkedAt).put("changes", e.lastChanges)
|
||||
.put("ports", JSONArray(e.backupPorts)).put("reserve", e.reserveUuid ?: JSONObject.NULL)
|
||||
.put("bridge", e.bridgeUuid ?: JSONObject.NULL).put("bridgeOn", e.bridgeEnabled).put("frag", e.ikeFragmentation)
|
||||
|
||||
internal fun fromJson(o: JSONObject): ProfileExtra
|
||||
{
|
||||
val ports = o.optJSONArray("ports")
|
||||
return ProfileExtra(
|
||||
uuid = o.getString("uuid"),
|
||||
source = runCatching { ProfileSourceType.valueOf(o.optString("source")) }.getOrDefault(ProfileSourceType.UNKNOWN),
|
||||
sourceValue = o.optString("value").takeIf { !o.isNull("value") && it.isNotEmpty() },
|
||||
addedAt = o.optLong("added"),
|
||||
autoUpdate = o.optBoolean("auto", true),
|
||||
checkedAt = o.optLong("checked"),
|
||||
lastChanges = o.optInt("changes"),
|
||||
backupPorts = if (ports == null) emptyList() else (0 until ports.length()).map { ports.getInt(it) },
|
||||
reserveUuid = o.optString("reserve").takeIf { !o.isNull("reserve") && it.isNotEmpty() },
|
||||
bridgeUuid = o.optString("bridge").takeIf { !o.isNull("bridge") && it.isNotEmpty() },
|
||||
bridgeEnabled = o.optBoolean("bridgeOn"),
|
||||
ikeFragmentation = o.optBoolean("frag", true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The profile signs in with a password and none is saved: nobody can start it unattended. */
|
||||
fun org.strongswan.android.data.VpnProfile.needsPassword(): Boolean =
|
||||
vpnType.has(org.strongswan.android.data.VpnType.VpnTypeFeature.USER_PASS) && password.isNullOrEmpty()
|
||||
|
||||
/**
|
||||
* For unattended starts (network rules, scenarios): true when [uuid] signs in
|
||||
* with an unsaved password - the start would only end in «password missing».
|
||||
* Records one event pointing at the server.
|
||||
*/
|
||||
fun blockedByPassword(context: android.content.Context, uuid: String): Boolean
|
||||
{
|
||||
val profile = runCatching {
|
||||
val source = org.strongswan.android.data.VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
source.getVpnProfile(java.util.UUID.fromString(uuid))
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}.getOrNull() ?: return false
|
||||
if (!profile.needsPassword())
|
||||
{
|
||||
return false
|
||||
}
|
||||
org.strongswan.android.logic.DiagLog.i("ProfileExtras", "${profile.name} needs a password, no unattended start")
|
||||
org.strongswan.android.logic.events.Events.autoNeedsPassword(context, uuid, profile.name ?: profile.gateway)
|
||||
return true
|
||||
}
|
||||
+292
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.profiles
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Base64
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import org.json.JSONObject
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.TrustedCertificateManager
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.EventAction
|
||||
import org.strongswan.android.logic.events.EventCategory
|
||||
import org.strongswan.android.logic.events.EventKind
|
||||
import org.strongswan.android.logic.events.EventLog
|
||||
import org.strongswan.android.utils.Constants
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** One changed field (F_ProfileUpdate): label, old and new value as shown. */
|
||||
data class ProfileChange(val label: String, val old: String, val new: String)
|
||||
|
||||
/** The server's copy of a profile and how it differs from the local one. */
|
||||
class ProfileCheck(
|
||||
val uuid: String,
|
||||
val changes: List<ProfileChange>,
|
||||
internal val gateway: String,
|
||||
internal val port: Int?,
|
||||
internal val remoteId: String?,
|
||||
internal val certificate: X509Certificate?,
|
||||
)
|
||||
|
||||
internal enum class ProfileField { ADDRESS, PORT, REMOTE_ID, CA }
|
||||
|
||||
/** A raw difference: CA values are the expiry dates. */
|
||||
internal data class FieldChange(val field: ProfileField, val old: Any?, val new: Any?)
|
||||
|
||||
/** The server-side fields of a downloaded .sswan. */
|
||||
internal data class RemoteProfile(val gateway: String, val port: Int?, val remoteId: String?, val certificate: X509Certificate?)
|
||||
|
||||
internal fun parseRemote(text: String, decode: (String) -> ByteArray): RemoteProfile
|
||||
{
|
||||
val remote = JSONObject(text).getJSONObject("remote")
|
||||
return RemoteProfile(
|
||||
gateway = remote.getString("addr"),
|
||||
port = remote.optInt("port").takeIf { it in 1..65535 },
|
||||
remoteId = remote.optString("id").ifEmpty { null },
|
||||
certificate = remote.optString("cert").ifEmpty { null }?.let {
|
||||
CertificateFactory.getInstance("X.509").generateCertificate(ByteArrayInputStream(decode(it))) as X509Certificate
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** What changes when the local profile takes the [remote] fields (port 4500 when unset). */
|
||||
internal fun diff(gateway: String?, port: Int?, remoteId: String?, currentCa: X509Certificate?, remote: RemoteProfile): List<FieldChange>
|
||||
{
|
||||
val changes = ArrayList<FieldChange>()
|
||||
if (remote.gateway != gateway)
|
||||
{
|
||||
changes += FieldChange(ProfileField.ADDRESS, gateway, remote.gateway)
|
||||
}
|
||||
if ((remote.port ?: 4500) != (port ?: 4500))
|
||||
{
|
||||
changes += FieldChange(ProfileField.PORT, port ?: 4500, remote.port ?: 4500)
|
||||
}
|
||||
if (remote.remoteId != null && remote.remoteId != remoteId)
|
||||
{
|
||||
changes += FieldChange(ProfileField.REMOTE_ID, remoteId ?: gateway, remote.remoteId)
|
||||
}
|
||||
val cert = remote.certificate
|
||||
if (cert != null && (currentCa == null || !currentCa.encoded.contentEquals(cert.encoded)))
|
||||
{
|
||||
changes += FieldChange(ProfileField.CA, currentCa?.notAfter, cert.notAfter)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-reads a profile from its link (F_Profile «Check now», the daily
|
||||
* check) and applies the server-side fields: address, port, remote
|
||||
* identity and CA certificate. The login, DNS, resilience settings,
|
||||
* routing rules and apps stay as they are.
|
||||
*/
|
||||
object ProfileUpdater
|
||||
{
|
||||
private const val TAG = "ProfileUpdater"
|
||||
private const val WORK_NAME = "profile-update-check"
|
||||
|
||||
/** Downloads and compares; throws on network or format errors. */
|
||||
@JvmStatic
|
||||
fun check(context: Context, uuid: String): ProfileCheck
|
||||
{
|
||||
val extra = ProfileExtras.get(context, uuid)
|
||||
val link = extra.sourceValue?.takeIf { extra.source == ProfileSourceType.LINK } ?: throw IllegalStateException("no link")
|
||||
val profile = loadProfile(context, uuid) ?: throw IllegalStateException("no profile")
|
||||
val text = download(link)
|
||||
val remote = parseRemote(text) { Base64.decode(it, Base64.DEFAULT) }
|
||||
val currentCa = profile.certificateAlias?.let { TrustedCertificateManager.getInstance().load().getCACertificateFromAlias(it) }
|
||||
val until = SimpleDateFormat("MM.yyyy", Locale.getDefault())
|
||||
val changes = diff(profile.gateway, profile.port, profile.remoteId, currentCa, remote).map {
|
||||
val label = context.getString(
|
||||
when (it.field)
|
||||
{
|
||||
ProfileField.ADDRESS -> R.string.profile_update_addr
|
||||
ProfileField.PORT -> R.string.profile_update_port
|
||||
ProfileField.REMOTE_ID -> R.string.profile_update_id
|
||||
ProfileField.CA -> R.string.profile_update_ca
|
||||
}
|
||||
)
|
||||
fun show(value: Any?): String = when (value)
|
||||
{
|
||||
null -> "—"
|
||||
is java.util.Date -> context.getString(R.string.profile_update_until, until.format(value))
|
||||
else -> value.toString()
|
||||
}
|
||||
ProfileChange(label, show(it.old), show(it.new))
|
||||
}
|
||||
val gateway = remote.gateway
|
||||
val port = remote.port
|
||||
val remoteId = remote.remoteId
|
||||
val certificate = remote.certificate
|
||||
ProfileExtras.update(context, uuid) { it.copy(checkedAt = System.currentTimeMillis(), lastChanges = changes.size) }
|
||||
return ProfileCheck(uuid, changes, gateway, port, remoteId, certificate)
|
||||
}
|
||||
|
||||
/** Writes the server-side fields and reconnects when the profile is in use. */
|
||||
@JvmStatic
|
||||
fun apply(context: Context, check: ProfileCheck)
|
||||
{
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
val profile = source.getVpnProfile(UUID.fromString(check.uuid)) ?: return
|
||||
profile.gateway = check.gateway
|
||||
profile.port = check.port
|
||||
if (check.remoteId != null)
|
||||
{
|
||||
profile.remoteId = check.remoteId
|
||||
}
|
||||
check.certificate?.let { cert ->
|
||||
val store = KeyStore.getInstance("LocalCertificateStore")
|
||||
store.load(null, null)
|
||||
store.setCertificateEntry(null, cert)
|
||||
profile.certificateAlias = store.getCertificateAlias(cert)
|
||||
TrustedCertificateManager.getInstance().reset()
|
||||
}
|
||||
source.updateVpnProfile(profile)
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
ProfileExtras.update(context, check.uuid) { it.copy(lastChanges = 0) }
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED).putExtra(Constants.VPN_PROFILES_SINGLE, check.uuid)
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
EventLog.getInstance(context).resolve("profile-update:${check.uuid}")
|
||||
DiagLog.i(TAG, "profile ${check.uuid} updated from its link (${check.changes.size} changes)")
|
||||
/* the running tunnel still uses the old settings */
|
||||
val service = VpnStateService.getLiveInstance()
|
||||
if (service != null && service.profile?.getUUID()?.toString() == check.uuid && service.state != VpnStateService.State.DISABLED)
|
||||
{
|
||||
service.reconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadProfile(context: Context, uuid: String): VpnProfile?
|
||||
{
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
return source.getVpnProfile(UUID.fromString(uuid))
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun download(link: String): String
|
||||
{
|
||||
val connection = URL(link).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = 15_000
|
||||
connection.readTimeout = 15_000
|
||||
try
|
||||
{
|
||||
if (connection.responseCode !in 200..299)
|
||||
{
|
||||
throw IllegalStateException("HTTP ${connection.responseCode}")
|
||||
}
|
||||
/* a profile is a few KiB; anything huge is not one */
|
||||
return connection.inputStream.use { String(it.readNBytesCompat(512 * 1024), Charsets.UTF_8) }
|
||||
}
|
||||
finally
|
||||
{
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun java.io.InputStream.readNBytesCompat(max: Int): ByteArray
|
||||
{
|
||||
val out = java.io.ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8192)
|
||||
while (out.size() < max)
|
||||
{
|
||||
val n = read(buffer)
|
||||
if (n < 0)
|
||||
{
|
||||
break
|
||||
}
|
||||
out.write(buffer, 0, n)
|
||||
}
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/** Daily check of the profiles with a link and «Update automatically» on. */
|
||||
@JvmStatic
|
||||
fun schedule(context: Context)
|
||||
{
|
||||
val request = PeriodicWorkRequestBuilder<ProfileUpdateWorker>(24, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
internal fun checkAll(context: Context)
|
||||
{
|
||||
val due = ProfileExtras.flow(context).value.values.filter { it.source == ProfileSourceType.LINK && it.autoUpdate }
|
||||
for (extra in due)
|
||||
{
|
||||
try
|
||||
{
|
||||
val check = check(context, extra.uuid)
|
||||
if (check.changes.isNotEmpty())
|
||||
{
|
||||
val name = loadProfile(context, extra.uuid)?.name ?: extra.uuid
|
||||
EventLog.getInstance(context).recordOnce(
|
||||
EventKind.ASK, EventCategory.CONNECTION, context.getString(R.string.event_profile_update_title, name),
|
||||
context.getString(R.string.event_profile_update_text, check.changes.size), EventAction.PROFILE_UPDATE,
|
||||
"profile-update:${extra.uuid}", extra.uuid,
|
||||
)
|
||||
}
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
ProfileExtras.update(context, extra.uuid) { it.copy(checkedAt = System.currentTimeMillis(), lastChanges = -1) }
|
||||
DiagLog.i(TAG, "profile check failed: $e")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileUpdateWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params)
|
||||
{
|
||||
override suspend fun doWork(): Result
|
||||
{
|
||||
ProfileUpdater.checkAll(applicationContext)
|
||||
return Result.success()
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.profiles
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.data.VpnType
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.events.EventCategory
|
||||
import org.strongswan.android.logic.events.EventKind
|
||||
import org.strongswan.android.logic.events.EventLog
|
||||
import org.strongswan.android.logic.events.Events
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The resilience settings of F_Profile at work. When the server does not
|
||||
* answer, the backup ports are tried in turn, then the reserve server; only
|
||||
* then the regular retry backoff takes over. While the operator whitelists
|
||||
* are on, a profile with «Bridge for whitelists» switches to its bridge and
|
||||
* comes back when they clear.
|
||||
*
|
||||
* Called on the main thread by [VpnStateService] and the whitelist detector.
|
||||
*/
|
||||
object Resilience
|
||||
{
|
||||
private const val TAG = "Resilience"
|
||||
/** marks the connects started here (they keep the attempt state) */
|
||||
const val KEY_INTERNAL = "org.strongswan.android.resilience.INTERNAL"
|
||||
/** port used instead of the profile's for this connect */
|
||||
const val KEY_PORT_OVERRIDE = "org.strongswan.android.resilience.PORT"
|
||||
|
||||
/* the profile the user asked for and where the attempt stands */
|
||||
private var origin: String? = null
|
||||
private var portIndex = -1
|
||||
private var onReserve = false
|
||||
/* set while switched to the bridge: the profile to come back to */
|
||||
private var bridgeOrigin: String? = null
|
||||
|
||||
/** Every connect request; a user's connect starts a new attempt. */
|
||||
@JvmStatic
|
||||
fun onConnect(uuid: String?, info: Bundle)
|
||||
{
|
||||
if (info.getBoolean(KEY_INTERNAL, false))
|
||||
{
|
||||
return
|
||||
}
|
||||
origin = uuid
|
||||
portIndex = -1
|
||||
onReserve = false
|
||||
bridgeOrigin = null
|
||||
}
|
||||
|
||||
/**
|
||||
* The server of the current attempt does not answer. Returns true when
|
||||
* another attempt was started (the regular retry stays off).
|
||||
*/
|
||||
@JvmStatic
|
||||
fun onUnreachable(service: VpnStateService): Boolean
|
||||
{
|
||||
val current = service.profile ?: return false
|
||||
val start = origin ?: current.getUUID().toString().also { origin = it }
|
||||
val extra = ProfileExtras.get(service, start)
|
||||
if (!onReserve && portIndex + 1 < extra.backupPorts.size)
|
||||
{
|
||||
portIndex++
|
||||
val port = extra.backupPorts[portIndex]
|
||||
DiagLog.i(TAG, "server does not answer, trying backup port $port")
|
||||
connect(service, start, current.password.takeIf { current.getUUID().toString() == start }, port)
|
||||
return true
|
||||
}
|
||||
val reserve = extra.reserveUuid
|
||||
if (!onReserve && reserve != null && reserve != start)
|
||||
{
|
||||
val profile = load(service, reserve) ?: return false
|
||||
if (profile.needsPassword())
|
||||
{ /* the reserve needs a password nobody can type in now: say so once */
|
||||
DiagLog.i(TAG, "reserve ${profile.name} needs a password, not switching")
|
||||
onReserve = true
|
||||
Events.switchNeedsPassword(service, reserve, profile.name ?: profile.gateway, load(service, start)?.name ?: "", bridge = false)
|
||||
return false
|
||||
}
|
||||
onReserve = true
|
||||
DiagLog.i(TAG, "server does not answer, switching to the reserve ${profile.name}")
|
||||
EventLog.getInstance(service).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION,
|
||||
service.getString(R.string.event_reserve_title, profile.name ?: profile.gateway),
|
||||
service.getString(R.string.event_reserve_text, load(service, start)?.name ?: ""), key = "reserve:$start",
|
||||
)
|
||||
connect(service, reserve, null, null)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Connected: tell when it took a backup port. */
|
||||
@JvmStatic
|
||||
fun onConnected(service: VpnStateService)
|
||||
{
|
||||
val start = origin ?: return
|
||||
if (!onReserve && portIndex >= 0)
|
||||
{
|
||||
val port = ProfileExtras.get(service, start).backupPorts.getOrNull(portIndex) ?: return
|
||||
EventLog.getInstance(service).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION, service.getString(R.string.event_backup_port_title, port),
|
||||
service.getString(R.string.event_backup_port_text), key = "port:$start",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The operator whitelists came or went (WhiteListManager). */
|
||||
@JvmStatic
|
||||
fun onWhitelist(context: Context, active: Boolean)
|
||||
{
|
||||
val service = VpnStateService.getLiveInstance() ?: return
|
||||
val current = service.profile?.getUUID()?.toString() ?: return
|
||||
if (active)
|
||||
{
|
||||
val extra = ProfileExtras.get(context, current)
|
||||
val bridge = extra.bridgeUuid?.takeIf { extra.bridgeEnabled && it != current } ?: return
|
||||
if (service.state == VpnStateService.State.DISABLED && service.errorState == VpnStateService.ErrorState.NO_ERROR)
|
||||
{ /* the VPN is off on purpose */
|
||||
return
|
||||
}
|
||||
val profile = load(context, bridge) ?: return
|
||||
if (profile.needsPassword())
|
||||
{
|
||||
DiagLog.i(TAG, "bridge ${profile.name} needs a password, not switching")
|
||||
Events.switchNeedsPassword(context, bridge, profile.name ?: profile.gateway, service.profile?.name ?: "", bridge = true)
|
||||
return
|
||||
}
|
||||
DiagLog.i(TAG, "whitelists on, switching to the bridge ${profile.name}")
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.INFO, EventCategory.CONNECTION, context.getString(R.string.event_bridge_on_title, profile.name ?: profile.gateway),
|
||||
context.getString(R.string.event_bridge_on_text), key = "bridge",
|
||||
)
|
||||
connect(service, bridge, null, null)
|
||||
bridgeOrigin = current
|
||||
}
|
||||
else
|
||||
{
|
||||
val back = bridgeOrigin ?: return
|
||||
bridgeOrigin = null
|
||||
if (service.state == VpnStateService.State.DISABLED && service.errorState == VpnStateService.ErrorState.NO_ERROR)
|
||||
{
|
||||
return
|
||||
}
|
||||
val profile = load(context, back) ?: return
|
||||
DiagLog.i(TAG, "whitelists off, back to ${profile.name}")
|
||||
EventLog.getInstance(context).resolve("bridge")
|
||||
EventLog.getInstance(context).record(
|
||||
EventKind.OK, EventCategory.CONNECTION, context.getString(R.string.event_bridge_off_title, profile.name ?: profile.gateway),
|
||||
context.getString(R.string.event_bridge_off_text), key = "bridge-off",
|
||||
)
|
||||
connect(service, back, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun connect(service: VpnStateService, uuid: String, password: String?, port: Int?)
|
||||
{
|
||||
val info = Bundle()
|
||||
info.putString(VpnProfileDataSource.KEY_UUID, uuid)
|
||||
password?.let { info.putString(VpnProfileDataSource.KEY_PASSWORD, it) }
|
||||
port?.let { info.putInt(KEY_PORT_OVERRIDE, it) }
|
||||
info.putBoolean(KEY_INTERNAL, true)
|
||||
service.connect(info, true)
|
||||
}
|
||||
|
||||
private fun load(context: Context, uuid: String) = runCatching {
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
source.getVpnProfile(UUID.fromString(uuid))
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.protection
|
||||
|
||||
import android.content.Context
|
||||
import androidx.preference.PreferenceManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/** The DNS server of the tunnel (F_Protection «DNS server»). */
|
||||
enum class DnsChoice { PROFILE, CLOUDFLARE, ADGUARD, CUSTOM }
|
||||
|
||||
/**
|
||||
* DNS and blocking (F_Protection). The blocking is done by the resolver
|
||||
* itself, so it follows from the choice: AdGuard DNS blocks ads, trackers
|
||||
* and phishing together, Cloudflare's 1.1.1.2 blocks malware and phishing
|
||||
* only. The servers replace the profile's ones on the next connect.
|
||||
*/
|
||||
data class ProtectionState(
|
||||
val choice: DnsChoice = DnsChoice.PROFILE,
|
||||
/** with CLOUDFLARE: the malware / phishing filtering resolver */
|
||||
val phishingFilter: Boolean = false,
|
||||
val custom: String = "",
|
||||
)
|
||||
{
|
||||
val blocksAds: Boolean get() = choice == DnsChoice.ADGUARD
|
||||
val blocksTrackers: Boolean get() = choice == DnsChoice.ADGUARD
|
||||
val blocksPhishing: Boolean get() = choice == DnsChoice.ADGUARD || (choice == DnsChoice.CLOUDFLARE && phishingFilter)
|
||||
|
||||
/** null: keep the profile's (or the server's) DNS */
|
||||
fun servers(): List<String>? = when (choice)
|
||||
{
|
||||
DnsChoice.PROFILE -> null
|
||||
DnsChoice.ADGUARD -> listOf("94.140.14.14", "94.140.15.15", "2a10:50c0::ad1:ff", "2a10:50c0::ad2:ff")
|
||||
DnsChoice.CLOUDFLARE -> if (phishingFilter) listOf("1.1.1.2", "1.0.0.2", "2606:4700:4700::1112", "2606:4700:4700::1002")
|
||||
else listOf("1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001")
|
||||
DnsChoice.CUSTOM -> parseCustom(custom).takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
fun withAds(on: Boolean): ProtectionState = when
|
||||
{
|
||||
on -> copy(choice = DnsChoice.ADGUARD)
|
||||
choice == DnsChoice.ADGUARD -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = true)
|
||||
else -> this
|
||||
}
|
||||
|
||||
fun withPhishing(on: Boolean): ProtectionState = when
|
||||
{
|
||||
on && choice == DnsChoice.ADGUARD -> this
|
||||
on -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = true)
|
||||
choice == DnsChoice.ADGUARD -> copy(choice = DnsChoice.CLOUDFLARE, phishingFilter = false)
|
||||
else -> copy(phishingFilter = false)
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
/** «1.2.3.4, 2001:db8::1» → the addresses that parse as IPs */
|
||||
fun parseCustom(text: String): List<String> = text.split(',', ' ', ';').map { it.trim() }.filter { it.isNotEmpty() }.filter { isIp(it) }
|
||||
|
||||
private fun isIp(s: String): Boolean =
|
||||
Regex("""^(\d{1,3})(\.\d{1,3}){3}$""").matches(s) && s.split('.').all { it.toInt() in 0..255 } ||
|
||||
(s.contains(':') && Regex("""^[0-9a-fA-F:]+$""").matches(s))
|
||||
}
|
||||
}
|
||||
|
||||
object Protection
|
||||
{
|
||||
private const val PREF_CHOICE = "pref_dns_choice"
|
||||
private const val PREF_PHISHING = "pref_dns_phishing"
|
||||
private const val PREF_CUSTOM = "pref_dns_custom"
|
||||
|
||||
private val _state = MutableStateFlow<ProtectionState?>(null)
|
||||
|
||||
@JvmStatic
|
||||
fun get(context: Context): ProtectionState
|
||||
{
|
||||
_state.value?.let { return it }
|
||||
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
|
||||
val state = ProtectionState(
|
||||
choice = runCatching { DnsChoice.valueOf(prefs.getString(PREF_CHOICE, DnsChoice.PROFILE.name)!!) }.getOrDefault(DnsChoice.PROFILE),
|
||||
phishingFilter = prefs.getBoolean(PREF_PHISHING, false),
|
||||
custom = prefs.getString(PREF_CUSTOM, "") ?: "",
|
||||
)
|
||||
_state.value = state
|
||||
return state
|
||||
}
|
||||
|
||||
fun flow(context: Context): StateFlow<ProtectionState?>
|
||||
{
|
||||
get(context)
|
||||
return _state
|
||||
}
|
||||
|
||||
fun set(context: Context, state: ProtectionState)
|
||||
{
|
||||
PreferenceManager.getDefaultSharedPreferences(context).edit()
|
||||
.putString(PREF_CHOICE, state.choice.name).putBoolean(PREF_PHISHING, state.phishingFilter).putString(PREF_CUSTOM, state.custom).apply()
|
||||
_state.value = state
|
||||
}
|
||||
|
||||
/** For CharonVpnService: the servers replacing the profile's ones, or null. */
|
||||
@JvmStatic
|
||||
fun dnsServers(context: Context): List<String>? = get(context).servers()
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import android.app.AppOpsManager
|
||||
import android.app.usage.NetworkStats
|
||||
import android.app.usage.NetworkStatsManager
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Per-app network traffic from the system statistics ("Usage access"), for
|
||||
* the «Recent apps» list. Android attributes tunneled traffic back to the
|
||||
* app that produced it, so the totals cover both the VPN and direct paths.
|
||||
* Only byte counts per app are read - never destinations or content.
|
||||
*/
|
||||
object AppTraffic
|
||||
{
|
||||
private const val TAG = "AppTraffic"
|
||||
/* the first uid of regular apps; lower ones are system services */
|
||||
private const val FIRST_APP_UID = 10000
|
||||
|
||||
fun hasAccess(context: Context): Boolean
|
||||
{
|
||||
val ops = context.getSystemService(Context.APP_OPS_SERVICE) as? AppOpsManager ?: return false
|
||||
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
{
|
||||
ops.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
|
||||
}
|
||||
else
|
||||
{
|
||||
@Suppress("DEPRECATION")
|
||||
ops.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, Process.myUid(), context.packageName)
|
||||
}
|
||||
return mode == AppOpsManager.MODE_ALLOWED
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes per package since [sinceMs] (wall clock), Wi-Fi and mobile
|
||||
* together; empty without usage access. The system keeps the counters
|
||||
* in buckets of up to two hours, so the window is approximate.
|
||||
*/
|
||||
fun since(context: Context, sinceMs: Long): Map<String, Long>
|
||||
{
|
||||
if (!hasAccess(context))
|
||||
{
|
||||
return emptyMap()
|
||||
}
|
||||
val manager = context.getSystemService(Context.NETWORK_STATS_SERVICE) as? NetworkStatsManager ?: return emptyMap()
|
||||
val now = System.currentTimeMillis()
|
||||
val perUid = HashMap<Int, Long>()
|
||||
@Suppress("DEPRECATION")
|
||||
for (type in intArrayOf(ConnectivityManager.TYPE_WIFI, ConnectivityManager.TYPE_MOBILE))
|
||||
{
|
||||
try
|
||||
{
|
||||
manager.querySummary(type, null, sinceMs, now).use { stats -> sum(stats, perUid) }
|
||||
}
|
||||
catch (e: Exception)
|
||||
{ /* no such network history or no access */
|
||||
Log.d(TAG, "querySummary($type) failed: $e")
|
||||
}
|
||||
}
|
||||
val pm = context.packageManager
|
||||
val result = HashMap<String, Long>()
|
||||
for ((uid, bytes) in perUid)
|
||||
{
|
||||
if (uid < FIRST_APP_UID || bytes <= 0)
|
||||
{
|
||||
continue
|
||||
}
|
||||
val pkg = pm.getPackagesForUid(uid)?.firstOrNull() ?: continue
|
||||
if (pkg != context.packageName)
|
||||
{
|
||||
result[pkg] = (result[pkg] ?: 0L) + bytes
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun sum(stats: NetworkStats, into: HashMap<Int, Long>)
|
||||
{
|
||||
val bucket = NetworkStats.Bucket()
|
||||
while (stats.hasNextBucket())
|
||||
{
|
||||
stats.getNextBucket(bucket)
|
||||
into[bucket.uid] = (into[bucket.uid] ?: 0L) + bucket.rxBytes + bucket.txBytes
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <R> NetworkStats.use(block: (NetworkStats) -> R): R = try
|
||||
{
|
||||
block(this)
|
||||
}
|
||||
finally
|
||||
{
|
||||
close()
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
/**
|
||||
* User-defined package name patterns ("package templates" in the UI):
|
||||
* - "ru.*" - package names starting with "ru."
|
||||
* - "*yandex*" - package names containing "yandex" anywhere
|
||||
* - "*.beta" - package names ending with ".beta"
|
||||
* - "ru.ozon.app" - without a star: prefix match, same as "ru.ozon.app*"
|
||||
* Matching is case-insensitive.
|
||||
*/
|
||||
object PackagePatterns
|
||||
{
|
||||
private val ALLOWED = Regex("[a-z0-9._*-]+")
|
||||
private val STARS = Regex("\\*+")
|
||||
|
||||
/** Canonical form of user input, or null if it is empty, too broad or has invalid characters. */
|
||||
fun normalize(input: String): String?
|
||||
{
|
||||
val text = input.trim().lowercase()
|
||||
if (text.isEmpty() || !ALLOWED.matches(text))
|
||||
{
|
||||
return null
|
||||
}
|
||||
val collapsed = text.replace(STARS, "*")
|
||||
if (collapsed.replace("*", "").isEmpty())
|
||||
{
|
||||
return null
|
||||
}
|
||||
return if (collapsed.contains('*')) collapsed else "$collapsed*"
|
||||
}
|
||||
|
||||
fun matches(pattern: String, pkg: String): Boolean
|
||||
{
|
||||
val core = pattern.trim('*').lowercase()
|
||||
if (core.isEmpty())
|
||||
{
|
||||
return false
|
||||
}
|
||||
val name = pkg.lowercase()
|
||||
val starts = pattern.startsWith("*")
|
||||
val ends = pattern.endsWith("*")
|
||||
return when
|
||||
{
|
||||
starts && ends -> name.contains(core)
|
||||
ends -> name.startsWith(core)
|
||||
starts -> name.endsWith(core)
|
||||
else -> name == core
|
||||
}
|
||||
}
|
||||
|
||||
/** Packages (of [installed]) matched by any of the patterns. */
|
||||
fun matchAll(patterns: Set<String>, installed: Set<String>): Set<String> =
|
||||
if (patterns.isEmpty())
|
||||
{
|
||||
emptySet()
|
||||
}
|
||||
else
|
||||
{
|
||||
installed.filterTo(HashSet()) { pkg -> patterns.any { matches(it, pkg) } }
|
||||
}
|
||||
|
||||
fun fromPrefix(prefix: String): String = "$prefix*"
|
||||
|
||||
fun fromKeyword(keyword: String): String = "*${keyword.lowercase()}*"
|
||||
}
|
||||
+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. */
|
||||
@JvmStatic
|
||||
fun classify(
|
||||
|
||||
+631
-223
File diff suppressed because it is too large.
Load diff
+99
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling
|
||||
|
||||
/**
|
||||
* Pure conversion rules from the pre-2.16 settings (named rule sets and
|
||||
* per-profile split-tunneling apps) to the single global direct-apps list.
|
||||
* No Android dependencies, so they are unit tested; [RoutingManager] applies
|
||||
* them once at startup.
|
||||
*/
|
||||
object RoutingMigration
|
||||
{
|
||||
data class RuleSetResult(
|
||||
val directPackages: Set<String>,
|
||||
val patterns: Set<String>,
|
||||
val categories: Set<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Enabled rule sets become explicit packages plus package patterns. A
|
||||
* built-in category whose every preset is fully present among the rule
|
||||
* sets (what the old "all RF apps" action produced) turns into the
|
||||
* category itself - same coverage, but shown as one switch. Disabled rule
|
||||
* sets are dropped (the old JSON is kept in a backup key).
|
||||
*/
|
||||
fun migrateRuleSets(ruleSets: List<RuleSet>): RuleSetResult
|
||||
{
|
||||
val active = ruleSets.filter { it.enabled }
|
||||
val direct = HashSet<String>()
|
||||
val patterns = LinkedHashSet<String>()
|
||||
for (ruleSet in active)
|
||||
{
|
||||
direct.addAll(ruleSet.packages)
|
||||
ruleSet.prefixes.forEach { patterns.add(PackagePatterns.fromPrefix(it)) }
|
||||
ruleSet.keywords.forEach { patterns.add(PackagePatterns.fromKeyword(it)) }
|
||||
}
|
||||
val categories = LinkedHashSet<String>()
|
||||
for (category in RoutingPresets.ALL_CATEGORIES)
|
||||
{
|
||||
val presets = RoutingPresets.presetsOf(category)
|
||||
if (presets.isNotEmpty() && presets.all { preset -> active.any { it.covers(preset) } })
|
||||
{
|
||||
categories.add(category)
|
||||
for (preset in presets)
|
||||
{
|
||||
preset.prefixes.forEach { patterns.remove(PackagePatterns.fromPrefix(it)) }
|
||||
preset.keywords.forEach { patterns.remove(PackagePatterns.fromKeyword(it)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return RuleSetResult(direct, patterns, categories)
|
||||
}
|
||||
|
||||
private fun RuleSet.covers(preset: RoutingPresets.Preset): Boolean
|
||||
{
|
||||
if (preset.prefixes.isEmpty() && preset.keywords.isEmpty())
|
||||
{
|
||||
return false
|
||||
}
|
||||
val lowerKeywords = keywords.map { it.lowercase() }.toSet()
|
||||
return prefixes.containsAll(preset.prefixes) && lowerKeywords.containsAll(preset.keywords.map { it.lowercase() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Packages a profile's old per-app setting sends direct (outside the VPN):
|
||||
* - EXCLUDE: the listed apps;
|
||||
* - ONLY: "all installed internet apps except the listed ones" snapshot
|
||||
* (an empty ONLY list never took effect in the old code, so it yields
|
||||
* nothing);
|
||||
* - DISABLE: nothing.
|
||||
* The app's own package is never listed (it is always excluded anyway).
|
||||
*/
|
||||
fun profileAppsToDirect(
|
||||
handling: SelectedAppsHandling,
|
||||
selected: Set<String>,
|
||||
installed: Set<String>,
|
||||
ownPackage: String,
|
||||
): Set<String> = when (handling)
|
||||
{
|
||||
SelectedAppsHandling.SELECTED_APPS_EXCLUDE -> selected - ownPackage
|
||||
SelectedAppsHandling.SELECTED_APPS_ONLY ->
|
||||
if (selected.isEmpty()) emptySet() else installed - selected - ownPackage
|
||||
else -> emptySet()
|
||||
}
|
||||
}
|
||||
+100
-64
@@ -14,10 +14,9 @@
|
||||
|
||||
package org.strongswan.android.logic.routing
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Built-in rule set templates.
|
||||
* Built-in app templates, grouped into categories the user can switch on in
|
||||
* one tap ("Banks", "Marketplaces", ...).
|
||||
* - Prefixes match package names, so "ru." catches every package in the ru.*
|
||||
* reverse-domain namespace.
|
||||
* - Keywords match anywhere in the package name (case-insensitive), so
|
||||
@@ -28,75 +27,112 @@ import java.util.UUID
|
||||
*/
|
||||
object RoutingPresets
|
||||
{
|
||||
data class Preset(val name: String, val prefixes: List<String> = emptyList(), val keywords: List<String> = emptyList())
|
||||
/* category ids; the namespace-wide preset lives in the hidden NAMESPACE
|
||||
* category that only "all RF apps" switches on */
|
||||
const val BANKS = "banks"
|
||||
const val GOV = "gov"
|
||||
const val SHOP = "shop"
|
||||
const val SOCIAL = "social"
|
||||
const val TRAVEL = "travel"
|
||||
const val SERVICES = "services"
|
||||
const val NAMESPACE = "ru_namespace"
|
||||
|
||||
/** User-visible categories in display order (the hidden [NAMESPACE] one is excluded). */
|
||||
val CATEGORIES: List<String> = listOf(BANKS, GOV, SHOP, SOCIAL, TRAVEL, SERVICES)
|
||||
|
||||
/** Everything "all RF apps" switches on. */
|
||||
val ALL_CATEGORIES: Set<String> = (CATEGORIES + NAMESPACE).toSet()
|
||||
|
||||
data class Preset(
|
||||
val name: String,
|
||||
val category: String,
|
||||
val prefixes: List<String> = emptyList(),
|
||||
val keywords: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/** Whether a package belongs to the preset (prefix, or keyword anywhere, case-insensitive). */
|
||||
fun Preset.matches(pkg: String): Boolean
|
||||
{
|
||||
if (prefixes.any { pkg.startsWith(it) })
|
||||
{
|
||||
return true
|
||||
}
|
||||
val lower = pkg.lowercase()
|
||||
return keywords.any { lower.contains(it.lowercase()) }
|
||||
}
|
||||
|
||||
/** Presets of one category, in declaration order. */
|
||||
fun presetsOf(category: String): List<Preset> = RF_BUNDLE.filter { it.category == category }
|
||||
|
||||
/** Whether the package matches any preset of [category] (the namespace one included). */
|
||||
fun matchesCategory(category: String, pkg: String): Boolean =
|
||||
presetsOf(category).any { it.matches(pkg) }
|
||||
|
||||
/** First user-visible category the package belongs to, or null. */
|
||||
fun categoryOf(pkg: String): String? =
|
||||
CATEGORIES.firstOrNull { matchesCategory(it, pkg) }
|
||||
|
||||
val RF_BUNDLE: List<Preset> = listOf(
|
||||
/* namespace-wide */
|
||||
Preset("Все RU.*", prefixes = listOf("ru.")),
|
||||
Preset("Все RU.*", NAMESPACE, prefixes = listOf("ru.")),
|
||||
/* ecosystems */
|
||||
Preset("Яндекс", prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
|
||||
Preset("VK", prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
|
||||
Preset("Одноклассники", prefixes = listOf("ru.ok.")),
|
||||
Preset("Дзен", keywords = listOf("dzen")),
|
||||
Preset("Rutube", keywords = listOf("rutube")),
|
||||
Preset("Звук", keywords = listOf("zvuk", "zvooq")),
|
||||
Preset("Иви", prefixes = listOf("ru.ivi")),
|
||||
Preset("Яндекс", SOCIAL, prefixes = listOf("ru.yandex."), keywords = listOf("yandex")),
|
||||
Preset("VK", SOCIAL, prefixes = listOf("com.vkontakte", "ru.vk", "com.vk"), keywords = listOf("vkontakte")),
|
||||
Preset("Одноклассники", SOCIAL, prefixes = listOf("ru.ok.")),
|
||||
Preset("Дзен", SOCIAL, keywords = listOf("dzen")),
|
||||
Preset("Rutube", SOCIAL, keywords = listOf("rutube")),
|
||||
Preset("Звук", SOCIAL, keywords = listOf("zvuk", "zvooq")),
|
||||
Preset("Иви", SOCIAL, prefixes = listOf("ru.ivi")),
|
||||
/* banks & finance */
|
||||
Preset("Сбер", keywords = listOf("sber")),
|
||||
Preset("Т-Банк", keywords = listOf("tinkoff", "tbank")),
|
||||
Preset("ВТБ", keywords = listOf("vtb")),
|
||||
Preset("Альфа-Банк", keywords = listOf("alfabank", "alpha-bank")),
|
||||
Preset("Газпромбанк", keywords = listOf("gazprombank")),
|
||||
Preset("Совкомбанк", keywords = listOf("sovcombank")),
|
||||
Preset("Почта Банк", keywords = listOf("pochtabank")),
|
||||
Preset("РСХБ", keywords = listOf("rshb", "rosselkhozbank")),
|
||||
Preset("ОТП-Банк", prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
|
||||
Preset("МКБ", prefixes = listOf("ru.mkb")),
|
||||
Preset("Уралсиб", keywords = listOf("uralsib")),
|
||||
Preset("ЮMoney", keywords = listOf("yoomoney")),
|
||||
Preset("Золотая Корона", keywords = listOf("koronapay")),
|
||||
Preset("Мой налог", keywords = listOf("nalog")),
|
||||
Preset("Сбер", BANKS, keywords = listOf("sber")),
|
||||
Preset("Т-Банк", BANKS, keywords = listOf("tinkoff", "tbank")),
|
||||
Preset("ВТБ", BANKS, keywords = listOf("vtb")),
|
||||
Preset("Альфа-Банк", BANKS, keywords = listOf("alfabank", "alpha-bank")),
|
||||
Preset("Газпромбанк", BANKS, keywords = listOf("gazprombank")),
|
||||
Preset("Совкомбанк", BANKS, keywords = listOf("sovcombank")),
|
||||
Preset("Почта Банк", BANKS, keywords = listOf("pochtabank")),
|
||||
Preset("РСХБ", BANKS, keywords = listOf("rshb", "rosselkhozbank")),
|
||||
Preset("ОТП-Банк", BANKS, prefixes = listOf("ru.otpbank"), keywords = listOf("otpbank")),
|
||||
Preset("МКБ", BANKS, prefixes = listOf("ru.mkb")),
|
||||
Preset("Уралсиб", BANKS, keywords = listOf("uralsib")),
|
||||
Preset("ЮMoney", BANKS, keywords = listOf("yoomoney")),
|
||||
Preset("Золотая Корона", BANKS, keywords = listOf("koronapay")),
|
||||
/* government */
|
||||
Preset("Госуслуги", GOV, prefixes = listOf("ru.rostel"), keywords = listOf("gosuslugi")),
|
||||
Preset("Мой налог", GOV, keywords = listOf("nalog")),
|
||||
/* retail & marketplaces */
|
||||
Preset("Ozon", keywords = listOf("ozon")),
|
||||
Preset("Wildberries", keywords = listOf("wildberries")),
|
||||
Preset("Мегамаркет", keywords = listOf("megamarket")),
|
||||
Preset("Пятёрочка", keywords = listOf("pyaterochka")),
|
||||
Preset("Перекрёсток", keywords = listOf("perekrestok")),
|
||||
Preset("Магнит", keywords = listOf("magnit")),
|
||||
Preset("Лента", keywords = listOf("lenta")),
|
||||
Preset("М.Видео", keywords = listOf("mvideo")),
|
||||
Preset("Спортмастер", keywords = listOf("sportmaster")),
|
||||
Preset("Лемана ПРО", keywords = listOf("leroymerlin", "lemana")),
|
||||
Preset("Самокат", keywords = listOf("samokat")),
|
||||
Preset("ВкусВилл", keywords = listOf("vkusvill")),
|
||||
Preset("Ozon", SHOP, keywords = listOf("ozon")),
|
||||
Preset("Wildberries", SHOP, keywords = listOf("wildberries")),
|
||||
Preset("Мегамаркет", SHOP, keywords = listOf("megamarket")),
|
||||
Preset("Пятёрочка", SHOP, keywords = listOf("pyaterochka")),
|
||||
Preset("Перекрёсток", SHOP, keywords = listOf("perekrestok")),
|
||||
Preset("Магнит", SHOP, keywords = listOf("magnit")),
|
||||
Preset("Лента", SHOP, keywords = listOf("lenta")),
|
||||
Preset("М.Видео", SHOP, keywords = listOf("mvideo")),
|
||||
Preset("Спортмастер", SHOP, keywords = listOf("sportmaster")),
|
||||
Preset("Лемана ПРО", SHOP, keywords = listOf("leroymerlin", "lemana")),
|
||||
Preset("Самокат", SHOP, keywords = listOf("samokat")),
|
||||
Preset("ВкусВилл", SHOP, keywords = listOf("vkusvill")),
|
||||
/* telecom */
|
||||
Preset("МТС", prefixes = listOf("ru.mts", "com.mts")),
|
||||
Preset("Мегафон", keywords = listOf("megafon")),
|
||||
Preset("Билайн", keywords = listOf("beeline")),
|
||||
Preset("T2/Tele2", prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
|
||||
Preset("Yota", prefixes = listOf("ru.yota")),
|
||||
Preset("МТС", SOCIAL, prefixes = listOf("ru.mts", "com.mts")),
|
||||
Preset("Мегафон", SOCIAL, keywords = listOf("megafon")),
|
||||
Preset("Билайн", SOCIAL, keywords = listOf("beeline")),
|
||||
Preset("T2/Tele2", SOCIAL, prefixes = listOf("ru.t2"), keywords = listOf("tele2")),
|
||||
Preset("Yota", SOCIAL, prefixes = listOf("ru.yota")),
|
||||
/* transport & travel */
|
||||
Preset("РЖД", keywords = listOf("rzd")),
|
||||
Preset("Аэрофлот", keywords = listOf("aeroflot")),
|
||||
Preset("Туту.ру", keywords = listOf("tutu.ru")),
|
||||
Preset("Aviasales", keywords = listOf("aviasales")),
|
||||
Preset("Ситимобил", keywords = listOf("sitimobil")),
|
||||
Preset("Делимобиль", keywords = listOf("delimobil")),
|
||||
Preset("РЖД", TRAVEL, keywords = listOf("rzd")),
|
||||
Preset("Аэрофлот", TRAVEL, keywords = listOf("aeroflot")),
|
||||
Preset("Туту.ру", TRAVEL, keywords = listOf("tutu.ru")),
|
||||
Preset("Aviasales", TRAVEL, keywords = listOf("aviasales")),
|
||||
Preset("Ситимобил", TRAVEL, keywords = listOf("sitimobil")),
|
||||
Preset("Делимобиль", TRAVEL, keywords = listOf("delimobil")),
|
||||
/* services */
|
||||
Preset("2ГИС", keywords = listOf("2gis", "dublgis")),
|
||||
Preset("ЦИАН", prefixes = listOf("ru.cian")),
|
||||
Preset("Дром", keywords = listOf("drom")),
|
||||
Preset("Юла", keywords = listOf("youla")),
|
||||
Preset("HeadHunter", prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
|
||||
Preset("Почта России", keywords = listOf("pochta", "russianpost")),
|
||||
Preset("СДЭК", keywords = listOf("cdek")),
|
||||
Preset("2ГИС", SERVICES, keywords = listOf("2gis", "dublgis")),
|
||||
Preset("ЦИАН", SERVICES, prefixes = listOf("ru.cian")),
|
||||
Preset("Дром", SERVICES, keywords = listOf("drom")),
|
||||
Preset("Юла", SERVICES, keywords = listOf("youla")),
|
||||
Preset("HeadHunter", SERVICES, prefixes = listOf("ru.hh"), keywords = listOf("headhunter")),
|
||||
Preset("Почта России", SERVICES, keywords = listOf("pochta", "russianpost")),
|
||||
Preset("СДЭК", SERVICES, keywords = listOf("cdek")),
|
||||
)
|
||||
}
|
||||
|
||||
fun RoutingPresets.Preset.asRuleSet(): RuleSet = RuleSet(
|
||||
id = UUID.randomUUID().toString(),
|
||||
name = name,
|
||||
enabled = true,
|
||||
prefixes = prefixes.toSet(),
|
||||
keywords = keywords.map { it.lowercase() }.toSet(),
|
||||
)
|
||||
+39
-7
@@ -155,8 +155,10 @@ class RuIpListManager private constructor(private val context: Context)
|
||||
* classification set is hot-swapped in native code instead */
|
||||
CharonVpnService.pushUserspaceRoutingData(context)
|
||||
}
|
||||
else if (enabled)
|
||||
{
|
||||
else if (enabled || excludedV4.isNotEmpty() || excludedV6.isNotEmpty())
|
||||
{ /* routes only change when the TUN is re-established - also when
|
||||
* the split is switched OFF, or the RU ranges keep bypassing the
|
||||
* tunnel until the next manual reconnect */
|
||||
scheduleReconnect()
|
||||
}
|
||||
}
|
||||
@@ -183,7 +185,17 @@ class RuIpListManager private constructor(private val context: Context)
|
||||
return try
|
||||
{
|
||||
val (text, sha256) = withContext(Dispatchers.IO) { fetchList() }
|
||||
val parsed = withContext(Dispatchers.IO) { parse(text) }
|
||||
if (sha256 == prefs.getString(PREF_SHA256, null) &&
|
||||
(excludedV4.isNotEmpty() || excludedV6.isNotEmpty()))
|
||||
{ /* identical list: nothing to parse, save or re-apply - above
|
||||
* all no reconnect that tears down a healthy tunnel daily */
|
||||
val now = System.currentTimeMillis()
|
||||
prefs.edit().putLong(PREF_LAST_UPDATE, now).remove(PREF_LAST_FAIL).apply()
|
||||
publish(status = RuIpListStatus.UP_TO_DATE, lastUpdatedMs = now)
|
||||
Log.i(TAG, "RU geoip list unchanged")
|
||||
return true
|
||||
}
|
||||
val parsed = withContext(Dispatchers.Default) { parse(text) }
|
||||
if (parsed.v4.isEmpty() && parsed.v6.isEmpty())
|
||||
{
|
||||
throw IOException("downloaded list contains no CIDRs")
|
||||
@@ -195,6 +207,7 @@ class RuIpListManager private constructor(private val context: Context)
|
||||
prefs.edit()
|
||||
.putLong(PREF_LAST_UPDATE, now)
|
||||
.putString(PREF_SHA256, sha256)
|
||||
.remove(PREF_LAST_FAIL)
|
||||
.apply()
|
||||
publish(
|
||||
status = RuIpListStatus.UP_TO_DATE,
|
||||
@@ -213,6 +226,8 @@ class RuIpListManager private constructor(private val context: Context)
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.w(TAG, "RU geoip list update failed", e)
|
||||
/* remembered so a blocked source is not retried on every cold start */
|
||||
prefs.edit().putLong(PREF_LAST_FAIL, System.currentTimeMillis()).apply()
|
||||
publish(status = RuIpListStatus.ERROR)
|
||||
false
|
||||
}
|
||||
@@ -224,14 +239,16 @@ class RuIpListManager private constructor(private val context: Context)
|
||||
|
||||
/* ------------------------------ internals ------------------------------ */
|
||||
|
||||
private fun loadFromDisk()
|
||||
private suspend fun loadFromDisk()
|
||||
{
|
||||
val file = listFile()
|
||||
if (file.isFile)
|
||||
{
|
||||
try
|
||||
{
|
||||
val parsed = parse(file.readText())
|
||||
/* thousands of CIDRs: read and parse off the main thread (this
|
||||
* runs at every process start) */
|
||||
val parsed = withContext(Dispatchers.Default) { parse(file.readText()) }
|
||||
excludedV4 = parsed.v4
|
||||
excludedV6 = parsed.v6
|
||||
publish(
|
||||
@@ -257,8 +274,8 @@ class RuIpListManager private constructor(private val context: Context)
|
||||
{
|
||||
return
|
||||
}
|
||||
val last = prefs.getLong(PREF_LAST_UPDATE, 0L)
|
||||
if (System.currentTimeMillis() - last >= Constants.RUSPLIT_CHECK_INTERVAL_MS)
|
||||
if (isAutoCheckDue(System.currentTimeMillis(), prefs.getLong(PREF_LAST_UPDATE, 0L),
|
||||
prefs.getLong(PREF_LAST_FAIL, 0L)))
|
||||
{
|
||||
scope.launch { updateNow() }
|
||||
}
|
||||
@@ -403,6 +420,21 @@ class RuIpListManager private constructor(private val context: Context)
|
||||
private const val FILE_NAME = "ru.list"
|
||||
private const val PREF_LAST_UPDATE = "rusplit_last_update"
|
||||
private const val PREF_SHA256 = "rusplit_sha256"
|
||||
private const val PREF_LAST_FAIL = "rusplit_last_fail"
|
||||
/* after a failed download wait this long before the next automatic try */
|
||||
internal const val FAIL_RETRY_MS = 3 * 60 * 60 * 1000L
|
||||
|
||||
/** Whether the automatic refresh is due (pure, unit-testable). */
|
||||
@JvmStatic
|
||||
internal fun isAutoCheckDue(now: Long, lastUpdate: Long, lastFail: Long): Boolean
|
||||
{
|
||||
if (now - lastUpdate < Constants.RUSPLIT_CHECK_INTERVAL_MS)
|
||||
{
|
||||
return false
|
||||
}
|
||||
/* clock moved backwards: don't let a "future" failure block forever */
|
||||
return lastFail <= 0L || lastFail > now || now - lastFail >= FAIL_RETRY_MS
|
||||
}
|
||||
private const val RECONNECT_DEBOUNCE_MS = 2000L
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000
|
||||
private const val READ_TIMEOUT_MS = 30_000
|
||||
|
||||
+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")
|
||||
}
|
||||
+6
-1
@@ -29,7 +29,12 @@ class UpdateCheckWorker(context: Context, params: WorkerParameters) : CoroutineW
|
||||
{
|
||||
override suspend fun doWork(): Result
|
||||
{
|
||||
UpdateManager.getInstance(applicationContext).checkIfDue()
|
||||
/* wait for the check (and a silent pre-download it starts): returning
|
||||
* early lets the system reclaim the process mid-request, outside the
|
||||
* worker's charging/network constraints */
|
||||
val manager = UpdateManager.getInstance(applicationContext)
|
||||
manager.checkIfDue()?.join()
|
||||
manager.awaitDownload()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
|
||||
+303
-47
@@ -26,6 +26,7 @@ import android.content.SharedPreferences
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
@@ -41,7 +42,6 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -51,6 +51,8 @@ import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.StrongSwanApplication
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.connectivity.UnderlyingNetwork
|
||||
import org.strongswan.android.logic.events.Events
|
||||
import org.strongswan.android.ui.MainActivity
|
||||
import org.strongswan.android.utils.Constants
|
||||
import java.io.File
|
||||
@@ -60,6 +62,7 @@ import java.net.Proxy
|
||||
import java.net.URL
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/** Categories of update failures, mapped to localized messages by the UI */
|
||||
enum class UpdateError { NETWORK, HASH, SIGNATURE, INSTALL, OTHER }
|
||||
@@ -74,6 +77,10 @@ data class UpdateInfo(
|
||||
val sha256: String,
|
||||
val size: Long,
|
||||
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
|
||||
@@ -115,13 +122,30 @@ class UpdateManager private constructor(private val context: Context)
|
||||
private const val UPDATES_CHANNEL = "org.strongswan.android.updates"
|
||||
private const val UPDATES_NOTIFICATION_ID = 1001
|
||||
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
|
||||
private var mInstance: UpdateManager? = null
|
||||
|
||||
/* called from the main thread and the WorkManager thread: a second
|
||||
* instance would run its own check loop and state */
|
||||
@JvmStatic
|
||||
fun getInstance(context: Context): UpdateManager
|
||||
{
|
||||
return mInstance ?: UpdateManager(context.applicationContext).also { mInstance = it }
|
||||
return mInstance ?: synchronized(this)
|
||||
{
|
||||
mInstance ?: UpdateManager(context.applicationContext).also { mInstance = it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,6 +178,15 @@ class UpdateManager private constructor(private val context: Context)
|
||||
|
||||
private var mCheckJob: Job? = null
|
||||
private var mDownloadJob: Job? = null
|
||||
/* the user asked to install (e.g. tapped "update ready") while no update
|
||||
* was known in this process: install as soon as the check finds it */
|
||||
@Volatile
|
||||
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 val mConnection = object : ServiceConnection
|
||||
@@ -179,7 +212,8 @@ class UpdateManager private constructor(private val context: Context)
|
||||
{
|
||||
cleanup()
|
||||
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()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
/* background checks only run while charging - zero battery impact */
|
||||
@@ -188,13 +222,16 @@ class UpdateManager private constructor(private val context: Context)
|
||||
.build()
|
||||
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
|
||||
UpdateCheckWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, request)
|
||||
mScope.launch {
|
||||
while (true)
|
||||
{
|
||||
checkIfDue()
|
||||
delay(Constants.UPDATE_CHECK_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
/* no in-process timer: the VPN service keeps the process alive, so a
|
||||
* loop here would check (and pre-download) on battery every 12 h and
|
||||
* defeat the worker's charging constraint. The worker and the check
|
||||
* on app resume cover it. */
|
||||
}
|
||||
|
||||
/** Waits for a running silent download (used by the worker). */
|
||||
suspend fun awaitDownload()
|
||||
{
|
||||
mDownloadJob?.join()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,17 +239,18 @@ class UpdateManager private constructor(private val context: Context)
|
||||
* last-check timestamp). Called by the periodic worker, the in-process
|
||||
* loop and on app resume.
|
||||
*/
|
||||
fun checkIfDue()
|
||||
fun checkIfDue(): Job?
|
||||
{
|
||||
if (!isAutoCheckEnabled() || mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
|
||||
{
|
||||
return
|
||||
return null
|
||||
}
|
||||
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()))
|
||||
{
|
||||
check()
|
||||
return check()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun isAutoCheckEnabled(): Boolean
|
||||
@@ -228,29 +266,101 @@ class UpdateManager private constructor(private val context: Context)
|
||||
check()
|
||||
}
|
||||
|
||||
private fun 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?)
|
||||
{
|
||||
if (mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
|
||||
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
|
||||
}
|
||||
mCheckJob = mScope.launch {
|
||||
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
|
||||
private fun check(): Job?
|
||||
{
|
||||
if (mCheckJob?.isActive == true || mDownloadJob?.isActive == true)
|
||||
{
|
||||
return mCheckJob?.takeIf { it.isActive }
|
||||
}
|
||||
val job = mScope.launch {
|
||||
mState.value = UpdateState.Checking
|
||||
val info = fetchManifest() ?: return@launch
|
||||
mPrefs.edit().putLong(Constants.PREF_LAST_UPDATE_CHECK, System.currentTimeMillis()).apply()
|
||||
val info = fetchManifest() ?: run {
|
||||
mInstallPending = false
|
||||
return@launch
|
||||
}
|
||||
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())
|
||||
{
|
||||
DiagLog.i(TAG, "update: available ${info.versionName} (${info.versionCode}), " +
|
||||
"installed ${currentVersionName()} (${currentVersionCode()})")
|
||||
mState.value = UpdateState.Available(info)
|
||||
maybeDownloadSilently(info)
|
||||
Events.updateAvailable(context, info.versionName, info.versionCode)
|
||||
if (mInstallPending)
|
||||
{
|
||||
mInstallPending = false
|
||||
downloadAndInstall()
|
||||
}
|
||||
else
|
||||
{
|
||||
maybeDownloadSilently(info)
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
mInstallPending = false
|
||||
DiagLog.i(TAG, "update: up to date (${currentVersionName()} ${currentVersionCode()})")
|
||||
mState.value = UpdateState.UpToDate
|
||||
}
|
||||
}
|
||||
mCheckJob = job
|
||||
return job
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,6 +370,7 @@ class UpdateManager private constructor(private val context: Context)
|
||||
* right away; otherwise a notification tells the user it is ready - one
|
||||
* tap installs it (a single system confirmation on Android < 12)
|
||||
*/
|
||||
@Synchronized
|
||||
private fun maybeDownloadSilently(info: UpdateInfo)
|
||||
{
|
||||
if (!isSilentUpdatesEnabled())
|
||||
@@ -267,7 +378,8 @@ class UpdateManager private constructor(private val context: Context)
|
||||
DiagLog.i(TAG, "update: silent download disabled, waiting for manual install")
|
||||
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)")
|
||||
return
|
||||
@@ -276,12 +388,17 @@ class UpdateManager private constructor(private val context: Context)
|
||||
{
|
||||
return
|
||||
}
|
||||
mCancelRequested = false
|
||||
mDownloadJob = mScope.launch {
|
||||
try
|
||||
{
|
||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (${info.size} B)")
|
||||
val apk = downloadApk(info)
|
||||
verifySignature(apk)
|
||||
if (mCancelRequested)
|
||||
{
|
||||
throw CancellationException("update download cancelled")
|
||||
}
|
||||
if (isVpnDown() && canInstallSilently())
|
||||
{
|
||||
Log.i(TAG, "Applying the downloaded update silently")
|
||||
@@ -292,18 +409,38 @@ class UpdateManager private constructor(private val context: Context)
|
||||
else
|
||||
{
|
||||
DiagLog.i(TAG, "update: ${info.versionName} downloaded, waiting for the user")
|
||||
notifyUpdateReady(info)
|
||||
/* back to Available: Downloading would block downloadAndInstall()
|
||||
* and leave the settings row stuck at 100% */
|
||||
mState.value = UpdateState.Available(info)
|
||||
if (mInstallPending)
|
||||
{
|
||||
mInstallPending = false
|
||||
UpdateInstaller.install(context, apk)
|
||||
mState.value = UpdateState.Idle
|
||||
}
|
||||
else
|
||||
{
|
||||
notifyUpdateReady(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (e: CancellationException)
|
||||
{
|
||||
onDownloadCancelled()
|
||||
}
|
||||
catch (e: Exception)
|
||||
{
|
||||
Log.e(TAG, "Silent update download failed", e)
|
||||
DiagLog.i(TAG, "update: silent download failed: ${e.javaClass.simpleName}: ${e.message}")
|
||||
/* the manual download from the settings screen still works */
|
||||
mState.value = UpdateState.Available(info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
{
|
||||
return mPrefs.getBoolean(Constants.PREF_SILENT_UPDATES, false)
|
||||
@@ -311,8 +448,20 @@ class UpdateManager private constructor(private val context: Context)
|
||||
|
||||
private fun isNetworkMetered(): Boolean
|
||||
{
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
return cm?.isActiveNetworkMetered ?: true
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return true
|
||||
/* the VPN marks itself unmetered (setMetered(false)), so with the tunnel
|
||||
* up the active network always looks unmetered - judge by the physical
|
||||
* network below it instead */
|
||||
val underlying = UnderlyingNetwork.resolve(context) ?: return cm.isActiveNetworkMetered
|
||||
val caps = try
|
||||
{
|
||||
cm.getNetworkCapabilities(underlying)
|
||||
}
|
||||
catch (_: Exception)
|
||||
{
|
||||
null
|
||||
} ?: return true
|
||||
return !caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
}
|
||||
|
||||
private fun isVpnDown(): Boolean
|
||||
@@ -350,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 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)
|
||||
.setContentTitle(title)
|
||||
.setContentText("$progress%")
|
||||
@@ -358,11 +507,16 @@ class UpdateManager private constructor(private val context: Context)
|
||||
.setStyle(Notification.ProgressStyle()
|
||||
.setProgressSegments(listOf(Notification.ProgressStyle.Segment(100)))
|
||||
.setProgress(progress))
|
||||
.setRequestPromotedOngoing(true)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.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
|
||||
{
|
||||
@@ -395,6 +549,10 @@ class UpdateManager private constructor(private val context: Context)
|
||||
/** Posts a notification that the downloaded update is ready to install */
|
||||
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
|
||||
if (manager == null)
|
||||
{
|
||||
@@ -419,6 +577,13 @@ class UpdateManager private constructor(private val context: Context)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setContentIntent(pending)
|
||||
.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()
|
||||
try
|
||||
{
|
||||
@@ -434,23 +599,41 @@ class UpdateManager private constructor(private val context: Context)
|
||||
* Downloads the update APK (with progress) and installs it as an
|
||||
* in-place update (the app data is preserved)
|
||||
*/
|
||||
@Synchronized
|
||||
fun downloadAndInstall()
|
||||
{
|
||||
val info = (mState.value as? UpdateState.Available)?.info ?: return
|
||||
if (mDownloadJob?.isActive == true)
|
||||
{
|
||||
{ /* a silent download is running: install once it is done */
|
||||
mInstallPending = true
|
||||
return
|
||||
}
|
||||
val info = (mState.value as? UpdateState.Available)?.info
|
||||
if (info == null)
|
||||
{ /* e.g. the "update ready" notification was tapped after the process
|
||||
* restarted: look the update up again and install it when found */
|
||||
mInstallPending = true
|
||||
check()
|
||||
return
|
||||
}
|
||||
mCancelRequested = false
|
||||
mDownloadJob = mScope.launch {
|
||||
try
|
||||
{
|
||||
DiagLog.i(TAG, "update: downloading ${info.versionName} (manual)")
|
||||
val apk = downloadApk(info)
|
||||
verifySignature(apk)
|
||||
if (mCancelRequested)
|
||||
{
|
||||
throw CancellationException("update download cancelled")
|
||||
}
|
||||
DiagLog.i(TAG, "update: installing ${info.versionName} (manual)")
|
||||
UpdateInstaller.install(context, apk)
|
||||
mState.value = UpdateState.Idle
|
||||
}
|
||||
catch (e: CancellationException)
|
||||
{
|
||||
onDownloadCancelled()
|
||||
}
|
||||
catch (e: UpdateException)
|
||||
{
|
||||
Log.e(TAG, "Update failed: ${e.kind}", e)
|
||||
@@ -482,6 +665,26 @@ class UpdateManager private constructor(private val context: Context)
|
||||
|
||||
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
|
||||
{
|
||||
val conn = openConnection(getBaseUrl() + Constants.UPDATE_MANIFEST_FILE)
|
||||
@@ -514,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>
|
||||
{
|
||||
if (notes == null)
|
||||
@@ -528,8 +783,9 @@ class UpdateManager private constructor(private val context: Context)
|
||||
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)
|
||||
if (!dir.exists() && !dir.mkdirs())
|
||||
{
|
||||
@@ -542,36 +798,32 @@ class UpdateManager private constructor(private val context: Context)
|
||||
}
|
||||
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
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
throw UpdateException(UpdateError.NETWORK, "HTTP $code")
|
||||
}
|
||||
val expected = if (info.size > 0) info.size else -1L
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
var downloaded = 0L
|
||||
var lastProgress = -1
|
||||
|
||||
conn.inputStream.use { input ->
|
||||
mDownloadConn = conn
|
||||
val downloaded = conn.inputStream.use { input ->
|
||||
FileOutputStream(apk).use { output ->
|
||||
while (true)
|
||||
{
|
||||
val len = input.read(buffer)
|
||||
if (len < 0)
|
||||
{
|
||||
break
|
||||
}
|
||||
output.write(buffer, 0, len)
|
||||
digest.update(buffer, 0, len)
|
||||
downloaded += len
|
||||
copyDownload(input, output, digest, { mCancelRequested }) { done ->
|
||||
if (expected > 0)
|
||||
{
|
||||
val progress = (downloaded * 100 / expected).toInt().coerceIn(0, 100)
|
||||
val progress = (done * 100 / expected).toInt().coerceIn(0, 100)
|
||||
if (progress != lastProgress)
|
||||
{
|
||||
lastProgress = progress
|
||||
@@ -596,6 +848,7 @@ class UpdateManager private constructor(private val context: Context)
|
||||
}
|
||||
finally
|
||||
{
|
||||
mDownloadConn = null
|
||||
conn.disconnect()
|
||||
cancelDownloadProgress()
|
||||
}
|
||||
@@ -693,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
|
||||
conn.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
conn.readTimeout = READ_TIMEOUT_MS
|
||||
conn.setRequestProperty("Connection", "close")
|
||||
conn.setRequestProperty("Cache-Control", "no-cache")
|
||||
applyS3Auth(conn, url)
|
||||
if (signS3)
|
||||
{
|
||||
applyS3Auth(conn, url)
|
||||
}
|
||||
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,99 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.Settings
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import org.strongswan.android.logic.events.EventAction
|
||||
import org.strongswan.android.logic.events.EventLog
|
||||
import org.strongswan.android.logic.events.ShxEvent
|
||||
import org.strongswan.android.logic.routing.RoutingManager
|
||||
import org.strongswan.android.ui.compose.InboxScreen
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
/** «Events»: what the app noticed and did by itself, opened from the bell on Home and from problem notifications. */
|
||||
class InboxActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
InboxScreen(onBack = { finish() }, onAction = ::perform, onNotificationSettings = ::openNotificationSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop()
|
||||
{
|
||||
/* everything shown counts as seen once the user leaves the screen */
|
||||
EventLog.getInstance(this).markAllRead()
|
||||
super.onStop()
|
||||
}
|
||||
|
||||
private fun perform(event: ShxEvent)
|
||||
{
|
||||
val action = event.action
|
||||
if (action == EventAction.SET_APP_DIRECT)
|
||||
{
|
||||
event.target?.let { RoutingManager.getInstance(this).setAppDirect(it, true) }
|
||||
EventLog.getInstance(this).answer(event.id)
|
||||
return
|
||||
}
|
||||
val main = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
when (action)
|
||||
{
|
||||
EventAction.NONE, EventAction.SET_APP_DIRECT -> return
|
||||
EventAction.OPEN_HOME -> startActivity(main)
|
||||
EventAction.OPEN_ROUTING -> startActivity(main.putExtra(MainActivity.EXTRA_OPEN_ROUTING, true))
|
||||
EventAction.INSTALL_UPDATE -> startActivity(main.putExtra(MainActivity.EXTRA_INSTALL_UPDATE, true))
|
||||
EventAction.OPEN_ROUTE_CHECK -> startActivity(Intent(this, RouteCheckActivity::class.java))
|
||||
EventAction.PROFILE_UPDATE -> event.target?.let { startActivity(ServerActivity.intent(this, it, checkUpdate = true)) }
|
||||
EventAction.OPEN_SERVER -> event.target?.let { startActivity(ServerActivity.intent(this, it)) }
|
||||
EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java))
|
||||
}
|
||||
if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE)
|
||||
{
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openNotificationSettings()
|
||||
{
|
||||
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
|
||||
{
|
||||
EventLog.ensureChannel(this)
|
||||
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
|
||||
}
|
||||
else
|
||||
{
|
||||
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, android.net.Uri.fromParts("package", packageName, null))
|
||||
}
|
||||
try
|
||||
{
|
||||
startActivity(intent)
|
||||
}
|
||||
catch (_: ActivityNotFoundException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,11 @@
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.IBinder
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
@@ -20,11 +24,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
@@ -35,13 +35,18 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -53,12 +58,31 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxChip
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxNote
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.LogContentProvider
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.DiagLog
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import java.io.BufferedReader
|
||||
import java.io.File
|
||||
@@ -68,18 +92,49 @@ class LogActivity : AppCompatActivity()
|
||||
{
|
||||
private val messages = kotlinx.coroutines.flow.MutableSharedFlow<Int>(extraBufferCapacity = 1)
|
||||
|
||||
/* the active profile goes into the report meta */
|
||||
private var vpn: VpnStateService? = null
|
||||
private val connection = object : ServiceConnection
|
||||
{
|
||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?)
|
||||
{
|
||||
vpn = (binder as VpnStateService.LocalBinder).service
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(name: ComponentName?)
|
||||
{
|
||||
vpn = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
bindService(Intent(this, VpnStateService::class.java), connection, Context.BIND_AUTO_CREATE)
|
||||
setContent {
|
||||
ShxTheme {
|
||||
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 */
|
||||
LogScreen(onBack = { finish() }, onSend = { if (ReportViewModel.apiReady(this)) report.open() else sendLog("") }, messages = messages)
|
||||
ReportSheet(report, profile = ::activeProfile, onMail = { comment -> report.close(); sendLog(comment) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendLog()
|
||||
override fun onDestroy()
|
||||
{
|
||||
unbindService(connection)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun activeProfile(): String
|
||||
{
|
||||
val profile = vpn?.profile ?: return ""
|
||||
return listOfNotNull(profile.name, profile.gateway).filter { it.isNotBlank() }.distinct().joinToString(" · ")
|
||||
}
|
||||
|
||||
private fun sendLog(comment: String)
|
||||
{
|
||||
/* the daemon log plus the lifecycle log; the latter survives process
|
||||
* deaths and documents watchdog/reconnect decisions */
|
||||
@@ -105,6 +160,10 @@ class LogActivity : AppCompatActivity()
|
||||
intent.putExtra(Intent.EXTRA_EMAIL, arrayOf(MainActivity.CONTACT_EMAIL))
|
||||
intent.putExtra(Intent.EXTRA_SUBJECT, getString(R.string.log_mail_subject, version))
|
||||
intent.type = "text/plain"
|
||||
if (comment.isNotBlank())
|
||||
{
|
||||
intent.putExtra(Intent.EXTRA_TEXT, comment)
|
||||
}
|
||||
if (uris.size > 1)
|
||||
{
|
||||
intent.putExtra(Intent.EXTRA_STREAM, uris)
|
||||
@@ -122,8 +181,15 @@ class LogViewModel : ViewModel()
|
||||
private val _lines = MutableStateFlow<List<String>>(emptyList())
|
||||
val lines: StateFlow<List<String>> = _lines
|
||||
|
||||
/* tail only while the screen is shown (set by LogScreen) */
|
||||
private val active = MutableStateFlow(false)
|
||||
private var started = false
|
||||
|
||||
fun setActive(value: Boolean)
|
||||
{
|
||||
active.value = value
|
||||
}
|
||||
|
||||
fun start(file: File)
|
||||
{
|
||||
if (started) return
|
||||
@@ -131,34 +197,64 @@ class LogViewModel : ViewModel()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val buffer = ArrayList<String>()
|
||||
var reader: BufferedReader? = null
|
||||
var lastLength = 0L
|
||||
suspend fun flush()
|
||||
{
|
||||
if (buffer.isEmpty())
|
||||
{
|
||||
return
|
||||
}
|
||||
val batch = buffer.toList()
|
||||
buffer.clear()
|
||||
withContext(Dispatchers.Main) {
|
||||
val merged = _lines.value + batch
|
||||
/* bounded: a long session must not grow the list forever */
|
||||
_lines.value = if (merged.size > MAX_LINES) merged.takeLast(MAX_LINES) else merged
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
reader = if (file.exists()) BufferedReader(FileReader(file)) else null
|
||||
while (isActive)
|
||||
{
|
||||
val line = reader?.readLine()
|
||||
if (!active.value)
|
||||
{
|
||||
active.first { it }
|
||||
}
|
||||
if (reader == null)
|
||||
{ /* the log may not exist yet (no connection so far) */
|
||||
if (file.exists())
|
||||
{
|
||||
reader = BufferedReader(FileReader(file))
|
||||
lastLength = 0L
|
||||
}
|
||||
else
|
||||
{
|
||||
delay(POLL_MS)
|
||||
continue
|
||||
}
|
||||
}
|
||||
val line = reader.readLine()
|
||||
if (line == null)
|
||||
{
|
||||
if (buffer.isNotEmpty())
|
||||
{
|
||||
val batch = buffer.toList()
|
||||
buffer.clear()
|
||||
withContext(Dispatchers.Main) {
|
||||
_lines.value = _lines.value + batch
|
||||
}
|
||||
flush()
|
||||
val length = if (file.exists()) file.length() else -1L
|
||||
if (length < lastLength)
|
||||
{ /* deleted or truncated (a new connection starts a fresh log):
|
||||
* the reader would stay on the old file forever */
|
||||
try { reader.close() } catch (_: Exception) {}
|
||||
reader = null
|
||||
withContext(Dispatchers.Main) { _lines.value = emptyList() }
|
||||
continue
|
||||
}
|
||||
delay(800)
|
||||
lastLength = length
|
||||
delay(POLL_MS)
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer.add(line)
|
||||
if (buffer.size >= 50)
|
||||
{
|
||||
val batch = buffer.toList()
|
||||
buffer.clear()
|
||||
withContext(Dispatchers.Main) {
|
||||
_lines.value = _lines.value + batch
|
||||
}
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -172,20 +268,44 @@ class LogViewModel : ViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object
|
||||
{
|
||||
const val MAX_LINES = 5000
|
||||
const val POLL_MS = 800L
|
||||
}
|
||||
}
|
||||
|
||||
/* F_Log: «Important» (default), «All», «Errors» */
|
||||
private enum class LogLevelFilter(val labelRes: Int)
|
||||
{
|
||||
ALL(R.string.log_filter_all),
|
||||
WARN(R.string.log_filter_warn),
|
||||
IMPORTANT(R.string.log_filter_important),
|
||||
ALL(R.string.log_filter_everything),
|
||||
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
|
||||
private fun LogScreen(
|
||||
onBack: () -> Unit,
|
||||
@@ -195,91 +315,134 @@ private fun LogScreen(
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
val allLines by viewModel.lines.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
||||
var filter by rememberSaveable { mutableStateOf(LogLevelFilter.ALL) }
|
||||
var filter by rememberSaveable { mutableStateOf(LogLevelFilter.IMPORTANT) }
|
||||
val lines = remember(allLines, filter) {
|
||||
when (filter)
|
||||
{
|
||||
LogLevelFilter.IMPORTANT -> allLines.filter { it.isImportantLine() }
|
||||
LogLevelFilter.ALL -> allLines
|
||||
LogLevelFilter.WARN -> allLines.filter { it.isErrorLine() || it.isWarnLine() }
|
||||
LogLevelFilter.ERROR -> allLines.filter { it.isErrorLine() }
|
||||
}
|
||||
}
|
||||
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.start(File(context.filesDir, CharonVpnService.LOG_FILE))
|
||||
}
|
||||
LaunchedEffect(lifecycle) {
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
viewModel.setActive(true)
|
||||
try
|
||||
{
|
||||
awaitCancellation()
|
||||
}
|
||||
finally
|
||||
{
|
||||
viewModel.setActive(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(messages) {
|
||||
messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
|
||||
}
|
||||
/* follow the tail only while the user is at the bottom (or switched the
|
||||
* filter): scrolling up to read must not be undone by every new batch */
|
||||
var followedSize by remember { mutableStateOf(0) }
|
||||
var followedFilter by remember { mutableStateOf(filter) }
|
||||
LaunchedEffect(lines.size, filter) {
|
||||
if (lines.isNotEmpty())
|
||||
{
|
||||
listState.scrollToItem(lines.lastIndex)
|
||||
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
|
||||
if (filter != followedFilter || followedSize == 0 || lastVisible >= followedSize - 2 ||
|
||||
lines.size < followedSize)
|
||||
{
|
||||
listState.scrollToItem(lines.lastIndex)
|
||||
}
|
||||
}
|
||||
followedSize = lines.size
|
||||
followedFilter = filter
|
||||
}
|
||||
val copied = stringResource(R.string.log_copied)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
SecondaryScaffold(
|
||||
title = stringResource(R.string.log_title),
|
||||
onBack = onBack,
|
||||
actions = {
|
||||
IconButton(onClick = onSend) {
|
||||
Icon(Icons.Default.Share, contentDescription = stringResource(R.string.send_log))
|
||||
/* 44dp tonal circle of the mockup header */
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(44.dp).clip(CircleShape).background(c.tile)
|
||||
.clickable(role = Role.Button) {
|
||||
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("log", lines.joinToString("\n")))
|
||||
scope.launch { snackbarHostState.showSnackbar(copied) }
|
||||
},
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Doc, contentDescription = stringResource(R.string.log_copy), tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
},
|
||||
snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) },
|
||||
) { padding ->
|
||||
Column(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(
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
|
||||
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
LogLevelFilter.entries.forEach { level ->
|
||||
androidx.compose.material3.FilterChip(
|
||||
selected = filter == level,
|
||||
onClick = { filter = level },
|
||||
label = { Text(stringResource(level.labelRes)) },
|
||||
)
|
||||
ShxChip(stringResource(level.labelRes), filter == level, { filter = level })
|
||||
}
|
||||
}
|
||||
if (lines.isEmpty())
|
||||
/* the dark console: grey time, coloured message */
|
||||
Box(Modifier.weight(1f).fillMaxWidth().clip(RoundedCornerShape(22.dp)).background(Color(0xFF1C1B20)))
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.empty_log),
|
||||
modifier = Modifier.padding(ShxSpacing.xl),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = ShxSpacing.md),
|
||||
) {
|
||||
items(lines.size) { index ->
|
||||
val line = lines[index]
|
||||
val color = when
|
||||
{
|
||||
line.isErrorLine() -> MaterialTheme.colorScheme.error
|
||||
line.isWarnLine() -> MaterialTheme.colorScheme.tertiary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
if (lines.isEmpty())
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.empty_log), style = LOG_STYLE, color = Color(0xFF79767F),
|
||||
modifier = Modifier.padding(14.dp),
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(14.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(lines.size) { index ->
|
||||
val line = lines[index]
|
||||
val (time, message) = splitLine(line)
|
||||
val color = when
|
||||
{
|
||||
line.isErrorLine() || line.isWarnLine() -> Color(0xFFFFB869)
|
||||
line.isGoodLine() -> Color(0xFF7CE3B0)
|
||||
else -> Color(0xFFE5E1E9)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
if (time.isNotEmpty())
|
||||
{
|
||||
Text(time, style = LOG_STYLE, color = Color(0xFF79767F))
|
||||
}
|
||||
Text(message, style = LOG_STYLE, color = color)
|
||||
}
|
||||
}
|
||||
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)
|
||||
@@ -21,6 +21,9 @@ import android.os.Bundle;
|
||||
|
||||
import org.strongswan.android.logic.updates.UpdateManager;
|
||||
import org.strongswan.android.ui.compose.ShxAppKt;
|
||||
import org.strongswan.android.ui.compose.ShxNavigation;
|
||||
import org.strongswan.android.ui.compose.ShxTab;
|
||||
import android.service.quicksettings.TileService;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.splashscreen.SplashScreen;
|
||||
@@ -35,6 +38,31 @@ public class MainActivity extends AppCompatActivity
|
||||
*/
|
||||
public static final String EXTRA_INSTALL_UPDATE = "org.strongswan.android.extra.INSTALL_UPDATE";
|
||||
|
||||
/**
|
||||
* Set as an intent extra to open the Routing tab (e.g. from the profile form)
|
||||
*/
|
||||
public static final String EXTRA_OPEN_ROUTING = "org.strongswan.android.extra.OPEN_ROUTING";
|
||||
|
||||
/**
|
||||
* Set as an intent extra to open the «Add server» sheet (end of the first-run screens)
|
||||
*/
|
||||
public static final String EXTRA_ADD_SERVER = "org.strongswan.android.extra.ADD_SERVER";
|
||||
|
||||
/**
|
||||
* Set as an intent extra to open the Servers tab (the «Change» action of the notification)
|
||||
*/
|
||||
public static final String EXTRA_OPEN_SERVERS = "org.strongswan.android.extra.OPEN_SERVERS";
|
||||
|
||||
/**
|
||||
* Set as an intent extra to connect the fastest server (the «Fastest» button of the widget)
|
||||
*/
|
||||
public static final String EXTRA_CONNECT_FASTEST = "org.strongswan.android.extra.CONNECT_FASTEST";
|
||||
|
||||
/**
|
||||
* Set as an intent extra to show the Updates page (the update notification)
|
||||
*/
|
||||
public static final String EXTRA_OPEN_UPDATES = "org.strongswan.android.extra.OPEN_UPDATES";
|
||||
|
||||
/**
|
||||
* Use "bring your own device" (BYOD) features
|
||||
*/
|
||||
@@ -47,7 +75,13 @@ public class MainActivity extends AppCompatActivity
|
||||
SplashScreen.Companion.installSplashScreen(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
handleInstallUpdate(getIntent());
|
||||
handleOpenRouting(getIntent());
|
||||
handleAddServer(getIntent());
|
||||
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
|
||||
@@ -62,8 +96,19 @@ public class MainActivity extends AppCompatActivity
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent)
|
||||
{
|
||||
handleAddServer(intent);
|
||||
super.onNewIntent(intent);
|
||||
handleInstallUpdate(intent);
|
||||
handleOpenRouting(intent);
|
||||
}
|
||||
|
||||
private void handleOpenRouting(Intent intent)
|
||||
{
|
||||
if (intent != null && intent.getBooleanExtra(EXTRA_OPEN_ROUTING, false))
|
||||
{
|
||||
intent.removeExtra(EXTRA_OPEN_ROUTING);
|
||||
ShxNavigation.INSTANCE.request(ShxTab.ROUTING);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleInstallUpdate(Intent intent)
|
||||
@@ -74,4 +119,33 @@ public class MainActivity extends AppCompatActivity
|
||||
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.rememberScrollState
|
||||
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.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
@@ -61,6 +57,10 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.components.PredictiveBackScale
|
||||
import org.strongswan.android.logic.imc.RemediationInstruction
|
||||
import org.strongswan.android.ui.compose.components.ShxCard
|
||||
@@ -85,7 +85,7 @@ class RemediationInstructionsActivity : AppCompatActivity()
|
||||
|
||||
setContent {
|
||||
ShxTheme {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
|
||||
Surface(Modifier.fillMaxSize(), color = LocalShxColors.current.background) {
|
||||
RemediationUi(
|
||||
instructions = loadInstructions(),
|
||||
selected = selected,
|
||||
@@ -132,25 +132,9 @@ private fun RemediationUi(
|
||||
{
|
||||
Column(Modifier.fillMaxSize())
|
||||
{
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
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,
|
||||
),
|
||||
ShxPageHeader(
|
||||
selected?.getTitle() ?: stringResource(R.string.remediation_instructions_title),
|
||||
onBack = { if (selected != null) onSelect(null) else onFinish() },
|
||||
)
|
||||
AnimatedContent(
|
||||
targetState = selected,
|
||||
@@ -167,7 +151,7 @@ private fun RemediationUi(
|
||||
if (instructions.isEmpty())
|
||||
{
|
||||
ShxEmptyState(
|
||||
icon = Icons.Rounded.CheckCircle,
|
||||
icon = ShxIcons.Check,
|
||||
message = stringResource(R.string.remediation_instructions_title),
|
||||
modifier = Modifier.padding(ShxSpacing.lg),
|
||||
)
|
||||
@@ -190,14 +174,14 @@ private fun RemediationUi(
|
||||
.clickable { onSelect(item) }
|
||||
.padding(ShxSpacing.lg),
|
||||
) {
|
||||
Text(item.getTitle(), style = MaterialTheme.typography.titleMedium)
|
||||
Text(item.getTitle(), style = ShxType.cardTitle)
|
||||
if (!item.getDescription().isNullOrEmpty())
|
||||
{
|
||||
Spacer(Modifier.height(ShxSpacing.xs))
|
||||
Text(
|
||||
item.getDescription(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = ShxType.note,
|
||||
color = LocalShxColors.current.textSub,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -216,15 +200,15 @@ private fun RemediationUi(
|
||||
) {
|
||||
Text(
|
||||
instruction.getDescription(),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = ShxType.note,
|
||||
modifier = Modifier.padding(top = ShxSpacing.sm),
|
||||
)
|
||||
if (!instruction.getHeader().isNullOrEmpty())
|
||||
{
|
||||
Text(
|
||||
instruction.getHeader(),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = ShxType.section,
|
||||
color = LocalShxColors.current.textSub,
|
||||
modifier = Modifier.padding(top = 20.dp, bottom = ShxSpacing.sm),
|
||||
)
|
||||
ShxCard {
|
||||
@@ -232,7 +216,7 @@ private fun RemediationUi(
|
||||
instruction.getItems().forEachIndexed { index, item ->
|
||||
Text(
|
||||
item,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = ShxType.body,
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
||||
)
|
||||
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
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.RouteCheckMode
|
||||
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
|
||||
|
||||
/** Standalone route diagnostics screen, opened from the Routing tab. */
|
||||
/** «How traffic goes»: route of a site, an address or an app. */
|
||||
class RouteCheckActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val mode = RouteCheckMode.entries.getOrElse(intent.getIntExtra(EXTRA_MODE, 0)) { RouteCheckMode.SITE }
|
||||
val query = intent.getStringExtra(EXTRA_QUERY).orEmpty()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
SecondaryScaffold(title = stringResource(R.string.route_check_title), onBack = { finish() }) { padding ->
|
||||
Box(Modifier.padding(padding)) {
|
||||
RouteCheckScreen()
|
||||
}
|
||||
Box(Modifier.fillMaxSize().background(LocalShxColors.current.background))
|
||||
{
|
||||
RouteCheckScreen(onBack = { finish() }, initialMode = mode, initialQuery = query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object
|
||||
{
|
||||
private const val EXTRA_MODE = "mode"
|
||||
private const val EXTRA_QUERY = "query"
|
||||
|
||||
fun intent(context: Context, mode: RouteCheckMode, query: String): Intent =
|
||||
Intent(context, RouteCheckActivity::class.java).putExtra(EXTRA_MODE, mode.ordinal).putExtra(EXTRA_QUERY, query)
|
||||
}
|
||||
}
|
||||
+737
@@ -0,0 +1,737 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.Manifest
|
||||
import android.app.TimePickerDialog
|
||||
import android.app.usage.UsageStatsManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.logic.autoconnect.AutoConnectManager
|
||||
import org.strongswan.android.logic.autoconnect.SsidReader
|
||||
import org.strongswan.android.logic.routing.AppTraffic
|
||||
import org.strongswan.android.logic.scenarios.Scenario
|
||||
import org.strongswan.android.logic.scenarios.ScenarioAction
|
||||
import org.strongswan.android.logic.scenarios.ScenarioEngine
|
||||
import org.strongswan.android.logic.scenarios.ScenarioTrigger
|
||||
import org.strongswan.android.ui.compose.design.ShxButton
|
||||
import org.strongswan.android.ui.compose.design.ShxButtonStyle
|
||||
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.design.ShxPageHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxPanel
|
||||
import org.strongswan.android.ui.compose.design.ShxSectionHeader
|
||||
import org.strongswan.android.ui.compose.design.ShxSheet
|
||||
import org.strongswan.android.ui.compose.design.ShxSwitch
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.utils.Constants
|
||||
|
||||
/** «Scenarios» (F_Automation): the built-in network rules, the user scenarios, trusted networks. */
|
||||
class ScenariosActivity : AppCompatActivity()
|
||||
{
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
ShxTheme {
|
||||
ScenariosScreen(onBack = { finish() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadProfiles(context: Context): List<VpnProfile>
|
||||
{
|
||||
val source = VpnProfileSource(context)
|
||||
source.open()
|
||||
try
|
||||
{
|
||||
return source.allVpnProfiles
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScenariosScreen(onBack: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
val auto = remember { AutoConnectManager.getInstance(context) }
|
||||
var config by remember { mutableStateOf(auto.currentConfig) }
|
||||
val scenarios by remember { ScenarioEngine.flow(context) }.collectAsStateWithLifecycle()
|
||||
var editing by remember { mutableStateOf<Scenario?>(null) }
|
||||
var pickRulesServer by remember { mutableStateOf(false) }
|
||||
val orderChanges by ScenarioEngine.orderChanges.collectAsStateWithLifecycle()
|
||||
var creating by remember { mutableStateOf(false) }
|
||||
var resumes by remember { mutableIntStateOf(0) }
|
||||
val owner = LocalLifecycleOwner.current
|
||||
DisposableEffect(owner) {
|
||||
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) { resumes++; config = auto.currentConfig } }
|
||||
owner.lifecycle.addObserver(observer)
|
||||
onDispose { owner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
val profiles by produceState(emptyList<VpnProfile>(), resumes) { value = withContext(Dispatchers.IO) { loadProfiles(context) } }
|
||||
|
||||
fun updateAuto(transform: (AutoConnectManager.Config) -> AutoConnectManager.Config)
|
||||
{
|
||||
auto.updateConfig { current ->
|
||||
var next = transform(current)
|
||||
next = next.copy(enabled = next.parkOnTrusted || next.connectOnUntrusted)
|
||||
if (next.enabled && next.profileUuid == null)
|
||||
{ /* the rules need a server: the last used, else the first */
|
||||
val mru = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context).getString(Constants.PREF_MRU_VPN_PROFILE, null)
|
||||
next = next.copy(profileUuid = mru ?: profiles.firstOrNull()?.getUUID()?.toString())
|
||||
}
|
||||
next
|
||||
}
|
||||
config = auto.currentConfig
|
||||
}
|
||||
/* «+ Add current network» needs the location permission to read the SSID */
|
||||
var pendingAdd by remember { mutableStateOf(false) }
|
||||
fun addCurrentNetwork()
|
||||
{
|
||||
val ssid = SsidReader.from(context)
|
||||
if (ssid != null)
|
||||
{
|
||||
updateAuto { it.copy(trustedSsids = it.trustedSsids + ssid) }
|
||||
}
|
||||
else
|
||||
{
|
||||
android.widget.Toast.makeText(context, R.string.scenarios_no_wifi, android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
if (pendingAdd)
|
||||
{
|
||||
pendingAdd = false
|
||||
addCurrentNetwork()
|
||||
}
|
||||
}
|
||||
|
||||
Box(Modifier.fillMaxSize().background(c.background))
|
||||
{
|
||||
Column(Modifier.fillMaxSize().statusBarsPadding().verticalScroll(rememberScrollState()).navigationBarsPadding().padding(bottom = 104.dp))
|
||||
{
|
||||
ShxPageHeader(stringResource(R.string.scenarios_title), onBack = onBack)
|
||||
Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.scenarios_intro), style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal),
|
||||
color = c.textSoft, modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
|
||||
)
|
||||
/* one order for the network rules and the scenarios: the top one that applies wins */
|
||||
val order = remember(scenarios, orderChanges) { ScenarioEngine.order(context) }
|
||||
order.forEach { id ->
|
||||
when (id)
|
||||
{
|
||||
org.strongswan.android.logic.scenarios.BUILTIN_TRUSTED ->
|
||||
RuleCard(ShxIcons.Wifi, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_trusted), stringResource(R.string.scenario_do_off), config.enabled && config.parkOnTrusted, null)
|
||||
{ on -> updateAuto { it.copy(parkOnTrusted = on, connectOnUntrusted = if (on && !it.enabled) true else it.connectOnUntrusted) } }
|
||||
org.strongswan.android.logic.scenarios.BUILTIN_UNTRUSTED ->
|
||||
RuleCard(ShxIcons.Phone, c.accentContainer, c.onAccentContainer, stringResource(R.string.scenarios_rule_untrusted), stringResource(R.string.scenario_do_on), config.enabled && config.connectOnUntrusted, null)
|
||||
{ on -> updateAuto { it.copy(connectOnUntrusted = on, parkOnTrusted = if (on && !it.enabled) true else it.parkOnTrusted) } }
|
||||
else -> scenarios.firstOrNull { it.id == id }?.let { s ->
|
||||
val (icon, bg, fg) = triggerLook(s.trigger)
|
||||
RuleCard(icon, bg, fg, ScenarioEngine.describeWhen(context, s), stringResource(ScenarioEngine.actionLabel(s.action)), s.enabled, onClick = { editing = s })
|
||||
{ on -> ScenarioEngine.save(context, scenarios.map { if (it.id == s.id) it.copy(enabled = on) else it }) }
|
||||
}
|
||||
}
|
||||
}
|
||||
/* the server the two network rules connect (was the old «Trusted networks» screen) */
|
||||
if (config.enabled)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(18.dp))
|
||||
.clickable(role = Role.Button) { pickRulesServer = true }.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.scenarios_rules_server), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
profiles.firstOrNull { it.getUUID().toString() == config.profileUuid }?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto),
|
||||
style = ShxType.body, color = c.textSub, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
ShxSectionHeader(stringResource(R.string.scenarios_trusted).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 10.dp))
|
||||
ShxPanel()
|
||||
{
|
||||
config.trustedSsids.sorted().forEach { ssid ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().height(56.dp).padding(start = 16.dp, end = 8.dp))
|
||||
{
|
||||
Icon(ShxIcons.Wifi, contentDescription = null, tint = c.onAccentContainer, modifier = Modifier.size(20.dp))
|
||||
Text(ssid, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(40.dp).clip(CircleShape).clickable(role = Role.Button) { updateAuto { it.copy(trustedSsids = it.trustedSsids - ssid) } },
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Close, contentDescription = stringResource(R.string.scenarios_remove_net), tint = c.iconMuted, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp).clickable(role = Role.Button) {
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED)
|
||||
{
|
||||
pendingAdd = true
|
||||
permission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
else addCurrentNetwork()
|
||||
},
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.scenarios_add_current), style = ShxType.body.copy(fontSize = 15.sp, fontWeight = FontWeight.Bold), color = c.accentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/* «+ Scenario» */
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 24.dp).height(60.dp)
|
||||
.shadow(10.dp, RoundedCornerShape(20.dp), ambientColor = c.accent, spotColor = c.accent)
|
||||
.clip(RoundedCornerShape(20.dp)).background(c.accent).clickable(role = Role.Button) { creating = true }.padding(horizontal = 22.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.scenarios_add), style = ShxType.button.copy(fontSize = 16.sp), color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
if (pickRulesServer)
|
||||
{
|
||||
ShxSheet(onDismiss = { pickRulesServer = false })
|
||||
{
|
||||
Text(stringResource(R.string.scenarios_rules_server_pick), style = ShxType.note.copy(fontWeight = FontWeight.Normal, lineHeight = 19.sp), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp))
|
||||
ShxPanel()
|
||||
{
|
||||
profiles.forEachIndexed { i, p ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable {
|
||||
updateAuto { it.copy(profileUuid = p.getUUID().toString()) }
|
||||
pickRulesServer = false
|
||||
}.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(p.name ?: p.gateway, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||
if (config.profileUuid == p.getUUID().toString())
|
||||
{
|
||||
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
if (i < profiles.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (creating || editing != null)
|
||||
{
|
||||
ScenarioSheet(
|
||||
initial = editing,
|
||||
profiles = profiles,
|
||||
onDismiss = { creating = false; editing = null },
|
||||
onSave = { saved ->
|
||||
val list = if (scenarios.any { it.id == saved.id }) scenarios.map { if (it.id == saved.id) saved else it } else scenarios + saved
|
||||
ScenarioEngine.save(context, list)
|
||||
creating = false
|
||||
editing = null
|
||||
},
|
||||
onDelete = editing?.let { e -> { ScenarioEngine.save(context, scenarios.filter { it.id != e.id }); editing = null } },
|
||||
onMove = editing?.let { e ->
|
||||
{ delta: Int ->
|
||||
ScenarioEngine.move(context, e.id, delta)
|
||||
editing = null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun triggerLook(trigger: ScenarioTrigger): Triple<ImageVector, Color, Color>
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
return when (trigger)
|
||||
{
|
||||
ScenarioTrigger.WIFI -> Triple(ShxIcons.Wifi, c.accentContainer, c.onAccentContainer)
|
||||
ScenarioTrigger.MOBILE -> Triple(ShxIcons.Phone, c.accentContainer, c.onAccentContainer)
|
||||
ScenarioTrigger.APP -> Triple(ShxIcons.Apps, c.warningStrong, c.onWarning)
|
||||
ScenarioTrigger.TIME -> Triple(ShxIcons.Clock, c.tile, c.textSoft)
|
||||
ScenarioTrigger.ROAMING -> Triple(ShxIcons.Globe, c.accentContainer, c.onAccentContainer)
|
||||
ScenarioTrigger.WHITELIST -> Triple(ShxIcons.Alert, c.warningStrong, c.onWarning)
|
||||
}
|
||||
}
|
||||
|
||||
/** One rule of F_Automation: icon well, «when», «→ then», switch. */
|
||||
@Composable
|
||||
private fun RuleCard(icon: ImageVector, bg: Color, fg: Color, whenText: String, thenText: String, on: Boolean, onClick: (() -> Unit)?, onToggle: (Boolean) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(shape).background(c.card).border(1.dp, c.cardBorder, shape)
|
||||
.then(if (onClick != null) Modifier.clickable(role = Role.Button, onClick = onClick) else Modifier).padding(14.dp),
|
||||
)
|
||||
{
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(RoundedCornerShape(14.dp)).background(bg))
|
||||
{
|
||||
Icon(icon, contentDescription = null, tint = fg, modifier = Modifier.size(22.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp))
|
||||
{
|
||||
Text(whenText, style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.textSub, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp))
|
||||
{
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.accent, modifier = Modifier.size(16.dp))
|
||||
Text(thenText, style = ShxType.cardTitle, color = c.text)
|
||||
}
|
||||
}
|
||||
ShxSwitch(on, onToggle)
|
||||
}
|
||||
}
|
||||
|
||||
/** «New scenario» (F_ScenarioAdd): when, what, server, the summary. */
|
||||
@Composable
|
||||
private fun ScenarioSheet(
|
||||
initial: Scenario?,
|
||||
profiles: List<VpnProfile>,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (Scenario) -> Unit,
|
||||
onDelete: (() -> Unit)?,
|
||||
onMove: ((Int) -> Unit)?,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
var trigger by remember { mutableStateOf(initial?.trigger ?: ScenarioTrigger.APP) }
|
||||
var action by remember { mutableStateOf(initial?.action ?: ScenarioAction.VPN_ON) }
|
||||
var packages by remember { mutableStateOf(initial?.packages ?: emptySet()) }
|
||||
var ssids by remember { mutableStateOf(initial?.ssids?.joinToString(", ") ?: SsidReader.from(context).orEmpty()) }
|
||||
var start by remember { mutableIntStateOf(initial?.startMinute ?: 60) }
|
||||
var end by remember { mutableIntStateOf(initial?.endMinute ?: 7 * 60) }
|
||||
var profile by remember { mutableStateOf(initial?.profileUuid) }
|
||||
var picking by remember { mutableStateOf(false) }
|
||||
var pickProfile by remember { mutableStateOf(false) }
|
||||
val draft = Scenario(
|
||||
id = initial?.id ?: java.util.UUID.randomUUID().toString(),
|
||||
enabled = initial?.enabled ?: true,
|
||||
trigger = trigger,
|
||||
ssids = ssids.split(',').map { it.trim() }.filter { it.isNotEmpty() }.toSet(),
|
||||
packages = packages,
|
||||
startMinute = start,
|
||||
endMinute = end,
|
||||
action = action,
|
||||
profileUuid = profile?.takeIf { action == ScenarioAction.VPN_ON || action == ScenarioAction.SWITCH_SERVER },
|
||||
)
|
||||
val valid = when (trigger)
|
||||
{
|
||||
ScenarioTrigger.APP -> packages.isNotEmpty()
|
||||
ScenarioTrigger.TIME -> start != end
|
||||
ScenarioTrigger.WIFI -> true
|
||||
else -> true
|
||||
} && !(action == ScenarioAction.SWITCH_SERVER && profile == null)
|
||||
|
||||
ShxSheet(onDismiss = onDismiss)
|
||||
{
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp))
|
||||
{
|
||||
Text(stringResource(if (initial == null) R.string.scenario_new else R.string.scenario_edit), style = ShxType.sheetTitle, color = c.text, modifier = Modifier.padding(start = 4.dp, top = 4.dp))
|
||||
Text(stringResource(R.string.scenario_step_when), style = ShxType.section, color = c.accentText, modifier = Modifier.padding(horizontal = 4.dp))
|
||||
val whens = listOf(
|
||||
ScenarioTrigger.WIFI to R.string.scenario_t_wifi, ScenarioTrigger.MOBILE to R.string.scenario_t_mobile, ScenarioTrigger.APP to R.string.scenario_t_app,
|
||||
ScenarioTrigger.TIME to R.string.scenario_t_time, ScenarioTrigger.ROAMING to R.string.scenario_t_roaming, ScenarioTrigger.WHITELIST to R.string.scenario_t_whitelist,
|
||||
)
|
||||
whens.chunked(3).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
row.forEach { (t, label) ->
|
||||
val on = trigger == t
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp, Alignment.CenterVertically),
|
||||
modifier = Modifier.weight(1f).height(76.dp).clip(RoundedCornerShape(18.dp)).background(if (on) c.accentSoft else c.card)
|
||||
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, RoundedCornerShape(18.dp))
|
||||
.clickable(role = Role.RadioButton) { trigger = t }.padding(horizontal = 4.dp),
|
||||
)
|
||||
{
|
||||
Icon(triggerLook(t).first, contentDescription = null, tint = if (on) c.onAccentContainer else c.text, modifier = Modifier.size(20.dp))
|
||||
Text(stringResource(label), style = ShxType.meta.copy(fontWeight = FontWeight.Bold, lineHeight = 14.sp), color = if (on) c.onAccentContainer else c.text, textAlign = TextAlign.Center, maxLines = 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
when (trigger)
|
||||
{
|
||||
ScenarioTrigger.APP -> AppChips(packages, onPick = { picking = true }, onRemove = { packages = packages - it })
|
||||
ScenarioTrigger.WIFI -> ShxFormField(label = stringResource(R.string.scenario_ssids), value = ssids, onValueChange = { ssids = it }, placeholder = stringResource(R.string.scenario_ssids_hint))
|
||||
ScenarioTrigger.TIME -> Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
TimeBox(stringResource(R.string.scenario_from), start, Modifier.weight(1f)) { start = it }
|
||||
TimeBox(stringResource(R.string.scenario_to), end, Modifier.weight(1f)) { end = it }
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
Text(stringResource(R.string.scenario_step_what), style = ShxType.section, color = c.accentText, modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 6.dp))
|
||||
listOf(ScenarioAction.VPN_ON, ScenarioAction.VPN_OFF, ScenarioAction.PAUSE, ScenarioAction.SWITCH_SERVER).chunked(2).forEach { row ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
row.forEach { a ->
|
||||
val on = action == a
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.weight(1f).height(52.dp).clip(RoundedCornerShape(16.dp)).background(if (on) c.accentSoft else c.card)
|
||||
.border(if (on) 2.dp else 1.dp, if (on) c.accent else c.cardBorder, RoundedCornerShape(16.dp))
|
||||
.clickable(role = Role.RadioButton) { action = a },
|
||||
)
|
||||
{
|
||||
Text(stringResource(ScenarioEngine.actionLabel(a)), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = if (on) c.onAccentContainer else c.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (action == ScenarioAction.VPN_ON || action == ScenarioAction.SWITCH_SERVER)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(18.dp))
|
||||
.clickable(role = Role.Button) { pickProfile = true }.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.scenario_server), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
profiles.firstOrNull { it.getUUID().toString() == profile }?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto),
|
||||
style = ShxType.body, color = c.textSub,
|
||||
)
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.scenario_summary, ScenarioEngine.describeWhen(context, draft),
|
||||
midSentence(stringResource(ScenarioEngine.actionLabel(action)))),
|
||||
style = ShxType.note.copy(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal), color = c.onAccentContainer,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(18.dp)).background(c.accentSoft).padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
)
|
||||
if (trigger == ScenarioTrigger.APP && !AppTraffic.hasAccess(context))
|
||||
{
|
||||
Text(stringResource(R.string.scenario_app_access), style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 17.sp), color = c.onWarningContainer,
|
||||
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(14.dp)).background(c.warningContainer).padding(12.dp))
|
||||
}
|
||||
if (onMove != null)
|
||||
{
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(R.string.scenario_raise), { onMove(-1) }, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.ArrowUp)
|
||||
ShxButton(stringResource(R.string.scenario_lower), { onMove(1) }, style = ShxButtonStyle.SOFT, height = 48.dp, modifier = Modifier.weight(1f), icon = ShxIcons.ArrowDown)
|
||||
}
|
||||
}
|
||||
if (onDelete != null)
|
||||
{
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
onDelete?.let { ShxButton(stringResource(R.string.server_delete_confirm), it, style = ShxButtonStyle.DANGER, height = 48.dp, modifier = Modifier.weight(1f)) }
|
||||
}
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
ShxButton(stringResource(android.R.string.cancel), onDismiss, style = ShxButtonStyle.OUTLINE, modifier = Modifier.weight(1f))
|
||||
ShxButton(stringResource(R.string.server_save), { if (valid) onSave(draft) }, modifier = Modifier.weight(2f), enabled = valid)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (picking)
|
||||
{
|
||||
AppPicker(packages, onDone = { packages = it; picking = false }, onDismiss = { picking = false })
|
||||
}
|
||||
if (pickProfile)
|
||||
{
|
||||
ShxSheet(onDismiss = { pickProfile = false })
|
||||
{
|
||||
ShxPanel()
|
||||
{
|
||||
(listOf<VpnProfile?>(null) + profiles).forEachIndexed { i, p ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().heightIn(min = 56.dp).clickable { profile = p?.getUUID()?.toString(); pickProfile = false }.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Text(p?.let { it.name ?: it.gateway } ?: stringResource(R.string.scenario_server_auto), style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, modifier = Modifier.weight(1f))
|
||||
if (profile == p?.getUUID()?.toString())
|
||||
{
|
||||
Icon(ShxIcons.Check, contentDescription = null, tint = c.accent, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
if (i < profiles.size)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TimeBox(label: String, minute: Int, modifier: Modifier, onPick: (Int) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = modifier.clip(RoundedCornerShape(16.dp)).background(c.card).border(1.dp, c.cardBorder, RoundedCornerShape(16.dp))
|
||||
.clickable(role = Role.Button) {
|
||||
TimePickerDialog(context, { _, h, m -> onPick(h * 60 + m) }, minute / 60, minute % 60, true).show()
|
||||
}.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
)
|
||||
{
|
||||
Text(label, style = ShxType.meta, color = c.textSub)
|
||||
Text(String.format("%02d:%02d", minute / 60, minute % 60), style = ShxType.cardHeading, color = c.text)
|
||||
}
|
||||
}
|
||||
|
||||
/** Chosen apps as chips with icons, plus «+ Choose». */
|
||||
@Composable
|
||||
private fun AppChips(packages: Set<String>, onPick: () -> Unit, onRemove: (String) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
val pm = context.packageManager
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(horizontal = 2.dp))
|
||||
{
|
||||
packages.forEach { pkg ->
|
||||
val info = remember(pkg) { runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull() }
|
||||
val label = remember(pkg) { info?.let { pm.getApplicationLabel(it).toString() } ?: pkg }
|
||||
val icon = remember(pkg) { info?.let { runCatching { pm.getApplicationIcon(it).toBitmap(72, 72).asImageBitmap() }.getOrNull() } }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(18.dp)).background(c.accentContainer).clickable { onRemove(pkg) }.padding(start = 6.dp, end = 12.dp),
|
||||
)
|
||||
{
|
||||
if (icon != null) Image(icon, contentDescription = null, modifier = Modifier.size(24.dp).clip(RoundedCornerShape(8.dp)))
|
||||
Text(label, style = ShxType.chip, color = c.onAccentContainer, maxLines = 1)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.height(36.dp).clip(RoundedCornerShape(18.dp)).border(1.dp, c.iconMuted, RoundedCornerShape(18.dp)).clickable(role = Role.Button, onClick = onPick).padding(horizontal = 12.dp),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.scenario_pick_apps), style = ShxType.chip, color = c.accentText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LaunchableApp(val pkg: String, val label: String, val icon: Drawable?)
|
||||
|
||||
/** Launchable apps, the most used of the last week first (with usage access). */
|
||||
private fun loadApps(context: Context): Pair<List<LaunchableApp>, Set<String>>
|
||||
{
|
||||
val pm = context.packageManager
|
||||
val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
val apps = pm.queryIntentActivities(intent, 0).map { it.activityInfo.packageName }.distinct()
|
||||
.filter { it != context.packageName }
|
||||
.map { pkg ->
|
||||
val info = runCatching { pm.getApplicationInfo(pkg, 0) }.getOrNull()
|
||||
LaunchableApp(pkg, info?.let { pm.getApplicationLabel(it).toString() } ?: pkg, info?.let { runCatching { pm.getApplicationIcon(it) }.getOrNull() })
|
||||
}
|
||||
val frequent = if (AppTraffic.hasAccess(context))
|
||||
{
|
||||
val usm = context.getSystemService(Context.USAGE_STATS_SERVICE) as? UsageStatsManager
|
||||
val now = System.currentTimeMillis()
|
||||
usm?.queryUsageStats(UsageStatsManager.INTERVAL_BEST, now - 7L * 24 * 60 * 60 * 1000, now)
|
||||
?.filter { it.totalTimeInForeground > 0 }?.sortedByDescending { it.totalTimeInForeground }
|
||||
?.map { it.packageName }?.filter { p -> apps.any { it.pkg == p } }?.distinct()?.take(7)?.toSet() ?: emptySet()
|
||||
}
|
||||
else emptySet()
|
||||
return apps.sortedBy { it.label.lowercase() } to frequent
|
||||
}
|
||||
|
||||
/** «Apps» (F_AppPicker): search, often opened first, checkboxes, Done. */
|
||||
@Composable
|
||||
private fun AppPicker(selected: Set<String>, onDone: (Set<String>) -> Unit, onDismiss: () -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
var chosen by remember { mutableStateOf(selected) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
val data by produceState<Pair<List<LaunchableApp>, Set<String>>?>(null) { value = withContext(Dispatchers.IO) { loadApps(context) } }
|
||||
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false))
|
||||
{
|
||||
Column(Modifier.fillMaxSize().background(c.background).statusBarsPadding().navigationBarsPadding())
|
||||
{
|
||||
ShxPageHeader(stringResource(R.string.stats_apps), onBack = onDismiss)
|
||||
{
|
||||
Text(stringResource(R.string.scenario_chosen, chosen.size), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.accentText, modifier = Modifier.padding(end = 8.dp))
|
||||
}
|
||||
Column(Modifier.weight(1f).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(26.dp)).background(c.tile).padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Search, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
Box(Modifier.weight(1f))
|
||||
{
|
||||
if (query.isEmpty())
|
||||
{
|
||||
Text(stringResource(R.string.scenario_search_apps), style = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium), color = c.textSub)
|
||||
}
|
||||
BasicTextField(query, { query = it }, singleLine = true, textStyle = ShxType.body.copy(fontSize = 16.sp, fontWeight = FontWeight.Medium, color = c.text), cursorBrush = SolidColor(c.accent), modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
val (apps, frequent) = data ?: (emptyList<LaunchableApp>() to emptySet())
|
||||
val shown = apps.filter { query.isBlank() || it.label.contains(query.trim(), ignoreCase = true) }
|
||||
val top = if (query.isBlank()) shown.filter { it.pkg in frequent }.sortedBy { frequent.indexOf(it.pkg) } else emptyList()
|
||||
val rest = shown - top.toSet()
|
||||
LazyColumn(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
if (top.isNotEmpty())
|
||||
{
|
||||
item { ShxSectionHeader(stringResource(R.string.scenario_often).uppercase(), Modifier.padding(horizontal = 6.dp)) }
|
||||
item { AppList(top, chosen) { pkg -> chosen = if (pkg in chosen) chosen - pkg else chosen + pkg } }
|
||||
item { ShxSectionHeader(stringResource(R.string.scenario_all_apps).uppercase(), Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp)) }
|
||||
}
|
||||
item { AppList(rest, chosen) { pkg -> chosen = if (pkg in chosen) chosen - pkg else chosen + pkg } }
|
||||
}
|
||||
ShxButton(stringResource(R.string.scenario_done), { onDone(chosen) }, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppList(apps: List<LaunchableApp>, chosen: Set<String>, onToggle: (String) -> Unit)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
if (apps.isEmpty()) return
|
||||
ShxPanel()
|
||||
{
|
||||
apps.forEachIndexed { i, app ->
|
||||
val icon = remember(app.pkg) { app.icon?.let { runCatching { it.toBitmap(96, 96).asImageBitmap() }.getOrNull() } }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(60.dp).clickable { onToggle(app.pkg) }.padding(start = 16.dp, end = 8.dp),
|
||||
)
|
||||
{
|
||||
Box(Modifier.size(36.dp).clip(RoundedCornerShape(10.dp)).background(c.tile), contentAlignment = Alignment.Center)
|
||||
{
|
||||
if (icon != null) Image(icon, contentDescription = null, modifier = Modifier.size(36.dp))
|
||||
else Text(app.label.take(1).uppercase(), style = ShxType.chip, color = c.textSoft)
|
||||
}
|
||||
Text(app.label, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = c.text, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
Checkbox(app.pkg in chosen, { onToggle(app.pkg) }, colors = CheckboxDefaults.colors(checkedColor = c.accent))
|
||||
}
|
||||
if (i < apps.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.tile))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** An action label inside the summary sentence: lower case, unless German (nouns) or it starts with an acronym like «VPN». */
|
||||
private fun midSentence(label: String): String =
|
||||
if (java.util.Locale.getDefault().language == "de" || label.getOrNull(1)?.isUpperCase() == true) label
|
||||
else label.replaceFirstChar { it.lowercase() }
|
||||
-213
@@ -1,213 +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
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.compose.foundation.clickable
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.ui.adapter.SelectedApplicationEntry
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
import java.util.TreeSet
|
||||
|
||||
class SelectedApplicationsActivity : AppCompatActivity()
|
||||
{
|
||||
private val selection = TreeSet<String>()
|
||||
private var readOnly = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?)
|
||||
{
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
readOnly = intent.getBooleanExtra(VpnProfileDataSource.KEY_READ_ONLY, false)
|
||||
val initial = savedInstanceState?.getStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
|
||||
?: intent.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
|
||||
?: arrayListOf()
|
||||
selection.addAll(initial)
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true)
|
||||
{
|
||||
override fun handleOnBackPressed()
|
||||
{
|
||||
prepareResult()
|
||||
finish()
|
||||
}
|
||||
})
|
||||
setContent {
|
||||
ShxTheme {
|
||||
SelectedAppsScreen(
|
||||
initial = selection,
|
||||
readOnly = readOnly,
|
||||
onBack = {
|
||||
prepareResult()
|
||||
finish()
|
||||
},
|
||||
onChange = {
|
||||
selection.clear()
|
||||
selection.addAll(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle)
|
||||
{
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.putStringArrayList(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
|
||||
}
|
||||
|
||||
private fun prepareResult()
|
||||
{
|
||||
val data = Intent()
|
||||
data.putExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(selection))
|
||||
setResult(RESULT_OK, data)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectedAppsScreen(
|
||||
initial: Set<String>,
|
||||
readOnly: Boolean,
|
||||
onBack: () -> Unit,
|
||||
onChange: (Set<String>) -> Unit,
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
var query by remember { mutableStateOf("") }
|
||||
var apps by remember { mutableStateOf<List<SelectedApplicationEntry>>(emptyList()) }
|
||||
var selected by remember { mutableStateOf(initial.toSet()) }
|
||||
var loading by remember { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val loaded = withContext(Dispatchers.IO) {
|
||||
val pm = context.packageManager
|
||||
val list = ArrayList<SelectedApplicationEntry>()
|
||||
for (info in pm.getInstalledApplications(PackageManager.GET_META_DATA))
|
||||
{
|
||||
if (pm.checkPermission(Manifest.permission.INTERNET, info.packageName) == PackageManager.PERMISSION_GRANTED)
|
||||
{
|
||||
val entry = SelectedApplicationEntry(pm, info)
|
||||
entry.isSelected = selected.contains(info.packageName)
|
||||
list.add(entry)
|
||||
}
|
||||
}
|
||||
list.sorted()
|
||||
}
|
||||
apps = loaded
|
||||
loading = false
|
||||
}
|
||||
|
||||
fun toggle(pkg: String)
|
||||
{
|
||||
if (readOnly) return
|
||||
selected = if (selected.contains(pkg)) selected - pkg else selected + pkg
|
||||
onChange(selected)
|
||||
}
|
||||
|
||||
/* pure system apps are hidden by default (they are rarely worth excluding); a selected one always shows */
|
||||
var showSystem by remember { mutableStateOf(false) }
|
||||
val filtered = remember(apps, query, showSystem, selected) {
|
||||
apps.filter { entry ->
|
||||
val flags = entry.info.flags
|
||||
val system = (flags and android.content.pm.ApplicationInfo.FLAG_SYSTEM) != 0 &&
|
||||
(flags and android.content.pm.ApplicationInfo.FLAG_UPDATED_SYSTEM_APP) == 0
|
||||
(showSystem || !system || selected.contains(entry.info.packageName)) &&
|
||||
(query.isBlank() || entry.toString().contains(query, ignoreCase = true) ||
|
||||
entry.info.packageName.contains(query, ignoreCase = true))
|
||||
}
|
||||
}
|
||||
|
||||
SecondaryScaffold(title = stringResource(R.string.profile_select_apps), onBack = onBack) { padding ->
|
||||
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
label = { Text(stringResource(R.string.search)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.sm).fillMaxWidth(),
|
||||
)
|
||||
androidx.compose.foundation.layout.Row(
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { showSystem = !showSystem }
|
||||
.padding(horizontal = ShxSpacing.lg),
|
||||
)
|
||||
{
|
||||
Text(stringResource(R.string.apps_show_system), modifier = Modifier.weight(1f))
|
||||
androidx.compose.material3.Switch(checked = showSystem, onCheckedChange = null)
|
||||
}
|
||||
if (loading)
|
||||
{
|
||||
LoadingIndicator(Modifier.padding(ShxSpacing.xl).size(32.dp))
|
||||
}
|
||||
else
|
||||
{
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(filtered, key = { it.info.packageName }) { entry ->
|
||||
val pkg = entry.info.packageName
|
||||
val checked = selected.contains(pkg)
|
||||
ListItem(
|
||||
headlineContent = { Text(entry.toString()) },
|
||||
supportingContent = { Text(pkg) },
|
||||
leadingContent = {
|
||||
val bmp = remember(pkg) { entry.icon.toBitmap() }
|
||||
Image(bitmap = bmp.asImageBitmap(), contentDescription = null, modifier = Modifier.size(40.dp))
|
||||
},
|
||||
trailingContent = {
|
||||
Checkbox(checked = checked, onCheckedChange = { toggle(pkg) }, enabled = !readOnly)
|
||||
},
|
||||
modifier = Modifier.clickable(enabled = !readOnly) { toggle(pkg) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)) {
|
||||
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.result.contract.ActivityResultContracts
|
||||
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.TextButton
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -38,6 +37,7 @@ import java.security.cert.CertificateException
|
||||
import java.security.cert.CertificateFactory
|
||||
import java.security.cert.X509Certificate
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.design.ShxDialogButton
|
||||
import org.strongswan.android.logic.TrustedCertificateManager
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
@@ -175,12 +175,12 @@ class TrustedCertificateImportActivity : AppCompatActivity()
|
||||
@androidx.compose.runtime.Composable
|
||||
private fun ConfirmImportUi(certificate: X509Certificate)
|
||||
{
|
||||
AlertDialog(
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = { finish() },
|
||||
title = { Text(stringResource(R.string.import_certificate)) },
|
||||
text = { Text(certificate.getSubjectDN().toString()) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
ShxDialogButton(primary = true, onClick = {
|
||||
if (storeCertificate(certificate))
|
||||
{
|
||||
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)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { finish() }) {
|
||||
ShxDialogButton(onClick = { finish() }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
|
||||
+217
-91
@@ -9,6 +9,7 @@
|
||||
|
||||
package org.strongswan.android.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
@@ -16,24 +17,23 @@ import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.foundation.layout.size
|
||||
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.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.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -42,22 +42,40 @@ import androidx.compose.runtime.mutableStateOf
|
||||
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.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.logic.StrongSwanApplication
|
||||
import org.strongswan.android.logic.TrustedCertificateManager
|
||||
import org.strongswan.android.logic.TrustedCertificateManager.TrustedCertificateSource
|
||||
import org.strongswan.android.security.TrustedCertificateEntry
|
||||
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.ShxType
|
||||
import java.beans.PropertyChangeListener
|
||||
import java.security.KeyStore
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
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
|
||||
private fun CertificatesScreen(
|
||||
selectMode: Boolean,
|
||||
@@ -95,28 +149,28 @@ private fun CertificatesScreen(
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val sources = remember {
|
||||
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),
|
||||
)
|
||||
}
|
||||
val c = LocalShxColors.current
|
||||
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) }
|
||||
val scope = rememberCoroutineScope()
|
||||
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()
|
||||
{
|
||||
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) {
|
||||
@@ -126,83 +180,155 @@ private fun CertificatesScreen(
|
||||
onDispose { TrustedCertificateManager.getInstance().deleteObserver(observer) }
|
||||
}
|
||||
|
||||
SecondaryScaffold(
|
||||
title = stringResource(R.string.trusted_certs_title),
|
||||
onBack = onBack,
|
||||
actions = {
|
||||
SecondaryScaffold(title = stringResource(R.string.trusted_certs_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 = 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)
|
||||
{
|
||||
IconButton(onClick = {
|
||||
importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java))
|
||||
}) { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.import_certificate)) }
|
||||
}
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
TrustedCertificateManager.getInstance().reset().load()
|
||||
}
|
||||
reload(sources[tab].first) { entries = it }
|
||||
}
|
||||
}) { 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
|
||||
},
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(18.dp))
|
||||
.border(1.dp, c.outline, RoundedCornerShape(18.dp))
|
||||
.clickable(role = Role.Button) { importLauncher.launch(Intent(context, TrustedCertificateImportActivity::class.java)) },
|
||||
)
|
||||
{
|
||||
Icon(ShxIcons.Plus, contentDescription = null, tint = c.text, modifier = Modifier.size(18.dp))
|
||||
Text(stringResource(R.string.certs_add), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.text)
|
||||
}
|
||||
}
|
||||
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 ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { pendingDelete = null },
|
||||
title = { Text(stringResource(R.string.delete_certificate_question)) },
|
||||
text = { Text(entry.subjectPrimary) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
try
|
||||
{
|
||||
val store = KeyStore.getInstance("LocalCertificateStore")
|
||||
store.load(null, null)
|
||||
store.deleteEntry(entry.alias)
|
||||
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))
|
||||
ShxDialog(
|
||||
title = stringResource(R.string.delete_certificate_question),
|
||||
paragraphs = listOf(entry.subjectPrimary),
|
||||
confirm = stringResource(R.string.server_delete_confirm),
|
||||
dismiss = stringResource(android.R.string.cancel),
|
||||
onConfirm = {
|
||||
try
|
||||
{
|
||||
val store = KeyStore.getInstance("LocalCertificateStore")
|
||||
store.load(null, null)
|
||||
store.deleteEntry(entry.alias)
|
||||
TrustedCertificateManager.getInstance().reset()
|
||||
}
|
||||
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 manager = TrustedCertificateManager.getInstance().load()
|
||||
manager.getCACertificates(source).map { (alias, cert) -> TrustedCertificateEntry(alias, cert) }
|
||||
.sorted()
|
||||
val c = LocalShxColors.current
|
||||
val notAfter = row.entry.certificate.notAfter
|
||||
val days = TimeUnit.MILLISECONDS.toDays(notAfter.time - System.currentTimeMillis())
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-5
@@ -79,6 +79,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
||||
private Bundle mProfileInfo;
|
||||
private boolean mWaitingForResult;
|
||||
private VpnStateService mService;
|
||||
private boolean mBound;
|
||||
/* profile lookups are SQLite queries and must not block the main thread */
|
||||
private final ExecutorService mProfileExecutor = Executors.newSingleThreadExecutor();
|
||||
private final ServiceConnection mServiceConnection = new ServiceConnection()
|
||||
@@ -101,6 +102,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
mWaitingForResult = false;
|
||||
VpnDialogBus.INSTANCE.setConsentHint(false);
|
||||
if (result.getResultCode() == RESULT_OK && mProfileInfo != null)
|
||||
{
|
||||
onVpnServicePrepared();
|
||||
@@ -140,8 +142,8 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
||||
{
|
||||
mWaitingForResult = savedInstanceState.getBoolean(WAITING_FOR_RESULT, false);
|
||||
}
|
||||
this.bindService(new Intent(this, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
mBound = this.bindService(new Intent(this, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
VpnDialogBus.INSTANCE.setListener(this);
|
||||
VpnDialogsKt.installVpnDialogHost(this);
|
||||
}
|
||||
@@ -159,10 +161,12 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
||||
super.onDestroy();
|
||||
VpnDialogBus.INSTANCE.setListener(null);
|
||||
mProfileExecutor.shutdown();
|
||||
if (mService != null)
|
||||
if (mBound)
|
||||
{
|
||||
this.unbindService(mServiceConnection);
|
||||
mBound = false;
|
||||
}
|
||||
mService = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,6 +225,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
||||
try
|
||||
{
|
||||
mWaitingForResult = true;
|
||||
VpnDialogBus.INSTANCE.setConsentHint(true);
|
||||
mPrepareVpnService.launch(intent);
|
||||
}
|
||||
catch (ActivityNotFoundException ex)
|
||||
@@ -229,6 +234,7 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
||||
* don't have the VPN components built into the system image.
|
||||
* com.android.vpndialogs/com.android.vpndialogs.ConfirmDialog
|
||||
* will not be found then */
|
||||
VpnDialogBus.INSTANCE.setConsentHint(false);
|
||||
showNotSupported(R.string.vpn_not_supported);
|
||||
mWaitingForResult = false;
|
||||
}
|
||||
@@ -429,6 +435,10 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
||||
mProfileExecutor.execute(() -> {
|
||||
VpnProfile profile = loadProfile(uuid);
|
||||
runOnUiThread(() -> {
|
||||
if (isFinishing() || isDestroyed())
|
||||
{ /* rotated or finished while the query ran */
|
||||
return;
|
||||
}
|
||||
if (profile != null)
|
||||
{
|
||||
startVpnProfile(profile);
|
||||
@@ -479,7 +489,12 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
||||
final Intent startIntent = intent;
|
||||
mProfileExecutor.execute(() -> {
|
||||
VpnProfile profile = loadProfile(uuid);
|
||||
runOnUiThread(() -> handleDisconnect(startIntent, profile));
|
||||
runOnUiThread(() -> {
|
||||
if (!isFinishing() && !isDestroyed())
|
||||
{
|
||||
handleDisconnect(startIntent, profile);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -521,11 +536,13 @@ public class VpnProfileControlActivity extends AppCompatActivity implements VpnD
|
||||
String action = normalizeAction(intent);
|
||||
|
||||
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);
|
||||
}
|
||||
else if (DISCONNECT.equals(action))
|
||||
{
|
||||
org.strongswan.android.logic.scenarios.ScenarioEngine.onManual(this);
|
||||
disconnect(intent);
|
||||
}
|
||||
else if (TOGGLE_VPN.equals(action))
|
||||
|
||||
+1
@@ -95,6 +95,7 @@ class VpnProfileDetailActivity : AppCompatActivity()
|
||||
val profile = checked.toProfile(existing)
|
||||
if (existing != null) dataSource.updateVpnProfile(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)
|
||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, profile.getUUID().toString())
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent)
|
||||
|
||||
+45
-11
@@ -34,6 +34,7 @@ import org.json.JSONArray;
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
import org.strongswan.android.R;
|
||||
import org.strongswan.android.logic.profiles.ProfileExtras;
|
||||
import org.strongswan.android.data.VpnProfile;
|
||||
import org.strongswan.android.data.VpnProfile.SelectedAppsHandling;
|
||||
import org.strongswan.android.data.VpnProfileDataSource;
|
||||
@@ -41,6 +42,7 @@ import org.strongswan.android.data.VpnProfileSource;
|
||||
import org.strongswan.android.data.VpnType;
|
||||
import org.strongswan.android.data.VpnType.VpnTypeFeature;
|
||||
import org.strongswan.android.logic.TrustedCertificateManager;
|
||||
import org.strongswan.android.logic.routing.RoutingManager;
|
||||
import org.strongswan.android.security.TrustedCertificateEntry;
|
||||
import org.strongswan.android.ui.compose.ImportUiModel;
|
||||
import org.strongswan.android.ui.compose.ImportScreenKt;
|
||||
@@ -178,7 +180,7 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
|
||||
ImportScreenKt.installImportScreen(this, mUi,
|
||||
this::saveProfile, this::finish,
|
||||
this::importPkcs12, this::chooseUserCertificate);
|
||||
this::importPkcs12, this::chooseUserCertificate, this::retryLoad);
|
||||
|
||||
Intent intent = getIntent();
|
||||
String action = intent.getAction();
|
||||
@@ -245,13 +247,29 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
mImportPKCS12.launch(intent);
|
||||
}
|
||||
|
||||
private Uri mLastUri;
|
||||
|
||||
private void loadProfile(Uri uri)
|
||||
{
|
||||
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();
|
||||
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)
|
||||
@@ -299,15 +317,8 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
if (error != null)
|
||||
{
|
||||
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();
|
||||
/* the sheet shows the failure with the likely reasons and a retry */
|
||||
mUi.setLoadError(error != null ? error : getString(R.string.profile_import_failed));
|
||||
return;
|
||||
}
|
||||
mExisting = mDataSource.getVpnProfile(mProfile.getUUID());
|
||||
@@ -629,6 +640,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
if (verifyInput())
|
||||
{
|
||||
updateProfileData();
|
||||
if (mProfile.getSelectedAppsHandling() != SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||
{ /* per-profile apps are gone: they join the global direct-apps list */
|
||||
RoutingManager.getInstance(this).importProfileApps(mProfile.getSelectedAppsHandling(), mProfile.getSelectedAppsSet());
|
||||
mProfile.setSelectedAppsHandling(SelectedAppsHandling.SELECTED_APPS_DISABLE);
|
||||
}
|
||||
if (mExisting != null)
|
||||
{
|
||||
mProfile.setUUID(mExisting.getUUID());
|
||||
@@ -653,6 +669,11 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
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.putExtra(Constants.VPN_PROFILES_SINGLE, mProfile.getUUID().toString());
|
||||
LocalBroadcastManager.getInstance(this).sendBroadcast(intent);
|
||||
@@ -660,6 +681,13 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
intent = new Intent(this, MainActivity.class);
|
||||
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
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()));
|
||||
finish();
|
||||
@@ -695,6 +723,12 @@ public class VpnProfileImportActivity extends AppCompatActivity
|
||||
*/
|
||||
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))
|
||||
{
|
||||
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.layout.fillMaxSize
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -33,6 +30,28 @@ import androidx.core.graphics.drawable.IconCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
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.VpnProfileSource
|
||||
import org.strongswan.android.ui.compose.SecondaryScaffold
|
||||
@@ -90,20 +109,40 @@ private fun ProfileSelectScreen(
|
||||
onSelect: (VpnProfile) -> Unit,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
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))
|
||||
}
|
||||
else
|
||||
{
|
||||
LazyColumn(Modifier.fillMaxSize().padding(padding)) {
|
||||
items(profiles, key = { it.getUUID().toString() }) { profile ->
|
||||
ListItem(
|
||||
headlineContent = { Text(profile.name ?: "") },
|
||||
supportingContent = { Text(profile.gateway ?: "") },
|
||||
modifier = Modifier.clickable { onSelect(profile) },
|
||||
if (profiles.isEmpty())
|
||||
{
|
||||
Text(stringResource(R.string.no_profiles), style = ShxType.body, color = c.textSub, modifier = Modifier.padding(horizontal = 6.dp, vertical = 8.dp))
|
||||
return@Column
|
||||
}
|
||||
/* the servers as on F_Servers: code circle, name, address */
|
||||
ShxPanel()
|
||||
{
|
||||
profiles.forEachIndexed { i, 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
-21
@@ -45,6 +45,9 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
private boolean mListening;
|
||||
private VpnProfileDataSource mDataSource;
|
||||
private VpnStateService mService;
|
||||
private boolean mBound;
|
||||
private VpnProfile mDefaultProfile;
|
||||
private boolean mDefaultProfileLoaded;
|
||||
private final ServiceConnection mServiceConnection = new ServiceConnection()
|
||||
{
|
||||
@Override
|
||||
@@ -71,8 +74,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
super.onCreate();
|
||||
|
||||
Context context = getApplicationContext();
|
||||
context.bindService(new Intent(context, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
mBound = context.bindService(new Intent(context, VpnStateService.class),
|
||||
mServiceConnection, Service.BIND_AUTO_CREATE);
|
||||
|
||||
mDataSource = new VpnProfileSource(this);
|
||||
mDataSource.open();
|
||||
@@ -83,10 +86,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
{
|
||||
super.onDestroy();
|
||||
|
||||
if (mService != null)
|
||||
/* the binding lives on the application context: unbind even if the
|
||||
* service never connected, or it leaks for the life of the process */
|
||||
if (mBound)
|
||||
{
|
||||
getApplicationContext().unbindService(mServiceConnection);
|
||||
mBound = false;
|
||||
}
|
||||
mService = null;
|
||||
mDataSource.close();
|
||||
mDataSource = null;
|
||||
}
|
||||
@@ -96,6 +103,8 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
{
|
||||
super.onStartListening();
|
||||
mListening = true;
|
||||
/* the configured profile may have changed while the panel was closed */
|
||||
mDefaultProfileLoaded = false;
|
||||
|
||||
if (mService != null)
|
||||
{
|
||||
@@ -118,7 +127,14 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
|
||||
private VpnProfile getProfile()
|
||||
{
|
||||
return mDataSource != null ? mDataSource.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(this)) : null;
|
||||
/* updateTile() runs on every state notification (including retry
|
||||
* countdown ticks): query the database once per listening session */
|
||||
if (!mDefaultProfileLoaded)
|
||||
{
|
||||
mDefaultProfile = mDataSource != null ? mDataSource.getVpnProfile(DefaultVpnProfile.getDefaultProfileUuid(this)) : null;
|
||||
mDefaultProfileLoaded = true;
|
||||
}
|
||||
return mDefaultProfile;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -136,7 +152,12 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
{ /* always get the plain profile without cached password */
|
||||
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)
|
||||
{
|
||||
switch (mService.getState())
|
||||
@@ -196,11 +217,16 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
return;
|
||||
}
|
||||
}
|
||||
Intent intent = new Intent(this, MainActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
/* no servers yet: straight to «Add server» */
|
||||
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)
|
||||
{
|
||||
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE));
|
||||
startActivityAndCollapse(PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -239,11 +265,28 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
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)
|
||||
{
|
||||
tile.setState(Tile.STATE_INACTIVE);
|
||||
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
|
||||
{
|
||||
@@ -253,30 +296,25 @@ public class VpnTileService extends TileService implements VpnStateService.VpnSt
|
||||
case DISABLED:
|
||||
tile.setState(Tile.STATE_INACTIVE);
|
||||
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_disconnected));
|
||||
tile.setLabel(getString(R.string.tile_connect));
|
||||
subtitle = getString(R.string.tile_off);
|
||||
break;
|
||||
case CONNECTING:
|
||||
tile.setState(Tile.STATE_ACTIVE);
|
||||
tile.setIcon(Icon.createWithResource(this, R.drawable.ic_notification_connecting));
|
||||
tile.setLabel(getString(R.string.tile_disconnect));
|
||||
subtitle = getString(R.string.tile_connecting);
|
||||
break;
|
||||
case CONNECTED:
|
||||
default:
|
||||
tile.setState(Tile.STATE_ACTIVE);
|
||||
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;
|
||||
}
|
||||
}
|
||||
if (profile != null && !isSecure())
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q)
|
||||
{ /* keep the action label, show the profile as the tile subtitle */
|
||||
tile.setSubtitle(profile.getName());
|
||||
}
|
||||
else
|
||||
{
|
||||
tile.setLabel(profile.getName());
|
||||
}
|
||||
tile.setSubtitle(subtitle);
|
||||
}
|
||||
tile.updateTile();
|
||||
}
|
||||
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2017 Tobias Brunner
|
||||
*
|
||||
* Copyright (C) secunet Security Networks AG
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.adapter;
|
||||
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.graphics.drawable.Drawable;
|
||||
|
||||
import java.text.Collator;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
public class SelectedApplicationEntry implements Comparable<SelectedApplicationEntry>
|
||||
{
|
||||
private final ApplicationInfo mInfo;
|
||||
private final Drawable mIcon;
|
||||
private final String mName;
|
||||
private boolean mSelected;
|
||||
|
||||
public SelectedApplicationEntry(PackageManager packageManager, ApplicationInfo info)
|
||||
{
|
||||
mInfo = info;
|
||||
CharSequence name = info.loadLabel(packageManager);
|
||||
mName = name == null ? info.packageName : name.toString();
|
||||
mIcon = info.loadIcon(packageManager);
|
||||
}
|
||||
|
||||
public void setSelected(boolean selected)
|
||||
{
|
||||
mSelected = selected;
|
||||
}
|
||||
|
||||
public boolean isSelected()
|
||||
{
|
||||
return mSelected;
|
||||
}
|
||||
|
||||
public ApplicationInfo getInfo()
|
||||
{
|
||||
return mInfo;
|
||||
}
|
||||
|
||||
public Drawable getIcon()
|
||||
{
|
||||
return mIcon;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return mName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(@NonNull SelectedApplicationEntry another)
|
||||
{
|
||||
return Collator.getInstance().compare(toString(), another.toString());
|
||||
}
|
||||
}
|
||||
+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.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.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 org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.unit.dp
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.theme.LocalShxColors
|
||||
import org.strongswan.android.ui.compose.theme.ShxType
|
||||
import org.strongswan.android.ui.compose.design.ShxDialogButton
|
||||
import org.strongswan.android.ui.compose.design.ShxIcons
|
||||
import org.strongswan.android.ui.compose.components.ShxSection
|
||||
import org.strongswan.android.ui.compose.components.ShxSectionDivider
|
||||
import org.strongswan.android.ui.compose.components.ShxSettingRow
|
||||
@@ -92,7 +85,7 @@ fun ConnectivityCheckScreen()
|
||||
ShxSection(title = stringResource(R.string.settings_section_general))
|
||||
{
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.NetworkCheck,
|
||||
icon = ShxIcons.Pulse,
|
||||
title = stringResource(R.string.health_check_title),
|
||||
subtitle = stringResource(R.string.health_check_summary),
|
||||
checked = config.enabled,
|
||||
@@ -100,7 +93,7 @@ fun ConnectivityCheckScreen()
|
||||
)
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Timer,
|
||||
icon = ShxIcons.Clock,
|
||||
title = stringResource(R.string.health_check_interval),
|
||||
subtitle = intervalLabel(config.intervalSec, config.aggressiveMode),
|
||||
enabled = config.enabled && !config.aggressiveMode,
|
||||
@@ -108,7 +101,7 @@ fun ConnectivityCheckScreen()
|
||||
)
|
||||
ShxSectionDivider()
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.Speed,
|
||||
icon = ShxIcons.Bolt,
|
||||
title = stringResource(R.string.health_check_aggressive_title),
|
||||
subtitle = stringResource(R.string.health_check_aggressive_summary),
|
||||
checked = config.aggressiveMode,
|
||||
@@ -116,7 +109,7 @@ fun ConnectivityCheckScreen()
|
||||
)
|
||||
ShxSectionDivider()
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.BatterySaver,
|
||||
icon = ShxIcons.Battery,
|
||||
title = stringResource(R.string.health_check_adaptive_title),
|
||||
subtitle = stringResource(R.string.health_check_adaptive_summary),
|
||||
checked = config.adaptiveMode,
|
||||
@@ -127,7 +120,7 @@ fun ConnectivityCheckScreen()
|
||||
ShxSection(title = stringResource(R.string.health_check_targets_title))
|
||||
{
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.HealthAndSafety,
|
||||
icon = ShxIcons.Shield,
|
||||
title = stringResource(R.string.health_check_server_title),
|
||||
subtitle = stringResource(R.string.health_check_server_summary),
|
||||
checked = config.serverCheckMode != ServerCheckMode.OFF,
|
||||
@@ -140,7 +133,7 @@ fun ConnectivityCheckScreen()
|
||||
listOf(ProbeTarget.GOOGLE, ProbeTarget.YOUTUBE, ProbeTarget.CLOUDFLARE).forEach { target ->
|
||||
ShxSectionDivider()
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.Public,
|
||||
icon = ShxIcons.Globe,
|
||||
title = targetLabel(target),
|
||||
subtitle = null,
|
||||
checked = config.targets.contains(target),
|
||||
@@ -157,8 +150,8 @@ fun ConnectivityCheckScreen()
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.health_check_mode),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (config.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = ShxType.body,
|
||||
color = if (config.enabled) LocalShxColors.current.text else LocalShxColors.current.textSub,
|
||||
)
|
||||
SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(top = ShxSpacing.sm))
|
||||
{
|
||||
@@ -175,7 +168,7 @@ fun ConnectivityCheckScreen()
|
||||
}
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.NetworkCheck,
|
||||
icon = ShxIcons.Pulse,
|
||||
title = stringResource(R.string.health_check_action),
|
||||
subtitle = actionLabel(config.serverAction),
|
||||
enabled = config.enabled,
|
||||
@@ -203,7 +196,7 @@ fun ConnectivityCheckScreen()
|
||||
ShxSection(title = stringResource(R.string.health_check_tunnel_dead_title))
|
||||
{
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.NetworkCheck,
|
||||
icon = ShxIcons.Pulse,
|
||||
title = stringResource(R.string.health_check_action),
|
||||
subtitle = actionLabel(config.tunnelDeadAction),
|
||||
enabled = config.enabled,
|
||||
@@ -231,7 +224,7 @@ fun ConnectivityCheckScreen()
|
||||
ShxSection(title = stringResource(R.string.health_check_partial_title))
|
||||
{
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.NetworkCheck,
|
||||
icon = ShxIcons.Pulse,
|
||||
title = stringResource(R.string.health_check_action),
|
||||
subtitle = actionLabel(config.partialDownAction),
|
||||
enabled = config.enabled,
|
||||
@@ -252,7 +245,7 @@ fun ConnectivityCheckScreen()
|
||||
if (showIntervalPicker)
|
||||
{
|
||||
val options = listOf(15, 30, 60, 300)
|
||||
AlertDialog(
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = { showIntervalPicker = false },
|
||||
title = { Text(stringResource(R.string.health_check_interval)) },
|
||||
text = {
|
||||
@@ -276,7 +269,7 @@ fun ConnectivityCheckScreen()
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showIntervalPicker = false }) {
|
||||
ShxDialogButton(onClick = { showIntervalPicker = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
@@ -336,20 +329,20 @@ private fun AttemptsRow(
|
||||
{
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = ShxType.body,
|
||||
color = if (enabled) LocalShxColors.current.text else LocalShxColors.current.textSub,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
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 = attempts.toString(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = ShxType.cardTitle,
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.sm),
|
||||
)
|
||||
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 kotlin.math.abs
|
||||
|
||||
/* «1,2 ГБ» in Russian, «1.2 GB» otherwise */
|
||||
private fun russian() = Locale.getDefault().language == "ru"
|
||||
|
||||
fun formatBytes(bytes: Long): String
|
||||
{
|
||||
val units = if (russian()) arrayOf("Б", "КБ", "МБ", "ГБ", "ТБ") else arrayOf("B", "KB", "MB", "GB", "TB")
|
||||
if (bytes <= 0L)
|
||||
{
|
||||
return "0 B"
|
||||
return "0 " + units[0]
|
||||
}
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
||||
var value = bytes.toDouble()
|
||||
var unit = 0
|
||||
while (value >= 1024 && unit < units.lastIndex)
|
||||
@@ -26,17 +29,18 @@ fun formatBytes(bytes: Long): String
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
return if (unit == 0) String.format(Locale.US, "%d %s", bytes, units[unit])
|
||||
else String.format(Locale.US, "%.1f %s", value, units[unit])
|
||||
return if (unit == 0) String.format(Locale.getDefault(), "%d %s", bytes, units[unit])
|
||||
else String.format(Locale.getDefault(), "%.1f %s", value, units[unit])
|
||||
}
|
||||
|
||||
fun formatRate(bytesPerSecond: Long): String
|
||||
{
|
||||
val perSecond = if (russian()) "/с" else "/s"
|
||||
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
|
||||
|
||||
+231
-500
@@ -52,26 +52,7 @@ 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 org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
@@ -89,6 +70,7 @@ 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.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
@@ -104,6 +86,7 @@ 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.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -116,7 +99,29 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
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 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.logic.VpnStateService.ErrorState
|
||||
import org.strongswan.android.logic.VpnStateService.State
|
||||
@@ -128,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.theme.LocalShxStatusColors
|
||||
import org.strongswan.android.ui.compose.theme.ScreenPadding
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.semantics.LiveRegionMode
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.liveRegion
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import org.strongswan.android.ui.compose.home.AvailabilityCard
|
||||
import org.strongswan.android.ui.compose.home.HeroConnect
|
||||
import org.strongswan.android.ui.compose.home.HomeBannerSlot
|
||||
import org.strongswan.android.ui.compose.home.ProfileChip
|
||||
import org.strongswan.android.ui.compose.home.DEFAULT_PAUSE_MINUTES
|
||||
import org.strongswan.android.ui.compose.home.HomeProblem
|
||||
import org.strongswan.android.ui.compose.home.PauseSheet
|
||||
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.TrafficTiles
|
||||
import org.strongswan.android.ui.compose.home.labelRes
|
||||
import org.strongswan.android.ui.compose.home.rttText
|
||||
import org.strongswan.android.ui.compose.home.sortedForDisplay
|
||||
@@ -167,17 +180,35 @@ fun HomeScreen(
|
||||
val whiteList by viewModel.whiteList.collectAsStateWithLifecycle()
|
||||
val exit by viewModel.exit.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 */
|
||||
val probeHistory = remember { mutableStateMapOf<ProbeTarget, SnapshotStateList<ProbeResult?>>() }
|
||||
/* the map is updated one target at a time as the parallel probes finish:
|
||||
* only append results that are new, not every entry on every emission */
|
||||
val lastSeen = remember { HashMap<ProbeTarget, ProbeResult>() }
|
||||
DisposableEffect(viewModel)
|
||||
{ /* the view model only polls traffic and probes while Home is shown */
|
||||
viewModel.setHomeVisible(true)
|
||||
onDispose { viewModel.setHomeVisible(false) }
|
||||
}
|
||||
LaunchedEffect(probes)
|
||||
{
|
||||
if (probes.isEmpty())
|
||||
{
|
||||
probeHistory.clear()
|
||||
lastSeen.clear()
|
||||
}
|
||||
else
|
||||
{
|
||||
probes.forEach { (target, result) ->
|
||||
if (lastSeen[target] === result)
|
||||
{
|
||||
return@forEach
|
||||
}
|
||||
lastSeen[target] = result
|
||||
val list = probeHistory.getOrPut(target) { mutableStateListOf() }
|
||||
list.add(result)
|
||||
while (list.size > RTT_HISTORY)
|
||||
@@ -187,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 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 connecting = state.vpnState == State.CONNECTING || state.vpnState == State.DISCONNECTING
|
||||
val hasError = state.error != ErrorState.NO_ERROR
|
||||
val selected = state.selected
|
||||
val durationSec = if (connected) traffic.durationSec else 0L
|
||||
val status = LocalShxStatusColors.current
|
||||
val statusText = when
|
||||
{
|
||||
hasError -> stringResource(state.errorText)
|
||||
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)
|
||||
}
|
||||
/* a pause only matters while the tunnel is actually down */
|
||||
val pauseActive = pauseUntil > 0L && state.vpnState == State.DISABLED
|
||||
val now = if (pauseActive) rememberNow() else System.currentTimeMillis()
|
||||
val problem = if (pauseActive && pauseUntil > now) null else homeProblem(state, networkAvailable)
|
||||
val whiteListActive = whiteList.config.enabled && whiteList.status == WhiteListStatus.ACTIVE
|
||||
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
|
||||
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<IntSize>()
|
||||
|
||||
Box(Modifier.fillMaxSize())
|
||||
{
|
||||
StatusGlowBackground(color = statusColor, active = connected || connecting)
|
||||
val wide = LocalConfiguration.current.screenWidthDp >= 840
|
||||
val leftItems: LazyListScope.() -> Unit = {
|
||||
item(key = "hero") {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = ShxSpacing.xl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
HeroConnect(
|
||||
state = state.vpnState,
|
||||
enabled = selected != null,
|
||||
onConnect = { selected?.let { viewModel.connect(context, it) } },
|
||||
onDisconnect = { viewModel.disconnect() },
|
||||
onCancel = { viewModel.disconnect() },
|
||||
)
|
||||
Spacer(Modifier.height(ShxSpacing.lg))
|
||||
/* the status is announced to TalkBack whenever it changes */
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
|
||||
) {
|
||||
AnimatedContent(
|
||||
targetState = statusText,
|
||||
transitionSpec = { fadeIn(effects) togetherWith fadeOut(effects) },
|
||||
label = "statusText",
|
||||
) { text ->
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = statusColor,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(ShxSpacing.xs))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.profiles.isEmpty())
|
||||
{
|
||||
item(key = "empty") {
|
||||
ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) {
|
||||
ShxEmptyState(
|
||||
icon = Icons.Rounded.Language,
|
||||
message = stringResource(R.string.no_profiles),
|
||||
actionLabel = stringResource(R.string.add_profile_cta),
|
||||
onAction = onAddProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val lockdown = remember { context.isVpnLockdownEnabled() }
|
||||
/* F_CertExpiry: the CA of the shown profile runs out soon */
|
||||
val certAlias = (state.activeProfile ?: selected)?.certificateAlias
|
||||
var certSnoozes by remember { mutableStateOf(0) }
|
||||
val certExpiry by produceState<CertExpiry?>(null, certAlias, certSnoozes) {
|
||||
value = withContext(Dispatchers.IO) { certExpiry(context, certAlias) }
|
||||
}
|
||||
val connectingNow = state.vpnState == State.CONNECTING
|
||||
val connectStep by produceState(1, connectingNow) {
|
||||
while (connectingNow)
|
||||
{
|
||||
value = withContext(Dispatchers.IO) { connectProgress(context) }
|
||||
kotlinx.coroutines.delay(600)
|
||||
}
|
||||
}
|
||||
val historyChanges by org.strongswan.android.logic.stats.TrafficHistory.changes.collectAsStateWithLifecycle()
|
||||
val today by produceState<org.strongswan.android.logic.stats.DayTraffic?>(null, historyChanges) {
|
||||
value = withContext(Dispatchers.IO) { org.strongswan.android.logic.stats.TrafficHistory.today(context) }
|
||||
}
|
||||
val ssid by viewModel.ssid.collectAsStateWithLifecycle()
|
||||
var hintTick by remember { mutableStateOf(0) }
|
||||
val hint by produceState<org.strongswan.android.ui.compose.home.ScenarioHint?>(null, ssid, state.vpnState, hintTick) {
|
||||
value = withContext(Dispatchers.IO) { org.strongswan.android.ui.compose.home.ScenarioHints.hint(context, ssid) }
|
||||
}
|
||||
val extras by remember { org.strongswan.android.logic.profiles.ProfileExtras.flow(context) }.collectAsStateWithLifecycle()
|
||||
val shownUuid = (state.activeProfile ?: selected)?.getUUID()?.toString()
|
||||
val hasLink = shownUuid != null && extras[shownUuid]?.source == org.strongswan.android.logic.profiles.ProfileSourceType.LINK
|
||||
val model = HomeModel(
|
||||
state = state,
|
||||
traffic = traffic,
|
||||
exit = exit,
|
||||
serverRttMs = probes[ProbeTarget.SERVER]?.takeIf { it.ok }?.rttMs,
|
||||
problem = problem,
|
||||
whiteListActive = whiteListActive,
|
||||
pausedUntil = if (pauseActive) pauseUntil else 0L,
|
||||
now = now,
|
||||
ruDirect = ruState.enabled,
|
||||
lockdown = lockdown,
|
||||
networkKinds = networkKinds,
|
||||
unread = unreadEvents,
|
||||
findingFastest = findingFastest,
|
||||
certExpiry = certExpiry,
|
||||
connectStep = connectStep,
|
||||
today = today,
|
||||
hint = hint,
|
||||
hasLink = hasLink,
|
||||
)
|
||||
val actions = HomeActions(
|
||||
connect = { selected?.let { viewModel.connect(context, it) } },
|
||||
disconnect = { viewModel.disconnect() },
|
||||
retry = { viewModel.reconnect() },
|
||||
resume = { viewModel.resumeNow() },
|
||||
extendPause = { viewModel.extendPause(DEFAULT_PAUSE_MINUTES) },
|
||||
openPause = { pauseSheetOpen = true },
|
||||
fastest = { viewModel.connectFastest(context) },
|
||||
pickServer = { pickerOpen = true },
|
||||
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
|
||||
Intent(Settings.ACTION_WIRELESS_SETTINGS)
|
||||
try
|
||||
{
|
||||
item(key = "chip") {
|
||||
ProfileChip(
|
||||
profile = selected,
|
||||
exit = exit,
|
||||
connected = connected,
|
||||
onClick = { pickerOpen = true },
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg).animateItem(),
|
||||
)
|
||||
}
|
||||
context.startActivity(panel)
|
||||
}
|
||||
item(key = "traffic") {
|
||||
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)
|
||||
catch (_: android.content.ActivityNotFoundException)
|
||||
{
|
||||
item(key = "availability") {
|
||||
AvailabilityCard(
|
||||
probes = probes,
|
||||
probing = probing,
|
||||
whiteList = whiteList,
|
||||
history = probeHistory,
|
||||
deadTargets = deadTargets,
|
||||
onOpenRouting = { viewModel.setTab(ShxTab.ROUTING) },
|
||||
onForceTunnel = viewModel::forceTunnelDeadTarget,
|
||||
onRefresh = { viewModel.refreshProbes() },
|
||||
)
|
||||
}
|
||||
context.startActivity(Intent(Settings.ACTION_SETTINGS))
|
||||
}
|
||||
item(key = "space") { Spacer(Modifier.height(ShxSpacing.xl)) }
|
||||
}
|
||||
if (wide)
|
||||
{
|
||||
/* expanded width: hero and profile on the left, status blocks on the right */
|
||||
Row(Modifier.fillMaxSize().statusBarsPadding())
|
||||
},
|
||||
vpnSettings = {
|
||||
try
|
||||
{
|
||||
LazyColumn(
|
||||
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() }
|
||||
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||
}
|
||||
}
|
||||
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),
|
||||
)
|
||||
catch (_: android.content.ActivityNotFoundException)
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -380,7 +372,7 @@ fun HomeScreen(
|
||||
},
|
||||
onLongPick = { profile ->
|
||||
pickerOpen = false
|
||||
sheetProfile = profile
|
||||
context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString()))
|
||||
},
|
||||
onManage = {
|
||||
pickerOpen = false
|
||||
@@ -393,69 +385,29 @@ fun HomeScreen(
|
||||
)
|
||||
}
|
||||
|
||||
if (state.detailsOpen)
|
||||
if (pauseSheetOpen)
|
||||
{
|
||||
ConnectionDetailsSheet(
|
||||
profile = state.activeProfile ?: state.selected,
|
||||
traffic = traffic,
|
||||
durationSec = durationSec,
|
||||
probes = probes,
|
||||
exit = exit,
|
||||
onDismiss = { viewModel.openDetails(false) },
|
||||
onDisconnect = {
|
||||
viewModel.openDetails(false)
|
||||
viewModel.disconnect()
|
||||
PauseSheet(
|
||||
onDismiss = { pauseSheetOpen = false },
|
||||
onPick = { minutes ->
|
||||
pauseSheetOpen = false
|
||||
viewModel.pause(minutes)
|
||||
},
|
||||
onMorning = {
|
||||
pauseSheetOpen = false
|
||||
viewModel.pauseUntilMorning()
|
||||
},
|
||||
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. */
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
/** «Server» sheet of Home: the servers as on F_Servers, manage / add below. */
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ProfilePickerSheet(
|
||||
profiles: List<VpnProfile>,
|
||||
@@ -468,279 +420,58 @@ private fun ProfilePickerSheet(
|
||||
onAdd: () -> Unit,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
/* the current profile first, the rest in list order */
|
||||
val ordered = remember(profiles, selected) {
|
||||
profiles.sortedByDescending { it.getUUID() == selected?.getUUID() }
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
)
|
||||
ShxSheet(onDismiss = onDismiss)
|
||||
{
|
||||
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 ->
|
||||
ShxProfileRow(
|
||||
profile = profile,
|
||||
active = profile.getUUID() == activeUuid,
|
||||
onClick = { onPick(profile) },
|
||||
onLongClick = { onLongPick(profile) },
|
||||
onLongClickLabel = stringResource(R.string.profile_actions),
|
||||
trailing = if (profile.getUUID() == selected?.getUUID() && profile.getUUID() != activeUuid)
|
||||
{
|
||||
{ Icon(Icons.Rounded.Check, contentDescription = null, tint = MaterialTheme.colorScheme.primary) }
|
||||
}
|
||||
else null,
|
||||
val active = profile.getUUID() == activeUuid
|
||||
val chosen = profile.getUUID() == selected?.getUUID()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(68.dp)
|
||||
.combinedClickable(
|
||||
onClick = { onPick(profile) },
|
||||
onLongClick = { onLongPick(profile) },
|
||||
onLongClickLabel = stringResource(R.string.profile_actions),
|
||||
)
|
||||
.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)
|
||||
{
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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")
|
||||
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())
|
||||
{
|
||||
val alpha = if (active) (if (animationsOn) pulse else 0.42f) else 0f
|
||||
val cx = size.width / 2f
|
||||
val cy = size.height * 0.16f
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(animated.copy(alpha = alpha * 0.30f), Color.Transparent),
|
||||
center = Offset(cx, cy),
|
||||
radius = size.minDimension * 0.62f,
|
||||
),
|
||||
radius = size.minDimension * 0.62f,
|
||||
center = Offset(cx, cy),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
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))
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
+252
-25
@@ -24,7 +24,9 @@ import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.IBinder
|
||||
import android.os.SystemClock
|
||||
import android.widget.Toast
|
||||
@@ -35,12 +37,18 @@ import androidx.lifecycle.repeatOnLifecycle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -50,6 +58,7 @@ import org.strongswan.android.data.ProfileExportProvider
|
||||
import org.strongswan.android.data.VpnProfile
|
||||
import org.strongswan.android.data.VpnProfileSource
|
||||
import org.strongswan.android.logic.CharonVpnService
|
||||
import org.strongswan.android.logic.PauseManager
|
||||
import org.strongswan.android.logic.VpnProfileExporter
|
||||
import org.strongswan.android.logic.VpnStateService
|
||||
import org.strongswan.android.logic.VpnStateService.ErrorState
|
||||
@@ -118,6 +127,8 @@ data class HomeUiState(
|
||||
val vpnState: State = State.DISABLED,
|
||||
val error: ErrorState = ErrorState.NO_ERROR,
|
||||
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 profiles: List<VpnProfile> = emptyList(),
|
||||
val selected: VpnProfile? = null,
|
||||
@@ -131,6 +142,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
private val _messages = MutableSharedFlow<Int>(extraBufferCapacity = 4)
|
||||
val messages: SharedFlow<Int> = _messages
|
||||
|
||||
private val homeVisible = MutableStateFlow(false)
|
||||
private val _ui = MutableStateFlow(HomeUiState())
|
||||
val ui: StateFlow<HomeUiState> = _ui
|
||||
|
||||
@@ -158,6 +170,75 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
private val _deadTargets = MutableStateFlow<List<DeadTargetUi>>(emptyList())
|
||||
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
|
||||
|
||||
/* written from the main thread (pullState) and the poll coroutine (Default) */
|
||||
@@ -204,46 +285,78 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
)
|
||||
reloadProfiles()
|
||||
WhiteListManager.getInstance(ctx).refreshIfStale()
|
||||
/* poll traffic only while the app is visible, and off the main thread */
|
||||
/* 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
|
||||
* tabs or behind another activity), the VPN is up, and off the main thread */
|
||||
viewModelScope.launch {
|
||||
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
while (isActive)
|
||||
{
|
||||
whileHomeConnected {
|
||||
tickTraffic()
|
||||
delay(TRAFFIC_POLL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
/* probe availability while the app is visible and the VPN is connected */
|
||||
/* probe availability under the same conditions */
|
||||
viewModelScope.launch {
|
||||
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
var lastProbeAt = 0L
|
||||
while (isActive)
|
||||
{
|
||||
val connected = _ui.value.vpnState == State.CONNECTED
|
||||
if (connected && SystemClock.elapsedRealtime() - lastProbeAt >= PROBE_INTERVAL_MS)
|
||||
whileHomeConnected {
|
||||
val wait = PROBE_INTERVAL_MS - (SystemClock.elapsedRealtime() - lastProbeAt)
|
||||
if (wait > 0)
|
||||
{
|
||||
lastProbeAt = SystemClock.elapsedRealtime()
|
||||
probeNow()
|
||||
delay(wait)
|
||||
}
|
||||
delay(PROBE_TICK_MS)
|
||||
lastProbeAt = SystemClock.elapsedRealtime()
|
||||
probeNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [block] repeatedly while the Home screen is visible and the VPN is
|
||||
* connected; suspends without any polling otherwise. [block] is
|
||||
* cancelled as soon as either condition goes away.
|
||||
*/
|
||||
private suspend fun whileHomeConnected(block: suspend () -> Unit)
|
||||
{
|
||||
combine(homeVisible, _ui.map { it.vpnState == State.CONNECTED }) { visible, connected ->
|
||||
visible && connected
|
||||
}.distinctUntilChanged().collectLatest { active ->
|
||||
while (active && currentCoroutineContext().isActive)
|
||||
{
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the Home screen while it is (not) shown. */
|
||||
fun setHomeVisible(visible: Boolean)
|
||||
{
|
||||
homeVisible.value = visible
|
||||
}
|
||||
|
||||
/** Immediately reruns the availability probe round (refresh button). */
|
||||
fun refreshProbes()
|
||||
{
|
||||
WhiteListManager.getInstance(getApplication()).checkNow()
|
||||
viewModelScope.launch {
|
||||
probeNow()
|
||||
refreshExit(force = true)
|
||||
refreshDeadTargets()
|
||||
probeNow(forceExit = true)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun probeNow()
|
||||
private suspend fun probeNow(forceExit: Boolean = false)
|
||||
{
|
||||
/* direct (outside the tunnel) probes: at most once per TTL, event-driven */
|
||||
WhiteListManager.getInstance(getApplication()).refreshIfStale()
|
||||
@@ -263,7 +376,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
addAll(healthConfig.targets)
|
||||
}
|
||||
refreshDeadTargets()
|
||||
viewModelScope.launch { refreshExit(force = false) }
|
||||
viewModelScope.launch { refreshExit(force = forceExit) }
|
||||
if (targets.isEmpty())
|
||||
{
|
||||
return
|
||||
@@ -308,6 +421,13 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
{
|
||||
}
|
||||
LocalBroadcastManager.getInstance(ctx).unregisterReceiver(profilesChanged)
|
||||
try
|
||||
{
|
||||
(ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager)?.unregisterNetworkCallback(networkCallback)
|
||||
}
|
||||
catch (_: IllegalArgumentException)
|
||||
{
|
||||
}
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
@@ -333,6 +453,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
|
||||
fun connect(context: Context, profile: VpnProfile)
|
||||
{
|
||||
org.strongswan.android.ui.compose.home.ScenarioHints.manualOn(context)
|
||||
val intent = Intent(context, VpnProfileControlActivity::class.java)
|
||||
intent.action = VpnProfileControlActivity.START_PROFILE
|
||||
intent.putExtra(VpnProfileControlActivity.EXTRA_VPN_PROFILE_UUID, profile.getUUID().toString())
|
||||
@@ -341,6 +462,9 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -349,17 +473,108 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
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)
|
||||
{
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val source = VpnProfileSource(getApplication())
|
||||
source.open()
|
||||
source.deleteVpnProfile(profile)
|
||||
source.close()
|
||||
try
|
||||
{
|
||||
source.deleteVpnProfile(profile)
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||
intent.putExtra(Constants.VPN_PROFILES_MULTIPLE, arrayOf(profile.getUUID().toString()))
|
||||
/* the broadcast reaches profilesChanged, which reloads the list */
|
||||
LocalBroadcastManager.getInstance(getApplication()).sendBroadcast(intent)
|
||||
reloadProfiles()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,11 +584,16 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val source = VpnProfileSource(getApplication())
|
||||
source.open()
|
||||
source.insertProfile(profile)
|
||||
source.close()
|
||||
try
|
||||
{
|
||||
source.insertProfile(profile)
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
val intent = Intent(Constants.VPN_PROFILES_CHANGED)
|
||||
LocalBroadcastManager.getInstance(getApplication()).sendBroadcast(intent)
|
||||
reloadProfiles()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,8 +628,14 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val source = VpnProfileSource(getApplication())
|
||||
source.open()
|
||||
val all = source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
|
||||
source.close()
|
||||
val all = try
|
||||
{
|
||||
source.allVpnProfiles.sortedBy { it.name?.lowercase() ?: "" }
|
||||
}
|
||||
finally
|
||||
{
|
||||
source.close()
|
||||
}
|
||||
_ui.update { state ->
|
||||
val selected = all.firstOrNull { it.getUUID() == state.selected?.getUUID() }
|
||||
?: all.firstOrNull { it.getUUID() == state.activeProfile?.getUUID() }
|
||||
@@ -450,6 +676,7 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
vpnState = state,
|
||||
error = svc.errorState,
|
||||
errorText = svc.errorText,
|
||||
retryIn = if (svc.errorState != ErrorState.NO_ERROR) svc.retryIn else 0,
|
||||
activeProfile = svc.profile,
|
||||
selected = svc.profile ?: it.selected,
|
||||
)
|
||||
@@ -630,10 +857,10 @@ class HomeViewModel(app: Application) : AndroidViewModel(app), VpnStateService.V
|
||||
companion object
|
||||
{
|
||||
private const val TRAFFIC_POLL_MS = 1000L
|
||||
private const val PROBE_TICK_MS = 1000L
|
||||
private const val PROBE_INTERVAL_MS = 10_000L
|
||||
private const val EXIT_REFRESH_MS = 60_000L
|
||||
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. */
|
||||
fun parseDeadTargets(raw: Array<String>?): List<DeadTargetUi> =
|
||||
|
||||
+255
-222
@@ -11,53 +11,59 @@
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
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.heightIn
|
||||
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.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.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.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.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.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.ui.compose.components.ShxCard
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
import org.strongswan.android.ui.LogActivity
|
||||
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.ShxType
|
||||
|
||||
/**
|
||||
* 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 remoteCertSecondary by mutableStateOf<String?>(null)
|
||||
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. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnable, onClose: Runnable,
|
||||
onImportPkcs12: Runnable, onSelectUserCert: Runnable)
|
||||
/** Renders the import UI as the mockup sheet (F_ImportPreview / F_ErrImport) in the transparent import activity. */
|
||||
fun ComponentActivity.installImportScreen(
|
||||
model: ImportUiModel,
|
||||
onImport: Runnable,
|
||||
onClose: Runnable,
|
||||
onImportPkcs12: Runnable,
|
||||
onSelectUserCert: Runnable,
|
||||
onRetry: Runnable,
|
||||
)
|
||||
{
|
||||
setContent {
|
||||
ShxTheme {
|
||||
/* the activity is a transparent host: the import UI is an Expressive bottom sheet */
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onClose::run,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
)
|
||||
ShxSheet(onDismiss = onClose::run)
|
||||
{
|
||||
ImportScreen(
|
||||
model = model,
|
||||
@@ -111,13 +124,13 @@ fun ComponentActivity.installImportScreen(model: ImportUiModel, onImport: Runnab
|
||||
onClose = onClose::run,
|
||||
onImportPkcs12 = onImportPkcs12::run,
|
||||
onSelectUserCert = onSelectUserCert::run,
|
||||
onRetry = onRetry::run,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ImportScreen(
|
||||
model: ImportUiModel,
|
||||
@@ -125,221 +138,241 @@ fun ImportScreen(
|
||||
onClose: () -> Unit,
|
||||
onImportPkcs12: () -> Unit,
|
||||
onSelectUserCert: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
)
|
||||
{
|
||||
Column(Modifier.fillMaxSize())
|
||||
val c = LocalShxColors.current
|
||||
val context = LocalContext.current
|
||||
when
|
||||
{
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.profile_import), fontWeight = FontWeight.SemiBold) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (model.showForm && !model.hideImport)
|
||||
model.loadError != null || (model.hideImport && model.existsWarning != null) ->
|
||||
{
|
||||
/* F_ErrImport */
|
||||
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(c.errorContainer))
|
||||
{
|
||||
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
|
||||
}
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = ShxSpacing.lg)
|
||||
.padding(bottom = ShxSpacing.xl),
|
||||
)
|
||||
{
|
||||
if (model.existsWarning != null)
|
||||
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),
|
||||
)
|
||||
{
|
||||
ImportBanner(
|
||||
text = model.existsWarning ?: "",
|
||||
container = MaterialTheme.colorScheme.errorContainer,
|
||||
content = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
ShxCard(modifier = Modifier.padding(top = ShxSpacing.md))
|
||||
{
|
||||
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)
|
||||
ImportInfoRow(Icons.Rounded.VpnKey, stringResource(R.string.profile_vpn_type_label), model.vpnType, divider = false)
|
||||
}
|
||||
|
||||
if (model.requireUserPass)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.profile_username_label).uppercase().replace(":", ""),
|
||||
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(),
|
||||
Text(stringResource(R.string.import_reasons).uppercase(), style = ShxType.section.copy(fontSize = 12.sp), color = c.accentText)
|
||||
val reasons = if (model.source?.startsWith("http") == true) listOf(
|
||||
R.string.import_reason_blocked to R.string.import_reason_blocked_d,
|
||||
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)
|
||||
reasons.forEach { (t, d) ->
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
|
||||
{
|
||||
Box(Modifier.padding(top = 8.dp).size(6.dp).clip(CircleShape).background(c.error))
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(t)) }
|
||||
append(" — ")
|
||||
withStyle(SpanStyle(color = c.textSub)) { append(stringResource(d)) }
|
||||
},
|
||||
style = ShxType.note.copy(fontSize = 14.sp), color = c.text,
|
||||
)
|
||||
}
|
||||
}
|
||||
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(
|
||||
text = stringResource(R.string.profile_import_shared_secret),
|
||||
container = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
content = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = ShxSpacing.sm),
|
||||
ShxButton(stringResource(R.string.retry), onRetry, height = 52.dp, modifier = Modifier.weight(1f))
|
||||
}
|
||||
ShxButton(stringResource(R.string.add_close), onClose, style = ShxButtonStyle.SOFT, height = 52.dp, modifier = Modifier.weight(1f))
|
||||
}
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (model.requireUserCert)
|
||||
{
|
||||
Text(
|
||||
stringResource(R.string.profile_user_certificate_label).uppercase().replace(":", ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
|
||||
)
|
||||
ShxCard {
|
||||
Column {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onSelectUserCert)
|
||||
.padding(horizontal = ShxSpacing.lg, vertical = 14.dp),
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
model.userCertAlias ?: stringResource(R.string.profile_user_select_certificate_label),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
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))
|
||||
}
|
||||
}
|
||||
val rows = buildList {
|
||||
add(stringResource(R.string.import_server) to (model.gateway to c.text))
|
||||
add(stringResource(R.string.import_type) to (model.vpnType to c.text))
|
||||
add(stringResource(R.string.import_login) to ((when
|
||||
{
|
||||
model.requireUserCert && model.requireUserPass -> stringResource(R.string.import_login_both)
|
||||
model.requireUserCert -> stringResource(R.string.import_login_cert)
|
||||
else -> stringResource(R.string.import_login_password)
|
||||
}) 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))
|
||||
if (model.showRemoteCert)
|
||||
{
|
||||
add(stringResource(R.string.import_ca) to (stringResource(R.string.import_ca_included) to c.onSuccess))
|
||||
}
|
||||
}
|
||||
rows.forEach { (k, v) ->
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().heightIn(min = 52.dp).padding(horizontal = 16.dp))
|
||||
{
|
||||
Text(k, style = ShxType.body, color = c.textSoft, maxLines = 1, softWrap = false)
|
||||
Text(
|
||||
v.first, style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = v.second, maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.End, modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (model.showRemoteCert)
|
||||
if (model.requireUserPass)
|
||||
{
|
||||
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(
|
||||
stringResource(R.string.profile_ca_label).uppercase().replace(":", ""),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm),
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(stringResource(R.string.import_exists_title)) }
|
||||
append(" ")
|
||||
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
|
||||
private fun ImportBanner(
|
||||
text: String,
|
||||
container: androidx.compose.ui.graphics.Color,
|
||||
content: androidx.compose.ui.graphics.Color,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
private fun ImportField(label: String, value: String, editable: Boolean, error: Boolean, secret: Boolean = false, onChange: (String) -> Unit)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = container,
|
||||
contentColor = content,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = ShxSpacing.sm),
|
||||
) {
|
||||
Text(
|
||||
text,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
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),
|
||||
)
|
||||
{
|
||||
Text(label, style = ShxType.meta.copy(fontWeight = FontWeight.Bold), color = if (error) c.error else c.textSub)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onChange,
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* 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.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-179
@@ -1,179 +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 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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.routing.RuleSet
|
||||
import org.strongswan.android.logic.routing.RoutingPresets
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
|
||||
/**
|
||||
* Searchable list of the built-in presets shown inside the rule set editor
|
||||
* sheet. Selection toggles immediately; "Done"/back returns to the editor.
|
||||
*/
|
||||
@Composable
|
||||
fun PresetPickerContent(
|
||||
ruleSet: RuleSet,
|
||||
onToggle: (RoutingPresets.Preset) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
)
|
||||
{
|
||||
var query by remember { mutableStateOf("") }
|
||||
val filtered = remember(query)
|
||||
{
|
||||
val q = query.trim()
|
||||
if (q.isEmpty())
|
||||
{
|
||||
RoutingPresets.RF_BUNDLE
|
||||
}
|
||||
else
|
||||
{
|
||||
RoutingPresets.RF_BUNDLE.filter { it.name.contains(q, ignoreCase = true) }
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth())
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = ShxSpacing.md, end = ShxSpacing.md, bottom = ShxSpacing.xs),
|
||||
)
|
||||
{
|
||||
IconButton(onClick = onBack)
|
||||
{
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
contentDescription = stringResource(R.string.action_back),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.routing_presets_label),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onBack)
|
||||
{
|
||||
Text(stringResource(R.string.action_done))
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
label = { Text(stringResource(R.string.routing_presets_search)) },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(Icons.Rounded.Search, contentDescription = null)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.xl),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 460.dp)
|
||||
.padding(top = ShxSpacing.xs),
|
||||
)
|
||||
{
|
||||
items(filtered, key = { it.name })
|
||||
{ preset ->
|
||||
val selected = ruleSet.prefixes.containsAll(preset.prefixes) &&
|
||||
ruleSet.keywords.containsAll(preset.keywords)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onToggle(preset) }
|
||||
.padding(horizontal = ShxSpacing.xl, vertical = 6.dp),
|
||||
)
|
||||
{
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 6.dp),
|
||||
)
|
||||
{
|
||||
Text(preset.name, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
preset.patternSummary(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Checkbox(checked = selected, onCheckedChange = { onToggle(preset) })
|
||||
}
|
||||
}
|
||||
if (filtered.isEmpty())
|
||||
{
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.routing_presets_none),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.lg),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "3 prefixes · 2 keywords" summary, skipping empty groups. */
|
||||
@Composable
|
||||
private fun RoutingPresets.Preset.patternSummary(): String
|
||||
{
|
||||
val parts = listOf(
|
||||
if (prefixes.isEmpty()) null else stringResource(R.string.routing_preset_prefixes, prefixes.size),
|
||||
if (keywords.isEmpty()) null else stringResource(R.string.routing_preset_keywords, keywords.size),
|
||||
).filterNotNull()
|
||||
return if (parts.isEmpty())
|
||||
{
|
||||
stringResource(R.string.routing_preset_no_patterns)
|
||||
}
|
||||
else
|
||||
{
|
||||
parts.joinToString(separator = " · ")
|
||||
}
|
||||
}
|
||||
+1
@@ -70,6 +70,7 @@ fun ProfileEditorPane(
|
||||
val saved = checked.toProfile(profile)
|
||||
if (profile != null) dataSource.updateVpnProfile(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)
|
||||
intent.putExtra(Constants.VPN_PROFILES_SINGLE, saved.getUUID().toString())
|
||||
LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
|
||||
|
||||
+208
-290
@@ -10,6 +10,7 @@
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import org.strongswan.android.ui.compose.components.ShxSectionCard
|
||||
import org.strongswan.android.ui.compose.components.ShxSettingRow
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.security.KeyChain
|
||||
@@ -38,11 +39,6 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
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.material3.Button
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
@@ -84,18 +80,37 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.SelectedAppsHandling
|
||||
import org.strongswan.android.data.VpnProfileDataSource
|
||||
import org.strongswan.android.data.VpnType
|
||||
import org.strongswan.android.data.VpnType.VpnTypeFeature
|
||||
import org.strongswan.android.ui.SelectedApplicationsActivity
|
||||
import org.strongswan.android.ui.MainActivity
|
||||
import org.strongswan.android.ui.TrustedCertificatesActivity
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
import org.strongswan.android.utils.Constants
|
||||
import org.strongswan.android.utils.IPRangeSet
|
||||
import org.strongswan.android.utils.Utils
|
||||
import java.util.TreeSet
|
||||
|
||||
data class ProfileFormState(
|
||||
val name: String = "",
|
||||
@@ -123,8 +138,6 @@ data class ProfileFormState(
|
||||
val excludedSubnets: String = "",
|
||||
val blockIpv4: Boolean = false,
|
||||
val blockIpv6: Boolean = false,
|
||||
val appsHandling: SelectedAppsHandling = SelectedAppsHandling.SELECTED_APPS_DISABLE,
|
||||
val selectedApps: Set<String> = emptySet(),
|
||||
val ikeProposal: String = "",
|
||||
val espProposal: String = "",
|
||||
val proxyHost: String = "",
|
||||
@@ -177,8 +190,6 @@ fun ProfileFormState.toProfile(existing: VpnProfile?): VpnProfile
|
||||
if (blockIpv4) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4
|
||||
if (blockIpv6) st = st or VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6
|
||||
profile.splitTunneling = if (st == 0) null else st
|
||||
profile.selectedAppsHandling = appsHandling
|
||||
profile.setSelectedApps(TreeSet(selectedApps))
|
||||
profile.ikeProposal = ikeProposal.trim().ifEmpty { null }
|
||||
profile.espProposal = espProposal.trim().ifEmpty { null }
|
||||
profile.proxyHost = proxyHost.trim().ifEmpty { null }
|
||||
@@ -217,8 +228,6 @@ fun VpnProfile.toFormState(): ProfileFormState
|
||||
excludedSubnets = excludedSubnets ?: "",
|
||||
blockIpv4 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV4 != 0,
|
||||
blockIpv6 = st and VpnProfile.SPLIT_TUNNELING_BLOCK_IPV6 != 0,
|
||||
appsHandling = selectedAppsHandling,
|
||||
selectedApps = selectedAppsSet ?: emptySet(),
|
||||
ikeProposal = ikeProposal ?: "",
|
||||
espProposal = espProposal ?: "",
|
||||
proxyHost = proxyHost ?: "",
|
||||
@@ -228,7 +237,6 @@ fun VpnProfile.toFormState(): ProfileFormState
|
||||
advanced = remoteId != null || localId != null || dnsServers != null || getMTU() != null
|
||||
|| port != null || getNATKeepAlive() != null || flags != 0 || st != 0
|
||||
|| includedSubnets != null || excludedSubnets != null
|
||||
|| selectedAppsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE
|
||||
|| ikeProposal != null || espProposal != null || proxyHost != null,
|
||||
)
|
||||
}
|
||||
@@ -289,6 +297,9 @@ fun ProfileFormState.isValid(): Boolean
|
||||
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)
|
||||
@Composable
|
||||
fun ProfileFormScreen(
|
||||
@@ -302,15 +313,14 @@ fun ProfileFormScreen(
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
val snackbarHostState = remember { androidx.compose.material3.SnackbarHostState() }
|
||||
androidx.compose.runtime.LaunchedEffect(messages)
|
||||
{
|
||||
messages?.collect { snackbarHostState.showSnackbar(context.getString(it)) }
|
||||
}
|
||||
val types = stringArrayResource(R.array.vpn_types)
|
||||
val appsHandling = stringArrayResource(R.array.apps_handling)
|
||||
var typeExpanded by remember { mutableStateOf(false) }
|
||||
var appsExpanded by remember { mutableStateOf(false) }
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
val enabled = !state.readOnly
|
||||
|
||||
@@ -322,336 +332,244 @@ fun ProfileFormScreen(
|
||||
onChange(latest.value.copy(caAlias = alias, caLabel = alias ?: "", caAuto = alias == null))
|
||||
}
|
||||
}
|
||||
val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK)
|
||||
{
|
||||
val list = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST) ?: arrayListOf()
|
||||
onChange(latest.value.copy(selectedApps = list.toSet()))
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
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 ->
|
||||
Box(Modifier.fillMaxSize().background(c.background))
|
||||
{
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
{
|
||||
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,
|
||||
onValueChange = { onChange(state.copy(gateway = it, gatewayError = null)) },
|
||||
label = { Text(stringResource(R.string.profile_gateway_label)) },
|
||||
supportingText = { Text(state.gatewayError ?: stringResource(R.string.profile_gateway_hint)) },
|
||||
isError = state.gatewayError != null,
|
||||
placeholder = stringResource(R.string.form_gateway_placeholder),
|
||||
error = state.gatewayError,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||
)
|
||||
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = types.getOrElse(state.vpnType.ordinal) { "" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.profile_vpn_type_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
ShxSectionHeader(stringResource(R.string.form_login))
|
||||
if (state.vpnType in LOGIN_TYPES)
|
||||
{
|
||||
ShxTabs(
|
||||
options = listOf(stringResource(R.string.form_login_password), stringResource(R.string.form_login_cert), stringResource(R.string.form_login_both)),
|
||||
selected = LOGIN_TYPES.indexOf(state.vpnType),
|
||||
onSelect = { if (enabled) onChange(state.copy(vpnType = LOGIN_TYPES[it])) },
|
||||
)
|
||||
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false }) {
|
||||
VpnType.values().forEachIndexed { index, type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(types.getOrElse(index) { type.identifier }) },
|
||||
onClick = {
|
||||
onChange(state.copy(vpnType = type))
|
||||
typeExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ShxNote(types.getOrElse(state.vpnType.ordinal) { state.vpnType.identifier }, tone = ShxTone.NEUTRAL)
|
||||
}
|
||||
if (state.vpnType.has(VpnTypeFeature.USER_PASS))
|
||||
{
|
||||
OutlinedTextField(
|
||||
ShxFormField(
|
||||
label = stringResource(R.string.profile_username_label).trimEnd(':'),
|
||||
value = state.username,
|
||||
onValueChange = { onChange(state.copy(username = it, usernameError = null)) },
|
||||
label = { Text(stringResource(R.string.profile_username_label)) },
|
||||
isError = state.usernameError != null,
|
||||
supportingText = state.usernameError?.let { { Text(it) } },
|
||||
error = state.usernameError,
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
OutlinedTextField(
|
||||
ShxFormField(
|
||||
label = stringResource(R.string.profile_password_label).trimEnd(':'),
|
||||
value = state.password,
|
||||
onValueChange = { onChange(state.copy(password = it)) },
|
||||
label = { Text(stringResource(R.string.profile_password_label)) },
|
||||
supportingText = { Text(stringResource(R.string.profile_password_hint)) },
|
||||
supporting = stringResource(R.string.profile_password_hint),
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(if (showPassword) Icons.Default.VisibilityOff else Icons.Default.Visibility, null)
|
||||
trailing = {
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.size(44.dp).clip(CircleShape).clickable { showPassword = !showPassword })
|
||||
{
|
||||
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))
|
||||
{
|
||||
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 ->
|
||||
onChange(latest.value.copy(userCertAlias = alias))
|
||||
}, arrayOf("RSA", "EC"), null, null, -1, state.userCertAlias)
|
||||
}, enabled = enabled) {
|
||||
Text(state.userCertAlias ?: stringResource(R.string.profile_user_select_certificate))
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
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)
|
||||
ShxPanel()
|
||||
{
|
||||
TextButton(onClick = {
|
||||
val intent = Intent(context, TrustedCertificatesActivity::class.java)
|
||||
intent.action = TrustedCertificatesActivity.SELECT_CERTIFICATE
|
||||
pickCa.launch(intent)
|
||||
}, enabled = enabled) {
|
||||
Text(state.caLabel.ifEmpty { stringResource(R.string.profile_ca_select_certificate) })
|
||||
ShxToggleRow(stringResource(R.string.profile_ca_auto_label), state.caAuto, { onChange(state.copy(caAuto = it)) }, enabled = enabled, divider = !state.caAuto)
|
||||
if (!state.caAuto)
|
||||
{
|
||||
FormPickRow(
|
||||
title = 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(
|
||||
value = state.name,
|
||||
onValueChange = { onChange(state.copy(name = it)) },
|
||||
label = { Text(stringResource(R.string.profile_name_label)) },
|
||||
supportingText = { Text(stringResource(R.string.profile_name_hint)) },
|
||||
enabled = enabled,
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
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)) },
|
||||
/* «More: port, MTU, DNS, identities» */
|
||||
val chevron by animateFloatAsState(if (state.advanced) 90f else 0f, label = "adv")
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(52.dp).clip(RoundedCornerShape(16.dp)).background(c.card)
|
||||
.border(1.dp, c.cardBorder, RoundedCornerShape(16.dp))
|
||||
.clickable(role = Role.Button) { onChange(state.copy(advanced = !state.advanced)) }.padding(horizontal = 16.dp),
|
||||
)
|
||||
{
|
||||
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(
|
||||
visible = state.advanced,
|
||||
enter = expandVertically() + fadeIn(),
|
||||
exit = shrinkVertically() + fadeOut(),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) {
|
||||
OutlinedTextField(state.remoteId, { onChange(state.copy(remoteId = it)) },
|
||||
label = { Text(stringResource(R.string.profile_remote_id_label)) },
|
||||
supportingText = { Text(stringResource(R.string.profile_remote_id_hint)) },
|
||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.localId, { onChange(state.copy(localId = it)) },
|
||||
label = { Text(stringResource(R.string.profile_local_id_label)) },
|
||||
supportingText = { Text(stringResource(R.string.profile_local_id_hint_user)) },
|
||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.dns, { onChange(state.copy(dns = it)) },
|
||||
label = { Text(stringResource(R.string.profile_dns_servers_label)) },
|
||||
supportingText = { Text(stringResource(R.string.profile_dns_servers_hint)) },
|
||||
enabled = enabled, singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.mtu, { onChange(state.copy(mtu = it)) },
|
||||
label = { Text(stringResource(R.string.profile_mtu_label)) },
|
||||
enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.port, { onChange(state.copy(port = it)) },
|
||||
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),
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
SwitchRow(stringResource(R.string.profile_cert_req_label), state.certReq, enabled) { onChange(state.copy(certReq = it)) }
|
||||
SwitchRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, enabled) { onChange(state.copy(useOcsp = it)) }
|
||||
SwitchRow(stringResource(R.string.profile_use_crl_label), state.useCrl, enabled) { onChange(state.copy(useCrl = it)) }
|
||||
SwitchRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, enabled) { onChange(state.copy(strictRevocation = it)) }
|
||||
SwitchRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, enabled) { onChange(state.copy(rsaPss = it)) }
|
||||
SwitchRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, enabled) { onChange(state.copy(ipv6Transport = it)) }
|
||||
OutlinedTextField(state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
|
||||
label = { Text(stringResource(R.string.profile_included_subnets_label)) },
|
||||
isError = state.includedSubnetsError != null,
|
||||
supportingText = { state.includedSubnetsError?.let { Text(it) } },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
|
||||
label = { Text(stringResource(R.string.profile_excluded_subnets_label)) },
|
||||
isError = state.excludedSubnetsError != null,
|
||||
supportingText = { state.excludedSubnetsError?.let { Text(it) } },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
SwitchRow(
|
||||
label = stringResource(R.string.profile_split_tunnelingv4_title),
|
||||
checked = state.blockIpv4,
|
||||
enabled = enabled,
|
||||
) { onChange(state.copy(blockIpv4 = it)) }
|
||||
SwitchRow(
|
||||
label = stringResource(R.string.profile_split_tunnelingv6_title),
|
||||
supporting = stringResource(R.string.profile_split_tunnelingv6_hint),
|
||||
checked = state.blockIpv6,
|
||||
enabled = enabled,
|
||||
) { onChange(state.copy(blockIpv6 = it)) }
|
||||
ExposedDropdownMenuBox(expanded = appsExpanded, onExpandedChange = { if (enabled) appsExpanded = it }) {
|
||||
OutlinedTextField(
|
||||
value = appsHandling.getOrElse(state.appsHandling.ordinal) { "" },
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
enabled = enabled,
|
||||
label = { Text(stringResource(R.string.profile_select_apps)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(appsExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
)
|
||||
ExposedDropdownMenu(expanded = appsExpanded, onDismissRequest = { appsExpanded = false }) {
|
||||
SelectedAppsHandling.values().forEachIndexed { index, handling ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(appsHandling.getOrElse(index) { handling.name }) },
|
||||
onClick = {
|
||||
onChange(state.copy(appsHandling = handling))
|
||||
appsExpanded = false
|
||||
},
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp))
|
||||
{
|
||||
ExposedDropdownMenuBox(expanded = typeExpanded, onExpandedChange = { if (enabled) typeExpanded = it })
|
||||
{
|
||||
FormPickRow(
|
||||
title = types.getOrElse(state.vpnType.ordinal) { "" },
|
||||
subtitle = stringResource(R.string.profile_vpn_type_label).trimEnd(':'),
|
||||
enabled = enabled,
|
||||
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable),
|
||||
) { typeExpanded = true }
|
||||
ExposedDropdownMenu(expanded = typeExpanded, onDismissRequest = { typeExpanded = false })
|
||||
{
|
||||
VpnType.values().forEachIndexed { index, type ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(types.getOrElse(index) { type.identifier }, style = ShxType.body) },
|
||||
onClick = {
|
||||
onChange(state.copy(vpnType = type))
|
||||
typeExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.appsHandling != SelectedAppsHandling.SELECTED_APPS_DISABLE)
|
||||
{
|
||||
TextButton(onClick = {
|
||||
val intent = Intent(context, SelectedApplicationsActivity::class.java)
|
||||
intent.putStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST, ArrayList(state.selectedApps))
|
||||
pickApps.launch(intent)
|
||||
}, enabled = enabled) {
|
||||
Text(stringResource(R.string.profile_select_apps) + " (${state.selectedApps.size})")
|
||||
ShxFormField(stringResource(R.string.profile_remote_id_label).trimEnd(':'), state.remoteId, { onChange(state.copy(remoteId = it)) },
|
||||
supporting = stringResource(R.string.profile_remote_id_hint), enabled = enabled)
|
||||
ShxFormField(stringResource(R.string.profile_local_id_label).trimEnd(':'), state.localId, { onChange(state.copy(localId = it)) },
|
||||
supporting = stringResource(R.string.profile_local_id_hint_user), enabled = enabled)
|
||||
ShxFormField(stringResource(R.string.profile_dns_servers_label).trimEnd(':'), state.dns, { onChange(state.copy(dns = it)) },
|
||||
supporting = stringResource(R.string.profile_dns_servers_hint), enabled = enabled, mono = true)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
|
||||
{
|
||||
ShxFormField(stringResource(R.string.profile_port_label).trimEnd(':'), state.port, { onChange(state.copy(port = it)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
|
||||
ShxFormField(stringResource(R.string.profile_mtu_label).trimEnd(':'), state.mtu, { onChange(state.copy(mtu = it)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f))
|
||||
}
|
||||
ShxFormField(stringResource(R.string.profile_nat_keepalive_label).trimEnd(':'), state.natKeepalive, { onChange(state.copy(natKeepalive = it)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||
ShxPanel()
|
||||
{
|
||||
ShxToggleRow(stringResource(R.string.profile_cert_req_label), state.certReq, { onChange(state.copy(certReq = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_use_ocsp_label), state.useOcsp, { onChange(state.copy(useOcsp = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_use_crl_label), state.useCrl, { onChange(state.copy(useCrl = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_strict_revocation_label), state.strictRevocation, { onChange(state.copy(strictRevocation = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_rsa_pss_label), state.rsaPss, { onChange(state.copy(rsaPss = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_ipv6_transport_label), state.ipv6Transport, { onChange(state.copy(ipv6Transport = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv4_title), state.blockIpv4, { onChange(state.copy(blockIpv4 = it)) }, enabled = enabled, divider = true)
|
||||
ShxToggleRow(stringResource(R.string.profile_split_tunnelingv6_title), state.blockIpv6, { onChange(state.copy(blockIpv6 = it)) },
|
||||
subtitle = stringResource(R.string.profile_split_tunnelingv6_hint), enabled = enabled)
|
||||
}
|
||||
ShxFormField(stringResource(R.string.profile_included_subnets_label).trimEnd(':'), state.includedSubnets, { onChange(state.copy(includedSubnets = it)) },
|
||||
error = state.includedSubnetsError, enabled = enabled, mono = true, singleLine = false)
|
||||
ShxFormField(stringResource(R.string.profile_excluded_subnets_label).trimEnd(':'), state.excludedSubnets, { onChange(state.copy(excludedSubnets = it)) },
|
||||
error = state.excludedSubnetsError, enabled = enabled, mono = true, singleLine = false)
|
||||
ShxPanel()
|
||||
{
|
||||
ShxRow(
|
||||
title = stringResource(R.string.profile_routing_link_title),
|
||||
subtitle = stringResource(R.string.profile_routing_link_summary),
|
||||
icon = ShxIcons.Route,
|
||||
chevron = true,
|
||||
onClick = { context.startActivity(Intent(context, MainActivity::class.java).putExtra(MainActivity.EXTRA_OPEN_ROUTING, true)) },
|
||||
)
|
||||
}
|
||||
ShxFormField(stringResource(R.string.profile_proposals_ike_label).trimEnd(':'), state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
|
||||
error = state.ikeProposalError, enabled = enabled, mono = true)
|
||||
ShxFormField(stringResource(R.string.profile_proposals_esp_label).trimEnd(':'), state.espProposal, { onChange(state.copy(espProposal = it)) },
|
||||
error = state.espProposalError, enabled = enabled, mono = true)
|
||||
ShxFormField(stringResource(R.string.profile_proxy_host_label).trimEnd(':'), state.proxyHost, { onChange(state.copy(proxyHost = it)) }, enabled = enabled)
|
||||
ShxFormField(stringResource(R.string.profile_proxy_port_label).trimEnd(':'), state.proxyPort, { onChange(state.copy(proxyPort = it)) },
|
||||
enabled = enabled, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
|
||||
ShxFormField(stringResource(R.string.profile_proxy_exclusions_label).trimEnd(':'), state.proxyExclusions, { onChange(state.copy(proxyExclusions = it)) }, enabled = enabled)
|
||||
}
|
||||
OutlinedTextField(state.ikeProposal, { onChange(state.copy(ikeProposal = it)) },
|
||||
label = { Text(stringResource(R.string.profile_proposals_ike_label)) },
|
||||
isError = state.ikeProposalError != null,
|
||||
supportingText = { state.ikeProposalError?.let { Text(it) } },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.espProposal, { onChange(state.copy(espProposal = it)) },
|
||||
label = { Text(stringResource(R.string.profile_proposals_esp_label)) },
|
||||
isError = state.espProposalError != null,
|
||||
supportingText = { state.espProposalError?.let { Text(it) } },
|
||||
enabled = enabled, modifier = Modifier.fillMaxWidth())
|
||||
OutlinedTextField(state.proxyHost, { onChange(state.copy(proxyHost = it)) },
|
||||
label = { Text(stringResource(R.string.profile_proxy_host_label)) },
|
||||
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
|
||||
private fun SwitchRow(
|
||||
label: String,
|
||||
checked: Boolean,
|
||||
private fun FormPickRow(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
enabled: Boolean,
|
||||
supporting: String? = null,
|
||||
onChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
bordered: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
)
|
||||
{
|
||||
ListItem(
|
||||
headlineContent = { Text(label) },
|
||||
supportingContent = supporting?.let { { Text(it) } },
|
||||
trailingContent = {
|
||||
Switch(checked = checked, onCheckedChange = onChange, enabled = enabled)
|
||||
},
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
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.lazy.LazyColumn
|
||||
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.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -71,6 +59,42 @@ import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.logic.VpnStateService.State
|
||||
import org.strongswan.android.ui.compose.components.ShxCard
|
||||
@@ -92,11 +116,12 @@ fun ProfilesScreen(
|
||||
onRestore: (VpnProfile) -> Unit,
|
||||
onShare: (VpnProfile) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onFastest: () -> Unit = {},
|
||||
)
|
||||
{
|
||||
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
|
||||
}
|
||||
/* null = nothing open, "" = new profile, otherwise the edited profile's uuid */
|
||||
@@ -135,7 +160,7 @@ fun ProfilesScreen(
|
||||
{
|
||||
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
|
||||
private fun ProfilesList(
|
||||
state: HomeUiState,
|
||||
@@ -154,21 +181,33 @@ private fun ProfilesList(
|
||||
onRestore: (VpnProfile) -> Unit,
|
||||
onShare: (VpnProfile) -> Unit,
|
||||
onAdd: () -> Unit,
|
||||
onFastest: () -> Unit = {},
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
var query by remember { mutableStateOf("") }
|
||||
var menuFor by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
var deleteFor by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
val activeUuid = state.activeProfile?.getUUID()
|
||||
val connected = state.vpnState == State.CONNECTED
|
||||
val profiles = state.profiles
|
||||
val filtered = remember(query, profiles)
|
||||
{
|
||||
if (query.isBlank()) profiles
|
||||
else profiles.filter {
|
||||
(it.name ?: "").contains(query, ignoreCase = true) || (it.gateway ?: "").contains(query, ignoreCase = true)
|
||||
/* direct pings of every server (outside the tunnel), refreshed while the tab is shown */
|
||||
val lifecycle = androidx.lifecycle.compose.LocalLifecycleOwner.current.lifecycle
|
||||
val pings by produceState(emptyMap<String, ServerPing>(), profiles, lifecycle) {
|
||||
lifecycle.repeatOnLifecycle(androidx.lifecycle.Lifecycle.State.STARTED) {
|
||||
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(
|
||||
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
) { _ ->
|
||||
Column(Modifier.fillMaxSize())
|
||||
deleteFor?.let { profile ->
|
||||
val current = profile.getUUID() == activeUuid && state.vpnState != State.DISABLED
|
||||
ShxDialog(
|
||||
title = stringResource(R.string.server_delete_title, profile.name ?: profile.gateway),
|
||||
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(
|
||||
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())
|
||||
ShxScreenTitle(stringResource(R.string.nav_profiles))
|
||||
{
|
||||
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(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
||||
) {
|
||||
LazyColumn {
|
||||
itemsIndexed(filtered, key = { _, p -> p.getUUID().toString() }) { index, profile ->
|
||||
val isActive = connected && profile.getUUID() == activeUuid
|
||||
Column(Modifier.animateItem()) {
|
||||
Box {
|
||||
ShxProfileRow(
|
||||
profile = profile,
|
||||
active = isActive,
|
||||
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
|
||||
onLongClick = { menuFor = profile },
|
||||
trailing = {
|
||||
IconButton(onClick = { menuFor = profile }) {
|
||||
Icon(
|
||||
Icons.Rounded.MoreVert,
|
||||
contentDescription = stringResource(R.string.action_more_options),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
if (profiles.isEmpty())
|
||||
{
|
||||
EmptyProfilesState(onAdd)
|
||||
return@Column
|
||||
}
|
||||
if (profiles.size > 1)
|
||||
{
|
||||
AutoServerCard(onFastest)
|
||||
}
|
||||
ShxSectionHeader(stringResource(R.string.servers_mine, profiles.size), Modifier.padding(top = 8.dp))
|
||||
ShxPanel()
|
||||
{
|
||||
profiles.forEachIndexed { index, profile ->
|
||||
val isActive = connected && profile.getUUID() == activeUuid
|
||||
ServerRow(
|
||||
profile = profile,
|
||||
active = isActive,
|
||||
ping = pings[profile.getUUID().toString()],
|
||||
onClick = { if (isActive) onDisconnect() else onConnect(profile) },
|
||||
onMore = { menuFor = profile },
|
||||
)
|
||||
if (index < profiles.lastIndex)
|
||||
{
|
||||
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
private fun EmptyProfilesState(query: String, onAdd: () -> Unit)
|
||||
private fun AutoServerCard(onClick: () -> Unit)
|
||||
{
|
||||
ShxCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Row(
|
||||
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 = Icons.Rounded.Language,
|
||||
message = stringResource(R.string.no_profiles),
|
||||
actionLabel = stringResource(R.string.add_profile_cta),
|
||||
onAction = onAdd,
|
||||
)
|
||||
Icon(ShxIcons.Bolt, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
else
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
|
||||
{
|
||||
ShxEmptyState(
|
||||
icon = Icons.Rounded.SearchOff,
|
||||
message = stringResource(R.string.nothing_found),
|
||||
)
|
||||
Text(stringResource(R.string.servers_auto), style = ShxType.rowTitle.copy(fontWeight = FontWeight.ExtraBold), color = c.text)
|
||||
Text(stringResource(R.string.servers_auto_sub), style = ShxType.sub.copy(fontWeight = FontWeight.SemiBold), color = c.onAccentContainer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
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.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.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.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.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
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.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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.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 androidx.compose.foundation.layout.Arrangement
|
||||
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 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.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
|
||||
fun RouteCheckScreen()
|
||||
fun RouteCheckScreen(onBack: () -> Unit, initialMode: RouteCheckMode = RouteCheckMode.SITE, initialQuery: String = "")
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val c = LocalShxColors.current
|
||||
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 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(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.md),
|
||||
)
|
||||
/** The installed apps; loads them on first use, so a check never runs against a list that is still empty. */
|
||||
suspend fun loadApps(): List<InstalledApp>
|
||||
{
|
||||
Text(
|
||||
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),
|
||||
)
|
||||
if (apps.isEmpty())
|
||||
{
|
||||
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
|
||||
private fun RouteCheckResult(report: RouteCheck.Report)
|
||||
{
|
||||
val status = LocalShxStatusColors.current
|
||||
Column(
|
||||
modifier = Modifier.padding(top = ShxSpacing.lg),
|
||||
verticalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
||||
)
|
||||
LaunchedEffect(mode)
|
||||
{
|
||||
if (!report.vpnNetworkFound)
|
||||
if (mode == RouteCheckMode.APP)
|
||||
{
|
||||
ShxStatusPill(
|
||||
text = stringResource(R.string.route_check_no_vpn),
|
||||
containerColor = status.disconnectedContainer,
|
||||
contentColor = status.onDisconnectedContainer,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
loadApps()
|
||||
}
|
||||
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)
|
||||
RouteCheckRow(stringResource(R.string.route_check_via_direct_dns), report.directDnsIps)
|
||||
return
|
||||
}
|
||||
ShxSectionCard(title = stringResource(R.string.route_check_routes))
|
||||
{
|
||||
for (route in report.routes)
|
||||
checking = true
|
||||
scope.launch {
|
||||
result = when (mode)
|
||||
{
|
||||
val label = stringResource(
|
||||
when (route.kind)
|
||||
RouteCheckMode.SITE -> checkSite(context, manager, input, rulesActive)
|
||||
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
|
||||
RouteCheck.RouteKind.DIRECT_RU -> R.string.route_verdict_direct
|
||||
RouteCheck.RouteKind.TUNNEL_DNS -> R.string.route_verdict_dns
|
||||
},
|
||||
)
|
||||
val direct = route.kind == RouteCheck.RouteKind.DIRECT_RU
|
||||
mode = m
|
||||
query = ""
|
||||
result = null
|
||||
}
|
||||
},
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically)
|
||||
{
|
||||
val shape = RoundedCornerShape(16.dp)
|
||||
Row(
|
||||
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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(
|
||||
text = route.ip,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier.weight(1f),
|
||||
Icon(
|
||||
when (mode)
|
||||
{
|
||||
RouteCheckMode.SITE -> ShxIcons.Globe
|
||||
RouteCheckMode.IP -> ShxIcons.Route
|
||||
RouteCheckMode.APP -> ShxIcons.Apps
|
||||
},
|
||||
contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp),
|
||||
)
|
||||
ShxStatusPill(
|
||||
text = label,
|
||||
containerColor = if (direct) status.connectingContainer else status.connectedContainer,
|
||||
contentColor = if (direct) status.onConnectingContainer else status.onConnectedContainer,
|
||||
Box(Modifier.weight(1f))
|
||||
{
|
||||
if (query.isEmpty())
|
||||
{
|
||||
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 = stringResource(R.string.route_check_resolve_failed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
if (reach != null) stringResource(R.string.route_check_opens_ms, reach) else stringResource(R.string.route_check_not_opens),
|
||||
style = ShxType.chip.copy(fontWeight = FontWeight.ExtraBold),
|
||||
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
|
||||
private fun RouteCheckRow(label: String, ips: List<String>)
|
||||
private fun SiteResult(result: CheckResult.Site, rulesActive: Boolean, onAlways: (Boolean) -> Unit)
|
||||
{
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
val c = LocalShxColors.current
|
||||
val report = result.report
|
||||
if (report.routes.isEmpty())
|
||||
{
|
||||
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) },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
{
|
||||
Text(text, style = ShxType.body.copy(fontWeight = FontWeight.ExtraBold), color = fg)
|
||||
}
|
||||
}
|
||||
|
||||
@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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+262
-435
@@ -4,155 +4,164 @@
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import org.strongswan.android.ui.compose.components.ShxSnackbarHost
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.text.format.DateUtils
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
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.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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.AltRoute
|
||||
import androidx.compose.material.icons.rounded.Block
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material.icons.rounded.ExpandLess
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.material.icons.rounded.Lan
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
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.Rule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.LoadingIndicator
|
||||
import androidx.compose.material.icons.rounded.Shield
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearWavyProgressIndicator
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.material.icons.rounded.ExpandLess
|
||||
import androidx.compose.material.icons.rounded.ExpandMore
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
import androidx.compose.material3.SegmentedButtonDefaults
|
||||
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
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.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
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.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
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.data.VpnProfileDataSource
|
||||
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.routing.ForceTunnelPrefix
|
||||
import org.strongswan.android.logic.routing.RuleSet
|
||||
import org.strongswan.android.logic.routing.RoutingManager
|
||||
import org.strongswan.android.logic.routing.RuIpListManager
|
||||
import org.strongswan.android.logic.routing.RuIpListState
|
||||
import org.strongswan.android.logic.routing.RuIpListStatus
|
||||
import org.strongswan.android.utils.Constants
|
||||
import org.strongswan.android.ui.RouteCheckActivity
|
||||
import org.strongswan.android.ui.SelectedApplicationsActivity
|
||||
import org.strongswan.android.ui.compose.components.ShxEmptyState
|
||||
import org.strongswan.android.ui.compose.components.ShxIconBadge
|
||||
import org.strongswan.android.ui.compose.components.ShxSection
|
||||
import org.strongswan.android.ui.compose.components.ShxSectionDivider
|
||||
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.routing.DeadHostsPage
|
||||
import org.strongswan.android.ui.compose.routing.DirectAppsScreen
|
||||
import org.strongswan.android.ui.compose.routing.RecentAppsPage
|
||||
import org.strongswan.android.ui.compose.routing.RulesPage
|
||||
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.routing.RoutingHeroCard
|
||||
import org.strongswan.android.ui.compose.routing.RuToggleRow
|
||||
import org.strongswan.android.ui.compose.routing.RuModeSelector
|
||||
import org.strongswan.android.ui.compose.routing.RuDetailsDisclosure
|
||||
import org.strongswan.android.ui.compose.routing.GeoipChipsRow
|
||||
import org.strongswan.android.ui.compose.routing.RfActionRow
|
||||
import org.strongswan.android.ui.compose.routing.RuleSetRow
|
||||
import org.strongswan.android.ui.compose.routing.AddRuleRow
|
||||
import org.strongswan.android.ui.compose.routing.ForcePrefixRow
|
||||
import org.strongswan.android.ui.compose.routing.AddForcePrefixRow
|
||||
import org.strongswan.android.ui.compose.routing.ForcePrefixDialog
|
||||
|
||||
/** In-tab pages of the Routing tab; "back" returns to the main page, not out of the tab. */
|
||||
private enum class RoutingPage { MAIN, APPS, RULES, RECENT, DEAD }
|
||||
|
||||
@Composable
|
||||
fun RoutingScreen()
|
||||
{
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
var page by rememberSaveable { mutableStateOf(RoutingPage.MAIN) }
|
||||
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
|
||||
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { ShxSnackbarHost(snackbarHostState) },
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
containerColor = LocalShxColors.current.background,
|
||||
) { _ ->
|
||||
RoutingContent(snackbarHostState)
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter)
|
||||
{
|
||||
PredictiveBackScale(
|
||||
enabled = page != RoutingPage.MAIN,
|
||||
onBack = { page = if (page == RoutingPage.DEAD) RoutingPage.RECENT else RoutingPage.MAIN },
|
||||
modifier = Modifier
|
||||
.widthIn(max = 640.dp)
|
||||
.fillMaxSize(),
|
||||
)
|
||||
{
|
||||
AnimatedContent(
|
||||
targetState = page,
|
||||
transitionSpec = {
|
||||
val forward = targetState != RoutingPage.MAIN
|
||||
(fadeIn(effects) + slideInHorizontally(spatial) { if (forward) it / 8 else -it / 8 }) togetherWith
|
||||
(fadeOut(effects) + slideOutHorizontally(spatial) { if (forward) -it / 8 else it / 8 })
|
||||
},
|
||||
label = "routingPage",
|
||||
) { current ->
|
||||
RoutingPageContent(current, snackbarHostState, onNavigate = { page = it }, onBack = { page = RoutingPage.MAIN })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun RoutingContent(snackbarHostState: SnackbarHostState)
|
||||
private fun RoutingPageContent(
|
||||
page: RoutingPage,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onNavigate: (RoutingPage) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val manager = remember { RoutingManager.getInstance(context) }
|
||||
@@ -160,58 +169,78 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
|
||||
val state by manager.state.collectAsStateWithLifecycle()
|
||||
val ruState by ruList.state.collectAsStateWithLifecycle()
|
||||
val config = state.config
|
||||
var editingId by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var focusName by rememberSaveable { mutableStateOf(false) }
|
||||
var pickTarget by remember { mutableStateOf<String?>(null) }
|
||||
/* force-prefix dialog: null = hidden, "" = add, otherwise the entry id */
|
||||
var forceDialogId by remember { mutableStateOf<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val pickApps = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val id = pickTarget
|
||||
if (result.resultCode == Activity.RESULT_OK && id != null)
|
||||
{
|
||||
val packages = result.data?.getStringArrayListExtra(VpnProfileDataSource.KEY_SELECTED_APPS_LIST)
|
||||
?: arrayListOf()
|
||||
manager.updateRuleSetPackages(id, packages.toSet())
|
||||
}
|
||||
pickTarget = null
|
||||
}
|
||||
|
||||
/* real number of apps excluded from the tunnel: prefixes/keywords expanded
|
||||
/* real packages that bypass the tunnel: categories and templates expanded
|
||||
* against installed apps (cached per config version inside the manager) */
|
||||
var excludedApps by remember { mutableStateOf(0) }
|
||||
LaunchedEffect(config)
|
||||
{
|
||||
excludedApps = if (config.enabled)
|
||||
val directPackages by produceState(initialValue = emptyList<String>(), config) {
|
||||
value = if (config.enabled)
|
||||
{
|
||||
withContext(Dispatchers.IO) { manager.computeExcludedPackages().size }
|
||||
withContext(Dispatchers.IO) { manager.computeExcludedPackages().filter { it != context.packageName }.sorted() }
|
||||
}
|
||||
else
|
||||
{
|
||||
0
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun addNewSet()
|
||||
/* dead direct destinations the engine keeps refusing and the split of the
|
||||
* session traffic; polled only while the screen is visible */
|
||||
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>(), lifecycle)
|
||||
{
|
||||
editingId = manager.addRuleSet()
|
||||
focusName = true
|
||||
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
val raw = try
|
||||
{
|
||||
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
|
||||
}
|
||||
catch (_: Throwable)
|
||||
{
|
||||
null
|
||||
}
|
||||
value = HomeViewModel.parseDeadTargets(raw)
|
||||
delay(5_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteSetWithUndo(ruleSet: RuleSet)
|
||||
/* the four busiest apps of the last hour for the «Recent apps» preview */
|
||||
val recent by produceState(initialValue = emptyList<RecentPreview>(), directPackages, deadTargets)
|
||||
{
|
||||
scope.launch {
|
||||
manager.deleteRuleSet(ruleSet.id)
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.routing_delete_snackbar),
|
||||
actionLabel = context.getString(R.string.action_undo),
|
||||
duration = SnackbarDuration.Long,
|
||||
)
|
||||
if (result == SnackbarResult.ActionPerformed)
|
||||
{
|
||||
manager.restoreRuleSet(ruleSet)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,331 +259,129 @@ private fun RoutingContent(snackbarHostState: SnackbarHostState)
|
||||
snackbarHostState.showSnackbar(message)
|
||||
}
|
||||
}
|
||||
val ruleCount = config.siteRules.size + config.forceTunnelPrefixes.size + config.directPrefixes.size + config.packagePatterns.size
|
||||
/* the hero says what bypasses the VPN: only the "direct" rules count there */
|
||||
val directRuleCount = config.siteRules.count { it.direct } + config.directPrefixes.size + config.packagePatterns.size
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = ShxSpacing.xl),
|
||||
)
|
||||
when (page)
|
||||
{
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.routing_title), fontWeight = FontWeight.SemiBold) },
|
||||
actions = {
|
||||
IconButton(onClick = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) })
|
||||
{
|
||||
Icon(
|
||||
Icons.Rounded.Route,
|
||||
contentDescription = stringResource(R.string.route_check_title),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
RoutingPage.APPS -> DirectAppsScreen(onBack = onBack)
|
||||
RoutingPage.RULES -> RulesPage(onBack = onBack)
|
||||
RoutingPage.RECENT -> RecentAppsPage(
|
||||
deadTargets = deadTargets,
|
||||
onForceTunnel = { dst -> forceTunnel(manager, dst) },
|
||||
onOpenDead = { onNavigate(RoutingPage.DEAD) },
|
||||
onBack = onBack,
|
||||
)
|
||||
|
||||
/* ---- hero: master toggle + live summary ---- */
|
||||
RoutingHeroCard(
|
||||
RoutingPage.DEAD -> DeadHostsPage(
|
||||
targets = deadTargets,
|
||||
onForceTunnel = { dst -> forceTunnel(manager, dst) },
|
||||
onBack = { onNavigate(RoutingPage.RECENT) },
|
||||
)
|
||||
RoutingPage.MAIN -> MainPage(
|
||||
enabled = config.enabled,
|
||||
setCount = config.ruleSets.count { it.enabled },
|
||||
appCount = excludedApps,
|
||||
onToggle = { manager.setGlobalEnabled(it) },
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg),
|
||||
)
|
||||
|
||||
/* kill switch + excluded apps: excluded apps are added to the VPN's disallowed list
|
||||
* (CharonVpnService.addDisallowedApplication), so the system lockdown does not
|
||||
* cover them - on a tunnel drop they keep using the normal network */
|
||||
if (config.enabled && excludedApps > 0 && remember { context.isVpnLockdownEnabled() })
|
||||
{
|
||||
Text(
|
||||
text = stringResource(R.string.routing_killswitch_note, excludedApps),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg + ShxSpacing.xs, vertical = ShxSpacing.sm),
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- dead direct destinations the engine keeps refusing ---- */
|
||||
val deadTargets by produceState(initialValue = emptyList<DeadTargetUi>())
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
val raw = try
|
||||
{
|
||||
withContext(Dispatchers.Default) { CharonVpnService.getDeadDirectTargets() }
|
||||
}
|
||||
catch (_: Throwable)
|
||||
{
|
||||
null
|
||||
}
|
||||
value = HomeViewModel.parseDeadTargets(raw)
|
||||
delay(5_000)
|
||||
}
|
||||
}
|
||||
if (deadTargets.isNotEmpty())
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.dead_targets_title))
|
||||
{
|
||||
Text(
|
||||
text = stringResource(R.string.dead_targets_summary),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
|
||||
)
|
||||
deadTargets.forEachIndexed { index, target ->
|
||||
ShxSectionDivider()
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.md),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.lg, vertical = 10.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.background(MaterialTheme.colorScheme.error, CircleShape),
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = target.dst,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontFamily = 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- RU networks direct: geoip list + routing mode ---- */
|
||||
ShxSection(title = stringResource(R.string.routing_section_ru))
|
||||
{
|
||||
RuToggleRow(
|
||||
title = stringResource(R.string.rusplit_toggle_title),
|
||||
subtitle = stringResource(R.string.rusplit_toggle_summary),
|
||||
checked = ruState.enabled,
|
||||
updating = ruState.status == RuIpListStatus.UPDATING,
|
||||
onCheckedChange = { ruList.setEnabled(it) },
|
||||
onUpdate = { refreshGeoip() },
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- rule sets ---- */
|
||||
ShxSection(title = stringResource(R.string.routing_rules_section))
|
||||
{
|
||||
if (config.enabled)
|
||||
{
|
||||
RfActionRow(onClick = { manager.applyRfBundle() })
|
||||
if (config.ruleSets.isNotEmpty())
|
||||
{
|
||||
ShxSectionDivider()
|
||||
}
|
||||
}
|
||||
if (config.ruleSets.isEmpty())
|
||||
{
|
||||
ShxEmptyState(
|
||||
icon = Icons.Rounded.AltRoute,
|
||||
message = stringResource(R.string.routing_empty),
|
||||
actionLabel = stringResource(R.string.routing_add_rule),
|
||||
onAction = { addNewSet() },
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
config.ruleSets.forEachIndexed { index, ruleSet ->
|
||||
RuleSetRow(
|
||||
ruleSet = ruleSet,
|
||||
onToggle = { manager.setRuleSetEnabled(ruleSet.id, it) },
|
||||
onOpen = { editingId = ruleSet.id; focusName = false },
|
||||
)
|
||||
if (index < config.ruleSets.lastIndex)
|
||||
{
|
||||
ShxSectionDivider()
|
||||
}
|
||||
}
|
||||
ShxSectionDivider()
|
||||
AddRuleRow(onClick = { addNewSet() })
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- advanced: userspace mode, GeoIP list, force-tunnel prefixes ---- */
|
||||
var advancedOpen by rememberSaveable { mutableStateOf(false) }
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { advancedOpen = !advancedOpen }
|
||||
.padding(horizontal = ShxSpacing.lg + ShxSpacing.lg, vertical = ShxSpacing.lg),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
text = stringResource(R.string.routing_advanced).uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f).semantics { heading() },
|
||||
)
|
||||
Icon(
|
||||
if (advancedOpen) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
AnimatedVisibility(visible = advancedOpen)
|
||||
{
|
||||
Column {
|
||||
if (ruState.enabled)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.routing_section_ru))
|
||||
{
|
||||
if (ruState.enabled)
|
||||
{
|
||||
RuModeSelector(
|
||||
mode = ruState.mode,
|
||||
onModeChange = { ruList.setMode(it) },
|
||||
)
|
||||
if (ruState.status == RuIpListStatus.UPDATING)
|
||||
{
|
||||
LinearWavyProgressIndicator(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.lg, vertical = ShxSpacing.xs),
|
||||
)
|
||||
}
|
||||
GeoipChipsRow(state = ruState)
|
||||
RuDetailsDisclosure(mode = ruState.mode)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
/* ---- force-tunnel prefixes: always through the tunnel ---- */
|
||||
ShxSection(title = stringResource(R.string.routing_force_section))
|
||||
{
|
||||
if (config.forceTunnelPrefixes.isEmpty())
|
||||
{
|
||||
Text(
|
||||
text = stringResource(R.string.routing_force_summary),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = ShxSpacing.lg, vertical = 6.dp),
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
config.forceTunnelPrefixes.forEachIndexed { index, entry ->
|
||||
ForcePrefixRow(
|
||||
entry = entry,
|
||||
onToggle = { manager.setForcePrefixEnabled(entry.id, it) },
|
||||
onEdit = { forceDialogId = entry.id },
|
||||
onDelete = { manager.deleteForcePrefix(entry.id) },
|
||||
)
|
||||
if (index < config.forceTunnelPrefixes.lastIndex)
|
||||
{
|
||||
ShxSectionDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
ShxSectionDivider()
|
||||
AddForcePrefixRow(onClick = { forceDialogId = "" })
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- rule set editor, hosted outside the scrolling column ---- */
|
||||
val editing = editingId?.let { id -> config.ruleSets.firstOrNull { it.id == id } }
|
||||
if (editing != null)
|
||||
{
|
||||
RuleSetEditorSheet(
|
||||
ruleSet = editing,
|
||||
requestNameFocus = focusName,
|
||||
onDismiss = { editingId = null; focusName = false },
|
||||
onRename = { manager.updateRuleSetName(editing.id, it) },
|
||||
onPickApps = {
|
||||
pickTarget = editing.id
|
||||
val intent = Intent(context, SelectedApplicationsActivity::class.java)
|
||||
intent.putStringArrayListExtra(
|
||||
VpnProfileDataSource.KEY_SELECTED_APPS_LIST,
|
||||
ArrayList(editing.packages),
|
||||
)
|
||||
pickApps.launch(intent)
|
||||
},
|
||||
onTogglePreset = { manager.togglePreset(editing.id, it) },
|
||||
onAddPrefix = { prefix ->
|
||||
manager.updateConfig { cfg ->
|
||||
cfg.copy(ruleSets = cfg.ruleSets.map {
|
||||
if (it.id == editing.id) it.copy(prefixes = it.prefixes + prefix) else it
|
||||
})
|
||||
}
|
||||
},
|
||||
onRemovePrefix = { prefix ->
|
||||
manager.updateConfig { cfg ->
|
||||
cfg.copy(ruleSets = cfg.ruleSets.map {
|
||||
if (it.id == editing.id) it.copy(prefixes = it.prefixes - prefix) else it
|
||||
})
|
||||
}
|
||||
},
|
||||
onAddKeyword = { keyword ->
|
||||
manager.updateConfig { cfg ->
|
||||
cfg.copy(ruleSets = cfg.ruleSets.map {
|
||||
if (it.id == editing.id) it.copy(keywords = it.keywords + keyword.lowercase()) else it
|
||||
})
|
||||
}
|
||||
},
|
||||
onRemoveKeyword = { keyword ->
|
||||
manager.updateConfig { cfg ->
|
||||
cfg.copy(ruleSets = cfg.ruleSets.map {
|
||||
if (it.id == editing.id) it.copy(keywords = it.keywords - keyword) else it
|
||||
})
|
||||
}
|
||||
},
|
||||
onDelete = {
|
||||
deleteSetWithUndo(editing)
|
||||
editingId = null
|
||||
focusName = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/* ---- force prefix add/edit dialog, hosted outside the scrolling column ---- */
|
||||
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 },
|
||||
ruState = ruState,
|
||||
directPackages = directPackages,
|
||||
ruleCount = ruleCount,
|
||||
directRuleCount = directRuleCount,
|
||||
deadCount = deadTargets.count { !it.viaTunnel },
|
||||
split = split,
|
||||
recent = recent,
|
||||
onMasterToggle = manager::setGlobalEnabled,
|
||||
onRuToggle = ruList::setEnabled,
|
||||
onRuMode = ruList::setMode,
|
||||
onRefresh = { refreshGeoip() },
|
||||
onOpenApps = { onNavigate(RoutingPage.APPS) },
|
||||
onOpen = onNavigate,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "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
|
||||
private fun MainPage(
|
||||
enabled: Boolean,
|
||||
ruState: org.strongswan.android.logic.routing.RuIpListState,
|
||||
directPackages: List<String>,
|
||||
ruleCount: Int,
|
||||
directRuleCount: Int,
|
||||
deadCount: Int,
|
||||
split: Pair<Long, Long>?,
|
||||
recent: List<RecentPreview>,
|
||||
onMasterToggle: (Boolean) -> Unit,
|
||||
onRuToggle: (Boolean) -> Unit,
|
||||
onRuMode: (String) -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onOpenApps: () -> Unit,
|
||||
onOpen: (RoutingPage) -> Unit,
|
||||
)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
val openCheck = { context.startActivity(Intent(context, RouteCheckActivity::class.java)) }
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
{
|
||||
ShxScreenTitle(stringResource(R.string.routing_title))
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
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,
|
||||
ruleCount = ruleCount,
|
||||
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()
|
||||
{
|
||||
ShxRow(
|
||||
title = stringResource(R.string.dead_targets_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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-359
@@ -1,359 +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 androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
import androidx.compose.material.icons.rounded.Apps
|
||||
import androidx.compose.material.icons.rounded.Category
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
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.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
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.input.ImeAction
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.strongswan.android.R
|
||||
import org.strongswan.android.logic.routing.RuleSet
|
||||
import org.strongswan.android.logic.routing.RoutingPresets
|
||||
import org.strongswan.android.ui.compose.components.ShxIconBadge
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
|
||||
/**
|
||||
* Editor of one rule set as a modal bottom sheet. The preset picker is a
|
||||
* second view inside the same sheet (back returns to the editor) so that two
|
||||
* sheets never stack on top of each other.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RuleSetEditorSheet(
|
||||
ruleSet: RuleSet,
|
||||
requestNameFocus: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
onPickApps: () -> Unit,
|
||||
onTogglePreset: (RoutingPresets.Preset) -> Unit,
|
||||
onAddPrefix: (String) -> Unit,
|
||||
onRemovePrefix: (String) -> Unit,
|
||||
onAddKeyword: (String) -> Unit,
|
||||
onRemoveKeyword: (String) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
)
|
||||
{
|
||||
var showPresets by remember { mutableStateOf(false) }
|
||||
BackHandler(enabled = showPresets) { showPresets = false }
|
||||
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
)
|
||||
{
|
||||
AnimatedContent(
|
||||
targetState = showPresets,
|
||||
transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(120)) },
|
||||
label = "rule-set-editor",
|
||||
)
|
||||
{ presetsVisible ->
|
||||
if (presetsVisible)
|
||||
{
|
||||
PresetPickerContent(
|
||||
ruleSet = ruleSet,
|
||||
onToggle = onTogglePreset,
|
||||
onBack = { showPresets = false },
|
||||
)
|
||||
}
|
||||
else
|
||||
{
|
||||
EditorContent(
|
||||
ruleSet = ruleSet,
|
||||
requestNameFocus = requestNameFocus,
|
||||
onDismiss = onDismiss,
|
||||
onRename = onRename,
|
||||
onPickApps = onPickApps,
|
||||
onOpenPresets = { showPresets = true },
|
||||
onAddPrefix = onAddPrefix,
|
||||
onRemovePrefix = onRemovePrefix,
|
||||
onAddKeyword = onAddKeyword,
|
||||
onRemoveKeyword = onRemoveKeyword,
|
||||
onDelete = onDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorContent(
|
||||
ruleSet: RuleSet,
|
||||
requestNameFocus: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onRename: (String) -> Unit,
|
||||
onPickApps: () -> Unit,
|
||||
onOpenPresets: () -> Unit,
|
||||
onAddPrefix: (String) -> Unit,
|
||||
onRemovePrefix: (String) -> Unit,
|
||||
onAddKeyword: (String) -> Unit,
|
||||
onRemoveKeyword: (String) -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
)
|
||||
{
|
||||
val nameFocusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(ruleSet.id, requestNameFocus)
|
||||
{
|
||||
if (requestNameFocus)
|
||||
{
|
||||
nameFocusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = ShxSpacing.xl),
|
||||
)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(start = ShxSpacing.xl, end = ShxSpacing.md, bottom = ShxSpacing.sm),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
text = stringResource(R.string.routing_editor_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDismiss)
|
||||
{
|
||||
Text(stringResource(R.string.action_done))
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedTextField(
|
||||
value = ruleSet.name,
|
||||
onValueChange = onRename,
|
||||
label = { Text(stringResource(R.string.routing_rule_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.xl)
|
||||
.focusRequester(nameFocusRequester),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
|
||||
EditorRow(
|
||||
icon = Icons.Rounded.Apps,
|
||||
title = stringResource(R.string.routing_pick_apps),
|
||||
subtitle = stringResource(R.string.routing_apps_count, ruleSet.packages.size),
|
||||
onClick = onPickApps,
|
||||
)
|
||||
EditorRow(
|
||||
icon = Icons.Rounded.Category,
|
||||
title = stringResource(R.string.routing_presets_selected, countSelectedPresets(ruleSet)),
|
||||
subtitle = stringResource(R.string.routing_presets_hint),
|
||||
onClick = onOpenPresets,
|
||||
)
|
||||
|
||||
ChipGroup(
|
||||
label = stringResource(R.string.routing_prefixes_label),
|
||||
values = ruleSet.prefixes.sorted(),
|
||||
text = { it },
|
||||
onRemove = onRemovePrefix,
|
||||
addLabel = stringResource(R.string.routing_add_prefix),
|
||||
onAdd = onAddPrefix,
|
||||
)
|
||||
ChipGroup(
|
||||
label = stringResource(R.string.routing_keywords_label),
|
||||
values = ruleSet.keywords.sorted(),
|
||||
text = { "*$it*" },
|
||||
onRemove = onRemoveKeyword,
|
||||
addLabel = stringResource(R.string.routing_add_keyword),
|
||||
onAdd = onAddKeyword,
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onDelete,
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.md),
|
||||
)
|
||||
{
|
||||
Icon(
|
||||
Icons.Rounded.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(ShxSpacing.sm))
|
||||
Text(stringResource(R.string.routing_delete))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Labeled chip group with trailing add field, shared by prefixes and keywords. */
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ChipGroup(
|
||||
label: String,
|
||||
values: List<String>,
|
||||
text: (String) -> String,
|
||||
onRemove: (String) -> Unit,
|
||||
addLabel: String,
|
||||
onAdd: (String) -> Unit,
|
||||
)
|
||||
{
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(start = ShxSpacing.xl, top = ShxSpacing.lg),
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.xs),
|
||||
horizontalArrangement = Arrangement.spacedBy(ShxSpacing.sm),
|
||||
verticalArrangement = Arrangement.spacedBy(ShxSpacing.xs),
|
||||
)
|
||||
{
|
||||
values.forEach { value ->
|
||||
AssistChip(
|
||||
onClick = { onRemove(value) },
|
||||
label = { Text(text(value), maxLines = 1) },
|
||||
trailingIcon = {
|
||||
Icon(Icons.Rounded.Close, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
AddChipField(label = addLabel, onAdd = onAdd)
|
||||
}
|
||||
|
||||
/** Editor row: badge, title/subtitle, chevron. */
|
||||
@Composable
|
||||
private fun EditorRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> Unit)
|
||||
{
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = ShxSpacing.xl, vertical = 14.dp),
|
||||
)
|
||||
{
|
||||
ShxIconBadge(icon)
|
||||
Spacer(Modifier.width(ShxSpacing.lg))
|
||||
Column(Modifier.weight(1f))
|
||||
{
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact input with an add action; commits on the IME "done" or the trailing button. */
|
||||
@Composable
|
||||
private fun AddChipField(
|
||||
label: String,
|
||||
onAdd: (String) -> Unit,
|
||||
)
|
||||
{
|
||||
var value by remember { mutableStateOf("") }
|
||||
val commit = {
|
||||
if (value.isNotBlank())
|
||||
{
|
||||
onAdd(value.trim())
|
||||
value = ""
|
||||
}
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
label = { Text(label) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { commit() }),
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { commit() }, enabled = value.isNotBlank())
|
||||
{
|
||||
Icon(Icons.Rounded.Add, contentDescription = null)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = ShxSpacing.xl, vertical = ShxSpacing.xs),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
}
|
||||
|
||||
/** A preset counts as selected when all of its patterns are in the set. */
|
||||
private fun countSelectedPresets(ruleSet: RuleSet): Int =
|
||||
RoutingPresets.RF_BUNDLE.count { preset ->
|
||||
ruleSet.prefixes.containsAll(preset.prefixes) && ruleSet.keywords.containsAll(preset.keywords)
|
||||
}
|
||||
+9
-12
@@ -24,6 +24,10 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
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.style.TextOverflow
|
||||
|
||||
@@ -34,24 +38,17 @@ fun SecondaryScaffold(
|
||||
onBack: () -> Unit,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
snackbarHost: @Composable () -> Unit = {},
|
||||
floatingActionButton: @Composable () -> Unit = {},
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
)
|
||||
{
|
||||
/* header of the mockup: 44dp back button + 22/800 title, no tinted bar */
|
||||
Scaffold(
|
||||
snackbarHost = snackbarHost,
|
||||
floatingActionButton = floatingActionButton,
|
||||
containerColor = LocalShxColors.current.background,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
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,
|
||||
),
|
||||
)
|
||||
ShxPageHeader(title = title, onBack = onBack, modifier = Modifier.statusBarsPadding(), actions = actions)
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
|
||||
+611
@@ -0,0 +1,611 @@
|
||||
/*
|
||||
* 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.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) }
|
||||
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 && org.strongswan.android.logic.protection.ProtectionState.parseCustom(custom).isEmpty())
|
||||
{ /* 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 }, placeholder = "9.9.9.9")
|
||||
ShxButton(
|
||||
stringResource(R.string.server_save), { update(state.copy(custom = custom)) },
|
||||
style = ShxButtonStyle.TONAL, height = 44.dp, modifier = Modifier.fillMaxWidth(),
|
||||
enabled = org.strongswan.android.logic.protection.ProtectionState.parseCustom(custom).isNotEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
+294
-301
@@ -24,32 +24,10 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
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 org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
@@ -77,7 +55,6 @@ import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Brightness6
|
||||
import androidx.compose.material3.SegmentedButton
|
||||
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.widthIn
|
||||
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.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.logic.StrongSwanApplication
|
||||
import org.strongswan.android.logic.WatchdogReceiver
|
||||
import org.strongswan.android.logic.connectivity.WhiteListManager
|
||||
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.UpdateState
|
||||
import org.strongswan.android.ui.LogActivity
|
||||
import org.strongswan.android.ui.TrustedCertificatesActivity
|
||||
import org.strongswan.android.ui.TrustedNetworksActivity
|
||||
import org.strongswan.android.ui.ConnectivityCheckActivity
|
||||
import org.strongswan.android.ui.VpnProfileImportActivity
|
||||
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 updateState by updateManager.state.collectAsStateWithLifecycle()
|
||||
var showUpdateConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
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) }
|
||||
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 */
|
||||
val effects = MaterialTheme.motionScheme.defaultEffectsSpec<Float>()
|
||||
val spatial = MaterialTheme.motionScheme.defaultSpatialSpec<androidx.compose.ui.unit.IntOffset>()
|
||||
@@ -202,65 +187,34 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(LocalShxColors.current.background)
|
||||
.statusBarsPadding()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(bottom = ShxSpacing.xl),
|
||||
)
|
||||
{
|
||||
if (showTitle || page != SettingsPage.ROOT)
|
||||
if (page != SettingsPage.ROOT)
|
||||
{
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
stringResource(page.titleRes),
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
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,
|
||||
),
|
||||
)
|
||||
ShxPageHeader(stringResource(page.titleRes), onBack = { pageState = SettingsPage.ROOT })
|
||||
}
|
||||
else if (showTitle)
|
||||
{
|
||||
ShxScreenTitle(stringResource(R.string.pref_title))
|
||||
}
|
||||
|
||||
if (page == SettingsPage.ROOT)
|
||||
{
|
||||
SettingsRoot(onOpen = { pageState = it })
|
||||
SettingsRoot(onOpen = { pageState = it }, updateState = updateState, versionName = versionName)
|
||||
}
|
||||
|
||||
if (page == SettingsPage.APPEARANCE)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_page_appearance))
|
||||
{
|
||||
ThemeModeRow()
|
||||
var addedRow = false
|
||||
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)
|
||||
},
|
||||
)
|
||||
addedRow = true
|
||||
}
|
||||
}
|
||||
AppearancePage()
|
||||
}
|
||||
|
||||
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))
|
||||
{
|
||||
var addedRow = false
|
||||
@@ -271,14 +225,14 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
ShxSectionDivider()
|
||||
}
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.History,
|
||||
icon = ShxIcons.Clock,
|
||||
title = stringResource(R.string.pref_default_vpn_profile),
|
||||
subtitle = defaultLabel,
|
||||
onClick = { showDefaultPicker = true },
|
||||
)
|
||||
ShxSectionDivider()
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.BatteryChargingFull,
|
||||
icon = ShxIcons.Battery,
|
||||
title = stringResource(R.string.pref_power_whitelist_title),
|
||||
subtitle = stringResource(R.string.pref_power_whitelist_summary),
|
||||
checked = ignorePower,
|
||||
@@ -293,7 +247,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
* disabled, which breaks watchdog-based recovery of the tunnel */
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Bedtime,
|
||||
icon = ShxIcons.Moon,
|
||||
title = stringResource(R.string.samsung_sleep_title),
|
||||
subtitle = stringResource(R.string.samsung_sleep_summary),
|
||||
onClick = { openSamsungBatterySettings(context) },
|
||||
@@ -302,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))
|
||||
{
|
||||
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 {
|
||||
mutableStateOf(WhiteListManager.getInstance(context).currentConfig.enabled)
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.GppMaybe,
|
||||
icon = ShxIcons.Alert,
|
||||
title = stringResource(R.string.whitelist_analysis_title),
|
||||
subtitle = stringResource(R.string.whitelist_analysis_summary),
|
||||
checked = whiteListEnabled,
|
||||
@@ -342,7 +278,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
mutableStateOf(VpnShortcutManager.getInstance(context).isEnabled())
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.Bolt,
|
||||
icon = ShxIcons.Bolt,
|
||||
title = stringResource(R.string.pref_routines_shortcuts_title),
|
||||
subtitle = stringResource(R.string.pref_routines_shortcuts_summary),
|
||||
checked = shortcutsEnabled,
|
||||
@@ -356,7 +292,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
mutableStateOf(prefs.getBoolean(Constants.PREF_WATCHDOG_ENABLED, true))
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.RestartAlt,
|
||||
icon = ShxIcons.Refresh,
|
||||
title = stringResource(R.string.watchdog_title),
|
||||
subtitle = stringResource(R.string.watchdog_summary),
|
||||
checked = watchdogEnabled,
|
||||
@@ -382,7 +318,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
mutableStateOf(prefs.getBoolean(Constants.PREF_SAFE_IPV6, false))
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.GppGood,
|
||||
icon = ShxIcons.Shield,
|
||||
title = stringResource(R.string.safe_ipv6_title),
|
||||
subtitle = stringResource(R.string.safe_ipv6_summary),
|
||||
checked = safeIpv6,
|
||||
@@ -399,7 +335,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
ShxSection(title = stringResource(R.string.settings_section_certs))
|
||||
{
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.VerifiedUser,
|
||||
icon = ShxIcons.Cert,
|
||||
title = stringResource(R.string.trusted_certs_title),
|
||||
onClick = {
|
||||
context.startActivity(Intent(context, TrustedCertificatesActivity::class.java))
|
||||
@@ -407,47 +343,33 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
)
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Cached,
|
||||
icon = ShxIcons.Refresh,
|
||||
title = stringResource(R.string.crl_cache),
|
||||
onClick = { showCrl = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (page == SettingsPage.SUPPORT)
|
||||
if (page == SettingsPage.PROTECTION)
|
||||
{
|
||||
if (managed.isAllowProfileImport)
|
||||
{
|
||||
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))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
ProtectionPage()
|
||||
}
|
||||
|
||||
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 {
|
||||
mutableStateOf(prefs.getBoolean(Constants.PREF_EXTENDED_DIAG, false))
|
||||
}
|
||||
ShxSettingToggleRow(
|
||||
icon = Icons.Rounded.BugReport,
|
||||
icon = ShxIcons.Bug,
|
||||
title = stringResource(R.string.extended_diag_title),
|
||||
subtitle = stringResource(R.string.extended_diag_summary),
|
||||
checked = extendedDiag,
|
||||
@@ -461,76 +383,25 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
|
||||
if (page == SettingsPage.UPDATES)
|
||||
{
|
||||
ShxSection(title = stringResource(R.string.settings_section_updates))
|
||||
{
|
||||
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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
UpdatesPage()
|
||||
}
|
||||
|
||||
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),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = ShxType.meta,
|
||||
color = LocalShxColors.current.textSub,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = ShxSpacing.lg),
|
||||
@@ -549,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)) +
|
||||
profiles.map { it.getUUID().toString() to (it.name ?: "") }
|
||||
AlertDialog(
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = { showDefaultPicker = false },
|
||||
title = { Text(stringResource(R.string.pref_default_vpn_profile)) },
|
||||
text = {
|
||||
@@ -574,7 +445,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { showDefaultPicker = false }) {
|
||||
ShxDialogButton(onClick = { showDefaultPicker = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
@@ -584,7 +455,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
if (showCrl)
|
||||
{
|
||||
val files = remember(showCrl) { context.fileList().filter { it.startsWith("crl-") } }
|
||||
AlertDialog(
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = { showCrl = false },
|
||||
title = { Text(stringResource(R.string.clear_crl_cache_title)) },
|
||||
text = {
|
||||
@@ -594,7 +465,7 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
ShxDialogButton(primary = true, danger = true, onClick = {
|
||||
if (files.isEmpty())
|
||||
{
|
||||
scope.launch { snackbarHostState.showSnackbar(context.getString(R.string.clear_crl_cache_msg_none)) }
|
||||
@@ -607,68 +478,13 @@ fun SettingsScreen(profiles: List<VpnProfile>, showTitle: Boolean = true)
|
||||
}) { Text(stringResource(R.string.clear)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showCrl = false }) {
|
||||
ShxDialogButton(onClick = { showCrl = false }) {
|
||||
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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -723,38 +539,215 @@ enum class SettingsPage(val titleRes: Int)
|
||||
ROOT(R.string.pref_title),
|
||||
APPEARANCE(R.string.settings_page_appearance),
|
||||
AUTOMATION(R.string.settings_section_automation),
|
||||
STABILITY(R.string.settings_stability),
|
||||
SECURITY(R.string.settings_section_security),
|
||||
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
|
||||
private fun SettingsRoot(onOpen: (SettingsPage) -> Unit)
|
||||
private fun SettingsRoot(onOpen: (SettingsPage) -> Unit, updateState: UpdateState, versionName: String)
|
||||
{
|
||||
val context = LocalContext.current
|
||||
ShxSection(title = stringResource(R.string.settings_quick))
|
||||
{
|
||||
KillSwitchRow()
|
||||
ShxSectionDivider()
|
||||
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)) },
|
||||
)
|
||||
val c = LocalShxColors.current
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
/* re-read on every ON_RESUME: the kill switch and the trusted networks change outside */
|
||||
var resumes by remember { mutableStateOf(0) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) resumes++ }
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
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) })
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(icon = Icons.Rounded.Bolt, title = stringResource(R.string.settings_section_automation), onClick = { onOpen(SettingsPage.AUTOMATION) })
|
||||
ShxSectionDivider()
|
||||
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) })
|
||||
ShxSectionDivider()
|
||||
ShxSettingRow(icon = Icons.Rounded.Description, title = stringResource(R.string.settings_section_support), onClick = { onOpen(SettingsPage.SUPPORT) })
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.padding(top = 4.dp).fillMaxWidth().height(48.dp).clip(RoundedCornerShape(24.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.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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,7 +760,7 @@ private fun ThemeModeRow()
|
||||
remember { ShxThemeSettings.load(context) }
|
||||
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))
|
||||
val options = listOf(
|
||||
ThemeMode.SYSTEM to R.string.theme_system,
|
||||
@@ -808,25 +801,25 @@ private fun KillSwitchRow()
|
||||
}
|
||||
if (showKillSwitchDialog)
|
||||
{
|
||||
AlertDialog(
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = { showKillSwitchDialog = false },
|
||||
title = { Text(stringResource(R.string.kill_switch_dialog_title)) },
|
||||
text = { Text(stringResource(R.string.kill_switch_dialog_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
ShxDialogButton(primary = true, onClick = {
|
||||
showKillSwitchDialog = false
|
||||
context.startActivity(Intent(Settings.ACTION_VPN_SETTINGS))
|
||||
}) { Text(stringResource(R.string.kill_switch_dialog_enable)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showKillSwitchDialog = false }) {
|
||||
ShxDialogButton(onClick = { showKillSwitchDialog = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ShxSettingRow(
|
||||
icon = Icons.Rounded.Security,
|
||||
icon = ShxIcons.Shield,
|
||||
title = stringResource(R.string.kill_switch_title),
|
||||
subtitle = stringResource(
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+112
-72
@@ -37,14 +37,15 @@ import androidx.compose.material.icons.outlined.AltRoute
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Storage
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBarItemDefaults
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.NavigationRailItemDefaults
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -69,10 +70,18 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
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.VpnProfileDataSource
|
||||
import org.strongswan.android.logic.StrongSwanApplication
|
||||
import org.strongswan.android.logic.TrustedCertificateManager
|
||||
import org.strongswan.android.logic.routing.RoutingManager
|
||||
import org.strongswan.android.ui.LogActivity
|
||||
import org.strongswan.android.ui.VpnProfileDetailActivity
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
@@ -98,13 +107,32 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
||||
{
|
||||
val state by viewModel.ui.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val addProfile = {
|
||||
context.startActivity(Intent(context, VpnProfileDetailActivity::class.java))
|
||||
/* «Add server» sheet: QR, link, file or manual entry */
|
||||
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 intent = Intent(context, VpnProfileDetailActivity::class.java)
|
||||
intent.putExtra(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString())
|
||||
context.startActivity(intent)
|
||||
/* F_Profile: the server page, editing goes from there */
|
||||
context.startActivity(org.strongswan.android.ui.ServerActivity.intent(context, profile.getUUID().toString()))
|
||||
}
|
||||
var pendingShare by remember { mutableStateOf<VpnProfile?>(null) }
|
||||
val shareProfile: (VpnProfile) -> Unit = { pendingShare = it }
|
||||
@@ -115,6 +143,37 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
||||
viewModel.messages.collect { snackbarHostState.showSnackbar(context.getString(it)) }
|
||||
}
|
||||
|
||||
/* open-tab requests from other activities (profile form -> Routing) */
|
||||
val requestedTab by ShxNavigation.requestedTab.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(requestedTab)
|
||||
{
|
||||
requestedTab?.let {
|
||||
viewModel.setTab(it)
|
||||
ShxNavigation.consume()
|
||||
}
|
||||
}
|
||||
|
||||
/* one-time notice after the 2.16 migration of "only selected apps" profiles */
|
||||
val routingManager = remember { RoutingManager.getInstance(context) }
|
||||
val routingState by routingManager.state.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(routingState.migratedOnlyProfiles)
|
||||
{
|
||||
if (routingState.migratedOnlyProfiles > 0)
|
||||
{
|
||||
val result = snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.routing_migrated_only),
|
||||
actionLabel = context.getString(R.string.dead_targets_open),
|
||||
duration = SnackbarDuration.Indefinite,
|
||||
withDismissAction = true,
|
||||
)
|
||||
routingManager.consumeMigrationNotice()
|
||||
if (result == SnackbarResult.ActionPerformed)
|
||||
{
|
||||
viewModel.setTab(ShxTab.ROUTING)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* the home list keeps its scroll state across tab switches */
|
||||
val homeListState = rememberLazyListState()
|
||||
|
||||
@@ -133,11 +192,15 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
||||
navigationSuiteItems = {
|
||||
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(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background,
|
||||
color = LocalShxColors.current.background,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize())
|
||||
{
|
||||
@@ -175,6 +238,7 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
||||
onRestore = viewModel::restore,
|
||||
onShare = shareProfile,
|
||||
onAdd = addProfile,
|
||||
onFastest = { viewModel.connectFastest(context) },
|
||||
)
|
||||
ShxTab.ROUTING -> RoutingScreen()
|
||||
ShxTab.SETTINGS -> SettingsScreen(profiles = state.profiles)
|
||||
@@ -186,81 +250,49 @@ fun ShxApp(viewModel: HomeViewModel = viewModel())
|
||||
)
|
||||
}
|
||||
pendingShare?.let { profile ->
|
||||
ShareProfileDialog(
|
||||
ShareProfileScreen(
|
||||
profile = profile,
|
||||
onDismiss = { pendingShare = null },
|
||||
onConfirm = { includePassword ->
|
||||
pendingShare = null
|
||||
viewModel.shareProfile(context, profile, includePassword)
|
||||
},
|
||||
onSendFile = { viewModel.shareProfile(context, profile, false) },
|
||||
)
|
||||
}
|
||||
if (addSheetOpen)
|
||||
{
|
||||
AddServerSheet(onDismiss = { addSheetOpen = false })
|
||||
}
|
||||
/* update sheets, the mandatory update included, over every tab */
|
||||
UpdateSheetHost()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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. */
|
||||
/** Item colors of the mockup nav bar: #E7DFFF pill, #1C1B20 selected, #49454F the rest. */
|
||||
@Composable
|
||||
private fun shxNavigationItemColors(): NavigationSuiteItemColors
|
||||
{
|
||||
val bg = MaterialTheme.colorScheme.surfaceContainer
|
||||
val indicator = MaterialTheme.colorScheme.primary.copy(alpha = 0.32f).compositeOver(bg)
|
||||
val c = LocalShxColors.current
|
||||
return NavigationSuiteItemColors(
|
||||
navigationBarItemColors = NavigationBarItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
indicatorColor = indicator,
|
||||
selectedIconColor = c.text,
|
||||
unselectedIconColor = c.textSoft,
|
||||
selectedTextColor = c.text,
|
||||
unselectedTextColor = c.textSoft,
|
||||
indicatorColor = c.accentContainer,
|
||||
),
|
||||
navigationRailItemColors = NavigationRailItemDefaults.colors(
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
indicatorColor = indicator,
|
||||
selectedIconColor = c.text,
|
||||
unselectedIconColor = c.textSoft,
|
||||
selectedTextColor = c.text,
|
||||
unselectedTextColor = c.textSoft,
|
||||
indicatorColor = c.accentContainer,
|
||||
),
|
||||
navigationDrawerItemColors = NavigationDrawerItemDefaults.colors(
|
||||
selectedContainerColor = indicator,
|
||||
selectedContainerColor = c.accentContainer,
|
||||
unselectedContainerColor = Color.Transparent,
|
||||
selectedIconColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
selectedTextColor = MaterialTheme.colorScheme.primary,
|
||||
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
selectedIconColor = c.text,
|
||||
unselectedIconColor = c.textSoft,
|
||||
selectedTextColor = c.text,
|
||||
unselectedTextColor = c.textSoft,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -282,22 +314,30 @@ private fun NavigationSuiteScope.ShxNavigationSuiteItems(
|
||||
onClick = { onSelect(tab) },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = tab.icon(selected = tab == selected),
|
||||
imageVector = tab.icon(),
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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.PROFILES -> if (selected) Icons.Filled.Storage else Icons.Outlined.Storage
|
||||
ShxTab.ROUTING -> if (selected) Icons.Filled.AltRoute else Icons.Outlined.AltRoute
|
||||
ShxTab.SETTINGS -> if (selected) Icons.Filled.Settings else Icons.Outlined.Settings
|
||||
ShxTab.HOME -> ShxIcons.Power
|
||||
ShxTab.PROFILES -> ShxIcons.Globe
|
||||
ShxTab.ROUTING -> ShxIcons.Route
|
||||
ShxTab.SETTINGS -> ShxIcons.Sliders
|
||||
}
|
||||
|
||||
private fun ShxTab.label(): Int = when (this)
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright (C) 2026 SHX VPN
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the
|
||||
* Free Software Foundation; either version 2 of the License, or (at your
|
||||
* option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful, but
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
* or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
|
||||
* for more details.
|
||||
*/
|
||||
|
||||
package org.strongswan.android.ui.compose
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* One-shot "open this tab" request from outside the main activity's Compose
|
||||
* tree (e.g. the profile form's link to the Routing tab). ShxApp consumes it.
|
||||
*/
|
||||
object ShxNavigation
|
||||
{
|
||||
private val _requestedTab = MutableStateFlow<ShxTab?>(null)
|
||||
val requestedTab: StateFlow<ShxTab?> = _requestedTab
|
||||
|
||||
fun request(tab: ShxTab)
|
||||
{
|
||||
_requestedTab.value = tab
|
||||
}
|
||||
|
||||
fun consume()
|
||||
{
|
||||
_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.fillMaxWidth
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Visibility
|
||||
import androidx.compose.material.icons.rounded.VisibilityOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import org.strongswan.android.ui.compose.design.ShxAlertDialog
|
||||
import org.strongswan.android.ui.compose.design.ShxFormField
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -44,7 +40,24 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
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.ui.compose.design.ShxDialogButton
|
||||
import org.strongswan.android.ui.compose.theme.ShxSpacing
|
||||
import org.strongswan.android.ui.compose.theme.ShxTheme
|
||||
|
||||
@@ -105,6 +118,16 @@ object VpnDialogBus
|
||||
{
|
||||
_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. */
|
||||
@@ -121,6 +144,11 @@ fun ComponentActivity.installVpnDialogHost()
|
||||
fun VpnDialogHost()
|
||||
{
|
||||
val dialog by VpnDialogBus.dialog.collectAsStateWithLifecycle()
|
||||
val consentHint by VpnDialogBus.consentHint.collectAsStateWithLifecycle()
|
||||
if (consentHint)
|
||||
{
|
||||
ConsentHint()
|
||||
}
|
||||
when (val d = dialog)
|
||||
{
|
||||
is VpnDialog.Confirmation -> ConfirmationUi(d)
|
||||
@@ -142,7 +170,7 @@ private fun cancelDialog()
|
||||
@Composable
|
||||
private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
|
||||
{
|
||||
AlertDialog(
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = ::cancelDialog,
|
||||
title = { Text(dialog.title) },
|
||||
text = { Text(stringResource(dialog.message)) },
|
||||
@@ -150,17 +178,17 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(ShxSpacing.xs)) {
|
||||
if (dialog.reconnect)
|
||||
{
|
||||
TextButton(onClick = {
|
||||
ShxDialogButton(onClick = {
|
||||
VpnDialogBus.dismiss()
|
||||
VpnDialogBus.listener?.onVpnDialogDisconnect()
|
||||
}) {
|
||||
Text(
|
||||
stringResource(R.string.disconnect),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
color = LocalShxColors.current.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(onClick = {
|
||||
ShxDialogButton(primary = true, onClick = {
|
||||
VpnDialogBus.dismiss()
|
||||
val listener = VpnDialogBus.listener
|
||||
if (dialog.disconnect)
|
||||
@@ -175,7 +203,7 @@ private fun ConfirmationUi(dialog: VpnDialog.Confirmation)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = ::cancelDialog) {
|
||||
ShxDialogButton(onClick = ::cancelDialog) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
@@ -189,52 +217,48 @@ private fun LoginUi(dialog: VpnDialog.Login)
|
||||
var password by rememberSaveable { mutableStateOf("") }
|
||||
var showPassword by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = ::cancelDialog,
|
||||
title = { Text(stringResource(R.string.login_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(ShxSpacing.md)) {
|
||||
OutlinedTextField(
|
||||
ShxFormField(
|
||||
label = stringResource(R.string.login_username),
|
||||
value = username,
|
||||
onValueChange = { username = it },
|
||||
label = { Text(stringResource(R.string.login_username)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
enabled = false,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
)
|
||||
OutlinedTextField(
|
||||
ShxFormField(
|
||||
label = stringResource(R.string.login_password),
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.login_password)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
trailing = {
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(
|
||||
if (showPassword) Icons.Rounded.Visibility else Icons.Rounded.VisibilityOff,
|
||||
if (showPassword) ShxIcons.Eye else ShxIcons.EyeOff,
|
||||
contentDescription = stringResource(
|
||||
if (showPassword) R.string.login_hide_password else R.string.login_show_password
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
ShxDialogButton(primary = true, onClick = {
|
||||
VpnDialogBus.dismiss()
|
||||
VpnDialogBus.listener?.onVpnDialogLogin(dialog.profileInfo, password)
|
||||
}) { Text(stringResource(R.string.login_confirm)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = ::cancelDialog) {
|
||||
ShxDialogButton(onClick = ::cancelDialog) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
@@ -244,18 +268,18 @@ private fun LoginUi(dialog: VpnDialog.Login)
|
||||
@Composable
|
||||
private fun CertificateRequiredUi(dialog: VpnDialog.CertificateRequired)
|
||||
{
|
||||
AlertDialog(
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = ::cancelDialog,
|
||||
title = { Text(stringResource(R.string.certificate_required_title)) },
|
||||
text = { Text(stringResource(R.string.certificate_required_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
ShxDialogButton(primary = true, onClick = {
|
||||
VpnDialogBus.dismiss()
|
||||
VpnDialogBus.listener?.onVpnDialogEditProfile(dialog.uuid)
|
||||
}) { Text(stringResource(R.string.edit_profile)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = ::cancelDialog) {
|
||||
ShxDialogButton(onClick = ::cancelDialog) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
@@ -265,12 +289,12 @@ private fun CertificateRequiredUi(dialog: VpnDialog.CertificateRequired)
|
||||
@Composable
|
||||
private fun PowerWhitelistUi()
|
||||
{
|
||||
AlertDialog(
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = ::cancelDialog,
|
||||
title = { Text(stringResource(R.string.power_whitelist_title)) },
|
||||
text = { Text(stringResource(R.string.power_whitelist_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
ShxDialogButton(primary = true, onClick = {
|
||||
VpnDialogBus.dismiss()
|
||||
VpnDialogBus.listener?.onVpnDialogPowerWhitelist()
|
||||
}) { Text(stringResource(android.R.string.ok)) }
|
||||
@@ -281,14 +305,47 @@ private fun PowerWhitelistUi()
|
||||
@Composable
|
||||
private fun NotSupportedUi(dialog: VpnDialog.NotSupported)
|
||||
{
|
||||
AlertDialog(
|
||||
ShxAlertDialog(
|
||||
onDismissRequest = ::cancelDialog,
|
||||
title = { Text(stringResource(R.string.vpn_not_supported_title)) },
|
||||
text = { Text(stringResource(dialog.message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = ::cancelDialog) {
|
||||
ShxDialogButton(primary = true, onClick = ::cancelDialog) {
|
||||
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.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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
|
||||
fun ShxSection(title: String, content: @Composable () -> Unit)
|
||||
{
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = ShxType.section,
|
||||
color = LocalShxColors.current.accentText,
|
||||
modifier = Modifier
|
||||
.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() },
|
||||
)
|
||||
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
|
||||
fun ShxSectionDivider(indent: Dp = 68.dp)
|
||||
fun ShxSectionDivider(indent: Dp = 0.dp)
|
||||
{
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f),
|
||||
modifier = Modifier.padding(start = indent),
|
||||
)
|
||||
Box(Modifier.padding(start = indent).fillMaxWidth().height(1.dp).background(LocalShxColors.current.divider))
|
||||
}
|
||||
|
||||
/** Rounded-square icon badge shared by all setting rows. */
|
||||
/** Rounded-square icon well of the rows: 40dp, radius 14, #E7DFFF. */
|
||||
@Composable
|
||||
fun ShxIconBadge(icon: ImageVector, tint: Color = MaterialTheme.colorScheme.primary)
|
||||
fun ShxIconBadge(icon: ImageVector, tint: Color = LocalShxColors.current.onAccentContainer)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.small,
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier.size(36.dp),
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
modifier = Modifier.size(40.dp).clip(RoundedCornerShape(14.dp)).background(LocalShxColors.current.accentContainer),
|
||||
)
|
||||
{
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = Modifier
|
||||
.padding(ShxSpacing.sm)
|
||||
.size(20.dp),
|
||||
)
|
||||
Icon(imageVector = icon, contentDescription = null, tint = tint, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RowScope.RowLabels(title: String, subtitle: String?, enabled: Boolean)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = ShxSpacing.lg),
|
||||
.padding(end = 10.dp),
|
||||
)
|
||||
{
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(title, style = ShxType.rowTitle.copy(fontSize = 15.sp), color = if (enabled) c.text else c.textSub)
|
||||
if (subtitle != null)
|
||||
{
|
||||
Text(
|
||||
subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(subtitle, style = ShxType.meta.copy(fontWeight = FontWeight.Normal, lineHeight = 16.sp), color = c.textSub)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
@Composable
|
||||
fun ShxSettingRow(
|
||||
icon: ImageVector,
|
||||
@Suppress("UNUSED_PARAMETER") icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
enabled: Boolean = true,
|
||||
onClick: () -> Unit,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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)
|
||||
Icon(
|
||||
Icons.AutoMirrored.Rounded.KeyboardArrowRight,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
|
||||
/** Setting row with a trailing switch; the whole row toggles. */
|
||||
/** Setting row with the mockup switch; the whole row toggles. */
|
||||
@Composable
|
||||
fun ShxSettingToggleRow(
|
||||
icon: ImageVector,
|
||||
@Suppress("UNUSED_PARAMETER") icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String? = null,
|
||||
checked: Boolean,
|
||||
@@ -158,6 +152,7 @@ fun ShxSettingToggleRow(
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -168,29 +163,28 @@ fun ShxSettingToggleRow(
|
||||
role = Role.Switch,
|
||||
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)
|
||||
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
|
||||
fun ShxCard(
|
||||
modifier: Modifier = Modifier,
|
||||
bordered: Boolean = false,
|
||||
@Suppress("UNUSED_PARAMETER") bordered: Boolean = true,
|
||||
content: @Composable () -> Unit,
|
||||
)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
border = if (bordered) BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant) else null,
|
||||
modifier = modifier,
|
||||
) {
|
||||
val c = LocalShxColors.current
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
Box(modifier.clip(shape).background(c.card).border(1.dp, c.cardBorder, shape))
|
||||
{
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -205,38 +199,26 @@ fun ShxEmptyState(
|
||||
onAction: (() -> Unit)? = null,
|
||||
)
|
||||
{
|
||||
val c = LocalShxColors.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(ShxSpacing.xl),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||
)
|
||||
{
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(64.dp)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape),
|
||||
modifier = Modifier.size(64.dp).clip(RoundedCornerShape(20.dp)).background(c.tile),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(30.dp),
|
||||
)
|
||||
Icon(icon, contentDescription = null, tint = c.iconMuted, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Text(
|
||||
message,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(message, style = ShxType.body, color = c.textSub, textAlign = TextAlign.Center)
|
||||
if (actionLabel != null && onAction != null)
|
||||
{
|
||||
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
|
||||
|
||||
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.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -22,27 +24,30 @@ import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
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
|
||||
fun ShxSectionCard(
|
||||
title: String,
|
||||
title: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
)
|
||||
{
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.large,
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
color = LocalShxColors.current.card,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(ShxSpacing.lg), verticalArrangement = Arrangement.spacedBy(ShxSpacing.md))
|
||||
{
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
if (title != null)
|
||||
{
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = ShxType.chip,
|
||||
color = LocalShxColors.current.textSub,
|
||||
modifier = Modifier.semantics { heading() },
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user