feat(android-protection): präzise Tamper-Lock + a11y-Onboarding-Guide

Tamper-Lock von Keyword-Scanning auf präzise Einzel-Surfaces umgebaut:
blockt nur ReBreaks eigene Screens (Admin-Deaktivierung via DeviceAdminAdd,
a11y-Ausschalten, VPN-Trennen/Surface), nie Listen oder fremde Apps.

- Deny-Removal = Admin-only: OS graut Uninstall+Force-Stop für aktiven
  Device-Admin aus; einziger Bypass (Admin deaktivieren) bleibt a11y-gesperrt.
  Andere Apps verwalten/force-stoppen/deinstallieren bleibt komplett frei.
- a11y-Onboarding: passiver Bottom-Overlay-Hinweis + Settings-Reset auf
  Startseite nach Aktivierung + 1s-Delay vor App-Rückkehr.
- VPN-Trennen-Dialog + a11y-Ausschalten neu abgedeckt.
- a11y-Service-Icon im Plugin (klar als ReBreak erkennbar).

Verifiziert auf A50 per logcat: alle 4 Surfaces blocken, Listen + fremde
Apps frei, keine False-Positives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-08 04:05:41 +02:00
parent 0bff819b09
commit 4a013bc43b
27 changed files with 1853 additions and 1329 deletions

View File

@ -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.

View File

@ -3,5 +3,5 @@
<string name="expo_splash_screen_resize_mode" translatable="false">cover</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="accessibility_service_description" translatable="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.</string>
<string name="accessibility_service_summary" translatable="false">Sichert den Schutz gegen Abschalten ab</string>
<string name="accessibility_service_summary" translatable="false">ReBreak Schutz</string>
</resources>

View File

@ -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",
],
},

View File

@ -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() {
</View>
);
}
// ─── 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<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['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 (
<View style={{ gap: 10 }}>
<SetupStep1
done={step1Done}
onActivate={onActivateFamilyControls}
colors={colors}
t={t}
/>
<SetupStep2
unlocked={step1Done}
code={screentimeCode}
confirmed={step2Done}
saving={screentimeSaving}
onGenerate={onGenerateScreentimeCode}
onConfirm={onConfirmScreentime}
colors={colors}
t={t}
/>
<SetupStep3
unlocked={step2Done}
done={step3Done}
onActivate={onActivateUrlFilter}
colors={colors}
t={t}
/>
</View>
);
}
function SetupStep1({
done,
onActivate,
colors,
t,
}: {
done: boolean;
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={1}
title={t('blocker.setup_step1_title')}
subtitle={done ? t('blocker.setup_step1_subtitle_done') : t('blocker.setup_step1_subtitle_pending')}
done={done}
unlocked
colors={colors}
>
{!done && (
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 10 }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.setup_step1_cta')}</Text>
}
</TouchableOpacity>
)}
</SetupStepCard>
);
}
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<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
return (
<SetupStepCard
stepNumber={2}
title={t('blocker.setup_step2_title')}
subtitle={confirmed ? t('blocker.setup_step2_subtitle_done') : t('blocker.setup_step2_subtitle_pending')}
done={confirmed}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 1 })}
colors={colors}
>
{unlocked && !confirmed && (
<View style={{ gap: 10, marginTop: 10 }}>
{!code ? (
<TouchableOpacity
onPress={onGenerate}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('blocker.screentime_generate_cta')}
</Text>
</TouchableOpacity>
) : (
<View style={{ gap: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 16, alignItems: 'center', gap: 4 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('blocker.screentime_code_label')}
</Text>
<Text style={{ fontSize: 36, fontFamily: 'Nunito_700Bold', color: colors.brandOrange, letterSpacing: 12 }}>
{code}
</Text>
</View>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 12, gap: 6 }}>
{[
t('blocker.screentime_step1'),
t('blocker.screentime_step2'),
t('blocker.screentime_step3'),
].map((step, i) => (
<Text
key={i}
style={{
fontSize: 13,
fontFamily: i === 0 ? 'Nunito_700Bold' : 'Nunito_600SemiBold',
color: i === 0 ? colors.brandOrange : colors.text,
lineHeight: 18,
}}
>
{step}
</Text>
))}
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 16, marginTop: 2 }}>
{t('blocker.screentime_step_note')}
</Text>
</View>
<TouchableOpacity
onPress={onConfirm}
disabled={saving}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
{saving
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.screentime_confirm_cta')}</Text>
}
</TouchableOpacity>
</View>
)}
</View>
)}
</SetupStepCard>
);
}
function SetupStep3({
unlocked,
done,
onActivate,
colors,
t,
}: {
unlocked: boolean;
done: boolean;
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={3}
title={t('blocker.setup_step3_title')}
subtitle={done ? t('blocker.setup_step3_subtitle_done') : t('blocker.setup_step3_subtitle_pending')}
done={done}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 2 })}
colors={colors}
>
{unlocked && !done && (
<View style={{ gap: 10, marginTop: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 10, padding: 10, flexDirection: 'row', gap: 8 }}>
<Ionicons name="information-circle-outline" size={15} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
{t('blocker.setup_step3_warning')}
</Text>
</View>
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.success, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.setup_step3_cta')}</Text>
}
</TouchableOpacity>
</View>
)}
</SetupStepCard>
);
}
// ─── 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<unknown>;
onRequestDeviceAdmin: () => Promise<{ launched: boolean }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['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 (
<View style={{ gap: 10 }}>
<AndroidStep1
done={vpnDone}
onActivate={onActivateVpn}
colors={colors}
t={t}
/>
{/* Display-Step 2 = Geräteadmin (Komponente AndroidStep3, i18n android_step3_*). */}
<AndroidStep3
unlocked={vpnDone}
done={adminDone}
onRequestAdmin={onRequestDeviceAdmin}
colors={colors}
t={t}
/>
{/* Display-Step 3 = a11y / ReBreak-Schutz (Komponente AndroidStep2, i18n android_step2_*). */}
<AndroidStep2
unlocked={adminDone}
done={a11yDone}
onActivate={onActivateAccessibility}
colors={colors}
t={t}
/>
</View>
);
}
function AndroidStep1({
done,
onActivate,
colors,
t,
}: {
done: boolean;
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={1}
title={t('blocker.android_step1_title')}
subtitle={done ? t('blocker.android_step1_subtitle_done') : t('blocker.android_step1_subtitle_pending')}
done={done}
unlocked
colors={colors}
>
{!done && (
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 10 }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step1_cta')}</Text>
}
</TouchableOpacity>
)}
</SetupStepCard>
);
}
function AndroidStep2({
unlocked,
done,
onActivate,
colors,
t,
}: {
unlocked: boolean;
done: boolean;
onActivate: () => Promise<unknown>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={3}
title={t('blocker.android_step2_title')}
subtitle={done ? t('blocker.android_step2_subtitle_done') : t('blocker.android_step2_subtitle_pending')}
done={done}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 2 })}
colors={colors}
>
{unlocked && !done && (
<View style={{ gap: 8, marginTop: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 12, gap: 6 }}>
{[
t('blocker.android_step2_instruction1'),
t('blocker.android_step2_instruction2'),
t('blocker.android_step2_instruction3'),
].map((line, i) => (
<Text
key={i}
style={{
fontSize: 13,
fontFamily: i === 0 ? 'Nunito_700Bold' : 'Nunito_600SemiBold',
color: i === 0 ? colors.brandOrange : colors.text,
lineHeight: 18,
}}
>
{line}
</Text>
))}
</View>
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 2 }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step2_cta')}</Text>
}
</TouchableOpacity>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 16 }}>
{t('blocker.android_step2_note')}
</Text>
</View>
)}
</SetupStepCard>
);
}
function AndroidStep3({
unlocked,
done,
onRequestAdmin,
colors,
t,
}: {
unlocked: boolean;
done: boolean;
onRequestAdmin: () => Promise<{ launched: boolean }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onRequestAdmin(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={2}
title={t('blocker.android_step3_title')}
subtitle={done ? t('blocker.android_step3_subtitle_done') : t('blocker.android_step3_subtitle_pending')}
done={done}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 1 })}
colors={colors}
>
{unlocked && !done && (
<View style={{ gap: 10, marginTop: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 10, padding: 10, flexDirection: 'row', gap: 8 }}>
<Ionicons name="information-circle-outline" size={15} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
{t('blocker.android_step3_warning')}
</Text>
</View>
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.success, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step3_cta')}</Text>
}
</TouchableOpacity>
</View>
)}
</SetupStepCard>
);
}
function SetupStepCard({
stepNumber,
title,
subtitle,
done,
unlocked,
lockedHint,
colors,
children,
}: {
stepNumber: number;
title: string;
subtitle: string;
done: boolean;
unlocked: boolean;
lockedHint?: string;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
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 (
<View style={{ backgroundColor: cardBg, borderWidth: 1, borderColor, borderRadius: 16, padding: 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: numberBg, alignItems: 'center', justifyContent: 'center' }}>
{done
? <Ionicons name="checkmark" size={18} color={colors.success} />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: numberColor }}>{stepNumber}</Text>
}
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: titleColor }}>{title}</Text>
{!!subtitle && (
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginTop: 2, lineHeight: 17 }}>{subtitle}</Text>
)}
</View>
{done && <Ionicons name="checkmark-circle" size={24} color={colors.success} />}
</View>
{lockedHint && !done && (
<View style={{ marginTop: 8, flexDirection: 'row', gap: 6, alignItems: 'center' }}>
<Ionicons name="lock-closed-outline" size={13} color={colors.textMuted} />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>{lockedHint}</Text>
</View>
)}
{children}
</View>
);
}

View File

