chahinebrini c7fc237dfd feat(android-protection): device-admin uninstall-block + boot-receiver + config plugin
Android self-bind protection auf nahezu MDM-Niveau ohne Device-Owner:
- Device-Admin (RebreakDeviceAdminReceiver) blockt Uninstall OS-seitig, aktiv ab
  Boot ohne Prozess/a11y. Deaktivierung nur via 24h-Cooldown (removeDeviceAdmin in
  forceDisable). a11y blockt die DeviceAdminAdd-Settings-Seite (Class-Match, auf
  Samsung One UI per Logcat verifiziert).
- Boot-Receiver (RebreakVpnBootReceiver) startet VPN+a11y nach Reboot, damit der
  Tamper-Lock ohne manuellen App-Start hochkommt.
- Manifest-Wiring (Device-Admin-Receiver, Boot-Receiver, RECEIVE_BOOT_COMPLETED,
  device_admin.xml) ins with-rebreak-protection-android Config-Plugin verlagert →
  ueberlebt 'expo prebuild' (android/ ist gitignored).
- a11y-Detection zurueck auf die funktionierende Version: zu breites 'loeschen'-
  Uninstall-Keyword raus (blockte halbe Settings); a11y-Label jetzt 'ReBreak Schutz'.
- a11y-Deeplink behaelt den Samsung-Step-Guide (openAccessibilitySettings).

Session-Frontend in diesem Batch:
- Avatar-Placeholder: neutrales clarity-avatar-line SVG statt dominantem Blau.
- DiGA-Milestone folgt kumulativen protectedDays (erreicht rueckfall-anfaellige User).
- Dev-Build crasht nicht mehr ohne CallKit-Native-Modul.
- VPN-Permission-Dialog nur noch im Bypass-Fall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 04:52:49 +02:00

159 lines
5.4 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { View, Text, TouchableOpacity, Animated } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useMe } from '../hooks/useMe';
import { useProtectionCoverage } from '../hooks/useProfileData';
import { apiFetch } from '../lib/api';
import { FormSheet } from './FormSheet';
import { useColors } from '../lib/theme';
const MILESTONES = [3, 7, 10] as const;
function storageKey(userId: string, days: number) {
return `@rebreak/diga_milestone_${userId}_${days}`;
}
type DemographicsResp = { birthYear: number | null } & Record<string, unknown>;
export function DiGaMilestoneModal() {
const { t } = useTranslation();
const colors = useColors();
const router = useRouter();
const { me } = useMe();
const { coverage } = useProtectionCoverage();
const [milestone, setMilestone] = useState<number | null>(null);
const scaleAnim = useRef(new Animated.Value(0.8)).current;
// Lean demographics check — only birthYear needed to determine completeness
const { data: demo } = useQuery<DemographicsResp>({
queryKey: ['diga-demo-check'],
queryFn: () => apiFetch('/api/profile/me/demographics'),
enabled: !!me,
staleTime: 60_000,
});
useEffect(() => {
if (!me || demo === undefined) return;
// Kumulative Schutz-Tage (fällt NIE auf 0 zurück, anders als die
// zusammenhängende Streak-Phase). So erreicht jeder engagierte User
// irgendwann einen Milestone — auch rückfall-anfällige, deren DiGA-Daten
// am wertvollsten sind.
const protectedDays = coverage?.protectedDays ?? 0;
const demographicsComplete = !!(demo?.birthYear);
if (demographicsComplete) return; // already filled → never show
(async () => {
// Find highest milestone reached and not yet shown
for (let i = MILESTONES.length - 1; i >= 0; i--) {
const m = MILESTONES[i];
if (protectedDays < m) continue;
const shown = await AsyncStorage.getItem(storageKey(me.id, m));
if (!shown) {
setMilestone(m);
return;
}
}
})();
}, [me?.id, coverage?.protectedDays, demo]);
useEffect(() => {
if (milestone !== null) {
Animated.spring(scaleAnim, { toValue: 1, useNativeDriver: true, damping: 14 }).start();
} else {
scaleAnim.setValue(0.8);
}
}, [milestone]);
async function dismiss() {
if (!me || !milestone) return;
await AsyncStorage.setItem(storageKey(me.id, milestone), '1');
setMilestone(null);
}
async function openProfile() {
await dismiss();
router.push('/profile?openDemo=1' as any);
}
if (!milestone) return null;
const badgeColor = milestone >= 10 ? '#f59e0b' : milestone >= 7 ? '#8b5cf6' : colors.brandOrange;
return (
<FormSheet
visible={true}
onClose={dismiss}
title=""
initialHeightPct={0.52}
dismissOnBackdrop
>
<Animated.View style={{ transform: [{ scale: scaleAnim }], paddingHorizontal: 24, paddingTop: 8, paddingBottom: 32, alignItems: 'center', gap: 0 }}>
{/* Milestone badge */}
<View style={{
width: 80, height: 80, borderRadius: 40,
backgroundColor: badgeColor + '18',
alignItems: 'center', justifyContent: 'center',
marginBottom: 16,
}}>
<Text style={{ fontSize: 40 }}>
{milestone >= 10 ? '🏆' : milestone >= 7 ? '🌟' : '🎉'}
</Text>
</View>
<View style={{
backgroundColor: badgeColor + '18',
borderRadius: 20,
paddingHorizontal: 14,
paddingVertical: 5,
marginBottom: 14,
}}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_700Bold', color: badgeColor }}>
{t('diga_milestone.badge', { days: milestone })}
</Text>
</View>
<Text style={{ fontSize: 22, fontFamily: 'Nunito_800ExtraBold', color: colors.text, textAlign: 'center', marginBottom: 10 }}>
{t('diga_milestone.title', { days: milestone })}
</Text>
<Text style={{ fontSize: 14, fontFamily: 'Nunito_400Regular', color: colors.textMuted, textAlign: 'center', lineHeight: 21, marginBottom: 28 }}>
{t('diga_milestone.body')}
</Text>
{/* Primary CTA */}
<TouchableOpacity
onPress={openProfile}
activeOpacity={0.85}
style={{
width: '100%',
backgroundColor: badgeColor,
borderRadius: 14,
paddingVertical: 15,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: 8,
marginBottom: 10,
}}
>
<Ionicons name="person-outline" size={17} color="#fff" />
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#fff' }}>
{t('diga_milestone.cta')}
</Text>
</TouchableOpacity>
{/* Dismiss */}
<TouchableOpacity onPress={dismiss} activeOpacity={0.7} hitSlop={12}>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.textMuted }}>
{t('diga_milestone.later')}
</Text>
</TouchableOpacity>
</Animated.View>
</FormSheet>
);
}