import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { AppState, Platform, ScrollView, Text, TouchableOpacity, View, Alert, ActivityIndicator } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; 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); // Load persisted screentime status on mount so the card stays hidden if already set useEffect(() => { if (Platform.OS !== 'ios') return; protection.getScreenTimePasscode().then((p) => { if (p) setScreentimeConfirmed(true); }).catch(() => {}); }, []); 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 deviceAdminActive = state?.layers.deviceAdmin === 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 && appDeletionLockActive && deviceAdminActive) : (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]); // ─── Android Device-Admin ──────────────────────────────────────────── async function handleRequestDeviceAdmin() { try { const result = await protection.requestDeviceAdmin(); if (!result.launched) { Alert.alert(t('blocker.android_admin_failed_title'), t('blocker.android_admin_failed_msg')); } else { await refresh(); } return result; } catch (e: any) { Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error')); return { launched: false }; } } // ─── 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 === 'ios' && FAMILY_CONTROLS_AVAILABLE && !mdmManaged && !nefilterActive ? ( /* iOS unsupervised: geführter 3-Schritt-Setup-Flow */ ) : Platform.OS === 'android' ? ( ) : ( )} {/* 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} ); } // ─── iOS Unsupervised 3-Step Setup Flow ────────────────────────────────────── type SetupFlowProps = { familyControlsActive: boolean; screentimeCode: string | null; screentimeConfirmed: boolean; screentimeSaving: boolean; urlFilterActive: boolean; onActivateFamilyControls: () => Promise<{ enabled: boolean; error?: string }>; onGenerateScreentimeCode: () => void; onConfirmScreentime: () => void; onActivateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>; colors: ReturnType; t: ReturnType['t']; }; function IosUnsupervisedSetupFlow({ familyControlsActive, screentimeCode, screentimeConfirmed, screentimeSaving, urlFilterActive, onActivateFamilyControls, onGenerateScreentimeCode, onConfirmScreentime, onActivateUrlFilter, colors, t, }: SetupFlowProps) { const step1Done = familyControlsActive; const step2Done = screentimeConfirmed; const step3Done = urlFilterActive; return ( ); } function SetupStep1({ done, onActivate, colors, t, }: { done: boolean; onActivate: () => Promise<{ enabled: boolean; error?: string }>; colors: ReturnType; t: ReturnType['t']; }) { const [busy, setBusy] = useState(false); async function handlePress() { if (done || busy) return; setBusy(true); try { await onActivate(); } finally { setBusy(false); } } return ( {!done && ( {busy ? : {t('blocker.setup_step1_cta')} } )} ); } function SetupStep2({ unlocked, code, confirmed, saving, onGenerate, onConfirm, colors, t, }: { unlocked: boolean; code: string | null; confirmed: boolean; saving: boolean; onGenerate: () => void; onConfirm: () => void; colors: ReturnType; t: ReturnType['t']; }) { return ( {unlocked && !confirmed && ( {!code ? ( {t('blocker.screentime_generate_cta')} ) : ( {t('blocker.screentime_code_label')} {code} {[ t('blocker.screentime_step1'), t('blocker.screentime_step2'), t('blocker.screentime_step3'), ].map((step, i) => ( {step} ))} {t('blocker.screentime_step_note')} {saving ? : {t('blocker.screentime_confirm_cta')} } )} )} ); } function SetupStep3({ unlocked, done, onActivate, colors, t, }: { unlocked: boolean; done: boolean; onActivate: () => Promise<{ enabled: boolean; error?: string }>; colors: ReturnType; t: ReturnType['t']; }) { const [busy, setBusy] = useState(false); async function handlePress() { if (done || busy) return; setBusy(true); try { await onActivate(); } finally { setBusy(false); } } return ( {unlocked && !done && ( {t('blocker.setup_step3_warning')} {busy ? : {t('blocker.setup_step3_cta')} } )} ); } // ─── Android 3-Step Setup Flow ─────────────────────────────────────────────── type AndroidSetupFlowProps = { vpnActive: boolean; // = a11y-Service enabled UND Tamper-Lock armed (appDeletionLock). Nur "enabled" // reicht NICHT — der a11y-Service ist ohne armed-Flag komplett passiv. accessibilityLocked: boolean; deviceAdminActive: boolean; onActivateVpn: () => Promise<{ enabled: boolean; error?: string }>; onActivateAccessibility: () => Promise; onRequestDeviceAdmin: () => Promise<{ launched: boolean }>; colors: ReturnType; t: ReturnType['t']; }; function AndroidSetupFlow({ vpnActive, accessibilityLocked, deviceAdminActive, onActivateVpn, onActivateAccessibility, onRequestDeviceAdmin, colors, t, }: AndroidSetupFlowProps) { // Reihenfolge KRITISCH: VPN → Geräteadmin → a11y. a11y MUSS zuletzt, weil der // Tamper-Lock (sobald armed) die Geräteadmin-Seite blockt — sonst kann der // User den Admin gar nicht mehr aktivieren. const vpnDone = vpnActive; const adminDone = deviceAdminActive; const a11yDone = accessibilityLocked; return ( {/* Display-Step 2 = Geräteadmin (Komponente AndroidStep3, i18n android_step3_*). */} {/* Display-Step 3 = a11y / ReBreak-Schutz (Komponente AndroidStep2, i18n android_step2_*). */} ); } function AndroidStep1({ done, onActivate, colors, t, }: { done: boolean; onActivate: () => Promise<{ enabled: boolean; error?: string }>; colors: ReturnType; t: ReturnType['t']; }) { const [busy, setBusy] = useState(false); async function handlePress() { if (done || busy) return; setBusy(true); try { await onActivate(); } finally { setBusy(false); } } return ( {!done && ( {busy ? : {t('blocker.android_step1_cta')} } )} ); } function AndroidStep2({ unlocked, done, onActivate, colors, t, }: { unlocked: boolean; done: boolean; onActivate: () => Promise; colors: ReturnType; t: ReturnType['t']; }) { const [busy, setBusy] = useState(false); async function handlePress() { if (busy) return; setBusy(true); try { await onActivate(); } finally { setBusy(false); } } return ( {unlocked && !done && ( {[ t('blocker.android_step2_instruction1'), t('blocker.android_step2_instruction2'), t('blocker.android_step2_instruction3'), ].map((line, i) => ( {line} ))} {busy ? : {t('blocker.android_step2_cta')} } {t('blocker.android_step2_note')} )} ); } function AndroidStep3({ unlocked, done, onRequestAdmin, colors, t, }: { unlocked: boolean; done: boolean; onRequestAdmin: () => Promise<{ launched: boolean }>; colors: ReturnType; t: ReturnType['t']; }) { const [busy, setBusy] = useState(false); async function handlePress() { if (done || busy) return; setBusy(true); try { await onRequestAdmin(); } finally { setBusy(false); } } return ( {unlocked && !done && ( {t('blocker.android_step3_warning')} {busy ? : {t('blocker.android_step3_cta')} } )} ); } function SetupStepCard({ stepNumber, title, subtitle, done, unlocked, lockedHint, colors, children, }: { stepNumber: number; title: string; subtitle: string; done: boolean; unlocked: boolean; lockedHint?: string; colors: ReturnType; children?: ReactNode; }) { const borderColor = done ? '#86efac' : unlocked ? colors.border : colors.border; const cardBg = done ? '#f0fdf4' : colors.surface; const numberBg = done ? '#dcfce7' : unlocked ? colors.surfaceElevated : colors.surfaceElevated; const numberColor = done ? colors.success : unlocked ? colors.text : colors.textMuted; const titleColor = done ? colors.success : unlocked ? colors.text : colors.textMuted; return ( {done ? : {stepNumber} } {title} {!!subtitle && ( {subtitle} )} {done && } {lockedHint && !done && ( {lockedHint} )} {children} ); }