Android self-bind protection auf nahezu MDM-Niveau ohne Device-Owner: - Device-Admin (RebreakDeviceAdminReceiver) blockt Uninstall OS-seitig, aktiv ab Boot ohne Prozess/a11y. Deaktivierung nur via 24h-Cooldown (removeDeviceAdmin in forceDisable). a11y blockt die DeviceAdminAdd-Settings-Seite (Class-Match, auf Samsung One UI per Logcat verifiziert). - Boot-Receiver (RebreakVpnBootReceiver) startet VPN+a11y nach Reboot, damit der Tamper-Lock ohne manuellen App-Start hochkommt. - Manifest-Wiring (Device-Admin-Receiver, Boot-Receiver, RECEIVE_BOOT_COMPLETED, device_admin.xml) ins with-rebreak-protection-android Config-Plugin verlagert → ueberlebt 'expo prebuild' (android/ ist gitignored). - a11y-Detection zurueck auf die funktionierende Version: zu breites 'loeschen'- Uninstall-Keyword raus (blockte halbe Settings); a11y-Label jetzt 'ReBreak Schutz'. - a11y-Deeplink behaelt den Samsung-Step-Guide (openAccessibilitySettings). Session-Frontend in diesem Batch: - Avatar-Placeholder: neutrales clarity-avatar-line SVG statt dominantem Blau. - DiGA-Milestone folgt kumulativen protectedDays (erreicht rueckfall-anfaellige User). - Dev-Build crasht nicht mehr ohne CallKit-Native-Modul. - VPN-Permission-Dialog nur noch im Bypass-Fall. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1050 lines
38 KiB
TypeScript
1050 lines
38 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
|
|
import { AppState, Platform, ScrollView, Text, TouchableOpacity, View, Alert, ActivityIndicator } from 'react-native';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useRouter } from 'expo-router';
|
|
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { AppHeader } from '../../components/AppHeader';
|
|
import { LayerSwitchCard } from '../../components/blocker/LayerSwitchCard';
|
|
import { ProtectionLockedCard } from '../../components/blocker/ProtectionLockedCard';
|
|
import { CooldownBanner } from '../../components/blocker/CooldownBanner';
|
|
import { AddDomainSheet } from '../../components/blocker/AddDomainSheet';
|
|
import { VipSwapSheet } from '../../components/blocker/VipSwapSheet';
|
|
import { MyFiltersList, VipDomainList } from '../../components/blocker/VipDomainList';
|
|
import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetailsSheet';
|
|
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
|
|
import { PermissionDeniedSheet } from '../../components/PermissionDeniedSheet';
|
|
import { ProtectionOffSheet } from '../../components/ProtectionOffSheet';
|
|
import { useProtectionState } from '../../hooks/useProtectionState';
|
|
import { useCustomDomains } from '../../hooks/useCustomDomains';
|
|
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
|
|
import { useDomainSubmissionRealtime } from '../../hooks/useDomainSubmissionRealtime';
|
|
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../lib/protection';
|
|
import { useColors } from '../../lib/theme';
|
|
import { useBlockerStatsStore } from '../../stores/blockerStats';
|
|
|
|
export default function BlockerScreen() {
|
|
const router = useRouter();
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
// react-native-bottom-tabs Tab-Bar ist iOS-nativ + translucent → unsere Content-View
|
|
// erstreckt sich UNTER den Tab-Bar. Ohne diese Höhe würden FAB + Bottom-Padding
|
|
// hinterm Tab-Bar verschwinden.
|
|
const tabBarHeight = useBottomTabBarHeight();
|
|
const {
|
|
state,
|
|
loading,
|
|
cooldownRemainingFormatted,
|
|
mdmManaged,
|
|
refresh,
|
|
activateUrlFilter,
|
|
activateFamilyControls,
|
|
requestDeactivation,
|
|
cancelDeactivation,
|
|
} = useProtectionState();
|
|
const refreshBlockerStatsIfStale = useBlockerStatsStore((s) => s.refreshIfStale);
|
|
const refreshBlockerStats = useBlockerStatsStore((s) => s.refresh);
|
|
|
|
const plan = state?.plan ?? 'free';
|
|
const {
|
|
domains,
|
|
tier,
|
|
count: domainCount,
|
|
limit: domainLimit,
|
|
addDomain,
|
|
submitDomain,
|
|
submitVipSwap,
|
|
refresh: refreshDomains,
|
|
} = useCustomDomains(plan);
|
|
const { sync: syncBlocklist, syncWebContent } = useBlocklistSync();
|
|
|
|
// Realtime: Domain-Submission-Status (approved/rejected/in_review) live patchen.
|
|
const onDomainChange = useCallback(async () => {
|
|
await refreshDomains();
|
|
await refreshBlockerStats().catch(() => {});
|
|
if (urlFilterActiveRef.current) {
|
|
const sync = await syncBlocklist();
|
|
console.log('[blocker] resync after domain change:', sync);
|
|
await refresh();
|
|
}
|
|
}, [refreshDomains, refreshBlockerStats, syncBlocklist, refresh]);
|
|
useDomainSubmissionRealtime(onDomainChange, true);
|
|
|
|
// Stats fürs Info-Sheet früh laden, damit beim Öffnen kein Loader-Flicker entsteht.
|
|
useEffect(() => {
|
|
refreshBlockerStatsIfStale(120_000).catch(() => {});
|
|
}, [refreshBlockerStatsIfStale]);
|
|
|
|
const [vipOpen, setVipOpen] = useState(false);
|
|
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
|
const [vipSwapOpen, setVipSwapOpen] = useState(false);
|
|
const [pendingNewDomainId, setPendingNewDomainId] = useState<string>('');
|
|
|
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
|
const [explainerOpen, setExplainerOpen] = useState(false);
|
|
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
|
|
const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false);
|
|
const [protectionOffOpen, setProtectionOffOpen] = useState(false);
|
|
|
|
// Screen Time Passcode (iOS Layer 3)
|
|
const [screentimeCode, setScreentimeCode] = useState<string | null>(null);
|
|
const [screentimeConfirmed, setScreentimeConfirmed] = useState(false);
|
|
const [screentimeSaving, setScreentimeSaving] = useState(false);
|
|
// Load persisted screentime status on mount so the card stays hidden if already set
|
|
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;
|
|
// "lockedIn" = beide Layer aktiv: URL-Filter (echter Schutz) UND App-Lock
|
|
// (Hardening). Family-Controls ALLEINE = kein Schutz, nur denyAppRemoval —
|
|
// ohne URL-Filter sieht der User trotzdem Glücksspielseiten. Daher BEIDE
|
|
// müssen an sein damit der "Schutz aktiv"-Banner gezeigt wird.
|
|
// Ausnahmen:
|
|
// - !FAMILY_CONTROLS_AVAILABLE (Distribution-Build ohne FC-Entitlement) →
|
|
// es kann gar keinen App-Lock geben, URL-Filter allein reicht.
|
|
// - mdmManaged → der App-Lock wird MDM-seitig durch nicht-entfernbares
|
|
// Profile + non-removable App enforced, FC-Toggle ist irrelevant.
|
|
// nefilterActive → Schutz via System-Profil, kein VPN-Toggle nötig → locked-in
|
|
const lockedIn = Platform.OS === 'android'
|
|
? (urlFilterActive && appDeletionLockActive && deviceAdminActive)
|
|
: (nefilterActive || urlFilterActive) && (mdmManaged || nefilterActive || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE);
|
|
|
|
const urlFilterActiveRef = useRef(urlFilterActive);
|
|
useEffect(() => { urlFilterActiveRef.current = urlFilterActive; }, [urlFilterActive]);
|
|
|
|
// Auto-Sync wenn URL-Filter oder NEFilter (MDM-Mode) beim Mount aktiv ist.
|
|
// Im MDM-Mode läuft NEFilter via System-Profil — urlFilterActive ist false (kein VPN),
|
|
// aber nefilterActive=true. Sync muss auch in diesem Fall laufen.
|
|
const syncedOnceRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!urlFilterActive && !nefilterActive) return;
|
|
if (syncedOnceRef.current) return;
|
|
syncedOnceRef.current = true;
|
|
syncBlocklist().then((res) => {
|
|
console.log('[blocker] auto-sync on mount:', res);
|
|
if (res.ok) refresh();
|
|
});
|
|
}, [urlFilterActive, nefilterActive, syncBlocklist, refresh]);
|
|
|
|
// Layer 2 / VIP: webContent-Domain-Liste IMMER beim Mount syncen — ungated,
|
|
// da Layer 2 an Family Controls hängt, nicht am URL-Filter.
|
|
const webContentSyncedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (webContentSyncedRef.current) return;
|
|
webContentSyncedRef.current = true;
|
|
syncWebContent();
|
|
}, [syncWebContent]);
|
|
|
|
// Wenn User aus System-Settings zurückkommt (z.B. nach a11y-Aktivierung) → State neu laden.
|
|
useEffect(() => {
|
|
const sub = AppState.addEventListener('change', (next) => {
|
|
if (next === 'active') {
|
|
refresh();
|
|
syncWebContent();
|
|
}
|
|
});
|
|
return () => sub.remove();
|
|
}, [refresh, syncWebContent]);
|
|
|
|
// ─── Android Device-Admin ────────────────────────────────────────────
|
|
|
|
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 };
|
|
}
|
|
}
|
|
|
|
// ─── Activate-Handler pro Layer ──────────────────────────────────────
|
|
|
|
async function handleActivateUrlFilter() {
|
|
try {
|
|
const result = await activateUrlFilter();
|
|
console.log('[blocker] activateUrlFilter:', result);
|
|
if (!result.enabled) {
|
|
// iOS-spezifisch: NEFilterErrorDomain code 5 = User hat „Nicht erlauben"
|
|
// im System-Dialog getippt → iOS cached den Denied-State. Special-Sheet
|
|
// statt rohem Alert (Recovery via removeFromPreferences + Settings-Deep-Link).
|
|
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();
|
|
console.log('[blocker] post-activate sync:', sync);
|
|
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) {
|
|
console.error('[blocker] activateUrlFilter threw:', e);
|
|
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
|
|
return { enabled: false };
|
|
}
|
|
}
|
|
|
|
async function handleActivateFamilyControls() {
|
|
try {
|
|
const result = await activateFamilyControls();
|
|
console.log('[blocker] activateFamilyControls:', result);
|
|
// `accessibility_pending` = a11y-Berechtigung fehlt noch → System-Settings wurden
|
|
// geöffnet. Kein Fehler-Modal (sonst Modal-Loop bei jedem Tap).
|
|
if (!result.enabled && result.error !== 'accessibility_pending') {
|
|
// iOS: NSCocoaErrorDomain code 4099 = XPC-Communication-Failure zum
|
|
// FamilyControls-Daemon (häufig nach „Don't Allow" oder bei iOS-Auth-
|
|
// Daemon-State-Issue). Recovery-Sheet statt rohem Alert — mit Anleitung
|
|
// (Restart Device / Settings / App reinstall).
|
|
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'),
|
|
);
|
|
}
|
|
return result;
|
|
} catch (e: any) {
|
|
console.error('[blocker] activateFamilyControls threw:', e);
|
|
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
|
|
}
|
|
return { enabled: false };
|
|
}
|
|
|
|
// ─── Screen Time Passcode (iOS Layer 3) ─────────────────────────────
|
|
|
|
function handleGenerateScreentimeCode() {
|
|
const code = Math.floor(1000 + Math.random() * 9000).toString();
|
|
setScreentimeCode(code);
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ─── 3-Click Cooldown-Trigger ────────────────────────────────────────
|
|
|
|
function openDetails() {
|
|
setDetailsOpen(true);
|
|
}
|
|
|
|
function fromDetailsToExplainer() {
|
|
setDetailsOpen(false);
|
|
setTimeout(() => setExplainerOpen(true), 250);
|
|
}
|
|
|
|
function deflectToLyra() {
|
|
setDetailsOpen(false);
|
|
setTimeout(() => router.push('/lyra' as any), 250);
|
|
}
|
|
|
|
function deflectToBreathe() {
|
|
setExplainerOpen(false);
|
|
setTimeout(() => router.push('/urge' as any), 250);
|
|
}
|
|
|
|
async function handleStartCooldown(reason: string) {
|
|
await requestDeactivation(reason);
|
|
}
|
|
|
|
async function handleCancelCooldown() {
|
|
try {
|
|
await cancelDeactivation();
|
|
} catch (e: any) {
|
|
Alert.alert(t('common.error'), e?.message ?? t('blocker.deactivation_cancel_failed'));
|
|
}
|
|
}
|
|
|
|
const bypassAlertShownRef = useRef(false);
|
|
useEffect(() => {
|
|
if (state?.phase !== 'recoveringFromBypass') {
|
|
bypassAlertShownRef.current = false;
|
|
return;
|
|
}
|
|
if (bypassAlertShownRef.current) return;
|
|
bypassAlertShownRef.current = true;
|
|
setProtectionOffOpen(true);
|
|
}, [state?.phase]);
|
|
|
|
// ─── Render ──────────────────────────────────────────────────────────
|
|
|
|
return (
|
|
<View className="flex-1 bg-neutral-50">
|
|
<AppHeader />
|
|
|
|
{loading && !state ? (
|
|
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
|
<ActivityIndicator size="large" color="#737373" />
|
|
</View>
|
|
) : state ? (
|
|
<>
|
|
<ScrollView
|
|
contentContainerStyle={{
|
|
padding: 16,
|
|
paddingBottom: tabBarHeight + 80,
|
|
gap: 14,
|
|
}}
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{/* Locked-In Mode (FC / NEFilter aktiv) → NUR Schutz-Status + Cooldown-Pfad */}
|
|
{lockedIn ? (
|
|
<ProtectionLockedCard
|
|
state={state}
|
|
mdmManaged={mdmManaged}
|
|
nefilterActive={nefilterActive}
|
|
onPressSettings={openDetails}
|
|
/>
|
|
) : Platform.OS === 'ios' && FAMILY_CONTROLS_AVAILABLE && !mdmManaged && !nefilterActive ? (
|
|
/* iOS unsupervised: geführter 3-Schritt-Setup-Flow */
|
|
<IosUnsupervisedSetupFlow
|
|
familyControlsActive={appDeletionLockActive}
|
|
screentimeCode={screentimeCode}
|
|
screentimeConfirmed={screentimeConfirmed}
|
|
screentimeSaving={screentimeSaving}
|
|
urlFilterActive={urlFilterActive}
|
|
onActivateFamilyControls={handleActivateFamilyControls}
|
|
onGenerateScreentimeCode={handleGenerateScreentimeCode}
|
|
onConfirmScreentime={handleScreentimeConfirm}
|
|
onActivateUrlFilter={handleActivateUrlFilter}
|
|
colors={colors}
|
|
t={t}
|
|
/>
|
|
) : Platform.OS === 'android' ? (
|
|
<AndroidSetupFlow
|
|
vpnActive={urlFilterActive}
|
|
accessibilityLocked={appDeletionLockActive}
|
|
deviceAdminActive={deviceAdminActive}
|
|
onActivateVpn={handleActivateUrlFilter}
|
|
onActivateAccessibility={handleActivateFamilyControls}
|
|
onRequestDeviceAdmin={handleRequestDeviceAdmin}
|
|
colors={colors}
|
|
t={t}
|
|
/>
|
|
) : (
|
|
<View style={{ gap: 10 }}>
|
|
<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={handleActivateUrlFilter}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{/* CooldownBanner */}
|
|
{state.cooldown.active && (
|
|
<CooldownBanner
|
|
remainingFormatted={cooldownRemainingFormatted}
|
|
onCancel={handleCancelCooldown}
|
|
/>
|
|
)}
|
|
|
|
|
|
{/* Sektion 1: Meine Filter (unified web + mail_domain) */}
|
|
<MyFiltersList
|
|
domains={domains}
|
|
tier={tier}
|
|
totalCount={domainCount}
|
|
totalLimit={domainLimit}
|
|
globalBlocklistCount={state.blocklistCount}
|
|
onAddPress={() => setAddSheetOpen(true)}
|
|
onSubmitDomain={submitDomain}
|
|
colors={colors}
|
|
/>
|
|
|
|
{/* Sektion 2: VIP-Liste (Zweitschutz, collapsible) */}
|
|
<VipDomainList
|
|
domains={domains}
|
|
open={vipOpen}
|
|
onToggle={() => setVipOpen((v) => !v)}
|
|
colors={colors}
|
|
/>
|
|
</ScrollView>
|
|
|
|
{/* Sheets */}
|
|
<AddDomainSheet
|
|
visible={addSheetOpen}
|
|
tier={tier}
|
|
onClose={() => {
|
|
setAddSheetOpen(false);
|
|
refreshDomains();
|
|
}}
|
|
onAdd={async (pattern, kind, opts) => {
|
|
const result = await addDomain(pattern, kind, opts);
|
|
if (result.ok) {
|
|
syncWebContent();
|
|
const sync = await syncBlocklist();
|
|
if (sync.ok) refresh();
|
|
if (result.vipFull && result.newDomainId) {
|
|
setAddSheetOpen(false);
|
|
setPendingNewDomainId(result.newDomainId);
|
|
// AddDomainSheet erst zu Ende dismissen lassen, DANN den
|
|
// VipSwapSheet präsentieren — sonst verschluckt iOS das
|
|
// zweite Modal (gleiches Muster wie fromDetailsToExplainer).
|
|
setTimeout(() => setVipSwapOpen(true), 320);
|
|
}
|
|
}
|
|
return result;
|
|
}}
|
|
/>
|
|
|
|
<VipSwapSheet
|
|
visible={vipSwapOpen}
|
|
newDomainId={pendingNewDomainId}
|
|
candidates={domains}
|
|
onClose={() => {
|
|
setVipSwapOpen(false);
|
|
setPendingNewDomainId('');
|
|
}}
|
|
onSwap={async (newId, evictedId) => {
|
|
const result = await submitVipSwap(newId, evictedId);
|
|
if (result.ok) {
|
|
syncWebContent();
|
|
}
|
|
return result;
|
|
}}
|
|
/>
|
|
|
|
<ProtectionDetailsSheet
|
|
visible={detailsOpen}
|
|
state={state}
|
|
mdmManaged={mdmManaged}
|
|
onClose={() => setDetailsOpen(false)}
|
|
onRequestDeactivation={fromDetailsToExplainer}
|
|
onTalkToLyra={deflectToLyra}
|
|
/>
|
|
|
|
<DeactivationExplainerSheet
|
|
visible={explainerOpen}
|
|
onClose={() => setExplainerOpen(false)}
|
|
onBreathe={deflectToBreathe}
|
|
onStartCooldown={handleStartCooldown}
|
|
/>
|
|
|
|
<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;
|
|
}}
|
|
/>
|
|
|
|
<ProtectionOffSheet
|
|
visible={protectionOffOpen}
|
|
onClose={() => setProtectionOffOpen(false)}
|
|
onReactivate={() => handleActivateUrlFilter()}
|
|
/>
|
|
</>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// ─── 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'];
|
|
};
|
|
|
|
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;
|
|
// = 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;
|
|
onActivateVpn: () => Promise<{ enabled: boolean; error?: string }>;
|
|
onActivateAccessibility: () => Promise<unknown>;
|
|
onRequestDeviceAdmin: () => Promise<{ launched: boolean }>;
|
|
colors: ReturnType<typeof import('../../lib/theme').useColors>;
|
|
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
|
|
};
|
|
|
|
function AndroidSetupFlow({
|
|
vpnActive,
|
|
accessibilityLocked,
|
|
deviceAdminActive,
|
|
onActivateVpn,
|
|
onActivateAccessibility,
|
|
onRequestDeviceAdmin,
|
|
colors,
|
|
t,
|
|
}: AndroidSetupFlowProps) {
|
|
// Reihenfolge KRITISCH: VPN → Geräteadmin → a11y. a11y MUSS zuletzt, weil der
|
|
// Tamper-Lock (sobald armed) die Geräteadmin-Seite blockt — sonst kann der
|
|
// User den Admin gar nicht mehr aktivieren.
|
|
const vpnDone = vpnActive;
|
|
const adminDone = deviceAdminActive;
|
|
const a11yDone = accessibilityLocked;
|
|
|
|
return (
|
|
<View style={{ gap: 10 }}>
|
|
<AndroidStep1
|
|
done={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 = a11y / ReBreak-Schutz (Komponente AndroidStep2, i18n android_step2_*). */}
|
|
<AndroidStep2
|
|
unlocked={adminDone}
|
|
done={a11yDone}
|
|
onActivate={onActivateAccessibility}
|
|
colors={colors}
|
|
t={t}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function AndroidStep1({
|
|
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.android_step1_title')}
|
|
subtitle={done ? t('blocker.android_step1_subtitle_done') : t('blocker.android_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.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={3}
|
|
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: 2 })}
|
|
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 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>
|
|
);
|
|
}
|
|
|