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

635 lines
22 KiB
TypeScript

import { useState, type ReactNode } from "react";
import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native";
import { Ionicons } from "@expo/vector-icons";
// Geteilter Schutz-Setup-Flow (Reihenfolge + Gating). Quelle der Wahrheit für
// Blocker UND Onboarding — NIE die Reihenfolge hier ändern ohne beide zu prüfen.
// Android: VPN → Geräteadmin → a11y (a11y zuletzt, sonst blockt der Tamper-Lock
// die Admin-Seite). iOS: App-Lock → Bildschirmzeit → URL-Filter.
// ─── iOS Unsupervised 3-Step Setup Flow ──────────────────────────────────────
type SetupFlowProps = {
familyControlsActive: boolean;
screentimeCode: string | null;
screentimeConfirmed: boolean;
screentimeSaving: boolean;
urlFilterActive: boolean;
onActivateFamilyControls: () => Promise<{ enabled: boolean; error?: string }>;
onGenerateScreentimeCode: () => void;
onConfirmScreentime: () => void;
onActivateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
};
export function IosUnsupervisedSetupFlow({
familyControlsActive,
screentimeCode,
screentimeConfirmed,
screentimeSaving,
urlFilterActive,
onActivateFamilyControls,
onGenerateScreentimeCode,
onConfirmScreentime,
onActivateUrlFilter,
colors,
t,
}: SetupFlowProps) {
const step1Done = familyControlsActive;
const step2Done = screentimeConfirmed;
const step3Done = urlFilterActive;
return (
<View style={{ gap: 10 }}>
<SetupStep1
done={step1Done}
onActivate={onActivateFamilyControls}
colors={colors}
t={t}
/>
<SetupStep2
unlocked={step1Done}
code={screentimeCode}
confirmed={step2Done}
saving={screentimeSaving}
onGenerate={onGenerateScreentimeCode}
onConfirm={onConfirmScreentime}
colors={colors}
t={t}
/>
<SetupStep3
unlocked={step2Done}
done={step3Done}
onActivate={onActivateUrlFilter}
colors={colors}
t={t}
/>
</View>
);
}
function SetupStep1({
done,
onActivate,
colors,
t,
}: {
done: boolean;
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={1}
title={t('blocker.setup_step1_title')}
subtitle={done ? t('blocker.setup_step1_subtitle_done') : t('blocker.setup_step1_subtitle_pending')}
done={done}
unlocked
colors={colors}
>
{!done && (
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 10 }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.setup_step1_cta')}</Text>
}
</TouchableOpacity>
)}
</SetupStepCard>
);
}
function SetupStep2({
unlocked,
code,
confirmed,
saving,
onGenerate,
onConfirm,
colors,
t,
}: {
unlocked: boolean;
code: string | null;
confirmed: boolean;
saving: boolean;
onGenerate: () => void;
onConfirm: () => void;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
return (
<SetupStepCard
stepNumber={2}
title={t('blocker.setup_step2_title')}
subtitle={confirmed ? t('blocker.setup_step2_subtitle_done') : t('blocker.setup_step2_subtitle_pending')}
done={confirmed}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 1 })}
colors={colors}
>
{unlocked && !confirmed && (
<View style={{ gap: 10, marginTop: 10 }}>
{!code ? (
<TouchableOpacity
onPress={onGenerate}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('blocker.screentime_generate_cta')}
</Text>
</TouchableOpacity>
) : (
<View style={{ gap: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 16, alignItems: 'center', gap: 4 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('blocker.screentime_code_label')}
</Text>
<Text style={{ fontSize: 36, fontFamily: 'Nunito_700Bold', color: colors.brandOrange, letterSpacing: 12 }}>
{code}
</Text>
</View>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 12, gap: 6 }}>
{[
t('blocker.screentime_step1'),
t('blocker.screentime_step2'),
t('blocker.screentime_step3'),
].map((step, i) => (
<Text
key={i}
style={{
fontSize: 13,
fontFamily: i === 0 ? 'Nunito_700Bold' : 'Nunito_600SemiBold',
color: i === 0 ? colors.brandOrange : colors.text,
lineHeight: 18,
}}
>
{step}
</Text>
))}
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 16, marginTop: 2 }}>
{t('blocker.screentime_step_note')}
</Text>
</View>
<TouchableOpacity
onPress={onConfirm}
disabled={saving}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
{saving
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.screentime_confirm_cta')}</Text>
}
</TouchableOpacity>
</View>
)}
</View>
)}
</SetupStepCard>
);
}
function SetupStep3({
unlocked,
done,
onActivate,
colors,
t,
}: {
unlocked: boolean;
done: boolean;
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={3}
title={t('blocker.setup_step3_title')}
subtitle={done ? t('blocker.setup_step3_subtitle_done') : t('blocker.setup_step3_subtitle_pending')}
done={done}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 2 })}
colors={colors}
>
{unlocked && !done && (
<View style={{ gap: 10, marginTop: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 10, padding: 10, flexDirection: 'row', gap: 8 }}>
<Ionicons name="information-circle-outline" size={15} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
{t('blocker.setup_step3_warning')}
</Text>
</View>
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.success, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.setup_step3_cta')}</Text>
}
</TouchableOpacity>
</View>
)}
</SetupStepCard>
);
}
// ─── Android 3-Step Setup Flow ───────────────────────────────────────────────
type AndroidSetupFlowProps = {
vpnActive: boolean;
// VPN-Aktivierung läuft → Spinner statt CTA, bis der Layer-State bestätigt ist.
vpnActivating?: boolean;
// = a11y-Service enabled UND Tamper-Lock armed (appDeletionLock). Nur "enabled"
// reicht NICHT — der a11y-Service ist ohne armed-Flag komplett passiv.
accessibilityLocked: boolean;
deviceAdminActive: boolean;
// Akku-Ausnahme (gegen Samsung-Sleep, der den a11y-Lock entbindet → Schutz weg).
batteryUnrestricted: boolean;
onActivateVpn: () => Promise<{ enabled: boolean; error?: string }>;
onActivateAccessibility: () => Promise<unknown>;
onRequestDeviceAdmin: () => Promise<{ launched: boolean }>;
onRequestBattery: () => Promise<{ opened: boolean; alreadyIgnored?: boolean }>;
onOpenAppDetails: () => Promise<{ opened: boolean }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
};
export function AndroidSetupFlow({
vpnActive,
vpnActivating,
accessibilityLocked,
deviceAdminActive,
batteryUnrestricted,
onActivateVpn,
onActivateAccessibility,
onRequestDeviceAdmin,
onRequestBattery,
onOpenAppDetails,
colors,
t,
}: AndroidSetupFlowProps) {
// Reihenfolge KRITISCH: VPN → Geräteadmin → Akku-Ausnahme → a11y. a11y MUSS
// zuletzt, weil der Tamper-Lock (sobald armed) Settings-Seiten blockt. Die
// Akku-Ausnahme MUSS VOR a11y: sonst schläfert Samsung den frisch aktivierten
// a11y-Service wieder ein → Lock entbunden → Schutz wertlos.
const vpnDone = vpnActive;
const adminDone = deviceAdminActive;
const batteryDone = batteryUnrestricted;
const a11yDone = accessibilityLocked;
return (
<View style={{ gap: 10 }}>
<AndroidStep1
done={vpnDone}
pending={!!vpnActivating && !vpnDone}
onActivate={onActivateVpn}
colors={colors}
t={t}
/>
{/* Display-Step 2 = Geräteadmin (Komponente AndroidStep3, i18n android_step3_*). */}
<AndroidStep3
unlocked={vpnDone}
done={adminDone}
onRequestAdmin={onRequestDeviceAdmin}
colors={colors}
t={t}
/>
{/* Display-Step 3 = Akku-Ausnahme (gegen Samsung-Sleep, der a11y entbindet). */}
<AndroidStepBattery
unlocked={adminDone}
done={batteryDone}
onRequest={onRequestBattery}
onOpenDetails={onOpenAppDetails}
colors={colors}
t={t}
/>
{/* Display-Step 4 = a11y / ReBreak-Schutz (Komponente AndroidStep2, i18n android_step2_*). */}
<AndroidStep2
unlocked={batteryDone}
done={a11yDone}
onActivate={onActivateAccessibility}
colors={colors}
t={t}
/>
</View>
);
}
function AndroidStep1({
done,
pending,
onActivate,
colors,
t,
}: {
done: boolean;
pending?: boolean;
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={1}
title={t('blocker.android_step1_title')}
subtitle={done ? t('blocker.android_step1_subtitle_done') : t('blocker.android_step1_subtitle_pending')}
done={done}
unlocked
colors={colors}
>
{!done && (pending ? (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, paddingVertical: 11, marginTop: 10 }}>
<ActivityIndicator size="small" color={colors.brandOrange} />
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>{t('blocker.activating')}</Text>
</View>
) : (
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 10 }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step1_cta')}</Text>
}
</TouchableOpacity>
))}
</SetupStepCard>
);
}
function AndroidStep2({
unlocked,
done,
onActivate,
colors,
t,
}: {
unlocked: boolean;
done: boolean;
onActivate: () => Promise<unknown>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={4}
title={t('blocker.android_step2_title')}
subtitle={done ? t('blocker.android_step2_subtitle_done') : t('blocker.android_step2_subtitle_pending')}
done={done}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 3 })}
colors={colors}
>
{unlocked && !done && (
<View style={{ gap: 8, marginTop: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 12, gap: 6 }}>
{[
t('blocker.android_step2_instruction1'),
t('blocker.android_step2_instruction2'),
t('blocker.android_step2_instruction3'),
].map((line, i) => (
<Text
key={i}
style={{
fontSize: 13,
fontFamily: i === 0 ? 'Nunito_700Bold' : 'Nunito_600SemiBold',
color: i === 0 ? colors.brandOrange : colors.text,
lineHeight: 18,
}}
>
{line}
</Text>
))}
</View>
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 2 }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step2_cta')}</Text>
}
</TouchableOpacity>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 16 }}>
{t('blocker.android_step2_note')}
</Text>
</View>
)}
</SetupStepCard>
);
}
function AndroidStepBattery({
unlocked,
done,
onRequest,
onOpenDetails,
colors,
t,
}: {
unlocked: boolean;
done: boolean;
onRequest: () => Promise<{ opened: boolean; alreadyIgnored?: boolean }>;
onOpenDetails: () => Promise<{ opened: boolean }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onRequest(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={3}
title={t('blocker.android_battery_title')}
subtitle={done ? t('blocker.android_battery_subtitle_done') : t('blocker.android_battery_subtitle_pending')}
done={done}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 2 })}
colors={colors}
>
{unlocked && !done && (
<View style={{ gap: 8, marginTop: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 12 }}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text, lineHeight: 18 }}>
{t('blocker.android_battery_body')}
</Text>
</View>
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 2 }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_battery_cta')}</Text>
}
</TouchableOpacity>
<TouchableOpacity onPress={() => { void onOpenDetails(); }} activeOpacity={0.7} style={{ paddingVertical: 4, alignItems: 'center' }}>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted, textAlign: 'center', lineHeight: 16 }}>
{t('blocker.android_battery_samsung_hint')}
</Text>
</TouchableOpacity>
</View>
)}
</SetupStepCard>
);
}
function AndroidStep3({
unlocked,
done,
onRequestAdmin,
colors,
t,
}: {
unlocked: boolean;
done: boolean;
onRequestAdmin: () => Promise<{ launched: boolean }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onRequestAdmin(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={2}
title={t('blocker.android_step3_title')}
subtitle={done ? t('blocker.android_step3_subtitle_done') : t('blocker.android_step3_subtitle_pending')}
done={done}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 1 })}
colors={colors}
>
{unlocked && !done && (
<View style={{ gap: 10, marginTop: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 10, padding: 10, flexDirection: 'row', gap: 8 }}>
<Ionicons name="information-circle-outline" size={15} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
{t('blocker.android_step3_warning')}
</Text>
</View>
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.success, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step3_cta')}</Text>
}
</TouchableOpacity>
</View>
)}
</SetupStepCard>
);
}
function SetupStepCard({
stepNumber,
title,
subtitle,
done,
unlocked,
lockedHint,
colors,
children,
}: {
stepNumber: number;
title: string;
subtitle: string;
done: boolean;
unlocked: boolean;
lockedHint?: string;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
children?: ReactNode;
}) {
const borderColor = done ? '#86efac' : unlocked ? colors.border : colors.border;
const cardBg = done ? '#f0fdf4' : colors.surface;
const numberBg = done ? '#dcfce7' : unlocked ? colors.surfaceElevated : colors.surfaceElevated;
const numberColor = done ? colors.success : unlocked ? colors.text : colors.textMuted;
const titleColor = done ? colors.success : unlocked ? colors.text : colors.textMuted;
return (
<View style={{ backgroundColor: cardBg, borderWidth: 1, borderColor, borderRadius: 16, padding: 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: numberBg, alignItems: 'center', justifyContent: 'center' }}>
{done
? <Ionicons name="checkmark" size={18} color={colors.success} />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: numberColor }}>{stepNumber}</Text>
}
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: titleColor }}>{title}</Text>
{!!subtitle && (
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginTop: 2, lineHeight: 17 }}>{subtitle}</Text>
)}
</View>
{done && <Ionicons name="checkmark-circle" size={24} color={colors.success} />}
</View>
{lockedHint && !done && (
<View style={{ marginTop: 8, flexDirection: 'row', gap: 6, alignItems: 'center' }}>
<Ionicons name="lock-closed-outline" size={13} color={colors.textMuted} />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>{lockedHint}</Text>
</View>
)}
{children}
</View>
);
}