diff --git a/apps/rebreak-native/NEXT_RELEASE.md b/apps/rebreak-native/NEXT_RELEASE.md
index 18940e9..dc9f572 100644
--- a/apps/rebreak-native/NEXT_RELEASE.md
+++ b/apps/rebreak-native/NEXT_RELEASE.md
@@ -1,15 +1,6 @@
# Next Release
-## New
-- Device detail view: tap any device on the Devices page to open a sheet with its connection status, when it was connected, and a protected/unprotected coverage donut (same visual as your profile)
-- "New device connected" push notification — when a Mac or Windows computer is bound via Rebreak Magic, your phone(s) get notified
-- Devices page now updates in real time the moment a new computer is paired — no manual refresh needed
-- Onboarding now sets up the full protection using the exact same guided, gated step flow as the protection screen (single source of truth) — Android: VPN → Device Administrator → Accessibility (strict order: the tamper lock has to come last, otherwise it would block the device-admin screen); iOS: App Lock → Screen Time passcode → content filter. Previously the device-admin / screen-time hardening steps only existed in the protection screen after onboarding.
-
-## Changed
-- Device list now shows device-specific icons (iPhone / Android / MacBook / PC) instead of generic outlines
-- Stationary protection (Mac/Windows) now runs exclusively via Rebreak Magic — the manual offline profile download has been removed. The offline profile would have shipped the removal password in plain text inside the file (bypass risk); with Magic the lock password stays server-side and is never shown to the user.
-- Mac DNS profile hardened with `ProhibitDisablement` — the filter can no longer be toggled off in System Settings.
-
-## Fixed
-- Android onboarding: if the VPN permission dialog failed to open (e.g. another always-on VPN active, work profile, or certain OEM quirks), the protection step would silently get stuck with no dialog and no error message — especially on Play Store builds, where the underlying error was swallowed. The step now surfaces the real error and offers a retry instead of dead-ending.
+## Improved
+- Protection lock (Android) is now surgical: it only blocks Rebreak's *own* sensitive screens — deactivating Rebreak's device administrator, turning off Rebreak's accessibility service, and disconnecting or changing Rebreak's VPN. Managing, force-stopping or uninstalling *other* apps is completely unaffected, and the accessibility-services list, device-admin list and other apps' info pages stay fully navigable. (Previously the lock could over-block entire settings lists, which also risked a Play review rejection.)
+- Uninstall protection now relies on the device administrator: the OS itself greys out "Uninstall" and "Force stop" for an active admin, and the only bypass — deactivating the admin — stays locked by the accessibility service. Net effect: Rebreak can't be removed, but you are never blocked from removing or force-stopping any other app.
+- Accessibility onboarding guide: while you're in Android's settings, a passive on-screen hint ("Rebreak: …") now appears at the bottom to point you to the right toggle. After you switch the service on, Settings is reset to its home screen (no leftover deep page or search term) and you're routed back to the app after a short moment so it reliably detects the service.
diff --git a/apps/rebreak-native/android/app/src/main/res/values/strings.xml b/apps/rebreak-native/android/app/src/main/res/values/strings.xml
index 2c02857..3129997 100644
--- a/apps/rebreak-native/android/app/src/main/res/values/strings.xml
+++ b/apps/rebreak-native/android/app/src/main/res/values/strings.xml
@@ -3,5 +3,5 @@
cover
false
Sichert deinen Schutz gegen impulsives Abschalten ab: Solange App-Lock aktiv ist, kann das ReBreak-VPN nicht in den Einstellungen deaktiviert und die App nicht deinstalliert werden. Das Blockieren von Glücksspielseiten selbst übernimmt das VPN — diese Berechtigung sichert es nur. Du kannst den Schutz jederzeit über die Abkühlphase in der App beenden.
- Sichert den Schutz gegen Abschalten ab
+ ReBreak Schutz
\ No newline at end of file
diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts
index b21cf95..529def4 100644
--- a/apps/rebreak-native/app.config.ts
+++ b/apps/rebreak-native/app.config.ts
@@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
ios: {
supportsTablet: true,
bundleIdentifier: MAIN_BUNDLE,
- buildNumber: "84",
+ buildNumber: "87",
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
@@ -62,7 +62,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
android: {
package: "org.rebreak.app",
- versionCode: 64,
+ versionCode: 67,
// Firebase / FCM-v1-Credentials: Pflicht ab Expo SDK 53 für Android-Push.
// Enthält client-config für beide Packages (org.rebreak.app + .dev) und
// ist NICHT geheim (API-Key per Package-Signing-Fingerprint restricted) —
@@ -93,6 +93,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
"FOREGROUND_SERVICE_MICROPHONE",
"FOREGROUND_SERVICE_PHONE_CALL",
"USE_FULL_SCREEN_INTENT",
+ // Nutzungszugriff: erlaubt das Erkennen des aktuellen Settings-Screens
+ // (UsageStatsManager) → state-aware a11y-Setup-Guide. User muss es manuell
+ // unter „Nutzungsdaten-Zugriff" freigeben (Special-Access-Permission).
+ "PACKAGE_USAGE_STATS",
],
},
diff --git a/apps/rebreak-native/app/(app)/blocker.tsx b/apps/rebreak-native/app/(app)/blocker.tsx
index d4df037..635e898 100644
--- a/apps/rebreak-native/app/(app)/blocker.tsx
+++ b/apps/rebreak-native/app/(app)/blocker.tsx
@@ -1,6 +1,5 @@
-import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
-import { AppState, Platform, ScrollView, Text, TouchableOpacity, View, Alert, ActivityIndicator } from 'react-native';
-import { Ionicons } from '@expo/vector-icons';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { AppState, Platform, ScrollView, View, Alert, ActivityIndicator } from 'react-native';
import { useRouter } from 'expo-router';
import { useBottomTabBarHeight } from 'react-native-bottom-tabs';
import { useTranslation } from 'react-i18next';
@@ -15,6 +14,7 @@ import { ProtectionDetailsSheet } from '../../components/blocker/ProtectionDetai
import { DeactivationExplainerSheet } from '../../components/blocker/DeactivationExplainerSheet';
import { PermissionDeniedSheet } from '../../components/PermissionDeniedSheet';
import { ProtectionOffSheet } from '../../components/ProtectionOffSheet';
+import { IosUnsupervisedSetupFlow, AndroidSetupFlow } from '../../components/blocker/SetupFlows';
import { useProtectionState } from '../../hooks/useProtectionState';
import { useCustomDomains } from '../../hooks/useCustomDomains';
import { useBlocklistSync } from '../../hooks/useBlocklistSync';
@@ -510,540 +510,3 @@ export default function BlockerScreen() {
);
}
-
-// ─── iOS Unsupervised 3-Step Setup Flow ──────────────────────────────────────
-
-type SetupFlowProps = {
- familyControlsActive: boolean;
- screentimeCode: string | null;
- screentimeConfirmed: boolean;
- screentimeSaving: boolean;
- urlFilterActive: boolean;
- onActivateFamilyControls: () => Promise<{ enabled: boolean; error?: string }>;
- onGenerateScreentimeCode: () => void;
- onConfirmScreentime: () => void;
- onActivateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>;
- colors: ReturnType;
- t: ReturnType['t'];
-};
-
-function IosUnsupervisedSetupFlow({
- familyControlsActive,
- screentimeCode,
- screentimeConfirmed,
- screentimeSaving,
- urlFilterActive,
- onActivateFamilyControls,
- onGenerateScreentimeCode,
- onConfirmScreentime,
- onActivateUrlFilter,
- colors,
- t,
-}: SetupFlowProps) {
- const step1Done = familyControlsActive;
- const step2Done = screentimeConfirmed;
- const step3Done = urlFilterActive;
-
- return (
-
-
-
-
-
- );
-}
-
-function SetupStep1({
- done,
- onActivate,
- colors,
- t,
-}: {
- done: boolean;
- onActivate: () => Promise<{ enabled: boolean; error?: string }>;
- colors: ReturnType;
- t: ReturnType['t'];
-}) {
- const [busy, setBusy] = useState(false);
-
- async function handlePress() {
- if (done || busy) return;
- setBusy(true);
- try { await onActivate(); } finally { setBusy(false); }
- }
-
- return (
-
- {!done && (
-
- {busy
- ?
- : {t('blocker.setup_step1_cta')}
- }
-
- )}
-
- );
-}
-
-function SetupStep2({
- unlocked,
- code,
- confirmed,
- saving,
- onGenerate,
- onConfirm,
- colors,
- t,
-}: {
- unlocked: boolean;
- code: string | null;
- confirmed: boolean;
- saving: boolean;
- onGenerate: () => void;
- onConfirm: () => void;
- colors: ReturnType;
- t: ReturnType['t'];
-}) {
- return (
-
- {unlocked && !confirmed && (
-
- {!code ? (
-
-
- {t('blocker.screentime_generate_cta')}
-
-
- ) : (
-
-
-
- {t('blocker.screentime_code_label')}
-
-
- {code}
-
-
-
- {[
- t('blocker.screentime_step1'),
- t('blocker.screentime_step2'),
- t('blocker.screentime_step3'),
- ].map((step, i) => (
-
- {step}
-
- ))}
-
- {t('blocker.screentime_step_note')}
-
-
-
- {saving
- ?
- : {t('blocker.screentime_confirm_cta')}
- }
-
-
- )}
-
- )}
-
- );
-}
-
-function SetupStep3({
- unlocked,
- done,
- onActivate,
- colors,
- t,
-}: {
- unlocked: boolean;
- done: boolean;
- onActivate: () => Promise<{ enabled: boolean; error?: string }>;
- colors: ReturnType;
- t: ReturnType['t'];
-}) {
- const [busy, setBusy] = useState(false);
-
- async function handlePress() {
- if (done || busy) return;
- setBusy(true);
- try { await onActivate(); } finally { setBusy(false); }
- }
-
- return (
-
- {unlocked && !done && (
-
-
-
-
- {t('blocker.setup_step3_warning')}
-
-
-
- {busy
- ?
- : {t('blocker.setup_step3_cta')}
- }
-
-
- )}
-
- );
-}
-
-// ─── Android 3-Step Setup Flow ───────────────────────────────────────────────
-
-type AndroidSetupFlowProps = {
- vpnActive: boolean;
- // = a11y-Service enabled UND Tamper-Lock armed (appDeletionLock). Nur "enabled"
- // reicht NICHT — der a11y-Service ist ohne armed-Flag komplett passiv.
- accessibilityLocked: boolean;
- deviceAdminActive: boolean;
- onActivateVpn: () => Promise<{ enabled: boolean; error?: string }>;
- onActivateAccessibility: () => Promise;
- onRequestDeviceAdmin: () => Promise<{ launched: boolean }>;
- colors: ReturnType;
- t: ReturnType['t'];
-};
-
-function AndroidSetupFlow({
- vpnActive,
- accessibilityLocked,
- deviceAdminActive,
- onActivateVpn,
- onActivateAccessibility,
- onRequestDeviceAdmin,
- colors,
- t,
-}: AndroidSetupFlowProps) {
- // Reihenfolge KRITISCH: VPN → Geräteadmin → a11y. a11y MUSS zuletzt, weil der
- // Tamper-Lock (sobald armed) die Geräteadmin-Seite blockt — sonst kann der
- // User den Admin gar nicht mehr aktivieren.
- const vpnDone = vpnActive;
- const adminDone = deviceAdminActive;
- const a11yDone = accessibilityLocked;
-
- return (
-
-
- {/* Display-Step 2 = Geräteadmin (Komponente AndroidStep3, i18n android_step3_*). */}
-
- {/* Display-Step 3 = a11y / ReBreak-Schutz (Komponente AndroidStep2, i18n android_step2_*). */}
-
-
- );
-}
-
-function AndroidStep1({
- done,
- onActivate,
- colors,
- t,
-}: {
- done: boolean;
- onActivate: () => Promise<{ enabled: boolean; error?: string }>;
- colors: ReturnType;
- t: ReturnType['t'];
-}) {
- const [busy, setBusy] = useState(false);
-
- async function handlePress() {
- if (done || busy) return;
- setBusy(true);
- try { await onActivate(); } finally { setBusy(false); }
- }
-
- return (
-
- {!done && (
-
- {busy
- ?
- : {t('blocker.android_step1_cta')}
- }
-
- )}
-
- );
-}
-
-function AndroidStep2({
- unlocked,
- done,
- onActivate,
- colors,
- t,
-}: {
- unlocked: boolean;
- done: boolean;
- onActivate: () => Promise;
- colors: ReturnType;
- t: ReturnType['t'];
-}) {
- const [busy, setBusy] = useState(false);
-
- async function handlePress() {
- if (busy) return;
- setBusy(true);
- try { await onActivate(); } finally { setBusy(false); }
- }
-
- return (
-
- {unlocked && !done && (
-
-
- {[
- t('blocker.android_step2_instruction1'),
- t('blocker.android_step2_instruction2'),
- t('blocker.android_step2_instruction3'),
- ].map((line, i) => (
-
- {line}
-
- ))}
-
-
- {busy
- ?
- : {t('blocker.android_step2_cta')}
- }
-
-
- {t('blocker.android_step2_note')}
-
-
- )}
-
- );
-}
-
-function AndroidStep3({
- unlocked,
- done,
- onRequestAdmin,
- colors,
- t,
-}: {
- unlocked: boolean;
- done: boolean;
- onRequestAdmin: () => Promise<{ launched: boolean }>;
- colors: ReturnType;
- t: ReturnType['t'];
-}) {
- const [busy, setBusy] = useState(false);
-
- async function handlePress() {
- if (done || busy) return;
- setBusy(true);
- try { await onRequestAdmin(); } finally { setBusy(false); }
- }
-
- return (
-
- {unlocked && !done && (
-
-
-
-
- {t('blocker.android_step3_warning')}
-
-
-
- {busy
- ?
- : {t('blocker.android_step3_cta')}
- }
-
-
- )}
-
- );
-}
-
-function SetupStepCard({
- stepNumber,
- title,
- subtitle,
- done,
- unlocked,
- lockedHint,
- colors,
- children,
-}: {
- stepNumber: number;
- title: string;
- subtitle: string;
- done: boolean;
- unlocked: boolean;
- lockedHint?: string;
- colors: ReturnType;
- children?: ReactNode;
-}) {
- const borderColor = done ? '#86efac' : unlocked ? colors.border : colors.border;
- const cardBg = done ? '#f0fdf4' : colors.surface;
- const numberBg = done ? '#dcfce7' : unlocked ? colors.surfaceElevated : colors.surfaceElevated;
- const numberColor = done ? colors.success : unlocked ? colors.text : colors.textMuted;
- const titleColor = done ? colors.success : unlocked ? colors.text : colors.textMuted;
-
- return (
-
-
-
- {done
- ?
- : {stepNumber}
- }
-
-
- {title}
- {!!subtitle && (
- {subtitle}
- )}
-
- {done && }
-
- {lockedHint && !done && (
-
-
- {lockedHint}
-
- )}
- {children}
-
- );
-}
-
diff --git a/apps/rebreak-native/app/onboarding/index.tsx b/apps/rebreak-native/app/onboarding/index.tsx
index eed7e23..daf46fc 100644
--- a/apps/rebreak-native/app/onboarding/index.tsx
+++ b/apps/rebreak-native/app/onboarding/index.tsx
@@ -1,6 +1,7 @@
-import { useMemo, useState } from 'react';
+import { useEffect, useMemo, useState } from 'react';
import { useRouter } from 'expo-router';
import { useMe, invalidateMe, type OnboardingStep } from '../../hooks/useMe';
+import { useLyraVoiceStore } from '../../stores/lyraVoice';
import { apiFetch } from '../../lib/api';
import { WelcomeSlide } from '../../components/onboarding/slides/WelcomeSlide';
import { PrivacySlide } from '../../components/onboarding/slides/PrivacySlide';
@@ -89,6 +90,19 @@ export default function OnboardingScreen() {
);
const [slide, setSlide] = useState(initialSlide);
+ // Lyra-Voice fürs Onboarding automatisch an — sie begleitet/spricht jede Slide
+ // vor (User kann per Volume-Button stummschalten). Beim Verlassen den vorigen
+ // Wert wiederherstellen, damit der App-weite Default unangetastet bleibt.
+ const voiceReady = useLyraVoiceStore((s) => s.ready);
+ useEffect(() => {
+ if (!voiceReady) return;
+ const prev = useLyraVoiceStore.getState().enabled;
+ void useLyraVoiceStore.getState().setEnabled(true);
+ return () => {
+ void useLyraVoiceStore.getState().setEnabled(prev);
+ };
+ }, [voiceReady]);
+
function goToLinearNext() {
const idx = LINEAR_ORDER.indexOf(slide);
if (idx < 0 || idx === LINEAR_ORDER.length - 1) {
@@ -104,10 +118,11 @@ export default function OnboardingScreen() {
setSlide(LINEAR_ORDER[idx - 1]);
}
- // Back erlaubt nur auf reinen Info-/Auswahl-Slides. NICHT auf:
- // welcome (erste), done (final), diga_code (hat eigenen onBack),
- // protection (interne Phasen + persistierter Backend-Step + Permission-Flow).
- const BACK_ALLOWED: Slide[] = ['privacy', 'nickname', 'diga_choice', 'plan', 'payment'];
+ // Back erlaubt auf Info-/Auswahl-Slides + protection. NICHT auf:
+ // welcome (erste), done (final), diga_code (hat eigenen onBack).
+ // protection: seit dem Card-Flow keine internen Phasen mehr → Back zur vorigen
+ // Slide ist unkritisch (aktivierter Schutz bleibt via Layer-State erhalten).
+ const BACK_ALLOWED: Slide[] = ['privacy', 'nickname', 'diga_choice', 'plan', 'payment', 'protection'];
const canGoBack = BACK_ALLOWED.includes(slide);
function exitToApp() {
diff --git a/apps/rebreak-native/components/RiveAvatar.tsx b/apps/rebreak-native/components/RiveAvatar.tsx
index 8f55344..5c50484 100644
--- a/apps/rebreak-native/components/RiveAvatar.tsx
+++ b/apps/rebreak-native/components/RiveAvatar.tsx
@@ -104,7 +104,16 @@ export function RiveAvatar({ emotion, size = 'md', showLabel = false, fallback =
const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900);
return () => clearTimeout(t);
}
- setCurrentAnim(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle);
+ const anim = EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle;
+ setCurrentAnim(anim);
+ // Intro-Emotions wie 'empathy' ('01 Wave 1') / 'thinking' ('WALK') sind
+ // One-Shots — ohne Übergang frieren sie auf dem letzten Frame ein und wirken
+ // "nicht animiert". Nach dem Intro in den Idle Loop fallen, damit der Avatar
+ // lebendig bleibt (idle loopt selbst).
+ if (anim !== EMOTION_ANIMATIONS.idle) {
+ const t = setTimeout(() => setCurrentAnim(EMOTION_ANIMATIONS.idle), 2600);
+ return () => clearTimeout(t);
+ }
}, [resolvedEmotion]);
return (
diff --git a/apps/rebreak-native/components/blocker/SetupFlows.tsx b/apps/rebreak-native/components/blocker/SetupFlows.tsx
new file mode 100644
index 0000000..dfff200
--- /dev/null
+++ b/apps/rebreak-native/components/blocker/SetupFlows.tsx
@@ -0,0 +1,544 @@
+import { useState, type ReactNode } from "react";
+import { View, Text, TouchableOpacity, ActivityIndicator } from "react-native";
+import { Ionicons } from "@expo/vector-icons";
+
+// Geteilter Schutz-Setup-Flow (Reihenfolge + Gating). Quelle der Wahrheit für
+// Blocker UND Onboarding — NIE die Reihenfolge hier ändern ohne beide zu prüfen.
+// Android: VPN → Geräteadmin → a11y (a11y zuletzt, sonst blockt der Tamper-Lock
+// die Admin-Seite). iOS: App-Lock → Bildschirmzeit → URL-Filter.
+// ─── iOS Unsupervised 3-Step Setup Flow ──────────────────────────────────────
+
+type SetupFlowProps = {
+ familyControlsActive: boolean;
+ screentimeCode: string | null;
+ screentimeConfirmed: boolean;
+ screentimeSaving: boolean;
+ urlFilterActive: boolean;
+ onActivateFamilyControls: () => Promise<{ enabled: boolean; error?: string }>;
+ onGenerateScreentimeCode: () => void;
+ onConfirmScreentime: () => void;
+ onActivateUrlFilter: () => Promise<{ enabled: boolean; error?: string }>;
+ colors: ReturnType;
+ t: ReturnType['t'];
+};
+
+export function IosUnsupervisedSetupFlow({
+ familyControlsActive,
+ screentimeCode,
+ screentimeConfirmed,
+ screentimeSaving,
+ urlFilterActive,
+ onActivateFamilyControls,
+ onGenerateScreentimeCode,
+ onConfirmScreentime,
+ onActivateUrlFilter,
+ colors,
+ t,
+}: SetupFlowProps) {
+ const step1Done = familyControlsActive;
+ const step2Done = screentimeConfirmed;
+ const step3Done = urlFilterActive;
+
+ return (
+
+
+
+
+
+ );
+}
+
+function SetupStep1({
+ done,
+ onActivate,
+ colors,
+ t,
+}: {
+ done: boolean;
+ onActivate: () => Promise<{ enabled: boolean; error?: string }>;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ const [busy, setBusy] = useState(false);
+
+ async function handlePress() {
+ if (done || busy) return;
+ setBusy(true);
+ try { await onActivate(); } finally { setBusy(false); }
+ }
+
+ return (
+
+ {!done && (
+
+ {busy
+ ?
+ : {t('blocker.setup_step1_cta')}
+ }
+
+ )}
+
+ );
+}
+
+function SetupStep2({
+ unlocked,
+ code,
+ confirmed,
+ saving,
+ onGenerate,
+ onConfirm,
+ colors,
+ t,
+}: {
+ unlocked: boolean;
+ code: string | null;
+ confirmed: boolean;
+ saving: boolean;
+ onGenerate: () => void;
+ onConfirm: () => void;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ return (
+
+ {unlocked && !confirmed && (
+
+ {!code ? (
+
+
+ {t('blocker.screentime_generate_cta')}
+
+
+ ) : (
+
+
+
+ {t('blocker.screentime_code_label')}
+
+
+ {code}
+
+
+
+ {[
+ t('blocker.screentime_step1'),
+ t('blocker.screentime_step2'),
+ t('blocker.screentime_step3'),
+ ].map((step, i) => (
+
+ {step}
+
+ ))}
+
+ {t('blocker.screentime_step_note')}
+
+
+
+ {saving
+ ?
+ : {t('blocker.screentime_confirm_cta')}
+ }
+
+
+ )}
+
+ )}
+
+ );
+}
+
+function SetupStep3({
+ unlocked,
+ done,
+ onActivate,
+ colors,
+ t,
+}: {
+ unlocked: boolean;
+ done: boolean;
+ onActivate: () => Promise<{ enabled: boolean; error?: string }>;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ const [busy, setBusy] = useState(false);
+
+ async function handlePress() {
+ if (done || busy) return;
+ setBusy(true);
+ try { await onActivate(); } finally { setBusy(false); }
+ }
+
+ return (
+
+ {unlocked && !done && (
+
+
+
+
+ {t('blocker.setup_step3_warning')}
+
+
+
+ {busy
+ ?
+ : {t('blocker.setup_step3_cta')}
+ }
+
+
+ )}
+
+ );
+}
+
+// ─── Android 3-Step Setup Flow ───────────────────────────────────────────────
+
+type AndroidSetupFlowProps = {
+ vpnActive: boolean;
+ // = a11y-Service enabled UND Tamper-Lock armed (appDeletionLock). Nur "enabled"
+ // reicht NICHT — der a11y-Service ist ohne armed-Flag komplett passiv.
+ accessibilityLocked: boolean;
+ deviceAdminActive: boolean;
+ onActivateVpn: () => Promise<{ enabled: boolean; error?: string }>;
+ onActivateAccessibility: () => Promise;
+ onRequestDeviceAdmin: () => Promise<{ launched: boolean }>;
+ colors: ReturnType;
+ t: ReturnType['t'];
+};
+
+export function AndroidSetupFlow({
+ vpnActive,
+ accessibilityLocked,
+ deviceAdminActive,
+ onActivateVpn,
+ onActivateAccessibility,
+ onRequestDeviceAdmin,
+ colors,
+ t,
+}: AndroidSetupFlowProps) {
+ // Reihenfolge KRITISCH: VPN → Geräteadmin → a11y. a11y MUSS zuletzt, weil der
+ // Tamper-Lock (sobald armed) die Geräteadmin-Seite blockt — sonst kann der
+ // User den Admin gar nicht mehr aktivieren.
+ const vpnDone = vpnActive;
+ const adminDone = deviceAdminActive;
+ const a11yDone = accessibilityLocked;
+
+ return (
+
+
+ {/* Display-Step 2 = Geräteadmin (Komponente AndroidStep3, i18n android_step3_*). */}
+
+ {/* Display-Step 3 = a11y / ReBreak-Schutz (Komponente AndroidStep2, i18n android_step2_*). */}
+
+
+ );
+}
+
+function AndroidStep1({
+ done,
+ onActivate,
+ colors,
+ t,
+}: {
+ done: boolean;
+ onActivate: () => Promise<{ enabled: boolean; error?: string }>;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ const [busy, setBusy] = useState(false);
+
+ async function handlePress() {
+ if (done || busy) return;
+ setBusy(true);
+ try { await onActivate(); } finally { setBusy(false); }
+ }
+
+ return (
+
+ {!done && (
+
+ {busy
+ ?
+ : {t('blocker.android_step1_cta')}
+ }
+
+ )}
+
+ );
+}
+
+function AndroidStep2({
+ unlocked,
+ done,
+ onActivate,
+ colors,
+ t,
+}: {
+ unlocked: boolean;
+ done: boolean;
+ onActivate: () => Promise;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ const [busy, setBusy] = useState(false);
+
+ async function handlePress() {
+ if (busy) return;
+ setBusy(true);
+ try { await onActivate(); } finally { setBusy(false); }
+ }
+
+ return (
+
+ {unlocked && !done && (
+
+
+ {[
+ t('blocker.android_step2_instruction1'),
+ t('blocker.android_step2_instruction2'),
+ t('blocker.android_step2_instruction3'),
+ ].map((line, i) => (
+
+ {line}
+
+ ))}
+
+
+ {busy
+ ?
+ : {t('blocker.android_step2_cta')}
+ }
+
+
+ {t('blocker.android_step2_note')}
+
+
+ )}
+
+ );
+}
+
+function AndroidStep3({
+ unlocked,
+ done,
+ onRequestAdmin,
+ colors,
+ t,
+}: {
+ unlocked: boolean;
+ done: boolean;
+ onRequestAdmin: () => Promise<{ launched: boolean }>;
+ colors: ReturnType;
+ t: ReturnType['t'];
+}) {
+ const [busy, setBusy] = useState(false);
+
+ async function handlePress() {
+ if (done || busy) return;
+ setBusy(true);
+ try { await onRequestAdmin(); } finally { setBusy(false); }
+ }
+
+ return (
+
+ {unlocked && !done && (
+
+
+
+
+ {t('blocker.android_step3_warning')}
+
+
+
+ {busy
+ ?
+ : {t('blocker.android_step3_cta')}
+ }
+
+
+ )}
+
+ );
+}
+
+function SetupStepCard({
+ stepNumber,
+ title,
+ subtitle,
+ done,
+ unlocked,
+ lockedHint,
+ colors,
+ children,
+}: {
+ stepNumber: number;
+ title: string;
+ subtitle: string;
+ done: boolean;
+ unlocked: boolean;
+ lockedHint?: string;
+ colors: ReturnType;
+ children?: ReactNode;
+}) {
+ const borderColor = done ? '#86efac' : unlocked ? colors.border : colors.border;
+ const cardBg = done ? '#f0fdf4' : colors.surface;
+ const numberBg = done ? '#dcfce7' : unlocked ? colors.surfaceElevated : colors.surfaceElevated;
+ const numberColor = done ? colors.success : unlocked ? colors.text : colors.textMuted;
+ const titleColor = done ? colors.success : unlocked ? colors.text : colors.textMuted;
+
+ return (
+
+
+
+ {done
+ ?
+ : {stepNumber}
+ }
+
+
+ {title}
+ {!!subtitle && (
+ {subtitle}
+ )}
+
+ {done && }
+
+ {lockedHint && !done && (
+
+
+ {lockedHint}
+
+ )}
+ {children}
+
+ );
+}
+
diff --git a/apps/rebreak-native/components/devices/DeviceProgressBar.tsx b/apps/rebreak-native/components/devices/DeviceProgressBar.tsx
index efa587e..d2f09e1 100644
--- a/apps/rebreak-native/components/devices/DeviceProgressBar.tsx
+++ b/apps/rebreak-native/components/devices/DeviceProgressBar.tsx
@@ -7,9 +7,11 @@ interface DeviceProgressBarProps {
count: number;
max: number;
atLimit: boolean;
+ /** Optionales Label (z.B. "Mobil" / "Computer") statt generischem progress_label */
+ label?: string;
}
-export function DeviceProgressBar({ count, max, atLimit }: DeviceProgressBarProps) {
+export function DeviceProgressBar({ count, max, atLimit, label }: DeviceProgressBarProps) {
const { t } = useTranslation();
const colors = useColors();
const fillAnim = useRef(new Animated.Value(0)).current;
@@ -37,8 +39,8 @@ export function DeviceProgressBar({ count, max, atLimit }: DeviceProgressBarProp
}}
>
{atLimit
- ? t('devices.progress_at_limit')
- : t('devices.progress_label', { count, max })}
+ ? (label ? `${label} — ${t('devices.progress_at_limit')}` : t('devices.progress_at_limit'))
+ : (label ?? t('devices.progress_label', { count, max }))}
void;
+ onClose: () => void;
+}) {
+ const { t } = useTranslation();
+ const colors = useColors();
+ const [checked, setChecked] = useState(false);
+
+ // Häkchen bei jedem Öffnen zurücksetzen — sonst klickt man beim nächsten Step
+ // mit schon-gesetztem Haken blind durch (genau das wollen wir verhindern).
+ useEffect(() => {
+ if (visible) setChecked(false);
+ }, [visible]);
+
+ return (
+
+ {/* FormSheet padded nur den Titel, nicht die children → hier selbst polstern. */}
+
+
+
+
+ {body}
+
+
+
+ {steps && steps.length > 0 && (
+
+ {steps.map((step, i) => (
+
+
+ {i + 1}
+
+
+ {step}
+
+
+ ))}
+
+ )}
+
+ {screenshot != null && (
+
+
+
+
+ {!!indicatorCaption && (
+
+
+
+ {indicatorCaption}
+
+
+ )}
+
+ )}
+
+ setChecked((c) => !c)}
+ style={{
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 12,
+ marginTop: 20,
+ paddingVertical: 4,
+ }}
+ >
+
+
+ {t('onboarding.protection_confirm.checkbox')}
+
+
+
+
+
+ {t('onboarding.protection_confirm.cta')}
+
+
+
+
+ );
+}
diff --git a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx
index 7bb824c..67b7af0 100644
--- a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx
+++ b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx
@@ -1,48 +1,43 @@
import { useEffect, useRef, useState } from 'react';
-import { Alert, AppState, Image, Platform, Text, useWindowDimensions, View } from 'react-native';
+import { Alert, AppState, Platform, View } from 'react-native';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../../lib/theme';
import { apiFetch } from '../../../lib/api';
import { invalidateMe } from '../../../hooks/useMe';
import { protection, FAMILY_CONTROLS_AVAILABLE } from '../../../lib/protection';
-import RebreakProtection from '../../../modules/rebreak-protection';
+import { useProtectionState } from '../../../hooks/useProtectionState';
+import { useBlocklistSync } from '../../../hooks/useBlocklistSync';
import { getPermissionScreenshot } from '../../../lib/onboardingAssets';
+import i18n from '../../../lib/i18n';
+import { AndroidSetupFlow, IosUnsupervisedSetupFlow } from '../../blocker/SetupFlows';
+import { LayerSwitchCard } from '../../blocker/LayerSwitchCard';
import { OnboardingShell } from '../OnboardingShell';
import { LyraBubble } from '../LyraBubble';
import { CTABar } from '../CTABar';
-import { ScreenshotPointer } from '../ScreenshotPointer';
+import { PermissionConfirmSheet } from '../PermissionConfirmSheet';
import { PermissionDeniedSheet } from '../../PermissionDeniedSheet';
-import i18n from '../../../lib/i18n';
+
+/** Steps mit Gate davor. VPN/Geräteadmin/AppLock/URL = echte System-Dialoge
+ * (welcher Button). a11y = bekommt einen reicheren Explainer (Overlay-Recht +
+ * Schalter-Suche mit Screenshot/Indikator), weil's für viele kompliziert ist.
+ * Screentime hat einen eigenen instruktiven Flow → kein Gate. */
+type ConfirmStep = 'vpn' | 'deviceadmin' | 'applock' | 'urlfilter' | 'a11y' | 'usage' | 'overlay';
/**
* Onboarding-Schutz-Step.
*
- * Platform.OS-Dispatch:
- * iOS → IosProtectionSlide (NEFilter + Family-Controls)
- * Android → AndroidProtectionSlide (VpnService + Accessibility-Tamper-Lock)
+ * WICHTIG: Dieser Step rendert EXAKT denselben Setup-Flow wie der Blocker-Screen
+ * (components/blocker/SetupFlows.tsx) — die Reihenfolge + das Gating sind dort
+ * die einzige Quelle der Wahrheit:
+ * Android: VPN → Geräteadmin → a11y (a11y MUSS zuletzt, sonst blockt der
+ * Tamper-Lock die Geräteadmin-Settings-Seite).
+ * iOS: App-Lock → Bildschirmzeit → URL-Filter.
*
- * Beide haben den gleichen Eltern-Vertrag (current/total/onDone) und nutzen
- * den gleichen Pre-Explainer + Lyra-Bubble + CTA-Pattern — die Innereien
- * unterscheiden sich nur in (a) welche Permission-Dialoge geöffnet werden
- * und (b) welche Screenshots gezeigt werden.
+ * Die Handler hier spiegeln blocker.tsx 1:1 (Activate + Sync + Recovery-Sheets).
+ * "Fertig" = der Blocker würde "Schutz aktiv" zeigen (lockedIn). Erst dann wird
+ * der "Weiter"-Button aktiv und der Onboarding-Step abgeschlossen.
*/
-
-export function ProtectionSlide(props: {
- onDone: () => void;
- current: number;
- total: number;
-}) {
- if (Platform.OS === 'android') {
- return ;
- }
- return ;
-}
-
-// ─── iOS ────────────────────────────────────────────────────────────────────
-
-type IosPhase = 'preexplain_url' | 'preexplain_lock' | 'done';
-
-function IosProtectionSlide({
+export function ProtectionSlide({
onDone,
current,
total,
@@ -52,471 +47,234 @@ function IosProtectionSlide({
total: number;
}) {
const { t } = useTranslation();
- const [phase, setPhase] = useState('preexplain_url');
- const [activating, setActivating] = useState(false);
+ const colors = useColors();
+ const { state, mdmManaged, refresh, activateUrlFilter, activateFamilyControls } =
+ useProtectionState();
+ const { sync: syncBlocklist } = useBlocklistSync();
+
+ const [screentimeCode, setScreentimeCode] = useState(null);
+ const [screentimeConfirmed, setScreentimeConfirmed] = useState(false);
+ const [screentimeSaving, setScreentimeSaving] = useState(false);
const [permissionDeniedOpen, setPermissionDeniedOpen] = useState(false);
const [familyControlsErrorOpen, setFamilyControlsErrorOpen] = useState(false);
+ const [confirmStep, setConfirmStep] = useState(null);
+ const finishedRef = useRef(false);
+ // a11y-Explainer NUR beim ersten Tap zeigen — danach (Settings-Rückkehr →
+ // nochmal tippen zum Armen) direkt durch, sonst nervt das Sheet doppelt.
+ const a11yExplainerShownRef = useRef(false);
- async function activateUrlFilter() {
- if (activating) return;
- setActivating(true);
- try {
- const res = await protection.activateUrlFilter();
- if (!res.enabled) {
- const isCodeFive =
- typeof res.error === 'string' &&
- /NEFilterErrorDomain:\s*5/i.test(res.error);
- if (isCodeFive) {
- setPermissionDeniedOpen(true);
- return;
- }
- Alert.alert(
- t('onboarding.protection.error_title'),
- res.error ?? t('onboarding.protection.error_unknown'),
- );
- return;
- }
- // Family Controls (App-Lock) braucht ein Distribution-Entitlement das
- // (noch) nicht freigegeben ist → in TestFlight/production-Builds ist
- // FAMILY_CONTROLS_AVAILABLE=false. Dann den Lock-Step überspringen:
- // URL-Filter allein = vollwertiger Schutz, der Lock ist nur Hardening.
- if (!FAMILY_CONTROLS_AVAILABLE) {
- finishProtectionStep();
- return;
- }
- setPhase('preexplain_lock');
- } finally {
- setActivating(false);
- }
- }
-
- async function activateAppLock() {
- if (activating) return;
- setActivating(true);
- try {
- const res = await protection.activateFamilyControls();
- if (!res.enabled) {
- // iOS NSCocoaErrorDomain:4099 = XPC-Communication-Failure (FamilyControls-Daemon
- // nicht erreichbar). Recovery-Sheet statt Alert-Loop — gibt User klare Anleitung
- // (Reboot/Settings/Reinstall) statt nur einen "Skip"-Button.
- const isXpcFailure =
- typeof res.error === 'string' && /NSCocoaErrorDomain:\s*4099/i.test(res.error);
- if (isXpcFailure) {
- setFamilyControlsErrorOpen(true);
- return;
- }
- Alert.alert(
- t('onboarding.protection.applock_failed_title'),
- res.error ?? t('onboarding.protection.applock_failed_msg'),
- [
- {
- text: t('onboarding.protection.applock_skip'),
- style: 'cancel',
- onPress: () => finishProtectionStep(),
- },
- { text: t('common.retry'), onPress: activateAppLock },
- ],
- );
- return;
- }
- finishProtectionStep();
- } finally {
- setActivating(false);
- }
- }
-
- async function finishProtectionStep() {
- await apiFetch('/api/profile/me/onboarding-step', {
- method: 'PATCH',
- body: { step: 'done' },
- }).catch(() => {});
- invalidateMe();
- setPhase('done');
- onDone();
- }
-
- if (phase === 'preexplain_url') {
- return (
-
- setPermissionDeniedOpen(false)}
- onRetry={async () => {
- const res = await protection.resetUrlFilter();
- if (res.enabled) {
- if (!FAMILY_CONTROLS_AVAILABLE) {
- finishProtectionStep();
- } else {
- setPhase('preexplain_lock');
- }
- }
- return res;
- }}
- />
-
- );
- }
- if (phase === 'preexplain_lock') {
- return (
-
- setFamilyControlsErrorOpen(false)}
- variant="family_controls"
- onRetry={async () => {
- const res = await protection.activateFamilyControls();
- if (res.enabled) {
- finishProtectionStep();
- }
- return res;
- }}
- />
-
- );
- }
- return null;
-}
-
-// ─── Android ────────────────────────────────────────────────────────────────
-
-type AndroidPhase =
- | 'preexplain_vpn'
- | 'preexplain_a11y'
- | 'a11y_pending'
- | 'done';
-
-function AndroidProtectionSlide({
- onDone,
- current,
- total,
-}: {
- onDone: () => void;
- current: number;
- total: number;
-}) {
- const { t } = useTranslation();
- const [phase, setPhase] = useState('preexplain_vpn');
- const [activating, setActivating] = useState(false);
- const restartPromptShownRef = useRef(false);
- // True wenn wir auf Settings-Rückkehr warten. AppState-Listener pollt dann
- // a11y-State + advanced automatisch wenn ReBreak-Schalter live ist.
- const awaitingReturnRef = useRef(false);
- const appStateRef = useRef(AppState.currentState);
-
- async function finishProtectionStep() {
- await apiFetch('/api/profile/me/onboarding-step', {
- method: 'PATCH',
- body: { step: 'done' },
- }).catch(() => {});
- invalidateMe();
- await maybeShowRestartPrompt();
- setPhase('done');
- onDone();
- }
-
- function maybeShowRestartPrompt(): Promise {
- if (Platform.OS !== 'android') return Promise.resolve();
- if (restartPromptShownRef.current) return Promise.resolve();
- restartPromptShownRef.current = true;
-
- return new Promise((resolve) => {
- Alert.alert(
- t('onboarding.protection.android_restart_title'),
- t('onboarding.protection.android_restart_body'),
- [
- {
- text: t('onboarding.protection.android_restart_later'),
- style: 'cancel',
- onPress: () => resolve(),
- },
- {
- text: t('onboarding.protection.android_restart_now'),
- onPress: () => {
- (async () => {
- try {
- const result = await RebreakProtection.openPowerDialog?.();
- if (!result?.opened) {
- await protection.openSystemSettings();
- }
- } catch {
- await protection.openSystemSettings().catch(() => {});
- } finally {
- resolve();
- }
- })();
- },
- },
- ],
- { cancelable: false },
- );
- });
- }
-
- async function activateVpn() {
- if (activating) return;
- setActivating(true);
- try {
- const res = await protection.activateUrlFilter();
- if (!res.enabled) {
- Alert.alert(
- t('onboarding.protection.error_title'),
- res.error ?? t('onboarding.protection.error_unknown'),
- );
- return;
- }
- setPhase('preexplain_a11y');
- } finally {
- setActivating(false);
- }
- }
-
- async function activateA11y() {
- if (activating) return;
- setActivating(true);
- try {
- const res = await protection.activateFamilyControls();
- if (res.enabled) {
- // Selten: User hatte a11y schon manuell aktiviert → Lock direkt armed.
- finishProtectionStep();
- return;
- }
- if (res.error === 'accessibility_pending') {
- // Native hat Settings geöffnet; warte auf Rückkehr + poll.
- awaitingReturnRef.current = true;
- setPhase('a11y_pending');
- return;
- }
- Alert.alert(
- t('onboarding.protection.error_title'),
- res.error ?? t('onboarding.protection.error_unknown'),
- );
- } finally {
- setActivating(false);
- }
- }
-
- async function resetProtectionForTesting() {
- if (activating) return;
- setActivating(true);
- try {
- // Native reset: stoppt VPN + disarmt Tamper-Lock + setzt filter_enabled=false.
- await protection.forceDisable();
-
- // Backend-Flag auf disabled, damit kein Auto-Reactivate direkt wieder greift.
- await apiFetch('/api/protection/dev-force-disabled', { method: 'POST' }).catch(() => {});
- invalidateMe();
-
- awaitingReturnRef.current = false;
- setPhase('preexplain_vpn');
-
- // Re-open Accessibility für den manuellen OFF-Check/Toggle (OS-Limit).
- await protection.openSystemSettings('accessibility');
- } catch (e) {
- Alert.alert(
- t('onboarding.protection.error_title'),
- e instanceof Error ? e.message : t('onboarding.protection.error_unknown'),
- );
- } finally {
- setActivating(false);
- }
- }
-
- // Auto-Check beim Foreground-Return: wenn a11y jetzt aktiv → Lock armen + done.
+ // Persistierten Screen-Time-Status laden, damit der Step nicht erneut gefragt
+ // wird, wenn der Code schon gesetzt ist (gleiche Logik wie blocker.tsx).
useEffect(() => {
- const sub = AppState.addEventListener('change', async (next) => {
- const prev = appStateRef.current;
- appStateRef.current = next;
- if (!awaitingReturnRef.current) return;
- if (prev.match(/inactive|background/) && next === 'active') {
- // User ist zurück in der App → den Repeating-Toast-Hint stoppen,
- // damit nicht weitere Toasts über unsere UI hereinflattern. Native
- // dismissed sich zwar auch von alleine nach ~30s, aber explizit ist
- // sauberer. Fail-safe da iOS keine dismiss-Methode hat.
- try {
- await RebreakProtection.dismissAccessibilityHint?.();
- } catch {
- // ignore — Methode existiert nicht auf iOS
- }
- try {
- const a11y = await RebreakProtection.isAccessibilityEnabled();
- if (a11y.enabled) {
- // ReBreak-Service ist live → Tamper-Lock armen + finish.
- const res = await protection.activateFamilyControls();
- if (res.enabled) {
- awaitingReturnRef.current = false;
- finishProtectionStep();
- }
- }
- } catch {
- // Ignorieren — User kann manuell auf "Ich habe ReBreak aktiviert" tippen.
- }
- }
- });
- return () => sub.remove();
+ if (Platform.OS !== 'ios') return;
+ protection
+ .getScreenTimePasscode()
+ .then((p) => {
+ if (p) setScreentimeConfirmed(true);
+ })
+ .catch(() => {});
}, []);
- if (phase === 'preexplain_vpn') {
- return (
-
- );
- }
- if (phase === 'preexplain_a11y') {
- return (
-
- );
- }
- if (phase === 'a11y_pending') {
- return (
-
- );
- }
- return null;
-}
+ const urlFilterActive = state?.layers.urlFilter === true;
+ const familyControlsActive = state?.layers.familyControls === true;
+ const appDeletionLockActive = (state?.layers.appDeletionLock ?? familyControlsActive) === true;
+ const nefilterActive = state?.layers.nefilterActive === true;
+ const deviceAdminActive = state?.layers.deviceAdmin === true;
-function A11yPendingView({
- current,
- total,
- activating,
- onRetry,
- onResetForTesting,
-}: {
- current: number;
- total: number;
- activating: boolean;
- onRetry: () => void;
- onResetForTesting?: () => void;
-}) {
- const { t } = useTranslation();
- const colors = useColors();
- return (
-
+ // "Fertig" == blocker.tsx lockedIn. Eine Quelle der Wahrheit.
+ const allDone =
+ Platform.OS === 'android'
+ ? urlFilterActive && appDeletionLockActive && deviceAdminActive
+ : (nefilterActive || urlFilterActive) &&
+ (mdmManaged || nefilterActive || appDeletionLockActive || !FAMILY_CONTROLS_AVAILABLE);
+
+ async function finishProtectionStep() {
+ if (finishedRef.current) return;
+ finishedRef.current = true;
+ await apiFetch('/api/profile/me/onboarding-step', {
+ method: 'PATCH',
+ body: { step: 'done' },
+ }).catch(() => {});
+ invalidateMe();
+ onDone();
+ }
+
+ // Foreground-Return → State neu laden. a11y/Geräteadmin/Bildschirmzeit werden in
+ // den System-Settings gesetzt; beim Zurückkommen pollen wir den neuen Layer-State,
+ // damit die Cards umschalten und "Weiter" freigeschaltet wird.
+ useEffect(() => {
+ const sub = AppState.addEventListener('change', (next) => {
+ if (next === 'active') refresh();
+ });
+ return () => sub.remove();
+ }, [refresh]);
+
+ // ─── Handler (1:1 wie blocker.tsx) ──────────────────────────────────────────
+
+ async function handleActivateUrlFilter() {
+ try {
+ const result = await activateUrlFilter();
+ if (!result.enabled) {
+ const isPermissionDenied =
+ Platform.OS === 'ios' &&
+ typeof result.error === 'string' &&
+ /NEFilterErrorDomain:\s*5/i.test(result.error);
+ if (isPermissionDenied) {
+ setPermissionDeniedOpen(true);
+ return result;
+ }
+ Alert.alert(
+ t('blocker.activate_url_failed_title'),
+ result.error ?? t('blocker.activate_url_failed_msg'),
+ [
+ { text: t('common.ok') },
+ { text: t('blocker.activate_settings_btn'), onPress: () => protection.openSystemSettings() },
+ ],
+ );
+ } else {
+ const sync = await syncBlocklist();
+ if (sync.ok) {
+ await refresh();
+ } else {
+ Alert.alert(
+ t('blocker.sync_list_failed_title'),
+ sync.error ?? t('blocker.sync_list_failed_msg'),
+ );
+ }
}
- >
-
-
- {t('onboarding.protection.android_a11y_pending_title')}
-
-
- );
-}
+ return result;
+ } catch (e: any) {
+ Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
+ return { enabled: false };
+ }
+ }
-// ─── PreExplainer (shared) ───────────────────────────────────────────────────
+ async function handleActivateFamilyControls() {
+ try {
+ const result = await activateFamilyControls();
+ if (!result.enabled && result.error !== 'accessibility_pending') {
+ const isXpcFailure =
+ Platform.OS === 'ios' &&
+ typeof result.error === 'string' &&
+ /NSCocoaErrorDomain:\s*4099/i.test(result.error);
+ if (isXpcFailure) {
+ setFamilyControlsErrorOpen(true);
+ return result;
+ }
+ Alert.alert(
+ t('blocker.activate_app_lock_failed_title'),
+ result.error ?? t('blocker.activate_app_lock_failed_msg'),
+ );
+ }
+ if (result.enabled) await refresh();
+ return result;
+ } catch (e: any) {
+ Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
+ return { enabled: false };
+ }
+ }
-function PreExplainer({
- dialog,
- lyraBodyKey,
- titleKey,
- ctaKey,
- buttonLabelKey,
- markerHintKey,
- pointerAlignment = 'center',
- activating,
- onActivate,
- current,
- total,
- children,
-}: {
- dialog: 'url_filter' | 'screen_time' | 'android_vpn' | 'android_a11y';
- lyraBodyKey: string;
- titleKey: string;
- ctaKey: string;
- buttonLabelKey: string;
- markerHintKey: string;
- pointerAlignment?: 'left' | 'center' | 'right';
- activating: boolean;
- onActivate: () => void;
- current: number;
- total: number;
- children?: React.ReactNode;
-}) {
- const { t } = useTranslation();
- const colors = useColors();
- const { height: screenH } = useWindowDimensions();
- const lang = i18n.language || 'de';
- const screenshot = getPermissionScreenshot(dialog, lang);
+ function handleGenerateScreentimeCode() {
+ setScreentimeCode(Math.floor(1000 + Math.random() * 9000).toString());
+ setScreentimeConfirmed(false);
+ }
- // Dynamische Screenshot-Höhe: Auf kleinen Phones (SE/mini ~667-844 pt)
- // capped damit alles + CTA-Bar ohne Scroll passt. Auf großen Phones/iPad
- // skaliert es mit. Min 200, Max 320.
- const screenshotHeight = Math.min(320, Math.max(200, screenH * 0.32));
+ async function handleScreentimeConfirm() {
+ if (!screentimeCode) return;
+ setScreentimeSaving(true);
+ try {
+ await protection.saveScreenTimePasscode(screentimeCode);
+ setScreentimeConfirmed(true);
+ setScreentimeCode(null);
+ } catch (e: any) {
+ Alert.alert(t('common.error'), e?.message ?? t('common.unknown_error'));
+ } finally {
+ setScreentimeSaving(false);
+ }
+ }
+
+ async function handleRequestDeviceAdmin() {
+ try {
+ const result = await protection.requestDeviceAdmin();
+ if (!result.launched) {
+ Alert.alert(t('blocker.android_admin_failed_title'), t('blocker.android_admin_failed_msg'));
+ } else {
+ await refresh();
+ }
+ return result;
+ } catch (e: any) {
+ Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
+ return { launched: false };
+ }
+ }
+
+ // ─── Anti-Blind-Klick-Gate ──────────────────────────────────────────────────
+ // Statt direkt die Permission zu feuern, öffnet der Card-Button erst das
+ // Confirm-Sheet. Erst nach Häkchen + „Weiter" läuft der echte Handler. Die
+ // gated-Wrapper geben sofort ein neutrales Result zurück (kein Fehler → kein
+ // Alert); die Card-done-States kommen ohnehin aus den Layer-Flags, nicht aus
+ // dem Return-Wert.
+ const gatedVpn = async () => {
+ setConfirmStep('vpn');
+ return { enabled: false };
+ };
+ const gatedDeviceAdmin = async () => {
+ setConfirmStep('deviceadmin');
+ return { launched: false };
+ };
+ const gatedApplock = async () => {
+ setConfirmStep('applock');
+ return { enabled: false };
+ };
+ const gatedUrlFilter = async () => {
+ setConfirmStep('urlfilter');
+ return { enabled: false };
+ };
+ const gatedA11y = async () => {
+ // Einmalig Nutzungszugriff holen — DAMIT erkennt die native Guide-Notification
+ // den aktuellen Samsung-Screen und führt Schritt für Schritt. Ohne das gäbe es
+ // nur dumme Toasts. Erst freigeben, dann zurück + a11y nochmal tippen.
+ if (!(await protection.hasUsageAccess())) {
+ setConfirmStep('usage');
+ return { enabled: false };
+ }
+ // Dann „Über anderen Apps anzeigen" — damit der Hinweis als sichtbares Overlay
+ // VOR den Settings schwebt (statt nur als leicht übersehbare Notification).
+ if (!(await protection.hasOverlayPermission())) {
+ setConfirmStep('overlay');
+ return { enabled: false };
+ }
+ // Erster Tap → Explainer (Installierte Dienste → ReBreak → Schalter, Screenshot).
+ // Folge-Taps (nach Settings-Rückkehr zum Armen) → direkt, ohne Sheet.
+ if (!a11yExplainerShownRef.current) {
+ a11yExplainerShownRef.current = true;
+ setConfirmStep('a11y');
+ return { enabled: false };
+ }
+ return handleActivateFamilyControls();
+ };
+
+ function runConfirmedAction(step: ConfirmStep) {
+ switch (step) {
+ case 'vpn':
+ case 'urlfilter':
+ return handleActivateUrlFilter();
+ case 'deviceadmin':
+ return handleRequestDeviceAdmin();
+ case 'applock':
+ case 'a11y':
+ return handleActivateFamilyControls();
+ case 'usage':
+ // Nutzungszugriff-Settings öffnen. User gibt frei, kommt zurück, tippt
+ // a11y nochmal → hasUsageAccess true → nächstes Gate / Explainer.
+ return protection.openUsageAccessSettings();
+ case 'overlay':
+ // „Über anderen Apps anzeigen"-Settings öffnen. Danach a11y nochmal tippen.
+ return protection.openOverlayPermissionSettings();
+ }
+ }
+
+ // ─── Render ─────────────────────────────────────────────────────────────────
return (
}
>
-
+
-
- {t(titleKey)}
-
-
-
-
+
+ {Platform.OS === 'android' ? (
+
+ ) : FAMILY_CONTROLS_AVAILABLE && !mdmManaged && !nefilterActive ? (
+
+ ) : (
+ /* iOS Distribution ohne Family-Controls-Entitlement (oder MDM/NEFilter):
+ nur der URL-Filter als einzelner Layer — exakt wie der Blocker-Fallback. */
+
+ )}
-
-
- setConfirmStep(null)}
+ onConfirm={() => {
+ const step = confirmStep;
+ setConfirmStep(null);
+ // Erst Sheet schließen lassen, DANN die Permission feuern. Auf iOS kann
+ // ein System-Dialog, der über ein noch wegslidendes Modal präsentiert
+ // wird, still fehlschlagen — exakt die Bug-Klasse, die wir vermeiden wollen.
+ if (step) setTimeout(() => runConfirmedAction(step), 350);
}}
- >
- {t(markerHintKey)}
-
- {children}
+ />
+
+ setPermissionDeniedOpen(false)}
+ onRetry={async () => {
+ const res = await protection.resetUrlFilter();
+ if (res.enabled) await refresh();
+ return res;
+ }}
+ />
+ setFamilyControlsErrorOpen(false)}
+ variant="family_controls"
+ onRetry={async () => {
+ const res = await protection.activateFamilyControls();
+ if (res.enabled) await refresh();
+ return res;
+ }}
+ />
);
}
diff --git a/apps/rebreak-native/deploy.sh b/apps/rebreak-native/deploy.sh
index ff6c3f2..d15464b 100755
--- a/apps/rebreak-native/deploy.sh
+++ b/apps/rebreak-native/deploy.sh
@@ -515,27 +515,52 @@ bump_ios_version() {
}
bump_android_version() {
- log "Android versionCode Bump..."
-
- local current_version_code
- current_version_code=$(get_current_version_code)
- local new_version_code
-
+ log "Android Version Bump..."
+
+ # WICHTIG: Gradle liest versionCode/versionName aus android/app/build.gradle —
+ # NICHT aus app.config.ts. Frühere Bumps seddeten nur app.config → verpufften
+ # (der AAB blieb auf dem alten build.gradle-Stand → Play "already submitted").
+ # Daher ist build.gradle hier die Quelle der Wahrheit; app.config + package.json
+ # werden nur synchron mitgezogen (Konsistenz, falls mal prebuild läuft).
+ local BUILD_GRADLE="$ANDROID_DIR/app/build.gradle"
+ [[ -f "$BUILD_GRADLE" ]] || die "build.gradle nicht gefunden: $BUILD_GRADLE"
+
+ local current_vc current_vn
+ current_vc=$(grep -E 'versionCode +[0-9]+' "$BUILD_GRADLE" | head -1 | grep -oE '[0-9]+' | head -1)
+ current_vn=$(grep -E 'versionName +"' "$BUILD_GRADLE" | head -1 | sed -E 's/.*versionName +"([^"]+)".*/\1/')
+
+ local new_vc new_vn
if [[ -n "$ANDROID_VERSION_CODE_OVERRIDE" ]]; then
- new_version_code="$ANDROID_VERSION_CODE_OVERRIDE"
+ new_vc="$ANDROID_VERSION_CODE_OVERRIDE"
else
- new_version_code=$((current_version_code + 1))
+ new_vc=$((current_vc + 1)) # Play-Pflicht: versionCode MUSS bei jedem Upload steigen
fi
-
- echo " versionCode: $current_version_code → $new_version_code"
-
+
+ if [[ -n "$EXPLICIT_VERSION" ]]; then
+ new_vn="$EXPLICIT_VERSION" # --version Override (für Minor/Major-Releases)
+ else
+ # Default: Patch-Segment +1 (x.y.z → x.y.(z+1)). Minor/Major bewusst manuell.
+ local major minor patch
+ IFS='.' read -r major minor patch <<< "$current_vn"
+ new_vn="${major:-0}.${minor:-0}.$(( ${patch:-0} + 1 ))"
+ fi
+
+ echo " versionCode: $current_vc → $new_vc"
+ echo " versionName: $current_vn → $new_vn"
+
if ! $DRY_RUN; then
- if [[ "$(uname)" == "Darwin" ]]; then
- sed -i '' "s/versionCode: $current_version_code,/versionCode: $new_version_code,/" "$APP_CONFIG"
- else
- sed -i "s/versionCode: $current_version_code,/versionCode: $new_version_code,/" "$APP_CONFIG"
- fi
- ok "Android versionCode aktualisiert"
+ local SED=(sed -i)
+ [[ "$(uname)" == "Darwin" ]] && SED=(sed -i '')
+
+ # 1) build.gradle — die Quelle, die Gradle wirklich liest
+ "${SED[@]}" -E "s/versionCode +[0-9]+/versionCode $new_vc/" "$BUILD_GRADLE"
+ "${SED[@]}" -E "s/versionName +\"[^\"]+\"/versionName \"$new_vn\"/" "$BUILD_GRADLE"
+ # 2) app.config.ts — versionCode synchron halten
+ "${SED[@]}" -E "s/versionCode: [0-9]+,/versionCode: $new_vc,/" "$APP_CONFIG"
+ # 3) package.json — versionName-Quelle für app.config (version: pkg.version) + iOS
+ "${SED[@]}" -E "s/\"version\": \"[^\"]+\"/\"version\": \"$new_vn\"/" "$PACKAGE_JSON"
+
+ ok "Android Version aktualisiert: $new_vn (versionCode $new_vc) → build.gradle + app.config + package.json"
fi
}
diff --git a/apps/rebreak-native/lib/onboardingAssets.ts b/apps/rebreak-native/lib/onboardingAssets.ts
index 7000a27..2d4da06 100644
--- a/apps/rebreak-native/lib/onboardingAssets.ts
+++ b/apps/rebreak-native/lib/onboardingAssets.ts
@@ -17,7 +17,7 @@
* dynamischen Pfade auflösen.
*/
-type Dialog = 'url_filter' | 'screen_time' | 'android_vpn' | 'android_a11y';
+type Dialog = 'url_filter' | 'screen_time' | 'android_vpn' | 'android_a11y' | 'android_a11y_overview';
type Lang = 'de' | 'en' | 'fr' | 'ar';
/* eslint-disable @typescript-eslint/no-require-imports */
@@ -42,6 +42,15 @@ const ANDROID_A11Y_DE = require('../assets/onboarding/de/android-a11y-rebreak-ro
const ANDROID_A11Y_EN = require('../assets/onboarding/en/android-a11y-rebreak-row-001.png');
const ANDROID_A11Y_FR = require('../assets/onboarding/fr/android-a11y-rebreak-row-001.png');
const ANDROID_A11Y_AR = require('../assets/onboarding/ar/android-a11y-rebreak-row-001.png');
+
+// Android — a11y-Übersicht (Samsung Homepage) mit „Installierte Dienste". Das ist
+// der Screen, auf dem der Deep-Link landet (tiefer kommt eine normale App nicht —
+// Detail-Page + Dienste-Liste sind signature-gesperrt). Hier zeigen wir dem User,
+// wo er auf „Installierte Dienste" tippen muss.
+const ANDROID_A11Y_OVERVIEW_DE = require('../assets/onboarding/de/android-a11y-overview-001.png');
+const ANDROID_A11Y_OVERVIEW_EN = require('../assets/onboarding/en/android-a11y-overview-001.png');
+const ANDROID_A11Y_OVERVIEW_FR = require('../assets/onboarding/fr/android-a11y-overview-001.png');
+const ANDROID_A11Y_OVERVIEW_AR = require('../assets/onboarding/ar/android-a11y-overview-001.png');
/* eslint-enable @typescript-eslint/no-require-imports */
const SCREENSHOTS: Record