@ -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<Slide>(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() {

View File

@ -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 (

View File

@ -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<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['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 (
<View style={{ gap: 10 }}>
<SetupStep1
done={step1Done}
onActivate={onActivateFamilyControls}
colors={colors}
t={t}
/>
<SetupStep2
unlocked={step1Done}
code={screentimeCode}
confirmed={step2Done}
saving={screentimeSaving}
onGenerate={onGenerateScreentimeCode}
onConfirm={onConfirmScreentime}
colors={colors}
t={t}
/>
<SetupStep3
unlocked={step2Done}
done={step3Done}
onActivate={onActivateUrlFilter}
colors={colors}
t={t}
/>
</View>
);
}
function SetupStep1({
done,
onActivate,
colors,
t,
}: {
done: boolean;
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={1}
title={t('blocker.setup_step1_title')}
subtitle={done ? t('blocker.setup_step1_subtitle_done') : t('blocker.setup_step1_subtitle_pending')}
done={done}
unlocked
colors={colors}
>
{!done && (
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 10 }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.setup_step1_cta')}</Text>
}
</TouchableOpacity>
)}
</SetupStepCard>
);
}
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<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
return (
<SetupStepCard
stepNumber={2}
title={t('blocker.setup_step2_title')}
subtitle={confirmed ? t('blocker.setup_step2_subtitle_done') : t('blocker.setup_step2_subtitle_pending')}
done={confirmed}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 1 })}
colors={colors}
>
{unlocked && !confirmed && (
<View style={{ gap: 10, marginTop: 10 }}>
{!code ? (
<TouchableOpacity
onPress={onGenerate}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('blocker.screentime_generate_cta')}
</Text>
</TouchableOpacity>
) : (
<View style={{ gap: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 16, alignItems: 'center', gap: 4 }}>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('blocker.screentime_code_label')}
</Text>
<Text style={{ fontSize: 36, fontFamily: 'Nunito_700Bold', color: colors.brandOrange, letterSpacing: 12 }}>
{code}
</Text>
</View>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 12, gap: 6 }}>
{[
t('blocker.screentime_step1'),
t('blocker.screentime_step2'),
t('blocker.screentime_step3'),
].map((step, i) => (
<Text
key={i}
style={{
fontSize: 13,
fontFamily: i === 0 ? 'Nunito_700Bold' : 'Nunito_600SemiBold',
color: i === 0 ? colors.brandOrange : colors.text,
lineHeight: 18,
}}
>
{step}
</Text>
))}
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 16, marginTop: 2 }}>
{t('blocker.screentime_step_note')}
</Text>
</View>
<TouchableOpacity
onPress={onConfirm}
disabled={saving}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
{saving
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.screentime_confirm_cta')}</Text>
}
</TouchableOpacity>
</View>
)}
</View>
)}
</SetupStepCard>
);
}
function SetupStep3({
unlocked,
done,
onActivate,
colors,
t,
}: {
unlocked: boolean;
done: boolean;
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={3}
title={t('blocker.setup_step3_title')}
subtitle={done ? t('blocker.setup_step3_subtitle_done') : t('blocker.setup_step3_subtitle_pending')}
done={done}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 2 })}
colors={colors}
>
{unlocked && !done && (
<View style={{ gap: 10, marginTop: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 10, padding: 10, flexDirection: 'row', gap: 8 }}>
<Ionicons name="information-circle-outline" size={15} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
{t('blocker.setup_step3_warning')}
</Text>
</View>
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.success, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.setup_step3_cta')}</Text>
}
</TouchableOpacity>
</View>
)}
</SetupStepCard>
);
}
// ─── 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<unknown>;
onRequestDeviceAdmin: () => Promise<{ launched: boolean }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['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 (
<View style={{ gap: 10 }}>
<AndroidStep1
done={vpnDone}
onActivate={onActivateVpn}
colors={colors}
t={t}
/>
{/* Display-Step 2 = Geräteadmin (Komponente AndroidStep3, i18n android_step3_*). */}
<AndroidStep3
unlocked={vpnDone}
done={adminDone}
onRequestAdmin={onRequestDeviceAdmin}
colors={colors}
t={t}
/>
{/* Display-Step 3 = a11y / ReBreak-Schutz (Komponente AndroidStep2, i18n android_step2_*). */}
<AndroidStep2
unlocked={adminDone}
done={a11yDone}
onActivate={onActivateAccessibility}
colors={colors}
t={t}
/>
</View>
);
}
function AndroidStep1({
done,
onActivate,
colors,
t,
}: {
done: boolean;
onActivate: () => Promise<{ enabled: boolean; error?: string }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={1}
title={t('blocker.android_step1_title')}
subtitle={done ? t('blocker.android_step1_subtitle_done') : t('blocker.android_step1_subtitle_pending')}
done={done}
unlocked
colors={colors}
>
{!done && (
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 10 }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step1_cta')}</Text>
}
</TouchableOpacity>
)}
</SetupStepCard>
);
}
function AndroidStep2({
unlocked,
done,
onActivate,
colors,
t,
}: {
unlocked: boolean;
done: boolean;
onActivate: () => Promise<unknown>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (busy) return;
setBusy(true);
try { await onActivate(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={3}
title={t('blocker.android_step2_title')}
subtitle={done ? t('blocker.android_step2_subtitle_done') : t('blocker.android_step2_subtitle_pending')}
done={done}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 2 })}
colors={colors}
>
{unlocked && !done && (
<View style={{ gap: 8, marginTop: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 12, padding: 12, gap: 6 }}>
{[
t('blocker.android_step2_instruction1'),
t('blocker.android_step2_instruction2'),
t('blocker.android_step2_instruction3'),
].map((line, i) => (
<Text
key={i}
style={{
fontSize: 13,
fontFamily: i === 0 ? 'Nunito_700Bold' : 'Nunito_600SemiBold',
color: i === 0 ? colors.brandOrange : colors.text,
lineHeight: 18,
}}
>
{line}
</Text>
))}
</View>
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.brandOrange, borderRadius: 10, paddingVertical: 10, alignItems: 'center', marginTop: 2 }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step2_cta')}</Text>
}
</TouchableOpacity>
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 16 }}>
{t('blocker.android_step2_note')}
</Text>
</View>
)}
</SetupStepCard>
);
}
function AndroidStep3({
unlocked,
done,
onRequestAdmin,
colors,
t,
}: {
unlocked: boolean;
done: boolean;
onRequestAdmin: () => Promise<{ launched: boolean }>;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
t: ReturnType<typeof import('react-i18next').useTranslation>['t'];
}) {
const [busy, setBusy] = useState(false);
async function handlePress() {
if (done || busy) return;
setBusy(true);
try { await onRequestAdmin(); } finally { setBusy(false); }
}
return (
<SetupStepCard
stepNumber={2}
title={t('blocker.android_step3_title')}
subtitle={done ? t('blocker.android_step3_subtitle_done') : t('blocker.android_step3_subtitle_pending')}
done={done}
unlocked={unlocked}
lockedHint={unlocked ? undefined : t('blocker.setup_step_locked_hint', { step: 1 })}
colors={colors}
>
{unlocked && !done && (
<View style={{ gap: 10, marginTop: 10 }}>
<View style={{ backgroundColor: colors.surfaceElevated, borderRadius: 10, padding: 10, flexDirection: 'row', gap: 8 }}>
<Ionicons name="information-circle-outline" size={15} color={colors.textMuted} style={{ marginTop: 1 }} />
<Text style={{ flex: 1, fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, lineHeight: 17 }}>
{t('blocker.android_step3_warning')}
</Text>
</View>
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.8}
style={{ backgroundColor: colors.success, borderRadius: 10, paddingVertical: 10, alignItems: 'center' }}
>
{busy
? <ActivityIndicator size="small" color="#fff" />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{t('blocker.android_step3_cta')}</Text>
}
</TouchableOpacity>
</View>
)}
</SetupStepCard>
);
}
function SetupStepCard({
stepNumber,
title,
subtitle,
done,
unlocked,
lockedHint,
colors,
children,
}: {
stepNumber: number;
title: string;
subtitle: string;
done: boolean;
unlocked: boolean;
lockedHint?: string;
colors: ReturnType<typeof import('../../lib/theme').useColors>;
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 (
<View style={{ backgroundColor: cardBg, borderWidth: 1, borderColor, borderRadius: 16, padding: 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
<View style={{ width: 36, height: 36, borderRadius: 18, backgroundColor: numberBg, alignItems: 'center', justifyContent: 'center' }}>
{done
? <Ionicons name="checkmark" size={18} color={colors.success} />
: <Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: numberColor }}>{stepNumber}</Text>
}
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: titleColor }}>{title}</Text>
{!!subtitle && (
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: colors.textMuted, marginTop: 2, lineHeight: 17 }}>{subtitle}</Text>
)}
</View>
{done && <Ionicons name="checkmark-circle" size={24} color={colors.success} />}
</View>
{lockedHint && !done && (
<View style={{ marginTop: 8, flexDirection: 'row', gap: 6, alignItems: 'center' }}>
<Ionicons name="lock-closed-outline" size={13} color={colors.textMuted} />
<Text style={{ fontSize: 11, fontFamily: 'Nunito_400Regular', color: colors.textMuted }}>{lockedHint}</Text>
</View>
)}
{children}
</View>
);
}

View File

@ -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 }))}
</Text>
<Text
style={{

View File

@ -0,0 +1,189 @@
import { useEffect, useState } from 'react';
import { Image, Text, TouchableOpacity, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet';
/**
* Anti-Blind-Klick-Gate fürs Onboarding: zeigt VOR der eigentlichen Permission
* eine kurze Instruktion (welcher Button im gleich folgenden System-Dialog) und
* verlangt ein bewusstes Häkchen, bevor der Weiter"-Button aktiv wird. Bricht
* das weiter-weiter"-Muster genau dort, wo User sonst den falschen Button tippen
* (v.a. iOS-Family-Controls: zwei Buttons, der blaue ist die Falle).
*
* Onboarding-only der Blocker-Screen aktiviert weiterhin direkt (kein Gate).
*/
export function PermissionConfirmSheet({
visible,
title,
body,
steps,
screenshot,
indicatorCaption,
onConfirm,
onClose,
}: {
visible: boolean;
title: string;
body: string;
/** Optional: nummerierte Schritte (z.B. a11y: erst Overlay erlauben, dann Schalter an). */
steps?: string[];
/** Optional: Screenshot (require-Handle) — zeigt dem User wie der Ziel-Screen aussieht. */
screenshot?: number;
/** Optional: Caption unter dem Screenshot — der Indikator „hier tippen". */
indicatorCaption?: string;
onConfirm: () => 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 visible={visible} onClose={onClose} title={title}>
{/* FormSheet padded nur den Titel, nicht die children → hier selbst polstern. */}
<View style={{ paddingHorizontal: 20, paddingTop: 4, paddingBottom: 28 }}>
<View
style={{
backgroundColor: colors.surfaceElevated,
borderRadius: 14,
padding: 16,
flexDirection: 'row',
gap: 12,
}}
>
<Ionicons name="information-circle" size={22} color={colors.brandOrange} style={{ marginTop: 1 }} />
<Text
style={{
flex: 1,
fontSize: 15,
lineHeight: 22,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
}}
>
{body}
</Text>
</View>
{steps && steps.length > 0 && (
<View style={{ marginTop: 12, gap: 8 }}>
{steps.map((step, i) => (
<View key={i} style={{ flexDirection: 'row', alignItems: 'flex-start', gap: 10 }}>
<View
style={{
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: colors.brandOrange,
alignItems: 'center',
justifyContent: 'center',
marginTop: 1,
}}
>
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: '#fff' }}>{i + 1}</Text>
</View>
<Text
style={{
flex: 1,
fontSize: 14,
lineHeight: 20,
fontFamily: 'Nunito_600SemiBold',
color: colors.text,
}}
>
{step}
</Text>
</View>
))}
</View>
)}
{screenshot != null && (
<View style={{ marginTop: 14, alignItems: 'center' }}>
<View
style={{
height: 240,
aspectRatio: 0.9,
borderRadius: 12,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border,
}}
>
<Image source={screenshot} style={{ width: '100%', height: '100%' }} resizeMode="contain" />
</View>
{!!indicatorCaption && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 8 }}>
<Ionicons name="arrow-up" size={16} color={colors.brandOrange} />
<Text
style={{
fontSize: 13,
fontFamily: 'Nunito_700Bold',
color: colors.brandOrange,
textAlign: 'center',
}}
>
{indicatorCaption}
</Text>
</View>
)}
</View>
)}
<TouchableOpacity
activeOpacity={0.7}
onPress={() => setChecked((c) => !c)}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 12,
marginTop: 20,
paddingVertical: 4,
}}
>
<Ionicons
name={checked ? 'checkbox' : 'square-outline'}
size={26}
color={checked ? colors.brandOrange : colors.textMuted}
/>
<Text
style={{
flex: 1,
fontSize: 15,
fontFamily: 'Nunito_700Bold',
color: colors.text,
}}
>
{t('onboarding.protection_confirm.checkbox')}
</Text>
</TouchableOpacity>
<TouchableOpacity
disabled={!checked}
activeOpacity={0.85}
onPress={onConfirm}
style={{
backgroundColor: colors.brandOrange,
borderRadius: 12,
paddingVertical: 15,
alignItems: 'center',
marginTop: 18,
opacity: checked ? 1 : 0.4,
}}
>
<Text style={{ fontSize: 16, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('onboarding.protection_confirm.cta')}
</Text>
</TouchableOpacity>
</View>
</FormSheet>
);
}

View File

