diff --git a/src/frontends/android/app/src/androidTest/java/org/strongswan/android/ui/compose/ScreensSmokeTest.kt b/src/frontends/android/app/src/androidTest/java/org/strongswan/android/ui/compose/ScreensSmokeTest.kt new file mode 100644 index 000000000..33d587bc7 --- /dev/null +++ b/src/frontends/android/app/src/androidTest/java/org/strongswan/android/ui/compose/ScreensSmokeTest.kt @@ -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 . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.ui.compose + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Rule +import org.junit.Test +import org.strongswan.android.R +import org.strongswan.android.ui.compose.theme.ShxTheme + +/** Smoke tests for the redesigned Profiles, Routing and Settings screens. */ +class ScreensSmokeTest +{ + @get:Rule + val composeRule = createComposeRule() + + private fun string(id: Int): String = + InstrumentationRegistry.getInstrumentation().targetContext.getString(id) + + @Test + fun profilesShowsEmptyState() + { + composeRule.setContent { + ShxTheme { + ProfilesScreen( + state = HomeUiState(), + onConnect = {}, + onDisconnect = {}, + onEdit = {}, + onDelete = {}, + onRestore = {}, + onShare = {}, + onAdd = {}, + ) + } + } + composeRule.onNodeWithText(string(R.string.no_profiles)).assertIsDisplayed() + } + + @Test + fun routingShowsHeroAndAdvancedBlock() + { + composeRule.setContent { + ShxTheme { + RoutingScreen() + } + } + composeRule.onNodeWithText(string(R.string.routing_title)).assertIsDisplayed() + /* advanced block is collapsed until tapped */ + composeRule.onNodeWithText(string(R.string.routing_advanced).uppercase()).assertIsDisplayed() + } + + @Test + fun settingsOpensSecurityPageAndGoesBack() + { + composeRule.setContent { + ShxTheme { + SettingsScreen(profiles = emptyList()) + } + } + composeRule.onNodeWithText(string(R.string.settings_section_security)).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithText(string(R.string.safe_ipv6_title)).assertIsDisplayed() + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileDetailActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileDetailActivity.kt index d60754be2..18a99e014 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileDetailActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileDetailActivity.kt @@ -55,6 +55,8 @@ class VpnProfileDetailActivity : AppCompatActivity() } } + private val messages = kotlinx.coroutines.flow.MutableSharedFlow(extraBufferCapacity = 1) + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -80,13 +82,14 @@ class VpnProfileDetailActivity : AppCompatActivity() state = formViewModel.state, onChange = { formViewModel.state = it }, onCancel = { finish() }, + messages = messages, onSave = { val state = formViewModel.state val checked = state.validate(this) formViewModel.state = checked if (!checked.isValid()) { - Toast.makeText(this, R.string.alert_text_no_input_gateway, Toast.LENGTH_SHORT).show() + messages.tryEmit(R.string.alert_text_no_input_gateway) return@ProfileFormScreen } val profile = checked.toProfile(existing) diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt index 6c0cb7541..a3c1bca3c 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/ProfileFormScreen.kt @@ -297,9 +297,16 @@ fun ProfileFormScreen( onChange: (ProfileFormState) -> Unit, onSave: () -> Unit, onCancel: () -> Unit, + /* one-shot messages (string resource ids) shown as a snackbar */ + messages: kotlinx.coroutines.flow.Flow? = null, ) { val context = LocalContext.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) } @@ -324,6 +331,7 @@ fun ProfileFormScreen( } Scaffold( + snackbarHost = { org.strongswan.android.ui.compose.components.ShxSnackbarHost(snackbarHostState) }, modifier = Modifier.windowInsetsPadding( WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom), ), diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt index 3ad55cb5e..27becdf7c 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxComponents.kt @@ -12,6 +12,10 @@ package org.strongswan.android.ui.compose.components import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.semantics import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -52,7 +56,8 @@ fun ShxSection(title: String, content: @Composable () -> Unit) color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier .fillMaxWidth() - .padding(start = ShxSpacing.lg + ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm), + .padding(start = ShxSpacing.lg + ShxSpacing.lg, top = 20.dp, bottom = ShxSpacing.sm) + .semantics { heading() }, ) ShxCard(modifier = Modifier.fillMaxWidth().padding(horizontal = ShxSpacing.lg)) { Column { content() } @@ -157,13 +162,18 @@ fun ShxSettingToggleRow( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .clickable(enabled = enabled) { onCheckedChange(!checked) } + .toggleable( + value = checked, + enabled = enabled, + role = Role.Switch, + onValueChange = onCheckedChange, + ) .padding(horizontal = ShxSpacing.lg, vertical = 14.dp), ) { ShxIconBadge(icon = icon, tint = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant) RowLabels(title, subtitle, enabled) - Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled) + Switch(checked = checked, onCheckedChange = null, enabled = enabled) } } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt index 926b45b79..fbe4dbe8a 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/components/ShxSectionCard.kt @@ -18,6 +18,8 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +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). */ @@ -39,6 +41,7 @@ fun ShxSectionCard( text = title.uppercase(), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.semantics { heading() }, ) content() } diff --git a/src/frontends/android/app/src/main/res/values-de/strings.xml b/src/frontends/android/app/src/main/res/values-de/strings.xml index 8691bb7e1..226c50e6f 100644 --- a/src/frontends/android/app/src/main/res/values-de/strings.xml +++ b/src/frontends/android/app/src/main/res/values-de/strings.xml @@ -273,4 +273,235 @@ VPN verbinden VPN trennen + Systemfarben + %1$d ms + — + ✕ + Profilaktionen + Suche löschen + Rückgängig + Fertig + Zurück + Profil gelöscht + Weitere Optionen + Zurück + Farben des Hintergrundbilds übernehmen (Android 12+) + Verbindungs-Watchdog + Die VPN-Verbindung nach dem Beenden oder Absturz des App-Prozesses automatisch wiederherstellen + Von Ruhe-Apps ausnehmen (Samsung) + One UI kann ungenutzte Apps auch bei deaktivierter Akkuoptimierung in den Ruhezustand versetzen. Im Ruhezustand kann die App die VPN-Verbindung nicht selbst wiederherstellen. Öffnen Sie die Systemeinstellungen → Akku → Einschränkungen für Hintergrundnutzung und fügen Sie diese App unter „Apps, die nie in den Ruhezustand versetzt werden“ hinzu. + Stille Updates + Updates über WLAN vorab herunterladen und automatisch installieren (Android 12+). Andernfalls benachrichtigen, sobald das Update bereit ist + App-Updates + Update zur Installation bereit + Version %1$s wurde heruntergeladen. Tippen Sie, um die App zu aktualisieren. + Wenn der Server kein IPv6 hat, verhindert dies, dass IPv6 in Dual-Stack-Netzen am Tunnel vorbei austritt + VPN-Profil teilen + Passwort in Datei einschließen + Die Profildatei enthält die Verbindungseinstellungen. Das Passwort wird unverschlüsselt in der Datei gespeichert, und ein in der App gespeichertes Zertifikat wird mit seinem privaten Schlüssel exportiert. Senden Sie die Datei nur an vertrauenswürdige Personen. + VPN-Profil konnte nicht exportiert werden + Analyse der ISP-Whitelist + Erkennt eine ISP-Whitelist und pausiert die Wiederverbindung, solange ausländische Hosts direkt nicht erreichbar sind + ISP-Whitelist ist aktiv + Prüfungen laufen direkt außerhalb des Tunnels. Die Wiederverbindung ist pausiert, bis die Whitelist aufgehoben wird + Direkt (außerhalb des Tunnels) + Ausländische Hosts (direkt) + Yandex (direkt) + Routing + Routing + RU-Netze direkt + Apps von RF-Unternehmen ausnehmen + Alle ru.*-Apps, Yandex, VK, Gosuslugi, große Banken, Handel, Telekommunikation, Verkehr und weitere russische Dienste + Immer über VPN + Adressen und Subnetze, die immer durch den Tunnel geleitet werden, auch wenn sie in RU-Bereiche fallen (Userspace-Modus) + Adresse hinzufügen + Adresse oder Subnetz + z. B. 130.49.213.176 oder 10.8.0.0/24 + Ungültige Adresse oder ungültiges Subnetz + Speichern + Löschen + Regelsätze + Regelsatz hinzufügen + Name des Regelsatzes + Apps: %1$d · Präfixe: %2$d · Schlüsselwörter: %3$d + Anwendungen auswählen + Ausgewählt: %1$d + Regelsatz + Paket-Präfixe + Schlüsselwörter + Details + Details ausblenden + Vorlagen + Vorlagen · %1$d ausgewählt + Fertige Gruppen von Diensten + Vorlagen durchsuchen + Nichts gefunden + %1$d Präfixe + %1$d Schlüsselwörter + keine Muster + Noch keine Regelsätze vorhanden. Legen Sie einen an oder nutzen Sie die Schnellaktion oben. + Regelsatz löschen + Regelsatz gelöscht + Paket-Präfix hinzufügen + Schlüsselwort hinzufügen + Routing aktiv + Routing inaktiv + Sets: %1$d · Apps direkt: %2$d + Gesamter Datenverkehr läuft über das VPN + IPv4 · %1$d + IPv6 · %1$d + Aktualisiert %1$s + Nie aktualisiert + RU-Netze direkt verbinden + Datenverkehr zu russischen IP-Adressen umgeht den VPN-Tunnel und nutzt die direkte Verbindung (geoip:ru) + Liste aktualisieren + Wird aktualisiert… + Liste aktualisiert: %1$d Bereiche + Aktualisierung nicht möglich, gespeicherte Version wird verwendet + Quelle: github.com/MetaCubeX/meta-rules-dat + Die Liste ist statisch und wird täglich von GitHub aktualisiert. Die Zuordnung erfolgt über IP-Adressen. Dienste hinter ausländischen CDNs sind möglicherweise nicht in der Liste enthalten. Änderungen werden nach einer erneuten Verbindung wirksam. + Betriebssystem-Routen + Userspace-Routing (Beta) + Userspace (Beta) + Das Komplement der RU-Liste wird als Systemrouten installiert und auf das Routenlimit gerundet. Bewährtes Verfahren, die Liste wird dabei jedoch vergröbert, und Aktualisierungen erfordern eine erneute Verbindung. + Der gesamte Datenverkehr geht in das VPN. Die Entscheidung Direkt/Tunnel wird pro Paket anhand der genauen Liste getroffen. Listenaktualisierungen werden sofort übernommen. TCP und UDP gehen direkt. Ping zu RU-Adressen funktioniert in diesem Modus nicht direkt, anderes ICMP wird verworfen. + Routenprüfung + Geben Sie eine Domain ein, um zu sehen, auf welche Adressen sie über den DNS des Tunnels und den DNS der direkten Verbindung aufgelöst wird und ob jede Adresse unter den angewendeten Ausnahmen durch den Tunnel läuft oder ihn umgeht. + Domain, z. B. inet.ya.ru + Prüfen + Wird geprüft… + Der Tunnel ist nicht verbunden. Die Routenansicht ist nicht verfügbar + Über den DNS des Tunnels aufgelöst + Über den DNS der direkten Verbindung aufgelöst + Routen + über den Tunnel + direkt (RU-Ausnahme) + direkt (privater DNS-Server) + Die Domain konnte auf keinem der beiden Pfade aufgelöst werden + keine + Verbindung + Profile + Passwort anzeigen + Passwort ausblenden + Verbinden + Profile + Einstellungen + Ungeschützt + Geschützt + Verbindung wird hergestellt… + Profil auswählen + Verbunden + Verbindung fehlgeschlagen + Profile + Profile durchsuchen + Nichts gefunden + Allgemein + Automatisierung + Zertifikate + Profile + Support + Sicherheit + Vertrauenswürdige Netzwerke + Über Mobilfunk und nicht vertrauenswürdiges WLAN verbinden + Profil für automatische Verbindung + Nicht ausgewählt + Vertrauenswürdige WLAN-Netzwerke + In diesen Netzwerken wird das VPN nur dann automatisch beendet, wenn es durch die Automatisierung gestartet wurde. Eine manuell gestartete Verbindung bleibt bestehen, bis Sie sie trennen. Wenn Sie zu Mobilfunk oder in ein anderes WLAN wechseln, wird das VPN erneut gestartet. + Noch keine vertrauenswürdigen Netzwerke + Netzwerk hinzufügen + Aktuelles hinzufügen (%1$s) + Netzwerkname (SSID) + Vertrauenswürdiges Netzwerk entfernen + Damit das VPN in vertrauenswürdigen WLAN-Netzwerken im Hintergrund pausiert werden kann, erlauben Sie den Standortzugriff immer. + Erlauben + Kill-Switch + Always-on-VPN mit Lockdown ist aktiv + Nicht aktiviert. Datenverkehr kann außerhalb des Tunnels austreten + Sicheres IPv6 + Den IPv6-Verkehr blockieren, wenn der Tunnel kein IPv6 bereitstellt, statt ihn am VPN vorbeizulassen + Systemeinstellungen für VPN öffnen + Kill-Switch aktivieren? + Der Kill-Switch ist die Systemoption „Always-on VPN“ mit „Verbindungen ohne VPN blockieren“ für diese App. Fällt der Tunnel aus, wird der gesamte Datenverkehr blockiert, statt auszutreten. Er kann nur in den Systemeinstellungen aktiviert werden. Hinweis: „Always-on VPN“ steht im Konflikt mit der automatischen Verbindung in vertrauenswürdigen Netzwerken, da das System den Tunnel wiederherstellen würde, den die automatische Verbindung im vertrauenswürdigen WLAN pausiert. Verwenden Sie nur eins von beidem. + Einstellungen öffnen + Erreichbarkeit + Erreichbarkeit erneut prüfen + Wird gemessen… + Server (direkt) + Google + YouTube + Cloudflare + Verbindungsprüfung + Tunnel überwachen und automatisch neu verbinden + Prüfintervall + Alle %1$d s + Alle %1$d Min. + Alle 5 s (aggressiver Modus) + Aggressiver Modus + Alle 5 s prüfen und ohne Bestätigung neu verbinden. Schnellere Wiederherstellung, aber höherer Akkuverbrauch und die Gefahr von Fehlverbindungen + Adaptiver Akkumodus + Seltener prüfen bei ausgeschaltetem Bildschirm, im Energiesparmodus oder bei niedrigem Akkustand + Prüfziele + VPN-Server prüfen + Server direkt außerhalb des Tunnels anpingen + Server nicht erreichbar + Internet über VPN nicht erreichbar + Einige Ziele nicht erreichbar + Wann prüfen + Immer + Nur wenn der Tunnel ausgefallen ist + Nie + Immer + Bei Tunnelausfall + Nie + Aktion + Neu verbinden + Nichts tun + Verbindungsversuche + Versuche verringern + Versuche erhöhen + Grundlagen + SHX VPN %1$s + Zuletzt verwendet + Profil hinzufügen + Verbindungsdetails + Empfang + Senden + — + Dauer + Empfangen %1$s, %2$s + Gesendet %1$s, %2$s + Über VPN + Direkt (RU) + %1$d%% direkt + Exit-IP + Netzwerk + WLAN + Mobile Daten + Nicht erreichbare direkte Hosts + Apps verbinden sich weiterhin mit diesen Hosts, doch sie antworten nie. Die Engine lehnt sie für ein Cooldown-Fenster ab. Lange nicht erreichbare Hosts werden bis zum Netzwerkwechsel automatisch über den VPN-Tunnel geleitet. Prüfen Sie die Server oder entfernen Sie die Hosts aus der App, die sie verwendet. + %1$d fehlgeschlagene Verbindungen + Cooldown %1$s + +%1$d weitere + Routing öffnen + über Tunnel + Über VPN + Kennungen, DNS, MTU, Split-Tunneling + Profil importieren + Erweiterte Diagnose + Regelmäßige Snapshots der Routing-Diagnose während der Verbindung (alle 5 Min.). Erhöht den Akkuverbrauch + Profil auswählen + Profile verwalten + Details anzeigen + Details ausblenden + Erweitert + %1$d ausgeschlossene App(s) nutzen immer das normale Netzwerk, auch wenn der Tunnel ausfällt. Der Kill-Switch blockiert sie nicht. + Darstellung + Schnelleinstellungen + Design + System + Hell + Dunkel + SHX VPN + Schutzstatus und Verbinden mit einem Tipp + diff --git a/src/frontends/android/docs/ui-ux-plan.md b/src/frontends/android/docs/ui-ux-plan.md index 4ccc47f74..e25fbe373 100644 --- a/src/frontends/android/docs/ui-ux-plan.md +++ b/src/frontends/android/docs/ui-ux-plan.md @@ -130,3 +130,18 @@ 5. Авторизованная проверка `aws s3 cp s3://shx-app/latest.json -` — совпадение versionCode и sha256. Публичный `GET` = `AccessDenied` — норма. Релизы: №1 после этапа 2 (versionCode 117, 2.10.0), финальный после этапов 3–4 (118). + +## Редизайн 2.15 (ветка interface-ux-ui-2026): статус по факту + +Этапы 0–4 выше устарели в части ограничений: minSdk уже 23, Expressive-тема включена. Что сделано: + +- **Тулчейн:** AGP 9.1.1, Gradle 9.3.1, Kotlin 2.2.21 (built-in Kotlin, `kotlin-android` убран), compileSdk 37 (targetSdk 36), Java 11, BOM 2026.09.00 (compose 1.12), **material3 1.5.0-beta01**. Библиотеки material3 1.5 объявляют minSdk 24: приложение держит 23 через `tools:overrideLibrary` в манифесте (на API 23 возможны сбои — риск принят). `proguard-android-optimize.txt` вместо non-optimize (AGP 9 отвергает старый файл). +- **Тема (этап 1) ✅:** `MaterialExpressiveTheme` + `MotionScheme.expressive()`, тональная палитра с `surfaceContainer*`, токены `ShxSpacing`/`ShxShapes`, выбор темы системная/светлая/тёмная (`ShxThemeSettings.themeMode`), сплэш/окно/системные панели совпадают с Compose-поверхностью (`shx_*` в `colors.xml`). +- **Компоненты ✅:** `ShxSectionCard`, `ShxListRow`, `ShxStatusPill`/`ShxConnectedPill`, `ShxEmptyState`, `ShxSnackbarHost`; переходы между вкладками — fade-through на motion scheme, «назад» с любой вкладки ведёт на «Подключение», `LoadingIndicator`/`LinearWavyProgressIndicator`, Snackbar вместо Toast в `ShxApp`, Настройках, форме профиля; хаптика подключения/отключения. +- **«Подключение» ✅:** hero-кнопка с морфингом формы (круг → cookie), чип профиля с exit-IP + выбор профиля в bottom sheet (дублирующий список профилей убран), плитки трафика, один слот баннера (ошибка приоритетнее белого списка), «Доступность» свёрнута в сводку. Файл разбит на `home/*`. На ширине ≥ 840dp две колонки. +- **«Профили» ✅:** свайп-удаление с Undo, поиск только при > 5 профилей. List-detail **не сделан** (форма профиля остаётся отдельной Activity). +- **«Маршрутизация» ✅:** режим/GeoIP/force-префиксы в блоке «Дополнительно»; строка про kill switch и исключённые приложения (исключённые идут мимо VPN, lockdown их не блокирует: они в `addDisallowedApplication`). Разбито на `routing/*`. +- **«Настройки» ✅:** корень (быстрые: kill switch, автоподключение) + 5 страниц (оформление, автоматизация, безопасность, обновления, поддержка), ширина ≤ 640dp. +- **Системные поверхности:** виджет Glance 2×1 (`ui/widget/ShxWidget.kt`, состояние публикует `VpnStateService`, кнопка идёт через `VpnProfileControlActivity`, как плитка); плитка QS показывает профиль в subtitle. `ProgressStyle` для загрузки обновления и категория плитки connectivity — **не сделаны**. +- **Доступность/тексты:** `heading()` у заголовков секций, `toggleable(role = Switch)` у строк-переключателей, liveRegion у статуса подключения, недостающие строки `values-de`. +- **Не сделано (вторичные экраны):** ImportScreen как bottom sheet, фильтр уровней и моноширинный шрифт в журнале, поиск по приложениям, карточка разрешения геолокации в «Доверенных сетях». Toast остался в `LogActivity`, `VpnProfileControlActivity`, `UpdateInstallStatusReceiver` (вне Compose-хоста).