chahinebrini 4a013bc43b feat(android-protection): präzise Tamper-Lock + a11y-Onboarding-Guide
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>
2026-06-08 04:05:41 +02:00

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>
);
}