rebreak-monorepo/apps/rebreak-native/hooks/useProtectionState.ts
chahinebrini b23bd6d29f feat(onboarding,protection): Duo-style flow + cooldown auto-disable fix + Family Controls live
## Duo-Style Onboarding (Foundation + alle Slides)

Self-contained Onboarding-Flow mit Lyra-Mascot ersetzt das Spotlight-POC vom
vorherigen Iteration. Slides leben unter `components/onboarding/slides/`.

- Foundation: OnboardingShell (Progress + ScrollView + sticky CTABar),
  LyraBubble (Rive-Avatar + animierte Speech-Bubble), SlideProgress, CTABar
- Slides: Welcome, Privacy (4 Versprechen), Nickname (inline + PATCH /me),
  DigaChoice (Ja/Nein-Branch), DigaCode (redeem-Endpoint + inline-Errors),
  Plan (Pro/Legend cards, monthly/yearly toggle, 2 Monate gratis, Härtefall-
  Mailto), Payment (RevenueCat-Dev-Stub bis Phase-0), Protection (activate +
  PermissionDeniedSheet-Wiring), Done (animierter Checkmark + Streak-Day-1)
- State-Machine in app/onboarding/index.tsx: 9 Slides, DiGA-Branch, Resume-
  on-launch via slideFromStep(me.onboardingStep)
- Routing-gate in (app)/_layout.tsx: step != 'done' → /onboarding
- Backend Profile.onboardingStep enum extended:
  welcome | account | plan | pre_protection | done (+ legacy nickname/block)
- Backend diga redeem: step='pre_protection' (NICHT 'done') — User muss noch
  durch Protection-Slide für NEFilter/VPN-Aktivierung
- Locale-Keys (de/en/fr/ar): onboarding.lyra.<slide>.body, .cta_primary,
  Plan-Tier-Details (3,99/7,99 €/Mo, 39,90/79,90 €/Jahr mit 2 Monaten gratis),
  Härtefall-Link, DiGA-Code-Errors, Protection-Feat-Descriptions

## Cooldown Auto-Disable Race-Fix

Bug: nach Cooldown-Ablauf bleib URL-Filter installiert (NEFilter in iOS-
Settings sichtbar als "Läuft..."). Root-cause: `/api/cooldown/status` GET
auto-resolved beim ersten expired-Hit; zweiter Call in
applyCooldownDisableIfElapsed sah cooldownEndsAt=null → bail → forceDisable
nie aufgerufen.

- useProtectionState.fetchState: lokalen next.cooldown.endsAt state nutzen
  statt redundantem API-Call. Atomarer, race-frei.
- AppState-Listener-Path unverändert (dort ist es der erste API-Call, kein
  Race).
- lib/protection.forceDisable: console.log für Debug-Visibility.

## iOS NEFilter Robust-Disable (Native)

`removeFromPreferences()` alleine ist auf iOS 18+ unzuverlässig — Settings-
UI zeigt "Läuft..." obwohl Provider beendet sein sollte. 2-Step-Pattern:

  1. loadFromPreferences
  2. isEnabled = false + saveToPreferences (stoppt Filter-Daemon)
  3. removeFromPreferences (Config-Eintrag aus Settings)

Quelle: Apple-Developer-Forums + eigene Empirie. Pattern wird auch in
PermissionDeniedSheet's resetUrlFilter genutzt (analog).

## Family Controls jetzt immer aktiv

Apple-Entitlement seit 2026-05 für ReBreak approved (TestFlight-akzeptiert).
`familyControlsEnabled: true` hart in app.config.ts (kein Env-Var-Gating mehr).
"Bald verfügbar"-Placeholder in blocker.tsx entfernt — App-Lock-Toggle ist
jetzt voll funktional auf iOS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 17:48:05 +02:00

217 lines
8.0 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { Alert, AppState, type AppStateStatus } from 'react-native';
import { useTranslation } from 'react-i18next';
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 { t } = useTranslation();
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);
// Verhindert Mehrfach-Alert wenn fetchState + AppState-Listener beide kurz
// hintereinander applyCooldownDisableIfElapsed → true sehen.
const cooldownDisabledNoticeShownRef = useRef(false);
// Freundlicher Hinweis nachdem der Cooldown abgelaufen ist und der Schutz
// (inkl. Tamper-Lock) abgeschaltet wurde. Android: a11y-Service kann sich
// nicht selbst deaktivieren → User zu den Einstellungen leiten.
const showCooldownElapsedNotice = useCallback(() => {
if (cooldownDisabledNoticeShownRef.current) return;
cooldownDisabledNoticeShownRef.current = true;
Alert.alert(
t('blocker.cooldown_elapsed_title'),
t('blocker.cooldown_elapsed_message'),
[
{ text: t('common.ok'), style: 'cancel' },
{
text: t('blocker.cooldown_elapsed_open_settings'),
onPress: () => {
protection.openSystemSettings('accessibility').catch(() => {});
},
},
],
);
}, [t]);
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.
// Wir nutzen LOKAL den Cooldown-State aus dem eben gefetchten `next` —
// KEIN redundanter API-Call zu /api/cooldown/status. Grund: der Backend-
// GET resolved den Cooldown autom. beim ersten expired-Hit. Ein zweiter
// Call würde dann cooldownEndsAt=null returnen → false bail → Filter
// bleibt installiert. Local-state-check ist atomar + race-frei.
if (
prevActive === true &&
!next.cooldown.active &&
next.cooldown.endsAt !== null
) {
await protection.forceDisable();
showCooldownElapsedNotice();
// 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);
}
}, [showCooldownElapsedNotice]);
// 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 während
// App backgrounded war. applyCooldownDisableIfElapsed macht hier den initialen
// API-Call (= keine Race-Condition mit anderem GET, weil das der erste post-
// background Call ist). fetchState danach räumt den State auf — der neue
// Guard im fetchState (`next.cooldown.endsAt !== null`) verhindert ein
// doppeltes forceDisable wenn der AppState-Listener schon disabled hat.
useEffect(() => {
const sub = AppState.addEventListener('change', async (status: AppStateStatus) => {
if (status !== 'active') return;
const didDisable = await protection.applyCooldownDisableIfElapsed();
if (didDisable) showCooldownElapsedNotice();
await fetchState(false);
});
return () => sub.remove();
}, [fetchState, showCooldownElapsedNotice]);
// 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 };