feat(calls): CallKit/ConnectionService + VoIP-PushKit + EU-Ringback

Caller/Callee UX:
- lib/ringback.ts + assets/sounds/ringback_eu.mp3 (EU 425Hz Festnetz-Tone)
- stores/call.ts: stopRingback bei connected, hangup-reasons, logCallToChat fix
- locales: 'Wird angerufen…' statt 'Ruft an…'

CallKit (iOS) + ConnectionService (Android):
- lib/callkit.ts: setupCallKeep, displayIncomingCall, startOutgoingCall, reportConnected/Ended (appName 'ReBreak-Audio', includesCallsInRecents=false für DSGVO/DiGA)
- hooks/useCallKeepEvents.ts: native answer/end/mute → useCallStore-Actions
- stores/call.ts: CallKit-Aufrufe an allen lifecycle-Punkten
- app.config.ts: @config-plugins/react-native-callkeep + UIBackgroundModes voip/audio + Android-Telecom-Perms

VoIP-PushKit Backend:
- services/voip-push.ts: @parse/node-apn Provider mit .p12 (Topic org.rebreak.app.voip)
- services/push.ts sendCallRingPush: feuert beide Pfade (VoIP iOS + Expo Android/Fallback)
- prisma: push_tokens.voip_token Column + Migration 20260604
- api/users/me/push-token: optional voipToken im Body
- Env (Infisical): APNS_VOIP_P12_PATH/PASSWORD/TOPIC/PRODUCTION

Push-tap routing + cold-start handling:
- app/_layout.tsx: type:'call' Push → useCallStore.receiveIncoming + /call

Docs: ops/CALLKIT_SETUP.md (Apple-Portal-Steps für VoIP-Cert)
This commit is contained in:
chahinebrini 2026-06-04 09:27:13 +02:00
parent 0cac3c9d1a
commit 822053e11e
37 changed files with 2141 additions and 47 deletions

View File

