chahinebrini db0aa6d24e feat(native): Protection Onboarding v2 + Devices + ProtectionSlide
- ProtectionOnboardingSheet: Android a11y 2-step flow mit tamper-arm nach Return
- ProtectionDetailsSheet: cleanup, iOS/Android split, locked-state logic
- ProtectionSlide: neuer Onboarding-Slide für Protection-Intro
- _layout.tsx: reconcileVpn on app-foreground (Android VPN self-heal)
- devices.tsx: Two-device approval flow
- useProtectionState: applyCooldownDisableIfElapsed, forceDisable on cooldown-end
- iOS module Info.plist: bundle version bumps
- app.config.ts: minor config updates
- tmp/.deploy-runtimes: build-time metrics aktualisiert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 04:30:20 +02:00

583 lines
18 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { Alert, AppState, Image, Platform, Text, useWindowDimensions, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../../lib/theme';
import { apiFetch } from '../../../lib/api';
import { invalidateMe } from '../../../hooks/useMe';
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../../lib/protection';
import RebreakProtection from '../../../modules/rebreak-protection';
import { getPermissionScreenshot } from '../../../lib/onboardingAssets';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
import { ScreenshotPointer } from '../ScreenshotPointer';
import { PermissionDeniedSheet } from '../../PermissionDeniedSheet';
import i18n from '../../../lib/i18n';
/**
* Onboarding-Schutz-Step.
*
* Platform.OS-Dispatch:
* iOS → IosProtectionSlide (NEFilter + Family-Controls)
* Android → AndroidProtectionSlide (VpnService + Accessibility-Tamper-Lock)
*
* Beide haben den gleichen Eltern-Vertrag (current/total/onDone) und nutzen
* den gleichen Pre-Explainer + Lyra-Bubble + CTA-Pattern — die Innereien
* unterscheiden sich nur in (a) welche Permission-Dialoge geöffnet werden
* und (b) welche Screenshots gezeigt werden.
*/
export function ProtectionSlide(props: {
onDone: () => void;
current: number;
total: number;
}) {
if (Platform.OS === 'android') {
return <AndroidProtectionSlide {...props} />;
}
return <IosProtectionSlide {...props} />;
}
// ─── iOS ────────────────────────────────────────────────────────────────────
type IosPhase = 'preexplain_url' | 'preexplain_lock' | 'done';
function IosProtectionSlide({
onDone,
current,
total,
}: {
onDone: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const [phase, setPhase] = useState<IosPhase>('preexplain_url');
const [activating, setActivating] = useState(false);
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false);
async function activateUrlFilter() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateUrlFilter();
if (!res.enabled) {
const isCodeFive =
typeof res.error === 'string' &&
/NEFilterErrorDomain:\s*5/i.test(res.error);
if (isCodeFive) {
setPermissionDeniedOpen(true);
return;
}
Alert.alert(
t('onboarding.protection.error_title'),
res.error ?? t('onboarding.protection.error_unknown'),
);
return;
}
// Family Controls (App-Lock) braucht ein Distribution-Entitlement das
// (noch) nicht freigegeben ist → in TestFlight/production-Builds ist
// FAMILY_CONTROLS_AVAILABLE=false. Dann den Lock-Step überspringen:
// URL-Filter allein = vollwertiger Schutz, der Lock ist nur Hardening.
if (!FAMILY_CONTROLS_AVAILABLE) {
finishProtectionStep();
return;
}
setPhase('preexplain_lock');
} finally {
setActivating(false);
}
}
async function activateAppLock() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateFamilyControls();
if (!res.enabled) {
// iOS NSCocoaErrorDomain:4099 = XPC-Communication-Failure (FamilyControls-Daemon
// nicht erreichbar). Recovery-Sheet statt Alert-Loop — gibt User klare Anleitung
// (Reboot/Settings/Reinstall) statt nur einen "Skip"-Button.
const isXpcFailure =
typeof res.error === 'string' && /NSCocoaErrorDomain:\s*4099/i.test(res.error);
if (isXpcFailure) {
setFamilyControlsErrorOpen(true);
return;
}
Alert.alert(
t('onboarding.protection.applock_failed_title'),
res.error ?? t('onboarding.protection.applock_failed_msg'),
[
{
text: t('onboarding.protection.applock_skip'),
style: 'cancel',
onPress: () => finishProtectionStep(),
},
{ text: t('common.retry'), onPress: activateAppLock },
],
);
return;
}
finishProtectionStep();
} finally {
setActivating(false);
}
}
async function finishProtectionStep() {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'done' },
}).catch(() => {});
invalidateMe();
setPhase('done');
onDone();
}
if (phase === 'preexplain_url') {
return (
<PreExplainer
key="ios-url"
dialog="url_filter"
lyraBodyKey="onboarding.lyra.protection_url.body"
titleKey="onboarding.protection.url_title"
ctaKey="onboarding.protection.cta_primary"
buttonLabelKey="onboarding.protection.dialog_button_allow"
markerHintKey="onboarding.protection.tap_marker_hint"
activating={activating}
onActivate={activateUrlFilter}
current={current}
total={total}
>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) {
if (!FAMILY_CONTROLS_AVAILABLE) {
finishProtectionStep();
} else {
setPhase('preexplain_lock');
}
}
return res;
}}
/>
</PreExplainer>
);
}
if (phase === 'preexplain_lock') {
return (
<PreExplainer
key="ios-lock"
dialog="screen_time"
lyraBodyKey="onboarding.lyra.protection_lock.body"
titleKey="onboarding.protection.lock_title"
ctaKey="onboarding.protection.cta_primary"
buttonLabelKey="onboarding.protection.dialog_button_continue"
markerHintKey="onboarding.protection.tap_marker_hint"
pointerAlignment="left"
activating={activating}
onActivate={activateAppLock}
current={current}
total={total}
>
<PermissionDeniedSheet
visible={familyControlsErrorOpen}
onClose={() => setFamilyControlsErrorOpen(false)}
variant="family_controls"
onRetry={async () => {
const res = await protection.activateFamilyControls();
if (res.enabled) {
finishProtectionStep();
}
return res;
}}
/>
</PreExplainer>
);
}
return null;
}
// ─── Android ────────────────────────────────────────────────────────────────
type AndroidPhase =
| 'preexplain_vpn'
| 'preexplain_a11y'
| 'a11y_pending'
| 'done';
function AndroidProtectionSlide({
onDone,
current,
total,
}: {
onDone: () => void;
current: number;
total: number;
}) {
const { t } = useTranslation();
const [phase, setPhase] = useState<AndroidPhase>('preexplain_vpn');
const [activating, setActivating] = useState(false);
const restartPromptShownRef = useRef(false);
// True wenn wir auf Settings-Rückkehr warten. AppState-Listener pollt dann
// a11y-State + advanced automatisch wenn ReBreak-Schalter live ist.
const awaitingReturnRef = useRef(false);
const appStateRef = useRef(AppState.currentState);
async function finishProtectionStep() {
await apiFetch('/api/profile/me/onboarding-step', {
method: 'PATCH',
body: { step: 'done' },
}).catch(() => {});
invalidateMe();
await maybeShowRestartPrompt();
setPhase('done');
onDone();
}
function maybeShowRestartPrompt(): Promise<void> {
if (Platform.OS !== 'android') return Promise.resolve();
if (restartPromptShownRef.current) return Promise.resolve();
restartPromptShownRef.current = true;
return new Promise((resolve) => {
Alert.alert(
t('onboarding.protection.android_restart_title'),
t('onboarding.protection.android_restart_body'),
[
{
text: t('onboarding.protection.android_restart_later'),
style: 'cancel',
onPress: () => resolve(),
},
{
text: t('onboarding.protection.android_restart_now'),
onPress: () => {
(async () => {
try {
const result = await RebreakProtection.openPowerDialog?.();
if (!result?.opened) {
await protection.openSystemSettings();
}
} catch {
await protection.openSystemSettings().catch(() => {});
} finally {
resolve();
}
})();
},
},
],
{ cancelable: false },
);
});
}
async function activateVpn() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateUrlFilter();
if (!res.enabled) {
Alert.alert(
t('onboarding.protection.error_title'),
res.error ?? t('onboarding.protection.error_unknown'),
);
return;
}
setPhase('preexplain_a11y');
} finally {
setActivating(false);
}
}
async function activateA11y() {
if (activating) return;
setActivating(true);
try {
const res = await protection.activateFamilyControls();
if (res.enabled) {
// Selten: User hatte a11y schon manuell aktiviert → Lock direkt armed.
finishProtectionStep();
return;
}
if (res.error === 'accessibility_pending') {
// Native hat Settings geöffnet; warte auf Rückkehr + poll.
awaitingReturnRef.current = true;
setPhase('a11y_pending');
return;
}
Alert.alert(
t('onboarding.protection.error_title'),
res.error ?? t('onboarding.protection.error_unknown'),
);
} finally {
setActivating(false);
}
}
async function resetProtectionForTesting() {
if (activating) return;
setActivating(true);
try {
// Native reset: stoppt VPN + disarmt Tamper-Lock + setzt filter_enabled=false.
await protection.forceDisable();
// Backend-Flag auf disabled, damit kein Auto-Reactivate direkt wieder greift.
await apiFetch('/api/protection/dev-force-disabled', { method: 'POST' }).catch(() => {});
invalidateMe();
awaitingReturnRef.current = false;
setPhase('preexplain_vpn');
// Re-open Accessibility für den manuellen OFF-Check/Toggle (OS-Limit).
await protection.openSystemSettings('accessibility');
} catch (e) {
Alert.alert(
t('onboarding.protection.error_title'),
e instanceof Error ? e.message : t('onboarding.protection.error_unknown'),
);
} finally {
setActivating(false);
}
}
// Auto-Check beim Foreground-Return: wenn a11y jetzt aktiv → Lock armen + done.
useEffect(() => {
const sub = AppState.addEventListener('change', async (next) => {
const prev = appStateRef.current;
appStateRef.current = next;
if (!awaitingReturnRef.current) return;
if (prev.match(/inactive|background/) && next === 'active') {
// User ist zurück in der App → den Repeating-Toast-Hint stoppen,
// damit nicht weitere Toasts über unsere UI hereinflattern. Native
// dismissed sich zwar auch von alleine nach ~30s, aber explizit ist
// sauberer. Fail-safe da iOS keine dismiss-Methode hat.
try {
await RebreakProtection.dismissAccessibilityHint?.();
} catch {
// ignore — Methode existiert nicht auf iOS
}
try {
const a11y = await RebreakProtection.isAccessibilityEnabled();
if (a11y.enabled) {
// ReBreak-Service ist live → Tamper-Lock armen + finish.
const res = await protection.activateFamilyControls();
if (res.enabled) {
awaitingReturnRef.current = false;
finishProtectionStep();
}
}
} catch {
// Ignorieren — User kann manuell auf "Ich habe ReBreak aktiviert" tippen.
}
}
});
return () => sub.remove();
}, []);
if (phase === 'preexplain_vpn') {
return (
<PreExplainer
key="android-vpn"
dialog="android_vpn"
lyraBodyKey="onboarding.lyra.protection_url_android.body"
titleKey="onboarding.protection.url_title_android"
ctaKey="onboarding.protection.cta_primary"
buttonLabelKey="onboarding.protection.dialog_button_vpn_ok"
markerHintKey="onboarding.protection.tap_marker_hint_android_vpn"
activating={activating}
onActivate={activateVpn}
current={current}
total={total}
/>
);
}
if (phase === 'preexplain_a11y') {
return (
<PreExplainer
key="android-a11y"
dialog="android_a11y"
lyraBodyKey="onboarding.lyra.protection_lock_android.body"
titleKey="onboarding.protection.lock_title_android"
ctaKey="onboarding.protection.cta_open_a11y"
buttonLabelKey="onboarding.protection.dialog_button_a11y_toggle"
markerHintKey="onboarding.protection.tap_marker_hint_android_a11y"
activating={activating}
onActivate={activateA11y}
current={current}
total={total}
/>
);
}
if (phase === 'a11y_pending') {
return (
<A11yPendingView
current={current}
total={total}
activating={activating}
onRetry={activateA11y}
onResetForTesting={__DEV__ ? resetProtectionForTesting : undefined}
/>
);
}
return null;
}
function A11yPendingView({
current,
total,
activating,
onRetry,
onResetForTesting,
}: {
current: number;
total: number;
activating: boolean;
onRetry: () => void;
onResetForTesting?: () => void;
}) {
const { t } = useTranslation();
const colors = useColors();
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('onboarding.protection.cta_check_a11y')}
onPrimary={onRetry}
primaryLoading={activating}
secondaryLabel={onResetForTesting ? 'Reset Schutz (DEV)' : undefined}
onSecondary={onResetForTesting}
/>
}
>
<LyraBubble
text={t('onboarding.protection.android_a11y_pending_body')}
emotion="empathy"
/>
<Text
style={{
marginTop: 14,
fontFamily: 'Nunito_700Bold',
fontSize: 12,
letterSpacing: 0.6,
color: colors.textMuted,
textTransform: 'uppercase',
textAlign: 'center',
}}
>
{t('onboarding.protection.android_a11y_pending_title')}
</Text>
</OnboardingShell>
);
}
// ─── PreExplainer (shared) ───────────────────────────────────────────────────
function PreExplainer({
dialog,
lyraBodyKey,
titleKey,
ctaKey,
buttonLabelKey,
markerHintKey,
pointerAlignment = 'center',
activating,
onActivate,
current,
total,
children,
}: {
dialog: 'url_filter' | 'screen_time' | 'android_vpn' | 'android_a11y';
lyraBodyKey: string;
titleKey: string;
ctaKey: string;
buttonLabelKey: string;
markerHintKey: string;
pointerAlignment?: 'left' | 'center' | 'right';
activating: boolean;
onActivate: () => void;
current: number;
total: number;
children?: React.ReactNode;
}) {
const { t } = useTranslation();
const colors = useColors();
const { height: screenH } = useWindowDimensions();
const lang = i18n.language || 'de';
const screenshot = getPermissionScreenshot(dialog, lang);
// Dynamische Screenshot-Höhe: Auf kleinen Phones (SE/mini ~667-844 pt)
// capped damit alles + CTA-Bar ohne Scroll passt. Auf großen Phones/iPad
// skaliert es mit. Min 200, Max 320.
const screenshotHeight = Math.min(320, Math.max(200, screenH * 0.32));
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t(ctaKey)}
onPrimary={onActivate}
primaryLoading={activating}
/>
}
>
<LyraBubble text={t(lyraBodyKey)} emotion="empathy" />
<Text
style={{
marginTop: 14,
fontFamily: 'Nunito_700Bold',
fontSize: 12,
letterSpacing: 0.6,
color: colors.textMuted,
textTransform: 'uppercase',
textAlign: 'center',
}}
>
{t(titleKey)}
</Text>
<View
style={{
marginTop: 8,
alignSelf: 'center',
height: screenshotHeight,
aspectRatio: 0.9,
}}
>
<Image
source={screenshot}
style={{ width: '100%', height: '100%' }}
resizeMode="contain"
/>
</View>
<ScreenshotPointer buttonLabel={t(buttonLabelKey)} alignment={pointerAlignment} />
<Text
style={{
marginTop: 10,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
lineHeight: 17,
color: colors.textMuted,
textAlign: 'center',
paddingHorizontal: 8,
}}
>
{t(markerHintKey)}
</Text>
{children}
</OnboardingShell>
);
}