- protection.ts: setCooldownTestMode/getCooldownTestMode (AsyncStorage 'dev:cooldown-testmode'); requestDeactivation sends testMode:true when on (__DEV__ only) - debug.tsx: CooldownTestModeToggle (Switch) — '40s instead of 24h, staging only' - useProtectionState.ts: wire applyCooldownDisableIfElapsed() — fires on cooldown active→false transition (guarded so no extra fetch per poll) + on AppState 'active'; protection actually turns off when the (test-)cooldown elapses (the 'Step 5b' auto-disable) - DeactivationExplainerSheet.tsx: useSafeAreaInsets — header paddingTop insets.top+14, ScrollView paddingBottom max(insets.bottom,12)+24; back btn Pressable→TouchableOpacity - ProtectionDetailsSheet.tsx: ScrollView paddingBottom max(insets.bottom,16)+24 (was 40); backdrop + 'Fertig' Pressable→TouchableOpacity tsc clean. (Note: 'sheet doesn't scroll' — the bottom content was being clipped under the home indicator; the paddingBottom fix should resolve it. Broader UI polish deferred to a separate session — Task #10.) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { AppState, type AppStateStatus } from 'react-native';
|
|
import {
|
|
protection,
|
|
type ProtectionState,
|
|
type ProtectionPhase,
|
|
formatCooldownRemaining,
|
|
} from '../lib/protection';
|
|
|
|
const POLL_MS_ACTIVE_COOLDOWN = 5_000;
|
|
const POLL_MS_NORMAL = 30_000;
|
|
|
|
type UseProtectionStateReturn = {
|
|
state: ProtectionState | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
/** Live Countdown-String "23:59:42" während Cooldown läuft. */
|
|
cooldownRemainingFormatted: string;
|
|
/** Refetch ohne loading-flicker. */
|
|
refresh: () => Promise<void>;
|
|
/** Aktiviert ALLE Layers (legacy, beide Dialoge nacheinander). */
|
|
activate: () => Promise<{ allLayersOn: boolean; missingLayers: string[] }>;
|
|
/** Aktiviert NUR den URL-Filter (NEFilter). */
|
|
activateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>;
|
|
/** Aktiviert NUR Family Controls (= der Lock — danach nur per Cooldown abschaltbar). */
|
|
activateFamilyControls: () => Promise<{ enabled: boolean; error?: string }>;
|
|
/** Startet 24h Cooldown via Backend. UI muss Friction-Flow vorher durchlaufen. */
|
|
requestDeactivation: (reason?: string) => Promise<void>;
|
|
/** Bricht laufenden Cooldown ab. Schutz bleibt aktiv. */
|
|
cancelDeactivation: () => Promise<void>;
|
|
};
|
|
|
|
/**
|
|
* Single-Source-of-Truth-Hook für Protection-State.
|
|
*
|
|
* - Initial-Fetch on mount
|
|
* - Polling: alle 30s normal, 5s während aktivem Cooldown (Live-Countdown)
|
|
* - Refresh on AppState 'active' (User kommt aus Background zurück)
|
|
* - Layer-Change-Listener vom Native-Modul (Bypass-Detection)
|
|
*/
|
|
export function useProtectionState(): UseProtectionStateReturn {
|
|
const [state, setState] = useState<ProtectionState | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [tickSeconds, setTickSeconds] = useState<number>(0);
|
|
|
|
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const tickTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const prevCooldownActiveRef = useRef<boolean | null>(null);
|
|
|
|
const fetchState = useCallback(async (showLoading = false) => {
|
|
if (showLoading) setLoading(true);
|
|
try {
|
|
const next = await protection.getCombinedState();
|
|
const prevActive = prevCooldownActiveRef.current;
|
|
prevCooldownActiveRef.current = next.cooldown.active;
|
|
|
|
// Cooldown ist gerade von active → inactive gekippt: Auto-Disable prüfen.
|
|
if (prevActive === true && !next.cooldown.active) {
|
|
const didDisable = await protection.applyCooldownDisableIfElapsed();
|
|
if (didDisable) {
|
|
// Nativer State hat sich geändert → ein weiterer Fetch für konsistenten State.
|
|
const afterDisable = await protection.getCombinedState();
|
|
setState(afterDisable);
|
|
setTickSeconds(afterDisable.cooldown.remainingSeconds);
|
|
setError(null);
|
|
return;
|
|
}
|
|
}
|
|
|
|
setState(next);
|
|
setTickSeconds(next.cooldown.remainingSeconds);
|
|
setError(null);
|
|
} catch (e: any) {
|
|
setError(e?.message ?? 'unknown');
|
|
} finally {
|
|
if (showLoading) setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// Initial fetch
|
|
useEffect(() => {
|
|
fetchState(true);
|
|
}, [fetchState]);
|
|
|
|
// Adaptive poll-rate: 5s während Cooldown, 30s sonst
|
|
useEffect(() => {
|
|
const interval = state?.cooldown.active ? POLL_MS_ACTIVE_COOLDOWN : POLL_MS_NORMAL;
|
|
if (pollTimer.current) clearInterval(pollTimer.current);
|
|
pollTimer.current = setInterval(() => fetchState(false), interval);
|
|
return () => {
|
|
if (pollTimer.current) clearInterval(pollTimer.current);
|
|
};
|
|
}, [state?.cooldown.active, fetchState]);
|
|
|
|
// Live-Countdown-Tick (nur während Cooldown — 1s-Decrement client-side)
|
|
useEffect(() => {
|
|
if (!state?.cooldown.active) {
|
|
if (tickTimer.current) {
|
|
clearInterval(tickTimer.current);
|
|
tickTimer.current = null;
|
|
}
|
|
return;
|
|
}
|
|
tickTimer.current = setInterval(() => {
|
|
setTickSeconds((s) => Math.max(0, s - 1));
|
|
}, 1000);
|
|
return () => {
|
|
if (tickTimer.current) clearInterval(tickTimer.current);
|
|
};
|
|
}, [state?.cooldown.active]);
|
|
|
|
// AppState-Listener: Refresh + Auto-Disable wenn Cooldown elapsed ist.
|
|
// Guard in applyCooldownDisableIfElapsed: cooldownEndsAt muss gesetzt sein
|
|
// (= es lief je ein Cooldown) und remainingSeconds <= 0. Verhindert
|
|
// False-Positives wenn canDisableProtection im Initial-State true ist.
|
|
useEffect(() => {
|
|
const sub = AppState.addEventListener('change', async (status: AppStateStatus) => {
|
|
if (status !== 'active') return;
|
|
await protection.applyCooldownDisableIfElapsed();
|
|
await fetchState(false);
|
|
});
|
|
return () => sub.remove();
|
|
}, [fetchState]);
|
|
|
|
// Native Layer-Change-Listener (User schaltet VPN extern aus etc.)
|
|
useEffect(() => {
|
|
const sub = protection.addLayerChangeListener(() => fetchState(false));
|
|
return () => sub?.remove();
|
|
}, [fetchState]);
|
|
|
|
// ─── Public Actions ────────────────────────────────────────────────
|
|
|
|
const activate = useCallback(async () => {
|
|
const result = await protection.activate();
|
|
await fetchState(false);
|
|
return result;
|
|
}, [fetchState]);
|
|
|
|
const activateUrlFilter = useCallback(async () => {
|
|
const result = await protection.activateUrlFilter();
|
|
await fetchState(false);
|
|
return result;
|
|
}, [fetchState]);
|
|
|
|
const activateFamilyControls = useCallback(async () => {
|
|
const result = await protection.activateFamilyControls();
|
|
await fetchState(false);
|
|
return result;
|
|
}, [fetchState]);
|
|
|
|
const requestDeactivation = useCallback(
|
|
async (reason?: string) => {
|
|
await protection.requestDeactivation(reason);
|
|
await fetchState(false);
|
|
},
|
|
[fetchState],
|
|
);
|
|
|
|
const cancelDeactivation = useCallback(async () => {
|
|
await protection.cancelDeactivation();
|
|
await fetchState(false);
|
|
}, [fetchState]);
|
|
|
|
return {
|
|
state,
|
|
loading,
|
|
error,
|
|
cooldownRemainingFormatted: formatCooldownRemaining(tickSeconds),
|
|
refresh: () => fetchState(false),
|
|
activate,
|
|
activateUrlFilter,
|
|
activateFamilyControls,
|
|
requestDeactivation,
|
|
cancelDeactivation,
|
|
};
|
|
}
|
|
|
|
export type { ProtectionPhase };
|