feat(native): Protection Onboarding v2 + Devices + ProtectionSlide

- 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 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-06-01 04:30:20 +02:00
parent adf0d33f1b
commit db0aa6d24e
11 changed files with 264 additions and 101 deletions

View File

@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
ios: { ios: {
supportsTablet: true, supportsTablet: true,
bundleIdentifier: MAIN_BUNDLE, bundleIdentifier: MAIN_BUNDLE,
buildNumber: "46", buildNumber: "48",
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen // Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den // signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements. // com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
@ -59,7 +59,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
android: { android: {
package: "org.rebreak.app", package: "org.rebreak.app",
versionCode: 36, versionCode: 37,
adaptiveIcon: { adaptiveIcon: {
// Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den // Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den
// Außenring) → adaptive-foreground.png ist das Logo auf transparentem // Außenring) → adaptive-foreground.png ist das Logo auf transparentem

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'; 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 { useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications'; import * as Notifications from 'expo-notifications';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -13,12 +13,14 @@ import { NativeTabs } from '../../components/NativeTabs';
import { MailConsentReminderSheet } from '../../components/mail/MailConsentReminderSheet'; import { MailConsentReminderSheet } from '../../components/mail/MailConsentReminderSheet';
import { ProtectionOnboardingSheet } from '../../components/ProtectionOnboardingSheet'; import { ProtectionOnboardingSheet } from '../../components/ProtectionOnboardingSheet';
import { protection } from '../../lib/protection'; import { protection } from '../../lib/protection';
import RebreakProtection from '../../modules/rebreak-protection';
import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons'; import { preloadTabIcons, getTabIcon } from '../../lib/tabIcons';
import { apiFetch } from '../../lib/api'; import { apiFetch } from '../../lib/api';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useMe } from '../../hooks/useMe'; import { useMe } from '../../hooks/useMe';
const ONBOARDING_COMPLETED_KEY = '@rebreak/protection-onboarding-completed'; const ONBOARDING_COMPLETED_KEY = '@rebreak/protection-onboarding-completed';
const ANDROID_RESTART_PROMPT_KEY = '@rebreak/protection-restart-prompt-shown';
type DmConvUnreadSlice = { unreadCount?: number }; type DmConvUnreadSlice = { unreadCount?: number };
@ -38,15 +40,69 @@ export default function AppLayout() {
// Android-only: Onboarding-Sheet bis beide Layer eingerichtet sind // Android-only: Onboarding-Sheet bis beide Layer eingerichtet sind
const [onboardingVisible, setOnboardingVisible] = useState(false); 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<void>((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 () => { const checkAndShowOnboarding = useCallback(async () => {
if (Platform.OS !== 'android') return; if (Platform.OS !== 'android') return;
await protection.reconcileVpn().catch(() => {});
const completed = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY); const completed = await AsyncStorage.getItem(ONBOARDING_COMPLETED_KEY);
if (completed === '1') return;
const layers = await protection.getDeviceState().catch(() => null); const layers = await protection.getDeviceState().catch(() => null);
if (!layers) return; if (!layers) return;
const vpnActive = layers.vpn === true; const vpnActive = layers.vpn === true;
const a11yActive = layers.accessibility === 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) { if (vpnActive && a11yActive) {
await AsyncStorage.setItem(ONBOARDING_COMPLETED_KEY, '1'); await AsyncStorage.setItem(ONBOARDING_COMPLETED_KEY, '1');
return; return;

View File

@ -497,13 +497,19 @@ export default function DevicesScreen() {
useProtectedDevicesRealtime(); useProtectedDevicesRealtime();
const MAX_PROTECTED_DEVICES = 2;
const TOTAL_DEVICE_SLOTS = 3; const TOTAL_DEVICE_SLOTS = 3;
const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length; const activeProtectedCount = protectedDevices.filter((d) => d.status !== 'revoked').length;
const totalRegistered = 1 + activeProtectedCount; const totalRegistered = mobileDevices.length + activeProtectedCount;
const atDeviceLimit = isLegend && activeProtectedCount >= MAX_PROTECTED_DEVICES; 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'); const subtitle = isLegend ? t('devices.subtitle_legend') : t('devices.subtitle_free');
async function handleRemoveProtected(id: string) { async function handleRemoveProtected(id: string) {
@ -552,22 +558,15 @@ export default function DevicesScreen() {
) : null} ) : null}
</View> </View>
{/* Section 1: Dieses Gerät */} {/* Unified devices section: Mobile zuerst, dann Desktop */}
<View> <View>
<SectionLabel title={t('devices.section_title_this')} /> <SectionLabel title={t('settings.devices')} />
<SectionCard> <SectionCard>
{mobileLoading && !currentDevice ? ( {isLoading && isEmpty ? (
<View style={{ paddingVertical: 32, alignItems: 'center' }}> <View style={{ paddingVertical: 32, alignItems: 'center' }}>
<ActivityIndicator color={colors.brandOrange} /> <ActivityIndicator color={colors.brandOrange} />
</View> </View>
) : currentDevice ? ( ) : isEmpty ? (
<MobileDeviceRow
device={currentDevice}
onRemove={removeMobileDevice}
onRequestRelease={requestRelease}
onCancelRelease={cancelRelease}
/>
) : (
<View style={{ paddingVertical: 20, alignItems: 'center' }}> <View style={{ paddingVertical: 20, alignItems: 'center' }}>
<Text <Text
style={{ style={{
@ -579,47 +578,40 @@ export default function DevicesScreen() {
{t('settings.devices_empty')} {t('settings.devices_empty')}
</Text> </Text>
</View> </View>
)}
</SectionCard>
</View>
{/* Section 2: Weitere geschützte Geräte */}
<View>
<SectionLabel title={t('devices.section_title_others')} />
<SectionCard>
{protectedLoading ? (
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
<ActivityIndicator color={colors.brandOrange} />
</View>
) : protectedDevices.length === 0 ? (
<View style={{ paddingVertical: 24, paddingHorizontal: 16, alignItems: 'center', gap: 8 }}>
<Ionicons name="laptop-outline" size={32} color={colors.border} />
<Text
style={{
fontSize: 13,
color: colors.textMuted,
fontFamily: 'Nunito_400Regular',
textAlign: 'center',
lineHeight: 18,
}}
>
{isLegend
? t('devices.add_mac')
: t('devices.subtitle_free')}
</Text>
</View>
) : ( ) : (
protectedDevices.map((device, i) => ( <>
<View {sortedMobile.map((device, i) => {
key={device.id} const isLast =
style={{ i === sortedMobile.length - 1 && protectedDevices.length === 0;
borderBottomWidth: i < protectedDevices.length - 1 ? 1 : 0, return (
borderBottomColor: colors.border, <View
}} key={device.id}
> style={{
<ProtectedDeviceRow device={device} onRemove={handleRemoveProtected} /> borderBottomWidth: isLast ? 0 : 1,
</View> borderBottomColor: colors.border,
)) }}
>
<MobileDeviceRow
device={device}
onRemove={removeMobileDevice}
onRequestRelease={requestRelease}
onCancelRelease={cancelRelease}
/>
</View>
);
})}
{protectedDevices.map((device, i) => (
<View
key={device.id}
style={{
borderBottomWidth: i < protectedDevices.length - 1 ? 1 : 0,
borderBottomColor: colors.border,
}}
>
<ProtectedDeviceRow device={device} onRemove={handleRemoveProtected} />
</View>
))}
</>
)} )}
</SectionCard> </SectionCard>
</View> </View>

View File

@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react'; 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 { useTranslation } from 'react-i18next';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { FormSheet } from './FormSheet'; import { FormSheet } from './FormSheet';
import { useColors } from '../lib/theme'; import { useColors } from '../lib/theme';
import { protection } from '../lib/protection'; import { protection } from '../lib/protection';
import RebreakProtection from '../modules/rebreak-protection';
type StepState = 'pending' | 'done'; type StepState = 'pending' | 'done';
@ -30,6 +31,7 @@ export function ProtectionOnboardingSheet({
// AppState-Listener: User kehrt aus System-Settings zurück → State neu abfragen // AppState-Listener: User kehrt aus System-Settings zurück → State neu abfragen
const refreshInFlightRef = useRef(false); const refreshInFlightRef = useRef(false);
const restartPromptShownRef = useRef(false);
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
@ -63,11 +65,52 @@ export function ProtectionOnboardingSheet({
// Beide Steps done → onComplete // Beide Steps done → onComplete
useEffect(() => { useEffect(() => {
if (vpnDone && a11yDone) { if (vpnDone && a11yDone) {
const t = setTimeout(onComplete, 400); let cancelled = false;
return () => clearTimeout(t); (async () => {
await maybeShowRestartPrompt();
if (!cancelled) onComplete();
})();
return () => { cancelled = true; };
} }
}, [vpnDone, a11yDone, onComplete]); }, [vpnDone, a11yDone, onComplete]);
function maybeShowRestartPrompt(): Promise<void> {
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() { async function handleVpnStep() {
setVpnLoading(true); setVpnLoading(true);
try { try {

View File

@ -12,10 +12,10 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { ProtectionState } from '../../lib/protection'; import type { ProtectionState } from '../../lib/protection';
import { apiFetch } from '../../lib/api';
import { useColors } from '../../lib/theme'; import { useColors } from '../../lib/theme';
import { FormSheet } from '../FormSheet'; import { FormSheet } from '../FormSheet';
import { HalfDonut } from '../common/HalfDonut'; import { HalfDonut } from '../common/HalfDonut';
import { useBlockerStatsStore } from '../../stores/blockerStats';
type Props = { type Props = {
visible: boolean; visible: boolean;
@ -27,22 +27,11 @@ type Props = {
onTalkToLyra: () => void; 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 // Brand colors
const HERO_COLOR = '#f97316'; // orange-500 (counter accent) const HERO_COLOR = '#f97316'; // orange-500 (counter accent)
const SEG_ACTIVE = '#16a34a';
const SEG_VOTE = '#3b82f6';
const SEG_REVIEW = '#f59e0b'; const SEG_REVIEW = '#f59e0b';
const SEG_APPROVED = '#16a34a';
const SEG_REJECTED = '#ef4444';
export function ProtectionDetailsSheet({ export function ProtectionDetailsSheet({
visible, visible,
@ -56,26 +45,21 @@ export function ProtectionDetailsSheet({
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US'; const localeTag = i18n.language === 'de' ? 'de-DE' : 'en-US';
const [stats, setStats] = useState<StatsResponse | null>(null); const stats = useBlockerStatsStore((s) => s.stats);
const [loadingStats, setLoadingStats] = useState(false); const loadingStats = useBlockerStatsStore((s) => s.loading);
const refreshStatsIfStale = useBlockerStatsStore((s) => s.refreshIfStale);
useEffect(() => { useEffect(() => {
if (!visible) return; if (!visible) return;
let alive = true; refreshStatsIfStale(90_000).catch(() => {});
setLoadingStats(true); }, [visible, refreshStatsIfStale]);
apiFetch<StatsResponse>('/api/blocklist/stats')
.then((res) => { if (alive) setStats(res); })
.catch(() => { /* silent */ })
.finally(() => { if (alive) setLoadingStats(false); });
return () => { alive = false; };
}, [visible]);
const globalCount = stats?.current ?? state.blocklistCount; const globalCount = stats?.current ?? state.blocklistCount;
const weeklyAdded = stats?.weeklyAdded ?? 0; const weeklyAdded = stats?.weeklyAdded ?? 0;
const monthlyAdded = stats?.monthlyAdded ?? 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 myInReview = stats?.mySubmissions?.inReview ?? 0;
const myApproved = stats?.mySubmissions?.approved ?? 0;
const myRejected = stats?.mySubmissions?.rejected ?? 0;
const avgPerUser = stats?.avgPerUser ?? 0; const avgPerUser = stats?.avgPerUser ?? 0;
const avgWait = stats?.avgApprovalWaitDays ?? 0; const avgWait = stats?.avgApprovalWaitDays ?? 0;
@ -84,8 +68,9 @@ export function ProtectionDetailsSheet({
visible={visible} visible={visible}
onClose={onClose} onClose={onClose}
title={t('blocker.details_title')} title={t('blocker.details_title')}
initialHeightPct={0.75} initialHeightPct={0.9}
minHeightPct={0.3} minHeightPct={0.3}
navHeaderOffset={84}
safeAreaBottom={false} safeAreaBottom={false}
> >
<ScrollView <ScrollView
@ -148,11 +133,11 @@ export function ProtectionDetailsSheet({
<HalfDonut <HalfDonut
segments={[ segments={[
{ value: myActive, color: SEG_ACTIVE },
{ value: myInVote, color: SEG_VOTE },
{ value: myInReview, color: SEG_REVIEW }, { value: myInReview, color: SEG_REVIEW },
{ value: myApproved, color: SEG_APPROVED },
{ value: myRejected, color: SEG_REJECTED },
]} ]}
centerValue={myActive + myInVote + myInReview} centerValue={myInReview + myApproved + myRejected}
centerLabel={t('blocker.kpi_my_submissions')} centerLabel={t('blocker.kpi_my_submissions')}
/> />
@ -166,9 +151,9 @@ export function ProtectionDetailsSheet({
marginTop: 4, marginTop: 4,
}} }}
> >
<LegendItem color={SEG_ACTIVE} label={t('blocker.kpi_status_active')} value={myActive} />
<LegendItem color={SEG_VOTE} label={t('blocker.kpi_status_vote')} value={myInVote} />
<LegendItem color={SEG_REVIEW} label={t('blocker.kpi_status_review')} value={myInReview} /> <LegendItem color={SEG_REVIEW} label={t('blocker.kpi_status_review')} value={myInReview} />
<LegendItem color={SEG_APPROVED} label={t('blocker.status_approved')} value={myApproved} />
<LegendItem color={SEG_REJECTED} label={t('blocker.status_rejected')} value={myRejected} />
</View> </View>
</View> </View>

View File

@ -222,6 +222,7 @@ function AndroidProtectionSlide({
const { t } = useTranslation(); const { t } = useTranslation();
const [phase, setPhase] = useState<AndroidPhase>('preexplain_vpn'); const [phase, setPhase] = useState<AndroidPhase>('preexplain_vpn');
const [activating, setActivating] = useState(false); const [activating, setActivating] = useState(false);
const restartPromptShownRef = useRef(false);
// True wenn wir auf Settings-Rückkehr warten. AppState-Listener pollt dann // True wenn wir auf Settings-Rückkehr warten. AppState-Listener pollt dann
// a11y-State + advanced automatisch wenn ReBreak-Schalter live ist. // a11y-State + advanced automatisch wenn ReBreak-Schalter live ist.
const awaitingReturnRef = useRef(false); const awaitingReturnRef = useRef(false);
@ -233,10 +234,49 @@ function AndroidProtectionSlide({
body: { step: 'done' }, body: { step: 'done' },
}).catch(() => {}); }).catch(() => {});
invalidateMe(); invalidateMe();
await maybeShowRestartPrompt();
setPhase('done'); setPhase('done');
onDone(); onDone();
} }
function maybeShowRestartPrompt(): Promise<void> {
if (Platform.OS !== 'android') return Promise.resolve();
if (restartPromptShownRef.current) return Promise.resolve();
restartPromptShownRef.current = true;
return new Promise((resolve) => {
Alert.alert(
t('onboarding.protection.android_restart_title'),
t('onboarding.protection.android_restart_body'),
[
{
text: t('onboarding.protection.android_restart_later'),
style: 'cancel',
onPress: () => resolve(),
},
{
text: t('onboarding.protection.android_restart_now'),
onPress: () => {
(async () => {
try {
const result = await RebreakProtection.openPowerDialog?.();
if (!result?.opened) {
await protection.openSystemSettings();
}
} catch {
await protection.openSystemSettings().catch(() => {});
} finally {
resolve();
}
})();
},
},
],
{ cancelable: false },
);
});
}
async function activateVpn() { async function activateVpn() {
if (activating) return; if (activating) return;
setActivating(true); 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. // Auto-Check beim Foreground-Return: wenn a11y jetzt aktiv → Lock armen + done.
useEffect(() => { useEffect(() => {
const sub = AppState.addEventListener('change', async (next) => { const sub = AppState.addEventListener('change', async (next) => {
@ -287,6 +353,15 @@ function AndroidProtectionSlide({
appStateRef.current = next; appStateRef.current = next;
if (!awaitingReturnRef.current) return; if (!awaitingReturnRef.current) return;
if (prev.match(/inactive|background/) && next === 'active') { 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 { try {
const a11y = await RebreakProtection.isAccessibilityEnabled(); const a11y = await RebreakProtection.isAccessibilityEnabled();
if (a11y.enabled) { if (a11y.enabled) {
@ -346,6 +421,7 @@ function AndroidProtectionSlide({
total={total} total={total}
activating={activating} activating={activating}
onRetry={activateA11y} onRetry={activateA11y}
onResetForTesting={__DEV__ ? resetProtectionForTesting : undefined}
/> />
); );
} }
@ -357,11 +433,13 @@ function A11yPendingView({
total, total,
activating, activating,
onRetry, onRetry,
onResetForTesting,
}: { }: {
current: number; current: number;
total: number; total: number;
activating: boolean; activating: boolean;
onRetry: () => void; onRetry: () => void;
onResetForTesting?: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const colors = useColors(); const colors = useColors();
@ -374,6 +452,8 @@ function A11yPendingView({
primaryLabel={t('onboarding.protection.cta_check_a11y')} primaryLabel={t('onboarding.protection.cta_check_a11y')}
onPrimary={onRetry} onPrimary={onRetry}
primaryLoading={activating} primaryLoading={activating}
secondaryLabel={onResetForTesting ? 'Reset Schutz (DEV)' : undefined}
onSecondary={onResetForTesting}
/> />
} }
> >

View File

@ -248,7 +248,11 @@ export function useProtectionState(): UseProtectionStateReturn {
await fetchState(false); await fetchState(false);
}, [fetchState]); }, [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 { return {
state, state,

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.3.13</string> <string>0.3.13</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>45</string> <string>48</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.3.13</string> <string>0.3.13</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>45</string> <string>48</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.3.13</string> <string>0.3.13</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>45</string> <string>48</string>
<key>EXAppExtensionAttributes</key> <key>EXAppExtensionAttributes</key>
<dict> <dict>
<key>EXExtensionPointIdentifier</key> <key>EXExtensionPointIdentifier</key>

View File

@ -9,9 +9,12 @@ Building Release AAB (gradlew bundleRelease)|344
Validating IPA (App-Store Connect)|83 Validating IPA (App-Store Connect)|83
Uploading zu App-Store Connect (TestFlight)|102 Uploading zu App-Store Connect (TestFlight)|102
Building Release AAB (gradlew bundleRelease)|356 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 Validating IPA (App-Store Connect)|94
Uploading zu App-Store Connect (TestFlight)|105 Uploading zu App-Store Connect (TestFlight)|105
Building Release AAB (gradlew bundleRelease)|356 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