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

131 lines
4.2 KiB
TypeScript

import { useState } from 'react';
import { View, Text } from 'react-native';
import { Image } from 'expo-image';
import { SvgXml } from 'react-native-svg';
import { useOnlineUsers } from '../hooks/useOnlineUsers';
import { resolveAvatar } from '../lib/resolveAvatar';
import { useColors } from '../lib/theme';
// clarity--avatar-line (assets/clarity--avatar-line.svg) als Inline-XML — kein
// svg-transformer im Projekt, daher via <SvgXml>. currentColor wird über die
// `color`-Prop getintet.
const AVATAR_PLACEHOLDER_SVG =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="currentColor" d="M18 17a7 7 0 1 0-7-7a7 7 0 0 0 7 7m0-12a5 5 0 1 1-5 5a5 5 0 0 1 5-5"/><path fill="currentColor" d="M30.47 24.37a17.16 17.16 0 0 0-24.93 0A2 2 0 0 0 5 25.74V31a2 2 0 0 0 2 2h22a2 2 0 0 0 2-2v-5.26a2 2 0 0 0-.53-1.37M29 31H7v-5.27a15.17 15.17 0 0 1 22 0Z"/></svg>';
type Size = 'sm' | 'md' | 'lg' | 'xl';
type Props = {
userId: string | null;
avatar: string | null;
nickname: string;
size?: Size;
showOnlineIndicator?: boolean;
isBot?: boolean;
// Online-Punkt OHNE Follow-Gate (rohe Presence). Für Kontexte wo die Beziehung
// bereits etabliert ist (z.B. DM-Header) — sonst zeigt der Punkt nur bei
// gefolgten Usern, was inkonsistent zum „online"-Text wäre (der ist ungated).
rawPresence?: boolean;
};
const SIZE_MAP: Record<
Size,
{ avatar: number; dot: number; border: number; font: number; inset: number }
> = {
// inset = bottom/right Offset, berechnet via `avatarRadius*0.293 - dotRadius`
// damit der Dot-Center exakt auf der Avatar-Perimeter bei 45° sitzt (4:30
// clock position). Konsistente Insta-Optik unabhängig vom Avatar-Size.
sm: { avatar: 28, dot: 8, border: 2, font: 11, inset: 0 },
md: { avatar: 40, dot: 11, border: 2.5, font: 14, inset: 0 },
lg: { avatar: 56, dot: 14, border: 3, font: 18, inset: 1 },
xl: { avatar: 96, dot: 18, border: 3, font: 32, inset: 5 },
};
function OnlineDot({ size, bgColor }: { size: Size; bgColor: string }) {
const s = SIZE_MAP[size];
return (
<View
style={{
position: 'absolute',
bottom: s.inset,
right: s.inset,
width: s.dot,
height: s.dot,
borderRadius: s.dot / 2,
backgroundColor: '#22c55e',
borderWidth: s.border,
borderColor: bgColor,
shadowColor: '#22c55e',
shadowOpacity: 0.3,
shadowRadius: 2,
shadowOffset: { width: 0, height: 0 },
elevation: 2,
}}
/>
);
}
export function UserAvatar({
userId,
avatar,
nickname,
size = 'md',
showOnlineIndicator = true,
isBot = false,
rawPresence = false,
}: Props) {
const colors = useColors();
const { isOnline, onlineUserIds } = useOnlineUsers();
const [imageFailed, setImageFailed] = useState(false);
const s = SIZE_MAP[size];
const radius = s.avatar / 2;
const hasImage = !!avatar && !isBot && !imageFailed;
const avatarUrl = hasImage ? resolveAvatar(avatar, nickname) : '';
const initials = (nickname.charAt(0) + (nickname.charAt(1) ?? '')).toUpperCase() || '?';
const showDot =
showOnlineIndicator !== false &&
!!userId &&
!isBot &&
(rawPresence ? onlineUserIds.has(userId) : isOnline(userId));
return (
<View style={{ position: 'relative', width: s.avatar, height: s.avatar }}>
{hasImage ? (
<Image
source={{ uri: avatarUrl }}
onError={() => setImageFailed(true)}
style={{
width: s.avatar,
height: s.avatar,
borderRadius: radius,
backgroundColor: colors.surfaceElevated,
}}
contentFit="cover"
/>
) : (
<View
style={{
width: s.avatar,
height: s.avatar,
borderRadius: radius,
backgroundColor: colors.avatarPlaceholder,
alignItems: 'center',
justifyContent: 'center',
}}
>
<SvgXml
xml={AVATAR_PLACEHOLDER_SVG}
width={s.avatar * 0.66}
height={s.avatar * 0.66}
color="#ffffff"
/>
</View>
)}
{showDot && <OnlineDot size={size} bgColor={colors.bg} />}
</View>
);
}