feat(debug,protection): Force Reset for Android screenshot-capture

Bug-context: user reports nach Cooldown-Disable auf v0.2.1 Android-Build
reactiviert sich Schutz auto → a11y-Settings bleibt blockiert → keine Screenshots
möglich. v0.3.0 hat den Backend-protectionDisabledAt-Guard der das verhindert,
aber Test-Devices brauchen ein direktes Reset-Tool für Multi-Locale-Screenshots.

Backend:
- POST /api/protection/dev-force-disabled — sets protectionDisabledAt=NOW()
  ohne Cooldown-Vorlauf. Production-Guard (rebreak.org-non-staging → 403).

Frontend:
- /debug Android-Section refactored: "Force Reset + Settings öffnen" Button
- Bundle aus 3 Steps:
  1. native forceDisable (VPN stop + tamper disarm + filter_enabled=false)
  2. backend dev-force-disabled (Anti-Auto-Reactivation-Mark)
  3. Settings → Bedienungshilfen öffnen
- Danach: User toggled ReBreak-Service in Android-Settings manuell off
  → frischer a11y-deep-link-Trigger für nächste Screenshot-Iteration

Also: fix /onboarding/welcome → /onboarding (Duo-Rewrite hat den alten Pfad
gelöscht). Route 404 auf Android sichtbar wenn User in debug-toggle 'welcome'
oder 'nickname' tappt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-17 22:33:40 +02:00
parent 77bb7b84dc
commit 56bb59915d
2 changed files with 158 additions and 3 deletions

View File

@ -7,6 +7,7 @@ import {
TouchableOpacity,
Alert,
Clipboard,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
@ -15,7 +16,7 @@ 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 } from '../lib/protection';
import { getCooldownTestMode, setCooldownTestMode, protection } from '../lib/protection';
import { useRealtimeDebugStore, type LogEntry } from '../stores/realtimeDebug';
import { supabase } from '../lib/supabase';
@ -115,6 +116,8 @@ export default function DebugScreen() {
<CooldownTestModeToggle />
{Platform.OS === 'android' ? <AndroidA11yResetToggle colors={colors} /> : null}
<RealtimeStatusCard />
<RealtimeLogCard />
@ -690,9 +693,13 @@ function OnboardingResetToggle({
});
invalidateMe();
if (step === 'welcome') {
router.replace('/onboarding/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') {
router.replace('/profile/edit');
// 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') {
@ -783,6 +790,117 @@ function OnboardingResetToggle({
);
}
// ─── 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() {

View File

@ -0,0 +1,37 @@
import { requireUser } from "../../utils/auth";
import { usePrisma } from "../../utils/prisma";
/**
* POST /api/protection/dev-force-disabled
*
* DEV/STAGING-ONLY: Setzt protectionDisabledAt = NOW() ohne Cooldown-Vorlauf.
* Frontend-Debug-Button für Screenshot-Capture (Android-a11y-reset-flow).
*
* Production-Guard: appUrl enthält "rebreak.org" aber NICHT "staging" 403.
*
* Sobald gesetzt:
* - /api/protection/state gibt protectionShouldBeActive=false zurück
* - Frontend's enforceProtection-Loop feuert KEINE Auto-Reactivation mehr
* - User kann a11y-Settings öffnen und manuell den ReBreak-Service off-toggeln
*
* Zum Wiedereinschalten: POST /api/protection/mark-active (clear flag).
*/
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const config = useRuntimeConfig(event);
const appUrl = (config.public?.appUrl as string) ?? "";
const isProductionUrl =
appUrl.includes("rebreak.org") && !appUrl.includes("staging");
if (isProductionUrl) {
throw createError({ statusCode: 403, message: "dev-only" });
}
const db = usePrisma();
await db.profile.update({
where: { id: user.id },
data: { protectionDisabledAt: new Date() },
});
return { success: true };
});