import { useCallback, useEffect, useRef, useState } from 'react'; import { Alert, AppState, Platform, type AppStateStatus } from 'react-native'; import { useTranslation } from 'react-i18next'; import { protection, type ProtectionState, type ProtectionPhase, formatCooldownRemaining, } from '../lib/protection'; import { apiFetch } from '../lib/api'; import type { WebContentFilterResult } from '../modules/rebreak-protection'; const POLL_MS_ACTIVE_COOLDOWN = 5_000; const POLL_MS_NORMAL = 30_000; function isProtectionActive(phase: string): boolean { return phase === 'active' || phase === 'cooldownActive' || phase === 'cooldownPending'; } function resolveEventSource(state: ProtectionState): 'vpn' | 'mdm' { if (state.layers.nefilterActive === true || state.mdmManaged) return 'mdm'; return 'vpn'; } type UseProtectionStateReturn = { state: ProtectionState | null; loading: boolean; error: string | null; /** Live Countdown-String "23:59:42" während Cooldown läuft. */ cooldownRemainingFormatted: string; /** True wenn Gerät als MDM-managed gilt (Backend + native NEFilter). */ mdmManaged: boolean; /** Refetch ohne loading-flicker. */ refresh: () => Promise; /** Aktiviert ALLE Layers (legacy, beide Dialoge nacheinander). */ activate: () => Promise<{ allLayersOn: boolean; missingLayers: string[] }>; /** Aktiviert NUR den URL-Filter (NEFilter). */ activateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>; /** Aktiviert NUR Family Controls (= der Lock — danach nur per Cooldown abschaltbar). */ activateFamilyControls: () => Promise<{ enabled: boolean; error?: string }>; /** * iOS Layer 2 — aktiviert den webContent-Filter (kuratierte Gambling-Domain- * Liste des Geräte-Landes). Stilles Sicherheitsnetz; braucht aktive Family * Controls. KEINE Auto-Trigger-Logik — explizit aufrufbare Capability. * No-op auf Android/Web. Siehe TODO(layer2-gating) in lib/protection.ts. */ applyWebContentFilter: () => Promise; /** iOS Layer 2 — setzt den webContent-Filter zurück. Rührt den App-Lock nicht an. */ clearWebContentFilter: () => Promise<{ cleared: boolean; error?: string }>; /** Startet 24h Cooldown via Backend. UI muss Friction-Flow vorher durchlaufen. */ requestDeactivation: (reason?: string) => Promise; /** Bricht laufenden Cooldown ab. Schutz bleibt aktiv. */ cancelDeactivation: () => Promise; }; /** * Single-Source-of-Truth-Hook für Protection-State. * * - Initial-Fetch on mount * - Polling: alle 30s normal, 5s während aktivem Cooldown (Live-Countdown) * - Refresh on AppState 'active' (User kommt aus Background zurück) * - Layer-Change-Listener vom Native-Modul (Bypass-Detection) */ export function useProtectionState(): UseProtectionStateReturn { const { t } = useTranslation(); const [state, setState] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tickSeconds, setTickSeconds] = useState(0); const pollTimer = useRef | null>(null); const tickTimer = useRef | null>(null); const prevCooldownActiveRef = useRef(null); const lastReportedActiveRef = useRef(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 // abgeschaltet wurde. // Android: a11y-Service (Bedienungshilfe) kann sich nicht selbst // deaktivieren → User braucht Settings-Deeplink. // iOS: NEFilter + Family Controls werden von forceDisable() vollständig // abgeschaltet — User muss NICHTS in den Einstellungen tun. // Kein "Bedienungshilfe"-Text (gibt es auf iOS nicht), kein // Settings-Button. const showCooldownElapsedNotice = useCallback(() => { if (cooldownDisabledNoticeShownRef.current) return; cooldownDisabledNoticeShownRef.current = true; if (Platform.OS === 'android') { 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(() => {}); }, }, ], ); } else { Alert.alert( t('blocker.cooldown_elapsed_title'), t('blocker.cooldown_elapsed_message_ios'), [{ text: t('common.ok'), style: 'cancel' }], ); } }, [t]); const fetchState = useCallback(async (showLoading = false) => { if (showLoading) setLoading(true); try { const next = await protection.getCombinedState(); const prevActive = prevCooldownActiveRef.current; prevCooldownActiveRef.current = next.cooldown.active; // Cooldown ist gerade von active → inactive gekippt: Auto-Disable. // Wir nutzen LOKAL den Cooldown-State aus dem eben gefetchten `next` — // KEIN redundanter API-Call zu /api/cooldown/status. Grund: der Backend- // GET resolved den Cooldown autom. beim ersten expired-Hit. Ein zweiter // Call würde dann cooldownEndsAt=null returnen → false bail → Filter // bleibt installiert. Local-state-check ist atomar + race-frei. if ( prevActive === true && !next.cooldown.active && next.cooldown.endsAt !== null ) { await protection.forceDisable(); showCooldownElapsedNotice(); // Nativer State hat sich geändert → ein weiterer Fetch für konsistenten State. const afterDisable = await protection.getCombinedState(); setState(afterDisable); setTickSeconds(afterDisable.cooldown.remainingSeconds); setError(null); return; } setState(next); setTickSeconds(next.cooldown.remainingSeconds); setError(null); } catch (e: any) { setError(e?.message ?? 'unknown'); } finally { if (showLoading) setLoading(false); } }, [showCooldownElapsedNotice]); // Initial fetch + best-effort NEFilter-State an Backend reporten (1x pro App-Open) useEffect(() => { fetchState(true); if (Platform.OS === 'ios') { protection.isNeFilterActive().then((res) => { apiFetch('/api/users/me/mdm-status', { method: 'POST', body: { mdmManaged: res.enabled }, }).catch(() => {}); }); } }, [fetchState]); // Adaptive poll-rate: 5s während Cooldown, 30s sonst useEffect(() => { const interval = state?.cooldown.active ? POLL_MS_ACTIVE_COOLDOWN : POLL_MS_NORMAL; if (pollTimer.current) clearInterval(pollTimer.current); pollTimer.current = setInterval(() => fetchState(false), interval); return () => { if (pollTimer.current) clearInterval(pollTimer.current); }; }, [state?.cooldown.active, fetchState]); // Live-Countdown-Tick (nur während Cooldown — 1s-Decrement client-side) useEffect(() => { if (!state?.cooldown.active) { if (tickTimer.current) { clearInterval(tickTimer.current); tickTimer.current = null; } return; } tickTimer.current = setInterval(() => { setTickSeconds((s) => Math.max(0, s - 1)); }, 1000); return () => { if (tickTimer.current) clearInterval(tickTimer.current); }; }, [state?.cooldown.active]); // AppState-Listener: Refresh + Auto-Disable wenn Cooldown elapsed während // App backgrounded war. applyCooldownDisableIfElapsed macht hier den initialen // API-Call (= keine Race-Condition mit anderem GET, weil das der erste post- // background Call ist). fetchState danach räumt den State auf — der neue // Guard im fetchState (`next.cooldown.endsAt !== null`) verhindert ein // doppeltes forceDisable wenn der AppState-Listener schon disabled hat. useEffect(() => { const sub = AppState.addEventListener('change', async (status: AppStateStatus) => { if (status !== 'active') return; const didDisable = await protection.applyCooldownDisableIfElapsed(); if (didDisable) showCooldownElapsedNotice(); await fetchState(false); }); return () => sub.remove(); }, [fetchState, showCooldownElapsedNotice]); // Native Layer-Change-Listener (User schaltet VPN extern aus etc.) useEffect(() => { const sub = protection.addLayerChangeListener(() => fetchState(false)); return () => sub?.remove(); }, [fetchState]); // Report protection-state transitions to the coverage log. // Fires only on genuine active↔inactive flips; deduped via ref. useEffect(() => { if (state === null) return; const active = isProtectionActive(state.phase); if (lastReportedActiveRef.current === active) return; lastReportedActiveRef.current = active; const source = resolveEventSource(state); apiFetch('/api/protection/event', { method: 'POST', body: { active, source } }).catch(() => {}); }, [state]); // ─── Public Actions ──────────────────────────────────────────────── const activate = useCallback(async () => { const result = await protection.activate(); await fetchState(false); return result; }, [fetchState]); const activateUrlFilter = useCallback(async () => { const result = await protection.activateUrlFilter(); await fetchState(false); return result; }, [fetchState]); const activateFamilyControls = useCallback(async () => { const result = await protection.activateFamilyControls(); await fetchState(false); return result; }, [fetchState]); // iOS Layer 2 — webContent-Filter. TODO(layer2-gating): bislang nur explizit // aufrufbar; die Auto-Trigger-Logik (an wenn NEURLFilter aus + Cooldown läuft) // ist eine offene User-Design-Entscheidung. const applyWebContentFilter = useCallback(async () => { const result = await protection.applyWebContentFilter(); await fetchState(false); return result; }, [fetchState]); const clearWebContentFilter = useCallback(async () => { const result = await protection.clearWebContentFilter(); await fetchState(false); return result; }, [fetchState]); const requestDeactivation = useCallback( async (reason?: string) => { await protection.requestDeactivation(reason); await fetchState(false); }, [fetchState], ); const cancelDeactivation = useCallback(async () => { await protection.cancelDeactivation(); await fetchState(false); }, [fetchState]); // MDM/NEFilter-Managed Mode ist iOS-spezifisch. Ohne Platform-Gate kann ein // account-seitiges mdmManaged-Flag fälschlich Android-UI in den iOS-Path ziehen. const mdmManaged = Platform.OS === 'ios' && (state?.mdmManaged === true || state?.layers.nefilterActive === true || state?.layers.mdmManaged === true); return { state, loading, error, cooldownRemainingFormatted: formatCooldownRemaining(tickSeconds), mdmManaged, refresh: () => fetchState(false), activate, activateUrlFilter, activateFamilyControls, applyWebContentFilter, clearWebContentFilter, requestDeactivation, cancelDeactivation, }; } export type { ProtectionPhase };