RebreakVpnService.onStartCommand crashed with SecurityException because Android 16's validateForegroundServiceType rejects the implicit 2-arg startForeground(). Now passes FOREGROUND_SERVICE_TYPE_SPECIAL_USE explicitly (Google's documented best practice) and guards the call so a failed foreground promotion stops the service cleanly instead of crashing the app. Verified vs reported Galaxy A54 / Android 16 signature (97% of crash events, 1-user crash loop). Bundles pending working-tree work across native/marketing/locales/mac + graphify-out rebuild. gitignore: google-services.json + /screenshots/. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
265 lines
9.9 KiB
TypeScript
265 lines
9.9 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { View, Text, Platform } from 'react-native';
|
|
import { Asset } from 'expo-asset';
|
|
import Rive, { Fit, Alignment, RNRiveErrorType, type RNRiveError } 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 (+ ggf. TWO_PHASE/SUSTAINED)
|
|
// erweitern, kein weiterer Code-Change nötig.
|
|
export type SupportedEmotion =
|
|
| 'idle'
|
|
| 'happy'
|
|
| 'empathy'
|
|
| 'thinking'
|
|
| 'listening'
|
|
| 'calm'
|
|
| 'sad'
|
|
| 'joy'
|
|
| 'confusion'
|
|
| 'surprise';
|
|
export type Emotion = SupportedEmotion | (string & {});
|
|
|
|
// Direkte Timeline-Namen aus der .riv-Datei (Code-Contract — siehe
|
|
// docs/RIVE_ANIMATOR_BRIEF.md). Der Animator MUSS die States exakt so benennen,
|
|
// sonst spielt nichts (silent, kein Error). Bis die erweiterte .riv geliefert
|
|
// wird, existieren die neuen Timelines noch nicht → der Avatar zeigt für diese
|
|
// States den statischen Idle-Frame (kein Crash). "thinking" ersetzt den alten
|
|
// "WALK"-Platzhalter und animiert daher erst mit der neuen .riv wieder.
|
|
export const EMOTION_ANIMATIONS: Record<string, string> = {
|
|
idle: 'Idle Loop',
|
|
// Reuse vorhandener Hasen-Posen (0 Rive-Arbeit). Namen, die es noch NICHT in
|
|
// der .riv gibt (calm/sad/joy/confusion/surprise), fallen via safeAnim auf Idle.
|
|
happy: '01 Wave 2', // fröhliches Winken (war ungenutzt)
|
|
empathy: '01 Wave 1', // sanftes Winken
|
|
thinking: 'Pose 1 loop', // Klemmbrett: arbeitet an deiner Eingabe (direkt Loop, kein Zucken)
|
|
listening: 'Pose 1 loop', // Therapeut notiert beim Zuhören (direkt Loop, kein Zucken)
|
|
calm: 'calm',
|
|
sad: 'sad',
|
|
joy: 'joy',
|
|
confusion: 'confusion',
|
|
surprise: 'surprise',
|
|
};
|
|
|
|
// Timelines, die AKTUELL wirklich in lyra-avatar.riv existieren. Wird ein Name an
|
|
// die native Rive-View gegeben, der NICHT existiert, crasht die App hart — onError
|
|
// fängt das NICHT zuverlässig ab (empirisch verifiziert, der native Runtime stürzt
|
|
// tiefer ab). Deshalb mappen wir jeden unbekannten Namen VOR dem Rendern auf den
|
|
// garantiert vorhandenen Idle-Loop. → Beim Anlegen einer neuen Timeline in der
|
|
// .riv hier den exakten Namen ergänzen, dann animiert der zugehörige State.
|
|
export const EXISTING_TIMELINES = new Set<string>([
|
|
'Idle Loop',
|
|
'idle to Pose 1',
|
|
'Pose 1 loop',
|
|
'01 Wave 1',
|
|
'01 Wave 2',
|
|
'WALK',
|
|
'Kedip',
|
|
]);
|
|
|
|
function safeAnim(name: string): string {
|
|
return EXISTING_TIMELINES.has(name) ? name : EMOTION_ANIMATIONS.idle;
|
|
}
|
|
|
|
// Mehrphasige States: Intro-Timeline läuft einmal, danach Loop-Timeline. Aktuell
|
|
// leer — der Remount beim Intro→Loop-Wechsel verursacht ein sichtbares Zucken,
|
|
// daher spielen thinking/listening direkt die Loop-Pose. Mechanismus bleibt für
|
|
// künftige '<state> intro' + '<state> loop'-States verfügbar.
|
|
const TWO_PHASE: Record<string, { loop: string; introMs: number }> = {};
|
|
|
|
// Sustained = bleibt aktiv bis die Emotion explizit wechselt (z.B. solange Lyra
|
|
// "denkt", "zuhört" oder mitatmet). Alle anderen sind One-Shot-Reaktionen und
|
|
// fallen nach SETTLE_MS in den Idle-Loop zurück, damit der Avatar lebendig bleibt
|
|
// und nie auf dem letzten Frame einfriert.
|
|
const SUSTAINED = new Set<string>(['idle', 'thinking', 'listening', 'calm']);
|
|
const SETTLE_MS = 2600;
|
|
|
|
const EMOTION_LABELS: Record<string, string> = {
|
|
idle: 'bereit',
|
|
happy: 'froh für dich',
|
|
empathy: 'versteht dich',
|
|
thinking: 'überlegt ...',
|
|
listening: 'hört zu',
|
|
calm: 'atmet mit dir',
|
|
sad: 'fühlt mit dir',
|
|
joy: 'freut sich für dich',
|
|
confusion: 'fragt nach',
|
|
surprise: 'überrascht',
|
|
};
|
|
|
|
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>(safeAnim(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(() => {
|
|
const wanted = EMOTION_ANIMATIONS[resolvedEmotion] ?? EMOTION_ANIMATIONS.idle;
|
|
const base = safeAnim(wanted);
|
|
setCurrentAnim(base);
|
|
|
|
// Mehrphasig (z.B. happy: 'idle to Pose 1' → 'Pose 1 loop'): Intro einmal
|
|
// spielen, dann in den Loop blenden. Nur wenn BEIDE Timelines wirklich
|
|
// existieren — sonst bleibt's bei base (kein Sprung auf einen toten Namen).
|
|
const phase = TWO_PHASE[resolvedEmotion];
|
|
if (phase && EXISTING_TIMELINES.has(wanted) && EXISTING_TIMELINES.has(phase.loop)) {
|
|
const t = setTimeout(() => setCurrentAnim(phase.loop), phase.introMs);
|
|
return () => clearTimeout(t);
|
|
}
|
|
|
|
// One-Shots (empathy/sad/joy/confusion/surprise) frieren sonst auf dem
|
|
// letzten Frame ein → nach SETTLE_MS zurück in den Idle-Loop. Sustained-
|
|
// States (idle/happy/thinking/listening/calm) bleiben aktiv bis Emotion-Wechsel.
|
|
if (!SUSTAINED.has(resolvedEmotion) && base !== EMOTION_ANIMATIONS.idle) {
|
|
const t = setTimeout(() => setCurrentAnim(EMOTION_ANIMATIONS.idle), SETTLE_MS);
|
|
return () => clearTimeout(t);
|
|
}
|
|
}, [resolvedEmotion]);
|
|
|
|
// Crash-Guard: rive-react-native crasht NATIV (App-Absturz, kein JS-Error),
|
|
// wenn animationName nicht in der .riv existiert — z.B. ein neuer Emotion-State,
|
|
// der noch nicht gebaut wurde. Sobald onError gesetzt ist, schaltet die Lib auf
|
|
// isUserHandlingErrors und ruft statt zu crashen diesen Handler. Wir fallen dann
|
|
// auf den garantiert vorhandenen Idle-Loop zurück (Guard verhindert Endlos-Reset).
|
|
const handleRiveError = (err: RNRiveError) => {
|
|
if (__DEV__) console.warn('[RiveAvatar]', err?.type, '—', err?.message);
|
|
if (
|
|
err?.type === RNRiveErrorType.IncorrectAnimationName &&
|
|
currentAnim !== EMOTION_ANIMATIONS.idle
|
|
) {
|
|
setCurrentAnim(EMOTION_ANIMATIONS.idle);
|
|
}
|
|
};
|
|
|
|
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
|
|
onError={handleRiveError}
|
|
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
|
|
onError={handleRiveError}
|
|
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>
|
|
);
|
|
}
|