The old streak was non-functional: streaks.current_days was always 0 (never computed/incremented), and the profile page read me.streak (0) + account created_at as the "since" date — showing "0 days protected since <signup>" for everyone. This is the DiGA key metric, so it had to be rebuilt. New model: optimistic protection-coverage based on actual VPN/MDM protection state, never resets to 0. - backend: append-only protection_state_log + migration; POST /api/protection/event (ingestion, deduped) + GET /api/protection/coverage (read-time compute, no cron); server-side cooldown_disable event on cooldown resolve. Generous >6h-off/day rule. - frontend: report protection on/off transitions (initial + flips, deduped) from useProtectionState; rewrote profile StreakSection → half-donut (protected vs unprotected) + progress bar (current streak → personal record) + empty state. - coverage starts fresh from deploy (no historical backfill — clean data for DiGA). - spec: docs/specs/protection-coverage-streak.md (shared contract). - old streaks/streak_events/profiles.streak left intact (coach/scores consumers). Also adds go-to-market one-pagers under docs/marketing/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Alert, AppState, Platform, type AppStateStatus } from 'react-native';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
protection,
|
|
type ProtectionState,
|
|
type ProtectionPhase,
|
|
formatCooldownRemaining,
|
|
} from '../lib/protection';
|
|
import { apiFetch } from '../lib/api';
|
|
import type { WebContentFilterResult } from '../modules/rebreak-protection';
|
|
|
|
const POLL_MS_ACTIVE_COOLDOWN = 5_000;
|
|
const POLL_MS_NORMAL = 30_000;
|
|
|
|
function isProtectionActive(phase: string): boolean {
|
|
return phase === 'active' || phase === 'cooldownActive' || phase === 'cooldownPending';
|
|
}
|
|
|
|
function resolveEventSource(state: ProtectionState): 'vpn' | 'mdm' {
|
|
if (state.layers.nefilterActive === true || state.mdmManaged) return 'mdm';
|
|
return 'vpn';
|
|
}
|
|
|
|
type UseProtectionStateReturn = {
|
|
state: ProtectionState | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
/** Live Countdown-String "23:59:42" während Cooldown läuft. */
|
|
cooldownRemainingFormatted: string;
|
|
/** True wenn Gerät als MDM-managed gilt (Backend + native NEFilter). */
|
|
mdmManaged: boolean;
|
|
/** 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 }>;
|
|
/**
|
|
* iOS Layer 2 — aktiviert den webContent-Filter (kuratierte Gambling-Domain-
|
|
* Liste des Geräte-Landes). Stilles Sicherheitsnetz; braucht aktive Family
|
|
* Controls. KEINE Auto-Trigger-Logik — explizit aufrufbare Capability.
|
|
* No-op auf Android/Web. Siehe TODO(layer2-gating) in lib/protection.ts.
|
|
*/
|
|
applyWebContentFilter: () => Promise<WebContentFilterResult>;
|
|
/** iOS Layer 2 — setzt den webContent-Filter zurück. Rührt den App-Lock nicht an. */
|
|
clearWebContentFilter: () => Promise<{ cleared: 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);
|
|
const lastReportedActiveRef = 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
|
|
// abgeschaltet wurde.
|
|
// Android: a11y-Service (Bedienungshilfe) kann sich nicht selbst
|
|
// deaktivieren → User braucht Settings-Deeplink.
|
|
// iOS: NEFilter + Family Controls werden von forceDisable() vollständig
|
|
// abgeschaltet — User muss NICHTS in den Einstellungen tun.
|
|
// Kein "Bedienungshilfe"-Text (gibt es auf iOS nicht), kein
|
|
// Settings-Button.
|
|
const showCooldownElapsedNotice = useCallback(() => {
|
|
if (cooldownDisabledNoticeShownRef.current) return;
|
|
cooldownDisabledNoticeShownRef.current = true;
|
|
if (Platform.OS === 'android') {
|
|
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(() => {});
|
|
},
|
|
},
|
|
],
|
|
);
|
|
} else {
|
|
Alert.alert(
|
|
t('blocker.cooldown_elapsed_title'),
|
|
t('blocker.cooldown_elapsed_message_ios'),
|
|
[{ text: t('common.ok'), style: 'cancel' }],
|
|
);
|
|
}
|
|
}, [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 + best-effort NEFilter-State an Backend reporten (1x pro App-Open)
|
|
useEffect(() => {
|
|
fetchState(true);
|
|
if (Platform.OS === 'ios') {
|
|
protection.isNeFilterActive().then((res) => {
|
|
apiFetch('/api/users/me/mdm-status', {
|
|
method: 'POST',
|
|
body: { mdmManaged: res.enabled },
|
|
}).catch(() => {});
|
|
});
|
|
}
|
|
}, [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]);
|
|
|
|
// Report protection-state transitions to the coverage log.
|
|
// Fires only on genuine active↔inactive flips; deduped via ref.
|
|
useEffect(() => {
|
|
if (state === null) return;
|
|
const active = isProtectionActive(state.phase);
|
|
if (lastReportedActiveRef.current === active) return;
|
|
lastReportedActiveRef.current = active;
|
|
const source = resolveEventSource(state);
|
|
apiFetch('/api/protection/event', { method: 'POST', body: { active, source } }).catch(() => {});
|
|
}, [state]);
|
|
|
|
// ─── 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]);
|
|
|
|
// iOS Layer 2 — webContent-Filter. TODO(layer2-gating): bislang nur explizit
|
|
// aufrufbar; die Auto-Trigger-Logik (an wenn NEURLFilter aus + Cooldown läuft)
|
|
// ist eine offene User-Design-Entscheidung.
|
|
const applyWebContentFilter = useCallback(async () => {
|
|
const result = await protection.applyWebContentFilter();
|
|
await fetchState(false);
|
|
return result;
|
|
}, [fetchState]);
|
|
|
|
const clearWebContentFilter = useCallback(async () => {
|
|
const result = await protection.clearWebContentFilter();
|
|
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]);
|
|
|
|
// MDM/NEFilter-Managed Mode ist iOS-spezifisch. Ohne Platform-Gate kann ein
|
|
// account-seitiges mdmManaged-Flag fälschlich Android-UI in den iOS-Path ziehen.
|
|
const mdmManaged =
|
|
Platform.OS === 'ios' &&
|
|
(state?.mdmManaged === true || state?.layers.nefilterActive === true || state?.layers.mdmManaged === true);
|
|
|
|
return {
|
|
state,
|
|
loading,
|
|
error,
|
|
cooldownRemainingFormatted: formatCooldownRemaining(tickSeconds),
|
|
mdmManaged,
|
|
refresh: () => fetchState(false),
|
|
activate,
|
|
activateUrlFilter,
|
|
activateFamilyControls,
|
|
applyWebContentFilter,
|
|
clearWebContentFilter,
|
|
requestDeactivation,
|
|
cancelDeactivation,
|
|
};
|
|
}
|
|
|
|
export type { ProtectionPhase };
|