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();
useEffect(() => {
if (!__DEV__) {
router.replace('/');
}
}, [router]);
if (!__DEV__) {
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}
);
}