fix(android): tamper-lock can't linger armed while protection is off (stuck "locked" UI)

Repro: after a reinstall / external VPN-revoke, `filter_enabled` flipped to false
but `tamper_armed` stayed true. Result: buildDeviceState reported tamperLock:true
purely from `tamper_armed` → UI mapped that to appDeletionLock:true → lockedIn:true
→ showed the green "protected & locked" card with no toggles → no way to reactivate.
(The a11y service didn't block — handleProtectedSettingsBlock checks isProtectionEnabled
— but it kept logging every settings-navigation, wasting CPU.) "Armed but disabled"
is an invalid state.

- RebreakAccessibilityService: top guard is now `if (!isTamperLockArmed() || !isProtectionEnabled()) return`
  — fully passive (no logging) whenever protection is off, regardless of a stale tamper flag.
- RebreakProtectionModule.buildDeviceState: tamperLock = tamper_armed && filter_enabled.
- RebreakProtectionModule.isVpnEffectivelyOn (revoke branch) and RebreakVpnService.onRevoke
  now clear `tamper_armed` together with `filter_enabled` — the two can't desync.
Self-heals: opening the blocker page after the update re-fetches state → tamperLock:false → toggles back.

Also: the tamper-block toast is now Lyra-voiced instead of a shield emoji (a real avatar
image isn't possible — Android 11+ ignores Toast.setView() for app toasts; lyra-persona
can refine the wording).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-11 18:34:45 +02:00
parent fc7a243c9b
commit 3c2aee7bda
3 changed files with 40 additions and 14 deletions

View File

@ -307,7 +307,10 @@ class RebreakProtectionModule : Module() {
return mapOf(
"vpn" to isVpnEffectivelyOn(ctx),
"accessibility" to isAccessibilityServiceEnabled(ctx),
"tamperLock" to isTamperLockArmed(ctx),
// Ein armed-aber-Schutz-aus Tamper-Lock ist effektiv KEIN Lock — sonst
// zeigt die UI „verriegelt" ohne dass der User je rauskommt (Desync-Fall:
// `tamper_armed` noch true, aber `filter_enabled` schon false).
"tamperLock" to (isTamperLockArmed(ctx) && isEnabledFlag(ctx)),
"blocklistCount" to count,
"blocklistLastSyncAt" to lastSyncAt,
)
@ -368,8 +371,14 @@ class RebreakProtectionModule : Module() {
val flag = isEnabledFlag(ctx)
Log.d(TAG, "isVpnEffectivelyOn: live=$live, prepareIntent=${intent != null}, flag=$flag")
if (intent != null) {
// Permission entzogen → definitely off
if (flag) prefs(ctx).edit().putBoolean(KEY_ENABLED, false).apply()
// Permission entzogen → definitely off. Auch den Tamper-Lock mit-disarmen,
// sonst bleibt der State desynct (tamper armed, Schutz aus) → „verriegelt"-UI.
if (flag) {
prefs(ctx).edit()
.putBoolean(KEY_ENABLED, false)
.putBoolean(KEY_TAMPER_ARMED, false)
.apply()
}
return false
}
return live || flag

View File

@ -52,13 +52,14 @@ class RebreakAccessibilityService : AccessibilityService() {
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event == null) return
// Globaler Kill-Switch: Dieser Service tut NUR etwas, wenn der User den
// App-Lock explizit armed hat. Ist er nicht armed (Default, inkl. frischem
// Onboarding und nach einem abgelaufenen Cooldown der `disarmTamperLock`
// aufgerufen hat) → vollständig passiv. Der a11y-Service kann sich nicht
// selbst deaktivieren, also ist das hier die einzige Stelle wo wir ihn
// stilllegen.
if (!isTamperLockArmed()) return
// Globaler Kill-Switch: Dieser Service tut NUR etwas, wenn der Schutz
// aktiv ist (`filter_enabled`) UND der User „App-Lock" explizit armed hat
// (`tamper_armed`). Beides muss stimmen — sonst vollständig passiv (auch
// kein Logging). Deckt ab: frisches Onboarding, abgelaufener Cooldown
// (disarmTamperLock), und den Desync-Fall „tamper noch armed aber Schutz
// aus" (z.B. VPN extern revoked). Der a11y-Service kann sich nicht selbst
// deaktivieren, also ist das hier die einzige Stelle wo wir ihn stilllegen.
if (!isTamperLockArmed() || !isProtectionEnabled()) return
val pkg = event.packageName?.toString() ?: return
if (pkg !in WATCHED_SETTINGS_PACKAGES) return
@ -131,10 +132,14 @@ class RebreakAccessibilityService : AccessibilityService() {
performGlobalAction(GLOBAL_ACTION_BACK)
}, 200)
// Toast in Lyra's Stimme statt eines kühlen Shield-Icons (Android 11+
// ignoriert setView/Custom-Layouts für App-Toasts → kein echtes Avatar-
// Bild möglich; deshalb signiert der Text mit „Lyra:"). lyra-persona darf
// den Wortlaut gern noch feinschleifen.
mainHandler.post {
Toast.makeText(
applicationContext,
"🛡 Diese Einstellung ist während des Schutzes gesperrt",
"Lyra: Das ist während deines Schutzes gesperrt 💛 Wenn du wirklich raus willst, geht das in der App.",
Toast.LENGTH_LONG,
).show()
}

View File

@ -162,9 +162,21 @@ class RebreakVpnService : VpnService() {
super.onDestroy()
}
/** Persistierte Flag (gleicher Storage wie der Plugin) clearen nur
* bei explizitem User-Revoke (System-Settings-Toggle aus). */
private fun clearEnabledFlag() = setEnabledFlag(false)
/** Persistierte Flags (gleicher Storage wie der Plugin) clearen nur bei
* explizitem User-Revoke (System-Settings-Toggle aus). Mit `filter_enabled`
* geht auch `tamper_armed` weg: ein Tamper-Lock ohne aktiven Schutz ist
* Unsinn und würde den State desynchen verriegelt"-UI ohne Ausweg. */
private fun clearEnabledFlag() {
try {
applicationContext.getSharedPreferences("rebreak_filter_prefs", Context.MODE_PRIVATE)
.edit()
.putBoolean("filter_enabled", false)
.putBoolean("tamper_armed", false)
.apply()
} catch (e: Exception) {
Log.w(TAG, "clearEnabledFlag failed: ${e.message}")
}
}
/** Setzt die Plugin-Prefs-Flag synchron mit dem tatsächlichen Service-State. */
private fun setEnabledFlag(value: Boolean) {