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:
parent
adf0d33f1b
commit
db0aa6d24e
@ -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
|
||||
|
||||
@ -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<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 () => {
|
||||
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;
|
||||
|
||||
@ -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}
|
||||
</View>
|
||||
|
||||
{/* Section 1: Dieses Gerät */}
|
||||
{/* Unified devices section: Mobile zuerst, dann Desktop */}
|
||||
<View>
|
||||
<SectionLabel title={t('devices.section_title_this')} />
|
||||
<SectionLabel title={t('settings.devices')} />
|
||||
<SectionCard>
|
||||
{mobileLoading && !currentDevice ? (
|
||||
{isLoading && isEmpty ? (
|
||||
<View style={{ paddingVertical: 32, alignItems: 'center' }}>
|
||||
<ActivityIndicator color={colors.brandOrange} />
|
||||
</View>
|
||||
) : currentDevice ? (
|
||||
<MobileDeviceRow
|
||||
device={currentDevice}
|
||||
onRemove={removeMobileDevice}
|
||||
onRequestRelease={requestRelease}
|
||||
onCancelRelease={cancelRelease}
|
||||
/>
|
||||
) : (
|
||||
) : isEmpty ? (
|
||||
<View style={{ paddingVertical: 20, alignItems: 'center' }}>
|
||||
<Text
|
||||
style={{
|
||||
@ -579,47 +578,40 @@ export default function DevicesScreen() {
|
||||
{t('settings.devices_empty')}
|
||||
</Text>
|
||||
</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
|
||||
key={device.id}
|
||||
style={{
|
||||
borderBottomWidth: i < protectedDevices.length - 1 ? 1 : 0,
|
||||
borderBottomColor: colors.border,
|
||||
}}
|
||||
>
|
||||
<ProtectedDeviceRow device={device} onRemove={handleRemoveProtected} />
|
||||
</View>
|
||||
))
|
||||
<>
|
||||
{sortedMobile.map((device, i) => {
|
||||
const isLast =
|
||||
i === sortedMobile.length - 1 && protectedDevices.length === 0;
|
||||
return (
|
||||
<View
|
||||
key={device.id}
|
||||
style={{
|
||||
borderBottomWidth: isLast ? 0 : 1,
|
||||
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>
|
||||
</View>
|
||||
|
||||
@ -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<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() {
|
||||
setVpnLoading(true);
|
||||
try {
|
||||
|
||||
@ -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<StatsResponse | null>(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<StatsResponse>('/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}
|
||||
>
|
||||
<ScrollView
|
||||
@ -148,11 +133,11 @@ export function ProtectionDetailsSheet({
|
||||
|
||||
<HalfDonut
|
||||
segments={[
|
||||
{ value: myActive, color: SEG_ACTIVE },
|
||||
{ value: myInVote, color: SEG_VOTE },
|
||||
{ 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')}
|
||||
/>
|
||||
|
||||
@ -166,9 +151,9 @@ export function ProtectionDetailsSheet({
|
||||
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_APPROVED} label={t('blocker.status_approved')} value={myApproved} />
|
||||
<LegendItem color={SEG_REJECTED} label={t('blocker.status_rejected')} value={myRejected} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
@ -222,6 +222,7 @@ function AndroidProtectionSlide({
|
||||
const { t } = useTranslation();
|
||||
const [phase, setPhase] = useState<AndroidPhase>('preexplain_vpn');
|
||||
const [activating, setActivating] = useState(false);
|
||||
const restartPromptShownRef = useRef(false);
|
||||
// True wenn wir auf Settings-Rückkehr warten. AppState-Listener pollt dann
|
||||
// a11y-State + advanced automatisch wenn ReBreak-Schalter live ist.
|
||||
const awaitingReturnRef = useRef(false);
|
||||
@ -233,10 +234,49 @@ function AndroidProtectionSlide({
|
||||
body: { step: 'done' },
|
||||
}).catch(() => {});
|
||||
invalidateMe();
|
||||
await maybeShowRestartPrompt();
|
||||
setPhase('done');
|
||||
onDone();
|
||||
}
|
||||
|
||||
function maybeShowRestartPrompt(): Promise<void> {
|
||||
if (Platform.OS !== 'android') return Promise.resolve();
|
||||
if (restartPromptShownRef.current) return Promise.resolve();
|
||||
restartPromptShownRef.current = true;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
Alert.alert(
|
||||
t('onboarding.protection.android_restart_title'),
|
||||
t('onboarding.protection.android_restart_body'),
|
||||
[
|
||||
{
|
||||
text: t('onboarding.protection.android_restart_later'),
|
||||
style: 'cancel',
|
||||
onPress: () => resolve(),
|
||||
},
|
||||
{
|
||||
text: t('onboarding.protection.android_restart_now'),
|
||||
onPress: () => {
|
||||
(async () => {
|
||||
try {
|
||||
const result = await RebreakProtection.openPowerDialog?.();
|
||||
if (!result?.opened) {
|
||||
await protection.openSystemSettings();
|
||||
}
|
||||
} catch {
|
||||
await protection.openSystemSettings().catch(() => {});
|
||||
} finally {
|
||||
resolve();
|
||||
}
|
||||
})();
|
||||
},
|
||||
},
|
||||
],
|
||||
{ cancelable: false },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function activateVpn() {
|
||||
if (activating) return;
|
||||
setActivating(true);
|
||||
@ -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}
|
||||
/>
|
||||
}
|
||||
>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>45</string>
|
||||
<string>48</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>45</string>
|
||||
<string>48</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.13</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>45</string>
|
||||
<string>48</string>
|
||||
<key>EXAppExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>EXExtensionPointIdentifier</key>
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user