chahinebrini 63fae25531 fix(android-protection): explicit specialUse FGS type — Samsung/Android 16 crash loop
RebreakVpnService.onStartCommand crashed with SecurityException because Android 16's validateForegroundServiceType rejects the implicit 2-arg startForeground(). Now passes FOREGROUND_SERVICE_TYPE_SPECIAL_USE explicitly (Google's documented best practice) and guards the call so a failed foreground promotion stops the service cleanly instead of crashing the app. Verified vs reported Galaxy A54 / Android 16 signature (97% of crash events, 1-user crash loop).

Bundles pending working-tree work across native/marketing/locales/mac + graphify-out rebuild. gitignore: google-services.json + /screenshots/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:33:28 +02:00

1408 lines
41 KiB
TypeScript

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 <View style={{ flex: 1, backgroundColor: colors.bg }} />;
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: colors.bg }} edges={['top']}>
<View
style={{
paddingHorizontal: 12,
paddingTop: 4,
paddingBottom: 12,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.06)',
}}
>
<TouchableOpacity
onPress={() => router.back()}
hitSlop={8}
activeOpacity={0.6}
style={{ width: 40, height: 40, alignItems: 'center', justifyContent: 'center' }}
>
<Ionicons name="chevron-back" size={26} color={colors.text} />
</TouchableOpacity>
<Text style={{ fontSize: 20, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
Debug
</Text>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 16, paddingBottom: 60 }}
showsVerticalScrollIndicator={false}
>
<View
style={{
backgroundColor: '#fef3c7',
borderRadius: 14,
padding: 14,
marginBottom: 20,
borderWidth: 1,
borderColor: '#fde68a',
flexDirection: 'row',
gap: 10,
alignItems: 'flex-start',
}}
>
<Ionicons name="warning-outline" size={20} color="#b45309" style={{ marginTop: 1 }} />
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#78350f' }}>
Dev only
</Text>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: '#92400e',
marginTop: 4,
lineHeight: 17,
}}
>
Diese Page ist nur in __DEV__ verfügbar. Production-Builds redirecten auf /.
</Text>
</View>
</View>
<LyraEmotionPreviewCard />
{me ? (
<PlanOverrideToggle
colors={colors}
userId={me.id}
currentPlan={me.plan}
/>
) : null}
{me ? (
<OnboardingResetToggle
colors={colors}
currentStep={me.onboardingStep}
/>
) : null}
<CooldownTestModeToggle />
{Platform.OS === 'android' ? <AndroidA11yResetToggle colors={colors} /> : null}
<RealtimeStatusCard />
<RealtimeLogCard />
{Platform.OS === 'ios' ? <ProtectionLogCard /> : null}
<RedirectTestCard />
<DebugStub
title="LLM-Provider Toggle"
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
icon="bulb-outline"
/>
<DebugStub
title="TTS-Provider Toggle"
subtitle="Phase C: aus app/urge.tsx hierher migrieren"
icon="volume-high-outline"
/>
<DebugStub
title="Bench-Output"
subtitle="TTS/LLM-Latenz-Logs anzeigen (TODO)"
icon="speedometer-outline"
/>
</ScrollView>
</SafeAreaView>
);
}
// ─── 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<Emotion>('idle');
const wanted = EMOTION_ANIMATIONS[emotion] ?? '—';
const exists = EXISTING_TIMELINES.has(wanted);
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="happy-outline" size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
Lyra Emotion Preview
</Text>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,
}}
>
Rive-State-Tester Timeline aus lyra-avatar.riv
</Text>
</View>
</View>
<View style={{ alignItems: 'center', gap: 8, marginBottom: 14 }}>
<RiveAvatar emotion={emotion} size="lg" showLabel />
<Text
style={{
fontSize: 11,
color: exists ? colors.success : colors.textMuted,
fontFamily: 'Menlo',
textAlign: 'center',
}}
>
{emotion} &quot;{wanted}&quot;
{exists ? ' ✓ im .riv' : ' → Idle (Timeline fehlt noch)'}
</Text>
</View>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
{PREVIEW_EMOTIONS.map((em) => {
const isActive = em === emotion;
return (
<TouchableOpacity
key={em}
onPress={() => setEmotion(em)}
activeOpacity={0.7}
style={{
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: 10,
backgroundColor: isActive ? colors.brandOrange : colors.surfaceElevated,
}}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: isActive ? '#ffffff' : colors.textMuted,
}}
>
{em}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
}
// ─── 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 (
<TouchableOpacity
onPress={onPress}
activeOpacity={0.7}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 8,
paddingVertical: 11,
paddingHorizontal: 12,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.border,
marginTop: 8,
}}
>
<Ionicons name={icon} size={16} color={colors.textMuted} />
<Text style={{ flex: 1, fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>
{label}
</Text>
<Ionicons name="open-outline" size={15} color={colors.textMuted} />
</TouchableOpacity>
);
}
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 8 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="arrow-redo-outline" size={18} color={colors.textMuted} />
</View>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}>
Redirect-Test (Layer 1)
</Text>
</View>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_400Regular',
color: colors.textMuted,
lineHeight: 17,
}}
>
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"?
</Text>
<Row
icon="shield-checkmark-outline"
label="Kontrolle: tipico.de direkt"
onPress={() => open('https://' + BLOCKED)}
/>
<Row
icon="git-branch-outline"
label="Test: 302-Redirect tipico.de"
onPress={() => open(redirectUrl)}
/>
</View>
);
}
// ─── 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 (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="wifi-outline" size={18} color={colors.textMuted} />
</View>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}>
Realtime Status
</Text>
<TouchableOpacity
onPress={() => refreshSnapshot()}
hitSlop={8}
activeOpacity={0.6}
>
<Ionicons name="refresh-outline" size={18} color={colors.textMuted} />
</TouchableOpacity>
</View>
<StatusRow
label="Connection"
value={connectionState}
valueColor={stateColor}
colors={colors}
/>
<StatusRow
label="Reconnects (since launch)"
value={String(reconnectCount)}
colors={colors}
/>
<StatusRow
label="Last event"
value={lastEventLabel}
colors={colors}
/>
<StatusRow
label="Token expires in"
value={
tokenSecsLeft == null
? 'n/a'
: tokenSecsLeft < 0
? 'EXPIRED'
: `${tokenSecsLeft}s`
}
valueColor={
tokenSecsLeft != null && tokenSecsLeft < 120 ? colors.error : undefined
}
colors={colors}
/>
{channels.length > 0 ? (
<View style={{ marginTop: 12 }}>
<Text
style={{
fontSize: 11,
color: colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
textTransform: 'uppercase',
letterSpacing: 0.8,
marginBottom: 6,
}}
>
Active channels ({channels.length})
</Text>
{channels.map((ch) => (
<View
key={ch.topic}
style={{
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 5,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<Text
style={{
fontSize: 11,
color: colors.text,
fontFamily: 'Nunito_400Regular',
flex: 1,
}}
numberOfLines={1}
>
{ch.topic}
</Text>
<Text
style={{
fontSize: 11,
color: stateAccent(ch.state, colors),
fontFamily: 'Nunito_600SemiBold',
marginLeft: 8,
}}
>
{ch.state}
</Text>
</View>
))}
</View>
) : (
<View style={{ marginTop: 8 }}>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
fontStyle: 'italic',
}}
>
Keine aktiven Channels
</Text>
</View>
)}
</View>
);
}
function StatusRow({
label,
value,
valueColor,
colors,
}: {
label: string;
value: string;
valueColor?: string;
colors: import('../lib/theme').ColorScheme;
}) {
return (
<View
style={{
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 6,
borderBottomWidth: 1,
borderBottomColor: colors.border,
}}
>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
}}
>
{label}
</Text>
<Text
style={{
fontSize: 12,
color: valueColor ?? colors.text,
fontFamily: 'Nunito_700Bold',
}}
>
{value}
</Text>
</View>
);
}
// ─── 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 (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="list-outline" size={18} color={colors.textMuted} />
</View>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}>
Realtime Log
</Text>
<TouchableOpacity onPress={copyLog} hitSlop={8} activeOpacity={0.6} style={{ marginRight: 8 }}>
<Ionicons name="copy-outline" size={17} color={colors.textMuted} />
</TouchableOpacity>
<TouchableOpacity onPress={clearLog} hitSlop={8} activeOpacity={0.6}>
<Ionicons name="trash-outline" size={17} color={colors.textMuted} />
</TouchableOpacity>
</View>
{log.length === 0 ? (
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
fontStyle: 'italic',
paddingVertical: 8,
}}
>
Noch keine Events — warte auf Subscription-Aktivität.
</Text>
) : (
<View
style={{
maxHeight: 260,
overflow: 'hidden',
}}
>
<ScrollView
style={{ flex: 1 }}
nestedScrollEnabled
showsVerticalScrollIndicator
>
{log.map((entry) => (
<LogLine key={entry.id} entry={entry} colors={colors} />
))}
</ScrollView>
</View>
)}
<Text
style={{
fontSize: 10,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 8,
opacity: 0.6,
}}
>
max 100 Einträge, neueste oben
</Text>
</View>
);
}
// ─── Protection Log Card (iOS native NEFilter/FamilyControls flow) ──────────
function ProtectionLogCard() {
const colors = useColors();
const [logs, setLogs] = useState<string[]>([]);
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 (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 10 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="shield-outline" size={18} color={colors.textMuted} />
</View>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold', flex: 1 }}>
Protection Log (nativ)
</Text>
<TouchableOpacity onPress={loadLogs} hitSlop={8} activeOpacity={0.6} style={{ marginRight: 8 }}>
<Ionicons name="refresh-outline" size={17} color={colors.textMuted} />
</TouchableOpacity>
<TouchableOpacity onPress={copyLogs} hitSlop={8} activeOpacity={0.6} style={{ marginRight: 8 }}>
<Ionicons name="copy-outline" size={17} color={colors.textMuted} />
</TouchableOpacity>
<TouchableOpacity onPress={clearLogs} hitSlop={8} activeOpacity={0.6}>
<Ionicons name="trash-outline" size={17} color={colors.textMuted} />
</TouchableOpacity>
</View>
{logs.length === 0 ? (
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
fontStyle: 'italic',
paddingVertical: 8,
}}
>
{loading
? 'Lade...'
: 'Keine Logs — aktiviere den URL-Filter, dann auf Refresh tippen.'}
</Text>
) : (
<View style={{ maxHeight: 320, overflow: 'hidden' }}>
<ScrollView style={{ flex: 1 }} nestedScrollEnabled showsVerticalScrollIndicator>
{logs.map((line, i) => (
<Text
key={i}
style={{
fontSize: 10,
lineHeight: 15,
color: line.includes('❌') || line.includes('failed')
? '#dc2626'
: line.includes('✅')
? '#16a34a'
: colors.textMuted,
fontFamily: 'Menlo',
marginBottom: 3,
}}
>
{line}
</Text>
))}
</ScrollView>
</View>
)}
<Text
style={{
fontSize: 10,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 8,
opacity: 0.6,
}}
>
SharedLogStore — max 200 Einträge, neueste oben
</Text>
</View>
);
}
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 (
<View
style={{
flexDirection: 'row',
gap: 6,
paddingVertical: 3,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.03)',
}}
>
<Text
style={{
fontSize: 10,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
width: 58,
flexShrink: 0,
}}
>
{hms}
</Text>
<Text
style={{
fontSize: 10,
color: isError ? colors.error : isOpen ? colors.success : colors.text,
fontFamily: 'Nunito_400Regular',
flex: 1,
flexWrap: 'wrap',
}}
>
{entry.text}
</Text>
</View>
);
}
// ─── 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<Plan, string> = {
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<Plan | null>(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 (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="star-outline" size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
Plan-Override (DEV)
</Text>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,
}}
>
POST /api/dev/set-plan — nur staging
</Text>
</View>
</View>
<View style={{ flexDirection: 'row', gap: 8 }}>
{PLANS.map((plan) => {
const isActive = plan === currentPlan;
const accent = PLAN_COLOR[plan];
return (
<TouchableOpacity
key={plan}
onPress={() => 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,
}}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: isActive ? '#ffffff' : colors.textMuted,
textTransform: 'capitalize',
}}
>
{plan}
</Text>
</TouchableOpacity>
);
})}
</View>
{sheetTarget ? (
<PlanChangeSheet
visible={sheetTarget !== null}
targetPlan={sheetTarget}
onConfirm={() => {
const t = sheetTarget;
setSheetTarget(null);
applyPlanSwitch(t);
}}
onClose={() => setSheetTarget(null)}
/>
) : null}
</View>
);
}
// ─── 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 (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="rocket-outline" size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
Onboarding-Step
</Text>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,
}}
>
PATCH /api/profile/me/onboarding-step — aktuell: {currentStep}
</Text>
</View>
</View>
<View style={{ flexDirection: 'row', gap: 6 }}>
{ONBOARDING_STEPS.map((step) => {
const isActive = step === currentStep;
return (
<TouchableOpacity
key={step}
onPress={() => 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,
}}
>
<Text
style={{
fontSize: 12,
fontFamily: 'Nunito_700Bold',
color: isActive ? '#ffffff' : colors.textMuted,
textTransform: 'capitalize',
}}
>
{step}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
}
// ─── 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 (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, marginBottom: 12 }}>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="accessibility-outline" size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
Force Reset (Android)
</Text>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,
}}
>
VPN stoppen + tamper-lock disarmen + Backend als disabled markieren
+ Settings öffnen — Anti-Auto-Reactivation für Screenshot-Capture.
</Text>
</View>
</View>
<TouchableOpacity
onPress={forceResetAndOpen}
disabled={busy}
activeOpacity={0.7}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 10,
paddingVertical: 10,
alignItems: 'center',
opacity: busy ? 0.5 : 1,
}}
>
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: '#ffffff',
}}
>
{busy ? 'Reset läuft...' : 'Force Reset + Settings öffnen'}
</Text>
</TouchableOpacity>
</View>
);
}
// ─── 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 (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
flexDirection: 'row',
gap: 12,
alignItems: 'flex-start',
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name="timer-outline" size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>
Test-Cooldown (40 Sek)
</Text>
<Switch
value={enabled}
onValueChange={toggle}
trackColor={{ false: colors.border, true: '#16a34a' }}
thumbColor="#ffffff"
/>
</View>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,
}}
>
Nur auf staging der nächste &quot;Cooldown starten&quot; nutzt dann 40 Sekunden statt 24h.
</Text>
</View>
</View>
);
}
// ─── Debug Stub ────────────────────────────────────────────────────────────
function DebugStub({
title,
subtitle,
icon,
}: {
title: string;
subtitle: string;
icon: React.ComponentProps<typeof Ionicons>['name'];
}) {
const colors = useColors();
return (
<View
style={{
backgroundColor: colors.surface,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.05)',
padding: 14,
marginBottom: 12,
flexDirection: 'row',
gap: 12,
alignItems: 'flex-start',
opacity: 0.6,
}}
>
<View
style={{
width: 36,
height: 36,
borderRadius: 11,
backgroundColor: colors.surfaceElevated,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={18} color={colors.textMuted} />
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, color: colors.text, fontFamily: 'Nunito_700Bold' }}>{title}</Text>
<Text
style={{
fontSize: 12,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
marginTop: 3,
lineHeight: 17,
}}
>
{subtitle}
</Text>
</View>
</View>
);
}