import { useCallback, useEffect, useRef, useState } from 'react'; import { View, ActivityIndicator, AppState, Platform, Alert } from 'react-native'; import { useRouter } from 'expo-router'; import * as Notifications from 'expo-notifications'; import { useTranslation } from 'react-i18next'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useAuthStore } from '../../stores/auth'; import { useNotificationStore } from '../../stores/notifications'; import { useMailConsentStore } from '../../stores/mailConsent'; import { useCommunityStore } from '../../stores/community'; import { useColors } from '../../lib/theme'; import { NativeTabs } from '../../components/NativeTabs'; import { MailConsentReminderSheet } from '../../components/mail/MailConsentReminderSheet'; import { ProtectionOnboardingSheet } from '../../components/ProtectionOnboardingSheet'; import { protection } from '../../lib/protection'; import RebreakProtection from '../../modules/rebreak-protection'; import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons'; import { apiFetch } from '../../lib/api'; import { useQuery } from '@tanstack/react-query'; import { useMe } from '../../hooks/useMe'; import { DiGaMilestoneModal } from '../../components/DiGaMilestoneModal'; const ONBOARDING_COMPLETED_KEY = '@rebreak/protection-onboarding-completed'; const ANDROID_RESTART_PROMPT_KEY = '@rebreak/protection-restart-prompt-shown'; type DmConvUnreadSlice = { unreadCount?: number }; export default function AppLayout() { const router = useRouter(); const { t } = useTranslation(); const { session, loading } = useAuthStore(); const colors = useColors(); const loadNotifications = useNotificationStore((s) => s.load); const startRealtime = useNotificationStore((s) => s.startRealtime); const stopRealtime = useNotificationStore((s) => s.stopRealtime); const resetNotifications = useNotificationStore((s) => s.reset); const composeInputFocused = useCommunityStore((s) => s.composeInputFocused); const { visible: consentVisible, connections: consentConnections, show: showConsent, hide: hideConsent, markConsented } = useMailConsentStore(); const rearmInFlightRef = useRef(false); const bypassNotifiedRef = useRef(false); // Android-only: Onboarding-Sheet bis beide Layer eingerichtet sind const [onboardingVisible, setOnboardingVisible] = useState(false); const restartPromptInFlightRef = useRef(false); const showAndroidRestartPromptIfNeeded = useCallback(async () => { if (Platform.OS !== 'android') return; if (restartPromptInFlightRef.current) return; const alreadyShown = await AsyncStorage.getItem(ANDROID_RESTART_PROMPT_KEY); if (alreadyShown === '1') return; restartPromptInFlightRef.current = true; await new Promise((resolve) => { Alert.alert( t('onboarding.protection.android_restart_title'), t('onboarding.protection.android_restart_body'), [ { text: t('onboarding.protection.android_restart_later'), style: 'cancel', onPress: () => resolve(), }, { text: t('onboarding.protection.android_restart_now'), onPress: () => { (async () => { try { const result = await RebreakProtection.openPowerDialog?.(); if (!result?.opened) { await protection.openSystemSettings(); } } catch { await protection.openSystemSettings().catch(() => {}); } finally { resolve(); } })(); }, }, ], { cancelable: false }, ); }); await AsyncStorage.setItem(ANDROID_RESTART_PROMPT_KEY, '1'); restartPromptInFlightRef.current = false; }, [t]); const checkAndShowOnboarding = useCallback(async () => { if (Platform.OS !== 'android') return; await protection.reconcileVpn().catch(() => {}); const completed = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY); const layers = await protection.getDeviceState().catch(() => null); if (!layers) return; const vpnActive = layers.vpn === true; const a11yActive = layers.accessibility === true; if (vpnActive && a11yActive) { // Self-heal: falls tamper_armed nach Reboot/Bypass verloren ging, // erneut armen ohne den User zurück in den A11y-Flow zu schicken. await protection.activateFamilyControls().catch(() => {}); await showAndroidRestartPromptIfNeeded(); } if (completed === '1') return; if (vpnActive && a11yActive) { await AsyncStorage.setItem(ONBOARDING_COMPLETED_KEY, '1'); return; } setOnboardingVisible(true); }, []); useEffect(() => { if (!session || Platform.OS !== 'android') return; checkAndShowOnboarding(); const sub = AppState.addEventListener('change', (next) => { if (next === 'active') checkAndShowOnboarding(); }); return () => sub.remove(); }, [session, checkAndShowOnboarding]); async function handleOnboardingComplete() { await AsyncStorage.setItem(ONBOARDING_COMPLETED_KEY, '1'); setOnboardingVisible(false); } function handleOnboardingSkip() { setOnboardingVisible(false); } // Unread DMs → badge on the Chat tab. Same query key chat.tsx uses, so // React Query dedupes (no double fetch when both layouts mount). const { data: dmConvs = [] } = useQuery({ queryKey: ['dm-conversations'], queryFn: () => apiFetch('/api/chat/dm-conversations'), staleTime: 30_000, enabled: !!session, }); const unreadDms = dmConvs.reduce((sum, c) => sum + (c.unreadCount ?? 0), 0); const chatBadge = unreadDms > 0 ? (unreadDms > 99 ? '99+' : String(unreadDms)) : undefined; // Android-Tab-Icons müssen async aus Ionicons-Font generiert werden (kein // SF-Symbol-Support). preloadTabIcons() läuft schon beim Modul-Import — hier // nur den ready-State tracken damit wir re-rendern wenn der Cache fertig ist. const [tabIconsReady, setTabIconsReady] = useState(Platform.OS !== 'android'); const hiddenTabBar = useCallback(() => null, []); useEffect(() => { if (Platform.OS === 'android' && !tabIconsReady) { preloadTabIcons().then(() => setTabIconsReady(true)); } }, [tabIconsReady]); useEffect(() => { if (!loading && !session) { router.replace('/signin'); } }, [session, loading]); // Onboarding-Routing-Gate (resume-on-relaunch via DB-State). // Aktuell nur Welcome-Branch — Nickname/Block/Pricing/DiGA-Stages werden im // kommenden Duo-Style Onboarding alle INNERHALB von /onboarding/* gehandhabt, // brauchen also keinen Re-Direct von (app) aus. const { me } = useMe(); useEffect(() => { if (!session || !me) return; if (me.onboardingStep !== 'done') { router.replace('/onboarding'); } }, [session, me?.onboardingStep]); useEffect(() => { if (!session) { resetNotifications(); return; } loadNotifications(); startRealtime(); return () => { stopRealtime(); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [session?.user?.id]); useEffect(() => { if (!session) return; apiFetch<{ id: string; email: string }[]>('/api/mail-connections/pending-consent') .then((pending) => { if (pending.length > 0) { showConsent(pending); } }) .catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, [session?.user?.id]); useEffect(() => { if (!session || Platform.OS !== 'ios') return; let cancelled = false; let pollTimer: ReturnType | null = null; async function notifyBypassDetected(): Promise { const perms = await Notifications.getPermissionsAsync(); let granted = perms.granted || perms.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; if (!granted) { const req = await Notifications.requestPermissionsAsync(); granted = req.granted || req.ios?.status === Notifications.IosAuthorizationStatus.PROVISIONAL; } if (!granted) return false; await Notifications.scheduleNotificationAsync({ content: { title: 'ReBreak Schutz manipuliert', body: 'Tippe hier, um den Schutz sofort wieder zu aktivieren.', sound: 'default', data: { type: 'protection_bypass_detected' }, }, trigger: null, }); return true; } async function enforceProtection() { if (cancelled || rearmInFlightRef.current) return; try { // Self-Heal: wenn der Schutz an sein soll der VpnService aber tot ist // (Reinstall / OS-Kill) → neu starten, bevor wir den State lesen. await protection.reconcileVpn(); if (cancelled) return; const state = await protection.getCombinedState(); if (cancelled) return; if (state.phase !== 'recoveringFromBypass') { bypassNotifiedRef.current = false; return; } if (bypassNotifiedRef.current) return; bypassNotifiedRef.current = true; const notified = await notifyBypassDetected(); if (!notified) { // Fallback wenn Notifications nicht erlaubt sind. Reaktivierung setzt // NUR den Filter/VPN wieder — kein a11y-Prompt (das passiert nur beim // ersten Einrichten). rearmInFlightRef.current = true; router.replace('/blocker'); await protection.activate().catch(() => null); } } finally { rearmInFlightRef.current = false; } } async function onBypassNotificationTap() { if (rearmInFlightRef.current) return; rearmInFlightRef.current = true; try { router.replace('/blocker'); // Reaktivierung = nur Filter/VPN wieder setzen (a11y nur beim ersten Mal). await protection.activate().catch(() => null); } finally { rearmInFlightRef.current = false; } } // Initial check + foreground re-check + periodisches Polling als Fallback. enforceProtection(); const notifTapSub = Notifications.addNotificationResponseReceivedListener((response) => { const type = response.notification.request.content.data?.type; if (type === 'protection_bypass_detected') { void onBypassNotificationTap(); } }); Notifications.getLastNotificationResponseAsync().then((response) => { const type = response?.notification.request.content.data?.type; if (type === 'protection_bypass_detected') { void onBypassNotificationTap(); } }); const appStateSub = AppState.addEventListener('change', (s) => { if (s === 'active') { enforceProtection(); } }); pollTimer = setInterval(enforceProtection, 15000); return () => { cancelled = true; notifTapSub.remove(); appStateSub.remove(); if (pollTimer) clearInterval(pollTimer); }; }, [session, router]); if (loading || !session) { return ( ); } return ( <> {consentVisible && ( )} {Platform.OS === 'android' && ( )} Platform.OS === 'ios' ? { sfSymbol: 'house.fill' } : (getTabIcon('home') as any), }} /> Platform.OS === 'ios' ? { sfSymbol: 'bubble.left.and.bubble.right.fill' } : (getTabIcon('chat') as any), }} /> Platform.OS === 'ios' ? { sfSymbol: 'sparkles' } : (getTabIcon('coach') as any), }} /> Platform.OS === 'ios' ? { sfSymbol: 'checkmark.shield.fill' } : (getTabIcon('blocker') as any), }} /> Platform.OS === 'ios' ? { sfSymbol: 'envelope.fill' } : (getTabIcon('mail') as any), }} /> ); }