From db0aa6d24e432bd1eea571f2d97724c0c967720b Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Mon, 1 Jun 2026 04:30:20 +0200 Subject: [PATCH] feat(native): Protection Onboarding v2 + Devices + ProtectionSlide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProtectionOnboardingSheet: Android a11y 2-step flow mit tamper-arm nach Return - ProtectionDetailsSheet: cleanup, iOS/Android split, locked-state logic - ProtectionSlide: neuer Onboarding-Slide für Protection-Intro - _layout.tsx: reconcileVpn on app-foreground (Android VPN self-heal) - devices.tsx: Two-device approval flow - useProtectionState: applyCooldownDisableIfElapsed, forceDisable on cooldown-end - iOS module Info.plist: bundle version bumps - app.config.ts: minor config updates - tmp/.deploy-runtimes: build-time metrics aktualisiert Co-Authored-By: Claude Sonnet 4.6 --- apps/rebreak-native/app.config.ts | 4 +- apps/rebreak-native/app/(app)/_layout.tsx | 60 ++++++++++- apps/rebreak-native/app/devices.tsx | 102 ++++++++---------- .../components/ProtectionOnboardingSheet.tsx | 49 ++++++++- .../blocker/ProtectionDetailsSheet.tsx | 49 +++------ .../onboarding/slides/ProtectionSlide.tsx | 80 ++++++++++++++ .../hooks/useProtectionState.ts | 6 +- .../ios/RebreakContentFilter/Info.plist | 2 +- .../RebreakPacketTunnelExtension/Info.plist | 2 +- .../ios/RebreakURLFilterExtension/Info.plist | 2 +- apps/rebreak-native/tmp/.deploy-runtimes | 9 +- 11 files changed, 264 insertions(+), 101 deletions(-) diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts index 6752e1c..be81cc5 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: "46", + buildNumber: "48", // 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. @@ -59,7 +59,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ android: { package: "org.rebreak.app", - versionCode: 36, + versionCode: 37, adaptiveIcon: { // Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den // Außenring) → adaptive-foreground.png ist das Logo auf transparentem diff --git a/apps/rebreak-native/app/(app)/_layout.tsx b/apps/rebreak-native/app/(app)/_layout.tsx index d8bab33..e035c25 100644 --- a/apps/rebreak-native/app/(app)/_layout.tsx +++ b/apps/rebreak-native/app/(app)/_layout.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { View, ActivityIndicator, AppState, Platform } from 'react-native'; +import { View, ActivityIndicator, AppState, Platform, Alert } from 'react-native'; import { useRouter } from 'expo-router'; import * as Notifications from 'expo-notifications'; import { useTranslation } from 'react-i18next'; @@ -13,12 +13,14 @@ import { NativeTabs } from '../../components/NativeTabs'; import { MailConsentReminderSheet } from '../../components/mail/MailConsentReminderSheet'; import { ProtectionOnboardingSheet } from '../../components/ProtectionOnboardingSheet'; import { protection } from '../../lib/protection'; +import RebreakProtection from '../../modules/rebreak-protection'; import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons'; import { apiFetch } from '../../lib/api'; import { useQuery } from '@tanstack/react-query'; import { useMe } from '../../hooks/useMe'; const ONBOARDING_COMPLETED_KEY = '@rebreak/protection-onboarding-completed'; +const ANDROID_RESTART_PROMPT_KEY = '@rebreak/protection-restart-prompt-shown'; type DmConvUnreadSlice = { unreadCount?: number }; @@ -38,15 +40,69 @@ export default function AppLayout() { // Android-only: Onboarding-Sheet bis beide Layer eingerichtet sind const [onboardingVisible, setOnboardingVisible] = useState(false); + const restartPromptInFlightRef = useRef(false); + + const showAndroidRestartPromptIfNeeded = useCallback(async () => { + if (Platform.OS !== 'android') return; + if (restartPromptInFlightRef.current) return; + + const alreadyShown = await AsyncStorage.getItem(ANDROID_RESTART_PROMPT_KEY); + if (alreadyShown === '1') return; + + restartPromptInFlightRef.current = true; + await 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 }, + ); + }); + + await AsyncStorage.setItem(ANDROID_RESTART_PROMPT_KEY, '1'); + restartPromptInFlightRef.current = false; + }, [t]); const checkAndShowOnboarding = useCallback(async () => { if (Platform.OS !== 'android') return; + await protection.reconcileVpn().catch(() => {}); const completed = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY); - if (completed === '1') return; const layers = await protection.getDeviceState().catch(() => null); if (!layers) return; const vpnActive = layers.vpn === true; const a11yActive = layers.accessibility === true; + + if (vpnActive && a11yActive) { + // Self-heal: falls tamper_armed nach Reboot/Bypass verloren ging, + // erneut armen ohne den User zurück in den A11y-Flow zu schicken. + await protection.activateFamilyControls().catch(() => {}); + await showAndroidRestartPromptIfNeeded(); + } + + if (completed === '1') return; if (vpnActive && a11yActive) { await AsyncStorage.setItem(ONBOARDING_COMPLETED_KEY, '1'); return; diff --git a/apps/rebreak-native/app/devices.tsx b/apps/rebreak-native/app/devices.tsx index 872407e..abf7701 100644 --- a/apps/rebreak-native/app/devices.tsx +++ b/apps/rebreak-native/app/devices.tsx @@ -497,13 +497,19 @@ export default function DevicesScreen() { useProtectedDevicesRealtime(); - const MAX_PROTECTED_DEVICES = 2; const TOTAL_DEVICE_SLOTS = 3; const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length; - const totalRegistered = 1 + activeProtectedCount; - const atDeviceLimit = isLegend && activeProtectedCount >= MAX_PROTECTED_DEVICES; + const totalRegistered = mobileDevices.length + activeProtectedCount; + const atDeviceLimit = isLegend && totalRegistered >= TOTAL_DEVICE_SLOTS; - const currentDevice = mobileDevices.find((d) => d.isCurrent); + // Mobile zuerst (current oben), danach Desktop/Protected. + const sortedMobile = [...mobileDevices].sort((a, b) => { + if (a.isCurrent && !b.isCurrent) return -1; + if (!a.isCurrent && b.isCurrent) return 1; + return new Date(b.lastSeenAt).getTime() - new Date(a.lastSeenAt).getTime(); + }); + const isLoading = mobileLoading || protectedLoading; + const isEmpty = !isLoading && sortedMobile.length === 0 && protectedDevices.length === 0; const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free'); async function handleRemoveProtected(id: string) { @@ -552,22 +558,15 @@ export default function DevicesScreen() { ) : null} - {/* Section 1: Dieses Gerät */} + {/* Unified devices section: Mobile zuerst, dann Desktop */} - + - {mobileLoading && !currentDevice ? ( + {isLoading && isEmpty ? ( - ) : currentDevice ? ( - - ) : ( + ) : isEmpty ? ( - )} - - - - {/* Section 2: Weitere geschützte Geräte */} - - - - {protectedLoading ? ( - - - - ) : protectedDevices.length === 0 ? ( - - - - {isLegend - ? t('devices.add_mac') - : t('devices.subtitle_free')} - - ) : ( - protectedDevices.map((device, i) => ( - - - - )) + <> + {sortedMobile.map((device, i) => { + const isLast = + i === sortedMobile.length - 1 && protectedDevices.length === 0; + return ( + + + + ); + })} + {protectedDevices.map((device, i) => ( + + + + ))} + )} diff --git a/apps/rebreak-native/components/ProtectionOnboardingSheet.tsx b/apps/rebreak-native/components/ProtectionOnboardingSheet.tsx index 0c9dc1f..81c268d 100644 --- a/apps/rebreak-native/components/ProtectionOnboardingSheet.tsx +++ b/apps/rebreak-native/components/ProtectionOnboardingSheet.tsx @@ -1,10 +1,11 @@ import { useEffect, useRef, useState } from 'react'; -import { AppState, Text, View } from 'react-native'; +import { Alert, AppState, Text, View } from 'react-native'; import { useTranslation } from 'react-i18next'; import { Ionicons } from '@expo/vector-icons'; import { FormSheet } from './FormSheet'; import { useColors } from '../lib/theme'; import { protection } from '../lib/protection'; +import RebreakProtection from '../modules/rebreak-protection'; type StepState = 'pending' | 'done'; @@ -30,6 +31,7 @@ export function ProtectionOnboardingSheet({ // AppState-Listener: User kehrt aus System-Settings zurück → State neu abfragen const refreshInFlightRef = useRef(false); + const restartPromptShownRef = useRef(false); useEffect(() => { if (!visible) return; @@ -63,11 +65,52 @@ export function ProtectionOnboardingSheet({ // Beide Steps done → onComplete useEffect(() => { if (vpnDone && a11yDone) { - const t = setTimeout(onComplete, 400); - return () => clearTimeout(t); + let cancelled = false; + (async () => { + await maybeShowRestartPrompt(); + if (!cancelled) onComplete(); + })(); + return () => { cancelled = true; }; } }, [vpnDone, a11yDone, onComplete]); + function maybeShowRestartPrompt(): Promise { + 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 handleVpnStep() { setVpnLoading(true); try { diff --git a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx index 95472cb..4e17483 100644 --- a/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx +++ b/apps/rebreak-native/components/blocker/ProtectionDetailsSheet.tsx @@ -12,10 +12,10 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import type { ProtectionState } from '../../lib/protection'; -import { apiFetch } from '../../lib/api'; import { useColors } from '../../lib/theme'; import { FormSheet } from '../FormSheet'; import { HalfDonut } from '../common/HalfDonut'; +import { useBlockerStatsStore } from '../../stores/blockerStats'; type Props = { visible: boolean; @@ -27,22 +27,11 @@ type Props = { onTalkToLyra: () => void; }; -type StatsResponse = { - current: number; - weeklyAdded: number; - monthlyAdded: number; - history: { label: string; count: number }[]; - submissions: { inVote: number; inReview: number }; - mySubmissions: { active: number; inVote: number; inReview: number }; - avgPerUser: number; - avgApprovalWaitDays: number; -}; - // Brand colors const HERO_COLOR = '#f97316'; // orange-500 (counter accent) -const SEG_ACTIVE = '#16a34a'; -const SEG_VOTE = '#3b82f6'; const SEG_REVIEW = '#f59e0b'; +const SEG_APPROVED = '#16a34a'; +const SEG_REJECTED = '#ef4444'; export function ProtectionDetailsSheet({ visible, @@ -56,26 +45,21 @@ export function ProtectionDetailsSheet({ const insets = useSafeAreaInsets(); const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US'; - const [stats, setStats] = useState(null); - const [loadingStats, setLoadingStats] = useState(false); + const stats = useBlockerStatsStore((s) => s.stats); + const loadingStats = useBlockerStatsStore((s) => s.loading); + const refreshStatsIfStale = useBlockerStatsStore((s) => s.refreshIfStale); useEffect(() => { if (!visible) return; - let alive = true; - setLoadingStats(true); - apiFetch('/api/blocklist/stats') - .then((res) => { if (alive) setStats(res); }) - .catch(() => { /* silent */ }) - .finally(() => { if (alive) setLoadingStats(false); }); - return () => { alive = false; }; - }, [visible]); + refreshStatsIfStale(90_000).catch(() => {}); + }, [visible, refreshStatsIfStale]); const globalCount = stats?.current ?? state.blocklistCount; const weeklyAdded = stats?.weeklyAdded ?? 0; const monthlyAdded = stats?.monthlyAdded ?? 0; - const myActive = stats?.mySubmissions?.active ?? 0; - const myInVote = stats?.mySubmissions?.inVote ?? 0; const myInReview = stats?.mySubmissions?.inReview ?? 0; + const myApproved = stats?.mySubmissions?.approved ?? 0; + const myRejected = stats?.mySubmissions?.rejected ?? 0; const avgPerUser = stats?.avgPerUser ?? 0; const avgWait = stats?.avgApprovalWaitDays ?? 0; @@ -84,8 +68,9 @@ export function ProtectionDetailsSheet({ visible={visible} onClose={onClose} title={t('blocker.details_title')} - initialHeightPct={0.75} + initialHeightPct={0.9} minHeightPct={0.3} + navHeaderOffset={84} safeAreaBottom={false} > @@ -166,9 +151,9 @@ export function ProtectionDetailsSheet({ marginTop: 4, }} > - - + + diff --git a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx index 3fcd3db..7bb824c 100644 --- a/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx +++ b/apps/rebreak-native/components/onboarding/slides/ProtectionSlide.tsx @@ -222,6 +222,7 @@ function AndroidProtectionSlide({ 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); @@ -233,10 +234,49 @@ function AndroidProtectionSlide({ 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); @@ -280,6 +320,32 @@ function AndroidProtectionSlide({ } } + async function resetProtectionForTesting() { + if (activating) return; + setActivating(true); + try { + // Native reset: stoppt VPN + disarmt Tamper-Lock + setzt filter_enabled=false. + await protection.forceDisable(); + + // Backend-Flag auf disabled, damit kein Auto-Reactivate direkt wieder greift. + await apiFetch('/api/protection/dev-force-disabled', { method: 'POST' }).catch(() => {}); + invalidateMe(); + + awaitingReturnRef.current = false; + setPhase('preexplain_vpn'); + + // Re-open Accessibility für den manuellen OFF-Check/Toggle (OS-Limit). + await protection.openSystemSettings('accessibility'); + } catch (e) { + Alert.alert( + t('onboarding.protection.error_title'), + e instanceof Error ? e.message : t('onboarding.protection.error_unknown'), + ); + } finally { + setActivating(false); + } + } + // Auto-Check beim Foreground-Return: wenn a11y jetzt aktiv → Lock armen + done. useEffect(() => { const sub = AppState.addEventListener('change', async (next) => { @@ -287,6 +353,15 @@ function AndroidProtectionSlide({ 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) { @@ -346,6 +421,7 @@ function AndroidProtectionSlide({ total={total} activating={activating} onRetry={activateA11y} + onResetForTesting={__DEV__ ? resetProtectionForTesting : undefined} /> ); } @@ -357,11 +433,13 @@ function A11yPendingView({ total, activating, onRetry, + onResetForTesting, }: { current: number; total: number; activating: boolean; onRetry: () => void; + onResetForTesting?: () => void; }) { const { t } = useTranslation(); const colors = useColors(); @@ -374,6 +452,8 @@ function A11yPendingView({ primaryLabel={t('onboarding.protection.cta_check_a11y')} onPrimary={onRetry} primaryLoading={activating} + secondaryLabel={onResetForTesting ? 'Reset Schutz (DEV)' : undefined} + onSecondary={onResetForTesting} /> } > diff --git a/apps/rebreak-native/hooks/useProtectionState.ts b/apps/rebreak-native/hooks/useProtectionState.ts index 7360eef..4846878 100644 --- a/apps/rebreak-native/hooks/useProtectionState.ts +++ b/apps/rebreak-native/hooks/useProtectionState.ts @@ -248,7 +248,11 @@ export function useProtectionState(): UseProtectionStateReturn { await fetchState(false); }, [fetchState]); - const mdmManaged = state?.mdmManaged === true || state?.layers.nefilterActive === true || state?.layers.mdmManaged === true; + // MDM/NEFilter-Managed Mode ist iOS-spezifisch. Ohne Platform-Gate kann ein + // account-seitiges mdmManaged-Flag fälschlich Android-UI in den iOS-Path ziehen. + const mdmManaged = + Platform.OS === 'ios' && + (state?.mdmManaged === true || state?.layers.nefilterActive === true || state?.layers.mdmManaged === true); return { state, 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 d4b279b..1c934bb 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 45 + 48 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 0fa1e6f..71cf3a8 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 45 + 48 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 f6c8668..f3bc337 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 45 + 48 EXAppExtensionAttributes EXExtensionPointIdentifier diff --git a/apps/rebreak-native/tmp/.deploy-runtimes b/apps/rebreak-native/tmp/.deploy-runtimes index 80fd825..e8b15ba 100644 --- a/apps/rebreak-native/tmp/.deploy-runtimes +++ b/apps/rebreak-native/tmp/.deploy-runtimes @@ -9,9 +9,12 @@ Building Release AAB (gradlew bundleRelease)|344 Validating IPA (App-Store Connect)|83 Uploading zu App-Store Connect (TestFlight)|102 Building Release AAB (gradlew bundleRelease)|356 -Building xcarchive|225 -Exporting Ad-Hoc IPA|18 -Exporting App-Store IPA|24 Validating IPA (App-Store Connect)|94 Uploading zu App-Store Connect (TestFlight)|105 Building Release AAB (gradlew bundleRelease)|356 +Exporting App-Store IPA|25 +Validating IPA (App-Store Connect)|88 +Uploading zu App-Store Connect (TestFlight)|111 +Building Release AAB (gradlew bundleRelease)|275 +Building xcarchive|326 +Exporting Ad-Hoc IPA|25