feat(blocker): post-cooldown disable shows a11y-settings notice (DiGA — user must be able to fully exit)

After the cooldown elapses and forceDisable() runs (VPN off + tamper-lock
disarmed), Android's a11y service can't deactivate itself — surface a friendly
Alert routing the user to Settings → Accessibility so they can finish removing
protection. Wired into both the fetchState cooldown active→inactive transition
and the AppState 'active' check; idempotent via ref.

(Native side — disable() also disarms the tamper-lock, RebreakAccessibilityService
goes fully passive when neither tamper-locked nor enabled, syncBlocklist no longer
re-starts the VpnService when disabled — lives in the gitignored module/android dir,
not committed here.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-11 17:12:39 +02:00
parent aac709ec41
commit 33f411ab55
3 changed files with 40 additions and 6 deletions

View File

@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import { Alert, AppState, type AppStateStatus } from 'react-native';
import { useTranslation } from 'react-i18next';
import {
protection,
type ProtectionState,
@ -39,6 +40,7 @@ type UseProtectionStateReturn = {
* - Layer-Change-Listener vom Native-Modul (Bypass-Detection)
*/
export function useProtectionState(): UseProtectionStateReturn {
const { t } = useTranslation();
const [state, setState] = useState<ProtectionState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -47,6 +49,30 @@ export function useProtectionState(): UseProtectionStateReturn {
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const tickTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const prevCooldownActiveRef = useRef<boolean | null>(null);
// Verhindert Mehrfach-Alert wenn fetchState + AppState-Listener beide kurz
// hintereinander applyCooldownDisableIfElapsed → true sehen.
const cooldownDisabledNoticeShownRef = useRef(false);
// Freundlicher Hinweis nachdem der Cooldown abgelaufen ist und der Schutz
// (inkl. Tamper-Lock) abgeschaltet wurde. Android: a11y-Service kann sich
// nicht selbst deaktivieren → User zu den Einstellungen leiten.
const showCooldownElapsedNotice = useCallback(() => {
if (cooldownDisabledNoticeShownRef.current) return;
cooldownDisabledNoticeShownRef.current = true;
Alert.alert(
t('blocker.cooldown_elapsed_title'),
t('blocker.cooldown_elapsed_message'),
[
{ text: t('common.ok'), style: 'cancel' },
{
text: t('blocker.cooldown_elapsed_open_settings'),
onPress: () => {
protection.openSystemSettings('accessibility').catch(() => {});
},
},
],
);
}, [t]);
const fetchState = useCallback(async (showLoading = false) => {
if (showLoading) setLoading(true);
@ -59,6 +85,7 @@ export function useProtectionState(): UseProtectionStateReturn {
if (prevActive === true && !next.cooldown.active) {
const didDisable = await protection.applyCooldownDisableIfElapsed();
if (didDisable) {
showCooldownElapsedNotice();
// Nativer State hat sich geändert → ein weiterer Fetch für konsistenten State.
const afterDisable = await protection.getCombinedState();
setState(afterDisable);
@ -76,7 +103,7 @@ export function useProtectionState(): UseProtectionStateReturn {
} finally {
if (showLoading) setLoading(false);
}
}, []);
}, [showCooldownElapsedNotice]);
// Initial fetch
useEffect(() => {
@ -117,11 +144,12 @@ export function useProtectionState(): UseProtectionStateReturn {
useEffect(() => {
const sub = AppState.addEventListener('change', async (status: AppStateStatus) => {
if (status !== 'active') return;
await protection.applyCooldownDisableIfElapsed();
const didDisable = await protection.applyCooldownDisableIfElapsed();
if (didDisable) showCooldownElapsedNotice();
await fetchState(false);
});
return () => sub.remove();
}, [fetchState]);
}, [fetchState, showCooldownElapsedNotice]);
// Native Layer-Change-Listener (User schaltet VPN extern aus etc.)
useEffect(() => {

View File

@ -295,7 +295,10 @@
"faq3_a": "Ja. Über die Domain-Liste auf der Blocker-Seite kannst du eigene Domains hinzufügen, die zusätzlich zur globalen Liste blockiert werden.",
"faq4_q": "Warum kann ich den Schutz nicht sofort abschalten?",
"faq4_a": "Wenn du im Drang bist, willst du oft schnell deaktivieren — und es danach bereuen. Der 24-Stunden-Cooldown gibt dir Zeit, den Drang abklingen zu lassen. Du kannst den Cooldown jederzeit abbrechen — der Schutz bleibt dann einfach an.",
"more_info_title": "Schutz deaktivieren"
"more_info_title": "Schutz deaktivieren",
"cooldown_elapsed_title": "Schutz ist aus",
"cooldown_elapsed_message": "Der Cooldown ist abgelaufen — der Schutz wurde deaktiviert. Du kannst den ReBreak-Bedienungshilfe-Dienst jetzt in den Einstellungen ausschalten.",
"cooldown_elapsed_open_settings": "Einstellungen öffnen"
},
"mail": {
"title": "Mail-Schutz",

View File

@ -295,7 +295,10 @@
"faq3_a": "Yes. From the domain list on the blocker page you can add custom domains that get blocked in addition to the global list.",
"faq4_q": "Why can't I turn protection off immediately?",
"faq4_a": "In the moment of urge, you often want to disable fast — and regret it after. The 24-hour cooldown gives you time for the urge to pass. You can cancel the cooldown anytime — protection then simply stays on.",
"more_info_title": "Disable protection"
"more_info_title": "Disable protection",
"cooldown_elapsed_title": "Protection is off",
"cooldown_elapsed_message": "The cooldown has elapsed — protection was disabled. You can now turn off the ReBreak accessibility service in Settings.",
"cooldown_elapsed_open_settings": "Open Settings"
},
"mail": {
"title": "Mail Shield",