From 2b4d0ef12bfee8b48497afe04450e0f6b73ad620 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sun, 8 Nov 2026 23:34:05 +0700 Subject: [PATCH] =?UTF-8?q?android:=20=C2=ABthe=20app=20closed=20unexpecte?= =?UTF-8?q?dly=C2=BB=20event=20with=20=C2=ABSend=20a=20report=C2=BB=20afte?= =?UTF-8?q?r=20a=20Java,=20native=20(daemon)=20or=20ANR=20death=20of=20the?= =?UTF-8?q?=20previous=20process;=20always-on/lockdown=20read=20from=20the?= =?UTF-8?q?=20running=20VpnService=20(the=20Settings.Global=20key=20never?= =?UTF-8?q?=20existed)=20with=20a=20warning=20when=20it=20gets=20turned=20?= =?UTF-8?q?off?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../strongswan/android/logic/AlwaysOnVpn.kt | 78 +++++++++++++ .../android/logic/CharonVpnService.java | 1 + .../strongswan/android/logic/CrashWatch.kt | 105 ++++++++++++++++++ .../android/logic/StrongSwanApplication.java | 4 + .../android/logic/events/EventLog.kt | 4 + .../strongswan/android/ui/InboxActivity.kt | 8 ++ .../org/strongswan/android/ui/LogActivity.kt | 10 ++ .../android/ui/compose/InboxScreen.kt | 2 + .../android/ui/compose/SettingsScreen.kt | 20 +--- .../app/src/main/res/values-de/strings.xml | 4 + .../app/src/main/res/values-pl/strings.xml | 4 + .../app/src/main/res/values-ru/strings.xml | 4 + .../app/src/main/res/values-uk/strings.xml | 4 + .../src/main/res/values-zh-rCN/strings.xml | 4 + .../src/main/res/values-zh-rTW/strings.xml | 4 + .../app/src/main/res/values/strings.xml | 4 + 16 files changed, 243 insertions(+), 17 deletions(-) create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/logic/AlwaysOnVpn.kt create mode 100644 src/frontends/android/app/src/main/java/org/strongswan/android/logic/CrashWatch.kt diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/AlwaysOnVpn.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/AlwaysOnVpn.kt new file mode 100644 index 000000000..6f35696e9 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/AlwaysOnVpn.kt @@ -0,0 +1,78 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic + +import android.content.Context +import android.net.VpnService +import android.os.Build +import androidx.preference.PreferenceManager +import org.strongswan.android.R +import org.strongswan.android.logic.events.EventAction +import org.strongswan.android.logic.events.EventCategory +import org.strongswan.android.logic.events.EventKind +import org.strongswan.android.logic.events.EventLog + +/** + * «Block connections without VPN» as the system reports it. Only the running + * VpnService can ask (Android 10+), so the last answer is kept for the UI. + * When it was on and is off now, the user gets a warning. + */ +object AlwaysOnVpn +{ + private const val PREF_LOCKDOWN = "pref_vpn_lockdown" + private const val EVENT_KEY = "lockdown-off" + + /** Last value seen by the service; false before the first connection or below Android 10. */ + @JvmStatic + fun isLockdown(context: Context): Boolean = + PreferenceManager.getDefaultSharedPreferences(context).getBoolean(PREF_LOCKDOWN, false) + + /** Called by the service when the tunnel is up. */ + @JvmStatic + fun update(service: VpnService) + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) + { + return + } + val lockdown = try + { + service.isLockdownEnabled + } + catch (_: Exception) + { + return + } + val prefs = PreferenceManager.getDefaultSharedPreferences(service) + val was = prefs.getBoolean(PREF_LOCKDOWN, false) + if (lockdown != was) + { + prefs.edit().putBoolean(PREF_LOCKDOWN, lockdown).apply() + DiagLog.i("AlwaysOnVpn", "lockdown ${if (lockdown) "on" else "off"} (always-on ${service.isAlwaysOn})") + } + val events = EventLog.getInstance(service) + if (lockdown) + { + events.resolve(EVENT_KEY) + } + else if (was) + { + events.record( + EventKind.WARN, EventCategory.CONNECTION, service.getString(R.string.event_lockdown_off_title), + service.getString(R.string.event_lockdown_off_text), EventAction.OPEN_VPN_SETTINGS, EVENT_KEY, + ) + } + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java index fa7c95537..babd22b1d 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CharonVpnService.java @@ -946,6 +946,7 @@ public class CharonVpnService extends VpnService implements Runnable, VpnStateSe if (state == State.CONNECTED) { TrafficHistory.start(this); + AlwaysOnVpn.update(this); } else { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CrashWatch.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CrashWatch.kt new file mode 100644 index 000000000..b99c99e38 --- /dev/null +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/CrashWatch.kt @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2026 SHX VPN + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License as published by the + * Free Software Foundation; either version 2 of the License, or (at your + * option) any later version. See . + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY + * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * for more details. + */ + +package org.strongswan.android.logic + +import android.app.ActivityManager +import android.app.ApplicationExitInfo +import android.content.Context +import android.os.Build +import androidx.preference.PreferenceManager +import org.strongswan.android.R +import org.strongswan.android.logic.events.EventAction +import org.strongswan.android.logic.events.EventCategory +import org.strongswan.android.logic.events.EventKind +import org.strongswan.android.logic.events.EventLog + +/** + * Notices that the previous process died by a crash and leaves an event with + * «Send a report». Android 11+ keeps the exit reasons itself, which also + * covers native crashes of the in-process daemon and ANRs; older versions only + * see Java exceptions, marked by the uncaught exception handler. + */ +object CrashWatch +{ + private const val TAG = "CrashWatch" + private const val PREF_SEEN = "pref_crash_seen_at" + private const val PREF_JAVA_CRASH = "pref_java_crash_at" + const val EVENT_KEY = "crash" + + /** From the uncaught exception handler: synchronous, the process dies right after. */ + @JvmStatic + fun markJavaCrash(context: Context) + { + try + { + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putLong(PREF_JAVA_CRASH, System.currentTimeMillis()).commit() + } + catch (_: Exception) + { + } + } + + /** At process start, off the main thread. */ + @JvmStatic + fun check(context: Context) + { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + var at = prefs.getLong(PREF_JAVA_CRASH, 0L) + var what = "java exception" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + { + try + { + val am = context.getSystemService(ActivityManager::class.java) + am?.getHistoricalProcessExitReasons(context.packageName, 0, 10) + ?.filter { it.reason in setOf(ApplicationExitInfo.REASON_CRASH, ApplicationExitInfo.REASON_CRASH_NATIVE, ApplicationExitInfo.REASON_ANR) } + ?.maxByOrNull { it.timestamp } + ?.takeIf { it.timestamp > at } + ?.let { + at = it.timestamp + what = "${reasonName(it.reason)}: ${it.description ?: ""}" + } + } + catch (e: Exception) + { + DiagLog.i(TAG, "exit reasons unavailable: $e") + } + } + val seen = prefs.getLong(PREF_SEEN, -1L) + if (seen < 0L) + { /* first start with this feature: older history is not news */ + prefs.edit().putLong(PREF_SEEN, maxOf(at, System.currentTimeMillis())).apply() + return + } + if (at <= seen) + { + return + } + prefs.edit().putLong(PREF_SEEN, at).apply() + DiagLog.i(TAG, "previous process crashed at $at ($what)") + EventLog.getInstance(context).record( + EventKind.WARN, EventCategory.APP, context.getString(R.string.event_crash_title), + context.getString(R.string.event_crash_text), EventAction.SEND_REPORT, EVENT_KEY, + ) + } + + private fun reasonName(reason: Int): String = when (reason) + { + ApplicationExitInfo.REASON_CRASH_NATIVE -> "native crash" + ApplicationExitInfo.REASON_ANR -> "ANR" + else -> "java crash" + } +} diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/StrongSwanApplication.java b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/StrongSwanApplication.java index bfa21ab93..680074c6f 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/StrongSwanApplication.java +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/StrongSwanApplication.java @@ -99,6 +99,7 @@ public class StrongSwanApplication extends Application Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { DiagLog.i(TAG, "UNCAUGHT EXCEPTION on " + thread.getName() + ": " + Log.getStackTraceString(throwable)); + CrashWatch.markJavaCrash(StrongSwanApplication.this); if (previousHandler != null) { previousHandler.uncaughtException(thread, throwable); @@ -107,6 +108,9 @@ public class StrongSwanApplication extends Application mDatabaseHelper = new DatabaseHelper(mContext); + /* «the app crashed» event with a report button for the previous process */ + mExecutorService.execute(() -> CrashWatch.check(mContext)); + mManagedConfigurationService = new ManagedConfigurationService(mContext); mTrustedCertificateManager = new ManagedTrustedCertificateManager(mContext, mExecutorService, mMainHandler, diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt index 2db3b0e61..d51676d15 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/logic/events/EventLog.kt @@ -70,6 +70,10 @@ enum class EventAction PROFILE_UPDATE, /** open the server page of the profile [ShxEvent.target] */ OPEN_SERVER, + /** share the logs with support (F_Log «Send») */ + SEND_REPORT, + /** the Android VPN settings (always-on, block without VPN) */ + OPEN_VPN_SETTINGS, } data class ShxEvent( diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt index 54dddc7e9..3d113cc72 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/InboxActivity.kt @@ -70,6 +70,14 @@ class InboxActivity : AppCompatActivity() EventAction.PROFILE_UPDATE -> event.target?.let { startActivity(ServerActivity.intent(this, it, checkUpdate = true)) } EventAction.OPEN_SERVER -> event.target?.let { startActivity(ServerActivity.intent(this, it)) } EventAction.OPEN_LOG -> startActivity(Intent(this, LogActivity::class.java)) + EventAction.SEND_REPORT -> startActivity(Intent(this, LogActivity::class.java).putExtra(LogActivity.EXTRA_SEND, true)) + EventAction.OPEN_VPN_SETTINGS -> try + { + startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) + } + catch (_: android.content.ActivityNotFoundException) + { + } } if (action == EventAction.OPEN_HOME || action == EventAction.OPEN_ROUTING || action == EventAction.INSTALL_UPDATE) { diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt index 5269299e5..2f32c797a 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/LogActivity.kt @@ -96,6 +96,16 @@ class LogActivity : AppCompatActivity() LogScreen(onBack = { finish() }, onSend = { sendLog() }, messages = messages) } } + /* «Send a report» from the crash event goes straight to the chooser */ + if (savedInstanceState == null && intent.getBooleanExtra(EXTRA_SEND, false)) + { + sendLog() + } + } + + companion object + { + const val EXTRA_SEND = "org.strongswan.android.extra.SEND_LOG" } private fun sendLog() diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt index 7275e8e37..0c20ddc60 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/InboxScreen.kt @@ -309,5 +309,7 @@ private fun EventAction.labelRes(): Int = when (this) EventAction.SET_APP_DIRECT -> R.string.events_action_direct EventAction.PROFILE_UPDATE -> R.string.events_action_profile_update EventAction.OPEN_SERVER -> R.string.events_action_open_server + EventAction.SEND_REPORT -> R.string.diag_send_report + EventAction.OPEN_VPN_SETTINGS -> R.string.home_lockdown_enable EventAction.NONE -> R.string.events_action_open } diff --git a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt index 8887bd235..d974553f1 100644 --- a/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt +++ b/src/frontends/android/app/src/main/java/org/strongswan/android/ui/compose/SettingsScreen.kt @@ -658,24 +658,10 @@ private fun updateStatusText(state: UpdateState): String } /** - * Checks whether the system-level lockdown ("Block connections without VPN") is - * active for our package. Returns false if not determinable. + * Whether the system-level lockdown ("Block connections without VPN") is on for + * us, as the VPN service last saw it (false before the first connection). */ -internal fun Context.isVpnLockdownEnabled(): Boolean -{ - return try - { - Settings.Global.getInt( - contentResolver, - "vpn_lockdown_$packageName", - 0 - ) != 0 - } - catch (_: Exception) - { - false - } -} +internal fun Context.isVpnLockdownEnabled(): Boolean = org.strongswan.android.logic.AlwaysOnVpn.isLockdown(this) /** * Opens the Samsung battery settings where the user can exclude the app from 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 328c79796..f725c64d4 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 @@ -1318,4 +1318,8 @@ %d vertrauenswürdiges Zertifikat %d vertrauenswürdige Zertifikate + Die App wurde unerwartet beendet + SHX VPN ist zuletzt abgestürzt. Senden Sie einen Bericht, damit wir die Ursache finden. + Durchgehend aktives VPN wurde ausgeschaltet + Es war vorher an. Jetzt gehen Apps bei jedem VPN-Abbruch für einige Sekunden direkt ins Netz. Schalten Sie es in den VPN-Einstellungen von Android wieder ein. diff --git a/src/frontends/android/app/src/main/res/values-pl/strings.xml b/src/frontends/android/app/src/main/res/values-pl/strings.xml index a2f9db813..8af2f1f28 100644 --- a/src/frontends/android/app/src/main/res/values-pl/strings.xml +++ b/src/frontends/android/app/src/main/res/values-pl/strings.xml @@ -1236,4 +1236,8 @@ %d zaufanych certyfikatów %d zaufanego certyfikatu + Aplikacja nieoczekiwanie się zamknęła + Ostatnio SHX VPN uległ awarii. Wyślij raport, abyśmy znaleźli przyczynę. + Stały VPN został wyłączony + Wcześniej był włączony. Teraz przy każdym zerwaniu VPN aplikacje na kilka sekund łączą się bezpośrednio. Włącz go ponownie w ustawieniach VPN Androida. diff --git a/src/frontends/android/app/src/main/res/values-ru/strings.xml b/src/frontends/android/app/src/main/res/values-ru/strings.xml index 2cfdfe1f5..a1895030d 100644 --- a/src/frontends/android/app/src/main/res/values-ru/strings.xml +++ b/src/frontends/android/app/src/main/res/values-ru/strings.xml @@ -1347,4 +1347,8 @@ Нет сохранённого пароля: переключиться на этот сервер не получится. Сохраните пароль в его профиле. «%1$s» не подключён автоматически Сетевое правило или сценарий хотели включить VPN, но в профиле нет сохранённого пароля. Сохраните пароль в профиле. + Приложение аварийно завершилось + В прошлый раз SHX VPN закрылся из-за сбоя. Отправьте отчёт, чтобы мы нашли причину. + Постоянный VPN выключили + Раньше он был включён. Теперь при обрыве VPN приложения на секунды выходят в сеть напрямую. Включите его снова в настройках VPN Android. diff --git a/src/frontends/android/app/src/main/res/values-uk/strings.xml b/src/frontends/android/app/src/main/res/values-uk/strings.xml index e1e7b7c4d..031487b2c 100644 --- a/src/frontends/android/app/src/main/res/values-uk/strings.xml +++ b/src/frontends/android/app/src/main/res/values-uk/strings.xml @@ -1246,4 +1246,8 @@ %d довірених сертифікатів %d довіреного сертифіката + Застосунок аварійно завершився + Минулого разу SHX VPN закрився через збій. Надішліть звіт, щоб ми знайшли причину. + Постійну VPN вимкнули + Раніше її було ввімкнено. Тепер у разі обриву VPN застосунки на секунди виходять у мережу напряму. Увімкніть її знову в налаштуваннях VPN Android. diff --git a/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml b/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml index bb237352f..890f59957 100644 --- a/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/src/frontends/android/app/src/main/res/values-zh-rCN/strings.xml @@ -1215,4 +1215,8 @@ %d 个受信任的证书 + 应用意外关闭 + SHX VPN 上次发生崩溃。请发送报告,帮助我们找到原因。 + 始终开启的 VPN 已被关闭 + 此前它处于开启状态。现在每次 VPN 断开时,应用都会直接联网几秒钟。请在 Android 的 VPN 设置中重新开启。 diff --git a/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml b/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml index cc17eb988..caea6f067 100644 --- a/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/src/frontends/android/app/src/main/res/values-zh-rTW/strings.xml @@ -1200,4 +1200,8 @@ %d 個信任的憑證 + 應用程式意外關閉 + SHX VPN 上次發生當機。請傳送報告,協助我們找出原因。 + 永久連線的 VPN 已被關閉 + 先前它處於開啟狀態。現在每次 VPN 中斷時,應用程式都會直接連網幾秒鐘。請在 Android 的 VPN 設定中重新開啟。 diff --git a/src/frontends/android/app/src/main/res/values/strings.xml b/src/frontends/android/app/src/main/res/values/strings.xml index d8e8155f9..396db16a9 100644 --- a/src/frontends/android/app/src/main/res/values/strings.xml +++ b/src/frontends/android/app/src/main/res/values/strings.xml @@ -1333,4 +1333,8 @@ No saved password: switching to it will not work. Save it in its profile. «%1$s» was not connected automatically A network rule or a scenario wanted the VPN on, but the profile has no saved password. Save it in the profile. + The app closed unexpectedly + SHX VPN crashed last time. Send a report so we can find the cause. + Always-on VPN was turned off + It was on before. Now apps go online directly for a few seconds whenever the VPN drops. Turn it back on in the Android VPN settings.