/** * Protection orchestration layer (JS-side). * * Verbindet das native rebreak-protection-Modul (Device-Layer-State) mit * dem Backend-Cooldown-API (`/api/cooldown/*` + `/api/protection/state`). * * Cooldown ist Backend-driven (JWT mit `cooldown_ends_at`-Claim, server-time * = single source of truth gegen lokale-Uhr-Manipulation). Native-Modul * kümmert sich nur um echten Device-State (NEFilter, Family Controls etc.). */ import { Platform } from "react-native"; import Constants from "expo-constants"; import AsyncStorage from "@react-native-async-storage/async-storage"; import RebreakProtection from "../modules/rebreak-protection"; import type { ActivateResult, DeviceLayers, HealthProbeOpts, HealthProbeResult, SyncBlocklistOpts, SyncBlocklistResult, SyncWebContentDomainsOpts, SyncWebContentDomainsResult, SystemSettingsTarget, WebContentFilterResult, } from "../modules/rebreak-protection"; import { apiFetch } from "./api"; // ─── Feature Flags ───────────────────────────────────────────────────────── /** * True only in dev/dev-client builds where Apple's FamilyControls Development * entitlement is active. False in preview/production (Distribution entitlement * pending Apple approval) — showing the App-Lock toggle in those builds would * throw NSCocoaErrorDomain:4099. */ export const FAMILY_CONTROLS_AVAILABLE = Constants.expoConfig?.extra?.familyControlsEnabled === true; // ─── Public Types ────────────────────────────────────────────────────────── export type ProtectionPhase = | "inactive" | "activating" | "active" | "cooldownPending" | "cooldownActive" | "recoveringFromBypass"; export type CooldownState = { active: boolean; endsAt: string | null; remainingSeconds: number; reason: string | null; }; export type ProtectionState = { phase: ProtectionPhase; layers: DeviceLayers; cooldown: CooldownState; blocklistCount: number; plan: "free" | "pro" | "legend"; /** Backend-reported: true wenn das Gerät als MDM-managed markiert wurde. */ mdmManaged: boolean; }; // ─── Backend Response-Types ──────────────────────────────────────────────── type BackendCooldownStatus = { active: boolean; remainingSeconds: number; cooldownEndsAt: string | null; token: string | null; canDisableProtection: boolean; reason?: string; }; // Matches actual response from `apps/rebreak/server/api/protection/state.get.ts` // (apiFetch unwrapt das `data`-Feld bereits, daher hier nur die Inner-Shape). type BackendProtectionState = { protectionShouldBeActive: boolean; cooldown: { active: boolean; remainingSeconds: number; cooldownEndsAt: string | null; }; plan: "free" | "pro" | "legend"; mdmManaged: boolean; }; // ─── Dev Helpers ─────────────────────────────────────────────────────────── const DEV_COOLDOWN_TESTMODE_KEY = "dev:cooldown-testmode"; export async function setCooldownTestMode(on: boolean): Promise { await AsyncStorage.setItem(DEV_COOLDOWN_TESTMODE_KEY, on ? "1" : "0"); } export async function getCooldownTestMode(): Promise { const val = await AsyncStorage.getItem(DEV_COOLDOWN_TESTMODE_KEY); return val === "1"; } // ─── Public API ──────────────────────────────────────────────────────────── export const protection = { // ─── Native-Calls (Device-Layer) ───────────────────────────────────────── activate(): Promise { return RebreakProtection.activate(); }, /** * iOS-only: read-only check ob NEFilter aktiv ist (egal ob via App-Code * oder via Sideload-Profile). Build 19 (2026-05-26): primary state-source * für blocker.tsx — wenn enabled=true → UI all-green, kein Schutz-Activate-Button. */ async isNeFilterActive(): Promise<{ enabled: boolean; localizedDescription?: string; error?: string; }> { if (Platform.OS !== "ios") return { enabled: false }; try { return await RebreakProtection.isNeFilterActive(); } catch (e) { return { enabled: false, error: String(e) }; } }, /** * iOS-only: probiert NEFilterDataProvider-Setup und retourniert ob's * funktioniert. Wenn enabled=true → Device ist MDM-managed (auto-Toggle on). * Wenn enabled=false → Device kann NEFilter nicht (iOS-Wall) → VPN-Pfad. * Setzt KEIN Flag selbst — Caller (Settings-UI) ruft setMdmSupervised(). */ async probeContentFilter(): Promise<{ enabled: boolean; error?: string }> { if (Platform.OS !== "ios") { return { enabled: false, error: "ios_only" }; } const res = await RebreakProtection.probeContentFilter(); const resAny = res as Record; const nativeLog = resAny.log; delete resAny.log; console.log(`[protection] probeContentFilter → ${JSON.stringify(res)}`); if (Array.isArray(nativeLog)) { for (const l of nativeLog) console.log(` [native] ${l}`); } return res; }, async activateUrlFilter(): Promise<{ enabled: boolean; error?: string }> { let res: { enabled: boolean; error?: string }; if (Platform.OS === "android") { // Android Layer-1 = VpnService (DNS-Filter). iOS-API erwartet hier // {enabled, error?}, also Native-`activate()`-Result re-shapen. const r = await RebreakProtection.activate(); const enabled = !r.missingLayers.includes("vpn"); res = enabled ? { enabled: true } : { enabled: false, error: r.errors?.[0] }; } else { // iOS Layer-1: PacketTunnel-VPN-Pfad. NEFilter läuft via sideloaded/MDM- // Profil ohne App-Code-Intervention — App aktiviert es nie selbst. // activateUrlFilter() ist nur noch für PacketTunnel (VPN) relevant. const pirServerURL = (Constants.expoConfig?.extra?.pirServerURL as string) ?? ""; const pirAuthToken = (Constants.expoConfig?.extra?.pirAuthToken as string) ?? ""; res = await RebreakProtection.activateUrlFilter({ pirServerURL, pirAuthToken, supervised: false, }); } // Diagnose: Fehler-String + nativer Log-Tail (inkl. der [EXT ...]-Zeilen // der Control-Provider-Extension) zeilenweise in Metro. { const resAny = res as Record; const nativeLog = resAny.log; delete resAny.log; console.log(`[protection] activateUrlFilter → ${JSON.stringify(res)}`); if (Array.isArray(nativeLog)) { for (const l of nativeLog) console.log(` [native] ${l}`); } } // Bei erfolgreicher Reaktivierung: Backend-Flag clearen (sonst bleibt // protectionShouldBeActive=false und Bypass-Detection feuert nicht mehr). // Best-effort — wenn das Backend nicht erreichbar ist, lokal nicht blocken. if (res.enabled) { apiFetch("/api/protection/mark-active", { method: "POST" }).catch(() => {}); } return res; }, /** * iOS-only Workaround: User hat "Nicht erlauben" beim NEFilter-System-Dialog * getippt → iOS cached den Denied-State + zeigt den Dialog beim erneuten * activateUrlFilter NICHT mehr (NEFilterErrorDomain code 5 silent). * resetUrlFilter macht removeFromPreferences VOR saveToPreferences — iOS * behandelt das als frischen Request → System-Dialog kommt erneut. * * Auf Android no-op (gibt einfach activateUrlFilter zurück, dort kein Cache). */ async resetUrlFilter(): Promise<{ enabled: boolean; error?: string }> { if (Platform.OS === "android") { return this.activateUrlFilter(); } return RebreakProtection.resetUrlFilter(); }, async activateFamilyControls(): Promise<{ enabled: boolean; error?: string }> { if (Platform.OS === "android") { // Android "App-Lock" = AccessibilityService als reiner Tamper-Lock (KEIN // Browser-Filter mehr — Glücksspielseiten blockt der VpnService DNS-Filter). // Der a11y-Service verhindert nur, dass schutz-relevante Settings geöffnet // werden (VPN abschalten / App deinstallieren / a11y-Service abschalten). // Two-step UX: // (1) A11y nicht aktiv → Settings öffnen, return {enabled:false} mit // Marker-Error. UI fragt nach Return den State neu ab und tappt // erneut auf "App lock" → wir landen in Step (2). // (2) A11y aktiv → tamperLock armen → return {enabled:true}. const a11y = await RebreakProtection.isAccessibilityEnabled(); if (!a11y.enabled) { await RebreakProtection.openAccessibilitySettings(); return { enabled: false, error: "accessibility_pending" }; } try { await RebreakProtection.armTamperLock(); return { enabled: true }; } catch (e: any) { return { enabled: false, error: e?.message ?? "tamper_lock_failed", }; } } return RebreakProtection.activateFamilyControls(); }, /** Schaltet alle Layer ab + disarmed den Tamper-Lock. NUR aufrufen wenn JS-Layer Cooldown verifiziert. */ async forceDisable() { console.log("[protection] forceDisable() — disarm tamper + native disable"); // Tamper-Lock ZUERST disarmen — sonst setzt der AccessibilityService den Schutz // nach dem Cooldown weiter durch (blockt z.B. das Ausschalten des a11y-Service in den // System-Settings) → der User kommt nicht aus dem Schutz raus, obwohl der Cooldown // abgelaufen ist. (Android-Bug-Fix.) try { await RebreakProtection.disarmTamperLock(); } catch (e) { console.warn("[protection] disarmTamperLock failed:", e); } const res = await RebreakProtection.disable(); console.log("[protection] native disable returned:", res); return res; }, getDeviceState(): Promise { return RebreakProtection.getDeviceState(); }, // ─── iOS Layer 2 — webContent-Filter (ManagedSettings) ─────────────────── // // Stilles WebKit-Sicherheitsnetz: blockt eine kuratierte, länderabhängige // Top-Gambling-Domain-Liste (≤50 Domains — Apple-Hartlimit) via // ManagedSettings. Braucht NUR Family Controls (kein MDM, kein neues // Entitlement). Auf Android/Web no-op. // // TODO(layer2-gating): Aktuell NUR explizit aufrufbare Capability. Die // Auto-Trigger-Logik ("Layer 2 automatisch AN sobald NEURLFilter/Layer 1 // aus ist + ein Cooldown läuft, sonst AUS") ist bewusst NICHT gebaut — sie // hängt an einer zuverlässigen NEURLFilter-Status-Erkennung (die aktuell // selbst nicht stabil funktioniert) und ist eine offene Produkt-/Design- // Entscheidung des Users. Bis dahin müssen Aufrufer applyWebContentFilter / // clearWebContentFilter explizit triggern. /** * Aktiviert Layer 2 — der webContent-Filter blockt die gebündelte Top- * Gambling-Domain-Liste des Geräte-Landes. Setzt eine aktive Family-Controls- * Authorization voraus. No-op auf Android/Web (gibt enabled:false zurück). */ async applyWebContentFilter(): Promise { if (Platform.OS !== "ios") { return { enabled: false, appliedCount: 0, region: "", error: "ios_only" }; } return RebreakProtection.applyWebContentFilter(); }, /** * Setzt Layer 2 zurück (blockedByFilter = .none). Rührt den App-Lock * (denyAppRemoval) NICHT an. No-op auf Android/Web. */ async clearWebContentFilter(): Promise<{ cleared: boolean; error?: string }> { if (Platform.OS !== "ios") { return { cleared: false, error: "ios_only" }; } return RebreakProtection.clearWebContentFilter(); }, /** * Synct die kuratierte Layer-2-Gambling-Domain-Liste vom Backend in den * App-Group-Cache (`webcontent-domains.json`). Die gebündelte JSON bleibt * Offline-Seed/Fallback. Nach erfolgreichem Sync wird der webContent-Filter * — wenn Family Controls authorisiert ist — nativ sofort reapplied. * * No-op auf Android/Web (Layer 2 ist iOS-only) → gibt updated:false zurück. * Best-effort: solange der Backend-Endpoint nicht deployed ist, schlägt der * Fetch fehl — der native loadWebContentDomains fällt dann sauber auf die * gebündelte JSON zurück. Der Aufrufer behandelt einen Fehler als nicht-fatal. */ async syncWebContentDomains( opts: SyncWebContentDomainsOpts, ): Promise { if (Platform.OS !== "ios") { return { updated: false }; } return RebreakProtection.syncWebContentDomains(opts); }, /** Self-Heal Layer-1-Filter. Bei App-Start/Foreground/Poll aufrufen. * * Android: VpnService neu starten falls er laufen sollte (`filter_enabled`) * aber tot ist (Reinstall / OS-Kill). * iOS: prüft ob unser NETunnelProviderManager noch da ist; falls User * „VPN löschen" in Settings getippt hat → silent recreate * (loadOrCreateTunnelManager + saveToPreferences + startVPNTunnel). * Wenn iOS Permission-Dialog zeigt: akzeptierte Friktion. * MDM-Mode (iOS): NEFilter läuft via Sideload-/MDM-Profile autonom — App- * Code darf den VPN-Stack nicht reaktivieren (würde Permissions-Dialog * triggern). Wenn NEFilter aktiv → no-op. * Web: no-op. */ async reconcileVpn(): Promise { if (Platform.OS === "ios") { const nef = await this.isNeFilterActive().catch(() => ({ enabled: false })); if (nef.enabled) return; try { const res = await RebreakProtection.reconcileUrlFilter(); if (res?.recreated) { console.log("[protection] iOS Packet-Tunnel auto-recreated nach VPN-Delete"); } else if (res?.error) { console.warn(`[protection] reconcileUrlFilter (ios) error: ${res.error}`); } } catch (e) { console.warn("[protection] reconcileUrlFilter (ios) failed:", e); } return; } if (Platform.OS === "android") { try { await RebreakProtection.reconcileVpn(); } catch (e) { console.warn("[protection] reconcileVpn (android) failed:", e); } return; } }, syncBlocklist(opts: SyncBlocklistOpts): Promise { return RebreakProtection.syncBlocklist(opts); }, runHealthProbe(opts?: HealthProbeOpts): Promise { return RebreakProtection.runHealthProbe(opts); }, openSystemSettings(target?: SystemSettingsTarget): Promise { return RebreakProtection.openSystemSettings(target); }, /** iOS: native Protection-Logs (NEFilter/FamilyControls flow) für Debug-Page. * Auf Android/Web no-op → leeres Array. */ async getProtectionLogs(): Promise { if (Platform.OS !== "ios") return []; try { return await RebreakProtection.getProtectionLogs(); } catch { return []; } }, /** iOS: leert die nativen Protection-Logs. No-op auf Android/Web. */ async clearProtectionLogs(): Promise { if (Platform.OS !== "ios") return; try { await RebreakProtection.clearProtectionLogs(); } catch { // ignore } }, addLayerChangeListener(cb: (layers: DeviceLayers) => void) { return RebreakProtection.addListener("onLayerChange", cb); }, // ─── Backend-Cooldown ──────────────────────────────────────────────────── /** Startet 24h Cooldown (oder 40s bei aktivem __DEV__-testMode). Schutz BLEIBT aktiv. */ async requestDeactivation( reason?: string, ): Promise<{ cooldownEndsAt: string }> { const testMode = __DEV__ ? await getCooldownTestMode() : false; const body: Record = { reason }; if (testMode) body.testMode = true; const res = await apiFetch<{ cooldownEndsAt: string; token: string; remainingSeconds: number; }>("/api/cooldown/request", { method: "POST", body }); return { cooldownEndsAt: res.cooldownEndsAt }; }, // ─── Screen Time Passcode (iOS Layer 3) ──────────────────────────────── /** Speichert den generierten 4-stelligen Screen-Time-Passcode auf dem Backend. * Wird im Onboarding aufgerufen nachdem User bestätigt hat den Code gesetzt zu haben. */ async saveScreenTimePasscode(passcode: string): Promise { await apiFetch("/api/protection/screentime-passcode", { method: "POST", body: { passcode }, }); }, /** Ruft den Screen-Time-Passcode ab — nur wenn kein aktiver Cooldown läuft. * Gibt null zurück wenn Code fehlt oder Cooldown noch aktiv ist. */ async getScreenTimePasscode(): Promise { const res = await apiFetch<{ passcode: string | null }>( "/api/protection/screentime-passcode", ); return res.passcode; }, /** Bricht laufenden Cooldown ab. Schutz BLEIBT aktiv. */ async cancelDeactivation(): Promise<{ cancelled: boolean }> { const res = await apiFetch<{ cancelled: boolean }>("/api/cooldown/cancel", { method: "POST", body: {}, }); return res; }, async getCooldownStatus(): Promise { try { const res = await apiFetch("/api/cooldown/status"); return { active: res.active, endsAt: res.cooldownEndsAt, remainingSeconds: res.remainingSeconds, reason: res.reason ?? null, }; } catch { // Offline / Backend down → konservativ: kein Cooldown angenommen return { active: false, endsAt: null, remainingSeconds: 0, reason: null }; } }, async getBackendProtectionState(): Promise { try { return await apiFetch("/api/protection/state"); } catch { return null; } }, // ─── Combined State (für UI) ───────────────────────────────────────────── /** * Holt nativen Device-State + Backend-Cooldown parallel und merged. * Phase-Berechnung folgt der State-Machine im Plan. */ async getCombinedState(): Promise { const [rawLayers, cooldown, backend, nefilterRes] = await Promise.all([ this.getDeviceState(), this.getCooldownStatus(), this.getBackendProtectionState(), Platform.OS === "ios" ? this.isNeFilterActive() : Promise.resolve({ enabled: false }), ]); // Android's native module reports {vpn, accessibility, tamperLock}; the UI // (blocker.tsx, isAllLayersOn) reads the iOS-shaped names {urlFilter, // familyControls, appDeletionLock}. Alias them so consumers are platform- // agnostic. Android "App-Lock" = AccessibilityService + armed tamper-lock, // so the lock-state maps to `tamperLock`. const layersBase: DeviceLayers = Platform.OS === "android" && rawLayers.urlFilter === undefined ? ({ ...rawLayers, urlFilter: rawLayers.vpn, familyControls: rawLayers.tamperLock, appDeletionLock: rawLayers.tamperLock, } as DeviceLayers) : rawLayers; const layers: DeviceLayers = { ...layersBase, nefilterActive: nefilterRes.enabled, nefilterDescription: (nefilterRes as { enabled: boolean; localizedDescription?: string }).localizedDescription, }; // "Aktiv" = der eigentliche Schutz läuft. Entweder via PacketTunnel-VPN // (urlFilter=true) ODER via System-/MDM-Profil-NEFilter (nefilterActive=true). // MDM-Mode: NEFilter läuft autonom — App-Code hat urlFilter=false (kein VPN), // aber nefilterActive=true. Beide gelten als aktiver Schutz. // "recoveringFromBypass" AUSSCHLIESSLICH wenn: Backend sagt Schutz soll aktiv // sein UND weder VPN noch NEFilter laufen UND wir nicht MDM-managed sind. const filterActive = layers.urlFilter === true || layers.nefilterActive === true; const phase: ProtectionPhase = cooldown.active ? "cooldownActive" : filterActive ? "active" : backend?.protectionShouldBeActive === true && !backend?.mdmManaged ? "recoveringFromBypass" : "inactive"; return { phase, layers, cooldown, blocklistCount: layers.blocklistCount, plan: backend?.plan ?? "free", mdmManaged: backend?.mdmManaged ?? false, }; }, /** * Wenn ein Cooldown TATSÄCHLICH gelaufen ist und jetzt elapsed → native disable. * * Defensiv: prüft `cooldownEndsAt` (heißt es gab einen Cooldown) UND * `remainingSeconds <= 0` (heißt er ist abgelaufen) UND `canDisableProtection`. * Backend kann `canDisableProtection: true` auch im initial-state geben; * der `cooldownEndsAt`-Check verhindert dann False-Positives. */ async applyCooldownDisableIfElapsed(): Promise { const status = await apiFetch( "/api/cooldown/status", ).catch(() => null); if (!status) return false; if (!status.canDisableProtection) return false; if (!status.cooldownEndsAt) return false; // nie ein Cooldown gewesen if (status.remainingSeconds > 0) return false; // Cooldown noch nicht abgelaufen await this.forceDisable(); return true; }, }; // ─── Helpers ─────────────────────────────────────────────────────────────── export function isAllLayersOn(layers: DeviceLayers): boolean { // iOS: urlFilter + appDeletionLock (fallback: familyControls für ältere Builds). // Android: vpn + accessibility (+ tamperLock optional). if ( layers.urlFilter !== undefined || layers.familyControls !== undefined || layers.appDeletionLock !== undefined ) { const lockLayer = layers.appDeletionLock ?? layers.familyControls; return layers.urlFilter === true && lockLayer === true; } if (layers.vpn !== undefined || layers.accessibility !== undefined) { return layers.vpn === true && layers.accessibility === true; } return false; } export function formatCooldownRemaining(seconds: number): string { if (seconds <= 0) return "00:00:00"; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; return [h, m, s].map((n) => String(n).padStart(2, "0")).join(":"); }