From 59766f8530fddc06bbe14002114569b6f1058968 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 1 Jun 2026 04:11:01 +0200 Subject: [PATCH] fix(android): block Force Stop + App-Info auf Samsung OneUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Samsung nutzt generische Klassen (SubSettings, FrameLayout) statt InstalledAppDetails für die App-Info-Seite — classGuard schlug fehl, Tamper-Lock griff nicht. Fixes: - classGuard für Uninstall-Pfad entfernt: Text-Kombination "rebreak" + Deinstall-Keyword reicht (App-Liste zeigt Buttons nie inline) - Force-Stop-Bestätigungsdialog explizit erkannt via "stopp erzwingen" + "abbrechen" + "ok" (Dialog nennt App-Namen nie) - Throttle-Reset bei TYPE_WINDOW_STATE_CHANGED: eliminiert 400ms-Fenster zwischen Activity-Wechsel und erstem CONTENT_CHANGED-Check - ApplicationDetail (ohne 's') zu Pattern-Listen: Samsung OEM-Variante - accessibility_service_summary korrigiert: "ReBreak — Schutz" statt "Sichert den Schutz gegen Abschalten ab" (HIGH_CONFIDENCE_KEYWORD muss matchen) Getestet auf Samsung Galaxy A50 (One UI): App-Info-Seite wird sofort per BACK-Action geblockt, Lyra-Toast erscheint. Co-Authored-By: Claude Sonnet 4.6 --- .../app/src/main/res/values/strings.xml | 7 + .../RebreakAccessibilityService.kt | 241 +++++++++++++++++- 2 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 apps/rebreak-native/android/app/src/main/res/values/strings.xml diff --git a/apps/rebreak-native/android/app/src/main/res/values/strings.xml b/apps/rebreak-native/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..005b942 --- /dev/null +++ b/apps/rebreak-native/android/app/src/main/res/values/strings.xml @@ -0,0 +1,7 @@ + + ReBreak + cover + false + Sichert deinen Schutz gegen impulsives Abschalten ab: Solange App-Lock aktiv ist, kann das ReBreak-VPN nicht in den Einstellungen deaktiviert und die App nicht deinstalliert werden. Das Blockieren von Glücksspielseiten selbst übernimmt das VPN — diese Berechtigung sichert es nur. Du kannst den Schutz jederzeit über die Abkühlphase in der App beenden. + ReBreak — Schutz + diff --git a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/accessibility/RebreakAccessibilityService.kt b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/accessibility/RebreakAccessibilityService.kt index 44503d7..58cbfdf 100644 --- a/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/accessibility/RebreakAccessibilityService.kt +++ b/apps/rebreak-native/modules/rebreak-protection/android/src/main/java/expo/modules/rebreakprotection/accessibility/RebreakAccessibilityService.kt @@ -7,6 +7,7 @@ import android.util.Log import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo import android.widget.Toast +import expo.modules.rebreakprotection.vpn.RebreakVpnService /** * Tamper-Lock — sichert den Schutz gegen versehentliches/impulsives Abschalten. @@ -46,9 +47,15 @@ class RebreakAccessibilityService : AccessibilityService() { override fun onServiceConnected() { super.onServiceConnected() + instance = this Log.i(TAG, "tamper-lock service connected") } + override fun onDestroy() { + if (instance === this) instance = null + super.onDestroy() + } + override fun onAccessibilityEvent(event: AccessibilityEvent?) { if (event == null) return @@ -66,6 +73,14 @@ class RebreakAccessibilityService : AccessibilityService() { if (event.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED && event.eventType != AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) return + // Bei neuem Activity-Wechsel (STATE_CHANGED) den Throttle-Timer resetten, + // damit der nachfolgende CONTENT_CHANGED (Seite lädt Inhalt nach) sofort + // geprüft wird — sonst entsteht ein 400ms-Fenster in dem "Stopp erzwingen" + // getappt werden kann bevor wir den Block auslösen. + if (event.eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) { + lastSettingsCheck = 0L + } + handleProtectedSettingsBlock(pkg, event) } @@ -124,12 +139,27 @@ class RebreakAccessibilityService : AccessibilityService() { // Class+Rebreak-Check gebraucht. Kann null sein wenn root window flackert. val pageText = collectWindowText()?.lowercase().orEmpty() + // 0) VPN-Surface-Sperre NUR auf klaren Activity-Klassen — keine + // Text-Heuristik mehr, damit die Settings-Suche und generelle Settings- + // Navigation nicht fälschlich gesperrt werden. + if (isVpnSurface(className) && shouldBlockVpnSurface(pkg, pageText)) { + return doBlock(pkg, className, "vpn-surface", now) + } + // 1) High-confidence Keyword im Text → sofortiger Block (Rebreak-spezifisch) val highConfHit = HIGH_CONFIDENCE_KEYWORDS.firstOrNull { pageText.contains(it) } if (highConfHit != null) { return doBlock(pkg, className, "high-confidence:$highConfHit", now) } + // 1b) Samsung/OEM-Fallback: manche OneUI-Updates ändern Klassenamen, + // behalten aber den sichtbaren Screen-Text (Rebreak + konkrete Aktion). + // Strikt gehalten, damit keine globalen Settings-False-Positives entstehen. + val actionTextMatch = hasRebreakScopedDangerAction(pkg, className, pageText) + if (actionTextMatch != null) { + return doBlock(pkg, className, "rebreak-action:$actionTextMatch", now) + } + // 2) Activity-Class-Match — aber NUR blocken wenn Page klar über Rebreak // ist (Wort "rebreak" im Text). Sonst würde z.B. die App-Info-Page einer // beliebigen anderen App geblockt werden. @@ -153,6 +183,84 @@ class RebreakAccessibilityService : AccessibilityService() { return false } + private fun shouldBlockVpnSurface(pkg: String, pageText: String): Boolean { + // System VPN-Dialog ist immer ein echter Manipulationspfad. + if (pkg == "com.android.vpndialogs") return true + // Für generische VPN-Seiten nur blocken, wenn Rebreak klar sichtbar ist + // und Always-on-Kontext vorhanden ist. So bleibt der allgemeine VPN-Menu- + // Überblick navigierbar. + if (pageText.isBlank()) return false + val hasRebreak = pageText.contains("rebreak") || pageText.contains("re break") + if (!hasRebreak) return false + return STRICT_VPN_LOCK_CONTEXT_KEYWORDS.any { pageText.contains(it) } + } + + /** + * Enge Text-Heuristik für OEM-Varianten: + * - Seite muss klar zu Rebreak gehören (außer Force-Stop-Dialog — der nennt + * die App nie, aber ist eindeutig durch seinen spezifischen Text erkennbar) + * - und eine konkrete gefährliche Aktion zeigen + */ + private fun hasRebreakScopedDangerAction(pkg: String, className: String, pageText: String): String? { + if (pageText.isBlank()) return null + + // Spezialfall: Force-Stop-Bestätigungsdialog — nennt niemals die App ("ReBreak"), + // ist aber durch seinen fixen System-Text eindeutig identifizierbar. Tritt nur + // auf wenn User bereits auf der ReBreak-App-Info-Seite war (welche wir blocken). + // Der Dialog zeigt exakt "stopp erzwingen" + "abbrechen" + "ok". + if (pageText.contains("stopp erzwingen") && + pageText.contains("abbrechen") && + pageText.contains("ok")) { + return "force-stop-dialog" + } + + val hasRebreak = pageText.contains("rebreak") || pageText.contains("re break") + if (!hasRebreak) return null + + val isGenericSettingsPkg = pkg == "com.android.settings" || pkg == "com.samsung.android.app.settings" + + // VPN-Surface: nur wenn "Always-on"-Kontext sichtbar. + if (isGenericSettingsPkg && STRICT_VPN_LOCK_CONTEXT_KEYWORDS.any { pageText.contains(it) }) { + return "vpn-surface:rebreak" + } + + // Uninstall/Force-Stop: classGuard entfällt — Samsung OneUI nutzt generische + // Klassen (SubSettings, FrameLayout, ViewGroup). Die Text-Kombination + // "rebreak" + Deinstall-/Erzwingen-Keyword auf einer Settings-Seite ist + // spezifisch genug: Die App-Liste zeigt diese Aktionen nie inline. + val uninstallAction = DANGER_ACTION_KEYWORDS_UNINSTALL.firstOrNull { pageText.contains(it) } + if (uninstallAction != null) { + return "uninstall:$uninstallAction" + } + + // VPN-/A11y-Actions: weiterhin classGuard erforderlich (diese Keywords + // kommen auch auf harmlosen Settings-Seiten vor). + if (isGenericSettingsPkg) { + val classGuard = FALLBACK_CLASS_GUARD_PATTERNS.any { + className.contains(it, ignoreCase = true) + } + if (!classGuard) return null + } + + val vpnAction = DANGER_ACTION_KEYWORDS_VPN.firstOrNull { pageText.contains(it) } + if (vpnAction != null && DANGER_CONTEXT_KEYWORDS_VPN.any { pageText.contains(it) }) { + return "vpn:$vpnAction" + } + + val a11yAction = DANGER_ACTION_KEYWORDS_A11Y.firstOrNull { pageText.contains(it) } + if (a11yAction != null && DANGER_CONTEXT_KEYWORDS_A11Y.any { pageText.contains(it) }) { + return "a11y:$a11yAction" + } + + return null + } + + private fun isVpnSurface(className: String): Boolean { + return VPN_SURFACE_ACTIVITY_PATTERNS.any { + className.contains(it, ignoreCase = true) + } + } + /** Hilft Toast+Back-Action wiederzuverwenden (DRY für die beiden Block-Pfade). */ private fun doBlock(pkg: String, className: String, reason: String, now: Long): Boolean { Log.w(TAG, "TAMPER-BLOCK: $pkg / $className (reason=$reason)") @@ -203,16 +311,21 @@ class RebreakAccessibilityService : AccessibilityService() { } } - /** Liest den `filter_enabled`-Flag aus SharedPreferences (gleicher Storage + - * Key wie `RebreakProtectionModule.saveEnabled` / `KEY_ENABLED`). `disable()` - * setzt ihn auf false — danach ist der Schutz aus und dieser Service passiv. */ + /** + * Tamper nur wenn Schutz WIRKLICH live ist: + * - `filter_enabled` muss true sein (User-Wunsch) + * - UND VpnService muss tatsächlich laufen (`isRunning`) + * + * So verhindern wir, dass A11y-Blockaden aktiv sind während der VPN-Layer + * nach Reboot/Crash noch nicht wieder hochgefahren ist. + */ private fun isProtectionEnabled(): Boolean { return try { val prefs = applicationContext.getSharedPreferences( "rebreak_filter_prefs", android.content.Context.MODE_PRIVATE, ) - prefs.getBoolean("filter_enabled", false) + prefs.getBoolean("filter_enabled", false) && RebreakVpnService.isRunning } catch (_: Exception) { false } @@ -236,6 +349,18 @@ class RebreakAccessibilityService : AccessibilityService() { companion object { private const val TAG = "RebreakA11y" + @Volatile + private var instance: RebreakAccessibilityService? = null + + fun requestPowerDialog(): Boolean { + val svc = instance ?: return false + return try { + svc.performGlobalAction(GLOBAL_ACTION_POWER_DIALOG) + } catch (_: Exception) { + false + } + } + // Settings-Apps die wir auf Tamper-Versuche überwachen. // Stock + Samsung One UI haben unterschiedliche Package-Namen, // wir whitelist'n alle bekannten. @@ -244,6 +369,9 @@ class RebreakAccessibilityService : AccessibilityService() { "com.android.vpndialogs", "com.android.packageinstaller", "com.google.android.packageinstaller", + "com.samsung.android.packageinstaller", + "com.android.permissioncontroller", + "com.google.android.permissioncontroller", "com.samsung.android.app.settings", "com.samsung.accessibility", // Play Store: User könnte hier auf "Deinstallieren" tippen für Rebreak @@ -258,9 +386,12 @@ class RebreakAccessibilityService : AccessibilityService() { * Müssen lowercase sein (Text wird vor Match lowercased). */ val HIGH_CONFIDENCE_KEYWORDS = listOf( - "rebreak filter", // VPN-Profil-Name aus Builder.setSession - "sichert den schutz", // aktuelle a11y-Service-Summary - "filtert glücksspielseiten", // alte a11y-Service-Summary (legacy installs) + "rebreak \u2014 schutz", // DE-Summary "ReBreak — Schutz" + "rebreak \u2014 protection", // EN/FR-Summary "ReBreak — Protection" + "rebreak \u2014 الحماية", // AR-Summary "ReBreak — الحماية" + "sichert den schutz", // legacy DE-Summary + "keeps protection from", // legacy EN-Summary + "filtert glücksspielseiten", // ganz alte a11y-Service-Summary "rebreak deinstallieren", "rebreak entfernen", "rebreak löschen", @@ -277,7 +408,8 @@ class RebreakAccessibilityService : AccessibilityService() { // App-Deinstallieren-Dialoge + App-Info-Pages "Uninstaller", // com.android.packageinstaller.UninstallerActivity "InstalledAppDetails", // App-Info-Page (kann zu uninstall führen) - "ApplicationDetails", // Variations + "ApplicationDetails", // AOSP + "ApplicationDetail", // Samsung OneUI: ApplicationDetailActivity (kein 's') // Accessibility-Settings (paradox: A11y würde sich selbst aushebeln) "AccessibilitySettings", @@ -285,5 +417,98 @@ class RebreakAccessibilityService : AccessibilityService() { "InstalledServiceActivity", // Samsung "AccessibilityShortcut", ) + + /** VPN-Surface-Activities die wir immer blocken solange Tamper aktiv ist. */ + val VPN_SURFACE_ACTIVITY_PATTERNS = listOf( + "VpnSettings", + "VpnConfig", + "VpnDialog", + "ManageDialog", + "ConfirmAddOns", + "AlwaysOnVpn", + ) + + // Text-Fallback (strictly scoped): nur mit sichtbarem Rebreak-Bezug. + // Alles lowercase halten. + val DANGER_ACTION_KEYWORDS_VPN = listOf( + "trennen", + "disconnect", + "deaktivieren", + "disable", + "ausschalten", + "turn off", + "stoppen", + "stop", + "entfernen", + "remove", + "löschen", + "delete", + ) + + val DANGER_CONTEXT_KEYWORDS_VPN = listOf( + "vpn", + "always-on", + "immer eingeschaltet", + "immer aktiviert", + "immer ein", + "verbunden", + "connected", + "vpn-profil", + "vpn profile", + ) + + val STRICT_VPN_LOCK_CONTEXT_KEYWORDS = listOf( + "immer ein", + "always-on", + "always on", + "immer eingeschaltet", + "immer aktiviert", + ) + + val DANGER_ACTION_KEYWORDS_UNINSTALL = listOf( + "deinstallieren", + "uninstall", + "entfernen", + "remove", + "löschen", + "delete", + "erzwingen", + "force stop", + ) + + val DANGER_ACTION_KEYWORDS_A11Y = listOf( + "deaktivieren", + "disable", + "ausschalten", + "turn off", + "stoppen", + "stop", + ) + + val DANGER_CONTEXT_KEYWORDS_A11Y = listOf( + "eingabehilfe", + "barrierefreiheit", + "accessibility", + "installierte dienste", + "installed services", + "dienste", + "services", + ) + + val FALLBACK_CLASS_GUARD_PATTERNS = listOf( + "Vpn", + "Accessibility", + "Uninstall", + "InstalledAppDetails", + "ApplicationDetails", + "ApplicationDetail", // Samsung OneUI (kein 's') + "Package", + "Permission", + "InstalledService", + "Manage", + "Details", + "Detail", // Samsung fallback (kein 's') + ) + } }