import { useEffect, useState } from 'react'; import { View, Text, ScrollView, Switch, TouchableOpacity, Alert, Clipboard, 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 } 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'; 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} ); } // ─── 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 ────────────────────────────────────────────────────── const ONBOARDING_STEPS = ['welcome', 'nickname', 'block', 'done'] as const; type OnboardingStepValue = (typeof ONBOARDING_STEPS)[number]; function OnboardingResetToggle({ colors, currentStep, }: { colors: import('../lib/theme').ColorScheme; currentStep: OnboardingStepValue; }) { 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(); if (step === 'welcome') { // /onboarding/welcome existiert nicht mehr (Duo-Rewrite → /onboarding/index.tsx). // Korrekter Pfad ist /onboarding (Expo Router löst index.tsx auto auf). router.replace('/onboarding'); } else if (step === 'nickname') { // Legacy-Stage — Duo-Flow navigiert intern; /onboarding triggert via // slideFromStep den Resume zur Nickname-Slide. router.replace('/onboarding'); } else if (step === 'block') { router.replace('/(app)/blocker'); } else if (step === 'done') { router.replace('/(app)'); } } 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} ); }