import { useEffect, useState } from 'react'; import { View, Text, ScrollView, Switch, TouchableOpacity, Alert, Clipboard, Linking, Platform, } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import { useColors } from '../lib/theme'; import { useMe, invalidateMe, type Plan, type OnboardingStep } from '../hooks/useMe'; import { apiFetch } from '../lib/api'; import { PlanChangeSheet } from '../components/plan/PlanChangeSheet'; import { getCooldownTestMode, setCooldownTestMode, protection } from '../lib/protection'; import { useRealtimeDebugStore, type LogEntry } from '../stores/realtimeDebug'; import { supabase } from '../lib/supabase'; import { RiveAvatar, EMOTION_ANIMATIONS, EXISTING_TIMELINES, type Emotion } from '../components/RiveAvatar'; export default function DebugScreen() { const router = useRouter(); const colors = useColors(); const { me } = useMe(); // Debug-Page ist in __DEV__ immer da, plus in TestFlight/production-Builds // wenn EXPO_PUBLIC_ENABLE_DEBUG gesetzt ist (eas.json production-Profil) — // damit Tester den Protection-Log-Viewer ohne Mac/Console.app nutzen können. // Für den echten App-Store-Release das Flag aus eas.json nehmen → wieder dicht. const debugAccessible = __DEV__ || process.env.EXPO_PUBLIC_ENABLE_DEBUG === '1'; useEffect(() => { if (!debugAccessible) { router.replace('/'); } }, [router, debugAccessible]); if (!debugAccessible) { return ; } return ( router.back()} hitSlop={8} activeOpacity={0.6} style={{ width: 40, height: 40, alignItems: 'center', justifyContent: 'center' }} > Debug Dev only Diese Page ist nur in __DEV__ verfügbar. Production-Builds redirecten auf /. {me ? ( ) : null} {me ? ( ) : null} {Platform.OS === 'android' ? : null} {Platform.OS === 'ios' ? : null} ); } // ─── Lyra Emotion Preview (Rive-State-Tester) ────────────────────────────── /** * Tippe einen Emotion-State an → der Avatar spielt die zugehörige Timeline aus * `assets/lyra-avatar.riv`. So lassen sich neue States testen, OHNE die echten * Trigger (Tippen / Atmen / Rückfall-Text / LLM-Generierung) auszulösen. * * Workflow zum Selber-Bauen: * 1. `lyra-avatar.riv` im Rive-Editor (rive.app) öffnen, Timeline exakt mit dem * unten angezeigten Namen anlegen (Contract — Tippfehler = stiller Ausfall). * 2. Als `.riv` exportieren, nach `apps/rebreak-native/assets/lyra-avatar.riv` * legen (überschreiben). * 3. iOS-Dev-Build neu laden (Metro `r`) — Avatar zeigt sofort die neue Anim. * (Android braucht Rebuild: Raw-Resource wird erst beim prebuild gemirrort.) * * Zeigt für noch nicht im .riv vorhandene States den statischen Idle-Frame. */ const PREVIEW_EMOTIONS: Emotion[] = [ 'idle', 'happy', 'empathy', 'thinking', 'listening', 'calm', 'sad', 'joy', 'confusion', 'surprise', ]; function LyraEmotionPreviewCard() { const colors = useColors(); const [emotion, setEmotion] = useState('idle'); const wanted = EMOTION_ANIMATIONS[emotion] ?? '—'; const exists = EXISTING_TIMELINES.has(wanted); return ( Lyra Emotion Preview Rive-State-Tester — Timeline aus lyra-avatar.riv {emotion} → "{wanted}" {exists ? ' ✓ im .riv' : ' → Idle (Timeline fehlt noch)'} {PREVIEW_EMOTIONS.map((em) => { const isActive = em === emotion; return ( setEmotion(em)} activeOpacity={0.7} style={{ paddingVertical: 8, paddingHorizontal: 14, borderRadius: 10, backgroundColor: isActive ? colors.brandOrange : colors.surfaceElevated, }} > {em} ); })} ); } // ─── Redirect-Test Card (Layer-1-Bypass-Repro) ───────────────────────────── /** * Reproduziert den Casino-Mail-Fall: ein Klick geht erst auf einen ERLAUBTEN * Zwischen-Host (wie SendGrid-Click-Tracking), der dann per HTTP-302 auf eine * BLOCKIERTE Domain weiterleitet. Damit lässt sich gezielt prüfen, ob der * DNS-Filter (Layer 1) die Zieldomain auch nach einem Redirect noch sinkholet. * * Nach dem Tippen: oben im "Protection Log" prüfen, ob ein `BLOCKED:`-Eintrag * für tipico.de auftaucht. */ function RedirectTestCard() { const colors = useColors(); // tipico.de ist sicher in der Blocklist (gambling-domains.json + 329k-Liste). const BLOCKED = 'tipico.de'; // httpbin.org/redirect-to liefert eine echte HTTP-302-Response auf das Ziel — // httpbin selbst ist nicht geblockt, spielt also den erlaubten Zwischen-Host. const redirectUrl = 'https://httpbin.org/redirect-to?url=' + encodeURIComponent('https://' + BLOCKED) + '&status_code=302'; function open(url: string) { Linking.openURL(url).catch((e) => Alert.alert('Fehler', String(e?.message ?? e)), ); } function Row({ icon, label, onPress, }: { icon: keyof typeof Ionicons.glyphMap; label: string; onPress: () => void; }) { return ( {label} ); } return ( Redirect-Test (Layer 1) Reproduziert den Casino-Mail-Fall: erlaubter Zwischen-Host → 302-Redirect → blockierte Domain (tipico.de). Beide tippen, dann oben den Protection Log vergleichen — kommt ein „BLOCKED: tipico.de"? open('https://' + BLOCKED)} /> open(redirectUrl)} /> ); } // ─── Realtime Status Card ────────────────────────────────────────────────── function RealtimeStatusCard() { const colors = useColors(); const connectionState = useRealtimeDebugStore((s) => s.connectionState); const reconnectCount = useRealtimeDebugStore((s) => s.reconnectCount); const lastEventAt = useRealtimeDebugStore((s) => s.lastEventAt); const tokenExpiresAt = useRealtimeDebugStore((s) => s.tokenExpiresAt); const refreshSnapshot = useRealtimeDebugStore((s) => s.refreshSnapshot); const [tick, setTick] = useState(0); const [channels, setChannels] = useState<{ topic: string; state: string }[]>([]); // Re-render once per second to keep relative timestamps live useEffect(() => { const t = setInterval(() => setTick((n) => n + 1), 1000); return () => clearInterval(t); }, []); // Refresh snapshot + channel list on each tick useEffect(() => { refreshSnapshot(); try { const rt = supabase.realtime as any; const raw: { topic?: string; state?: string }[] = rt.channels ?? []; setChannels( raw.map((ch) => ({ topic: ch.topic ?? '?', state: ch.state ?? '?', })), ); } catch { setChannels([]); } }, [tick, refreshSnapshot]); const stateColor = stateAccent(connectionState, colors); const tokenSecsLeft = tokenExpiresAt != null ? Math.round(tokenExpiresAt - Date.now() / 1000) : null; const lastEventLabel = lastEventAt ? relativeSeconds(Date.now() - lastEventAt) : 'never'; return ( Realtime Status refreshSnapshot()} hitSlop={8} activeOpacity={0.6} > {channels.length > 0 ? ( Active channels ({channels.length}) {channels.map((ch) => ( {ch.topic} {ch.state} ))} ) : ( Keine aktiven Channels )} ); } function StatusRow({ label, value, valueColor, colors, }: { label: string; value: string; valueColor?: string; colors: import('../lib/theme').ColorScheme; }) { return ( {label} {value} ); } // ─── Realtime Log Card ───────────────────────────────────────────────────── function RealtimeLogCard() { const colors = useColors(); const log = useRealtimeDebugStore((s) => s.log); const clearLog = useRealtimeDebugStore((s) => s.clearLog); function copyLog() { const text = log .map((e) => `[${new Date(e.ts).toISOString()}] ${e.text}`) .join('\n'); Clipboard.setString(text); Alert.alert('Kopiert', `${log.length} Log-Einträge in Zwischenablage.`); } return ( Realtime Log {log.length === 0 ? ( Noch keine Events — warte auf Subscription-Aktivität. ) : ( {log.map((entry) => ( ))} )} max 100 Einträge, neueste oben ); } // ─── Protection Log Card (iOS native NEFilter/FamilyControls flow) ────────── function ProtectionLogCard() { const colors = useColors(); const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(false); async function loadLogs() { setLoading(true); try { const next = await protection.getProtectionLogs(); // neueste oben setLogs([...next].reverse()); } finally { setLoading(false); } } useEffect(() => { loadLogs(); }, []); function copyLogs() { Clipboard.setString(logs.join('\n')); Alert.alert('Kopiert', `${logs.length} Protection-Log-Zeilen in Zwischenablage.`); } async function clearLogs() { await protection.clearProtectionLogs(); setLogs([]); } return ( Protection Log (nativ) {logs.length === 0 ? ( {loading ? 'Lade...' : 'Keine Logs — aktiviere den URL-Filter, dann auf Refresh tippen.'} ) : ( {logs.map((line, i) => ( {line} ))} )} SharedLogStore — max 200 Einträge, neueste oben ); } function LogLine({ entry, colors, }: { entry: LogEntry; colors: import('../lib/theme').ColorScheme; }) { const time = new Date(entry.ts); const hms = `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`; const isError = entry.text.includes('error') || entry.text.includes('close') || entry.text.includes('CLOSED'); const isOpen = entry.text.includes('open') || entry.text.includes('joined'); return ( {hms} {entry.text} ); } // ─── Helpers ─────────────────────────────────────────────────────────────── function pad(n: number) { return String(n).padStart(2, '0'); } function relativeSeconds(diffMs: number): string { const s = Math.round(diffMs / 1000); if (s < 60) return `vor ${s}s`; const m = Math.floor(s / 60); if (m < 60) return `vor ${m}m`; return `vor ${Math.floor(m / 60)}h`; } function stateAccent(state: string, colors: import('../lib/theme').ColorScheme): string { const upper = state.toUpperCase(); if (upper === 'OPEN' || upper === 'JOINED' || upper === 'SUBSCRIBED') return colors.success; if (upper === 'CLOSED' || upper === 'CHANNEL_ERROR' || upper === 'TIMED_OUT') return colors.error; if (upper === 'CONNECTING' || upper === 'JOINING') return colors.warning; return colors.textMuted; } // ─── Plan Override ───────────────────────────────────────────────────────── const PLANS: Plan[] = ['free', 'pro', 'legend']; const PLAN_COLOR: Record = { free: '#737373', pro: '#007AFF', legend: '#f59e0b', }; function PlanOverrideToggle({ colors, userId, currentPlan, }: { colors: import('../lib/theme').ColorScheme; userId: string; currentPlan: Plan; }) { const [loading, setLoading] = useState(false); const [sheetTarget, setSheetTarget] = useState(null); async function applyPlanSwitch(plan: Plan) { setLoading(true); try { await apiFetch('/api/dev/set-plan', { method: 'POST', body: { plan }, }); invalidateMe(); } catch (e: unknown) { Alert.alert('Fehler', e instanceof Error ? e.message : String(e)); } finally { setLoading(false); } } function switchPlan(plan: Plan) { if (plan === currentPlan) return; setSheetTarget(plan); } return ( Plan-Override (DEV) POST /api/dev/set-plan — nur staging {PLANS.map((plan) => { const isActive = plan === currentPlan; const accent = PLAN_COLOR[plan]; return ( switchPlan(plan)} disabled={loading || isActive} activeOpacity={0.7} style={{ flex: 1, paddingVertical: 10, borderRadius: 10, alignItems: 'center', backgroundColor: isActive ? accent : colors.surfaceElevated, opacity: loading ? 0.5 : 1, }} > {plan} ); })} {sheetTarget ? ( { const t = sheetTarget; setSheetTarget(null); applyPlanSwitch(t); }} onClose={() => setSheetTarget(null)} /> ) : null} ); } // ─── Onboarding Reset ────────────────────────────────────────────────────── // Nur die im aktuellen Duo-Flow relevanten Sprungziele. `pre_protection` springt // per slideFromStep direkt zum Protection-Step (überspringt Welcome/Nickname/Plan) // — das ist der schnellste Weg, den Schutz-Onboarding-Flow zu testen. const ONBOARDING_STEPS = ['welcome', 'pre_protection', 'done'] as const; type OnboardingStepValue = (typeof ONBOARDING_STEPS)[number]; function OnboardingResetToggle({ colors, currentStep, }: { colors: import('../lib/theme').ColorScheme; currentStep: OnboardingStep; }) { const router = useRouter(); const [loading, setLoading] = useState(false); async function setStep(step: OnboardingStepValue) { if (loading || step === currentStep) return; setLoading(true); try { await apiFetch('/api/profile/me/onboarding-step', { method: 'PATCH', body: { step }, }); invalidateMe(); // done → zurück in die App; alles andere (welcome, pre_protection) → /onboarding, // wo slideFromStep den passenden Resume-Slide auflöst. if (step === 'done') { router.replace('/(app)'); } else { router.replace('/onboarding'); } } catch (e: unknown) { Alert.alert('Fehler', e instanceof Error ? e.message : String(e)); } finally { setLoading(false); } } return ( Onboarding-Step PATCH /api/profile/me/onboarding-step — aktuell: {currentStep} {ONBOARDING_STEPS.map((step) => { const isActive = step === currentStep; return ( setStep(step)} disabled={loading || isActive} activeOpacity={0.7} style={{ flex: 1, paddingVertical: 10, borderRadius: 10, alignItems: 'center', backgroundColor: isActive ? colors.brandOrange : colors.surfaceElevated, opacity: loading ? 0.5 : 1, }} > {step} ); })} ); } // ─── Android: A11y-Settings Quick-Open ────────────────────────────────────── /** * Android-only Dev-Helper: öffnet Settings → Bedienungshilfen damit User den * ReBreak-Service manuell off-toggeln kann. Wird gebraucht für Screenshot- * Capture vom a11y-Settings-Page in 4 Sprachen — nach Cooldown disarmt * forceDisable() den tamper-lock, aber das System-Switch in den Einstellungen * bleibt programmatisch un-modifizierbar (Android-OS-Restriction). */ function AndroidA11yResetToggle({ colors, }: { colors: import('../lib/theme').ColorScheme; }) { const [busy, setBusy] = useState(false); async function forceResetAndOpen() { if (busy) return; setBusy(true); try { // Step 1: native forceDisable — stoppt VPN, disarmt tamper-lock, setzt // filter_enabled=false → a11y-service wird passiv → blockt nichts mehr. await protection.forceDisable(); // Step 2: backend protection-state auf "explizit disabled" markieren → // enforceProtection-Loop feuert KEINE Auto-Reactivation in den nächsten // Pollings. Sonst wäre der a11y-Service-Off-Toggle zwecklos weil die // App den Filter sofort wieder hochfährt. await apiFetch('/api/protection/dev-force-disabled', { method: 'POST' }); invalidateMe(); // Step 3: Android-Settings → Bedienungshilfen öffnen — User toggelt // ReBreak-Service manuell off (Android-OS-Restriction: programmatisch // nicht setzbar). Danach Screenshot-Capture frischer Trigger möglich. await protection.openSystemSettings('accessibility'); } catch (e: unknown) { Alert.alert('Fehler', e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } } return ( Force Reset (Android) VPN stoppen + tamper-lock disarmen + Backend als disabled markieren + Settings öffnen — Anti-Auto-Reactivation für Screenshot-Capture. {busy ? 'Reset läuft...' : 'Force Reset + Settings öffnen'} ); } // ─── Cooldown Test Mode ──────────────────────────────────────────────────── function CooldownTestModeToggle() { const colors = useColors(); const [enabled, setEnabled] = useState(false); useEffect(() => { getCooldownTestMode().then(setEnabled); }, []); async function toggle(value: boolean) { setEnabled(value); await setCooldownTestMode(value); } return ( Test-Cooldown (40 Sek) Nur auf staging — der nächste "Cooldown starten" nutzt dann 40 Sekunden statt 24h. ); } // ─── Debug Stub ──────────────────────────────────────────────────────────── function DebugStub({ title, subtitle, icon, }: { title: string; subtitle: string; icon: React.ComponentProps['name']; }) { const colors = useColors(); return ( {title} {subtitle} ); }