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