chahinebrini 4a013bc43b feat(android-protection): präzise Tamper-Lock + a11y-Onboarding-Guide
Tamper-Lock von Keyword-Scanning auf präzise Einzel-Surfaces umgebaut:
blockt nur ReBreaks eigene Screens (Admin-Deaktivierung via DeviceAdminAdd,
a11y-Ausschalten, VPN-Trennen/Surface), nie Listen oder fremde Apps.

- Deny-Removal = Admin-only: OS graut Uninstall+Force-Stop für aktiven
  Device-Admin aus; einziger Bypass (Admin deaktivieren) bleibt a11y-gesperrt.
  Andere Apps verwalten/force-stoppen/deinstallieren bleibt komplett frei.
- a11y-Onboarding: passiver Bottom-Overlay-Hinweis + Settings-Reset auf
  Startseite nach Aktivierung + 1s-Delay vor App-Rückkehr.
- VPN-Trennen-Dialog + a11y-Ausschalten neu abgedeckt.
- a11y-Service-Icon im Plugin (klar als ReBreak erkennbar).

Verifiziert auf A50 per logcat: alle 4 Surfaces blocken, Listen + fremde
Apps frei, keine False-Positives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 04:05:41 +02:00

182 lines
6.2 KiB
TypeScript

import { useEffect, useState } from 'react';
import { View, Text, Platform } from 'react-native';
import { Asset } from 'expo-asset';
import Rive, { Fit, Alignment } from 'rive-react-native';
// Android: Rive akzeptiert NUR raw-resource oder url, kein file:// uri.
// Asset liegt als android/app/src/main/res/raw/lyra_avatar.riv (gebundelt
// via plugins/with-rive-asset-android.js). resourceName = filename ohne
// extension, lowercase + underscores (Android raw-resource convention).
const ANDROID_RIVE_RESOURCE = 'lyra_avatar';
// Modul-Level: nur EINMAL die Asset registrieren + URI cachen.
// In Production-Builds wird die .riv ins App-Bundle gebakt (Asset.localUri
// zeigt sofort auf das Bundle-File). In Dev-Builds wird sie beim ersten Mal
// von Metro gezogen + ins App-Sandbox-Cache geschrieben — danach offline.
const RIVE_MODULE = require('../assets/lyra-avatar.riv');
let cachedRiveUri: string | null = null;
let preloadPromise: Promise<string | null> | null = null;
function preloadRiveAsset(): Promise<string | null> {
if (cachedRiveUri) return Promise.resolve(cachedRiveUri);
if (preloadPromise) return preloadPromise;
preloadPromise = Asset.fromModule(RIVE_MODULE)
.downloadAsync()
.then((asset) => {
const uri = asset.localUri ?? asset.uri;
cachedRiveUri = uri;
return uri;
})
.catch((err) => {
console.warn('[RiveAvatar] preload failed:', err?.message ?? err);
preloadPromise = null;
return null;
});
return preloadPromise;
}
// Kicke den Preload sofort beim Modul-Import an — damit der erste
// Render bereits die cached URI nutzt (außer im allerersten App-Start).
preloadRiveAsset();
// Supported emotions sind durch state-machine im .riv-file definiert.
// Neue states: nur EMOTION_ANIMATIONS + EMOTION_LABELS erweitern, kein weiterer Code-Change nötig.
export type SupportedEmotion = 'idle' | 'happy' | 'thinking' | 'empathy';
export type Emotion = SupportedEmotion | (string & {});
// Direkte Animation-Namen aus der .riv-Datei (1:1 mit Nuxt BenAvatar.vue).
// "happy" hat zwei Phasen: erst Übergangs-Animation, dann Loop.
const EMOTION_ANIMATIONS: Record<string, string> = {
idle: 'Idle Loop',
happy: 'idle to Pose 1',
thinking: 'WALK',
empathy: '01 Wave 1',
};
const EMOTION_LABELS: Record<string, string> = {
idle: 'bereit',
happy: 'froh für dich',
thinking: 'überlegt ...',
empathy: 'versteht dich',
};
const SIZE_PX: Record<'sm' | 'md' | 'lg', number> = {
sm: 40,
md: 112,
lg: 160,
};
type Props = {
emotion: Emotion;
size?: 'sm' | 'md' | 'lg';
showLabel?: boolean;
fallback?: Emotion;
};
export function RiveAvatar({ emotion, size = 'md', showLabel = false, fallback = 'idle' }: Props) {
const px = SIZE_PX[size];
const resolvedEmotion = EMOTION_ANIMATIONS[emotion] !== undefined ? emotion : fallback;
// Aktuelle Animation als deklarativer State (kein imperatives ref.play()).
const [currentAnim, setCurrentAnim] = useState<string>(EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle);
// Lokale URI für die .riv-Datei — geht über expo-asset damit der File
// im App-Sandbox gecached wird statt jedes Mal von Metro zu streamen.
// Nach erstem Load funktioniert's auch komplett offline.
const [riveUri, setRiveUri] = useState<string | null>(cachedRiveUri);
useEffect(() => {
if (riveUri) return; // schon gecached
let active = true;
preloadRiveAsset().then((uri) => {
if (active && uri) setRiveUri(uri);
});
return () => {
active = false;
};
}, [riveUri]);
useEffect(() => {
if (resolvedEmotion === 'happy') {
// 2-Phasen-Flow: Übergang (~900ms) → Loop (1:1 wie Nuxt-BenAvatar)
setCurrentAnim('idle to Pose 1');
const t = setTimeout(() => setCurrentAnim('Pose 1 loop'), 900);
return () => clearTimeout(t);
}
const anim = EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle;
setCurrentAnim(anim);
// Intro-Emotions wie 'empathy' ('01 Wave 1') / 'thinking' ('WALK') sind
// One-Shots — ohne Übergang frieren sie auf dem letzten Frame ein und wirken
// "nicht animiert". Nach dem Intro in den Idle Loop fallen, damit der Avatar
// lebendig bleibt (idle loopt selbst).
if (anim !== EMOTION_ANIMATIONS.idle) {
const t = setTimeout(() => setCurrentAnim(EMOTION_ANIMATIONS.idle), 2600);
return () => clearTimeout(t);
}
}, [resolvedEmotion]);
return (
<View style={{ alignItems: 'center', gap: 4 }}>
<View
style={{
width: px,
height: px,
// Floating-Shadow nur für md/lg — bei sm zu klein, würde unsauber wirken
...(size !== 'sm' && {
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.18,
shadowRadius: 16,
elevation: 10,
}),
}}
>
<View
style={{
width: px,
height: px,
borderRadius: px / 2,
overflow: 'hidden',
backgroundColor: '#ffffff',
borderWidth: size !== 'sm' ? 4 : 0,
borderColor: '#ffffff',
}}
>
{Platform.OS === 'android' ? (
// Android: Bundle-Resource direkt (kein expo-asset Preload nötig)
<Rive
key={currentAnim}
resourceName={ANDROID_RIVE_RESOURCE}
autoplay
animationName={currentAnim}
fit={Fit.Cover}
alignment={Alignment.Center}
style={{ width: px, height: px }}
/>
) : riveUri ? (
// iOS: file:// URI aus expo-asset Cache funktioniert
<Rive
key={currentAnim}
source={{ uri: riveUri }}
autoplay
animationName={currentAnim}
fit={Fit.Cover}
alignment={Alignment.Center}
style={{ width: px, height: px }}
/>
) : (
<View style={{ width: px, height: px, backgroundColor: '#ffffff' }} />
)}
</View>
</View>
{showLabel && (
<Text style={{ fontSize: 11, color: '#737373', letterSpacing: 0.3 }}>
{EMOTION_LABELS[resolvedEmotion] ?? EMOTION_LABELS[fallback] ?? ''}
</Text>
)}
</View>
);
}