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); const finishedRef = 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; // "Fertig" == blocker.tsx lockedIn. Eine Quelle der Wahrheit. const allDone = Platform.OS === 'android' ? urlFilterActive && appDeletionLockActive && deviceAdminActive : (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(); } // 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]); // ─── Handler (1:1 wie blocker.tsx) ────────────────────────────────────────── async function handleActivateUrlFilter() { try { const result = await activateUrlFilter(); if (!result.enabled) { 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) { 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 }; }; 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': 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; }} /> ); }