chahinebrini c32eeeb070 fix(protection): NEFilter retry + FamilyControls 4099 recovery sheet
Tester reports: nach 'Don't Allow' im System-Dialog reagiert Re-Request
nicht (NEFilter), plus FamilyControls wirft NSCocoaErrorDomain:4099
(XPC-Daemon-Failure). Mehrere TestFlight-User betroffen.

Swift native:
- resetUrlFilter: 800ms delay nach remove + 3x retry-loop bei code 5
- activateFamilyControls: 3x retry-loop mit Backoff bei 4099

JS:
- PermissionDeniedSheet generic via variant prop (nefilter|family_controls)
- Blocker + Onboarding: 4099-detect → Recovery-Sheet mit 3-Step-Fallback

I18n DE/EN/FR/AR: blocker.family_controls_error.* keys
2026-05-20 03:51:33 +02:00

489 lines
14 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 } 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;
}
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) 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);
// 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();
setPhase('done');
onDone();
}
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);
}
}
// 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') {
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}
/>
);
}
return null;
}
function A11yPendingView({
current,
total,
activating,
onRetry,
}: {
current: number;
total: number;
activating: boolean;
onRetry: () => 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}
/>
}
>
<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>
);
}