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>
603 lines
24 KiB
TypeScript
603 lines
24 KiB
TypeScript
/**
|
|
* 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<void> {
|
|
await AsyncStorage.setItem(DEV_COOLDOWN_TESTMODE_KEY, on ? "1" : "0");
|
|
}
|
|
|
|
export async function getCooldownTestMode(): Promise<boolean> {
|
|
const val = await AsyncStorage.getItem(DEV_COOLDOWN_TESTMODE_KEY);
|
|
return val === "1";
|
|
}
|
|
|
|
// ─── Public API ────────────────────────────────────────────────────────────
|
|
|
|
export const protection = {
|
|
// ─── Native-Calls (Device-Layer) ─────────────────────────────────────────
|
|
|
|
activate(): Promise<ActivateResult> {
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
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();
|
|
},
|
|
|
|
/**
|
|
* Android-only: löst den System-Dialog zum Aktivieren des Geräteadministrators aus.
|
|
* Gibt {launched:true} zurück wenn der Dialog gestartet wurde. Das tatsächliche
|
|
* Ergebnis (accept/deny) liest die UI via AppState-Return + `isDeviceAdminActive`.
|
|
*/
|
|
async requestDeviceAdmin(): Promise<{ launched: boolean }> {
|
|
if (Platform.OS !== "android") return { launched: false };
|
|
try {
|
|
return await RebreakProtection.requestDeviceAdmin();
|
|
} catch (e) {
|
|
console.warn("[protection] requestDeviceAdmin failed:", e);
|
|
return { launched: false };
|
|
}
|
|
},
|
|
|
|
/** Android-only: Deaktiviert den Device-Admin-Receiver. Wird im Cooldown-Resolve aufgerufen. */
|
|
async removeDeviceAdmin(): Promise<void> {
|
|
if (Platform.OS !== "android") return;
|
|
try {
|
|
await RebreakProtection.removeDeviceAdmin();
|
|
} catch (e) {
|
|
console.warn("[protection] removeDeviceAdmin failed:", e);
|
|
}
|
|
},
|
|
|
|
/** 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);
|
|
}
|
|
// Device-Admin MUSS vor disable() entfernt werden — sonst kann die App nach dem
|
|
// Cooldown nicht deinstalliert werden (aktiver Device-Admin blockt Deinstallation).
|
|
// Das ist die Safety-Auflage: legitimer Ausstieg via Cooldown MUSS funktionieren.
|
|
if (Platform.OS === "android") {
|
|
try {
|
|
await RebreakProtection.removeDeviceAdmin();
|
|
} catch (e) {
|
|
console.warn("[protection] removeDeviceAdmin in forceDisable failed:", e);
|
|
}
|
|
}
|
|
const res = await RebreakProtection.disable();
|
|
console.log("[protection] native disable returned:", res);
|
|
return res;
|
|
},
|
|
|
|
getDeviceState(): Promise<DeviceLayers> {
|
|
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<WebContentFilterResult> {
|
|
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<SyncWebContentDomainsResult> {
|
|
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<void> {
|
|
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<SyncBlocklistResult> {
|
|
return RebreakProtection.syncBlocklist(opts);
|
|
},
|
|
|
|
runHealthProbe(opts?: HealthProbeOpts): Promise<HealthProbeResult> {
|
|
return RebreakProtection.runHealthProbe(opts);
|
|
},
|
|
|
|
openSystemSettings(target?: SystemSettingsTarget): Promise<void> {
|
|
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<string[]> {
|
|
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<void> {
|
|
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<string, unknown> = { 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<void> {
|
|
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<string | null> {
|
|
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<CooldownState> {
|
|
try {
|
|
const res = await apiFetch<BackendCooldownStatus>("/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<BackendProtectionState | null> {
|
|
try {
|
|
return await apiFetch<BackendProtectionState>("/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<ProtectionState> {
|
|
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<boolean> {
|
|
const status = await apiFetch<BackendCooldownStatus>(
|
|
"/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(":");
|
|
}
|