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>
182 lines
6.2 KiB
TypeScript
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>
|
|
);
|
|
}
|