import { useCallback, useEffect, useRef, useState } from 'react'; import { AppState, Linking, Platform, ScrollView, Text, TouchableOpacity, View, Alert, ActivityIndicator } from 'react-native'; import { useRouter } from 'expo-router'; import { useBottomTabBarHeight } from 'react-native-bottom-tabs'; import { useTranslation } from 'react-i18next'; import { AppHeader } from '../../components/AppHeader'; import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard'; import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard'; import { CooldownBanner } from '../../components/blocker/CooldownBanner'; import { AddDomainSheet } from '../../components/blocker/AddDomainSheet'; import { VipSwapSheet } from '../../components/blocker/VipSwapSheet'; import { MyFiltersList, VipDomainList } from '../../components/blocker/VipDomainList'; import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet'; import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet'; import { PermissionDeniedSheet } from '../../components/PermissionDeniedSheet'; import { ProtectionOffSheet } from '../../components/ProtectionOffSheet'; import { useProtectionState } from '../../hooks/useProtectionState'; import { useCustomDomains } from '../../hooks/useCustomDomains'; import { useBlocklistSync } from '../../hooks/useBlocklistSync'; import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime'; import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection'; import { useColors } from '../../lib/theme'; import { useBlockerStatsStore } from '../../stores/blockerStats'; export default function BlockerScreen() { const router = useRouter(); const { t } = useTranslation(); const colors = useColors(); // react-native-bottom-tabs Tab-Bar ist iOS-nativ + translucent → unsere Content-View // erstreckt sich UNTER den Tab-Bar. Ohne diese Höhe würden FAB + Bottom-Padding // hinterm Tab-Bar verschwinden. const tabBarHeight = useBottomTabBarHeight(); const { state, loading, cooldownRemainingFormatted, mdmManaged, refresh, activateUrlFilter, activateFamilyControls, requestDeactivation, cancelDeactivation, } = useProtectionState(); const refreshBlockerStatsIfStale = useBlockerStatsStore((s) => s.refreshIfStale); const refreshBlockerStats = useBlockerStatsStore((s) => s.refresh); const plan = state?.plan ?? 'free'; const { domains, tier, count: domainCount, limit: domainLimit, addDomain, submitDomain, submitVipSwap, refresh: refreshDomains, } = useCustomDomains(plan); const { sync: syncBlocklist, syncWebContent } = useBlocklistSync(); // Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen. const onDomainChange = useCallback(async () => { await refreshDomains(); await refreshBlockerStats().catch(() => {}); if (urlFilterActiveRef.current) { const sync = await syncBlocklist(); console.log('[blocker] resync after domain change:', sync); await refresh(); } }, [refreshDomains, refreshBlockerStats, syncBlocklist, refresh]); useDomainSubmissionRealtime(onDomainChange, true); // Stats fürs Info-Sheet früh laden, damit beim Öffnen kein Loader-Flicker entsteht. useEffect(() => { refreshBlockerStatsIfStale(120_000).catch(() => {}); }, [refreshBlockerStatsIfStale]); const [vipOpen, setVipOpen] = useState(false); const [addSheetOpen, setAddSheetOpen] = useState(false); const [vipSwapOpen, setVipSwapOpen] = useState(false); const [pendingNewDomainId, setPendingNewDomainId] = useState(''); const [detailsOpen, setDetailsOpen] = useState(false); const [explainerOpen, setExplainerOpen] = useState(false); const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false); const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false); const [protectionOffOpen, setProtectionOffOpen] = useState(false); // Screen Time Passcode (iOS Layer 3) const [screentimeCode, setScreentimeCode] = useState(null); const [screentimeConfirmed, setScreentimeConfirmed] = useState(false); const [screentimeSaving, setScreentimeSaving] = useState(false); const urlFilterActive = state?.layers.urlFilter === true; const familyControlsActive = state?.layers.familyControls === true; const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true; const nefilterActive = state?.layers.nefilterActive === true; const accessibilityActive = state?.layers.accessibility === true; // "lockedIn" = beide Layer aktiv: URL-Filter (echter Schutz) UND App-Lock // (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval — // ohne URL-Filter sieht der User trotzdem Glücksspielseiten. Daher BEIDE // müssen an sein damit der "Schutz aktiv"-Banner gezeigt wird. // Ausnahmen: // - !FAMILY_CONTROLS_AVAILABLE (Distribution-Build ohne FC-Entitlement) → // es kann gar keinen App-Lock geben, URL-Filter allein reicht. // - mdmManaged → der App-Lock wird MDM-seitig durch nicht-entfernbares // Profile + non-removable App enforced, FC-Toggle ist irrelevant. // nefilterActive → Schutz via System-Profil, kein VPN-Toggle nötig → locked-in const lockedIn = Platform.OS === 'android' ? (urlFilterActive && accessibilityActive) : (nefilterActive || urlFilterActive) && (mdmManaged || nefilterActive || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE); const urlFilterActiveRef = useRef(urlFilterActive); useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]); // Auto-Sync wenn URL-Filter oder NEFilter (MDM-Mode) beim Mount aktiv ist. // Im MDM-Mode läuft NEFilter via System-Profil — urlFilterActive ist false (kein VPN), // aber nefilterActive=true. Sync muss auch in diesem Fall laufen. const syncedOnceRef = useRef(false); useEffect(() => { if (!urlFilterActive && !nefilterActive) return; if (syncedOnceRef.current) return; syncedOnceRef.current = true; syncBlocklist().then((res) => { console.log('[blocker] auto-sync on mount:', res); if (res.ok) refresh(); }); }, [urlFilterActive, nefilterActive, syncBlocklist, refresh]); // Layer 2 / VIP: webContent-Domain-Liste IMMER beim Mount syncen — ungated, // da Layer 2 an Family Controls hängt, nicht am URL-Filter. const webContentSyncedRef = useRef(false); useEffect(() => { if (webContentSyncedRef.current) return; webContentSyncedRef.current = true; syncWebContent(); }, [syncWebContent]); // Wenn User aus System-Settings zurückkommt (z.B. nach a11y-Aktivierung) → State neu laden. useEffect(() => { const sub = AppState.addEventListener('change', (next) => { if (next === 'active') { refresh(); syncWebContent(); } }); return () => sub.remove(); }, [refresh, syncWebContent]); // ─── Activate-Handler pro Layer ────────────────────────────────────── async function handleActivateUrlFilter() { try { const result = await activateUrlFilter(); console.log('[blocker] activateUrlFilter:', result); if (!result.enabled) { // iOS-spezifisch: NEFilterErrorDomain code 5 = User hat „Nicht erlauben" // im System-Dialog getippt → iOS cached den Denied-State. Special-Sheet // statt rohem Alert (Recovery via removeFromPreferences + Settings-Deep-Link). const isPermissionDenied = Platform.OS === 'ios' && typeof result.error === 'string' && /NEFilterErrorDomain:\s*5/i.test(result.error); if (isPermissionDenied) { setPermissionDeniedOpen(true); return result; } Alert.alert( t('blocker.activate_url_failed_title'), result.error ?? t('blocker.activate_url_failed_msg'), [ { text: t('common.ok') }, { text: t('blocker.activate_settings_btn'), onPress: () => protection.openSystemSettings() }, ], ); } else { const sync = await syncBlocklist(); console.log('[blocker] post-activate sync:', sync); if (sync.ok) { await refresh(); } else { Alert.alert( t('blocker.sync_list_failed_title'), sync.error ?? t('blocker.sync_list_failed_msg'), ); } } return result; } catch (e: any) { console.error('[blocker] activateUrlFilter threw:', e); Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error')); return { enabled: false }; } } async function handleActivateFamilyControls() { try { const result = await activateFamilyControls(); console.log('[blocker] activateFamilyControls:', result); // `accessibility_pending` = a11y-Berechtigung fehlt noch → System-Settings wurden // geöffnet. Kein Fehler-Modal (sonst Modal-Loop bei jedem Tap). if (!result.enabled && result.error !== 'accessibility_pending') { // iOS: NSCocoaErrorDomain code 4099 = XPC-Communication-Failure zum // FamilyControls-Daemon (häufig nach „Don't Allow" oder bei iOS-Auth- // Daemon-State-Issue). Recovery-Sheet statt rohem Alert — mit Anleitung // (Restart Device / Settings / App reinstall). const isXpcFailure = Platform.OS === 'ios' && typeof result.error === 'string' && /NSCocoaErrorDomain:\s*4099/i.test(result.error); if (isXpcFailure) { setFamilyControlsErrorOpen(true); return result; } Alert.alert( t('blocker.activate_app_lock_failed_title'), result.error ?? t('blocker.activate_app_lock_failed_msg'), ); } return result; } catch (e: any) { console.error('[blocker] activateFamilyControls threw:', e); Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error')); } return { enabled: false }; } // ─── Screen Time Passcode (iOS Layer 3) ───────────────────────────── function handleGenerateScreentimeCode() { const code = Math.floor(1000 + Math.random() * 9000).toString(); setScreentimeCode(code); setScreentimeConfirmed(false); } async function handleScreentimeConfirm() { if (!screentimeCode) return; setScreentimeSaving(true); try { await protection.saveScreenTimePasscode(screentimeCode); setScreentimeConfirmed(true); setScreentimeCode(null); } catch (e: any) { Alert.alert(t('common.error'), e?.message ?? t('common.unknown_error')); } finally { setScreentimeSaving(false); } } // ─── 3-Click Cooldown-Trigger ──────────────────────────────────────── function openDetails() { setDetailsOpen(true); } function fromDetailsToExplainer() { setDetailsOpen(false); setTimeout(() => setExplainerOpen(true), 250); } function deflectToLyra() { setDetailsOpen(false); setTimeout(() => router.push('/lyra' as any), 250); } function deflectToBreathe() { setExplainerOpen(false); setTimeout(() => router.push('/urge' as any), 250); } async function handleStartCooldown(reason: string) { await requestDeactivation(reason); } async function handleCancelCooldown() { try { await cancelDeactivation(); } catch (e: any) { Alert.alert(t('common.error'), e?.message ?? t('blocker.deactivation_cancel_failed')); } } const bypassAlertShownRef = useRef(false); useEffect(() => { if (state?.phase !== 'recoveringFromBypass') { bypassAlertShownRef.current = false; return; } if (bypassAlertShownRef.current) return; bypassAlertShownRef.current = true; setProtectionOffOpen(true); }, [state?.phase]); // ─── Render ────────────────────────────────────────────────────────── return ( {loading && !state ? ( ) : state ? ( <> {/* Locked-In Mode (FC / NEFilter aktiv) → NUR Schutz-Status + Cooldown-Pfad */} {lockedIn ? ( ) : ( {Platform.OS === 'android' ? ( ) : FAMILY_CONTROLS_AVAILABLE && !mdmManaged ? ( /* iOS App-Lock nur zeigen wenn (a) das Family-Controls- Entitlement im Build aktiv ist (Distribution-Builds ohne Apple-Approval → ausblenden statt sandbox-blockiertes Feature, NSCocoaErrorDomain:4099) UND (b) wir nicht MDM-managed sind (dann ist der per-App-FC-Authorization- Toggle UI-irrelevant — Schutz läuft via MDM-VPN, App-Lock wird MDM-seitig durch nicht-entfernbares Profile enforced). */ ) : null} {/* iOS Layer 3 — Screen Time Passcode */} {Platform.OS === 'ios' && FAMILY_CONTROLS_AVAILABLE && !mdmManaged && appDeletionLockActive && ( Linking.openURL('App-Prefs:SCREEN_TIME').catch(() => Linking.openSettings())} onConfirm={handleScreentimeConfirm} colors={colors} t={t} /> )} )} {/* CooldownBanner */} {state.cooldown.active && ( )} {/* Sektion 1: Meine Filter (unified web + mail_domain) */} setAddSheetOpen(true)} onSubmitDomain={submitDomain} colors={colors} /> {/* Sektion 2: VIP-Liste (Zweitschutz, collapsible) */} setVipOpen((v) => !v)} colors={colors} /> {/* Sheets */} { setAddSheetOpen(false); refreshDomains(); }} onAdd={async (pattern, kind, opts) => { const result = await addDomain(pattern, kind, opts); if (result.ok) { syncWebContent(); const sync = await syncBlocklist(); if (sync.ok) refresh(); if (result.vipFull && result.newDomainId) { setAddSheetOpen(false); setPendingNewDomainId(result.newDomainId); // AddDomainSheet erst zu Ende dismissen lassen, DANN den // VipSwapSheet präsentieren — sonst verschluckt iOS das // zweite Modal (gleiches Muster wie fromDetailsToExplainer). setTimeout(() => setVipSwapOpen(true), 320); } } return result; }} /> { setVipSwapOpen(false); setPendingNewDomainId(''); }} onSwap={async (newId, evictedId) => { const result = await submitVipSwap(newId, evictedId); if (result.ok) { syncWebContent(); } return result; }} /> setDetailsOpen(false)} onRequestDeactivation={fromDetailsToExplainer} onTalkToLyra={deflectToLyra} /> setExplainerOpen(false)} onBreathe={deflectToBreathe} onStartCooldown={handleStartCooldown} /> setPermissionDeniedOpen(false)} onRetry={async () => { const res = await protection.resetUrlFilter(); if (res.enabled) { await refresh(); } return res; }} /> setFamilyControlsErrorOpen(false)} variant="family_controls" onRetry={async () => { const res = await protection.activateFamilyControls(); if (res.enabled) { await refresh(); } return res; }} /> setProtectionOffOpen(false)} onReactivate={() => handleActivateUrlFilter()} /> ) : null} ); } // ─── Screen Time Passcode Card (iOS Layer 3) ────────────────────────────────── function ScreentimePasscodeCard({ code, confirmed, saving, onGenerate, onOpenSettings, onConfirm, colors, t, }: { code: string | null; confirmed: boolean; saving: boolean; onGenerate: () => void; onOpenSettings: () => void; onConfirm: () => void; colors: ReturnType; t: ReturnType['t']; }) { if (confirmed) { return ( 🔐 {t('blocker.screentime_confirmed_title')} {t('blocker.screentime_confirmed_desc')} ); } return ( 🔒 {t('blocker.screentime_title')} {t('blocker.screentime_desc')} {!code ? ( {t('blocker.screentime_generate_cta')} ) : ( {t('blocker.screentime_code_label')} {code} {t('blocker.screentime_code_hint')} {t('blocker.screentime_open_settings_cta')} {saving ? : {t('blocker.screentime_confirm_cta')} } )} ); }