chahinebrini 63fae25531 fix(android-protection): explicit specialUse FGS type — Samsung/Android 16 crash loop
RebreakVpnService.onStartCommand crashed with SecurityException because Android 16's validateForegroundServiceType rejects the implicit 2-arg startForeground(). Now passes FOREGROUND_SERVICE_TYPE_SPECIAL_USE explicitly (Google's documented best practice) and guards the call so a failed foreground promotion stops the service cleanly instead of crashing the app. Verified vs reported Galaxy A54 / Android 16 signature (97% of crash events, 1-user crash loop).

Bundles pending working-tree work across native/marketing/locales/mac + graphify-out rebuild. gitignore: google-services.json + /screenshots/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:33:28 +02:00

489 lines
20 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);
// Fallback-Ausweg: erscheint, wenn der Schutz auf diesem Gerät nicht aktiviert
// werden kann (Timeout oder fehlgeschlagener Versuch) — sonst hängt der User
// im Onboarding fest (z.B. Android-16-VPN-Crash). Schutz später im Blocker.
const [showSkip, setShowSkip] = useState(false);
// VPN-Aktivierung läuft (Android): Spinner am VPN-Step bis der Layer-State
// bestätigt ist. Der Protection-State pollt sonst zu selten → der Step bliebe
// ~1min „offen", obwohl der Tunnel längst läuft.
const [vpnPending, setVpnPending] = useState(false);
const finishedRef = useRef(false);
const armingRef = 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;
// a11y-SERVICE an (≠ Tamper-Lock armed). Trennung kommt direkt aus dem nativen
// getDeviceState ({accessibility} vs {tamperLock}).
const a11yServiceOn = state?.layers.accessibility === true;
// Akku-Ausnahme: ohne sie schläfert Samsung & Co. die App ein → a11y-Service
// wird entbunden → Lock wertlos. Daher Pflicht-Step im Android-Flow.
const batteryUnrestricted = state?.layers.batteryUnrestricted === true;
// "Fertig" == blocker.tsx lockedIn. Eine Quelle der Wahrheit.
const allDone =
Platform.OS === 'android'
? urlFilterActive && appDeletionLockActive && deviceAdminActive && batteryUnrestricted
: (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();
}
// Notausgang: Schutz noch nicht aktiv, aber User soll nicht festsitzen.
function handleSkipProtection() {
Alert.alert(
t('onboarding.protection_skip.title'),
t('onboarding.protection_skip.body'),
[
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('onboarding.protection_skip.confirm'),
style: 'destructive',
onPress: () => {
void finishProtectionStep();
},
},
],
);
}
// 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]);
// Sicherheitsnetz: Ist der Schutz nach 30 s noch nicht aktiv (z.B. weil die
// Aktivierung auf diesem OS scheitert), den "Später einrichten"-Ausweg zeigen.
// (Failed-Aktivierungen blenden ihn sofort ein, siehe Handler unten.)
useEffect(() => {
if (allDone) {
setShowSkip(false);
return;
}
const id = setTimeout(() => setShowSkip(true), 30000);
return () => clearTimeout(id);
}, [allDone]);
// Nach VPN-Freigabe schneller nachpollen, bis urlFilter aktiv ist (sonst ~1min,
// bis der Step grün wird). Spinner läuft, solange vpnPending. Deckel ~60s.
useEffect(() => {
if (urlFilterActive) {
setVpnPending(false);
return;
}
if (!vpnPending) return;
let ticks = 0;
const id = setInterval(() => {
ticks += 1;
refresh();
if (ticks >= 20) {
clearInterval(id);
setVpnPending(false);
}
}, 3000);
return () => clearInterval(id);
}, [vpnPending, urlFilterActive, refresh]);
// Auto-Arm (Android): kam der User aus den a11y-Settings zurück und hat den
// Service aktiviert (accessibility=true), war der Tamper-Lock bisher NICHT armed
// — das passierte erst beim ZWEITEN Tap auf den a11y-Button (Two-Step-Design).
// Hier ziehen wir das automatisch nach: sobald a11y-Service an + noch nicht
// armed, einmal activateFamilyControls() → geht bei aktivem a11y direkt in den
// Arm-Pfad (kein Settings-Öffnen) → Step wird grün ohne zweiten Tap.
useEffect(() => {
if (Platform.OS !== 'android') return;
if (!a11yServiceOn || appDeletionLockActive || armingRef.current) return;
armingRef.current = true;
activateFamilyControls()
.catch(() => {})
.finally(() => {
armingRef.current = false;
refresh();
});
}, [a11yServiceOn, appDeletionLockActive, activateFamilyControls, refresh]);
// ─── Handler (1:1 wie blocker.tsx) ──────────────────────────────────────────
async function handleActivateUrlFilter() {
try {
const result = await activateUrlFilter();
if (!result.enabled) {
// Aktivierung fehlgeschlagen → Notausgang sofort anbieten (nicht erst nach 30s).
setShowSkip(true);
setVpnPending(false);
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) {
setShowSkip(true);
setVpnPending(false);
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 };
};
// Akku-Ausnahme: System-Dialog direkt öffnen (ein Tap „Zulassen"); der
// Step-Card-Text erklärt das Warum. Return-Refresh via AppState-'active'.
const gatedBattery = async () => protection.requestIgnoreBatteryOptimizations();
// Samsung-Sonderweg: App-Detail-Settings (Akku → „Uneingeschränkt" + raus aus
// „Schlafende Apps") — das deckt der reine AOSP-Whitelist-Dialog nicht ab.
const openBatteryDetails = async () => protection.openAppDetailsSettings();
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':
setVpnPending(true);
return handleActivateUrlFilter();
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}
secondaryLabel={!allDone && showSkip ? t('onboarding.protection_skip.label') : undefined}
onSecondary={!allDone && showSkip ? handleSkipProtection : undefined}
/>
}
>
<LyraBubble text={t('onboarding.lyra.protection.body')} emotion="empathy" />
<View style={{ marginTop: 16 }}>
{Platform.OS === 'android' ? (
<AndroidSetupFlow
vpnActive={urlFilterActive}
vpnActivating={vpnPending}
accessibilityLocked={appDeletionLockActive}
deviceAdminActive={deviceAdminActive}
batteryUnrestricted={batteryUnrestricted}
onActivateVpn={gatedVpn}
onActivateAccessibility={gatedA11y}
onRequestDeviceAdmin={gatedDeviceAdmin}
onRequestBattery={gatedBattery}
onOpenAppDetails={openBatteryDetails}
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>
);
}