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

307 lines
10 KiB
TypeScript

import { useEffect } from 'react';
import { AppState, I18nManager, Platform } from 'react-native';
I18nManager.allowRTL(true);
import { Stack, router } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import * as Notifications from 'expo-notifications';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { KeyboardProvider } from 'react-native-keyboard-controller';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import * as SplashScreen from 'expo-splash-screen';
import {
useFonts,
Nunito_400Regular,
Nunito_600SemiBold,
Nunito_700Bold,
Nunito_800ExtraBold,
} from '@expo-google-fonts/nunito';
import { supabase } from '../lib/supabase';
import { useAuthStore } from '../stores/auth';
import { useThemeStore } from '../stores/theme';
import { useRealtimeDebugStore } from '../stores/realtimeDebug';
import { useColors } from '../lib/theme';
import { useLanguageStore } from '../stores/language';
import { useAppLockStore } from '../stores/appLock';
import { useLyraVoiceStore } from '../stores/lyraVoice';
import { AppLockGate } from '../components/AppLockGate';
import { DeviceLimitReachedSheet } from '../components/DeviceLimitReachedSheet';
import { DeviceApprovalIncomingSheet } from '../components/DeviceApprovalIncomingSheet';
import { DeviceApprovalPendingSheet } from '../components/DeviceApprovalPendingSheet';
import { useDeviceApprovalRealtime } from '../hooks/useDeviceApprovalRealtime';
import { useDevicesStore } from '../stores/devices';
import { OnlinePresenceProvider } from '../components/OnlinePresenceProvider';
import { usePushTokenRegistration } from '../hooks/usePushTokenRegistration';
import { useIncomingCalls } from '../hooks/useIncomingCalls';
import { useCallStore } from '../stores/call';
import { useCallKeepEvents } from '../hooks/useCallKeepEvents';
import '../lib/i18n'; // i18next-Init via Side-Effect
import '../global.css';
SplashScreen.preventAutoHideAsync();
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowBanner: true,
shouldShowList: true,
shouldPlaySound: true,
shouldSetBadge: false,
}),
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 1000 * 60,
},
},
});
function RootLayoutInner() {
const { loading, init, user } = useAuthStore();
const initTheme = useThemeStore((s) => s.init);
const colorScheme = useThemeStore((s) => s.colorScheme);
const initLanguage = useLanguageStore((s) => s.init);
const initAppLock = useAppLockStore((s) => s.init);
const initLyraVoice = useLyraVoiceStore((s) => s.init);
const appLockReady = useAppLockStore((s) => s.ready);
const initRealtimeDebug = useRealtimeDebugStore((s) => s.init);
const colors = useColors();
const [fontsLoaded] = useFonts({
Nunito_400Regular,
Nunito_600SemiBold,
Nunito_700Bold,
Nunito_800ExtraBold,
});
// Push-Token-Registration nach Login (idempotent)
usePushTokenRegistration(user?.id);
// Eingehende Voice-Calls (Ring-Channel) — zeigt den Call-Screen wenn jemand
// anruft. Foreground-only (Phase 1).
useIncomingCalls(user?.id);
// CallKit/ConnectionService Event-Bridge — mappt native UI-Actions
// (Accept/Decline/Hangup im Lockscreen-CallKit-UI) auf useCallStore.
useCallKeepEvents();
// Apple-Style Device-Approval Realtime — lauscht auf neue Approval-Requests
// für diesen User und zeigt das Incoming-Sheet wenn ein anderes Gerät
// sich anmelden möchte.
useDeviceApprovalRealtime(!!user?.id);
// Push-Tap-Deep-Link: User tippt Notification → navigate zu Chat / Call
useEffect(() => {
const handle = (response: Notifications.NotificationResponse | null | undefined) => {
if (!response) return;
const data = response.notification.request.content.data as
| {
type?: 'dm' | 'room' | 'call';
targetId?: string;
callId?: string;
from?: { id: string; nickname: string; avatar: string | null };
}
| undefined;
if (!data?.type) return;
if (data.type === 'dm' && data.targetId) {
router.push({ pathname: '/dm', params: { userId: data.targetId } });
} else if (data.type === 'room' && data.targetId) {
router.push({ pathname: '/room', params: { roomId: data.targetId } });
} else if (data.type === 'call' && data.callId && data.from) {
// Eingehender Anruf via regulärem Push (Android-Pfad / iOS-Fallback wenn
// kein voipToken). Auf iOS gilt: VoIP-PushKit ist der einzige Pfad
// der die App im Background wachrüttelt — und der landet NICHT hier
// (geht via AppDelegate → useCallKeepEvents). Wenn der User stattdessen
// einen verpassten-Anruf-Push tappt, ist der Call längst beendet —
// wir würden ihn künstlich auferwecken und die App zeigt einen Geist-
// /call-Screen. Deshalb auf iOS hier nichts tun.
if (Platform.OS === 'ios') return;
try {
useCallStore.getState().receiveIncoming(data.callId, data.from);
router.push('/call');
} catch {
// ignore — Call evtl. schon beendet
}
}
};
const sub = Notifications.addNotificationResponseReceivedListener(handle);
// Cold-Start: App wurde durch Notification-Tap geöffnet
Notifications.getLastNotificationResponseAsync().then(handle).catch(() => {});
return () => sub.remove();
}, []);
useEffect(() => {
init();
initTheme();
initLanguage();
initAppLock();
initLyraVoice();
if (__DEV__) initRealtimeDebug();
}, []);
// Supabase-Doku-Pattern für RN: Token-Auto-Refresh nur wenn App aktiv ist.
// Plus Foreground-Reconnect via onAuthStateChange (TOKEN_REFRESHED →
// realtime.setAuth wird intern getriggert). Fixt den Realtime-Disconnect-Bug
// bei lange eingeloggten Usern (siehe `project_session_2026-05-15_push.md`).
useEffect(() => {
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') {
supabase.auth.startAutoRefresh();
} else {
supabase.auth.stopAutoRefresh();
}
});
return () => sub.remove();
}, []);
useEffect(() => {
if (fontsLoaded && !loading && appLockReady) {
SplashScreen.hideAsync();
}
}, [fontsLoaded, loading, appLockReady]);
if (!fontsLoaded || loading || !appLockReady) {
// Nativer expo-splash-screen bleibt sichtbar bis SplashScreen.hideAsync()
// im Effect oben aufgerufen wird → kein Flicker durch zusätzlichen
// React-Splash mehr (User-Feedback: "geht sehr schnell vorbei und zuckt")
return null;
}
return (
<OnlinePresenceProvider>
<AppLockGate>
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
<DeviceLimitReachedSheet />
<DeviceApprovalIncomingSheet />
<DeviceApprovalPendingSheet
onApproved={() => {
// Slot wurde freigegeben — register retry
useDevicesStore.getState().ensureRegistered().catch(() => {});
}}
/>
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
contentStyle: { backgroundColor: colors.bg },
}}
>
<Stack.Screen name="index" />
<Stack.Screen name="(auth)" />
<Stack.Screen name="(app)" />
<Stack.Screen
name="lyra"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="urge"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_bottom',
}}
/>
<Stack.Screen
name="call"
options={{
headerShown: false,
presentation: 'fullScreenModal',
animation: 'slide_from_bottom',
gestureEnabled: false, // kein versehentliches Swipe-Dismiss im Call
}}
/>
<Stack.Screen
name="dm"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="settings"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="profile/index"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="profile/[userId]"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="games"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="debug"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="help"
options={{
headerShown: false,
presentation: 'card',
animation: 'slide_from_right',
}}
/>
<Stack.Screen
name="onboarding/index"
options={{
headerShown: false,
presentation: 'card',
animation: 'fade',
gestureEnabled: false,
}}
/>
</Stack>
</AppLockGate>
</OnlinePresenceProvider>
);
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<QueryClientProvider client={queryClient}>
<ActionSheetProvider>
<SafeAreaProvider>
<RootLayoutInner />
</SafeAreaProvider>
</ActionSheetProvider>
</QueryClientProvider>
</KeyboardProvider>
</GestureHandlerRootView>
);
}