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: {
|
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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user