@ -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 <AndroidProtectionSlide {...props} />;
}
return <IosProtectionSlide {...props} />;
}
// ─── iOS ────────────────────────────────────────────────────────────────────
type IosPhase = 'preexplain_url' | 'preexplain_lock' | 'done';
function IosProtectionSlide({
export function ProtectionSlide({
onDone,
current,
total,
@ -52,531 +47,340 @@ function IosProtectionSlide({
total: number;
}) {
const { t } = useTranslation();
const [phase, setPhase] = useState<IosPhase>('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<string | null>(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<ConfirmStep | null>(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);
}
}
// Persistierten Screen-Time-Status laden, damit der Step nicht erneut gefragt
// wird, wenn der Code schon gesetzt ist (gleiche Logik wie blocker.tsx).
useEffect(() => {
if (Platform.OS !== 'ios') return;
protection
.getScreenTimePasscode()
.then((p) => {
if (p) setScreentimeConfirmed(true);
})
.catch(() => {});
}, []);
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);
}
}
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;
// "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();
setPhase('done');
onDone();
}
if (phase === 'preexplain_url') {
// 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'),
);
}
}
return result;
} catch (e: any) {
Alert.alert(t('blocker.activation_failed_title'), e?.message ?? t('common.unknown_error'));
return { enabled: false };
}
}
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 handleGenerateScreentimeCode() {
setScreentimeCode(Math.floor(1000 + Math.random() * 9000).toString());
setScreentimeConfirmed(false);
}
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 (
<PreExplainer
key="ios-url"
dialog="url_filter"
lyraBodyKey="onboarding.lyra.protection_url.body"
titleKey="onboarding.protection.url_title"
ctaKey="onboarding.protection.cta_primary"
buttonLabelKey="onboarding.protection.dialog_button_allow"
markerHintKey="onboarding.protection.tap_marker_hint"
activating={activating}
onActivate={activateUrlFilter}
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('common.continue')}
onPrimary={finishProtectionStep}
primaryDisabled={!allDone}
/>
}
>
<LyraBubble text={t('onboarding.lyra.protection.body')} emotion="empathy" />
<View style={{ marginTop: 16 }}>
{Platform.OS === 'android' ? (
<AndroidSetupFlow
vpnActive={urlFilterActive}
accessibilityLocked={appDeletionLockActive}
deviceAdminActive={deviceAdminActive}
onActivateVpn={gatedVpn}
onActivateAccessibility={gatedA11y}
onRequestDeviceAdmin={gatedDeviceAdmin}
colors={colors}
t={t}
/>
) : FAMILY_CONTROLS_AVAILABLE && !mdmManaged && !nefilterActive ? (
<IosUnsupervisedSetupFlow
familyControlsActive={appDeletionLockActive}
screentimeCode={screentimeCode}
screentimeConfirmed={screentimeConfirmed}
screentimeSaving={screentimeSaving}
urlFilterActive={urlFilterActive}
onActivateFamilyControls={gatedApplock}
onGenerateScreentimeCode={handleGenerateScreentimeCode}
onConfirmScreentime={handleScreentimeConfirm}
onActivateUrlFilter={gatedUrlFilter}
colors={colors}
t={t}
/>
) : (
/* iOS Distribution ohne Family-Controls-Entitlement (oder MDM/NEFilter):
nur der URL-Filter als einzelner Layer exakt wie der Blocker-Fallback. */
<LayerSwitchCard
icon="globe-outline"
title={t('blocker.layers_url_filter_title')}
subtitle={
urlFilterActive
? t('blocker.layers_url_filter_subtitle_active')
: t('blocker.layers_url_filter_subtitle_inactive')
}
active={urlFilterActive}
onActivate={gatedUrlFilter}
/>
)}
</View>
<PermissionConfirmSheet
visible={confirmStep !== null}
title={confirmStep ? t(`onboarding.protection_confirm.${confirmStep}_title`) : ''}
body={confirmStep ? t(`onboarding.protection_confirm.${confirmStep}_body`) : ''}
// a11y ist der komplizierte Step → reicher Explainer mit nummerierten
// Schritten (erst Overlay erlauben, dann ReBreak-Schalter) + Screenshot
// + Indikator. Die anderen Steps bleiben reiner Text.
// Keine Text-Schritte mehr im a11y-Sheet — das passive ReBreak-Overlay führt
// in den Settings Schritt für Schritt. Sheet bleibt dezent (Lyra-Satz + Bild).
steps={undefined}
screenshot={
confirmStep === 'a11y'
? getPermissionScreenshot('android_a11y_overview', i18n.language || 'de')
: undefined
}
indicatorCaption={
confirmStep === 'a11y' ? t('onboarding.protection_confirm.a11y_indicator') : undefined
}
onClose={() => 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);
}}
/>
<PermissionDeniedSheet
visible={permissionDeniedOpen}
onClose={() => setPermissionDeniedOpen(false)}
onRetry={async () => {
const res = await protection.resetUrlFilter();
if (res.enabled) {
if (!FAMILY_CONTROLS_AVAILABLE) {
finishProtectionStep();
} else {
setPhase('preexplain_lock');
}
}
if (res.enabled) await refresh();
return res;
}}
/>
</PreExplainer>
);
}
if (phase === 'preexplain_lock') {
return (
<PreExplainer
key="ios-lock"
dialog="screen_time"
lyraBodyKey="onboarding.lyra.protection_lock.body"
titleKey="onboarding.protection.lock_title"
ctaKey="onboarding.protection.cta_primary"
buttonLabelKey="onboarding.protection.dialog_button_continue"
markerHintKey="onboarding.protection.tap_marker_hint"
pointerAlignment="left"
activating={activating}
onActivate={activateAppLock}
current={current}
total={total}
>
<PermissionDeniedSheet
visible={familyControlsErrorOpen}
onClose={() => setFamilyControlsErrorOpen(false)}
variant="family_controls"
onRetry={async () => {
const res = await protection.activateFamilyControls();
if (res.enabled) {
finishProtectionStep();
}
if (res.enabled) await refresh();
return res;
}}
/>
</PreExplainer>
);
}
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<AndroidPhase>('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<void> {
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.
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 (phase === 'preexplain_vpn') {
return (
<PreExplainer
key="android-vpn"
dialog="android_vpn"
lyraBodyKey="onboarding.lyra.protection_url_android.body"
titleKey="onboarding.protection.url_title_android"
ctaKey="onboarding.protection.cta_primary"
buttonLabelKey="onboarding.protection.dialog_button_vpn_ok"
markerHintKey="onboarding.protection.tap_marker_hint_android_vpn"
activating={activating}
onActivate={activateVpn}
current={current}
total={total}
/>
);
}
if (phase === 'preexplain_a11y') {
return (
<PreExplainer
key="android-a11y"
dialog="android_a11y"
lyraBodyKey="onboarding.lyra.protection_lock_android.body"
titleKey="onboarding.protection.lock_title_android"
ctaKey="onboarding.protection.cta_open_a11y"
buttonLabelKey="onboarding.protection.dialog_button_a11y_toggle"
markerHintKey="onboarding.protection.tap_marker_hint_android_a11y"
activating={activating}
onActivate={activateA11y}
current={current}
total={total}
/>
);
}
if (phase === 'a11y_pending') {
return (
<A11yPendingView
current={current}
total={total}
activating={activating}
onRetry={activateA11y}
onResetForTesting={__DEV__ ? resetProtectionForTesting : undefined}
/>
);
}
return null;
}
function A11yPendingView({
current,
total,
activating,
onRetry,
onResetForTesting,
}: {
current: number;
total: number;
activating: boolean;
onRetry: () => void;
onResetForTesting?: () => void;
}) {
const { t } = useTranslation();
const colors = useColors();
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t('onboarding.protection.cta_check_a11y')}
onPrimary={onRetry}
primaryLoading={activating}
secondaryLabel={onResetForTesting ? 'Reset Schutz (DEV)' : undefined}
onSecondary={onResetForTesting}
/>
}
>
<LyraBubble
text={t('onboarding.protection.android_a11y_pending_body')}
emotion="empathy"
/>
<Text
style={{
marginTop: 14,
fontFamily: 'Nunito_700Bold',
fontSize: 12,
letterSpacing: 0.6,
color: colors.textMuted,
textTransform: 'uppercase',
textAlign: 'center',
}}
>
{t('onboarding.protection.android_a11y_pending_title')}
</Text>
</OnboardingShell>
);
}
// ─── PreExplainer (shared) ───────────────────────────────────────────────────
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);
// 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));
return (
<OnboardingShell
current={current}
total={total}
cta={
<CTABar
primaryLabel={t(ctaKey)}
onPrimary={onActivate}
primaryLoading={activating}
/>
}
>
<LyraBubble text={t(lyraBodyKey)} emotion="empathy" />
<Text
style={{
marginTop: 14,
fontFamily: 'Nunito_700Bold',
fontSize: 12,
letterSpacing: 0.6,
color: colors.textMuted,
textTransform: 'uppercase',
textAlign: 'center',
}}
>
{t(titleKey)}
</Text>
<View
style={{
marginTop: 8,
alignSelf: 'center',
height: screenshotHeight,
aspectRatio: 0.9,
}}
>
<Image
source={screenshot}
style={{ width: '100%', height: '100%' }}
resizeMode="contain"
/>
</View>
<ScreenshotPointer buttonLabel={t(buttonLabelKey)} alignment={pointerAlignment} />
<Text
style={{
marginTop: 10,
fontFamily: 'Nunito_400Regular',
fontSize: 12,
lineHeight: 17,
color: colors.textMuted,
textAlign: 'center',
paddingHorizontal: 8,
}}
>
{t(markerHintKey)}
</Text>
{children}
</OnboardingShell>
);
}

View File

@ -515,27 +515,52 @@ bump_ios_version() {
}
bump_android_version() {
log "Android versionCode Bump..."
log "Android Version Bump..."
local current_version_code
current_version_code=$(get_current_version_code)
local new_version_code
# 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
}

View File

@ -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<Dialog, Partial<Record<Lang, number>>> = {
@ -69,6 +78,12 @@ const SCREENSHOTS: Record<Dialog, Partial<Record<Lang, number>>> = {
fr: ANDROID_A11Y_FR,
ar: ANDROID_A11Y_AR,
},
android_a11y_overview: {
de: ANDROID_A11Y_OVERVIEW_DE,
en: ANDROID_A11Y_OVERVIEW_EN,
fr: ANDROID_A11Y_OVERVIEW_FR,
ar: ANDROID_A11Y_OVERVIEW_AR,
},
};
/**

View File

@ -218,6 +218,10 @@ export const protection = {
// (2) A11y aktiv → tamperLock armen → return {enabled:true}.
const a11y = await RebreakProtection.isAccessibilityEnabled();
if (!a11y.enabled) {
// Deep-Link direkt zu ReBreaks a11y-Detail-Page (5-stufige Fallback-Kette,
// Samsung-Highlight). Der Overlay-Gatekeeper, der das früher blockte, ist
// nativ entfernt → landet jetzt direkt am ReBreak-Schalter statt auf der
// Overlay-Permission-Seite. (Braucht den nativen Rebuild zum Greifen.)
await RebreakProtection.openAccessibilitySettings();
return { enabled: false, error: "accessibility_pending" };
}
@ -395,6 +399,48 @@ export const protection = {
return RebreakProtection.runHealthProbe(opts);
},
/** Android: Hat die App Nutzungszugriff (für den state-aware a11y-Guide)? */
async hasUsageAccess(): Promise<boolean> {
if (Platform.OS !== "android") return false;
try {
const r = await RebreakProtection.hasUsageAccess();
return r?.granted === true;
} catch {
return false;
}
},
/** Android: Öffnet die Nutzungszugriff-Settings zum Freigeben. */
async openUsageAccessSettings(): Promise<void> {
if (Platform.OS !== "android") return;
try {
await RebreakProtection.openUsageAccessSettings();
} catch (e) {
console.warn("[protection] openUsageAccessSettings failed:", e);
}
},
/** Android: Hat die App „Über anderen Apps anzeigen" (passives Guide-Overlay)? */
async hasOverlayPermission(): Promise<boolean> {
if (Platform.OS !== "android") return false;
try {
const r = await RebreakProtection.hasOverlayPermission();
return r?.granted === true;
} catch {
return false;
}
},
/** Android: Öffnet die „Über anderen Apps anzeigen"-Settings zum Freigeben. */
async openOverlayPermissionSettings(): Promise<void> {
if (Platform.OS !== "android") return;
try {
await RebreakProtection.openOverlayPermissionSettings();
} catch (e) {
console.warn("[protection] openOverlayPermissionSettings failed:", e);
}
},
openSystemSettings(target?: SystemSettingsTarget): Promise<void> {
return RebreakProtection.openSystemSettings(target);
},

