/** * 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, SystemSettingsTarget, } 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 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"; }; // ─── 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(); }, async activateUrlFilter(): Promise<{ 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 res = await RebreakProtection.activate(); const enabled = !res.missingLayers.includes("vpn"); return enabled ? { enabled: true } : { enabled: false, error: res.errors?.[0] }; } return RebreakProtection.activateUrlFilter(); }, 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() { // 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); } return RebreakProtection.disable(); }, getDeviceState(): Promise { return RebreakProtection.getDeviceState(); }, /** Android: VpnService neu starten falls er laufen sollte (`filter_enabled`) * aber tot ist (Reinstall / OS-Kill). Bei App-Start/Foreground aufrufen, * damit der State nicht „an aber tot" bleibt. No-op auf iOS/Web. */ async reconcileVpn(): Promise { if (Platform.OS !== "android") return; try { await RebreakProtection.reconcileVpn(); } catch (e) { console.warn("[protection] reconcileVpn failed:", e); } }, syncBlocklist(opts: SyncBlocklistOpts): Promise { return RebreakProtection.syncBlocklist(opts); }, runHealthProbe(opts?: HealthProbeOpts): Promise { return RebreakProtection.runHealthProbe(opts); }, openSystemSettings(target?: SystemSettingsTarget): Promise { return RebreakProtection.openSystemSettings(target); }, 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 }; }, /** 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] = await Promise.all([ this.getDeviceState(), this.getCooldownStatus(), this.getBackendProtectionState(), ]); // 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 layers: DeviceLayers = Platform.OS === "android" && rawLayers.urlFilter === undefined ? ({ ...rawLayers, urlFilter: rawLayers.vpn, familyControls: rawLayers.tamperLock, appDeletionLock: rawLayers.tamperLock, } as DeviceLayers) : rawLayers; // "Aktiv" = der eigentliche Schutz (URL-/DNS-Filter) läuft. Der App-Lock // (familyControls/tamperLock) ist optionales Hardening — er macht den Schutz // schwerer abschaltbar, ist aber keine Voraussetzung für "geschützt". Er wird // nur beim ersten Aktivieren eingerichtet; eine Reaktivierung setzt nur den // Filter wieder. → "recoveringFromBypass" heißt deshalb: Filter ist aus, // obwohl das Backend sagt er sollte an sein (= jemand hat den VPN extern aus). const phase: ProtectionPhase = cooldown.active ? "cooldownActive" : backend?.protectionShouldBeActive === true && layers.urlFilter !== true ? "recoveringFromBypass" : layers.urlFilter === true ? "active" : "inactive"; return { phase, layers, cooldown, blocklistCount: layers.blocklistCount, plan: backend?.plan ?? "free", }; }, /** * 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(":"); }