import { useEffect, useRef, useState } from 'react'; import { Alert, AppState, Platform, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { useColors } from '../../../lib/theme'; import { apiFetch } from '../../../lib/api'; import { invalidateMe } from '../../../hooks/useMe'; import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../../lib/protection'; import { useProtectionState } from '../../../hooks/useProtectionState'; import { useBlocklistSync } from '../../../hooks/useBlocklistSync'; import { getPermissionScreenshot } from '../../../lib/onboardingAssets'; import i18n from '../../../lib/i18n'; import { AndroidSetupFlow, IosUnsupervisedSetupFlow } from '../../blocker/SetupFlows'; import { LayerSwitchCard } from '../../blocker/LayerSwitchCard'; import { OnboardingShell } from '../OnboardingShell'; import { LyraBubble } from '../LyraBubble'; import { CTABar } from '../CTABar'; import { PermissionConfirmSheet } from '../PermissionConfirmSheet'; import { PermissionDeniedSheet } from '../../PermissionDeniedSheet'; /** Steps mit Gate davor. VPN/Geräteadmin/AppLock/URL = echte System-Dialoge * (welcher Button). a11y = bekommt einen reicheren Explainer (Overlay-Recht + * Schalter-Suche mit Screenshot/Indikator), weil's für viele kompliziert ist. * Screentime hat einen eigenen instruktiven Flow → kein Gate. */ type ConfirmStep = 'vpn' | 'deviceadmin' | 'applock' | 'urlfilter' | 'a11y' | 'usage' | 'overlay'; /** * Onboarding-Schutz-Step. * * WICHTIG: Dieser Step rendert EXAKT denselben Setup-Flow wie der Blocker-Screen * (components/blocker/SetupFlows.tsx) — die Reihenfolge + das Gating sind dort * die einzige Quelle der Wahrheit: * Android: VPN → Geräteadmin → a11y (a11y MUSS zuletzt, sonst blockt der * Tamper-Lock die Geräteadmin-Settings-Seite). * iOS: App-Lock → Bildschirmzeit → URL-Filter. * * Die Handler hier spiegeln blocker.tsx 1:1 (Activate + Sync + Recovery-Sheets). * "Fertig" = der Blocker würde "Schutz aktiv" zeigen (lockedIn). Erst dann wird * der "Weiter"-Button aktiv und der Onboarding-Step abgeschlossen. */ export function ProtectionSlide({ onDone, current, total, }: { onDone: () => void; current: number; total: number; }) { const { t } = useTranslation(); const colors = useColors(); const { state, mdmManaged, refresh, activateUrlFilter, activateFamilyControls } = useProtectionState(); const { sync: syncBlocklist } = useBlocklistSync(); const [screentimeCode, setScreentimeCode] = useState(null); const [screentimeConfirmed, setScreentimeConfirmed] = useState(false); const [screentimeSaving, setScreentimeSaving] = useState(false); const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false); const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false); const [confirmStep, setConfirmStep] = useState(null); // Fallback-Ausweg: erscheint, wenn der Schutz auf diesem Gerät nicht aktiviert // werden kann (Timeout oder fehlgeschlagener Versuch) — sonst hängt der User // im Onboarding fest (z.B. Android-16-VPN-Crash). Schutz später im Blocker. const [showSkip, setShowSkip] = useState(false); // VPN-Aktivierung läuft (Android): Spinner am VPN-Step bis der Layer-State // bestätigt ist. Der Protection-State pollt sonst zu selten → der Step bliebe // ~1min „offen", obwohl der Tunnel längst läuft. const [vpnPending, setVpnPending] = useState(false); const finishedRef = useRef(false); const armingRef = useRef(false); // a11y-Explainer NUR beim ersten Tap zeigen — danach (Settings-Rückkehr → // nochmal tippen zum Armen) direkt durch, sonst nervt das Sheet doppelt. const a11yExplainerShownRef = useRef(false); // Persistierten Screen-Time-Status laden, damit der Step nicht erneut gefragt // wird, wenn der Code schon gesetzt ist (gleiche Logik wie blocker.tsx). 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; // a11y-SERVICE an (≠ Tamper-Lock armed). Trennung kommt direkt aus dem nativen // getDeviceState ({accessibility} vs {tamperLock}). const a11yServiceOn = state?.layers.accessibility === true; // Akku-Ausnahme: ohne sie schläfert Samsung & Co. die App ein → a11y-Service // wird entbunden → Lock wertlos. Daher Pflicht-Step im Android-Flow. const batteryUnrestricted = state?.layers.batteryUnrestricted === true; // "Fertig" == blocker.tsx lockedIn. Eine Quelle der Wahrheit. const allDone = Platform.OS === 'android' ? urlFilterActive && appDeletionLockActive && deviceAdminActive && batteryUnrestricted : (nefilterActive || urlFilterActive) && (mdmManaged || nefilterActive || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE); async function finishProtectionStep() { if (finishedRef.current) return; finishedRef.current = true; await apiFetch('/api/profile/me/onboarding-step', { method: 'PATCH', body: { step: 'done' }, }).catch(() => {}); invalidateMe(); onDone(); } // Notausgang: Schutz noch nicht aktiv, aber User soll nicht festsitzen. function handleSkipProtection() { Alert.alert( t('onboarding.protection_skip.title'), t('onboarding.protection_skip.body'), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('onboarding.protection_skip.confirm'), style: 'destructive', onPress: () => { void finishProtectionStep(); }, }, ], ); } // Foreground-Return → State neu laden. a11y/Geräteadmin/Bildschirmzeit werden in // den System-Settings gesetzt; beim Zurückkommen pollen wir den neuen Layer-State, // damit die Cards umschalten und "Weiter" freigeschaltet wird. useEffect(() => { const sub = AppState.addEventListener('change', (next) => { if (next === 'active') refresh(); }); return () => sub.remove(); }, [refresh]); // Sicherheitsnetz: Ist der Schutz nach 30 s noch nicht aktiv (z.B. weil die // Aktivierung auf diesem OS scheitert), den "Später einrichten"-Ausweg zeigen. // (Failed-Aktivierungen blenden ihn sofort ein, siehe Handler unten.) useEffect(() => { if (allDone) { setShowSkip(false); return; } const id = setTimeout(() => setShowSkip(true), 30000); return () => clearTimeout(id); }, [allDone]); // Nach VPN-Freigabe schneller nachpollen, bis urlFilter aktiv ist (sonst ~1min, // bis der Step grün wird). Spinner läuft, solange vpnPending. Deckel ~60s. useEffect(() => { if (urlFilterActive) { setVpnPending(false); return; } if (!vpnPending) return; let ticks = 0; const id = setInterval(() => { ticks += 1; refresh(); if (ticks >= 20) { clearInterval(id); setVpnPending(false); } }, 3000); return () => clearInterval(id); }, [vpnPending, urlFilterActive, refresh]); // Auto-Arm (Android): kam der User aus den a11y-Settings zurück und hat den // Service aktiviert (accessibility=true), war der Tamper-Lock bisher NICHT armed // — das passierte erst beim ZWEITEN Tap auf den a11y-Button (Two-Step-Design). // Hier ziehen wir das automatisch nach: sobald a11y-Service an + noch nicht // armed, einmal activateFamilyControls() → geht bei aktivem a11y direkt in den // Arm-Pfad (kein Settings-Öffnen) → Step wird grün ohne zweiten Tap. useEffect(() => { if (Platform.OS !== 'android') return; if (!a11yServiceOn || appDeletionLockActive || armingRef.current) return; armingRef.current = true; activateFamilyControls() .catch(() => {}) .finally(() => { armingRef.current = false; refresh(); }); }, [a11yServiceOn, appDeletionLockActive, activateFamilyControls, refresh]); // ─── Handler (1:1 wie blocker.tsx) ────────────────────────────────────────── async function handleActivateUrlFilter() { try { const result = await activateUrlFilter(); if (!result.enabled) { // Aktivierung fehlgeschlagen → Notausgang sofort anbieten (nicht erst nach 30s). setShowSkip(true); setVpnPending(false); 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(); 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) { setShowSkip(true); setVpnPending(false); Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error')); return { enabled: false }; } } async function handleActivateFamilyControls() { try { const result = await activateFamilyControls(); if (!result.enabled && result.error !== 'accessibility_pending') { 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'), ); } if (result.enabled) await refresh(); return result; } catch (e: any) { Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error')); return { enabled: false }; } } function handleGenerateScreentimeCode() { setScreentimeCode(Math.floor(1000 + Math.random() * 9000).toString()); 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); } } 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 }; } } // ─── Anti-Blind-Klick-Gate ────────────────────────────────────────────────── // Statt direkt die Permission zu feuern, öffnet der Card-Button erst das // Confirm-Sheet. Erst nach Häkchen + „Weiter" läuft der echte Handler. Die // gated-Wrapper geben sofort ein neutrales Result zurück (kein Fehler → kein // Alert); die Card-done-States kommen ohnehin aus den Layer-Flags, nicht aus // dem Return-Wert. const gatedVpn = async () => { setConfirmStep('vpn'); return { enabled: false }; }; const gatedDeviceAdmin = async () => { setConfirmStep('deviceadmin'); return { launched: false }; }; // Akku-Ausnahme: System-Dialog direkt öffnen (ein Tap „Zulassen"); der // Step-Card-Text erklärt das Warum. Return-Refresh via AppState-'active'. const gatedBattery = async () => protection.requestIgnoreBatteryOptimizations(); // Samsung-Sonderweg: App-Detail-Settings (Akku → „Uneingeschränkt" + raus aus // „Schlafende Apps") — das deckt der reine AOSP-Whitelist-Dialog nicht ab. const openBatteryDetails = async () => protection.openAppDetailsSettings(); const gatedApplock = async () => { setConfirmStep('applock'); return { enabled: false }; }; const gatedUrlFilter = async () => { setConfirmStep('urlfilter'); return { enabled: false }; }; const gatedA11y = async () => { // Einmalig Nutzungszugriff holen — DAMIT erkennt die native Guide-Notification // den aktuellen Samsung-Screen und führt Schritt für Schritt. Ohne das gäbe es // nur dumme Toasts. Erst freigeben, dann zurück + a11y nochmal tippen. if (!(await protection.hasUsageAccess())) { setConfirmStep('usage'); return { enabled: false }; } // Dann „Über anderen Apps anzeigen" — damit der Hinweis als sichtbares Overlay // VOR den Settings schwebt (statt nur als leicht übersehbare Notification). if (!(await protection.hasOverlayPermission())) { setConfirmStep('overlay'); return { enabled: false }; } // Erster Tap → Explainer (Installierte Dienste → ReBreak → Schalter, Screenshot). // Folge-Taps (nach Settings-Rückkehr zum Armen) → direkt, ohne Sheet. if (!a11yExplainerShownRef.current) { a11yExplainerShownRef.current = true; setConfirmStep('a11y'); return { enabled: false }; } return handleActivateFamilyControls(); }; function runConfirmedAction(step: ConfirmStep) { switch (step) { case 'vpn': setVpnPending(true); return handleActivateUrlFilter(); case 'urlfilter': return handleActivateUrlFilter(); case 'deviceadmin': return handleRequestDeviceAdmin(); case 'applock': case 'a11y': return handleActivateFamilyControls(); case 'usage': // Nutzungszugriff-Settings öffnen. User gibt frei, kommt zurück, tippt // a11y nochmal → hasUsageAccess true → nächstes Gate / Explainer. return protection.openUsageAccessSettings(); case 'overlay': // „Über anderen Apps anzeigen"-Settings öffnen. Danach a11y nochmal tippen. return protection.openOverlayPermissionSettings(); } } // ─── Render ───────────────────────────────────────────────────────────────── return ( } > {Platform.OS === 'android' ? ( ) : FAMILY_CONTROLS_AVAILABLE && !mdmManaged && !nefilterActive ? ( ) : ( /* iOS Distribution ohne Family-Controls-Entitlement (oder MDM/NEFilter): nur der URL-Filter als einzelner Layer — exakt wie der Blocker-Fallback. */ )} setConfirmStep(null)} onConfirm={() => { const step = confirmStep; setConfirmStep(null); // Erst Sheet schließen lassen, DANN die Permission feuern. Auf iOS kann // ein System-Dialog, der über ein noch wegslidendes Modal präsentiert // wird, still fehlschlagen — exakt die Bug-Klasse, die wir vermeiden wollen. if (step) setTimeout(() => runConfirmedAction(step), 350); }} /> 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; }} /> ); }