@ -1,6 +1,25 @@
# Changelog # Changelog
All notable changes to rebreak-native will be documented in this file. All notable changes to rebreak-native will be documented in this file.
## v0.3.13 (Build 71 / versionCode 54) — 2026-06-04\n\n### Fixes
- Voice calls: in-call speaker button now actually routes audio to the phone speaker (instead of being a no-op). The route is re-applied after WebRTC has set up its audio session, so it survives the call-connect transition
- DM screen: keyboard really stays open now when tapping the send button — the previous fix only handled the keyboard-Enter case. Send button stays mounted while the message is being uploaded so the touch target doesn't disappear underneath your finger
- DM screen: tuned the gap above the input bar so the last message sits at a comfortable middle distance (not too tight, not floating too high)
- DM info sheet: the partner avatar now renders correctly for users with a default/list avatar (not just custom photo uploads), using the same avatar component as the header. The chevron now sits inline right next to the name
- DM info sheet: tapping a shared image now opens the same full-screen viewer as in the chat (rounded corners + save button) instead of doing nothing behind the sheet
### Features
- Voice calls now show up in the chat: after every call the conversation bubbles up to the top of the chat list with "📞 Audio call" as the last-message preview, and an inline call-note row appears in the chat thread itself (Instagram-style — with duration if the call connected, or "Missed call" / "Call declined" / "No answer" if it didn't)
- Voice calls: new speaker button in the call screen between Mute and Hangup (volume-medium icon → volume-high when active). Works on both iOS (AVAudioSession route override) and Android (AudioManager speakerphone)
- Mobile dev tooling: new `./dev.sh mobile` command auto-detects connected iPhone (USB) + Android (ADB) and builds + launches dev clients on both in parallel, sharing a single Metro bundler
- DM image viewer is now a swipeable gallery — tapping any photo (in the chat or the info sheet) opens a carousel of all images shared in that conversation, starting at the one you tapped. Swipe left/right to browse, with a position counter (e.g. 2 / 6); the save button always targets the current image
### Changes
- DM chat background is now always the clean solid style (white in light mode, black in dark) — removed the per-chat background picker again for simplicity
- DM voice notes restyled to Instagram-style waveforms: incoming notes have black bars on a light grey bubble, your own notes have white bars on a mint-green bubble. While playing, the upcoming part dims to grey and fills back in as it progresses\n
## v0.3.13 (Build 70 / versionCode 53) — 2026-06-03\n\n### Fixes ## v0.3.13 (Build 70 / versionCode 53) — 2026-06-03\n\n### Fixes
- DM screen: tuned the gap above the input bar so the last message sits at a comfortable middle distance (not too tight, not floating too high) - DM screen: tuned the gap above the input bar so the last message sits at a comfortable middle distance (not too tight, not floating too high)

View File

@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
ios: { ios: {
supportsTablet: true, supportsTablet: true,
bundleIdentifier: MAIN_BUNDLE, bundleIdentifier: MAIN_BUNDLE,
buildNumber: "70", buildNumber: "73",
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen // Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den // signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements. // com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
@ -54,12 +54,15 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
"Rebreak speichert Bilder in deine Foto-Mediathek.", "Rebreak speichert Bilder in deine Foto-Mediathek.",
NSFaceIDUsageDescription: NSFaceIDUsageDescription:
"Rebreak nutzt Face ID, um die App zu entsperren — damit niemand außer dir sie öffnen kann.", "Rebreak nutzt Face ID, um die App zu entsperren — damit niemand außer dir sie öffnen kann.",
// CallKit + PushKit: wacht App bei eingehendem VoIP-Push auf, hält
// Audio-Session im Background. Apple verlangt 'voip' für PushKit.
UIBackgroundModes: ["audio", "voip"],
}, },
}, },
android: { android: {
package: "org.rebreak.app", package: "org.rebreak.app",
versionCode: 53, versionCode: 56,
adaptiveIcon: { adaptiveIcon: {
// Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den // Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den
// Außenring) → adaptive-foreground.png ist das Logo auf transparentem // Außenring) → adaptive-foreground.png ist das Logo auf transparentem
@ -76,6 +79,14 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
"POST_NOTIFICATIONS", "POST_NOTIFICATIONS",
"BIND_ACCESSIBILITY_SERVICE", "BIND_ACCESSIBILITY_SERVICE",
"RECORD_AUDIO", "RECORD_AUDIO",
// CallKeep / ConnectionService
"MANAGE_OWN_CALLS",
"READ_PHONE_STATE",
"READ_PHONE_NUMBERS",
"BIND_TELECOM_CONNECTION_SERVICE",
"FOREGROUND_SERVICE_MICROPHONE",
"FOREGROUND_SERVICE_PHONE_CALL",
"USE_FULL_SCREEN_INTENT",
], ],
}, },
@ -84,6 +95,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
"expo-localization", "expo-localization",
"expo-font", "expo-font",
"expo-web-browser", "expo-web-browser",
// WebRTC (Voice-Calls) — Config-Plugin setzt Mic/Camera-Permissions + Podfile
"@config-plugins/react-native-webrtc",
// CallKit (iOS) + ConnectionService (Android) — native Call-UI mit Wake-aus-killed-State
"@config-plugins/react-native-callkeep",
[ [
"expo-media-library", "expo-media-library",
{ {
@ -110,6 +125,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
"./plugins/with-fmt-consteval-fix", "./plugins/with-fmt-consteval-fix",
// Xcode 14+ resource-bundle-signing fix (needed because useFrameworks: static) // Xcode 14+ resource-bundle-signing fix (needed because useFrameworks: static)
"./plugins/with-resource-bundle-signing-fix", "./plugins/with-resource-bundle-signing-fix",
// SDK 54 prebuilt RN + react-native-webrtc: non-modular React-Header erlauben
"./plugins/with-allow-nonmodular-includes",
// Phase 5: NEFilter Extension + Family Controls Entitlements (iOS) // Phase 5: NEFilter Extension + Family Controls Entitlements (iOS)
"./plugins/with-rebreak-protection-ios", "./plugins/with-rebreak-protection-ios",
// Phase 5: VpnService + AccessibilityService (Android) // Phase 5: VpnService + AccessibilityService (Android)

View File

@ -79,7 +79,8 @@ function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }
> >
{conv.isOwn ? `${t('chat.you')} ` : ''} {conv.isOwn ? `${t('chat.you')} ` : ''}
{conv.lastMessage || {conv.lastMessage ||
(conv.lastAttachmentType === 'audio' ? t('chat.voice_message') : (conv.lastAttachmentType === 'call' ? `📞 ${t('chat.call_audio')}` :
conv.lastAttachmentType === 'audio' ? t('chat.voice_message') :
conv.lastAttachmentType === 'image' ? t('chat.photo') : conv.lastAttachmentType === 'image' ? t('chat.photo') :
t('chat.media_sent'))} t('chat.media_sent'))}
</Text> </Text>

View File

@ -35,6 +35,9 @@ import { useDeviceApprovalRealtime } from '../hooks/useDeviceApprovalRealtime';
import { useDevicesStore } from '../stores/devices'; import { useDevicesStore } from '../stores/devices';
import { OnlinePresenceProvider } from '../components/OnlinePresenceProvider'; import { OnlinePresenceProvider } from '../components/OnlinePresenceProvider';
import { usePushTokenRegistration } from '../hooks/usePushTokenRegistration'; 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 '../lib/i18n'; // i18next-Init via Side-Effect
import '../global.css'; import '../global.css';
@ -78,26 +81,53 @@ function RootLayoutInner() {
// Push-Token-Registration nach Login (idempotent) // Push-Token-Registration nach Login (idempotent)
usePushTokenRegistration(user?.id); 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 // Apple-Style Device-Approval Realtime — lauscht auf neue Approval-Requests
// für diesen User und zeigt das Incoming-Sheet wenn ein anderes Gerät // für diesen User und zeigt das Incoming-Sheet wenn ein anderes Gerät
// sich anmelden möchte. // sich anmelden möchte.
useDeviceApprovalRealtime(!!user?.id); useDeviceApprovalRealtime(!!user?.id);
// Push-Tap-Deep-Link: User tippt Notification → navigate zu Chat // Push-Tap-Deep-Link: User tippt Notification → navigate zu Chat / Call
useEffect(() => { useEffect(() => {
const sub = Notifications.addNotificationResponseReceivedListener( const handle = (response: Notifications.NotificationResponse | null | undefined) => {
(response) => { if (!response) return;
const data = response.notification.request.content.data as const data = response.notification.request.content.data as
| { type?: 'dm' | 'room'; targetId?: string } | {
| undefined; type?: 'dm' | 'room' | 'call';
if (!data?.type || !data.targetId) return; targetId?: string;
if (data.type === 'dm') { callId?: string;
router.push({ pathname: '/dm', params: { userId: data.targetId } }); from?: { id: string; nickname: string; avatar: string | null };
} else if (data.type === 'room') { }
router.push({ pathname: '/room', params: { roomId: data.targetId } }); | 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 — Realtime hat (vermutlich) keine Subscription
// gehabt weil App im Background war. Wir simulieren receiveIncoming
// damit der Standard-Accept/Decline-Flow greift. Falls der Caller in
// der Zwischenzeit aufgelegt hat: ring-cancel kommt sobald Channel
// subscribed, dann teardown.
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(); return () => sub.remove();
}, []); }, []);
@ -176,6 +206,15 @@ function RootLayoutInner() {
animation: 'slide_from_bottom', 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 <Stack.Screen
name="dm" name="dm"
options={{ options={{

View File

@ -0,0 +1,178 @@
import { useEffect, useState } from 'react';
import { View, Text, TouchableOpacity, Alert, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { useCallStore } from '../stores/call';
import { UserAvatar } from '../components/UserAvatar';
function fmtDuration(ms: number) {
const s = Math.floor(ms / 1000);
const m = Math.floor(s / 60);
return `${m}:${String(s % 60).padStart(2, '0')}`;
}
export default function CallScreen() {
const { t } = useTranslation();
const router = useRouter();
const status = useCallStore((s) => s.status);
const peer = useCallStore((s) => s.peer);
const muted = useCallStore((s) => s.muted);
const speaker = useCallStore((s) => s.speaker);
const startedAt = useCallStore((s) => s.startedAt);
const endReason = useCallStore((s) => s.endReason);
const acceptCall = useCallStore((s) => s.acceptCall);
const declineCall = useCallStore((s) => s.declineCall);
const hangup = useCallStore((s) => s.hangup);
const toggleMute = useCallStore((s) => s.toggleMute);
const toggleSpeaker = useCallStore((s) => s.toggleSpeaker);
const clear = useCallStore((s) => s._clear);
const [elapsed, setElapsed] = useState(0);
// Kein aktiver Call → Screen schließen.
useEffect(() => {
if (status === 'idle') router.back();
}, [status, router]);
// Call beendet → kurz "beendet" zeigen, dann schließen + aufräumen.
useEffect(() => {
if (status !== 'ended') return;
const tm = setTimeout(() => {
clear();
router.back();
}, 1300);
return () => clearTimeout(tm);
}, [status, clear, router]);
// Gesprächsdauer-Timer.
useEffect(() => {
if (status !== 'connected' || !startedAt) return;
const id = setInterval(() => setElapsed(Date.now() - startedAt), 1000);
return () => clearInterval(id);
}, [status, startedAt]);
async function onAccept() {
try {
await acceptCall();
} catch (e: any) {
if (e?.message === 'webrtc_unavailable') {
Alert.alert(t('call.title'), t('call.needs_rebuild'));
}
hangup('failed');
}
}
const subtitle =
status === 'outgoing'
? t('call.calling')
: status === 'incoming'
? t('call.incoming')
: status === 'connecting'
? t('call.connecting')
: status === 'connected'
? fmtDuration(elapsed)
: status === 'ended'
? endReason === 'declined'
? t('call.declined')
: endReason === 'unanswered'
? t('call.no_answer')
: endReason === 'failed'
? t('call.failed')
: t('call.ended')
: '';
return (
<View style={{ flex: 1 }}>
<LinearGradient colors={['#0f172a', '#1e3a34', '#0f172a']} style={StyleSheet.absoluteFill} />
<SafeAreaView style={{ flex: 1, justifyContent: 'space-between', alignItems: 'center' }}>
{/* Oben: Avatar + Name + Status */}
<View style={{ alignItems: 'center', marginTop: 72, gap: 18 }}>
<UserAvatar
userId={peer?.id ?? null}
avatar={peer?.avatar ?? null}
nickname={peer?.nickname ?? '?'}
size="xl"
/>
<Text style={{ color: '#fff', fontSize: 26, fontFamily: 'Nunito_700Bold' }} numberOfLines={1}>
{peer?.nickname ?? '…'}
</Text>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 16, fontFamily: 'Nunito_600SemiBold', fontVariant: ['tabular-nums'] }}>
{subtitle}
</Text>
</View>
{/* Unten: Aktions-Buttons */}
<View style={{ marginBottom: 56, width: '100%', alignItems: 'center' }}>
{status === 'incoming' ? (
<View style={{ flexDirection: 'row', justifyContent: 'space-evenly', width: '100%' }}>
<CircleBtn color="#ef4444" icon="call" rotate onPress={declineCall} label={t('call.decline')} />
<CircleBtn color="#22c55e" icon="call" onPress={onAccept} label={t('call.accept')} />
</View>
) : status === 'ended' ? null : (
<View style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 28 }}>
{(status === 'connected' || status === 'connecting') && (
<CircleBtn
color={muted ? '#fff' : 'rgba(255,255,255,0.18)'}
iconColor={muted ? '#0f172a' : '#fff'}
icon={muted ? 'mic-off' : 'mic'}
onPress={toggleMute}
label={muted ? t('call.unmute') : t('call.mute')}
/>
)}
{(status === 'connected' || status === 'connecting' || status === 'outgoing') && (
<CircleBtn
color={speaker ? '#fff' : 'rgba(255,255,255,0.18)'}
iconColor={speaker ? '#0f172a' : '#fff'}
icon={speaker ? 'volume-high' : 'volume-medium'}
onPress={toggleSpeaker}
label={speaker ? t('call.speaker_on') : t('call.speaker_off')}
/>
)}
<CircleBtn color="#ef4444" icon="call" rotate onPress={() => hangup('ended')} label={t('call.hang_up')} />
</View>
)}
</View>
</SafeAreaView>
</View>
);
}
function CircleBtn({
color,
iconColor = '#fff',
icon,
rotate,
onPress,
label,
}: {
color: string;
iconColor?: string;
icon: keyof typeof Ionicons.glyphMap;
rotate?: boolean;
onPress: () => void;
label: string;
}) {
return (
<View style={{ alignItems: 'center', gap: 8 }}>
<TouchableOpacity
activeOpacity={0.8}
onPress={onPress}
style={{
width: 68,
height: 68,
borderRadius: 34,
backgroundColor: color,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Ionicons name={icon} size={28} color={iconColor} style={rotate ? { transform: [{ rotate: '135deg' }] } : undefined} />
</TouchableOpacity>
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: 12, fontFamily: 'Nunito_600SemiBold' }}>{label}</Text>
</View>
);
}

View File

@ -40,6 +40,8 @@ import { useDmRealtime } from '../hooks/useChatRealtime';
import { useDmTyping } from '../hooks/useDmTyping'; import { useDmTyping } from '../hooks/useDmTyping';
import { useColors } from '../lib/theme'; import { useColors } from '../lib/theme';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { useCallStore, isWebRTCAvailable } from '../stores/call';
import { useMe } from '../hooks/useMe';
import { supabase } from '../lib/supabase'; import { supabase } from '../lib/supabase';
import { UserAvatar } from '../components/UserAvatar'; import { UserAvatar } from '../components/UserAvatar';
import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus'; import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus';
@ -95,6 +97,7 @@ export default function DmScreen() {
const styles = makeStyles(colors); const styles = makeStyles(colors);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const myUserId = useAuthStore((s) => s.user?.id); const myUserId = useAuthStore((s) => s.user?.id);
const { me } = useMe();
const { userId } = useLocalSearchParams<{ userId: string }>(); const { userId } = useLocalSearchParams<{ userId: string }>();
@ -331,10 +334,20 @@ export default function DmScreen() {
const canCall = canCallData?.canCall ?? false; const canCall = canCallData?.canCall ?? false;
function startCall() { function startCall() {
// TODO(phase1): echte Call-Engine (WebRTC + coturn + Signaling). Bis der if (!userId || !partner) return;
// TURN-Server steht + ein Dev-Build mit react-native-webrtc existiert, hier // Native WebRTC fehlt im aktuellen Build → ehrlicher Hinweis statt Crash.
// nur ein ehrlicher Hinweis statt eines toten Buttons. if (!isWebRTCAvailable()) {
Alert.alert(t('chat.call'), t('chat.call_coming_soon')); Alert.alert(t('chat.call'), t('call.needs_rebuild'));
return;
}
useCallStore
.getState()
.startCall(
{ id: userId, nickname: partner.nickname ?? '?', avatar: partner.avatar ?? null },
{ id: me?.id ?? myUserId ?? '', nickname: me?.nickname ?? 'Du', avatar: me?.avatar ?? null },
)
.catch((e: any) => console.log('[CALL] startCall error:', e?.message ?? e));
router.push('/call' as any);
} }
async function pickImage() { async function pickImage() {
@ -421,9 +434,11 @@ export default function DmScreen() {
setReplyTo(null); setReplyTo(null);
setSending(true); setSending(true);
sendStopTyping(); sendStopTyping();
// Fokus halten: das Leeren des Inputs tauscht Send→Mic-Button und kann den // Fokus 1× re-assertieren reicht — die mehrfach-focus-Aufrufe waren cargo-cult.
// Fokus verlieren. Re-assert nach dem Re-Render → Tastatur bleibt offen. // Wichtiger: Send-Button bleibt mounted solange `sending` true ist (siehe
requestAnimationFrame(() => inputRef.current?.focus()); // Render-Bedingung unten), dadurch fällt das Touch-Target nicht weg und
// die Tastatur bleibt stehen.
inputRef.current?.focus();
try { try {
let attachmentMeta: { url: string; type: string; name: string } | null = null; let attachmentMeta: { url: string; type: string; name: string } | null = null;
@ -940,20 +955,20 @@ export default function DmScreen() {
// Insta/WA-Style: nach dem Senden bleibt die Tastatur offen // Insta/WA-Style: nach dem Senden bleibt die Tastatur offen
// (Fokus bleibt am Input), bis der User woanders hin tippt. // (Fokus bleibt am Input), bis der User woanders hin tippt.
blurOnSubmit={false} blurOnSubmit={false}
editable={!sending && !uploading} // editable NICHT auf !sending setzen — das wäre der Grund warum
// die Tastatur nach Send dismisst (non-editable TextInput → iOS
// forciert Blur → Keyboard weg). User darf während des Sendens
// schon die nächste Nachricht tippen (wie WhatsApp/Insta).
editable={!uploading}
/> />
{(inputText.trim().length > 0 || attachment) ? ( {(inputText.trim().length > 0 || attachment) ? (
<TouchableOpacity <TouchableOpacity
style={[styles.sendBtn, (sending || uploading) && styles.sendBtnDisabled]} style={styles.sendBtn}
onPress={handleSend} onPress={handleSend}
disabled={sending || uploading} disabled={uploading}
activeOpacity={0.7} activeOpacity={0.7}
> >
{sending || uploading ? ( <Ionicons name="send" size={16} color="#fff" />
<ActivityIndicator size="small" color="#fff" />
) : (
<Ionicons name="send" size={16} color="#fff" />
)}
</TouchableOpacity> </TouchableOpacity>
) : ( ) : (
<TouchableOpacity <TouchableOpacity

Binary file not shown.

View File

@ -161,7 +161,6 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
} }
export type MessageReaction = { emoji: string; count: number; mine: boolean }; export type MessageReaction = { emoji: string; count: number; mine: boolean };
export type ChatMsg = { export type ChatMsg = {
id: string; id: string;
userId: string; userId: string;
@ -231,7 +230,51 @@ function useBubbleColors() {
}; };
} }
export function ChatBubble({ function CallNoteRow({ msg }: { msg: ChatMsg }) {
const { t } = useTranslation();
const colors = useColors();
const [, stateRaw, durRaw] = (msg.attachmentName ?? 'audio:ended:0').split(':');
const state = (stateRaw || 'ended') as 'ended' | 'unanswered' | 'declined' | 'failed' | 'busy';
const durSec = parseInt(durRaw || '0', 10) || 0;
const isMissed = state !== 'ended';
const iconColor = isMissed ? '#ef4444' : (msg.isOwn ? '#10b981' : colors.text);
let label: string;
if (!isMissed) {
const m = Math.floor(durSec / 60);
const s = (durSec % 60).toString().padStart(2, '0');
label = `${t('chat.call_audio')} · ${m}:${s}`;
} else if (state === 'declined') {
label = msg.isOwn ? t('chat.call_declined') : t('chat.call_missed');
} else if (state === 'unanswered') {
label = msg.isOwn ? t('chat.call_no_answer') : t('chat.call_missed');
} else {
label = t('chat.call_failed');
}
const time = new Date(msg.createdAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
return (
<View style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 16 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: colors.surfaceElevated, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 16 }}>
<View style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: 'rgba(0,0,0,0.06)', alignItems: 'center', justifyContent: 'center' }}>
<Ionicons name="call-outline" size={15} color={iconColor} />
</View>
<View>
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>{label}</Text>
<Text style={{ fontSize: 11, color: colors.textMuted, marginTop: 1 }}>{time}</Text>
</View>
</View>
</View>
);
}
export function ChatBubble(props: Props) {
// Call-Notiz (System-Row, kein Bubble) — eigenes Render-Path, ohne Hooks-Aufwand.
if (props.msg.attachmentType === 'call') {
return <CallNoteRow msg={props.msg} />;
}
return <ChatBubbleInner {...props} />;
}
function ChatBubbleInner({
msg, msg,
showName = false, showName = false,
isFirstInGroup = true, isFirstInGroup = true,

View File

@ -254,8 +254,8 @@ export function MagicSheet({
)} )}
</View> </View>
{/* Verbundene Macs */} {/* Verbundene Ger\u00e4te */}
<SectionTitle text="Verbundene Macs" colors={colors} /> <SectionTitle text="Verbundene Ger\u00e4te" colors={colors} />
<View style={cardStyle(colors)}> <View style={cardStyle(colors)}>
{devices === null ? ( {devices === null ? (
<ActivityIndicator /> <ActivityIndicator />

View File

@ -7,6 +7,8 @@
# ./dev.sh default: ios --device (physisches iPhone USB + Build) # ./dev.sh default: ios --device (physisches iPhone USB + Build)
# ./dev.sh ios iOS Dev (Default: USB-Device mit Build) # ./dev.sh ios iOS Dev (Default: USB-Device mit Build)
# ./dev.sh android Android Dev (Gradle Build + Install + Launch) # ./dev.sh android Android Dev (Gradle Build + Install + Launch)
# ./dev.sh mobile Auto-detect angeschl. iPhone + Android via USB,
# baut+launcht auf BEIDEN parallel mit shared Metro
# ./dev.sh metro Nur Metro starten # ./dev.sh metro Nur Metro starten
# ./dev.sh clean iOS: Nuclear clean (Pods, DerivedData, Archives) # ./dev.sh clean iOS: Nuclear clean (Pods, DerivedData, Archives)
# ./dev.sh install ios Build Release + Install auf iPhone USB # ./dev.sh install ios Build Release + Install auf iPhone USB
@ -27,6 +29,11 @@
# --no-launch Build+Install, aber kein Auto-Launch # --no-launch Build+Install, aber kein Auto-Launch
# --wifi Metro mit --host lan (nur in Kombi mit --no-build) # --wifi Metro mit --host lan (nur in Kombi mit --no-build)
# #
# FLAGS (mobile):
# --no-build Beide nur Metro/Install (kein Native-Rebuild)
# --ios-only Nur iOS bauen (falls Android-Device da aber ignorieren)
# --android-only Nur Android bauen
#
# FLAGS (metro): # FLAGS (metro):
# --keep Cache behalten (kein --clear) # --keep Cache behalten (kein --clear)
# #
@ -257,6 +264,182 @@ cmd_android() {
ok "Android Dev Build abgeschlossen" ok "Android Dev Build abgeschlossen"
} }
# ---------------------------------------------------------------------------
# Device-Detection Helpers
# ---------------------------------------------------------------------------
detect_ios_device() {
# Gibt UDID des ersten ONLINE iPhone/iPad zurück, leer wenn keins
command -v xcrun >/dev/null 2>&1 || { echo ""; return 0; }
{ xcrun xctrace list devices 2>/dev/null \
| awk '/^== Devices ==/{f=1; next} /^== /{f=0} f' \
| grep -E "iPhone|iPad" \
| grep -v Simulator \
| head -1 \
| sed -nE 's/.*\(([0-9A-Fa-f-]{25,})\).*/\1/p'; } || true
}
detect_ios_device_name() {
command -v xcrun >/dev/null 2>&1 || { echo ""; return 0; }
{ xcrun xctrace list devices 2>/dev/null \
| awk '/^== Devices ==/{f=1; next} /^== /{f=0} f' \
| grep -E "iPhone|iPad" \
| grep -v Simulator \
| head -1 \
| sed -E 's/ *\(.*//'; } || true
}
detect_android_devices() {
# Gibt alle ADB-Device-IDs (eine pro Zeile)
command -v adb >/dev/null 2>&1 || return 0
{ adb devices 2>/dev/null | awk '/\tdevice$/ {print $1}'; } || true
}
start_shared_metro() {
local LOG="/tmp/rebreak-metro.log"
log "Killing alte Metro-Instanzen auf 8081..."
lsof -ti:8081 2>/dev/null | xargs kill -9 2>/dev/null || true
pkill -f "expo start" 2>/dev/null || true
sleep 1
log "Starte Metro im Hintergrund → $LOG"
(cd "$SCRIPT_DIR" && nohup pnpm expo start --dev-client --clear > "$LOG" 2>&1 &)
# Warte auf Metro-Bereitschaft
local tries=0
while [[ $tries -lt 30 ]]; do
if curl -s http://localhost:8081/status 2>/dev/null | grep -q packager-status:running; then
ok "Metro bereit (http://localhost:8081)"
return 0
fi
sleep 1
tries=$((tries+1))
done
warn "Metro-Statuscheck nicht bestätigt nach 30s — trotzdem weiter (Log: $LOG)"
}
cmd_mobile() {
local BUILD=true
local IOS_ONLY=false
local ANDROID_ONLY=false
while [[ $# -gt 0 ]]; do
case "$1" in
--no-build) BUILD=false; shift ;;
--ios-only) IOS_ONLY=true; shift ;;
--android-only) ANDROID_ONLY=true; shift ;;
*) die "Unbekannter Flag für 'mobile': $1" ;;
esac
done
section "Mobile Dev (Auto-Detect iOS + Android)"
# ── Detect ────────────────────────────────────────────────
local IOS_UDID=""
local IOS_NAME=""
local ANDROID_IDS=()
# set +e block: detection-Funktionen dürfen leer zurückkommen ohne Script-Abort
set +e
if ! $ANDROID_ONLY; then
IOS_UDID="$(detect_ios_device)"
IOS_NAME="$(detect_ios_device_name)"
fi
if ! $IOS_ONLY; then
while IFS= read -r line; do
[[ -n "$line" ]] && ANDROID_IDS+=("$line")
done < <(detect_android_devices)
fi
set -e
echo ""
if [[ -n "$IOS_UDID" ]]; then
ok "iOS: ${IOS_NAME:-iPhone} (${IOS_UDID})"
else
warn "Kein iOS-Device via USB gefunden"
fi
if [[ ${#ANDROID_IDS[@]} -gt 0 ]]; then
for id in "${ANDROID_IDS[@]}"; do
local model
model=$(adb -s "$id" shell getprop ro.product.model 2>/dev/null | tr -d '\r')
ok "Android: ${model:-unknown} ($id)"
done
else
warn "Kein Android-Device via ADB gefunden"
fi
echo ""
if [[ -z "$IOS_UDID" && ${#ANDROID_IDS[@]} -eq 0 ]]; then
die "Keine Devices erkannt — USB-Kabel checken, Trust-Dialog auf iPhone, USB-Debugging auf Android"
fi
# ── Shared Metro ──────────────────────────────────────────
start_shared_metro
local IOS_LOG="/tmp/rebreak-ios-build.log"
local ANDROID_LOG="/tmp/rebreak-android-build.log"
local IOS_PID=""
local ANDROID_PID=""
# ── iOS Build (parallel, im Hintergrund) ──────────────────
if [[ -n "$IOS_UDID" ]]; then
if $BUILD; then
log "iOS-Build startet → $IOS_LOG"
(
cd "$SCRIPT_DIR"
pnpm expo run:ios --device "$IOS_UDID" --no-bundler
) > "$IOS_LOG" 2>&1 &
IOS_PID=$!
else
log "iOS — skip Build (--no-build), App muss installiert sein"
fi
fi
# ── Android Build (parallel) ──────────────────────────────
if [[ ${#ANDROID_IDS[@]} -gt 0 ]]; then
if $BUILD; then
log "Android-Build startet → $ANDROID_LOG"
(
cd "$ANDROID_DIR"
./gradlew assembleDebug --console=plain
local APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
[[ -f "$APK" ]] || { echo "APK nicht gefunden"; exit 1; }
for id in "${ANDROID_IDS[@]}"; do
echo "→ install on $id"
adb -s "$id" install -r -d "$APK"
adb -s "$id" shell monkey -p org.rebreak.app -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
done
) > "$ANDROID_LOG" 2>&1 &
ANDROID_PID=$!
else
log "Android — skip Build (--no-build), APK muss installiert sein"
fi
fi
# ── Live-Logs follow ──────────────────────────────────────
echo ""
log "Builds laufen parallel. Live-Logs:"
[[ -n "$IOS_PID" ]] && echo " iOS : tail -f $IOS_LOG (pid $IOS_PID)"
[[ -n "$ANDROID_PID" ]] && echo " Android : tail -f $ANDROID_LOG (pid $ANDROID_PID)"
echo " Metro : tail -f /tmp/rebreak-metro.log"
echo ""
local IOS_RC=0
local ANDROID_RC=0
if [[ -n "$IOS_PID" ]]; then
log "Warte auf iOS-Build..."
wait "$IOS_PID" && IOS_RC=$? || IOS_RC=$?
if [[ $IOS_RC -eq 0 ]]; then ok "iOS-Build fertig"; else error "iOS-Build FAIL (rc=$IOS_RC) — $IOS_LOG"; fi
fi
if [[ -n "$ANDROID_PID" ]]; then
log "Warte auf Android-Build..."
wait "$ANDROID_PID" && ANDROID_RC=$? || ANDROID_RC=$?
if [[ $ANDROID_RC -eq 0 ]]; then ok "Android-Build fertig"; else error "Android-Build FAIL (rc=$ANDROID_RC) — $ANDROID_LOG"; fi
fi
echo ""
ok "Mobile-Dev bereit. Metro läuft im Hintergrund (kill via: lsof -ti:8081 | xargs kill)."
echo "Metro-Log live: tail -f /tmp/rebreak-metro.log"
}
cmd_metro() { cmd_metro() {
local CLEAR_FLAG="--clear" local CLEAR_FLAG="--clear"
@ -518,6 +701,10 @@ case "$COMMAND" in
cmd_android "$@" cmd_android "$@"
;; ;;
mobile)
cmd_mobile "$@"
;;
metro) metro)
cmd_metro "$@" cmd_metro "$@"
;; ;;
@ -555,6 +742,7 @@ case "$COMMAND" in
echo "Verfügbare Commands:" echo "Verfügbare Commands:"
echo " ios iOS Dev (Metro + Xcode/Simulator/Device)" echo " ios iOS Dev (Metro + Xcode/Simulator/Device)"
echo " android Android Dev (Metro + Gradle + Install)" echo " android Android Dev (Metro + Gradle + Install)"
echo " mobile Auto-detect iPhone+Android via USB, parallel-Build"
echo " metro Nur Metro starten" echo " metro Nur Metro starten"
echo " clean iOS Nuclear Clean" echo " clean iOS Nuclear Clean"
echo " install ios Release-Build auf iPhone installieren" echo " install ios Release-Build auf iPhone installieren"

View File

@ -0,0 +1,62 @@
/**
* Bridge zwischen CallKit/ConnectionService-Events und unserer Call-Store.
*
* Wenn der User in der nativen Call-UI Accept/Reject/Hangup tippt, kommt das
* NICHT über unser React-UI rein sondern via RNCallKeep-Events. Wir
* übersetzen die in store-Actions.
*
* Wird einmal app-weit im _layout.tsx aufgerufen.
*/
import { useEffect } from 'react';
import { useRouter } from 'expo-router';
import RNCallKeep from 'react-native-callkeep';
import { useCallStore } from '../stores/call';
import { setupCallKeep } from '../lib/callkit';
export function useCallKeepEvents() {
const router = useRouter();
useEffect(() => {
void setupCallKeep();
// User tippt "Annehmen" in der CallKit-/ConnectionService-UI
const onAnswer = ({ callUUID }: { callUUID: string }) => {
console.log('[callkeep] answer', callUUID);
const st = useCallStore.getState();
if (st.status !== 'incoming') return;
// Call-Screen öffnen und Accept-Flow triggern.
router.push('/call');
void st.acceptCall();
};
// User tippt "Ablehnen" oder "Auflegen" in der nativen UI
const onEnd = ({ callUUID }: { callUUID: string }) => {
console.log('[callkeep] end', callUUID);
const st = useCallStore.getState();
if (st.status === 'idle' || st.status === 'ended') return;
if (st.status === 'incoming') {
st.declineCall();
} else {
st.hangup('ended');
}
};
// User mutet/unmutet über die native UI
const onMuted = ({ muted }: { muted: boolean; callUUID: string }) => {
const st = useCallStore.getState();
if (st.muted !== muted) st.toggleMute();
};
RNCallKeep.addEventListener('answerCall', onAnswer);
RNCallKeep.addEventListener('endCall', onEnd);
RNCallKeep.addEventListener('didPerformSetMutedCallAction', onMuted);
// didActivateAudioSession kommt nach CallKit-Audio-Activation — wir nutzen
// das (noch) nicht aktiv, weil WebRTC + InCallManager das selber regeln.
return () => {
RNCallKeep.removeEventListener('answerCall');
RNCallKeep.removeEventListener('endCall');
RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
};
}, [router]);
}

View File

@ -0,0 +1,47 @@
import { useEffect } from 'react';
import { useRouter } from 'expo-router';
import { supabase } from '../lib/supabase';
import { useCallStore, type CallPeer } from '../stores/call';
/**
* Lauscht (app-weit, solange eingeloggt) auf den persönlichen Ring-Channel
* `call-ring:<myUserId>` und zeigt bei einer eingehenden Einladung den
* Call-Screen. Phase 1 = foreground-only (klingelt nur bei offener App;
* Wake-when-closed via VoIP-Push ist Phase 2).
*/
export function useIncomingCalls(myUserId: string | undefined) {
const router = useRouter();
useEffect(() => {
if (!myUserId) return;
console.log('[CALL/recv] subscribing call-ring channel for', myUserId);
const chan = supabase.channel(`call-ring:${myUserId}`);
chan.on('broadcast', { event: 'ring' }, (msg: any) => {
console.log('[CALL/recv] RING received', msg?.payload);
const callId = msg?.payload?.callId as string | undefined;
const from = msg?.payload?.from as CallPeer | undefined;
if (!callId || !from) return;
// Schon in einem Call → ignorieren (MVP: kein call-waiting).
if (useCallStore.getState().status !== 'idle') return;
useCallStore.getState().receiveIncoming(callId, from);
router.push('/call');
});
chan.on('broadcast', { event: 'cancel' }, (msg: any) => {
const callId = msg?.payload?.callId as string | undefined;
const st = useCallStore.getState();
if (st.callId === callId && st.status === 'incoming') {
// Caller hat aufgelegt bevor wir annehmen konnten → verpasster Anruf.
st.hangup('unanswered');
}
});
chan.subscribe((status: string, err?: any) => {
console.log('[CALL/recv] call-ring subscribe status:', status, err ?? '');
});
return () => {
console.log('[CALL/recv] unsubscribing call-ring for', myUserId);
supabase.removeChannel(chan);
};
}, [myUserId, router]);
}

View File

@ -41,7 +41,7 @@ export async function registerPushTokenWithBackend(): Promise<string | null> {
return null; return null;
} }
// 2) Android-Channel (muss vor getExpoPushTokenAsync existieren) // 2) Android-Channels (muss vor getExpoPushTokenAsync existieren)
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('chat', { await Notifications.setNotificationChannelAsync('chat', {
name: 'Chat-Nachrichten', name: 'Chat-Nachrichten',
@ -50,6 +50,16 @@ export async function registerPushTokenWithBackend(): Promise<string | null> {
lightColor: '#007AFF', lightColor: '#007AFF',
sound: 'default', sound: 'default',
}); });
// Dedizierter Channel für eingehende Anrufe — MAX importance, längere
// Vibration, bypasst DND nicht (das bräuchte Critical-Alert-Permission).
await Notifications.setNotificationChannelAsync('calls', {
name: 'Anrufe',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 500, 200, 500, 200, 500],
lightColor: '#007AFF',
sound: 'default',
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
});
} }
// 3) Token holen // 3) Token holen

View File

@ -0,0 +1,134 @@
/**
* CallKit (iOS) / ConnectionService (Android) Wrapper.
*
* Zentralisiert react-native-callkeep so dass stores/call.ts platform-agnostic
* bleibt. Drei Verantwortungen:
* 1. setup() einmal beim App-Start (Permission-Prompt auf Android)
* 2. displayIncomingCall() wenn Push/Realtime ein Ring signalisiert
* 3. startCall() wenn der User selbst einen Anruf initiiert
*
* Privacy für DiGA (sensible Userbasis, Art. 9 DSGVO):
* - includesCallsInRecents: false KEIN iCloud-Sync der Anrufliste
* - handle = userId (Email-Type) keine Telefonnummern-Style-Anzeige
* - appName "ReBreak-Audio" erscheint im Lockscreen-Banner
*/
import { Platform, PermissionsAndroid } from 'react-native';
import RNCallKeep, { CONSTANTS as CK_CONSTANTS } from 'react-native-callkeep';
let didSetup = false;
export async function setupCallKeep(): Promise<void> {
if (didSetup) return;
try {
await RNCallKeep.setup({
ios: {
appName: 'ReBreak-Audio',
// KEIN imageName → Default-Avatar von CallKit; wir setzen den echten
// Caller-Namen via displayIncomingCall(localizedCallerName)
supportsVideo: false,
maximumCallGroups: '1',
maximumCallsPerCallGroup: '1',
// Privacy: KEINE Anrufliste in iOS-Recents (= kein iCloud-Sync, kein
// Leak an Apple). Für DiGA mit Suchterkrankungs-Zielgruppe Pflicht.
includesCallsInRecents: false,
},
android: {
alertTitle: 'Anrufe in ReBreak zulassen',
alertDescription:
'ReBreak braucht Telefon-Konto-Berechtigung, um eingehende Sprach-Anrufe wie in Telefon-Apps anzuzeigen.',
cancelButton: 'Nicht jetzt',
okButton: 'Erlauben',
// Foreground-Service für Android 11+ Mic-Background-Access
foregroundService: {
channelId: 'org.rebreak.app.calls',
channelName: 'ReBreak-Anrufe',
notificationTitle: 'ReBreak: Anruf aktiv',
},
},
});
if (Platform.OS === 'android') {
RNCallKeep.setAvailable(true);
// Android 14+: Full-Screen-Intent für Lockscreen-Call-UI braucht extra Permission
try {
await PermissionsAndroid.request(
// @ts-ignore — RN-Types haben USE_FULL_SCREEN_INTENT evtl. noch nicht
'android.permission.USE_FULL_SCREEN_INTENT' as any,
);
} catch {}
}
didSetup = true;
} catch (e: any) {
console.warn('[callkeep] setup failed', e?.message ?? e);
}
}
/**
* UUID-Generator CallKit braucht lowercase UUID (Apple-Quirk).
* Wir mappen 1:1 callId callUUID (deterministic) damit beide Apps dieselbe ID nutzen.
*/
export function callIdToUuid(callId: string): string {
// CallId hat Format: "<timestamp>-<random8>", z.B. "1717491600000-abc12345"
// Wir bauen daraus eine deterministische UUID v4-Form via simple Hex-Padding.
const clean = callId.replace(/[^a-z0-9]/gi, '').toLowerCase().padEnd(32, '0').slice(0, 32);
return `${clean.slice(0, 8)}-${clean.slice(8, 12)}-4${clean.slice(13, 16)}-8${clean.slice(17, 20)}-${clean.slice(20, 32)}`;
}
export function displayIncomingCall(callId: string, callerName: string): void {
try {
const uuid = callIdToUuid(callId);
RNCallKeep.displayIncomingCall(
uuid,
callerName, // handle — wird im iOS-Lockscreen als Untertitel angezeigt
callerName, // localizedCallerName — als Titel
'generic', // handleType: kein Phone-Number-Format
false, // hasVideo
);
} catch (e: any) {
console.warn('[callkeep] displayIncomingCall failed', e?.message ?? e);
}
}
export function startOutgoingCall(callId: string, calleeName: string): void {
try {
const uuid = callIdToUuid(callId);
RNCallKeep.startCall(uuid, calleeName, calleeName, 'generic', false);
} catch (e: any) {
console.warn('[callkeep] startCall failed', e?.message ?? e);
}
}
export function reportConnected(callId: string): void {
try {
const uuid = callIdToUuid(callId);
if (Platform.OS === 'android') {
RNCallKeep.setCurrentCallActive(uuid);
}
} catch {}
}
export function endCall(callId: string): void {
try {
const uuid = callIdToUuid(callId);
RNCallKeep.endCall(uuid);
} catch {}
}
export function reportEnded(
callId: string,
reason: 'failed' | 'declined' | 'unanswered' | 'ended' = 'ended',
): void {
try {
const uuid = callIdToUuid(callId);
const reasonCode =
reason === 'failed'
? CK_CONSTANTS.END_CALL_REASONS.FAILED
: reason === 'unanswered'
? CK_CONSTANTS.END_CALL_REASONS.UNANSWERED
: reason === 'declined'
? CK_CONSTANTS.END_CALL_REASONS.MISSED
: CK_CONSTANTS.END_CALL_REASONS.REMOTE_ENDED;
RNCallKeep.reportEndCallWithUUID(uuid, reasonCode);
} catch {}
}
export { RNCallKeep };

View File

@ -0,0 +1,53 @@
/**
* Ringback-Sound für ausgehende Voice-Calls.
*
* Warum nicht InCallManager.startRingback?
* - Auf iOS nutzt InCallManager System-Ringback, der je nach Locale/User-
* Setting unterschiedlich klingt (oft fällt es auf den User-Ringtone
* zurück verwirrend, weil das wie ein eingehender Call klingt).
* - Wir wollen einen konsistenten "tüüüt-tüüüt"-Ton (EU-Festnetz-Standard
* 425 Hz, 1s an / 4s aus, ITU-T E.180) auf BEIDEN Plattformen.
*
* Asset: `assets/sounds/ringback_eu.mp3` selbst generiert mit ffmpeg,
* CC0 (Public Domain, kein Lizenz-Risiko).
*/
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
let sound: Audio.Sound | null = null;
export async function startRingback(): Promise<void> {
try {
// Audio-Mode aktiv setzen damit der Ton im Earpiece spielt
// (NICHT laut über Speaker — wie bei echten Anrufen).
await Audio.setAudioModeAsync({
playsInSilentModeIOS: true,
allowsRecordingIOS: false,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
shouldDuckAndroid: true,
playThroughEarpieceAndroid: true,
staysActiveInBackground: false,
});
if (sound) {
try { await sound.unloadAsync(); } catch {}
sound = null;
}
const { sound: s } = await Audio.Sound.createAsync(
require('../assets/sounds/ringback_eu.mp3'),
{ shouldPlay: true, isLooping: true, volume: 1.0 },
);
sound = s;
} catch (e: any) {
// Best-effort — falls Sound-System hängt soll der Call trotzdem weiterlaufen.
console.warn('[ringback] start failed', e?.message ?? e);
}
}
export async function stopRingback(): Promise<void> {
if (!sound) return;
const s = sound;
sound = null;
try { await s.stopAsync(); } catch {}
try { await s.unloadAsync(); } catch {}
}

View File

@ -21,6 +21,15 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
detectSessionInUrl: false, detectSessionInUrl: false,
}, },
realtime: { realtime: {
// WICHTIG: vsn '1.0.0' (reines JSON-Text-Protokoll) statt default '2.0.0'
// (Phoenix-V2-Binary). Unser self-hosted Realtime-Container v2.28.32 hat
// den V2-Binary-Decoder nicht — bekommt er einen Binary-Frame, crasht der
// Channel-Process mit FunctionClauseError und reißt ALLE anderen Channels
// auf demselben Socket mit (1011 / "socket closed 1011"). Spam von
// notifRealtime/approvalRealtime + Call-Ring-Drops sind genau das.
// Sobald der Realtime-Container auf >=v2.34 upgraded ist, kann dieser
// Override entfernt werden. Repo-Memory: supabase-realtime-binary-crash.md
vsn: '1.0.0',
params: { params: {
apikey: supabaseAnonKey, apikey: supabaseAnonKey,
}, },

View File

@ -999,6 +999,11 @@
"you": "أنت: ", "you": "أنت: ",
"just_now": "الآن", "just_now": "الآن",
"voice_message": "رسالة صوتية", "voice_message": "رسالة صوتية",
"call_audio": "مكالمة صوتية",
"call_missed": "مكالمة فائتة",
"call_no_answer": "لا يوجد رد",
"call_declined": "تم رفض المكالمة",
"call_failed": "فشلت المكالمة",
"photo": "صورة", "photo": "صورة",
"media_sent": "وسائط", "media_sent": "وسائط",
"new_conversation": "محادثة جديدة", "new_conversation": "محادثة جديدة",
@ -1398,6 +1403,24 @@
"crisis_emergency_cta": "112 — الطوارئ", "crisis_emergency_cta": "112 — الطوارئ",
"crisis_disclaimer": "هذه الجهات مستقلة عن rebreak. نحيلك إليها ولكننا لا نُقدّم الإرشاد بأنفسنا." "crisis_disclaimer": "هذه الجهات مستقلة عن rebreak. نحيلك إليها ولكننا لا نُقدّم الإرشاد بأنفسنا."
}, },
"call": {
"title": "مكالمة",
"calling": "جارٍ الاتصال…",
"incoming": "مكالمة واردة",
"connecting": "جارٍ الاتصال…",
"declined": "مرفوضة",
"no_answer": "لا إجابة",
"failed": "فشلت المكالمة",
"ended": "انتهت المكالمة",
"decline": "رفض",
"accept": "قبول",
"mute": "كتم",
"unmute": "إلغاء الكتم",
"speaker_on": "مكبر الصوت",
"speaker_off": "سماعة الأذن",
"hang_up": "إنهاء",
"needs_rebuild": "المكالمات تحتاج تحديث التطبيق — متاحة بعد البناء التالي."
},
"presence": { "presence": {
"online": "متصل", "online": "متصل",
"typing": "يكتب", "typing": "يكتب",

View File

@ -1070,6 +1070,11 @@
"you": "Du: ", "you": "Du: ",
"just_now": "gerade", "just_now": "gerade",
"voice_message": "Sprachnachricht", "voice_message": "Sprachnachricht",
"call_audio": "Audioanruf",
"call_missed": "Verpasster Anruf",
"call_no_answer": "Keine Antwort",
"call_declined": "Anruf abgelehnt",
"call_failed": "Anruf fehlgeschlagen",
"photo": "Foto", "photo": "Foto",
"media_sent": "Medien", "media_sent": "Medien",
"new_conversation": "Neue Unterhaltung", "new_conversation": "Neue Unterhaltung",
@ -1470,6 +1475,24 @@
"crisis_emergency_cta": "112 — Notruf", "crisis_emergency_cta": "112 — Notruf",
"crisis_disclaimer": "Diese Stellen sind unabhängig von Rebreak. Wir verweisen weiter, beraten aber nicht selbst." "crisis_disclaimer": "Diese Stellen sind unabhängig von Rebreak. Wir verweisen weiter, beraten aber nicht selbst."
}, },
"call": {
"title": "Anruf",
"calling": "Wird angerufen…",
"incoming": "Eingehender Anruf",
"connecting": "Verbinde…",
"declined": "Abgelehnt",
"no_answer": "Keine Antwort",
"failed": "Anruf fehlgeschlagen",
"ended": "Anruf beendet",
"decline": "Ablehnen",
"accept": "Annehmen",
"mute": "Stumm",
"unmute": "Laut",
"speaker_on": "Lautsprecher",
"speaker_off": "Hörer",
"hang_up": "Auflegen",
"needs_rebuild": "Anrufe brauchen ein App-Update — verfügbar nach dem nächsten Build."
},
"presence": { "presence": {
"online": "Online", "online": "Online",
"typing": "schreibt", "typing": "schreibt",

View File

@ -1068,6 +1068,11 @@
"you": "You: ", "you": "You: ",
"just_now": "just now", "just_now": "just now",
"voice_message": "Voice message", "voice_message": "Voice message",
"call_audio": "Audio call",
"call_missed": "Missed call",
"call_no_answer": "No answer",
"call_declined": "Call declined",
"call_failed": "Call failed",
"photo": "Photo", "photo": "Photo",
"media_sent": "Media", "media_sent": "Media",
"new_conversation": "New conversation", "new_conversation": "New conversation",
@ -1468,6 +1473,24 @@
"crisis_emergency_cta": "112 — Emergency", "crisis_emergency_cta": "112 — Emergency",
"crisis_disclaimer": "These services are independent of Rebreak. We refer you onward but do not offer counselling ourselves." "crisis_disclaimer": "These services are independent of Rebreak. We refer you onward but do not offer counselling ourselves."
}, },
"call": {
"title": "Call",
"calling": "Calling…",
"incoming": "Incoming call",
"connecting": "Connecting…",
"declined": "Declined",
"no_answer": "No answer",
"failed": "Call failed",
"ended": "Call ended",
"decline": "Decline",
"accept": "Accept",
"mute": "Mute",
"unmute": "Unmute",
"speaker_on": "Speaker",
"speaker_off": "Earpiece",
"hang_up": "End",
"needs_rebuild": "Calls need an app update — available after the next build."
},
"presence": { "presence": {
"online": "Online", "online": "Online",
"typing": "typing", "typing": "typing",

View File

@ -988,6 +988,11 @@
"you": "Vous : ", "you": "Vous : ",
"just_now": "à l'instant", "just_now": "à l'instant",
"voice_message": "Message vocal", "voice_message": "Message vocal",
"call_audio": "Appel audio",
"call_missed": "Appel manqué",
"call_no_answer": "Pas de réponse",
"call_declined": "Appel refusé",
"call_failed": "Échec de l'appel",
"photo": "Photo", "photo": "Photo",
"media_sent": "Média", "media_sent": "Média",
"new_conversation": "Nouvelle conversation", "new_conversation": "Nouvelle conversation",
@ -1384,6 +1389,24 @@
"crisis_emergency_cta": "112 — Urgences", "crisis_emergency_cta": "112 — Urgences",
"crisis_disclaimer": "Ces services sont indépendants de Rebreak. Nous vous orientons mais n'assurons pas de conseil nous-mêmes." "crisis_disclaimer": "Ces services sont indépendants de Rebreak. Nous vous orientons mais n'assurons pas de conseil nous-mêmes."
}, },
"call": {
"title": "Appel",
"calling": "Appel sortant…",
"incoming": "Appel entrant",
"connecting": "Connexion…",
"declined": "Refusé",
"no_answer": "Pas de réponse",
"failed": "Échec de l'appel",
"ended": "Appel terminé",
"decline": "Refuser",
"accept": "Accepter",
"mute": "Muet",
"unmute": "Son",
"speaker_on": "Haut-parleur",
"speaker_off": "Écouteur",
"hang_up": "Raccrocher",
"needs_rebuild": "Les appels nécessitent une mise à jour — disponibles après la prochaine build."
},
"presence": { "presence": {
"online": "En ligne", "online": "En ligne",
"typing": "écrit", "typing": "écrit",

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.3.13</string> <string>0.3.13</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>70</string> <string>73</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.3.13</string> <string>0.3.13</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>70</string> <string>73</string>
<key>NSExtension</key> <key>NSExtension</key>
<dict> <dict>
<key>NSExtensionPointIdentifier</key> <key>NSExtensionPointIdentifier</key>

View File

@ -19,7 +19,7 @@
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>0.3.13</string> <string>0.3.13</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>70</string> <string>73</string>
<key>EXAppExtensionAttributes</key> <key>EXAppExtensionAttributes</key>
<dict> <dict>
<key>EXExtensionPointIdentifier</key> <key>EXExtensionPointIdentifier</key>

View File

@ -12,6 +12,8 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@config-plugins/react-native-callkeep": "^12.0.0",
"@config-plugins/react-native-webrtc": "^15.0.1",
"@expo-google-fonts/nunito": "^0.2.3", "@expo-google-fonts/nunito": "^0.2.3",
"@expo/metro-runtime": "~6.1.2", "@expo/metro-runtime": "~6.1.2",
"@expo/react-native-action-sheet": "^4.1.1", "@expo/react-native-action-sheet": "^4.1.1",
@ -61,7 +63,9 @@
"react-i18next": "^15.1.0", "react-i18next": "^15.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
"react-native-bottom-tabs": "^1.2.0", "react-native-bottom-tabs": "^1.2.0",
"react-native-callkeep": "^4.3.16",
"react-native-gesture-handler": "~2.28.0", "react-native-gesture-handler": "~2.28.0",
"react-native-incall-manager": "^4.2.1",
"react-native-keyboard-controller": "^1.21.7", "react-native-keyboard-controller": "^1.21.7",
"react-native-mmkv": "^3.1.0", "react-native-mmkv": "^3.1.0",
"react-native-reanimated": "~4.1.7", "react-native-reanimated": "~4.1.7",
@ -70,6 +74,8 @@
"react-native-sse": "^1.2.1", "react-native-sse": "^1.2.1",
"react-native-svg": "15.12.1", "react-native-svg": "15.12.1",
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-voip-push-notification": "^3.3.3",
"react-native-webrtc": "^124.0.7",
"react-native-worklets": "~0.5.1", "react-native-worklets": "~0.5.1",
"rive-react-native": "^9.0.1", "rive-react-native": "^9.0.1",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",

View File

@ -0,0 +1,71 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/**
* Fix für SDK-54 + prebuilt React Native + react-native-webrtc:
* "include of non-modular header inside framework module"
* (als Fehler, weil Xcode `-Werror=non-modular-include-in-framework-module`
* auf Framework-Module setzt).
*
* Ab RN 0.81 / Expo SDK 54 wird React Native als prebuilt XCFramework
* (React.framework, modular) ausgeliefert. Native Module wie react-native-webrtc
* importieren React-Core-Header non-modular (`#import "React/..."`) das ist bei
* modularen Frameworks ein -Werror Build bricht ab.
*
* Lösung (dokumentiert): auf den Pod-Targets non-modular Includes erlauben
* (CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES=YES) + das -Werror
* neutralisieren (-Wno-error=non-modular-include-in-framework-module).
*
* Injiziert in den bestehenden `post_install do |installer|`-Block (ein zweiter
* würde den ersten ersetzen). Idempotent via Marker. Läuft als
* withDangerousMod('ios') während `expo prebuild`, vor `pod install`.
*/
const { withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const MARKER = '# REBREAK_ALLOW_NONMODULAR_INCLUDES';
const FIX = `
${MARKER}
# SDK 54 prebuilt React Native: react-native-webrtc & Co. importieren
# React-Header non-modular -> -Werror bricht den Build ab. Auf allen
# Pod-Targets erlauben + -Werror entschärfen.
installer.pods_project.targets.each do |t|
t.build_configurations.each do |config|
config.build_settings['CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES'] = 'YES'
cflags = config.build_settings['OTHER_CFLAGS'] || ['$(inherited)']
cflags = [cflags] unless cflags.is_a?(Array)
unless cflags.include?('-Wno-error=non-modular-include-in-framework-module')
cflags << '-Wno-error=non-modular-include-in-framework-module'
end
config.build_settings['OTHER_CFLAGS'] = cflags
end
end
Pod::UI.puts " -> Allowed non-modular includes on #{installer.pods_project.targets.count} pod targets".green
${MARKER}
`;
module.exports = function withAllowNonModularIncludes(config) {
return withDangerousMod(config, [
'ios',
async (cfg) => {
const podfilePath = path.join(cfg.modRequest.platformProjectRoot, 'Podfile');
if (!fs.existsSync(podfilePath)) {
console.warn('[with-allow-nonmodular-includes] Podfile not found at', podfilePath);
return cfg;
}
let podfile = fs.readFileSync(podfilePath, 'utf-8');
if (podfile.includes(MARKER)) {
return cfg; // schon gepatcht
}
const anchorRe = /(post_install do \|installer\|[^\n]*\n)/;
if (!anchorRe.test(podfile)) {
console.warn('[with-allow-nonmodular-includes] no `post_install do |installer|` block — skipping');
return cfg;
}
podfile = podfile.replace(anchorRe, `$1${FIX}`);
fs.writeFileSync(podfilePath, podfile);
console.log('[with-allow-nonmodular-includes] patched Podfile post_install');
return cfg;
},
]);
};

View File

@ -0,0 +1,487 @@
import { create } from 'zustand';
import { NativeModules } from 'react-native';
import type { RealtimeChannel } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
import { apiFetch } from '../lib/api';
import { startRingback, stopRingback } from '../lib/ringback';
import * as callkit from '../lib/callkit';
// ─── Voice-Call-Engine (1:1, Audio-only, foreground-only / Phase 1) ──────────
//
// Signaling läuft über Supabase Realtime Broadcast — KEIN eigener Signaling-
// Server nötig:
// • Ring-Channel `call-ring:<userId>` → nur die initiale Einladung/Abbruch.
// • Call-Channel `call:<callId>` → ready/offer/answer/ice/decline/hangup.
//
// Handshake-Reihenfolge (verhindert "offer kommt bevor Callee subscribed"-Race):
// Caller: ring → join call-channel → WARTET auf `ready` → erst dann offer.
// Callee: accept → join call-channel → `ready` → wartet auf offer → answer.
//
// react-native-webrtc ist ein natives Modul → LAZY require + Guard. In einem
// Build ohne das Modul (z.B. der aktuelle Dev-Build) ist isWebRTCAvailable()
// false und der Call-Button meldet "Rebuild nötig" statt zu crashen.
export function isWebRTCAvailable(): boolean {
return !!(NativeModules as any).WebRTCModule;
}
export type CallStatus =
| 'idle'
| 'outgoing' // wir rufen an, warten auf Annahme
| 'incoming' // es klingelt bei uns
| 'connecting' // angenommen, ICE/DTLS baut auf
| 'connected' // Audio läuft
| 'ended';
export type CallPeer = { id: string; nickname: string; avatar: string | null };
export type CallEndReason = 'declined' | 'ended' | 'failed' | 'unanswered' | 'busy' | null;
const UNANSWERED_MS = 35_000;
// Nicht-reaktive Handles (gehören nicht in den Zustand-State).
let pc: any = null;
let localStream: any = null;
let callChan: RealtimeChannel | null = null;
let pendingRemoteIce: any[] = [];
let unansweredTimer: ReturnType<typeof setTimeout> | null = null;
let selfMe: CallPeer | null = null; // für Caller-Side DM-Logging
let currentRole: 'caller' | 'callee' | null = null;
let loggedCallId: string | null = null; // Idempotenz-Guard für DM-Log
type CallState = {
status: CallStatus;
peer: CallPeer | null;
callId: string | null;
muted: boolean;
speaker: boolean;
startedAt: number | null;
endReason: CallEndReason;
startCall: (peer: CallPeer, me: CallPeer) => Promise<void>;
receiveIncoming: (callId: string, from: CallPeer) => void;
acceptCall: () => Promise<void>;
declineCall: () => void;
hangup: (reason?: CallEndReason) => void;
toggleMute: () => void;
toggleSpeaker: () => void;
_clear: () => void;
};
function rtc() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('react-native-webrtc');
}
// Audio-Routing (Earpiece vs. Lautsprecher) über AVAudioSession (iOS) /
// AudioManager (Android). react-native-webrtc exposed das nicht direkt.
function inCall() {
// eslint-disable-next-line @typescript-eslint/no-var-requires
return require('react-native-incall-manager').default;
}
// Diagnose-Logging (taucht in Metro-Konsole + adb logcat ReactNativeJS auf).
function clog(...args: any[]) {
console.log('[CALL]', ...args);
}
// Fire-and-forget: einem noch klingelnden Callee (noch nicht im Call-Channel)
// den Abbruch auf seinem Ring-Channel signalisieren.
function fireRingCancel(peerId: string, callId: string) {
const chan = supabase.channel(`call-ring:${peerId}`);
chan.subscribe((s: string) => {
if (s === 'SUBSCRIBED') {
chan.send({ type: 'broadcast', event: 'cancel', payload: { callId } });
setTimeout(() => supabase.removeChannel(chan), 300);
}
});
}
function teardown() {
if (unansweredTimer) { clearTimeout(unansweredTimer); unansweredTimer = null; }
try { pc?.close?.(); } catch {}
try { localStream?.getTracks?.().forEach((t: any) => t.stop()); } catch {}
if (callChan) { supabase.removeChannel(callChan); callChan = null; }
// Audio-Session beenden → normales Audio-Routing wiederherstellen.
// stopRingtone/stopRingback sind no-ops wenn nichts läuft — safe.
try { inCall().stopRingtone(); } catch {}
void stopRingback();
try { inCall().stop(); } catch {}
pc = null;
localStream = null;
pendingRemoteIce = [];
}
// DM-Log nach Call-Ende. Nur der CALLER schreibt (verhindert Duplikate).
// Format: attachmentType='call', attachmentName='<kind>:<state>:<durSec>'.
async function logCallToChat(
peerId: string,
callId: string,
state: 'ended' | 'unanswered' | 'declined' | 'failed' | 'busy',
startedAt: number | null,
) {
// Caller loggt jeden Call. Callee loggt NUR missed/declined/unanswered
// wenn der Caller dazu nicht kommt (z.B. Callee drückt 'decline' bevor der
// Caller-DM-Call durchgeht). Verbundene Calls werden vom Caller geloggt.
if (currentRole === 'callee' && state === 'ended') return;
if (loggedCallId === callId) return;
loggedCallId = callId;
const durSec = startedAt ? Math.max(0, Math.round((Date.now() - startedAt) / 1000)) : 0;
const meta = `audio:${state}:${durSec}`;
try {
await apiFetch('/api/chat/dm', {
method: 'POST',
body: {
receiverId: peerId,
content: '',
attachmentType: 'call',
attachmentName: meta,
},
});
clog('logged call DM →', peerId, meta);
} catch (e: any) {
clog('logCallToChat FAILED', e?.message ?? e);
}
}
export const useCallStore = create<CallState>((set, get) => {
// ─── WebRTC-Setup gemeinsam für beide Seiten ──────────────────────────────
async function buildPeer() {
const { RTCPeerConnection } = rtc();
clog('buildPeer: fetching ice-servers…');
let ice: { iceServers: any[]; iceTransportPolicy?: string };
try {
ice = await apiFetch('/api/calls/ice-servers');
} catch (e: any) {
clog('buildPeer: ICE-FETCH FAILED', e?.message ?? e, e?.statusCode ?? '');
throw e;
}
clog('buildPeer: ice ok', JSON.stringify({
servers: ice.iceServers?.length,
policy: ice.iceTransportPolicy,
urls: ice.iceServers?.[0]?.urls,
}));
pc = new RTCPeerConnection({
iceServers: ice.iceServers,
iceTransportPolicy: (ice.iceTransportPolicy as any) ?? 'relay',
});
pc.addEventListener('icecandidate', (e: any) => {
if (e.candidate && callChan) {
callChan.send({
type: 'broadcast',
event: 'ice',
payload: {
candidate: e.candidate.candidate,
sdpMid: e.candidate.sdpMid,
sdpMLineIndex: e.candidate.sdpMLineIndex,
},
});
}
});
pc.addEventListener('icecandidateerror', (e: any) => {
clog('ICE-CANDIDATE-ERROR', e?.errorCode, e?.errorText, e?.url);
});
pc.addEventListener('iceconnectionstatechange', () => {
clog('iceConnectionState =', pc?.iceConnectionState);
});
pc.addEventListener('icegatheringstatechange', () => {
clog('iceGatheringState =', pc?.iceGatheringState);
});
pc.addEventListener('connectionstatechange', () => {
const st = pc?.connectionState;
clog('connectionState =', st);
if (st === 'connected') {
if (get().status !== 'connected') {
set({ status: 'connected', startedAt: Date.now() });
}
// Ringback aus — Verbindung steht.
void stopRingback();
// CallKit/ConnectionService: Call als aktiv markieren (Android needs this
// für Mute/Speaker-Controls).
try {
const cid = get().callId;
if (cid) callkit.reportConnected(cid);
} catch {}
// WebRTC hat seine eigene AVAudioSession aktiviert und unser früherer
// InCallManager-Call ist evtl. überschrieben worden — Speaker-Route
// jetzt erneut setzen, damit der User-Toggle wirklich greift.
try {
inCall().setForceSpeakerphoneOn(get().speaker);
clog('post-connect: speaker route applied =', get().speaker);
} catch (e: any) {
clog('post-connect setForceSpeakerphoneOn failed:', e?.message ?? e);
}
} else if (st === 'failed') {
get().hangup('failed');
}
});
// Mikrofon holen + Track anhängen.
clog('buildPeer: getUserMedia(audio)…');
const { mediaDevices } = rtc();
try {
localStream = await mediaDevices.getUserMedia({ audio: true, video: false });
} catch (e: any) {
clog('buildPeer: getUserMedia FAILED', e?.message ?? e);
throw e;
}
localStream.getTracks().forEach((t: any) => pc.addTrack(t, localStream));
// Audio-Session in den "In-Call"-Mode bringen (richtet Routing, Bluetooth,
// Audio-Focus etc. ein). Default = Earpiece, NICHT Speaker.
try {
inCall().start({ media: 'audio', auto: false });
inCall().setForceSpeakerphoneOn(get().speaker);
} catch (e: any) {
clog('InCallManager start failed:', e?.message ?? e);
}
clog('buildPeer: done (track added)');
}
function addRemoteIce(payload: any) {
const { RTCIceCandidate } = rtc();
const cand = new RTCIceCandidate(payload);
if (pc?.remoteDescription) {
pc.addIceCandidate(cand).catch(() => {});
} else {
pendingRemoteIce.push(payload);
}
}
function drainIce() {
const { RTCIceCandidate } = rtc();
pendingRemoteIce.forEach((p) => pc?.addIceCandidate(new RTCIceCandidate(p)).catch(() => {}));
pendingRemoteIce = [];
}
// Per-Call-Channel beitreten + Events verdrahten. role steuert offer/answer.
function joinCallChannel(callId: string, role: 'caller' | 'callee') {
const chan = supabase.channel(`call:${callId}`, {
config: { broadcast: { self: false } },
});
chan.on('broadcast', { event: 'ready' }, async () => {
clog('recv ready (role=' + role + ', pc=' + !!pc + ')');
if (role !== 'caller' || !pc) return;
const offer = await pc.createOffer({});
await pc.setLocalDescription(offer);
clog('caller: sending offer');
chan.send({ type: 'broadcast', event: 'offer', payload: { type: offer.type, sdp: offer.sdp } });
});
chan.on('broadcast', { event: 'offer' }, async (msg: any) => {
clog('recv offer (role=' + role + ', pc=' + !!pc + ')');
if (role !== 'callee' || !pc) return;
const { RTCSessionDescription } = rtc();
await pc.setRemoteDescription(new RTCSessionDescription(msg.payload));
drainIce();
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
clog('callee: sending answer');
chan.send({ type: 'broadcast', event: 'answer', payload: { type: answer.type, sdp: answer.sdp } });
});
chan.on('broadcast', { event: 'answer' }, async (msg: any) => {
clog('recv answer (role=' + role + ')');
if (role !== 'caller' || !pc) return;
const { RTCSessionDescription } = rtc();
await pc.setRemoteDescription(new RTCSessionDescription(msg.payload));
drainIce();
});
chan.on('broadcast', { event: 'ice' }, (msg: any) => addRemoteIce(msg.payload));
chan.on('broadcast', { event: 'decline' }, () => { clog('recv decline'); get().hangup('declined'); });
chan.on('broadcast', { event: 'hangup' }, () => { clog('recv hangup'); get().hangup('ended'); });
callChan = chan;
return chan;
}
return {
status: 'idle',
peer: null,
callId: null,
muted: false,
speaker: false,
startedAt: null,
endReason: null,
// ─── Caller ──────────────────────────────────────────────────────────
startCall: async (peer, me) => {
if (get().status !== 'idle') return;
if (!isWebRTCAvailable()) throw new Error('webrtc_unavailable');
const callId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
clog('startCall → callee', peer.id, 'callId', callId);
selfMe = me;
currentRole = 'caller';
loggedCallId = null;
set({ status: 'outgoing', peer, callId, muted: false, speaker: false, startedAt: null, endReason: null });
// 1) Callee anklingeln (ephemerer Ring-Channel des Callees).
const ring = supabase.channel(`call-ring:${peer.id}`);
await new Promise<void>((res) => ring.subscribe((s: string) => s === 'SUBSCRIBED' && res()));
clog('ring channel subscribed → sending ring');
ring.send({
type: 'broadcast',
event: 'ring',
payload: { callId, from: { id: me.id, nickname: me.nickname, avatar: me.avatar } },
});
setTimeout(() => supabase.removeChannel(ring), 300);
// Background-Push an den Callee (deckt den Fall ab, dass die App nicht
// im Foreground ist — Realtime ist dann nicht subscribed). Fire-and-forget:
// ein fehlgeschlagener Push darf den Call nicht blocken.
apiFetch('/api/calls/ring', {
method: 'POST',
body: { peerId: peer.id, callId },
}).catch((e: any) => clog('ring-push failed', e?.message ?? e));
// CallKit: User-Seite in Recents/UI als ausgehender Call sichtbar machen.
try { callkit.startOutgoingCall(callId, peer.nickname || 'ReBreak'); } catch {}
// Ringback-Ton für den Anrufer — eigenes mp3-Asset (EU-Standard 425 Hz,
// 1s an / 4s aus). InCallManager.start() ohne `ringback`-Param läuft
// parallel für die AVAudioSession-Aktivierung, der eigentliche Ton kommt
// von expo-av.
try {
inCall().start({ media: 'audio', auto: false });
} catch (e: any) { clog('inCall start (caller) failed', e?.message ?? e); }
void startRingback();
// 2) Call-Channel join (Handler setzen) → PeerConnection bauen → ERST DANN
// subscriben. Sonst könnte ein schnelles `ready` den offer-Handler treffen,
// bevor pc existiert → kein offer → Deadlock.
const chan = joinCallChannel(callId, 'caller');
try {
await buildPeer();
} catch (e: any) {
clog('startCall: buildPeer FAILED →', e?.message ?? e);
get().hangup('failed');
throw e;
}
chan.subscribe((s: string) => clog('caller call-channel:', s));
// 3) Timeout: keine Annahme → auflegen (hangup signalisiert Ring-Cancel).
unansweredTimer = setTimeout(() => {
if (get().status === 'outgoing') get().hangup('unanswered');
}, UNANSWERED_MS);
},
// ─── Callee: Klingeln empfangen ───────────────────────────────────────
receiveIncoming: (callId, from) => {
// Schon im Gespräch? → ignorieren (MVP: kein call-waiting).
if (get().status !== 'idle') return;
clog('receiveIncoming from', from.id, 'callId', callId);
currentRole = 'callee';
loggedCallId = null;
set({ status: 'incoming', peer: from, callId, muted: false, speaker: false, startedAt: null, endReason: null });
// CallKit-/ConnectionService-UI hochziehen — das zeigt nativen Call-Screen
// über Lockscreen, sogar wenn die App im Background ist.
try { callkit.displayIncomingCall(callId, from.nickname || 'ReBreak'); } catch {}
// Klingelton für den Empfänger. InCallManager.start() muss VOR
// startRingtone laufen damit iOS die AVAudioSession korrekt aktiviert.
try { inCall().start({ media: 'audio', auto: false }); } catch (e: any) { clog('inCall start (callee) failed', e?.message ?? e); }
try { inCall().startRingtone('_DEFAULT_', undefined, undefined, 30); } catch (e: any) { clog('startRingtone failed', e?.message ?? e); }
},
acceptCall: async () => {
const { callId, status } = get();
if (status !== 'incoming' || !callId) return;
if (!isWebRTCAvailable()) throw new Error('webrtc_unavailable');
clog('acceptCall callId', callId);
// Klingelton stoppen — wir nehmen ab.
try { inCall().stopRingtone(); } catch {}
set({ status: 'connecting' });
const chan = joinCallChannel(callId, 'callee');
await new Promise<void>((res) => chan.subscribe((s: string) => s === 'SUBSCRIBED' && res()));
clog('callee call-channel subscribed');
try {
await buildPeer();
} catch (e: any) {
clog('acceptCall: buildPeer FAILED →', e?.message ?? e);
get().hangup('failed');
throw e;
}
clog('callee: sending ready');
chan.send({ type: 'broadcast', event: 'ready', payload: {} });
},
declineCall: () => {
const { callId, peer, startedAt } = get();
if (callId) {
// CallKit/ConnectionService aus dem Lockscreen-UI entfernen.
try { callkit.endCall(callId); } catch {}
// Kurz joinen um decline zu senden (Callee war noch nicht im Channel).
const chan = supabase.channel(`call:${callId}`);
chan.subscribe((s: string) => {
if (s === 'SUBSCRIBED') {
chan.send({ type: 'broadcast', event: 'decline', payload: {} });
setTimeout(() => supabase.removeChannel(chan), 300);
}
});
}
// Callee logged abgelehnten Call in eigenen Chat-Verlauf.
if (peer && callId) {
logCallToChat(peer.id, callId, 'declined', startedAt);
}
set({ status: 'ended', endReason: 'declined' });
teardown();
},
hangup: (reason = 'ended') => {
const { status, peer, callId, startedAt } = get();
if (status === 'idle' || status === 'ended') {
teardown();
return;
}
// CallKit/ConnectionService schließen.
if (callId) {
try {
callkit.endCall(callId);
callkit.reportEnded(callId, reason as any);
} catch {}
}
// Peer im Call-Channel informieren (falls schon verbunden).
callChan?.send({ type: 'broadcast', event: 'hangup', payload: {} });
// Klingelt der Callee noch (noch nicht im Call-Channel)? → Ring abbrechen.
if (peer && callId && (status === 'outgoing' || status === 'connecting')) {
fireRingCancel(peer.id, callId);
}
// Call als DM ins Chat-Log schreiben (nur Caller, fire-and-forget).
if (peer && callId && reason) {
const state = (reason === null ? 'ended' : reason) as 'ended' | 'unanswered' | 'declined' | 'failed' | 'busy';
logCallToChat(peer.id, callId, state, startedAt);
}
set({ status: 'ended', endReason: reason });
teardown();
},
toggleMute: () => {
const next = !get().muted;
try {
localStream?.getAudioTracks?.().forEach((t: any) => { t.enabled = !next; });
} catch {}
set({ muted: next });
},
toggleSpeaker: () => {
const next = !get().speaker;
try {
// Sicherheitshalber start() erneut aufrufen — falls die AVAudioSession
// von WebRTC zwischendurch deaktiviert wurde, sonst greift
// setForceSpeakerphoneOn auf iOS nicht.
inCall().start({ media: 'audio', auto: false });
inCall().setForceSpeakerphoneOn(next);
clog('toggleSpeaker →', next);
} catch (e: any) {
clog('setForceSpeakerphoneOn failed:', e?.message ?? e);
}
set({ speaker: next });
},
_clear: () => {
teardown();
set({ status: 'idle', peer: null, callId: null, muted: false, speaker: false, startedAt: null, endReason: null });
},
};
});

View File

@ -44,9 +44,15 @@ Building Release AAB (gradlew bundleRelease)|272
Validating IPA (App-Store Connect)|117 Validating IPA (App-Store Connect)|117
Uploading zu App-Store Connect (TestFlight)|138 Uploading zu App-Store Connect (TestFlight)|138
Building Release AAB (gradlew bundleRelease)|273 Building Release AAB (gradlew bundleRelease)|273
Building xcarchive|213
Exporting Ad-Hoc IPA|18
Exporting App-Store IPA|23
Validating IPA (App-Store Connect)|78 Validating IPA (App-Store Connect)|78
Uploading zu App-Store Connect (TestFlight)|90 Uploading zu App-Store Connect (TestFlight)|90
Building Release AAB (gradlew bundleRelease)|321 Building Release AAB (gradlew bundleRelease)|321
Validating IPA (App-Store Connect)|75
Uploading zu App-Store Connect (TestFlight)|80
Building Release AAB (gradlew bundleRelease)|269
Building xcarchive|265
Exporting Ad-Hoc IPA|19
Exporting App-Store IPA|26
Validating IPA (App-Store Connect)|72
Uploading zu App-Store Connect (TestFlight)|87
Building Release AAB (gradlew bundleRelease)|299

View File

@ -14,6 +14,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@parse/node-apn": "^8.1.0",
"@prisma/adapter-pg": "^7.2.0", "@prisma/adapter-pg": "^7.2.0",
"@prisma/client": "^7.2.0", "@prisma/client": "^7.2.0",
"@supabase/supabase-js": "^2.39.7", "@supabase/supabase-js": "^2.39.7",

View File

@ -0,0 +1,20 @@
-- VoIP-PushKit-Token pro User-Device.
--
-- Apple's PushKit liefert separat von APNs einen device-token (64-char hex)
-- der NUR für CallKit-Wake-from-killed-State-Pushes via VoIP-Services-Cert
-- (`.p12`) genutzt wird. Der Token rotiert unabhängig vom regulären APNs-Token.
--
-- Storage-Strategie: Single optional column auf push_tokens.
-- - Selbe Row wie der reguläre Expo-Token (Device = Row).
-- - voip_token = NULL → kein VoIP-Push (Android, Web, alte iOS-Builds).
-- - voip_token gesetzt → backend/server/services/voip-push.ts sendVoIPPush() feuert
-- bei eingehendem Call zusätzlich zur regulären Push.
--
-- DSGVO: kaskadiert via existing FK → keine zusätzliche Logik nötig.
ALTER TABLE "rebreak"."push_tokens"
ADD COLUMN IF NOT EXISTS "voip_token" TEXT;
-- Index für sendCallRingPush-Lookup ("alle Tokens des Users die VoIP-fähig sind").
CREATE INDEX IF NOT EXISTS "push_tokens_voip_token_idx"
ON "rebreak"."push_tokens"("user_id") WHERE "voip_token" IS NOT NULL;

View File

@ -162,6 +162,10 @@ model PushToken {
platform String // "ios" | "android" platform String // "ios" | "android"
deviceId String? @map("device_id") deviceId String? @map("device_id")
enabled Boolean @default(true) enabled Boolean @default(true)
/// iOS-only: PushKit VoIP-Token (64-char hex). Rotiert unabhängig vom Expo-Token.
/// NULL → kein CallKit-Wake-Push möglich (Android, Web, alte iOS-Builds).
/// Versendet via backend/server/services/voip-push.ts mit .p12-Cert (APNs HTTP/2).
voipToken String? @map("voip_token")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
lastUsedAt DateTime? @map("last_used_at") lastUsedAt DateTime? @map("last_used_at")

View File

@ -0,0 +1,54 @@
/**
* POST /api/calls/ring
*
* Triggert einen Push an den Callee bei einem eingehenden Voice-Call.
* Wird vom Caller direkt nach dem Supabase-Realtime-Broadcast aufgerufen
* (fire-and-forget). Der Push deckt den Background-/Locked-Screen-Fall ab;
* Foreground wird weiter via Realtime gehandhabt.
*
* Body: { peerId: string, callId: string }
*
* Kein VoIPPushKit/CallKit (Phase 2). Regulärer APNs/FCM Alert-Push mit
* priority=high + channelId="calls".
*/
import { requireUser } from "../../utils/auth";
import { usePrisma } from "../../utils/prisma";
import { sendCallRingPush } from "../../services/push";
export default defineEventHandler(async (event) => {
const user = await requireUser(event);
const body = await readBody<{ peerId?: string; callId?: string }>(event);
const peerId = body?.peerId?.trim();
const callId = body?.callId?.trim();
if (!peerId || !callId) {
throw createError({ statusCode: 400, statusMessage: "peerId_and_callId_required" });
}
if (peerId === user.id) {
throw createError({ statusCode: 400, statusMessage: "cannot_ring_self" });
}
const db = usePrisma();
const me = await db.profile.findUnique({
where: { id: user.id },
select: { id: true, nickname: true, username: true, avatar: true },
});
if (!me) {
throw createError({ statusCode: 404, statusMessage: "caller_profile_not_found" });
}
const callerName = me.nickname || me.username || "Jemand";
// Fire-and-forget — auch wenn der Push fehlschlägt soll der Caller
// keine Verzögerung sehen. Der Realtime-Ring läuft parallel.
void sendCallRingPush({
receiverId: peerId,
callerName,
callerId: me.id,
callerNickname: me.nickname || me.username || "",
callerAvatar: me.avatar,
callId,
});
return { ok: true };
});

View File

@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => {
attachmentName?: string; attachmentName?: string;
}; };
if (!receiverId || (!content?.trim() && !attachmentUrl)) { if (!receiverId || (!content?.trim() && !attachmentUrl && attachmentType !== 'call')) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
message: "receiverId und content/Anhang erforderlich", message: "receiverId und content/Anhang erforderlich",

View File

@ -15,6 +15,9 @@ const Body = z.object({
token: z.string().min(10).max(200), // ExponentPushToken[xxx] token: z.string().min(10).max(200), // ExponentPushToken[xxx]
platform: z.enum(["ios", "android"]), platform: z.enum(["ios", "android"]),
deviceId: z.string().max(120).optional(), deviceId: z.string().max(120).optional(),
/// iOS-PushKit-Token (64-char hex) für CallKit-Wake-Pushes. Optional —
/// Client kann später via separatem Call dieselbe Row updaten.
voipToken: z.string().min(32).max(200).optional(),
}); });
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -28,7 +31,7 @@ export default defineEventHandler(async (event) => {
}); });
} }
const { token, platform, deviceId } = parsed.data; const { token, platform, deviceId, voipToken } = parsed.data;
const db = usePrisma(); const db = usePrisma();
await db.pushToken.upsert({ await db.pushToken.upsert({
@ -38,6 +41,7 @@ export default defineEventHandler(async (event) => {
token, token,
platform, platform,
deviceId: deviceId ?? null, deviceId: deviceId ?? null,
voipToken: voipToken ?? null,
enabled: true, enabled: true,
lastUsedAt: new Date(), lastUsedAt: new Date(),
}, },
@ -45,6 +49,9 @@ export default defineEventHandler(async (event) => {
userId: user.id, // Token könnte das Device gewechselt haben userId: user.id, // Token könnte das Device gewechselt haben
platform, platform,
deviceId: deviceId ?? null, deviceId: deviceId ?? null,
// Wichtig: voipToken nur überschreiben wenn der Client einen mitliefert,
// sonst behalten (separate VoIP-Rotation-Calls könnten ihn schon gesetzt haben).
...(voipToken !== undefined ? { voipToken } : {}),
enabled: true, enabled: true,
lastUsedAt: new Date(), lastUsedAt: new Date(),
}, },

View File

@ -14,6 +14,7 @@
*/ */
import { Expo, type ExpoPushMessage } from "expo-server-sdk"; import { Expo, type ExpoPushMessage } from "expo-server-sdk";
import { usePrisma } from "../utils/prisma"; import { usePrisma } from "../utils/prisma";
import { sendVoIPPush } from "./voip-push";
const expo = new Expo(); const expo = new Expo();
@ -126,3 +127,121 @@ export function truncatePreview(text: string, max = 100): string {
if (text.length <= max) return text; if (text.length <= max) return text;
return text.slice(0, max - 1) + "…"; return text.slice(0, max - 1) + "…";
} }
export interface CallRingPushPayload {
/** Empfänger (Callee) — bekommt den Push */
receiverId: string;
/** Caller-Display-Name (für Titel) */
callerName: string;
/** Caller-Profil (für Show-Screen nach Tap) */
callerId: string;
callerNickname: string;
callerAvatar: string | null;
/** Call-ID — muss matchen damit Callee an die richtige Session andocken kann */
callId: string;
}
/**
* Push für eingehenden Voice-Call.
*
* Foreground-Calls funktionieren via Supabase Realtime; dieser Push deckt den
* Background/locked-screen-Fall ab. Beim Tap navigiert der Client direkt in
* den /call-Screen und triggert `useCall.receiveIncoming(callId, from)`
* die normale Accept/Decline-UI greift dann.
*
* Wichtig: KEIN VoIPPushKit (Apple-only, braucht CallKit). Wir nutzen den
* regulären APNs-Alert-Push mit `priority: high` + Notifications-Sound.
* Trade-off: Auf iOS wacht ein force-quit-killed Process NICHT auf das ist
* dasselbe Verhalten wie reguläre Chat-Pushes. Für echte CallKit-Integration
* Phase 2 (eigene Initiative).
*/
export async function sendCallRingPush(payload: CallRingPushPayload): Promise<void> {
try {
const db = usePrisma();
const profile = await db.profile.findUnique({
where: { id: payload.receiverId },
select: { chatPushEnabled: true, deletedAt: true },
});
if (!profile || profile.deletedAt || !profile.chatPushEnabled) return;
const tokens = await db.pushToken.findMany({
where: { userId: payload.receiverId, enabled: true },
select: { id: true, token: true, voipToken: true, platform: true },
});
if (tokens.length === 0) return;
// ─── 1) VoIP-Pushes (iOS, CallKit-Wake-from-killed-State) ─────────────
// Läuft parallel zum regulären Push. Wenn voipToken NULL ist (Android,
// alte iOS-Builds ohne PushKit-Setup) wird das Device hier übersprungen
// und fällt auf den normalen Lockscreen-Banner zurück.
for (const t of tokens) {
if (t.platform === "ios" && t.voipToken) {
void sendVoIPPush({
voipToken: t.voipToken,
callId: payload.callId,
callerName: payload.callerName,
callerId: payload.callerId,
callerNickname: payload.callerNickname,
callerAvatar: payload.callerAvatar,
});
}
}
// ─── 2) Reguläre Expo-Pushes (Android FCM + iOS-Fallback) ────────────
const messages: ExpoPushMessage[] = [];
const validTokenIds: string[] = [];
for (const t of tokens) {
if (!Expo.isExpoPushToken(t.token)) {
await db.pushToken
.update({ where: { id: t.id }, data: { enabled: false } })
.catch(() => {});
continue;
}
messages.push({
to: t.token,
sound: "default",
title: `📞 ${payload.callerName}`,
body: "Eingehender Anruf",
priority: "high",
// Android: dedizierter Calls-Channel (im Client mit Importance.MAX +
// Vibration + Bypass-DND zu konfigurieren)
channelId: "calls",
// iOS: zeigt als alert auch im Lockscreen (interruption-level)
// 'time-sensitive' wäre besser, braucht aber Critical-Alerts-Entitlement.
// 'active' (default) reicht für Phase 1.
data: {
type: "call",
callId: payload.callId,
from: {
id: payload.callerId,
nickname: payload.callerNickname,
avatar: payload.callerAvatar,
},
},
});
validTokenIds.push(t.id);
}
if (messages.length === 0) return;
const chunks = expo.chunkPushNotifications(messages);
for (const chunk of chunks) {
try {
await expo.sendPushNotificationsAsync(chunk);
} catch (err) {
console.error("[push] call-ring chunk send failed:", err);
}
}
await db.pushToken
.updateMany({
where: { id: { in: validTokenIds } },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
} catch (err) {
console.error("[push] sendCallRingPush failed:", err);
}
}

View File

@ -0,0 +1,130 @@
/**
* iOS-VoIP-Push via PushKit + APNs HTTP/2.
*
* Unterschied zu services/push.ts (Expo Server SDK):
* - Reguläre Pushes laufen über Expo's Proxy (token = ExponentPushToken[xxx])
* - VoIP-Pushes MÜSSEN direkt an APNs gehen (Apple verbietet Proxy für PushKit)
* - Nutzt das VoIP-Services-Cert (.p12) das wir bei Apple beantragt haben
* - Wake-from-killed-State: einziger Weg ein iOS-App aufzuwecken um CallKit
* UI anzuzeigen
*
* Env-Vars (aus Infisical):
* APNS_VOIP_P12_PATH Pfad zur .p12-Datei (z.B. /root/.secrets/rebreak-voip.p12)
* APNS_VOIP_P12_PASSWORD Cert-Password
* APNS_VOIP_TOPIC Bundle-ID + ".voip" (org.rebreak.app.voip)
* APNS_VOIP_PRODUCTION "true" für Production-APNs-Endpoint
*
* Wenn ENV-Vars fehlen: Service no-op (kein Error, nur Log-Warnung beim ersten Send).
* Macht reguläre Pushes nicht kaputt wenn das VoIP-Setup noch unfertig ist.
*/
import apn from "@parse/node-apn";
import fs from "node:fs";
let provider: apn.Provider | null = null;
let initialized = false;
let topic: string | null = null;
function getProvider(): apn.Provider | null {
if (initialized) return provider;
initialized = true;
const p12Path = process.env.APNS_VOIP_P12_PATH;
const p12Pass = process.env.APNS_VOIP_P12_PASSWORD;
const tpc = process.env.APNS_VOIP_TOPIC;
const production = process.env.APNS_VOIP_PRODUCTION === "true";
if (!p12Path || !p12Pass || !tpc) {
console.warn(
"[voip-push] disabled — missing env (APNS_VOIP_P12_PATH/PASSWORD/TOPIC)",
);
return null;
}
if (!fs.existsSync(p12Path)) {
console.warn(`[voip-push] disabled — p12 file not found at ${p12Path}`);
return null;
}
try {
topic = tpc;
provider = new apn.Provider({
pfx: p12Path,
passphrase: p12Pass,
production,
});
console.log(`[voip-push] initialized (topic=${tpc}, production=${production})`);
return provider;
} catch (err) {
console.error("[voip-push] init failed:", err);
return null;
}
}
export interface VoIPCallPayload {
/** VoIP-PushKit-Token des Empfänger-Devices (64-char hex, NICHT der Expo-Token) */
voipToken: string;
/** Eindeutige Call-ID (matcht callIdToUuid() im Client) */
callId: string;
/** Display-Name für CallKit-UI */
callerName: string;
/** User-ID des Anrufers — Client braucht das um peer-Profile zu fetchen */
callerId: string;
callerNickname: string;
callerAvatar: string | null;
}
/**
* Sendet einen VoIP-Push an genau ein iOS-Device.
*
* Apple-Vorgaben (verifiziert via developer.apple.com/documentation/pushkit):
* - apns-push-type: voip
* - apns-topic: bundle-id + ".voip" (NICHT der reguläre bundle-id)
* - apns-priority: 10
* - apns-expiration: 0 (kein Storage wenn Device offline, verworfen)
*
* @returns true bei Erfolg (oder no-op wenn Service disabled), false bei Fehler.
*/
export async function sendVoIPPush(payload: VoIPCallPayload): Promise<boolean> {
const p = getProvider();
if (!p || !topic) return true; // no-op, regulärer Push übernimmt
const note = new apn.Notification();
note.topic = topic;
note.expiry = 0; // sofort verwerfen wenn Device unreachable
note.priority = 10;
note.pushType = "voip";
note.payload = {
type: "call",
callId: payload.callId,
callerName: payload.callerName,
from: {
id: payload.callerId,
nickname: payload.callerNickname,
avatar: payload.callerAvatar,
},
};
try {
const result = await p.send(note, payload.voipToken);
if (result.failed.length > 0) {
const f = result.failed[0];
console.warn(
`[voip-push] failed token=${payload.voipToken.slice(0, 8)}… reason=`,
f.response ?? f.error,
);
return false;
}
return true;
} catch (err) {
console.error("[voip-push] send threw:", err);
return false;
}
}
/** Cleanup bei Server-Shutdown — wichtig wegen HTTP/2-Verbindung an Apple. */
export function shutdownVoIPProvider(): void {
if (provider) {
provider.shutdown();
provider = null;
initialized = false;
}
}

57
ops/CALLKIT_SETUP.md Normal file
View File

@ -0,0 +1,57 @@
# CallKit + VoIP Setup für ReBreak
## 1. CSR generieren (Mac, 2 Min)
```bash
cd /tmp
# Private Key
openssl genrsa -out rebreak-voip.key 2048
# CSR (Common Name muss eindeutig sein — App-Identifier nehmen)
openssl req -new -key rebreak-voip.key -out rebreak-voip.csr \
-subj "/emailAddress=YOUR_APPLE_DEV_EMAIL@example.com/CN=ReBreak VoIP Push/C=DE"
```
`rebreak-voip.csr` an mich, `rebreak-voip.key` **bei dir behalten** (private key, nie teilen).
## 2. Apple Dev Portal (5 Min)
1. https://developer.apple.com/account → Identifiers → App-Identifier `org.rebreak.app` (oder wie deiner heißt)
2. **Capabilities** → ✅ "Push Notifications" + ✅ "Voice over IP" (= das CallKit-Entitlement)
3. → Speichern
4. Certificates → "+" → "**VoIP Services Certificate**"
5. App-Identifier auswählen → CSR von oben hochladen → Download `.cer`
6. **Provisioning Profile** für die App neu generieren + downloaden (weil neue Capabilities)
## 3. Files an mich
- `rebreak-voip.cer` (Apple Output)
- Neues Provisioning Profile `.mobileprovision`
- Den `rebreak-voip.key` NICHT — den brauchen wir nur auf dem Backend (du legst ihn unter `/root/.secrets/rebreak-voip.key` auf Hetzner oder via Infisical)
## 4. .cer in .p8/.p12 konvertieren (ich mach das)
```bash
# .cer (DER) → .pem
openssl x509 -inform DER -outform PEM -in rebreak-voip.cer -out rebreak-voip.pem
# .pem + .key → .p12 (für apn2/node-apn)
openssl pkcs12 -export -inkey rebreak-voip.key -in rebreak-voip.pem \
-out rebreak-voip.p12 -passout pass:CHOOSE_PASSWORD
```
## 5. Was Apple beim nächsten App-Review checkt
- VoIP-Push wird NUR für Calls verwendet (kein silent-sync)
- `reportNewIncomingCall` innerhalb 5s nach Push
- `includesCallsInRecents: false` (Privacy für DiGA — Anrufe sollen NICHT in iCloud sync)
- App-Description in Store sollte Call-Feature erwähnen
Apple-Review-Dauer aktuell: ~24h. Kein Sonder-Antrag, normaler Review.
## 6. Android (kein Apple-Antrag, aber Permission im Manifest)
- `FOREGROUND_SERVICE_PHONE_CALL` (Android 11+ für Mic-Access im Background)
- `MANAGE_OWN_CALLS` (ConnectionService)
- `USE_FULL_SCREEN_INTENT` (Android 14+ für Full-Screen-Call-UI)
- `READ_PHONE_STATE` für Account-Registrierung
→ Alles automatisch via callkeep Config-Plugin, kein Manual-Step von dir.

231
pnpm-lock.yaml generated
View File

@ -120,6 +120,12 @@ importers:
apps/rebreak-native: apps/rebreak-native:
dependencies: dependencies:
'@config-plugins/react-native-callkeep':
specifier: ^12.0.0
version: 12.0.0(expo@54.0.34)
'@config-plugins/react-native-webrtc':
specifier: ^15.0.1
version: 15.0.1(expo@54.0.34)
'@expo-google-fonts/nunito': '@expo-google-fonts/nunito':
specifier: ^0.2.3 specifier: ^0.2.3
version: 0.2.3 version: 0.2.3
@ -267,9 +273,15 @@ importers:
react-native-bottom-tabs: react-native-bottom-tabs:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) version: 1.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
react-native-callkeep:
specifier: ^4.3.16
version: 4.3.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))
react-native-gesture-handler: react-native-gesture-handler:
specifier: ~2.28.0 specifier: ~2.28.0
version: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) version: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
react-native-incall-manager:
specifier: ^4.2.1
version: 4.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))
react-native-keyboard-controller: react-native-keyboard-controller:
specifier: ^1.21.7 specifier: ^1.21.7
version: 1.21.7(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) version: 1.21.7(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
@ -294,6 +306,12 @@ importers:
react-native-url-polyfill: react-native-url-polyfill:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)) version: 2.0.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))
react-native-voip-push-notification:
specifier: ^3.3.3
version: 3.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))
react-native-webrtc:
specifier: ^124.0.7
version: 124.0.7(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))
react-native-worklets: react-native-worklets:
specifier: ~0.5.1 specifier: ~0.5.1
version: 0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0) version: 0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
@ -322,6 +340,9 @@ importers:
backend: backend:
dependencies: dependencies:
'@parse/node-apn':
specifier: ^8.1.0
version: 8.1.0
'@prisma/adapter-pg': '@prisma/adapter-pg':
specifier: ^7.2.0 specifier: ^7.2.0
version: 7.8.0 version: 7.8.0
@ -957,6 +978,16 @@ packages:
'@colordx/core@5.4.3': '@colordx/core@5.4.3':
resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==} resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==}
'@config-plugins/react-native-callkeep@12.0.0':
resolution: {integrity: sha512-IoRZ0+u8iBbmpnF9qvodQ77NI6lebm2OThAER4zOTPFKxEpKMkmCPoFk54CD5yyy4N1cLBXnbELj/GNQRDQBaQ==}
peerDependencies:
expo: ^54
'@config-plugins/react-native-webrtc@15.0.1':
resolution: {integrity: sha512-1/RSRnMOWPqHmEJCGEhElzFvkXUiNkGYsbAIxJl/3+t9OW2l26Qsjo8Ei7v/OMJ7g0Ek6qnKkjCxYrrLSfJi2A==}
peerDependencies:
expo: ^56
'@devframes/hub@0.5.2': '@devframes/hub@0.5.2':
resolution: {integrity: sha512-qMkBFw1OqhPuNs1tQWkRq0z0Tg49kXNu53bs59tdF4lytKupatWVnL3cpsVPqn+Q5P7A70r99BKTcm+prMtHqw==} resolution: {integrity: sha512-qMkBFw1OqhPuNs1tQWkRq0z0Tg49kXNu53bs59tdF4lytKupatWVnL3cpsVPqn+Q5P7A70r99BKTcm+prMtHqw==}
peerDependencies: peerDependencies:
@ -2756,6 +2787,10 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
'@parse/node-apn@8.1.0':
resolution: {integrity: sha512-LowcdkKPDikbbzIr3zwEdoFW5wfEbbTzpYQeen3S8kKLePG0AQK586Li+OLC7bonyLmlk//Yk3smHZOLSV6TZA==}
engines: {node: 20 || 22 || 24}
'@pinojs/redact@0.4.0': '@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@ -4385,6 +4420,10 @@ packages:
asap@2.0.6: asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
assert-plus@1.0.0:
resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
engines: {node: '>=0.8'}
assert@2.1.0: assert@2.1.0:
resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==}
@ -4643,6 +4682,9 @@ packages:
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
buffer-from@1.1.2: buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@ -4908,6 +4950,9 @@ packages:
core-js-compat@3.49.0: core-js-compat@3.49.0:
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
core-util-is@1.0.2:
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
core-util-is@1.0.3: core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@ -5043,6 +5088,15 @@ packages:
supports-color: supports-color:
optional: true optional: true
debug@4.3.4:
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@4.4.3: debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'} engines: {node: '>=6.0'}
@ -5207,6 +5261,9 @@ packages:
eastasianwidth@0.2.0: eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1: ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@ -5455,6 +5512,10 @@ packages:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
event-target-shim@6.0.2:
resolution: {integrity: sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==}
engines: {node: '>=10.13.0'}
events-universal@1.0.1: events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
@ -5750,6 +5811,10 @@ packages:
exsolve@1.0.8: exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
extsprintf@1.4.1:
resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==}
engines: {'0': node >=0.6.0}
fast-check@3.23.2: fast-check@3.23.2:
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
engines: {node: '>=8.0.0'} engines: {node: '>=8.0.0'}
@ -6479,6 +6544,16 @@ packages:
resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==} resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
jsonwebtoken@9.0.3:
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
engines: {node: '>=12', npm: '>=6'}
jwa@2.0.1:
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
keyv@4.5.4: keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@ -6709,12 +6784,33 @@ packages:
lodash.defaults@4.2.0: lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
lodash.isarguments@3.1.0: lodash.isarguments@3.1.0:
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
lodash.isboolean@3.0.3:
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
lodash.isinteger@4.0.4:
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
lodash.isnumber@3.0.3:
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
lodash.isplainobject@4.0.6:
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
lodash.isstring@4.0.1:
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
lodash.memoize@4.1.2: lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
lodash.once@4.1.1:
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
lodash.throttle@4.1.1: lodash.throttle@4.1.1:
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
@ -6999,6 +7095,9 @@ packages:
ms@2.0.0: ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
ms@2.1.2:
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -7946,6 +8045,11 @@ packages:
react: '*' react: '*'
react-native: '*' react-native: '*'
react-native-callkeep@4.3.16:
resolution: {integrity: sha512-aIxn02T5zW4jNPyzRdFGTWv6xD3Vy/1AkBMB6iYvWZEHWnfmgNGF0hELqg03Vbc2BNUhfqpu17aIydos+5Hurg==}
peerDependencies:
react-native: '>=0.40.0'
react-native-css-interop@0.2.3: react-native-css-interop@0.2.3:
resolution: {integrity: sha512-wc+JI7iUfdFBqnE18HhMTtD0q9vkhuMczToA87UdHGWwMyxdT5sCcNy+i4KInPCE855IY0Ic8kLQqecAIBWz7w==} resolution: {integrity: sha512-wc+JI7iUfdFBqnE18HhMTtD0q9vkhuMczToA87UdHGWwMyxdT5sCcNy+i4KInPCE855IY0Ic8kLQqecAIBWz7w==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -7968,6 +8072,11 @@ packages:
react: '*' react: '*'
react-native: '*' react-native: '*'
react-native-incall-manager@4.2.1:
resolution: {integrity: sha512-HTdtzQ/AswUbuNhcL0gmyZLAXo8VqBO7SIh+BwbeeM1YMXXlR+Q2MvKxhD4yanjJPeyqMfuRhryCQCJhPlsdAw==}
peerDependencies:
react-native: '>=0.40.0'
react-native-is-edge-to-edge@1.3.1: react-native-is-edge-to-edge@1.3.1:
resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==}
peerDependencies: peerDependencies:
@ -8020,6 +8129,16 @@ packages:
peerDependencies: peerDependencies:
react-native: '*' react-native: '*'
react-native-voip-push-notification@3.3.3:
resolution: {integrity: sha512-cyWuI9//T1IQIq4RPq0QQe0NuEwIpnE0L98H2sUH4MjFsNMD/yNE4EJzEZN4cIwfPMZaASa0gQw6B1a7VwnkMA==}
peerDependencies:
react-native: '>=0.60.0'
react-native-webrtc@124.0.7:
resolution: {integrity: sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA==}
peerDependencies:
react-native: '>=0.60.0'
react-native-worklets@0.5.1: react-native-worklets@0.5.1:
resolution: {integrity: sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==} resolution: {integrity: sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==}
peerDependencies: peerDependencies:
@ -9184,6 +9303,10 @@ packages:
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
verror@1.10.1:
resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==}
engines: {node: '>=0.6.0'}
vite-dev-rpc@1.1.0: vite-dev-rpc@1.1.0:
resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==} resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==}
peerDependencies: peerDependencies:
@ -10361,6 +10484,14 @@ snapshots:
'@colordx/core@5.4.3': {} '@colordx/core@5.4.3': {}
'@config-plugins/react-native-callkeep@12.0.0(expo@54.0.34)':
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
'@config-plugins/react-native-webrtc@15.0.1(expo@54.0.34)':
dependencies:
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
'@devframes/hub@0.5.2(devframe@0.5.2(typescript@5.9.3))': '@devframes/hub@0.5.2(devframe@0.5.2(typescript@5.9.3))':
dependencies: dependencies:
birpc: 4.0.0 birpc: 4.0.0
@ -12470,6 +12601,15 @@ snapshots:
'@parcel/watcher-win32-ia32': 2.5.6 '@parcel/watcher-win32-ia32': 2.5.6
'@parcel/watcher-win32-x64': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6
'@parse/node-apn@8.1.0':
dependencies:
debug: 4.4.3
jsonwebtoken: 9.0.3
node-forge: 1.4.0
verror: 1.10.1
transitivePeerDependencies:
- supports-color
'@pinojs/redact@0.4.0': {} '@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
@ -14343,6 +14483,8 @@ snapshots:
asap@2.0.6: {} asap@2.0.6: {}
assert-plus@1.0.0: {}
assert@2.1.0: assert@2.1.0:
dependencies: dependencies:
call-bind: 1.0.9 call-bind: 1.0.9
@ -14645,6 +14787,8 @@ snapshots:
buffer-crc32@1.0.0: {} buffer-crc32@1.0.0: {}
buffer-equal-constant-time@1.0.1: {}
buffer-from@1.1.2: {} buffer-from@1.1.2: {}
buffer@5.7.1: buffer@5.7.1:
@ -14964,6 +15108,8 @@ snapshots:
dependencies: dependencies:
browserslist: 4.28.2 browserslist: 4.28.2
core-util-is@1.0.2: {}
core-util-is@1.0.3: {} core-util-is@1.0.3: {}
crc-32@1.2.2: {} crc-32@1.2.2: {}
@ -15100,6 +15246,10 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
debug@4.3.4:
dependencies:
ms: 2.1.2
debug@4.4.3: debug@4.4.3:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
@ -15235,6 +15385,10 @@ snapshots:
eastasianwidth@0.2.0: {} eastasianwidth@0.2.0: {}
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {} ee-first@1.1.1: {}
effect@3.20.0: effect@3.20.0:
@ -15559,6 +15713,8 @@ snapshots:
event-target-shim@5.0.1: {} event-target-shim@5.0.1: {}
event-target-shim@6.0.2: {}
events-universal@1.0.1: events-universal@1.0.1:
dependencies: dependencies:
bare-events: 2.8.2 bare-events: 2.8.2
@ -15901,6 +16057,8 @@ snapshots:
exsolve@1.0.8: {} exsolve@1.0.8: {}
extsprintf@1.4.1: {}
fast-check@3.23.2: fast-check@3.23.2:
dependencies: dependencies:
pure-rand: 6.1.0 pure-rand: 6.1.0
@ -16739,7 +16897,31 @@ snapshots:
acorn: 8.16.0 acorn: 8.16.0
eslint-visitor-keys: 3.4.3 eslint-visitor-keys: 3.4.3
espree: 9.6.1 espree: 9.6.1
semver: 7.7.4 semver: 7.8.1
jsonwebtoken@9.0.3:
dependencies:
jws: 4.0.1
lodash.includes: 4.3.0
lodash.isboolean: 3.0.3
lodash.isinteger: 4.0.4
lodash.isnumber: 3.0.3
lodash.isplainobject: 4.0.6
lodash.isstring: 4.0.1
lodash.once: 4.1.1
ms: 2.1.3
semver: 7.8.1
jwa@2.0.1:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.1:
dependencies:
jwa: 2.0.1
safe-buffer: 5.2.1
keyv@4.5.4: keyv@4.5.4:
dependencies: dependencies:
@ -16942,10 +17124,24 @@ snapshots:
lodash.defaults@4.2.0: {} lodash.defaults@4.2.0: {}
lodash.includes@4.3.0: {}
lodash.isarguments@3.1.0: {} lodash.isarguments@3.1.0: {}
lodash.isboolean@3.0.3: {}
lodash.isinteger@4.0.4: {}
lodash.isnumber@3.0.3: {}
lodash.isplainobject@4.0.6: {}
lodash.isstring@4.0.1: {}
lodash.memoize@4.1.2: {} lodash.memoize@4.1.2: {}
lodash.once@4.1.1: {}
lodash.throttle@4.1.1: {} lodash.throttle@4.1.1: {}
lodash.uniq@4.5.0: {} lodash.uniq@4.5.0: {}
@ -17320,6 +17516,8 @@ snapshots:
ms@2.0.0: {} ms@2.0.0: {}
ms@2.1.2: {}
ms@2.1.3: {} ms@2.1.3: {}
muggle-string@0.4.1: {} muggle-string@0.4.1: {}
@ -17589,7 +17787,7 @@ snapshots:
node-abi@3.92.0: node-abi@3.92.0:
dependencies: dependencies:
semver: 7.7.4 semver: 7.8.1
optional: true optional: true
node-addon-api@6.1.0: node-addon-api@6.1.0:
@ -17633,7 +17831,7 @@ snapshots:
dependencies: dependencies:
hosted-git-info: 7.0.2 hosted-git-info: 7.0.2
proc-log: 4.2.0 proc-log: 4.2.0
semver: 7.7.4 semver: 7.8.1
validate-npm-package-name: 5.0.1 validate-npm-package-name: 5.0.1
npm-run-path@5.3.0: npm-run-path@5.3.0:
@ -18676,6 +18874,10 @@ snapshots:
sf-symbols-typescript: 2.2.0 sf-symbols-typescript: 2.2.0
use-latest-callback: 0.2.6(react@19.1.0) use-latest-callback: 0.2.6(react@19.1.0)
react-native-callkeep@4.3.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)):
dependencies:
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
react-native-css-interop@0.2.3(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.19(yaml@2.8.4)): react-native-css-interop@0.2.3(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.19(yaml@2.8.4)):
dependencies: dependencies:
'@babel/helper-module-imports': 7.28.6 '@babel/helper-module-imports': 7.28.6
@ -18702,6 +18904,10 @@ snapshots:
react: 19.1.0 react: 19.1.0
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0) react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
react-native-incall-manager@4.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)):
dependencies:
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
react-native-is-edge-to-edge@1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): react-native-is-edge-to-edge@1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
dependencies: dependencies:
react: 19.1.0 react: 19.1.0
@ -18755,6 +18961,19 @@ snapshots:
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0) react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
whatwg-url-without-unicode: 8.0.0-3 whatwg-url-without-unicode: 8.0.0-3
react-native-voip-push-notification@3.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)):
dependencies:
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
react-native-webrtc@124.0.7(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)):
dependencies:
base64-js: 1.5.1
debug: 4.3.4
event-target-shim: 6.0.2
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
transitivePeerDependencies:
- supports-color
react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0): react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
dependencies: dependencies:
'@babel/core': 7.29.0 '@babel/core': 7.29.0
@ -20061,6 +20280,12 @@ snapshots:
- '@types/react' - '@types/react'
- '@types/react-dom' - '@types/react-dom'
verror@1.10.1:
dependencies:
assert-plus: 1.0.0
core-util-is: 1.0.2
extsprintf: 1.4.1
vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)): vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)):
dependencies: dependencies:
birpc: 2.9.0 birpc: 2.9.0