- with-rebreak-protection-android plugin now copies the source accessibility_service_config.xml via withDangerousMod instead of generating it from a string. Eliminates the silent regression where prebuild wrote flagReportViewIds + missing packageNames, leaving Samsung's content scan unable to read OEM dialogs. - ProtectionOnboardingSheet refresh() now calls activateFamilyControls() once a11y is detected as enabled, so armTamperLock() actually runs. Previously the sheet auto-completed on getDeviceState() alone, leaving tamper_armed=false and the service permanently passive. - RebreakProtectionModule.isAccessibilityServiceEnabled() now trusts the AccessibilityManager list as authoritative when AM is available (even when empty). Settings.Secure fallback only kicks in if AM is null/exception. Fixes the banner falsely showing "Schutz aktiv" when the system has unbound the service but ENABLED_ACCESSIBILITY_SERVICES still holds the id. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
287 lines
7.9 KiB
TypeScript
287 lines
7.9 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { 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';
|
|
|
|
type StepState = 'pending' | 'done';
|
|
|
|
export function ProtectionOnboardingSheet({
|
|
visible,
|
|
onComplete,
|
|
onSkip,
|
|
}: {
|
|
visible: boolean;
|
|
onComplete: () => void;
|
|
onSkip: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const colors = useColors();
|
|
|
|
const [vpnState, setVpnState] = useState<StepState>('pending');
|
|
const [a11yState, setA11yState] = useState<StepState>('pending');
|
|
const [vpnLoading, setVpnLoading] = useState(false);
|
|
const [a11yLoading, setA11yLoading] = useState(false);
|
|
|
|
const vpnDone = vpnState === 'done';
|
|
const a11yDone = a11yState === 'done';
|
|
|
|
// AppState-Listener: User kehrt aus System-Settings zurück → State neu abfragen
|
|
const refreshInFlightRef = useRef(false);
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
|
|
async function refresh() {
|
|
if (refreshInFlightRef.current) return;
|
|
refreshInFlightRef.current = true;
|
|
try {
|
|
const layers = await protection.getDeviceState();
|
|
const vpnActive = layers.vpn === true;
|
|
const a11yActive = layers.accessibility === true;
|
|
if (vpnActive) setVpnState('done');
|
|
if (a11yActive && vpnActive) {
|
|
// Arm tamper-lock once a11y is enabled — activateFamilyControls() second
|
|
// call goes through the armTamperLock() path. Without this, the service
|
|
// is bound but stays passive because tamper_armed stays false.
|
|
const r = await protection.activateFamilyControls();
|
|
if (r.enabled) setA11yState('done');
|
|
}
|
|
} finally {
|
|
refreshInFlightRef.current = false;
|
|
}
|
|
}
|
|
|
|
refresh();
|
|
const sub = AppState.addEventListener('change', (next) => {
|
|
if (next === 'active') refresh();
|
|
});
|
|
return () => sub.remove();
|
|
}, [visible]);
|
|
|
|
// Beide Steps done → onComplete
|
|
useEffect(() => {
|
|
if (vpnDone && a11yDone) {
|
|
const t = setTimeout(onComplete, 400);
|
|
return () => clearTimeout(t);
|
|
}
|
|
}, [vpnDone, a11yDone, onComplete]);
|
|
|
|
async function handleVpnStep() {
|
|
setVpnLoading(true);
|
|
try {
|
|
const result = await protection.activateUrlFilter();
|
|
if (result.enabled) setVpnState('done');
|
|
} finally {
|
|
setVpnLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleA11yStep() {
|
|
if (!vpnDone) return;
|
|
setA11yLoading(true);
|
|
try {
|
|
const result = await protection.activateFamilyControls();
|
|
if (result.enabled) setA11yState('done');
|
|
} finally {
|
|
setA11yLoading(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<FormSheet
|
|
visible={visible}
|
|
onClose={onSkip}
|
|
title={t('protection_onboarding.sheet_title')}
|
|
initialHeightPct={0.58}
|
|
minHeightPct={0.35}
|
|
backdropOpacity={0.18}
|
|
dismissOnBackdrop={false}
|
|
growWithKeyboard={false}
|
|
>
|
|
<View style={{ flex: 1, paddingHorizontal: 20, paddingTop: 16, gap: 12 }}>
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.textMuted,
|
|
lineHeight: 19,
|
|
marginBottom: 4,
|
|
}}
|
|
>
|
|
{t('protection_onboarding.sheet_intro')}
|
|
</Text>
|
|
|
|
{/* Step 1 — VPN */}
|
|
<StepCard
|
|
stepNumber={1}
|
|
title={t('protection_onboarding.step_vpn_title')}
|
|
description={t('protection_onboarding.step_vpn_desc')}
|
|
state={vpnState}
|
|
loading={vpnLoading}
|
|
disabled={false}
|
|
ctaLabel={t('protection_onboarding.step_vpn_cta')}
|
|
onPress={handleVpnStep}
|
|
colors={colors}
|
|
/>
|
|
|
|
{/* Step 2 — A11y */}
|
|
<StepCard
|
|
stepNumber={2}
|
|
title={t('protection_onboarding.step_a11y_title')}
|
|
description={t('protection_onboarding.step_a11y_desc')}
|
|
state={a11yState}
|
|
loading={a11yLoading}
|
|
disabled={!vpnDone}
|
|
ctaLabel={t('protection_onboarding.step_a11y_cta')}
|
|
onPress={handleA11yStep}
|
|
colors={colors}
|
|
/>
|
|
|
|
<View style={{ flex: 1 }} />
|
|
|
|
{/* Skip */}
|
|
<View style={{ alignItems: 'center', paddingBottom: 8 }}>
|
|
<Text
|
|
onPress={onSkip}
|
|
style={{
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_600SemiBold',
|
|
color: colors.textMuted,
|
|
textDecorationLine: 'underline',
|
|
}}
|
|
>
|
|
{t('protection_onboarding.skip_cta')}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</FormSheet>
|
|
);
|
|
}
|
|
|
|
// ─── StepCard ────────────────────────────────────────────────────────────────
|
|
|
|
import { ActivityIndicator, TouchableOpacity } from 'react-native';
|
|
import type { ColorScheme } from '../lib/theme';
|
|
|
|
function StepCard({
|
|
stepNumber,
|
|
title,
|
|
description,
|
|
state,
|
|
loading,
|
|
disabled,
|
|
ctaLabel,
|
|
onPress,
|
|
colors,
|
|
}: {
|
|
stepNumber: number;
|
|
title: string;
|
|
description: string;
|
|
state: StepState;
|
|
loading: boolean;
|
|
disabled: boolean;
|
|
ctaLabel: string;
|
|
onPress: () => void;
|
|
colors: ColorScheme;
|
|
}) {
|
|
const done = state === 'done';
|
|
|
|
return (
|
|
<View
|
|
style={{
|
|
backgroundColor: colors.surface,
|
|
borderRadius: 16,
|
|
borderWidth: 1,
|
|
borderColor: done ? '#16a34a40' : disabled ? colors.border : colors.border,
|
|
padding: 14,
|
|
gap: 10,
|
|
opacity: disabled ? 0.48 : 1,
|
|
}}
|
|
>
|
|
{/* Header row */}
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
|
|
{/* Step badge / checkmark */}
|
|
<View
|
|
style={{
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: 14,
|
|
backgroundColor: done ? '#16a34a' : disabled ? colors.surfaceElevated : colors.brandOrange + '18',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
}}
|
|
>
|
|
{done ? (
|
|
<Ionicons name="checkmark" size={16} color="#fff" />
|
|
) : (
|
|
<Text
|
|
style={{
|
|
fontSize: 13,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: disabled ? colors.textMuted : colors.brandOrange,
|
|
}}
|
|
>
|
|
{stepNumber}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_700Bold', color: colors.text }}>
|
|
{title}
|
|
</Text>
|
|
</View>
|
|
|
|
{/* Lock icon when disabled */}
|
|
{disabled && (
|
|
<Ionicons name="lock-closed" size={16} color={colors.textMuted} />
|
|
)}
|
|
</View>
|
|
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontFamily: 'Nunito_400Regular',
|
|
color: colors.textMuted,
|
|
lineHeight: 17,
|
|
}}
|
|
>
|
|
{description}
|
|
</Text>
|
|
|
|
{!done && (
|
|
<TouchableOpacity
|
|
onPress={onPress}
|
|
disabled={disabled || loading}
|
|
activeOpacity={0.8}
|
|
style={{
|
|
backgroundColor: disabled ? colors.surfaceElevated : colors.brandOrange,
|
|
borderRadius: 10,
|
|
paddingVertical: 10,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
flexDirection: 'row',
|
|
gap: 6,
|
|
}}
|
|
>
|
|
{loading ? (
|
|
<ActivityIndicator size="small" color={disabled ? colors.textMuted : '#fff'} />
|
|
) : (
|
|
<Text
|
|
style={{
|
|
fontSize: 14,
|
|
fontFamily: 'Nunito_700Bold',
|
|
color: disabled ? colors.textMuted : '#fff',
|
|
}}
|
|
>
|
|
{ctaLabel}
|
|
</Text>
|
|
)}
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
);
|
|
}
|