chahinebrini 63fae25531 fix(android-protection): explicit specialUse FGS type — Samsung/Android 16 crash loop
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>
2026-06-10 22:33:28 +02:00

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>
);
}