Android-Onboarding (Platform.OS dispatch in ProtectionSlide):
- Neue Phasen für Android: preexplain_vpn → preexplain_a11y → a11y_pending
- AppState-Listener: nach Settings-Rückkehr auto-poll isAccessibilityEnabled
→ wenn live, armTamperLock + finish (kein Fokus-Klick nötig)
- onboardingAssets: 8 neue Mappings (android_vpn + android_a11y × 4 Locales)
- Screenshots: vpn-permission + a11y-rebreak-row pro Locale
- Locale-Keys: protection_url_android, protection_lock_android, cta_open_a11y,
cta_check_a11y, dialog_button_vpn_ok, dialog_button_a11y_toggle, tap_marker_hint_*
Lyra-Post i18n Phase 1 (Scaffold, feature-flag OFF by default):
- schema.prisma: CommunityPost.i18nKey String? (nullable)
- migration 20260517_add_lyra_post_i18n_key: ALTER TABLE ADD COLUMN i18n_key
(NICHT auto-deployed — `prisma migrate deploy` als separater Step)
- server/lib/lyraPostCatalog.ts: 15 Templates skelettiert + pickRandomTemplate
- cron/lyra-post: USE_TEMPLATE_CATALOG=true Branch → speichert i18nKey;
default false → LLM-Path unverändert (zero-risk-deployment)
- community.createPost: optionaler i18nKey-Parameter
- posts.get: i18nKey in API-Response
- PostCard: 3-Zeilen-Branch — i18nKey ? t('lyra_posts.'+id) : content
- stores/community: i18nKey?: string|null im Interface
- de.json: lyra_posts-Block mit 15 IDs + DE-Texten
Single-Banner-Verhalten auf Android verifiziert:
lockedIn=urlFilter && appDeletionLock funktioniert weiter — auf Android
alias appDeletionLock ← tamperLock; onboarding arms tamperLock, also
nach onboarding-done direkt ProtectionLockedCard sichtbar.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
463 lines
13 KiB
TypeScript
463 lines
13 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);
|
|
|
|
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) {
|
|
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"
|
|
activating={activating}
|
|
onActivate={activateAppLock}
|
|
current={current}
|
|
total={total}
|
|
/>
|
|
);
|
|
}
|
|
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,
|
|
activating,
|
|
onActivate,
|
|
current,
|
|
total,
|
|
children,
|
|
}: {
|
|
dialog: 'url_filter' | 'screen_time' | 'android_vpn' | 'android_a11y';
|
|
lyraBodyKey: string;
|
|
titleKey: string;
|
|
ctaKey: string;
|
|
buttonLabelKey: string;
|
|
markerHintKey: string;
|
|
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)} />
|
|
|
|
<Text
|
|
style={{
|
|
marginTop: 10,
|
|
fontFamily: 'Nunito_400Regular',
|
|
fontSize: 12,
|
|
lineHeight: 17,
|
|
color: colors.textMuted,
|
|
textAlign: 'center',
|
|
paddingHorizontal: 8,
|
|
}}
|
|
>
|
|
{t(markerHintKey)}
|
|
</Text>
|
|
{children}
|
|
</OnboardingShell>
|
|
);
|
|
}
|