Tamper-Lock von Keyword-Scanning auf präzise Einzel-Surfaces umgebaut: blockt nur ReBreaks eigene Screens (Admin-Deaktivierung via DeviceAdminAdd, a11y-Ausschalten, VPN-Trennen/Surface), nie Listen oder fremde Apps. - Deny-Removal = Admin-only: OS graut Uninstall+Force-Stop für aktiven Device-Admin aus; einziger Bypass (Admin deaktivieren) bleibt a11y-gesperrt. Andere Apps verwalten/force-stoppen/deinstallieren bleibt komplett frei. - a11y-Onboarding: passiver Bottom-Overlay-Hinweis + Settings-Reset auf Startseite nach Aktivierung + 1s-Delay vor App-Rückkehr. - VPN-Trennen-Dialog + a11y-Ausschalten neu abgedeckt. - a11y-Service-Icon im Plugin (klar als ReBreak erkennbar). Verifiziert auf A50 per logcat: alle 4 Surfaces blocken, Listen + fremde Apps frei, keine False-Positives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
387 lines
15 KiB
TypeScript
387 lines
15 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Alert, AppState, Platform, 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 { useProtectionState } from '../../../hooks/useProtectionState';
|
|
import { useBlocklistSync } from '../../../hooks/useBlocklistSync';
|
|
import { getPermissionScreenshot } from '../../../lib/onboardingAssets';
|
|
import i18n from '../../../lib/i18n';
|
|
import { AndroidSetupFlow, IosUnsupervisedSetupFlow } from '../../blocker/SetupFlows';
|
|
import { LayerSwitchCard } from '../../blocker/LayerSwitchCard';
|
|
import { OnboardingShell } from '../OnboardingShell';
|
|
import { LyraBubble } from '../LyraBubble';
|
|
import { CTABar } from '../CTABar';
|
|
import { PermissionConfirmSheet } from '../PermissionConfirmSheet';
|
|
import { PermissionDeniedSheet } from '../../PermissionDeniedSheet';
|
|
|
|
/** Steps mit Gate davor. VPN/Geräteadmin/AppLock/URL = echte System-Dialoge
|
|
* (welcher Button). a11y = bekommt einen reicheren Explainer (Overlay-Recht +
|
|
* Schalter-Suche mit Screenshot/Indikator), weil's für viele kompliziert ist.
|
|
* Screentime hat einen eigenen instruktiven Flow → kein Gate. */
|
|
type ConfirmStep = 'vpn' | 'deviceadmin' | 'applock' | 'urlfilter' | 'a11y' | 'usage' | 'overlay';
|
|
|
|
/**
|
|
* Onboarding-Schutz-Step.
|
|
*
|
|
* WICHTIG: Dieser Step rendert EXAKT denselben Setup-Flow wie der Blocker-Screen
|
|
* (components/blocker/SetupFlows.tsx) — die Reihenfolge + das Gating sind dort
|
|
* die einzige Quelle der Wahrheit:
|
|
* Android: VPN → Geräteadmin → a11y (a11y MUSS zuletzt, sonst blockt der
|
|
* Tamper-Lock die Geräteadmin-Settings-Seite).
|
|
* iOS: App-Lock → Bildschirmzeit → URL-Filter.
|
|
*
|
|
* Die Handler hier spiegeln blocker.tsx 1:1 (Activate + Sync + Recovery-Sheets).
|
|
* "Fertig" = der Blocker würde "Schutz aktiv" zeigen (lockedIn). Erst dann wird
|
|
* der "Weiter"-Button aktiv und der Onboarding-Step abgeschlossen.
|
|
*/
|
|
export function ProtectionSlide({
|
|
onDone,
|
|
current,
|
|
total,
|
|
}: {
|
|
onDone: () => void;
|
|
current: number;
|
|
total: number;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
const { state, mdmManaged, refresh, activateUrlFilter, activateFamilyControls } =
|
|
useProtectionState();
|
|
const { sync: syncBlocklist } = useBlocklistSync();
|
|
|
|
const [screentimeCode, setScreentimeCode] = useState<string | null>(null);
|
|
const [screentimeConfirmed, setScreentimeConfirmed] = useState(false);
|
|
const [screentimeSaving, setScreentimeSaving] = useState(false);
|
|
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
|
|
const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false);
|
|
const [confirmStep, setConfirmStep] = useState<ConfirmStep | null>(null);
|
|
const finishedRef = useRef(false);
|
|
// a11y-Explainer NUR beim ersten Tap zeigen — danach (Settings-Rückkehr →
|
|
// nochmal tippen zum Armen) direkt durch, sonst nervt das Sheet doppelt.
|
|
const a11yExplainerShownRef = useRef(false);
|
|
|
|
// Persistierten Screen-Time-Status laden, damit der Step nicht erneut gefragt
|
|
// wird, wenn der Code schon gesetzt ist (gleiche Logik wie blocker.tsx).
|
|
useEffect(() => {
|
|
if (Platform.OS !== 'ios') return;
|
|
protection
|
|
.getScreenTimePasscode()
|
|
.then((p) => {
|
|
if (p) setScreentimeConfirmed(true);
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const urlFilterActive = state?.layers.urlFilter === true;
|
|
const familyControlsActive = state?.layers.familyControls === true;
|
|
const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
|
|
const nefilterActive = state?.layers.nefilterActive === true;
|
|
const deviceAdminActive = state?.layers.deviceAdmin === true;
|
|
|
|
// "Fertig" == blocker.tsx lockedIn. Eine Quelle der Wahrheit.
|
|
const allDone =
|
|
Platform.OS === 'android'
|
|
? urlFilterActive && appDeletionLockActive && deviceAdminActive
|
|
: (nefilterActive || urlFilterActive) &&
|
|
(mdmManaged || nefilterActive || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE);
|
|
|
|
async function finishProtectionStep() {
|
|
if (finishedRef.current) return;
|
|
finishedRef.current = true;
|
|
await apiFetch('/api/profile/me/onboarding-step', {
|
|
method: 'PATCH',
|
|
body: { step: 'done' },
|
|
}).catch(() => {});
|
|
invalidateMe();
|
|
onDone();
|
|
}
|
|
|
|
// Foreground-Return → State neu laden. a11y/Geräteadmin/Bildschirmzeit werden in
|
|
// den System-Settings gesetzt; beim Zurückkommen pollen wir den neuen Layer-State,
|
|
// damit die Cards umschalten und "Weiter" freigeschaltet wird.
|
|
useEffect(() => {
|
|
const sub = AppState.addEventListener('change', (next) => {
|
|
if (next === 'active') refresh();
|
|
});
|
|
return () => sub.remove();
|
|
}, [refresh]);
|
|
|
|
// ─── Handler (1:1 wie blocker.tsx) ──────────────────────────────────────────
|
|
|
|
async function handleActivateUrlFilter() {
|
|
try {
|
|
const result = await activateUrlFilter();
|
|
if (!result.enabled) {
|
|
const isPermissionDenied =
|
|
Platform.OS === 'ios' &&
|
|
typeof result.error === 'string' &&
|
|
/NEFilterErrorDomain:\s*5/i.test(result.error);
|
|
if (isPermissionDenied) {
|
|
setPermissionDeniedOpen(true);
|
|
return result;
|
|
}
|
|
Alert.alert(
|
|
t('blocker.activate_url_failed_title'),
|
|
result.error ?? t('blocker.activate_url_failed_msg'),
|
|
[
|
|
{ text: t('common.ok') },
|
|
{ text: t('blocker.activate_settings_btn'), onPress: () => protection.openSystemSettings() },
|
|
],
|
|
);
|
|
} else {
|
|
const sync = await syncBlocklist();
|
|
if (sync.ok) {
|
|
await refresh();
|
|
} else {
|
|
Alert.alert(
|
|
t('blocker.sync_list_failed_title'),
|
|
sync.error ?? t('blocker.sync_list_failed_msg'),
|
|
);
|
|
}
|
|
}
|
|
return result;
|
|
} catch (e: any) {
|
|
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
|
|
return { enabled: false };
|
|
}
|
|
}
|
|
|
|
async function handleActivateFamilyControls() {
|
|
try {
|
|
const result = await activateFamilyControls();
|
|
if (!result.enabled && result.error !== 'accessibility_pending') {
|
|
const isXpcFailure =
|
|
Platform.OS === 'ios' &&
|
|
typeof result.error === 'string' &&
|
|
/NSCocoaErrorDomain:\s*4099/i.test(result.error);
|
|
if (isXpcFailure) {
|
|
setFamilyControlsErrorOpen(true);
|
|
return result;
|
|
}
|
|
Alert.alert(
|
|
t('blocker.activate_app_lock_failed_title'),
|
|
result.error ?? t('blocker.activate_app_lock_failed_msg'),
|
|
);
|
|
}
|
|
if (result.enabled) await refresh();
|
|
return result;
|
|
} catch (e: any) {
|
|
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
|
|
return { enabled: false };
|
|
}
|
|
}
|
|
|
|
function handleGenerateScreentimeCode() {
|
|
setScreentimeCode(Math.floor(1000 + Math.random() * 9000).toString());
|
|
setScreentimeConfirmed(false);
|
|
}
|
|
|
|
async function handleScreentimeConfirm() {
|
|
if (!screentimeCode) return;
|
|
setScreentimeSaving(true);
|
|
try {
|
|
await protection.saveScreenTimePasscode(screentimeCode);
|
|
setScreentimeConfirmed(true);
|
|
setScreentimeCode(null);
|
|
} catch (e: any) {
|
|
Alert.alert(t('common.error'), e?.message ?? t('common.unknown_error'));
|
|
} finally {
|
|
setScreentimeSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleRequestDeviceAdmin() {
|
|
try {
|
|
const result = await protection.requestDeviceAdmin();
|
|
if (!result.launched) {
|
|
Alert.alert(t('blocker.android_admin_failed_title'), t('blocker.android_admin_failed_msg'));
|
|
} else {
|
|
await refresh();
|
|
}
|
|
return result;
|
|
} catch (e: any) {
|
|
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
|
|
return { launched: false };
|
|
}
|
|
}
|
|
|
|
// ─── Anti-Blind-Klick-Gate ──────────────────────────────────────────────────
|
|
// Statt direkt die Permission zu feuern, öffnet der Card-Button erst das
|
|
// Confirm-Sheet. Erst nach Häkchen + „Weiter" läuft der echte Handler. Die
|
|
// gated-Wrapper geben sofort ein neutrales Result zurück (kein Fehler → kein
|
|
// Alert); die Card-done-States kommen ohnehin aus den Layer-Flags, nicht aus
|
|
// dem Return-Wert.
|
|
const gatedVpn = async () => {
|
|
setConfirmStep('vpn');
|
|
return { enabled: false };
|
|
};
|
|
const gatedDeviceAdmin = async () => {
|
|
setConfirmStep('deviceadmin');
|
|
return { launched: false };
|
|
};
|
|
const gatedApplock = async () => {
|
|
setConfirmStep('applock');
|
|
return { enabled: false };
|
|
};
|
|
const gatedUrlFilter = async () => {
|
|
setConfirmStep('urlfilter');
|
|
return { enabled: false };
|
|
};
|
|
const gatedA11y = async () => {
|
|
// Einmalig Nutzungszugriff holen — DAMIT erkennt die native Guide-Notification
|
|
// den aktuellen Samsung-Screen und führt Schritt für Schritt. Ohne das gäbe es
|
|
// nur dumme Toasts. Erst freigeben, dann zurück + a11y nochmal tippen.
|
|
if (!(await protection.hasUsageAccess())) {
|
|
setConfirmStep('usage');
|
|
return { enabled: false };
|
|
}
|
|
// Dann „Über anderen Apps anzeigen" — damit der Hinweis als sichtbares Overlay
|
|
// VOR den Settings schwebt (statt nur als leicht übersehbare Notification).
|
|
if (!(await protection.hasOverlayPermission())) {
|
|
setConfirmStep('overlay');
|
|
return { enabled: false };
|
|
}
|
|
// Erster Tap → Explainer (Installierte Dienste → ReBreak → Schalter, Screenshot).
|
|
// Folge-Taps (nach Settings-Rückkehr zum Armen) → direkt, ohne Sheet.
|
|
if (!a11yExplainerShownRef.current) {
|
|
a11yExplainerShownRef.current = true;
|
|
setConfirmStep('a11y');
|
|
return { enabled: false };
|
|
}
|
|
return handleActivateFamilyControls();
|
|
};
|
|
|
|
function runConfirmedAction(step: ConfirmStep) {
|
|
switch (step) {
|
|
case 'vpn':
|
|
case 'urlfilter':
|
|
return handleActivateUrlFilter();
|
|
case 'deviceadmin':
|
|
return handleRequestDeviceAdmin();
|
|
case 'applock':
|
|
case 'a11y':
|
|
return handleActivateFamilyControls();
|
|
case 'usage':
|
|
// Nutzungszugriff-Settings öffnen. User gibt frei, kommt zurück, tippt
|
|
// a11y nochmal → hasUsageAccess true → nächstes Gate / Explainer.
|
|
return protection.openUsageAccessSettings();
|
|
case 'overlay':
|
|
// „Über anderen Apps anzeigen"-Settings öffnen. Danach a11y nochmal tippen.
|
|
return protection.openOverlayPermissionSettings();
|
|
}
|
|
}
|
|
|
|
// ─── Render ─────────────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<OnboardingShell
|
|
current={current}
|
|
total={total}
|
|
cta={
|
|
<CTABar
|
|
primaryLabel={t('common.continue')}
|
|
onPrimary={finishProtectionStep}
|
|
primaryDisabled={!allDone}
|
|
/>
|
|
}
|
|
>
|
|
<LyraBubble text={t('onboarding.lyra.protection.body')} emotion="empathy" />
|
|
|
|
<View style={{ marginTop: 16 }}>
|
|
{Platform.OS === 'android' ? (
|
|
<AndroidSetupFlow
|
|
vpnActive={urlFilterActive}
|
|
accessibilityLocked={appDeletionLockActive}
|
|
deviceAdminActive={deviceAdminActive}
|
|
onActivateVpn={gatedVpn}
|
|
onActivateAccessibility={gatedA11y}
|
|
onRequestDeviceAdmin={gatedDeviceAdmin}
|
|
colors={colors}
|
|
t={t}
|
|
/>
|
|
) : FAMILY_CONTROLS_AVAILABLE && !mdmManaged && !nefilterActive ? (
|
|
<IosUnsupervisedSetupFlow
|
|
familyControlsActive={appDeletionLockActive}
|
|
screentimeCode={screentimeCode}
|
|
screentimeConfirmed={screentimeConfirmed}
|
|
screentimeSaving={screentimeSaving}
|
|
urlFilterActive={urlFilterActive}
|
|
onActivateFamilyControls={gatedApplock}
|
|
onGenerateScreentimeCode={handleGenerateScreentimeCode}
|
|
onConfirmScreentime={handleScreentimeConfirm}
|
|
onActivateUrlFilter={gatedUrlFilter}
|
|
colors={colors}
|
|
t={t}
|
|
/>
|
|
) : (
|
|
/* iOS Distribution ohne Family-Controls-Entitlement (oder MDM/NEFilter):
|
|
nur der URL-Filter als einzelner Layer — exakt wie der Blocker-Fallback. */
|
|
<LayerSwitchCard
|
|
icon="globe-outline"
|
|
title={t('blocker.layers_url_filter_title')}
|
|
subtitle={
|
|
urlFilterActive
|
|
? t('blocker.layers_url_filter_subtitle_active')
|
|
: t('blocker.layers_url_filter_subtitle_inactive')
|
|
}
|
|
active={urlFilterActive}
|
|
onActivate={gatedUrlFilter}
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
<PermissionConfirmSheet
|
|
visible={confirmStep !== null}
|
|
title={confirmStep ? t(`onboarding.protection_confirm.${confirmStep}_title`) : ''}
|
|
body={confirmStep ? t(`onboarding.protection_confirm.${confirmStep}_body`) : ''}
|
|
// a11y ist der komplizierte Step → reicher Explainer mit nummerierten
|
|
// Schritten (erst Overlay erlauben, dann ReBreak-Schalter) + Screenshot
|
|
// + Indikator. Die anderen Steps bleiben reiner Text.
|
|
// Keine Text-Schritte mehr im a11y-Sheet — das passive ReBreak-Overlay führt
|
|
// in den Settings Schritt für Schritt. Sheet bleibt dezent (Lyra-Satz + Bild).
|
|
steps={undefined}
|
|
screenshot={
|
|
confirmStep === 'a11y'
|
|
? getPermissionScreenshot('android_a11y_overview', i18n.language || 'de')
|
|
: undefined
|
|
}
|
|
indicatorCaption={
|
|
confirmStep === 'a11y' ? t('onboarding.protection_confirm.a11y_indicator') : undefined
|
|
}
|
|
onClose={() => setConfirmStep(null)}
|
|
onConfirm={() => {
|
|
const step = confirmStep;
|
|
setConfirmStep(null);
|
|
// Erst Sheet schließen lassen, DANN die Permission feuern. Auf iOS kann
|
|
// ein System-Dialog, der über ein noch wegslidendes Modal präsentiert
|
|
// wird, still fehlschlagen — exakt die Bug-Klasse, die wir vermeiden wollen.
|
|
if (step) setTimeout(() => runConfirmedAction(step), 350);
|
|
}}
|
|
/>
|
|
|
|
<PermissionDeniedSheet
|
|
visible={permissionDeniedOpen}
|
|
onClose={() => setPermissionDeniedOpen(false)}
|
|
onRetry={async () => {
|
|
const res = await protection.resetUrlFilter();
|
|
if (res.enabled) await refresh();
|
|
return res;
|
|
}}
|
|
/>
|
|
<PermissionDeniedSheet
|
|
visible={familyControlsErrorOpen}
|
|
onClose={() => setFamilyControlsErrorOpen(false)}
|
|
variant="family_controls"
|
|
onRetry={async () => {
|
|
const res = await protection.activateFamilyControls();
|
|
if (res.enabled) await refresh();
|
|
return res;
|
|
}}
|
|
/>
|
|
</OnboardingShell>
|
|
);
|
|
}
|