View File

@ -409,7 +409,9 @@
"diga_choice": {
"body": "هل لديك رمز وصفة طبية من تأمينك الصحي؟ إذن كل شيء مفتوح لك."
},
"diga_code": { "body": "اكتب رمزك — سأتحقق منه لك." },
"diga_code": {
"body": "اكتب رمزك — سأتحقق منه لك."
},
"plan": {
"body": "لكي تستمر الحماية على جهازك، نحتاج إلى خطة — أول 14 يوماً مجاناً. ما الذي يناسبك؟"
},
@ -431,7 +433,9 @@
"protection_lock_android": {
"body": "الخطوة الأخيرة: سأفتح إعدادات إمكانية الوصول. ابحث عن «ReBreak» وفعّل المفتاح — ثم ارجع إلى التطبيق."
},
"done": { "body": "تم. اليوم الأول من سلسلتك — ولست وحدك." },
"done": {
"body": "تم. اليوم الأول من سلسلتك — ولست وحدك."
},
"audio_play": "تفعيل الصوت",
"audio_loading": "جاري تحميل الصوت...",
"audio_stop": "إيقاف التشغيل",
@ -559,6 +563,28 @@
"title": "اختر اسمك المستعار",
"body": "هذا هو اسمك الوحيد في rebreak. لا أحد يرى بريدك أو اسمك الحقيقي.",
"finish": "فهمت"
},
"protection_confirm": {
"checkbox": "فهمت",
"cta": "متابعة",
"vpn_title": "إذن VPN",
"vpn_body": "سيطلب Android إذن VPN الآن. اضغط «سماح/موافق» في المربع — إنه ليس VPN حقيقياً، الفلتر يعمل محلياً على جهازك.",
"deviceadmin_title": "حماية الجهاز",
"deviceadmin_body": "اضغط «تفعيل» في المربع التالي. تبقى الحماية فعّالة منذ إعادة التشغيل — لا تُطلب أي صلاحيات إضافية.",
"applock_title": "قفل التطبيق",
"applock_body": "انتبه في المربع التالي: اضغط الزر السفلي — وليس الأزرق. هذه الطريقة الوحيدة لتفعيل القفل.",
"urlfilter_title": "فلتر المحتوى",
"urlfilter_body": "سيطلب iOS إذناً للفلتر. اضغط «سماح».",
"a11y_title": "إمكانية الوصول",
"a11y_body": "فقط اتبع تلميح ReBreak — سيرشدك خطوة بخطوة إلى المفتاح. لا داعي لحفظ أي شيء.",
"a11y_step1": "اضغط «الخدمات المثبّتة».",
"a11y_step2": "اضغط ReBreak (ستعرفه من الشعار).",
"a11y_indicator": "ReBreak يرشدك خطوة بخطوة",
"a11y_step3": "فعّل المفتاح، وأكّد مربع الحوار — ثم ارجع إلى التطبيق.",
"usage_title": "لمرة واحدة: تفعيل الإرشاد خطوة بخطوة",
"usage_body": "قائمة إمكانية الوصول في Samsung معقّدة. لكي أرشدك خطوة بخطوة، امنح ReBreak «الوصول إلى بيانات الاستخدام» مرة واحدة — سأفتح الصفحة الآن. فعّله هناك، ارجع واضغط الحماية مرة أخرى.",
"overlay_title": "لمرة واحدة: السماح بطبقة التلميح",
"overlay_body": "لكي يظهر تلميحي بوضوح أمام الإعدادات بدلاً من إخفائه، امنح ReBreak «العرض فوق التطبيقات الأخرى» مرة واحدة — سأفتح الصفحة. فعّله هناك، ارجع واضغط الحماية مرة أخرى."
}
},
"protection_onboarding": {
@ -1204,7 +1230,7 @@
"devices": {
"section_title_this": "هذا الجهاز",
"section_title_others": "أجهزة محمية أخرى",
"subtitle_legend": "الحماية على ما يصل إلى 3 أجهزة — بغض النظر عن الجهاز المستخدم.",
"subtitle_legend": "حماية شاملة على ما يصل إلى 5 أجهزة — 3 أجهزة محمولة و2 كمبيوتر.",
"subtitle_free": "الجهاز الحالي محمي.",
"add_mac": "إضافة Mac",
"add_windows": "إضافة Windows (قريباً)",
@ -1265,7 +1291,10 @@
"release_cancel": "إلغاء التحرير",
"release_cancel_confirm": "إلغاء التحرير فعلاً؟",
"release_cancel_body": "سيظل الجهاز مرتبطاً بحسابك.",
"release_cancel_cta": "نعم، إلغاء"
"release_cancel_cta": "نعم، إلغاء",
"subtitle_pro": "حماية لهاتفك وجهاز الكمبيوتر الخاص بك — حيث تكون الحاجة حقيقية.",
"progress_mobile": "محمول (iOS / Android)",
"progress_desktop": "كمبيوتر (Mac / Windows)"
},
"plan": {
"change": {
@ -1435,5 +1464,34 @@
"body": "هذا استثنائي — وأنت تساعدنا في الحصول على اعتماد ReBreak كتطبيق صحة رقمي (DiGA). نحتاج إلى بيانات ديموغرافية مجهولة الهوية. طوعي، دقيقتان فقط.",
"cta": "ملء البيانات",
"later": "ربما لاحقًا"
},
"magic": {
"tagline_mac": "اربط iPhone واحمِ جهاز Mac — في 30 ثانية.",
"tagline_windows": "حماية من القمار لجهاز Windows الخاص بك — خلال دقيقتين.",
"platform_question": "أي كمبيوتر تريد حمايته؟",
"step1_title": "1. تنزيل %{app}",
"step1_body_mac": "افتحه على جهاز Mac (الحد الأدنى macOS %{version}).",
"step1_body_windows": "افتحه على جهاز Windows (الحد الأدنى Windows %{version}).",
"open_download": "فتح التنزيل",
"send_link_mac": "إرسال الرابط إلى جهاز Mac",
"send_link_windows": "إرسال الرابط إلى جهاز الكمبيوتر",
"step2_title": "2. إنشاء رمز الاقتران",
"limit_reached": "تم بلوغ حد أجهزة الكمبيوتر (%{count}/%{max}).",
"limit_hint_legend": "أزل أولاً أحد أجهزة الكمبيوتر المتصلة.",
"limit_hint_pro": "أزل أولاً جهاز كمبيوتر — أو قم بالترقية إلى Legend لجهازين.",
"code_explainer": "أنشئ رمزاً من 6 أرقام وأدخله في %{app}. صالح لمدة 10 دقائق ولاستخدام واحد.",
"generating": "جارٍ الإنشاء…",
"generate_new": "إنشاء رمز جديد",
"generate": "إنشاء رمز",
"enter_in_app": "أدخله في %{app}:",
"expires_in": "ينتهي خلال %{time}",
"copy": "نسخ",
"discard_code": "تجاهل الرمز",
"connected_title": "أجهزة الكمبيوتر المتصلة",
"connected_empty": "لا يوجد كمبيوتر متصل بعد. بمجرد استخدام رمز الاقتران في تطبيق Mac أو Windows، سيظهر هنا.",
"generate_error": "فشل إنشاء الرمز",
"app_mac": "تطبيق Mac",
"app_windows": "تطبيق Windows",
"manual_fallback": "بدون تطبيق؟ ثبّت ملف DNS يدوياً"
}
}

View File

@ -676,12 +676,16 @@
"applock_body": "Achtung im nächsten Dialog: Tipp den UNTEREN Button — nicht den blauen. Nur so wird die App-Sperre aktiv.",
"urlfilter_title": "Inhaltsfilter",
"urlfilter_body": "Gleich fragt iOS nach Erlaubnis für den Filter. Tipp auf „Erlauben“.",
"a11y_title": "Schutz-Wächter (Bedienungshilfen)",
"a11y_body": "Dieser Schritt ist etwas länger — ich führ dich durch. Über die Bedienungshilfen schützt ReBreak deine Einstellungen vor versehentlichem Abschalten.",
"a11y_step1": "Zuerst fragt Android nach „Über anderen Apps anzeigen“ — tippe Erlauben (brauchen wir, um dir den nächsten Schritt einzublenden).",
"a11y_step2": "Dann öffnet sich die Bedienungshilfen-Liste — such „ReBreak“.",
"a11y_step3": "Tippe ReBreak an und schalte den Schalter ein. Komm danach zurück zur App.",
"a11y_indicator": "Hier ReBreak antippen & einschalten"
"a11y_title": "Bedienungshilfen",
"a11y_body": "Folge einfach dem ReBreak-Hinweis — er führt dich in den Einstellungen Schritt für Schritt zum Schalter. Du musst dir nichts merken.",
"a11y_step1": "Tippe auf „Installierte Dienste“.",
"a11y_step2": "Tippe ReBreak an (am Logo erkennbar).",
"a11y_indicator": "ReBreak begleitet dich Schritt für Schritt",
"a11y_step3": "Schalter ein, im Dialog bestätigen — dann zurück zur App.",
"usage_title": "Einmalig: Schritt-Führung freischalten",
"usage_body": "Samsungs Bedienungshilfen-Menü ist fummelig. Damit ich dich Schritt für Schritt führen kann, gib ReBreak einmal „Nutzungszugriff“ — ich öffne gleich die Seite. Schalt ReBreak dort an, komm zurück und tipp nochmal auf den Schutz.",
"overlay_title": "Einmalig: Hinweis-Overlay erlauben",
"overlay_body": "Damit mein Hinweis sichtbar VOR den Einstellungen schwebt (statt versteckt in der Leiste), gib ReBreak einmal „Über anderen Apps anzeigen“ — ich öffne die Seite. Schalt ReBreak dort an, komm zurück und tipp nochmal auf den Schutz."
}
},
"protection_onboarding": {

View File

@ -676,12 +676,16 @@
"applock_body": "Heads up in the next dialog: tap the BOTTOM button — not the blue one. Thats the only way the app lock turns on.",
"urlfilter_title": "Content filter",
"urlfilter_body": "iOS will now ask permission for the filter. Tap “Allow”.",
"a11y_title": "Protection guard (Accessibility)",
"a11y_body": "This step is a little longer — Ill guide you. Via Accessibility, ReBreak protects your settings from being switched off by accident.",
"a11y_step1": "First Android asks for “Display over other apps” — tap Allow (we need it to show you the next step on screen).",
"a11y_step2": "Then the Accessibility list opens — find “ReBreak”.",
"a11y_step3": "Tap ReBreak and turn the switch on. Then come back to the app.",
"a11y_indicator": "Tap ReBreak here & switch it on"
"a11y_title": "Accessibility",
"a11y_body": "Just follow the ReBreak hint — it guides you step by step to the switch in Settings. Nothing to memorise.",
"a11y_step1": "Tap “Installed services”.",
"a11y_step2": "Tap ReBreak (youll recognise it by the logo).",
"a11y_indicator": "ReBreak guides you step by step",
"a11y_step3": "Turn the switch on, confirm the dialog — then come back to the app.",
"usage_title": "One-time: enable step-by-step guidance",
"usage_body": "Samsungs accessibility menu is fiddly. So I can guide you step by step, give ReBreak “Usage access” once — Ill open the page now. Turn ReBreak on there, come back and tap protection again.",
"overlay_title": "One-time: allow the hint overlay",
"overlay_body": "So my hint sits visibly in front of Settings (instead of hidden in the notification shade), give ReBreak “Display over other apps” once — Ill open the page. Turn ReBreak on there, come back and tap protection again."
}
},
"protection_onboarding": {

View File

@ -407,7 +407,9 @@
"diga_choice": {
"body": "Tu as un code d'ordonnance de ta caisse d'assurance ? Alors tout est débloqué pour toi."
},
"diga_code": { "body": "Tape ton code — je le vérifie pour toi." },
"diga_code": {
"body": "Tape ton code — je le vérifie pour toi."
},
"plan": {
"body": "Pour faire tourner la protection sur ton appareil, il nous faut un plan — les 14 premiers jours sont offerts. Qu'est-ce qui te convient ?"
},
@ -559,6 +561,28 @@
"title": "Choisis ton alias",
"body": "C'est ton seul nom sur rebreak. Personne ne voit ton e-mail ni ton vrai nom.",
"finish": "Compris"
},
"protection_confirm": {
"checkbox": "Jai compris",
"cta": "Continuer",
"vpn_title": "Autorisation VPN",
"vpn_body": "Android va demander lautorisation VPN. Touche « Autoriser/OK » dans la boîte de dialogue — ce nest pas un vrai VPN, le filtre tourne localement sur ton appareil.",
"deviceadmin_title": "Protection de lappareil",
"deviceadmin_body": "Touche « Activer » dans la boîte de dialogue suivante. La protection reste active dès le redémarrage — aucun autre droit nest demandé.",
"applock_title": "Verrou dapp",
"applock_body": "Attention dans la boîte suivante : touche le bouton du BAS — pas le bleu. Cest la seule façon dactiver le verrou.",
"urlfilter_title": "Filtre de contenu",
"urlfilter_body": "iOS va demander lautorisation pour le filtre. Touche « Autoriser ».",
"a11y_title": "Accessibilité",
"a11y_body": "Suis simplement lindication ReBreak — elle te guide pas à pas jusquà linterrupteur. Rien à retenir.",
"a11y_step1": "Touche « Services installés ».",
"a11y_step2": "Touche ReBreak (reconnaissable au logo).",
"a11y_indicator": "ReBreak te guide pas à pas",
"a11y_step3": "Active linterrupteur, confirme la boîte de dialogue — puis reviens dans lapp.",
"usage_title": "Une fois : activer le guidage pas à pas",
"usage_body": "Le menu daccessibilité de Samsung est pénible. Pour te guider étape par étape, donne à ReBreak l« accès aux données dusage » une fois — jouvre la page. Active ReBreak là, reviens et touche à nouveau la protection.",
"overlay_title": "Une fois : autoriser loverlay daide",
"overlay_body": "Pour que mon indication saffiche devant les Réglages (au lieu dêtre cachée dans le tiroir), donne à ReBreak « Superposition à dautres applis » une fois — jouvre la page. Active ReBreak là, reviens et touche à nouveau la protection."
}
},
"mail": {
@ -1193,7 +1217,7 @@
"devices": {
"section_title_this": "Cet appareil",
"section_title_others": "Autres appareils protégés",
"subtitle_legend": "Protection sur jusqu'à 3 appareils — quel que soit celui que vous utilisez.",
"subtitle_legend": "Protection sans faille sur jusqu'à 5 appareils — 3 mobiles, 2 ordinateurs.",
"subtitle_free": "Appareil actuel protégé.",
"add_mac": "Ajouter un Mac",
"add_windows": "Ajouter Windows (bientôt)",
@ -1251,7 +1275,10 @@
"release_cancel": "Annuler la libération",
"release_cancel_confirm": "Vraiment annuler la libération ?",
"release_cancel_body": "L'appareil restera lié à votre compte.",
"release_cancel_cta": "Oui, annuler"
"release_cancel_cta": "Oui, annuler",
"subtitle_pro": "Protection pour ton téléphone et ton ordinateur — là où ça compte vraiment.",
"progress_mobile": "Mobile (iOS / Android)",
"progress_desktop": "Ordinateur (Mac / Windows)"
},
"plan": {
"change": {
@ -1421,5 +1448,34 @@
"body": "C'est extraordinaire — et tu nous aides à faire certifier ReBreak comme DiGA (Application de Santé Numérique). Nous avons besoin de données démographiques anonymes. Volontaire, 2 minutes.",
"cta": "Remplir les données",
"later": "Peut-être plus tard"
},
"magic": {
"tagline_mac": "Lie ton iPhone et protège ton Mac — en 30 secondes.",
"tagline_windows": "Protection anti-jeu pour ton PC Windows — en 2 minutes.",
"platform_question": "Quel ordinateur veux-tu protéger ?",
"step1_title": "1. Télécharger %{app}",
"step1_body_mac": "Ouvre-le sur ton Mac (min. macOS %{version}).",
"step1_body_windows": "Ouvre-le sur ton PC Windows (min. Windows %{version}).",
"open_download": "Ouvrir le téléchargement",
"send_link_mac": "Envoyer le lien à mon Mac",
"send_link_windows": "Envoyer le lien à mon PC",
"step2_title": "2. Générer le code d'appairage",
"limit_reached": "Limite d'ordinateurs atteinte (%{count}/%{max}).",
"limit_hint_legend": "Retire d'abord un ordinateur connecté.",
"limit_hint_pro": "Retire d'abord un ordinateur — ou passe à Legend pour 2 ordinateurs.",
"code_explainer": "Génère un code à 6 chiffres et saisis-le dans %{app}. Valable 10 minutes, à usage unique.",
"generating": "Génération…",
"generate_new": "Générer un nouveau code",
"generate": "Générer le code",
"enter_in_app": "À saisir dans %{app} :",
"expires_in": "Expire dans %{time}",
"copy": "Copier",
"discard_code": "Abandonner le code",
"connected_title": "Ordinateurs connectés",
"connected_empty": "Aucun ordinateur connecté pour l'instant. Dès que tu utilises un code d'appairage dans l'app Mac ou Windows, il apparaît ici.",
"generate_error": "Échec de la génération",
"app_mac": "l'app Mac",
"app_windows": "l'app Windows",
"manual_fallback": "Sans app ? Installer le profil DNS manuellement"
}
}

View File

@ -2,7 +2,14 @@ package expo.modules.rebreakprotection
import android.accessibilityservice.AccessibilityServiceInfo
import android.app.Activity
import android.app.AppOpsManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.admin.DevicePolicyManager
import android.app.usage.UsageEvents
import android.app.usage.UsageStatsManager
import androidx.core.app.NotificationCompat
import android.content.ComponentName
import android.content.Context
import android.content.Intent
@ -17,7 +24,6 @@ import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import expo.modules.rebreakprotection.admin.RebreakDeviceAdminReceiver
@ -65,11 +71,8 @@ class RebreakProtectionModule : Module() {
private var stickyHintStepIndex: Int = 0
private var samsungGuideOverlay: LinearLayout? = null
private var samsungGuideTextView: TextView? = null
private var samsungGuidePrevButton: Button? = null
private var samsungGuideNextButton: Button? = null
private var samsungGuideWindowManager: WindowManager? = null
private var samsungGuideLayoutParams: WindowManager.LayoutParams? = null
private var samsungGuideWatchRunnable: Runnable? = null
override fun definition() = ModuleDefinition {
Name("RebreakProtection")
@ -103,7 +106,25 @@ class RebreakProtectionModule : Module() {
CodedException("no_activity", "Activity nicht verfügbar — App im Hintergrund?", null)
)
val consentIntent = VpnService.prepare(activity)
// VpnService.prepare() kann auf manchen OEMs/Configs werfen (z.B. aktives
// Always-On-VPN eines anderen Apps, Work-Profile, NPE auf älteren Samsung).
// Ungefangen → Expo rejected → JS-Onboarding verschluckt es in Release-Builds
// → User sieht keinen Dialog & kommt nicht weiter. Lieber sauber resolven
// mit Fehler, damit die UI eine Meldung + Retry zeigen kann.
val consentIntent = try {
VpnService.prepare(activity)
} catch (e: Exception) {
Log.w(TAG, "VpnService.prepare() threw: ${e.message}", e)
saveEnabled(false)
promise.resolve(
mapOf(
"allLayersOn" to false,
"missingLayers" to listOf("vpn", "accessibility", "tamperLock"),
"errors" to listOf("vpn_prepare_failed: ${e.message ?: e::class.java.simpleName}"),
)
)
return@AsyncFunction
}
if (consentIntent == null) {
startVpnService()
saveEnabled(true)
@ -115,7 +136,22 @@ class RebreakProtectionModule : Module() {
return@AsyncFunction
}
pendingActivatePromise = promise
try {
activity.startActivityForResult(consentIntent, VPN_CONSENT_REQUEST_CODE)
} catch (e: Exception) {
// Dialog-Launch fehlgeschlagen (z.B. ActivityNotFound auf gestripptem
// System-VPN-UI). Promise NICHT hängen lassen — sonst Silent-Stuck.
Log.w(TAG, "startActivityForResult(VPN consent) threw: ${e.message}", e)
pendingActivatePromise = null
saveEnabled(false)
promise.resolve(
mapOf(
"allLayersOn" to false,
"missingLayers" to listOf("vpn", "accessibility", "tamperLock"),
"errors" to listOf("vpn_consent_launch_failed: ${e.message ?: e::class.java.simpleName}"),
)
)
}
}
}
@ -263,15 +299,12 @@ class RebreakProtectionModule : Module() {
AsyncFunction("openAccessibilitySettings") { promise: Promise ->
val ctx = requireContext()
// Für den Samsung-Schrittguide brauchen wir Overlay-Recht.
// Fehlt es, zuerst die Systemseite dafür öffnen (einmaliger Gatekeeper).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(ctx)) {
if (openOverlayPermissionSettings(ctx)) {
promise.resolve(mapOf("opened" to true, "via" to "overlay-permission"))
return@AsyncFunction
}
}
// KEIN Overlay-Permission-Gatekeeper mehr davor: der blockte sonst den
// ganzen Deep-Link (öffnete erst die "Über anderen Apps anzeigen"-Seite
// statt der Bedienungshilfen → User landet falsch). Das Overlay-Guide ist
// seit dem in-App Explainer-Sheet redundant; auf den Listen-Fallbacks
// (Stufe 4/5) versucht startSamsungGuideOverlay() es ohnehin nochmal und
// degradiert sauber zum Sticky-Hint, wenn die Permission fehlt.
val rebreakA11yComponent = ComponentName(ctx, RebreakAccessibilityService::class.java)
val componentFlat = rebreakA11yComponent.flattenToString()
@ -353,11 +386,16 @@ class RebreakProtectionModule : Module() {
try {
ctx.startActivity(intent)
Log.i(TAG, "openA11y: $tag → started ($resolved)")
// Bei den Fallback-Pfaden (4 + 5) Toast-Anleitung zeigen damit User
// emotional nicht in der generischen Eingabehilfe-Page verloren geht.
// Auf der Detail-Page (1-3) ist die UI klar genug, kein Toast nötig.
// Landet auf der a11y-Übersicht/Liste (Samsung: Detail/Dienste-Liste sind
// signature-gesperrt → tiefer kommen wir nicht). Hier den User durch die
// Navigation FÜHREN: State-aware Notification-Guide (UsageStats erkennt den
// echten Settings-Screen, schaltet die Nachricht selbst weiter). KEIN
// Overlay (das wäre ein zweites UI zum Bedienen). Ohne Usage-Access-Recht
// → dummer Sticky-Hint-Toast als Fallback.
if (tag == "list-highlighted" || tag == "list-plain") {
if (!startSamsungGuideOverlay(ctx)) {
if (hasUsageAccess(ctx)) {
startA11yGuideWatch(ctx)
} else {
val hintText = currentStickyHintText(ctx)
startStickyHint(ctx, hintText)
}
@ -371,6 +409,38 @@ class RebreakProtectionModule : Module() {
promise.reject(CodedException("open_failed", "no accessibility settings activity available", null))
}
// Hat die App "Nutzungszugriff"? (→ state-aware a11y-Setup-Guide möglich)
AsyncFunction("hasUsageAccess") {
mapOf("granted" to hasUsageAccess(requireContext()))
}
// Öffnet die "Nutzungsdaten-Zugriff"-Settings, damit der User ReBreak freigibt.
AsyncFunction("openUsageAccessSettings") {
val ctx = requireContext()
val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
try {
ctx.startActivity(intent)
mapOf("opened" to true)
} catch (e: Exception) {
Log.w(TAG, "openUsageAccessSettings: ${e.message}")
mapOf("opened" to false)
}
}
// Hat die App das "Über anderen Apps anzeigen"-Recht? (→ passives Guide-Overlay)
AsyncFunction("hasOverlayPermission") {
val ctx = requireContext()
val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx)
mapOf("granted" to granted)
}
// Öffnet die "Über anderen Apps anzeigen"-Settings für ReBreak.
AsyncFunction("openOverlayPermissionSettings") {
mapOf("opened" to openOverlayPermissionSettings(requireContext()))
}
AsyncFunction("armTamperLock") { promise: Promise ->
val ctx = requireContext()
val vpn = isVpnEffectivelyOn(ctx)
@ -564,9 +634,257 @@ class RebreakProtectionModule : Module() {
mainHandler.post(runnable)
}
private fun startSamsungGuideOverlay(ctx: Context): Boolean {
// ─── State-aware a11y-Setup-Guide (UsageStats → Notification) ────────────────
//
// KEIN Overlay (wäre ein zweites UI, das der überforderte User bedienen müsste).
// Stattdessen: UsageStatsManager erkennt den echten Settings-Screen, und wir
// posten/aktualisieren EINE Notification mit der passenden Nachricht. Der User
// bedient nur Settings; die Notification ist reines, passives Feedback.
// Verifizierte Samsung-Activities (A50, One UI):
// AccessibilityHomepageActivity → Übersicht → "Installierte Dienste"
// InstalledServicesActivity → Dienste → "ReBreak wählen"
// AccessibilityDetailsSettings → ReBreak → "Schalter ein"
private var a11yGuideWatchRunnable: Runnable? = null
private var lastGuideStep = Int.MIN_VALUE
/** Hat die App "Nutzungszugriff" (PACKAGE_USAGE_STATS via AppOps)? */
private fun hasUsageAccess(ctx: Context): Boolean {
return try {
val appOps = ctx.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
val uid = android.os.Process.myUid()
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
appOps.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, uid, ctx.packageName)
} else {
@Suppress("DEPRECATION")
appOps.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, uid, ctx.packageName)
}
mode == AppOpsManager.MODE_ALLOWED
} catch (e: Exception) {
Log.w(TAG, "hasUsageAccess: ${e.message}")
false
}
}
/** Klassenname der zuletzt in den Vordergrund gekommenen Activity (letzte ~12s). */
private fun currentForegroundActivity(ctx: Context): String? {
return try {
val usm = ctx.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
val now = System.currentTimeMillis()
val events = usm.queryEvents(now - 12_000L, now)
val e = UsageEvents.Event()
var lastClass: String? = null
while (events.hasNextEvent()) {
events.getNextEvent(e)
val isFg = e.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
e.eventType == UsageEvents.Event.ACTIVITY_RESUMED)
if (isFg) lastClass = e.className
}
lastClass
} catch (e: Exception) {
Log.w(TAG, "currentForegroundActivity: ${e.message}")
null
}
}
/**
* Aktueller a11y-Guide-Step aus der UsageStats-Event-SEQUENZ (nicht nur der
* letzten Activity). Samsung nutzt für Dienste-Liste UND ReBreak-Detail dieselbe
* generische `SubSettings`-Activity unterscheidbar nur über die TIEFE:
* Homepage setzt Tiefe 0
* 1. SubSettings nach Homepage Liste (Step 1: ReBreak wählen)
* 2. SubSettings (tiefer) Detail (Step 2: Schalter + Zulassen)
* Für den normalen Vorwärts-Flow (HomepageListeDetail) zuverlässig. Geht der
* User zurück, kann die Tiefe überschätzen (Back tiefer, da instanceId via
* Public-API nicht lesbar) selbstkorrigierend, da der Guide weiter zum Ziel führt.
* Rückgabe: 0/1/2 = Step, -1 = nicht in der a11y-Navigation (verirrt).
*/
private fun currentA11yStep(ctx: Context): Int {
return try {
val usm = ctx.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
val now = System.currentTimeMillis()
val events = usm.queryEvents(now - 60_000L, now)
val e = UsageEvents.Event()
var lastFg: String? = null
var subDepth = 0
while (events.hasNextEvent()) {
events.getNextEvent(e)
val isResumed = e.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND ||
(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
e.eventType == UsageEvents.Event.ACTIVITY_RESUMED)
if (!isResumed) continue
val c = (e.className ?: "").lowercase()
lastFg = c
when {
c.contains("accessibilityhomepage") -> subDepth = 0
c.contains("accessibilitydetails") -> subDepth = 2
c.contains("installedservices") -> subDepth = 1
c.contains("accessibility") && c.contains("subsettings") ->
if (subDepth < 2) subDepth += 1
}
}
val last = lastFg ?: return -1
when {
last.contains("accessibilityhomepage") -> 0
last.contains("accessibility") && (
last.contains("subsettings") || last.contains("installedservices") ||
last.contains("accessibilitydetails")
) -> if (subDepth >= 2) 2 else 1
last.contains("accessibilitysettings") -> 0
else -> -1
}
} catch (ex: Exception) {
Log.w(TAG, "currentA11yStep: ${ex.message}")
-1
}
}
/** Settings-Activity → Guide-Step. -1 = nicht in der a11y-Navigation (verirrt). */
private fun a11yStepForActivity(cls: String?): Int {
if (cls == null) return -1
val c = cls.lowercase()
return when {
// Übersicht (Samsung Homepage / AOSP Liste)
c.contains("accessibilityhomepage") -> 0
// AOSP/Pixel: getrennte Detail-Page (Schalter)
c.contains("accessibilitydetails") -> 2
// AOSP: Dienste-Liste
c.contains("installedservices") -> 1
// Samsung One UI: Dienste-Liste UND ReBreak-Detail laufen BEIDE über die
// generische winset-SubSettings-Activity — via UsageStats nicht trennbar.
// Daher alle a11y-SubSettings = Step 1 mit kombinierter Anweisung
// (ReBreak wählen → Schalter → Zulassen). Auf "accessibility" einschränken,
// damit kein fremder Settings-SubSettings fälschlich matched.
c.contains("accessibility") && c.contains("subsettings") -> 1
// AOSP-Übersicht (generische Action-Activity)
c.contains("accessibilitysettings") -> 0
else -> -1
}
}
private fun ensureGuideChannel(ctx: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(GUIDE_CHANNEL_ID) == null) {
val ch = NotificationChannel(
GUIDE_CHANNEL_ID,
ctx.getString(R.string.a11y_guide_title),
NotificationManager.IMPORTANCE_HIGH, // Heads-up bei jedem Schritt-Wechsel
).apply {
setSound(null, null)
enableVibration(false)
setShowBadge(false)
}
nm.createNotificationChannel(ch)
}
}
}
private fun postGuideNotification(ctx: Context, step: Int) {
ensureGuideChannel(ctx)
val msg = when (step) {
0 -> ctx.getString(R.string.a11y_hint_step_open_installed)
1 -> ctx.getString(R.string.a11y_hint_step_select_rebreak)
2 -> ctx.getString(R.string.a11y_hint_step_enable_toggle)
else -> ctx.getString(R.string.a11y_guide_lost)
}
val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val piFlags = PendingIntent.FLAG_UPDATE_CURRENT or
(if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
val pi = launch?.let { PendingIntent.getActivity(ctx, 0, it, piFlags) }
val notif = NotificationCompat.Builder(ctx, GUIDE_CHANNEL_ID)
.setSmallIcon(ctx.applicationInfo.icon)
.setContentTitle(ctx.getString(R.string.a11y_guide_title))
.setContentText(msg)
.setStyle(NotificationCompat.BigTextStyle().bigText(msg))
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setOngoing(true)
.setOnlyAlertOnce(false)
.apply { pi?.let { setContentIntent(it) } }
.build()
try {
(ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.notify(GUIDE_NOTIF_ID, notif)
} catch (e: Exception) {
Log.w(TAG, "postGuideNotification: ${e.message}")
}
}
private fun cancelGuideNotification(ctx: Context) {
try {
(ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.cancel(GUIDE_NOTIF_ID)
} catch (_: Exception) {
}
lastGuideStep = Int.MIN_VALUE
}
/** Pollt den Settings-Screen + aktualisiert das Guide-Display (Overlay/Notif), bis a11y an ist. */
private fun startA11yGuideWatch(ctx: Context) {
a11yGuideWatchRunnable?.let { mainHandler.removeCallbacks(it) }
lastGuideStep = Int.MIN_VALUE
val startedAt = android.os.SystemClock.uptimeMillis()
val maxMs = STICKY_HINT_MAX_SEC * 1000L
val runnable = object : Runnable {
override fun run() {
if (isAccessibilityServiceEnabled(ctx)) {
stopGuideDisplay(ctx)
a11yGuideWatchRunnable = null
// 1s warten, BEVOR wir in die App zurück-routen — gibt dem System Zeit,
// den frisch aktivierten a11y-Service zu propagieren, sonst erkennt die
// App ihn beim Foreground-Return evtl. noch nicht. Davor Settings auf die
// Startseite zurücksetzen (Suchbegriff + a11y-Detailstand wegwischen), dann
// ~250ms später ReBreak drüberlegen.
mainHandler.postDelayed({
resetSettingsToRoot(ctx)
mainHandler.postDelayed({ bringRebreakToFront(ctx) }, 250L)
}, 1000L)
Log.i(TAG, "a11yGuide: service enabled → reset settings + return in ~1.25s")
return
}
if (android.os.SystemClock.uptimeMillis() - startedAt >= maxMs) {
stopGuideDisplay(ctx)
a11yGuideWatchRunnable = null
return
}
val step = currentA11yStep(ctx)
if (step != lastGuideStep) {
lastGuideStep = step
showGuideStep(ctx, step)
}
mainHandler.postDelayed(this, 500L)
}
}
a11yGuideWatchRunnable = runnable
mainHandler.postDelayed(runnable, 400L)
}
private fun stopA11yGuideWatch(ctx: Context) {
a11yGuideWatchRunnable?.let { mainHandler.removeCallbacks(it) }
a11yGuideWatchRunnable = null
stopGuideDisplay(ctx)
}
/** Step-Text (ohne Branding — das Branding macht der „ReBreak"-Header). */
private fun guideStepText(ctx: Context, step: Int): String = when (step) {
0 -> ctx.getString(R.string.a11y_hint_step_open_installed)
1 -> ctx.getString(R.string.a11y_hint_step_select_rebreak)
2 -> ctx.getString(R.string.a11y_hint_step_enable_toggle)
else -> ctx.getString(R.string.a11y_guide_lost)
}
/**
* PASSIVES Overlay (kein Button, kein Touch-Intercept): schwebt oben VOR der
* Settings-App und zeigt ReBreak" + den aktuellen Schritt. Der User liest nur,
* tippt weiter in Settings (FLAG_NOT_TOUCHABLE lässt Touches durch). Auto-Advance
* macht der Watch via currentA11yStep. Gibt false zurück wenn Overlay-Recht fehlt
* Caller nutzt dann die Notification.
*/
private fun showOverlayStep(ctx: Context, step: Int): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(ctx)) {
Log.w(TAG, "samsungGuide: SYSTEM_ALERT_WINDOW not granted, fallback to toast")
return false
}
stopStickyHint()
@ -574,113 +892,65 @@ class RebreakProtectionModule : Module() {
try {
if (samsungGuideOverlay == null) {
samsungGuideWindowManager = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
// Full-width Banner UNTEN mit oben abgerundeten Ecken (bottom-sheet-Look).
val roundedBg = android.graphics.drawable.GradientDrawable().apply {
setColor(Color.parseColor("#F2191F24"))
// [tl, tl, tr, tr, br, br, bl, bl] → nur oben rund
cornerRadii = floatArrayOf(48f, 48f, 48f, 48f, 0f, 0f, 0f, 0f)
}
val container = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding(26, 20, 26, 18)
setBackgroundColor(Color.parseColor("#E6191F24"))
setPadding(56, 28, 56, 40)
background = roundedBg
}
val title = TextView(ctx).apply {
text = ctx.getString(R.string.a11y_guide_title)
setTextColor(Color.WHITE)
textSize = 14f
val header = TextView(ctx).apply {
text = "ReBreak"
setTextColor(Color.parseColor("#FF8A3D"))
textSize = 13f
setTypeface(typeface, android.graphics.Typeface.BOLD)
}
val message = TextView(ctx).apply {
setTextColor(Color.WHITE)
textSize = 16f
setPadding(0, 10, 0, 14)
textSize = 18f
setPadding(0, 6, 0, 0)
}
val actions = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT,
)
}
val prev = Button(ctx).apply {
text = ctx.getString(R.string.a11y_guide_btn_prev)
setOnClickListener {
if (stickyHintStepIndex > 0) {
stickyHintStepIndex -= 1
updateSamsungGuideOverlayUi(ctx)
}
}
}
val next = Button(ctx).apply {
layoutParams = LinearLayout.LayoutParams(
0,
LinearLayout.LayoutParams.WRAP_CONTENT,
1f,
).apply {
marginStart = 12
}
setOnClickListener {
if (stickyHintStepIndex < 3) {
stickyHintStepIndex += 1
updateSamsungGuideOverlayUi(ctx)
} else {
stopSamsungGuideOverlay()
}
}
}
actions.addView(prev)
actions.addView(next)
container.addView(title)
container.addView(header)
container.addView(message)
container.addView(actions)
val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else WindowManager.LayoutParams.TYPE_PHONE,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
PixelFormat.TRANSLUCENT,
)
).apply {
gravity = Gravity.BOTTOM
y = 0
}
samsungGuideOverlay = container
samsungGuideTextView = message
samsungGuidePrevButton = prev
samsungGuideNextButton = next
samsungGuideLayoutParams = lp
applySamsungGuideOverlayPosition()
samsungGuideWindowManager?.addView(container, lp)
}
updateSamsungGuideOverlayUi(ctx)
startSamsungGuideWatch(ctx)
samsungGuideTextView?.text = guideStepText(ctx, step)
} catch (e: Exception) {
Log.w(TAG, "samsungGuide: failed to show overlay: ${e.message}")
Log.w(TAG, "showOverlayStep: ${e.message}")
}
}
return true
}
private fun startSamsungGuideWatch(ctx: Context) {
samsungGuideWatchRunnable?.let { mainHandler.removeCallbacks(it) }
val runnable = object : Runnable {
override fun run() {
if (samsungGuideOverlay == null) {
samsungGuideWatchRunnable = null
return
/** Zeigt den Step bevorzugt als passives Overlay, sonst als Notification. */
private fun showGuideStep(ctx: Context, step: Int) {
if (!showOverlayStep(ctx, step)) {
postGuideNotification(ctx, step)
}
if (isAccessibilityServiceEnabled(ctx)) {
resetStickyHintProgress()
}
private fun stopGuideDisplay(ctx: Context) {
stopSamsungGuideOverlay()
bringRebreakToFront(ctx)
return
}
mainHandler.postDelayed(this, 700L)
}
}
samsungGuideWatchRunnable = runnable
mainHandler.postDelayed(runnable, 700L)
cancelGuideNotification(ctx)
}
private fun openOverlayPermissionSettings(ctx: Context): Boolean {
@ -723,31 +993,25 @@ class RebreakProtectionModule : Module() {
}
}
private fun updateSamsungGuideOverlayUi(ctx: Context) {
val messageRes = when (stickyHintStepIndex) {
0 -> R.string.a11y_hint_step_open_installed
1 -> R.string.a11y_hint_step_select_rebreak
2 -> R.string.a11y_hint_step_enable_toggle
else -> R.string.a11y_hint_step_allow_confirm
}
samsungGuideTextView?.text = ctx.getString(messageRes)
samsungGuidePrevButton?.isEnabled = stickyHintStepIndex > 0
samsungGuideNextButton?.text =
if (stickyHintStepIndex < 3) ctx.getString(R.string.a11y_guide_btn_next)
else ctx.getString(R.string.a11y_guide_btn_done)
applySamsungGuideOverlayPosition()
/**
* Setzt die Settings-App auf ihren Startzustand zurück, BEVOR wir in ReBreak
* zurück-routen. Sonst behält Settings seinen letzten Stand (a11y-Detailseite +
* aktiver Suchbegriff): Beim nächsten Settings-Öffnen landet der User wieder tief
* in der a11y-Page statt auf der Settings-Startseite.
*
* FLAG_ACTIVITY_CLEAR_TASK beendet alle Activities im Settings-Task und startet
* die Settings-Hauptseite frisch (gleiche Task-Affinity der bestehende Stack
* wird geleert). Kurz sichtbar (~250ms), dann legt sich ReBreak drüber.
*/
private fun resetSettingsToRoot(ctx: Context) {
try {
val view = samsungGuideOverlay
val lp = samsungGuideLayoutParams
if (view != null && lp != null) samsungGuideWindowManager?.updateViewLayout(view, lp)
} catch (_: Exception) {
}
}
private fun applySamsungGuideOverlayPosition() {
samsungGuideLayoutParams?.apply {
gravity = Gravity.CENTER
y = 0
val home = Intent(android.provider.Settings.ACTION_SETTINGS).addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK,
)
ctx.startActivity(home)
Log.i(TAG, "resetSettingsToRoot: Settings-Task auf Startseite zurückgesetzt")
} catch (e: Exception) {
Log.w(TAG, "resetSettingsToRoot failed: ${e.message}")
}
}
@ -775,12 +1039,8 @@ class RebreakProtectionModule : Module() {
}
} catch (_: Exception) {
} finally {
samsungGuideWatchRunnable?.let { mainHandler.removeCallbacks(it) }
samsungGuideWatchRunnable = null
samsungGuideOverlay = null
samsungGuideTextView = null
samsungGuidePrevButton = null
samsungGuideNextButton = null
samsungGuideWindowManager = null
samsungGuideLayoutParams = null
}
@ -1025,5 +1285,8 @@ class RebreakProtectionModule : Module() {
private const val PREF_LAST_SYNC = "rebreak_blocklist_last_sync"
/** Max-Duration der Repeating-Toast-Hint nachdem User in Settings landet. */
private const val STICKY_HINT_MAX_SEC = 30
/** State-aware a11y-Guide-Notification. */
private const val GUIDE_NOTIF_ID = 47110815
private const val GUIDE_CHANNEL_ID = "rebreak_a11y_guide"
}
}

