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>
159 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|