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>
489 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|