fix(android): block Force Stop + App-Info auf Samsung OneUI

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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-01 04:11:01 +02:00
parent 2e056c7257
commit 59766f8530
2 changed files with 240 additions and 8 deletions

View File

@ -0,0 +1,7 @@
<resources>
<string name="app_name">ReBreak</string>
<string name="expo_splash_screen_resize_mode" translatable="false">cover</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="accessibility_service_description" translatable="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.</string>
<string name="accessibility_service_summary" translatable="false">ReBreak — Schutz</string>
</resources>

View File

@ -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')
)
}
}