rebreak-monorepo/apps/rebreak-native/components/ProtectionOnboardingSheet.tsx
chahinebrini c1dd7e7320 fix(native/protection-android): a11y plugin self-heals XML, arm tamper-lock on return, truthful status check
- 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>
2026-05-16 11:24:45 +02:00

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>
);
}