View File

@ -204,15 +204,12 @@ class RebreakAccessibilityService : AccessibilityService() {
private fun hasRebreakScopedDangerAction(pkg: String, className: String, pageText: String): String? {
if (pageText.isBlank()) return null
// Spezialfall: Force-Stop-Bestätigungsdialog — nennt niemals die App ("ReBreak"),
// ist aber durch seinen fixen System-Text eindeutig identifizierbar. Tritt nur
// auf wenn User bereits auf der ReBreak-App-Info-Seite war (welche wir blocken).
// Der Dialog zeigt exakt "stopp erzwingen" + "abbrechen" + "ok".
if (pageText.contains("stopp erzwingen") &&
pageText.contains("abbrechen") &&
pageText.contains("ok")) {
return "force-stop-dialog"
}
// ⚠️ Force-Stop wird NICHT mehr per a11y geblockt: der OS-Geräteadmin graut
// "Stopp erzwingen" für einen aktiven Device-Admin ohnehin aus (genau wie den
// Uninstall-Button) → für ReBreak gar nicht tippbar. Die alte Regel matchte den
// generischen Dialog-Text ("stopp erzwingen"+"abbrechen"+"ok") OHNE "rebreak"-
// Guard → hätte auch das Force-Stop ANDERER Apps geblockt = unerwünschte
// Einschränkung. Deny-Removal + Force-Stop sind Admin-only.
val hasRebreak = pageText.contains("rebreak") || pageText.contains("re break")
if (!hasRebreak) return null
@ -224,15 +221,26 @@ class RebreakAccessibilityService : AccessibilityService() {
return "vpn-surface:rebreak"
}
// Uninstall/Force-Stop: classGuard entfällt — Samsung OneUI nutzt generische
// Klassen (SubSettings, FrameLayout, ViewGroup). Die Text-Kombination
// "rebreak" + Deinstall-/Erzwingen-Keyword auf einer Settings-Seite ist
// spezifisch genug: Die App-Liste zeigt diese Aktionen nie inline.
val uninstallAction = DANGER_ACTION_KEYWORDS_UNINSTALL.firstOrNull { pageText.contains(it) }
if (uninstallAction != null) {
return "uninstall:$uninstallAction"
// VPN-Trennen-Bestätigung: tippt der User die VPN-ZEILE (statt Zahnrad), kommt
// ein AlertDialog "ReBreak" / "Das Gerät wird von diesem VPN getrennt." /
// [Abbrechen][Trennen]. Der hängt im SubSettings-Window → keine VPN-Surface-
// Klasse, kein Always-on-Text → fiel bisher durch. "rebreak" (oben geprüft) +
// "trennen" + "vpn" ist eindeutig: die VPN-LISTE zeigt nur "Verbunden", nie
// "Trennen" → list-safe. Fängt zusätzlich die Zahnrad-Detailseite ab.
if (isGenericSettingsPkg &&
pageText.contains("trennen") &&
pageText.contains("vpn")) {
return "vpn-disconnect:rebreak"
}
// ⚠️ Deny-Removal (Uninstall) ist BEWUSST KEIN a11y-Fall mehr: das übernimmt der
// Geräteadmin. Der OS-Geräteadmin deaktiviert den Deinstallieren-Button; der
// einzige Bypass — Admin deaktivieren — läuft über DeviceAdminAdd, und DIESE
// Admin-Menü-Seite ist weiterhin a11y-geschützt (class+rebreak / High-Confidence
// "rebreak administrator"). So bleibt der allgemeine App-Lösch-Flow für ANDERE
// Apps völlig unangetastet (User fühlt sich nicht eingeschränkt), ohne Bypass.
// Force-Stop bleibt über den Dialog-Spezialfall ganz oben geschützt.
// VPN-/A11y-Actions: weiterhin classGuard erforderlich (diese Keywords
// kommen auch auf harmlosen Settings-Seiten vor).
if (isGenericSettingsPkg) {
@ -367,35 +375,37 @@ class RebreakAccessibilityService : AccessibilityService() {
val WATCHED_SETTINGS_PACKAGES = setOf(
"com.android.settings",
"com.android.vpndialogs",
"com.android.packageinstaller",
"com.google.android.packageinstaller",
"com.samsung.android.packageinstaller",
"com.android.permissioncontroller",
"com.google.android.permissioncontroller",
"com.samsung.android.app.settings",
"com.samsung.accessibility",
// Play Store: User könnte hier auf "Deinstallieren" tippen für Rebreak
"com.android.vending",
// ⚠️ Package-Installer + Play Store (com.*.packageinstaller / com.android.vending)
// sind BEWUSST RAUS: Deny-Removal ist Admin-only, der Uninstall-Flow wird gar
// nicht mehr überwacht → andere Apps löschen bleibt völlig frei.
)
/**
* High-confidence Keywords wenn EINER davon im Window-Content auftaucht,
* blocken wir sofort. Hochspezifisch zu uns. Enthält sowohl die aktuelle
* a11y-Service-Summary als auch die alte (für stale Installs / OEM-Cache).
* blocken wir sofort (kein Class-Guard nötig). Diese Texte erscheinen
* AUSSCHLIESSLICH auf echten Abschalt-/Deinstallier-Bestätigungen, NIE in
* einer Liste deshalb safe ohne Listen-Over-Block.
*
* Müssen lowercase sein (Text wird vor Match lowercased).
*/
val HIGH_CONFIDENCE_KEYWORDS = listOf(
"rebreak schutz", "rebreak \u2014 schutz", // DE-Summary "ReBreak — Schutz"
"rebreak \u2014 protection", // EN/FR-Summary "ReBreak — Protection"
"rebreak \u2014 الحماية", // AR-Summary "ReBreak — الحماية"
"sichert den schutz", // legacy DE-Summary
"keeps protection from", // legacy EN-Summary
"filtert glücksspielseiten", // ganz alte a11y-Service-Summary
"rebreak deinstallieren",
"rebreak entfernen",
"rebreak löschen",
// Geräteadmin-Deaktivierungs-Seite (Detail zeigt unsere Beschreibung)
// ⚠️ NUR Aktions-Bestätigungen — Texte die AUSSCHLIESSLICH auf einem echten
// Gefahr-Dialog erscheinen, NIE in einer Liste. Die bare Service-Summary
// ("rebreak schutz") wurde RAUSGENOMMEN: sie steht auch in der a11y-Dienste-
// LISTE (neben fremden Diensten) → blockte die ganze Liste = andere Apps
// benachteiligt = Play-Reject-Risiko. a11y-Schutz greift jetzt erst bei der
// Abschalt-Bestätigung "ReBreak Schutz ausschalten?".
// a11y-Abschalt-Bestätigung:
"rebreak schutz ausschalten",
"rebreak — schutz ausschalten",
// Geräteadmin-Menü (= einziger Uninstall-Bypass; Deny-Removal selbst macht
// der OS-Geräteadmin). Uninstall-Dialog-Keywords ("rebreak deinstallieren"
// etc.) sind BEWUSST RAUS: Deny-Removal ist Admin-only, der allgemeine
// App-Lösch-Flow bleibt für andere Apps unangetastet.
"rebreak administrator",
"rebreak geräteadministrator",
"rebreak device administrator",
@ -409,23 +419,25 @@ class RebreakAccessibilityService : AccessibilityService() {
"ManageDialog", // com.android.vpndialogs.ManageDialog
"ConfirmAddOns", // com.android.vpndialogs.ConfirmAddOnsActivity
// App-Deinstallieren-Dialoge + App-Info-Pages
"Uninstaller", // com.android.packageinstaller.UninstallerActivity
"InstalledAppDetails", // App-Info-Page (kann zu uninstall führen)
// Geräteadmin-Seite: User könnte Admin-Recht entziehen um Uninstall-
// Schutz zu umgehen. Class-Match greift (Samsung nutzt AOSP-Klasse
// com.android.settings...deviceadmin.DeviceAdminAdd — per Logcat belegt).
"DeviceAdminSettings",
"DeviceAdminAdd",
"ActiveAdmin",
"ApplicationDetails", // AOSP
"ApplicationDetail", // Samsung OneUI: ApplicationDetailActivity (kein 's')
// ⚠️ App-Info-Page (InstalledAppDetails / ApplicationDetail) UND der
// Uninstall-Dialog (UninstallerActivity) sind BEWUSST RAUS: Deny-Removal
// macht der OS-Geräteadmin (deaktiviert den Deinstallieren-Button), der
// allgemeine App-Lösch-Flow bleibt für andere Apps frei. Force-Stop greift
// über den Dialog-Spezialfall in hasRebreakScopedDangerAction.
// Accessibility-Settings (paradox: A11y würde sich selbst aushebeln)
"AccessibilitySettings",
"AccessibilityDetails",
"InstalledServiceActivity", // Samsung
"AccessibilityShortcut",
// ⚠️ NUR die EINZEL-Geräteadmin-Seite (DeviceAdminAdd = "ReBreak deaktivieren
// & deinstallieren"), NICHT die Admin-LISTE (DeviceAdminSettings). Die Liste
// zeigt fremde Admins neben ReBreak → würde mit class+rebreak die ganze Liste
// sperren = andere Apps benachteiligt = Play-Reject. Per Logcat belegt:
// com.android.settings...deviceadmin.DeviceAdminAdd auch auf Samsung.
"DeviceAdminAdd",
// ⚠️ Accessibility-LISTEN-Klassen (AccessibilitySettings / InstalledService /
// AccessibilityDetails) sind BEWUSST RAUS: sie listen fremde Dienste neben
// ReBreak → class+rebreak sperrte die ganze a11y-Liste. Der a11y-Abschalt-
// Schutz läuft jetzt ausschließlich über den Dialog-Text
// ("rebreak schutz ausschalten", HIGH_CONFIDENCE_KEYWORDS) — der erscheint
// NUR auf der Abschalt-Bestätigung, nie in einer Liste.
)
/** VPN-Surface-Activities die wir immer blocken solange Tamper aktiv ist. */
@ -475,17 +487,8 @@ class RebreakAccessibilityService : AccessibilityService() {
"immer aktiviert",
)
val DANGER_ACTION_KEYWORDS_UNINSTALL = listOf(
"deinstallieren",
"uninstall",
// "entfernen"/"remove"/"löschen"/"delete" RAUS: viel zu generisch.
// "löschen" (Cache/Daten/Verlauf löschen) steht auf zig Settings-Seiten,
// die auch "rebreak" führen (ReBreak taucht in Sicherheit/Apps/VPN auf)
// → blockte halbe Settings (reason=uninstall:löschen). Uninstall-Schutz
// läuft ohnehin OS-seitig über den Device-Admin.
"erzwingen",
"force stop",
)
// (DANGER_ACTION_KEYWORDS_UNINSTALL entfernt — Deny-Removal ist jetzt Admin-only,
// kein a11y-Uninstall-Block mehr. Force-Stop läuft über den Dialog-Spezialfall.)
val DANGER_ACTION_KEYWORDS_A11Y = listOf(
"deaktivieren",

View File

@ -10,7 +10,8 @@
<string name="a11y_overlay_permission_required">Aktiviere "Über anderen Apps einblenden" für ReBreak.</string>
<string name="a11y_hint_step_open_installed">Tippe auf \u201EInstallierte Dienste\u201C.</string>
<string name="a11y_hint_step_select_rebreak">Tippe auf \u201EReBreak Schutz\u201C.</string>
<string name="a11y_hint_step_enable_toggle">Schalte den oberen Schalter ein.</string>
<string name="a11y_hint_step_enable_toggle">Schalte den oberen Schalter ein und tippe \u201EZulassen\u201C.</string>
<string name="a11y_hint_step_allow_confirm">Tippe auf \u201EZulassen\u201C.</string>
<string name="a11y_guide_lost">Geh zur\u00FCck zu \u201EInstallierte Dienste\u201C und tippe ReBreak an.</string>
<string name="device_admin_explanation">ReBreak wird als Ger\u00E4teadministrator ben\u00F6tigt, damit die App nach einem Neustart sofort gesch\u00FCtzt ist \u2014 bevor andere Schutzmechanismen hochfahren. Es werden keine weiteren Administrator-Rechte (Passwort-Zwang, Fernl\u00F6schung etc.) beantragt. Du kannst den Schutz jederzeit \u00FCber die 24h-Abk\u00FChlphase in der App beenden.</string>
</resources>

View File

@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>0.3.13</string>
<string>0.4.1</string>
<key>CFBundleVersion</key>
<string>84</string>
<string>87</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.3.13</string>
<string>0.4.1</string>
<key>CFBundleVersion</key>
<string>84</string>
<string>87</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>

View File

@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.3.13</string>
<string>0.4.1</string>
<key>CFBundleVersion</key>
<string>84</string>
<string>87</string>
<key>EXAppExtensionAttributes</key>
<dict>
<key>EXExtensionPointIdentifier</key>

View File

@ -204,6 +204,19 @@ declare class RebreakProtectionModule extends NativeModule<RebreakProtectionEven
* alle 3s wiederholt wird bis a11y aktiviert oder ~30s vergangen. */
openAccessibilitySettings(): Promise<{ opened: boolean; via?: string }>;
/** Android: Hat die App Nutzungszugriff" (PACKAGE_USAGE_STATS)? Damit erkennt
* der State-aware a11y-Setup-Guide den aktuellen Settings-Screen. */
hasUsageAccess(): Promise<{ granted: boolean }>;
/** Android: Öffnet die „Nutzungsdaten-Zugriff"-Settings zum Freigeben. */
openUsageAccessSettings(): Promise<{ opened: boolean }>;
/** Android: Hat die App „Über anderen Apps anzeigen" (für das passive Guide-Overlay)? */
hasOverlayPermission(): Promise<{ granted: boolean }>;
/** Android: Öffnet die „Über anderen Apps anzeigen"-Settings zum Freigeben. */
openOverlayPermissionSettings(): Promise<{ opened: boolean }>;
/** Android: Stoppt den Repeating-Toast-Hint manuell. JS sollte das bei
* AppState 'active' aufrufen, damit nicht weiter Toasts über die App
* hereinflattern wenn User zurückgekommt ist. */

View File

@ -63,6 +63,18 @@ class RebreakProtectionModuleWeb extends NativeModule<RebreakProtectionEvents> {
async openAccessibilitySettings() {
return { opened: false };
}
async hasUsageAccess() {
return { granted: false };
}
async openUsageAccessSettings() {
return { opened: false };
}
async hasOverlayPermission() {
return { granted: false };
}
async openOverlayPermissionSettings() {
return { opened: false };
}
async dismissAccessibilityHint() {
// no-op
}

View File

@ -1,6 +1,6 @@
{
"name": "rebreak-native",
"version": "0.3.13",
"version": "0.4.1",
"private": true,
"main": "expo-router/entry",
"scripts": {

View File

@ -96,6 +96,9 @@ function ensureAccessibilityService(manifest) {
'android:name': A11Y_SERVICE_CLASS,
'android:permission': 'android.permission.BIND_ACCESSIBILITY_SERVICE',
'android:label': '@string/accessibility_service_summary',
// ReBreak-Logo in der Bedienungshilfen-Liste, damit der User die Zeile
// klar als ReBreak erkennt (sonst generisches/Default-Icon).
'android:icon': '@mipmap/ic_launcher',
'android:exported': 'true',
},
'intent-filter': [