From 822053e11e0f57f117942716b29c27c2d708acaa Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Thu, 4 Jun 2026 09:27:13 +0200 Subject: [PATCH] feat(calls): CallKit/ConnectionService + VoIP-PushKit + EU-Ringback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/rebreak-native/CHANGELOG.md | 19 + apps/rebreak-native/app.config.ts | 21 +- apps/rebreak-native/app/(app)/chat.tsx | 3 +- apps/rebreak-native/app/_layout.tsx | 65 ++- apps/rebreak-native/app/call.tsx | 178 +++++++ apps/rebreak-native/app/dm.tsx | 45 +- .../assets/sounds/ringback_eu.mp3 | Bin 0 -> 60858 bytes .../components/chat/ChatBubble.tsx | 47 +- .../components/devices/MagicSheet.tsx | 4 +- apps/rebreak-native/dev.sh | 188 +++++++ .../rebreak-native/hooks/useCallKeepEvents.ts | 62 +++ apps/rebreak-native/hooks/useIncomingCalls.ts | 47 ++ .../hooks/usePushTokenRegistration.ts | 12 +- apps/rebreak-native/lib/callkit.ts | 134 +++++ apps/rebreak-native/lib/ringback.ts | 53 ++ apps/rebreak-native/lib/supabase.ts | 9 + apps/rebreak-native/locales/ar.json | 23 + apps/rebreak-native/locales/de.json | 23 + apps/rebreak-native/locales/en.json | 23 + apps/rebreak-native/locales/fr.json | 23 + .../ios/RebreakContentFilter/Info.plist | 2 +- .../RebreakPacketTunnelExtension/Info.plist | 2 +- .../ios/RebreakURLFilterExtension/Info.plist | 2 +- apps/rebreak-native/package.json | 6 + .../plugins/with-allow-nonmodular-includes.js | 71 +++ apps/rebreak-native/stores/call.ts | 487 ++++++++++++++++++ apps/rebreak-native/tmp/.deploy-runtimes | 12 +- backend/package.json | 1 + .../migration.sql | 20 + backend/prisma/schema.prisma | 4 + backend/server/api/calls/ring.post.ts | 54 ++ backend/server/api/chat/dm.post.ts | 2 +- .../server/api/users/me/push-token.post.ts | 9 +- backend/server/services/push.ts | 119 +++++ backend/server/services/voip-push.ts | 130 +++++ ops/CALLKIT_SETUP.md | 57 ++ pnpm-lock.yaml | 231 ++++++++- 37 files changed, 2141 insertions(+), 47 deletions(-) create mode 100644 apps/rebreak-native/app/call.tsx create mode 100644 apps/rebreak-native/assets/sounds/ringback_eu.mp3 create mode 100644 apps/rebreak-native/hooks/useCallKeepEvents.ts create mode 100644 apps/rebreak-native/hooks/useIncomingCalls.ts create mode 100644 apps/rebreak-native/lib/callkit.ts create mode 100644 apps/rebreak-native/lib/ringback.ts create mode 100644 apps/rebreak-native/plugins/with-allow-nonmodular-includes.js create mode 100644 apps/rebreak-native/stores/call.ts create mode 100644 backend/prisma/migrations/20260604_add_voip_push_token/migration.sql create mode 100644 backend/server/api/calls/ring.post.ts create mode 100644 backend/server/services/voip-push.ts create mode 100644 ops/CALLKIT_SETUP.md diff --git a/apps/rebreak-native/CHANGELOG.md b/apps/rebreak-native/CHANGELOG.md index 47f6e30..993d7cc 100644 --- a/apps/rebreak-native/CHANGELOG.md +++ b/apps/rebreak-native/CHANGELOG.md @@ -1,6 +1,25 @@ # Changelog 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 - 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) diff --git a/apps/rebreak-native/app.config.ts b/apps/rebreak-native/app.config.ts index a925b65..09dbc87 100644 --- a/apps/rebreak-native/app.config.ts +++ b/apps/rebreak-native/app.config.ts @@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ios: { supportsTablet: true, bundleIdentifier: MAIN_BUNDLE, - buildNumber: "70", + buildNumber: "73", // Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen // signInAsync()-Flow. Ohne flag generiert Expo's prebuild den // 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.", NSFaceIDUsageDescription: "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: { package: "org.rebreak.app", - versionCode: 53, + versionCode: 56, adaptiveIcon: { // Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den // Außenring) → adaptive-foreground.png ist das Logo auf transparentem @@ -76,6 +79,14 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ "POST_NOTIFICATIONS", "BIND_ACCESSIBILITY_SERVICE", "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-font", "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", { @@ -110,6 +125,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ "./plugins/with-fmt-consteval-fix", // Xcode 14+ resource-bundle-signing fix (needed because useFrameworks: static) "./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) "./plugins/with-rebreak-protection-ios", // Phase 5: VpnService + AccessibilityService (Android) diff --git a/apps/rebreak-native/app/(app)/chat.tsx b/apps/rebreak-native/app/(app)/chat.tsx index 25c2043..3831870 100644 --- a/apps/rebreak-native/app/(app)/chat.tsx +++ b/apps/rebreak-native/app/(app)/chat.tsx @@ -79,7 +79,8 @@ function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void } > {conv.isOwn ? `${t('chat.you')} ` : ''} {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') : t('chat.media_sent'))} diff --git a/apps/rebreak-native/app/_layout.tsx b/apps/rebreak-native/app/_layout.tsx index cb9aaff..81aac0c 100644 --- a/apps/rebreak-native/app/_layout.tsx +++ b/apps/rebreak-native/app/_layout.tsx @@ -35,6 +35,9 @@ import { useDeviceApprovalRealtime } from '../hooks/useDeviceApprovalRealtime'; import { useDevicesStore } from '../stores/devices'; import { OnlinePresenceProvider } from '../components/OnlinePresenceProvider'; import { usePushTokenRegistration } from '../hooks/usePushTokenRegistration'; +import { useIncomingCalls } from '../hooks/useIncomingCalls'; +import { useCallStore } from '../stores/call'; +import { useCallKeepEvents } from '../hooks/useCallKeepEvents'; import '../lib/i18n'; // i18next-Init via Side-Effect import '../global.css'; @@ -78,26 +81,53 @@ function RootLayoutInner() { // Push-Token-Registration nach Login (idempotent) usePushTokenRegistration(user?.id); + // Eingehende Voice-Calls (Ring-Channel) — zeigt den Call-Screen wenn jemand + // anruft. Foreground-only (Phase 1). + useIncomingCalls(user?.id); + + // CallKit/ConnectionService Event-Bridge — mappt native UI-Actions + // (Accept/Decline/Hangup im Lockscreen-CallKit-UI) auf useCallStore. + useCallKeepEvents(); + // Apple-Style Device-Approval Realtime — lauscht auf neue Approval-Requests // für diesen User und zeigt das Incoming-Sheet wenn ein anderes Gerät // sich anmelden möchte. useDeviceApprovalRealtime(!!user?.id); - // Push-Tap-Deep-Link: User tippt Notification → navigate zu Chat + // Push-Tap-Deep-Link: User tippt Notification → navigate zu Chat / Call useEffect(() => { - const sub = Notifications.addNotificationResponseReceivedListener( - (response) => { - const data = response.notification.request.content.data as - | { type?: 'dm' | 'room'; targetId?: string } - | undefined; - if (!data?.type || !data.targetId) return; - if (data.type === 'dm') { - router.push({ pathname: '/dm', params: { userId: data.targetId } }); - } else if (data.type === 'room') { - router.push({ pathname: '/room', params: { roomId: data.targetId } }); + const handle = (response: Notifications.NotificationResponse | null | undefined) => { + if (!response) return; + const data = response.notification.request.content.data as + | { + type?: 'dm' | 'room' | 'call'; + targetId?: string; + callId?: string; + from?: { id: string; nickname: string; avatar: string | null }; + } + | undefined; + if (!data?.type) return; + if (data.type === 'dm' && data.targetId) { + router.push({ pathname: '/dm', params: { userId: data.targetId } }); + } else if (data.type === 'room' && data.targetId) { + router.push({ pathname: '/room', params: { roomId: data.targetId } }); + } else if (data.type === 'call' && data.callId && data.from) { + // Eingehender Anruf — 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(); }, []); @@ -176,6 +206,15 @@ function RootLayoutInner() { animation: 'slide_from_bottom', }} /> + 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 ( + + + + {/* Oben: Avatar + Name + Status */} + + + + {peer?.nickname ?? '…'} + + + {subtitle} + + + + {/* Unten: Aktions-Buttons */} + + {status === 'incoming' ? ( + + + + + ) : status === 'ended' ? null : ( + + {(status === 'connected' || status === 'connecting') && ( + + )} + {(status === 'connected' || status === 'connecting' || status === 'outgoing') && ( + + )} + hangup('ended')} label={t('call.hang_up')} /> + + )} + + + + ); +} + +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 ( + + + + + {label} + + ); +} diff --git a/apps/rebreak-native/app/dm.tsx b/apps/rebreak-native/app/dm.tsx index 683110e..d3a6a5f 100644 --- a/apps/rebreak-native/app/dm.tsx +++ b/apps/rebreak-native/app/dm.tsx @@ -40,6 +40,8 @@ import { useDmRealtime } from '../hooks/useChatRealtime'; import { useDmTyping } from '../hooks/useDmTyping'; import { useColors } from '../lib/theme'; import { useAuthStore } from '../stores/auth'; +import { useCallStore, isWebRTCAvailable } from '../stores/call'; +import { useMe } from '../hooks/useMe'; import { supabase } from '../lib/supabase'; import { UserAvatar } from '../components/UserAvatar'; import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus'; @@ -95,6 +97,7 @@ export default function DmScreen() { const styles = makeStyles(colors); const queryClient = useQueryClient(); const myUserId = useAuthStore((s) => s.user?.id); + const { me } = useMe(); const { userId } = useLocalSearchParams<{ userId: string }>(); @@ -331,10 +334,20 @@ export default function DmScreen() { const canCall = canCallData?.canCall ?? false; function startCall() { - // TODO(phase1): echte Call-Engine (WebRTC + coturn + Signaling). Bis der - // TURN-Server steht + ein Dev-Build mit react-native-webrtc existiert, hier - // nur ein ehrlicher Hinweis statt eines toten Buttons. - Alert.alert(t('chat.call'), t('chat.call_coming_soon')); + if (!userId || !partner) return; + // Native WebRTC fehlt im aktuellen Build → ehrlicher Hinweis statt Crash. + if (!isWebRTCAvailable()) { + 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() { @@ -421,9 +434,11 @@ export default function DmScreen() { setReplyTo(null); setSending(true); sendStopTyping(); - // Fokus halten: das Leeren des Inputs tauscht Send→Mic-Button und kann den - // Fokus verlieren. Re-assert nach dem Re-Render → Tastatur bleibt offen. - requestAnimationFrame(() => inputRef.current?.focus()); + // Fokus 1× re-assertieren reicht — die mehrfach-focus-Aufrufe waren cargo-cult. + // Wichtiger: Send-Button bleibt mounted solange `sending` true ist (siehe + // Render-Bedingung unten), dadurch fällt das Touch-Target nicht weg und + // die Tastatur bleibt stehen. + inputRef.current?.focus(); try { 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 // (Fokus bleibt am Input), bis der User woanders hin tippt. 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) ? ( - {sending || uploading ? ( - - ) : ( - - )} + ) : ( pZ@R5;jSC%#PENms*9cLQFZk9fL8#p90TCs6B3hAQqwV>I?cw( zeU?w)oUrJ5NohF+rAw;nnpgA;jIWx*t!%G5I=kHT^!B@bCnzK=A}TgMG5JAy=Hr~a zLS*Ta%4g4O8=6opuRGp$_4E&pjKBZz@ypEo(#ra`@7sF^$KUzrhqONWA%!m-4}QM5 zaF_m9F2{gZq;!7%_W$qe5x70KZhR<7&J#9vePF zo|)+R!se~U-L=~kxFDS5kdJ#5xMCr*UxOmF19@k6WcE)V+$VU~Iwh}2f-}3uW_@o* zW(H;#XXp9@SCPkI(mIR@+-5KJX;$#HEPUtGC9me_3wUSAf}1%&dK7E5&|+e?T0r>~ z>LWXjsuG!v?)n}X%^GwL(fdf5?ma0H9KwWu;X1>oV%~9-83n@?c}pd{b^KS2*AQ$J zUxIsrn?uJK3J+U=$jh?u!4@VSN}|I9iHpIs6{Pe$b(h^{$$ZAI6;~%+CdA{Vv=XpV zRxeOqJv@Bd@jz2o^-Rt{&EetUzA0atpdBxlO(Zujm&G?j=xJ0dlu-UIR5)lBMux8k z%fg$LY^2W=fwYo9&4MPNR)@Rpt(0KuvXujtb%3z_@kiTzNKep5s5WW=DumL4aseNq z?DE&3Ab=1LVEQx?L+GMn7m+{avCQ4-h}6;c6jbXBFOYP$*{w#W(YpFy-ySWW7811g zZ0zQ;LXK|QrNx#g$!I$^GCG8poSC(1sGu6Y^JGU#vQZ%Y>@8tFS_em+$QkOdWi5}3 zQ*oQ~tX2ctpCNbvI{<+b2I?TT!+R>M5;r|EIiXBMh@ zbv_N0gjBgAzcyh0GHqvp7?6a6fjBs05I0;7oCL3m`06_19%e;w_cS~JGta?AA%0D(z?5@?g$RJWuSKi`udd2 z)}D7imitzWHkTeO=%}JZuje0ryc?R4R+}N|oBm{{q*}Gn>7K=h8Cg35^rjp|Km9c? zqrhu&%^Lpt=KbqB?N!afMlAcLHPxm{TTHA>lHt}jp*Sc33ZN1WfHdIr!9#FeqAB~@ zeJWh*h8wd6P6xSBqSR!2$D6#uqQ#6^)hC$+^XE!RkiL;k zi%tFq(;VFXf;qXh+K$UY{tgB@WEK*Az63gk&P|_IFI8t;U*T+6jJ-3B6!IMxa>-e) zFWId>_^$G)zW#n}ZJ9+jov&#_+88A8zDacWn#w({5xlr7)BqgZ4@l#F26zam0UPQT zkefyoubdaPdO<%}laoN%<5)Q=94dpQ1MP1%omVOzzdlGxBax5)q)(#B)C6#nJhbkc zy@kI0l<&;#7u2W6=?+xB=tcSkSPRZRAG^JjXYF~~<1J@Czb*4ag>|De3Xo#wBhNmQ zvcBg62?rli&a;nr3l9^&fBsJWU0Qrfn|0$0&adz6)4mEGd_Ozl4a0BZpLx*+r$U&) zDS?!uSto%DgVo?s;Zrx>+Ku?#_Oyw2fkSREGo{`lWC*8D52eeaS*(%mV_{S)h#3hu zT^_DVDI&%E#-BsFjiRskPPD$mN^H%G`edqti60d#9dGQQs_!ZTHZDKmunMcB%S+d% z8h5F2|jm$T}8c{|TytFedd+4Ar%vMQ~Po)4a@9dBKE>@^Gkqo5?Zw6yg?@rUn% zLPS~WI)+WtB1FlGgC=od2w%XM=2$sOFjd#kLD^m|5ueP5SNlmlpb#MIh&URT6IT?s zb^8J2MVSrzCB}!P?Wv);8e2X{Vs`Yhr=;=BMP9})Ip4iFVN@L&R_W@3h3Osne4$>8 zaol$WCrY2WBYhG|>|D)1t=go-o|7^%s)_g!f@Z)EF9M zD9!mGD^2?bp4@(1Fz5Bsp`@mPHrjgr%mn4>(M^xG>b2b=ARC0>0#p%N06QWHPz@3W zv4>p1kLZ}xuZxfeUyqZw_oIycN4a3Fk$v!!xq_gD$5WuAUCvChRgq!{9pFSO=2OSm z>u_z%&fZ>0NmV0L(#zX+c>YO+Dsp+U&fsy&yxx44uAOTZl@+8k(5ubVG6uh+GKQ(2 zvW%>9U2lrBiJ3FsXy1$YR26|xbwLiOHQZa!nm0zx;)z3&o*Vb-jG zLq>*czNfCpLeZwOf@6o;MEYP5Kr5cN~WJOP_MIu9)v}~^H_qA*S!hIyKLGlEtxBwvo5&TT* zX;dlG;2bx@Re3`O>MYibX_d<#ql7FeSh(U22)I84-g;T?6d=|8#%bxmx#-xT&Lpkj+GMt6SwX|-p#25y?UIbupi!&%FuW{icIrMOQvMx_4|@mrEbk4II^n9VN~VWJQP616$pX6Isk&h>F++W zT2^Rsl2VN%Yhz_hwV|R=BI69R8ms=EzdKeP*eJfNQu$RnBd5Z^WysOXVhdT>>wd8N zvU~gvi_xKt+2ar^{rl4gXJ;qyn`Ox(i zh!`{_gkcfN(2^ruf>e_@yxgLih(iWa4C17Kbh^niRqHUByeTAx(7(S?ypa-XbroLp z_IAxZBMFa7XLQRtNJ(X5*d`Y;JotpFXxUC{tyXk8!Fl7Z5qOu+yLGeC(D02mSCz|9~FAW`9&`6+{Hp@wDi z(htt7xn-+*XXWlJ6>CK&7d|;CBYm=_-jHj0>G@|1tv7G*#KEhcd?4XY zKFd~yW^ONqwm!H+NSwn8-<+j%&{Mb+V2AqvERSlA#G$bvujp-cOmrPbray0Llo~b+ z8?1I6>N36dVe{6LVt=Q@?Qz3_W94FkN#3)9Ne_cT*+=AFhQm+UzaYu@gx--()N4F2 z*UR(V&<~t&h$2k8P?1af&|e`0LT1uBR5}`7i(ecx1Ok{*C`cTLHYnmY!6dX!BYt;f zrKVyqrzn-iD}Y=(*iQeRPmzBIQ&AbWKdm)p^}#;@Lhnb#l3 zz)+oI8oCT0xgJPoIgmRIc^)hGJeY)L7SeSmSfTQ0t-kEJ+>$4K>1J$iCAZ)Zm2btK znYph1ppvG(u2TDs@<6LJm$_P_Ha+1@TC*`W_aR|Zj`9$kPn+8diT%|Lr+djolZM51 zW<_tmJI`s!8jIv#{InD0@DZ6~xwkz&Pt4F!J$|S|BX-+LQtvZ4zZh=8>qx32^jv^3 z$^gWLdIp|*HB0?&Zx@WdzOq@JHDY>6 zmO7_6I~CofUZY7`B^o92l*MCG=2{`qcQ2-I3GcHh{kJ<+Sfrz#vr*-5y%Fsm{nD!Y z{Xy4Zh&oZBuxURhXJ-HEBO?BqxYwU1)^T_2MysRV%&gS2Bvt`&97F)%DFCKKh~r8i z@(3-1zH|ANS?2S;udGW^c@_OREj+t80`W0|ov_%FPeVJ+3xBKhoLHW6pZMlmfw5+d zLlNC51HCJ~Mq6^%=BZDiNlmNMeSge2`lM&Fy){zZ9pOsdLGC_}G>K^N`_j?4AhtKe zDk61e9W6ip*yYedR*+86u`Q_Oi^ZV|Uf_oL$Z#)=rQt)BE3A?ePy!lRNa=IP`}NbdDM4Sh=NON`sXH@}+`Eg^Dc3{pen5%TSb;Z;%8~=`EcD z8`<=303~UDOOja_~3zX4Em*r=~sJG53{|FPaEiDH$hgBG+a7+?mK=hqW%^x z20gK7HDeo@OJEiWbqN=*vhQgh3P0LA_oLhM$24TTpnaSBbKuO6l}JDH{<|Qmo};7H z(LFpq;`-6G8%`b61D7WdwoC0V@Y8aL)RLf9gb<3KnDHM2z`-~B9a5LqqmynHl z3*l*hlMc}j6Gmru44_m9btpXw4&_8-LN6*@h9YsdWS?x`gMA40g4&^;LJ-mMB%W7= z9eykqp{7Wr`8IGGGRz}xG^ixGNDtg)F?g%Nl+T6w=A`^^pd>l}{_f3Q^4G#X8WI-5 zR++^bHavdD(|Y7j+6LD*O)FZKXSxdPAO}+oCWb>43@B&^@kG(D7F2NRjAM*;Y6MLIchT7{a9x2)GA`+mb&N z<+pYr&-zNRD7+3Zbo*j=IswjitlV-i1;ruw-CABVF)w`jWi%Y0XUy|Hf4#2mH@VI9 zynF}y+ur*2UYeDkX9Mb(HoUC&?9H8Dw~O4Q+eE(*b&&Q5xJl|juEo(bRM9qd$UAm3 z@3o$n_WEc_BfCIuE04|VHxQVJzvf-Z%sP)TLVPN2gQ#{nAOgIDRc1Fw7wHI7hrgJis zXk)~)K9h1clDUJO!(xW7x_7e7KuS0*Moxv#U`W^adqzVEEaHc1bo8?ZNK#tnYCso9 zV|ci(%%gkBl$RGeymE+Hn03?2?qpO&ndS2MpAOZmk)*Y#ne&gaODLabxF+;QSoWzr z1t<H~J!Cjt*XKtp_-?gFk*8pI8H$Snuv<^_;?`qQ>We;FR4I z6{qGgg!xd%_**8_o-(*bHK-foozl2ryjy(WJ;`oIZ_#Vp+ow~$yHj^Lf1leg@AWuDT_+8KB7ZEe0)Pws&nS^Y!{h((?62j}L?EQm8kPwv{(`xB>blPMV2HoY6=W|8?Ak(^P4W&znf^v*&3-5R^;+B#Uc8l>;o7| zXKyED_HT5Qy*$wC%vLl1*fjk<`@`bw!J7xObB!;xt8d$^RBbdAzSF#Xs#t`Dl^r1p zN<&bAr=Udfac20+A<(md&QSb!(70peJ_S=`t^?B2&c|Zh70S*7{pYGB;RmPVOC+Vu3JLrbSzj4S7>~J zL2Nx@3>6}(bP|gikkWm%N9S*02~zadq?r647;fTtyeB3KGA0?rBv&i z_xjmx?t^d!}%jd$iled9fr{r@Ol;!?CbzSUs=5s=7Pp^lV9LYmDf$T7Gb3DY`r+ zO+?0nG^fDFVhCA3hjiAnvkR!+m{+V(-P9h8Rq-*r`qF8&vg$s$L3oy)#L=0>@94yT z6A=fD2JwLvLb!(M*XoyGt>{A>@-?OA!lPTc1Df8tpcR09h{rM+@&IAU zVxrY$^}QOh%J(SAsJwjIgtrSeYOZ_cx@m`>0QaH*nD#)W%!ofg=IA_`}%y_ zGKjbLFtW`bRRNXO_?mD?&vJ8m>Ww3TkC8vW!lmK>E35B4lmr`s7bO|#eQL5-^nvJttOyQ zUAcmin6tS>`Agq)w&J;`-RfJ<<(_|R8BAbFbbah^+?@`08j$0JN2#kG)-5;L%O-3y zrtNXk^wDVFs)L!Kr*}Ujh?|^}xHR8=$On5IuifNMcgJjQ{L%bf00Gbd)IcgY7vzaw zg##hag>cFAKeKw1NYBMajdz_SS2r77a8($_({9@znWm>}!Q*~;ByIiP{allxt(-?1 zNL!eyo!B5vr~l;eoHdn=aYC-J)MF}lDHDO1NdAYjwzUWnnPkSvHqY$Q!A@^4cPW-d zJ6)uXbKX7CU0$NmjH?=66-z5S#eAC_?0Dh3bqVt@Rr6jB*A5F>PR~9R&m4fFAk+X& z(MIFBiJ-}+x??>2u9KVt9R7KG_!>;@l>s_8R}d`HI7PTm(nHk4t!Z)vO%&aF<1OTV zTv}i^vAuWoU~*5Howr{Ezi>@W-J%gYOPNBfkG<73BinwHXGrcCyLf&>c|jk8mfqDZ z6GLa!GQn??BA5MEuClD!*gH3@DbQXRCrg>4yB}IDnD#~70j@R(1ta*+fQ(V3;3Skh zUMi}WHnd-8D`{2=w-e__TLMYz+WXfi z#}&QFPpb<*##ax+${WrY`8w8qDyz*acjh@0R#+5Oz%^Qy^YT-K=?`MB4=F`N zZVxnSJ15xv(@GObZ=tar$lHgfXjpPM?xqE zB~t2v#hg!q@-({<1(m~TfLb^caID-~F!l7x-jlV0NSX?`>QQ%zSqZl`j+z~0kxP@B zA=Nm;FgiS{gB-H+vF3A>V!HQ!JSV>k)>Xx~!yJ&3sKXb#IM)5v+N6j_5uG}Re&r-s z=4G26hJ5r|BfEUSRwl;5LEP}s!~SmM1oNtEnvEAMW%Zr6ra8f*1Vme17=yX>_9Dj^x<>&i0^X=#(KxU%5{>(thGQK~Gq z;V@zS<5GQ|HJu5Pg>Ugg>Pn?e6Gyvl=i1)8u{>Sbr1cNdtBW@R1k3z4)1Gt!CX5Iu z!1I(8l_g3-N&!K0$P1JG=yQ#>lxL2n?V;3CxbT)bT1rPo4>EE>Vb7_B+QnKV}=xWYf3LT}q?NpU7@+SV$9!@HVQQ5YrFspNf<6n&6Fpl-32$;NHYjO{Olhh(U}nuP`EnzU z9}_a_N&*18X>d{&iV#pjaRXB*Yp}*EX2S5%CvK1ydE60H1E_V7k|Z^&;}ZIM)?Cyn ze**SZ8zslOD8tFR(X`e2?$(CtM?W7HLTr<}!}=RV)h`%yJt5R=dS_FkI1z2&y^=v0 zucaiT$yZ-h@x?0_DVvQ;PO3b{Z|f{#DnNT{c9+)I7}O6?;D!KbLJ`mpnq0hx+KG2s zl0+z^c{Xt7$Q6}x&`9-Tp@;S$UIvcacn$rSIq7&*N_X&+HC>61`%! zlTsO_H%Te9 z&xO}j)fz86UYO=fkPKZ<81oe856HT!bQ%B2>|3*N(h?BPeG8;=;T4G z@QwRM<_+FeB_e0+Bqha5_s2C|l4Nd7u-@WbvfABFwca1JzO&(KuMZ_a;c|lklpi3C zS^@Di-^9DI*(YDxHVfW+6$c7m7}eOk-IhYaf*!awrZh&%Np4!48I}`k-Tlt2)I+NC z-bI#2cjE@dh)VmbuG;e>n>J0E2UN+v!o(%ibUHMz$D&rs9taye3|%RGzcO3$M5Uyp zF>_$r&`#yjNQ#SMK4i$9F8|hzxk5U>0{z~e=Eo`&xAs(29x8b^Ur)T8lA6VH)F?rK zI6)e?RX8KGA*PMEEyUN4m0Jv^`uIHhm4$XNp{~rKXuP{)`BsGq@kHN*R!L1ngP`Y9c;g$=+~>8DE{{8^8>19@S?WD5 z%<;;i>icUB2q{d0q6VM;Xe35(z^Q;x__-hsxE(m!k|LtdC&cZmHU3>D_;b*nQ!-(i zCbt<6qeh#zo+-ZKC-yHK2NLa{$$}n^eQsLsTNDo$7Q34<@4UdmVzRm~z zuy?AAMv8P#Y8>ASf4k`R+>?#0N%u}eda{eNdWXz=uzfuI2?;+IyAx=f(QOKdm<#$M} z$F*bo0;5ZWG|U*HC5itzJ}d=O^N#Et_0S`_inDo-&Ruf&Xi0(?21qjVRD;xgA0^Y& zgvvAnhog0$ZE%+o`9)7MgEN!MW54s9S1$9_M~$b9B+N6_Df)-9L`qB^o9b|P7iQ&1&4l|-+}6TA>2uXfHm zk}6*^fD$7lpd|8!P)Z;T%JxbHY8<+Let(+<1_b*2Cr4hKZzk+_uc4__QQ zqcDpCt5ln3UkAP34I3Dh-)L-HO4$rRKl6CWa_G69zfH@G)*e5kWj)Ui8hDS~8Vr3P zHsBu5KkU3=)owu)ANBIH_IzFZYhc}OgwAZkrfSWb$%8j zp1mUzOv1SVB|(Wo$q}kh2GlL+IS>K#wh}~gqI?ZzsjvrCMZ^oILU_)?uFthAdn7RE zs__ZLTOW!GAMn^aTebr`UJE8OY8un8e$4hcVCucF+~=EKlzH`T{-*Mjo?p|#7J=jEmj?@cgEI?yld^?T4J)Lz5;dO- zUF`N7wqM3ByYRWBtQ@8mz4Yu%7mOspQdGK2Rc-~JE$v<;Tt}h;|hQ5 zHiu5MS_BRW#ElGntlVKRb^g*npZdyZe4KpL&;(OZT|{va0c}SQZO7I%s>6i8 z+LyGT`|kM1*YEW0+)b`K?c>GKzxu7e#X<^*On}HD8gWW0s7JTy#jLNmi<7@Vw7Zw5Vii z5gUs}mv)4h#E08z+j#3MU*xPzTAqvz?=qo~v{zPAZ_X6|Za22&Bg{S+-o5a?!d}xL zc|aL*ON16u)4LQaAZ}^Lz%84>mKqMj`4r_?ATZdXVi>320L4QPLCH`eQ1+v)wAn?O z3%pFqFd~JsP$`6)_%XQEU~0{gy>rX>kqV{P246;7KHCsHFAQ#>my+ zz3Rc&Z<^kvo0H2B#xKPu5c1B8uYTNvud_zEud8=qtGc$eU_8sz^^U$lO%X@1hg)59 zp$q5{G@gjF`ktwL=!fbJiKqHEXn)3_g1_z^s_d z%WG8}TDmlE!c*fLEHcp>nV(zj4pY}PA5~Qyu=Z=f%N~^y3nsNwr2vSaM<1ub2lFB> z<}(G{qNjGrTGBRKI$$}J0hXN1gm=M z^vrp_Ya*u`*y$HK-&R&Ko_gu(xKfmF&o^ICS~co>&w>6uRr#EhIDNFgy){9#fuLsL zS`;C<$naIh#meq!C>}}}3LvhsgR@W|ycZ}*I-o6n|4Ntoh1SGxt=S{3@HkxaPk&8H-Nqqm2e*NM6dMEpH&HHuS%&z;lTk3fpmNOHj|xcyP;emKjrD*vhB z+s*Ge2k(ifKWjI(%a_W?{Nx#1SYn|Umj0=5wVZo50Ajk3A~_<=s6qo9#O;9I zMZ`gAK(f%lpf#ujA|7gvxCDo@0b{qXC9^D_)hlJBbbmg$tgZ1h@+-3U=ERFG<&ln$ zR=R-;jyuuW8!^crxg2xMpLj{KNr*n7=T*cBdgC6?;zcV|A=vp%2kHLm_MvE+*DjDC?&bkNO#M2)E*oeeVvjG4o7@%aN zOpTA3I(lDqZ{5M^D?Zxfj(Q}{GXH$Em4UrDE#$H}gJ4i`F~XRm$a5xD3d|ma!e;;l zQC%~F^5G=nJ>`e;gR&WLVw-P(Wr;>I0Mug+JHY!2hzFmIJ-Ca$E%f66s@v_<^;23r z^0n+NBqMN|J>nN6YUBU`lqghAN1yHmFT2&AmK8n2$J3+=V~QH8eF;%R9~dk7RO2sl zDUkH%MDhtACbXJ!+)fo0VmaIAL1X+zmGcnYcb4QneV1mBteGiaei`lIVd;8C#zRpB zYOi`iCayx|8zoPUJ|KON&Rg^YPK^@|!O7BYwhez~_2W{jhRBUsWJ}_ z1$>qK;K)~c`S&3q)xUmNgTL){tXxh=mxlY0c?xj!5=8(2?*F_0CHJqTIabaY2Mg|B1N#-kD)$QoW(8I`%y|7yW0m{O0;3A697ecKgt5wTldLhR|6X#~wTg)cC$s)R_1_B)d(_83gFm9!UG5JY z7!0tx90ssXMzPABq=89|RSpwcC$d=OPQbvZ#43jouHRv-a=%$%R$-OHjMuL;R=Hm& zFf6djVF>JJ9IM<<5}029Tjj7veN5-$Xa4Uchh3{b=NQxLf4f$(4NXkv<7fWwU8~rJ z=Fd6C^orf(FeUagkKN^dlECnaRSrX7zv5Wsexbmuz$%9suit5`a=%$%RAH6F2-k@) zR=E=}Fe$OhVM6O<7OUJz8W_}AU;SzuIQmBR?vi7-~V6EH9- zvC3gW>tq(I+({Z3)L7*(fb~ZdtK1(r{`~-}+`nh_XY#*Q4tv!9llH$K{k`O{YxUn- z@MrSBU8~rJ=AX3x{pjyqtJsF-zqjDeBzBkklgGawVRyNI&+3mPR=GcLU@*WchXJgU zQLJ(&X<$-gmBWPAi7ZyR6EH9;vC3hD>vtHd+;0|`RaoUP znDP3Z#wz!l1x6KCIgD_f2xFBy0Rxi~s~je@PG+&nouq+5ja3c}K8vGIcZ`UfeyYvTa3@J4^tdmi!awlnE zQe&0Fgw}~HR=E=}FeI>`@=n`S_Xtd&yzf>d!gG^!nefRcu2O)A{(B|9jUewxRiRjxoJrcR5Un{mf%` zxt}C3ykeEZ5ZJFcR=Hm&Fe|XiVaDrs8mruI78q4n + + + + + + {label} + {time} + + + + ); +} + +export function ChatBubble(props: Props) { + // Call-Notiz (System-Row, kein Bubble) — eigenes Render-Path, ohne Hooks-Aufwand. + if (props.msg.attachmentType === 'call') { + return ; + } + return ; +} + +function ChatBubbleInner({ msg, showName = false, isFirstInGroup = true, diff --git a/apps/rebreak-native/components/devices/MagicSheet.tsx b/apps/rebreak-native/components/devices/MagicSheet.tsx index a7dae5a..3e0bd1c 100644 --- a/apps/rebreak-native/components/devices/MagicSheet.tsx +++ b/apps/rebreak-native/components/devices/MagicSheet.tsx @@ -254,8 +254,8 @@ export function MagicSheet({ )} - {/* Verbundene Macs */} - + {/* Verbundene Ger\u00e4te */} + {devices === null ? ( diff --git a/apps/rebreak-native/dev.sh b/apps/rebreak-native/dev.sh index 0102a6b..6e2c55e 100755 --- a/apps/rebreak-native/dev.sh +++ b/apps/rebreak-native/dev.sh @@ -7,6 +7,8 @@ # ./dev.sh default: ios --device (physisches iPhone USB + Build) # ./dev.sh ios iOS Dev (Default: USB-Device mit Build) # ./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 clean iOS: Nuclear clean (Pods, DerivedData, Archives) # ./dev.sh install ios Build Release + Install auf iPhone USB @@ -27,6 +29,11 @@ # --no-launch Build+Install, aber kein Auto-Launch # --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): # --keep Cache behalten (kein --clear) # @@ -257,6 +264,182 @@ cmd_android() { 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() { local CLEAR_FLAG="--clear" @@ -518,6 +701,10 @@ case "$COMMAND" in cmd_android "$@" ;; + mobile) + cmd_mobile "$@" + ;; + metro) cmd_metro "$@" ;; @@ -555,6 +742,7 @@ case "$COMMAND" in echo "Verfügbare Commands:" echo " ios iOS Dev (Metro + Xcode/Simulator/Device)" echo " android Android Dev (Metro + Gradle + Install)" + echo " mobile Auto-detect iPhone+Android via USB, parallel-Build" echo " metro Nur Metro starten" echo " clean iOS Nuclear Clean" echo " install ios Release-Build auf iPhone installieren" diff --git a/apps/rebreak-native/hooks/useCallKeepEvents.ts b/apps/rebreak-native/hooks/useCallKeepEvents.ts new file mode 100644 index 0000000..d0e3449 --- /dev/null +++ b/apps/rebreak-native/hooks/useCallKeepEvents.ts @@ -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]); +} diff --git a/apps/rebreak-native/hooks/useIncomingCalls.ts b/apps/rebreak-native/hooks/useIncomingCalls.ts new file mode 100644 index 0000000..5a4af92 --- /dev/null +++ b/apps/rebreak-native/hooks/useIncomingCalls.ts @@ -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:` 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]); +} diff --git a/apps/rebreak-native/hooks/usePushTokenRegistration.ts b/apps/rebreak-native/hooks/usePushTokenRegistration.ts index 3f3a4d4..a06e9b8 100644 --- a/apps/rebreak-native/hooks/usePushTokenRegistration.ts +++ b/apps/rebreak-native/hooks/usePushTokenRegistration.ts @@ -41,7 +41,7 @@ export async function registerPushTokenWithBackend(): Promise { return null; } - // 2) Android-Channel (muss vor getExpoPushTokenAsync existieren) + // 2) Android-Channels (muss vor getExpoPushTokenAsync existieren) if (Platform.OS === 'android') { await Notifications.setNotificationChannelAsync('chat', { name: 'Chat-Nachrichten', @@ -50,6 +50,16 @@ export async function registerPushTokenWithBackend(): Promise { lightColor: '#007AFF', 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 diff --git a/apps/rebreak-native/lib/callkit.ts b/apps/rebreak-native/lib/callkit.ts new file mode 100644 index 0000000..ed691db --- /dev/null +++ b/apps/rebreak-native/lib/callkit.ts @@ -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 { + 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: "-", 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 }; diff --git a/apps/rebreak-native/lib/ringback.ts b/apps/rebreak-native/lib/ringback.ts new file mode 100644 index 0000000..5180bd7 --- /dev/null +++ b/apps/rebreak-native/lib/ringback.ts @@ -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 { + 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 { + if (!sound) return; + const s = sound; + sound = null; + try { await s.stopAsync(); } catch {} + try { await s.unloadAsync(); } catch {} +} diff --git a/apps/rebreak-native/lib/supabase.ts b/apps/rebreak-native/lib/supabase.ts index 16ba16b..576cc5e 100644 --- a/apps/rebreak-native/lib/supabase.ts +++ b/apps/rebreak-native/lib/supabase.ts @@ -21,6 +21,15 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, { detectSessionInUrl: false, }, 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: { apikey: supabaseAnonKey, }, diff --git a/apps/rebreak-native/locales/ar.json b/apps/rebreak-native/locales/ar.json index e7e9867..c1674f2 100644 --- a/apps/rebreak-native/locales/ar.json +++ b/apps/rebreak-native/locales/ar.json @@ -999,6 +999,11 @@ "you": "أنت: ", "just_now": "الآن", "voice_message": "رسالة صوتية", + "call_audio": "مكالمة صوتية", + "call_missed": "مكالمة فائتة", + "call_no_answer": "لا يوجد رد", + "call_declined": "تم رفض المكالمة", + "call_failed": "فشلت المكالمة", "photo": "صورة", "media_sent": "وسائط", "new_conversation": "محادثة جديدة", @@ -1398,6 +1403,24 @@ "crisis_emergency_cta": "112 — الطوارئ", "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": { "online": "متصل", "typing": "يكتب", diff --git a/apps/rebreak-native/locales/de.json b/apps/rebreak-native/locales/de.json index cac572c..781e0da 100644 --- a/apps/rebreak-native/locales/de.json +++ b/apps/rebreak-native/locales/de.json @@ -1070,6 +1070,11 @@ "you": "Du: ", "just_now": "gerade", "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", "media_sent": "Medien", "new_conversation": "Neue Unterhaltung", @@ -1470,6 +1475,24 @@ "crisis_emergency_cta": "112 — Notruf", "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": { "online": "Online", "typing": "schreibt", diff --git a/apps/rebreak-native/locales/en.json b/apps/rebreak-native/locales/en.json index 83870b6..42b1457 100644 --- a/apps/rebreak-native/locales/en.json +++ b/apps/rebreak-native/locales/en.json @@ -1068,6 +1068,11 @@ "you": "You: ", "just_now": "just now", "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", "media_sent": "Media", "new_conversation": "New conversation", @@ -1468,6 +1473,24 @@ "crisis_emergency_cta": "112 — Emergency", "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": { "online": "Online", "typing": "typing", diff --git a/apps/rebreak-native/locales/fr.json b/apps/rebreak-native/locales/fr.json index 902a0a9..8f26e6f 100644 --- a/apps/rebreak-native/locales/fr.json +++ b/apps/rebreak-native/locales/fr.json @@ -988,6 +988,11 @@ "you": "Vous : ", "just_now": "à l'instant", "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", "media_sent": "Média", "new_conversation": "Nouvelle conversation", @@ -1384,6 +1389,24 @@ "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." }, + "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": { "online": "En ligne", "typing": "écrit", diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist index ca2b9a1..4569c31 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakContentFilter/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 70 + 73 NSExtension NSExtensionPointIdentifier diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist index 9828da0..45976eb 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakPacketTunnelExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 70 + 73 NSExtension NSExtensionPointIdentifier diff --git a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist index f4b8478..3a525a4 100644 --- a/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist +++ b/apps/rebreak-native/modules/rebreak-protection/ios/RebreakURLFilterExtension/Info.plist @@ -19,7 +19,7 @@ CFBundleShortVersionString 0.3.13 CFBundleVersion - 70 + 73 EXAppExtensionAttributes EXExtensionPointIdentifier diff --git a/apps/rebreak-native/package.json b/apps/rebreak-native/package.json index 45ebfff..b77a597 100644 --- a/apps/rebreak-native/package.json +++ b/apps/rebreak-native/package.json @@ -12,6 +12,8 @@ "typecheck": "tsc --noEmit" }, "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/metro-runtime": "~6.1.2", "@expo/react-native-action-sheet": "^4.1.1", @@ -61,7 +63,9 @@ "react-i18next": "^15.1.0", "react-native": "0.81.5", "react-native-bottom-tabs": "^1.2.0", + "react-native-callkeep": "^4.3.16", "react-native-gesture-handler": "~2.28.0", + "react-native-incall-manager": "^4.2.1", "react-native-keyboard-controller": "^1.21.7", "react-native-mmkv": "^3.1.0", "react-native-reanimated": "~4.1.7", @@ -70,6 +74,8 @@ "react-native-sse": "^1.2.1", "react-native-svg": "15.12.1", "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", "rive-react-native": "^9.0.1", "tailwindcss": "^3.4.14", diff --git a/apps/rebreak-native/plugins/with-allow-nonmodular-includes.js b/apps/rebreak-native/plugins/with-allow-nonmodular-includes.js new file mode 100644 index 0000000..7c5ca99 --- /dev/null +++ b/apps/rebreak-native/plugins/with-allow-nonmodular-includes.js @@ -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; + }, + ]); +}; diff --git a/apps/rebreak-native/stores/call.ts b/apps/rebreak-native/stores/call.ts new file mode 100644 index 0000000..cb2056a --- /dev/null +++ b/apps/rebreak-native/stores/call.ts @@ -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:` → nur die initiale Einladung/Abbruch. +// • Call-Channel `call:` → 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 | 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; + receiveIncoming: (callId: string, from: CallPeer) => void; + acceptCall: () => Promise; + 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='::'. +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((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((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((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 }); + }, + }; +}); diff --git a/apps/rebreak-native/tmp/.deploy-runtimes b/apps/rebreak-native/tmp/.deploy-runtimes index ed281b6..fd98c91 100644 --- a/apps/rebreak-native/tmp/.deploy-runtimes +++ b/apps/rebreak-native/tmp/.deploy-runtimes @@ -44,9 +44,15 @@ Building Release AAB (gradlew bundleRelease)|272 Validating IPA (App-Store Connect)|117 Uploading zu App-Store Connect (TestFlight)|138 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 Uploading zu App-Store Connect (TestFlight)|90 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 diff --git a/backend/package.json b/backend/package.json index 47b4a24..b75937a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@parse/node-apn": "^8.1.0", "@prisma/adapter-pg": "^7.2.0", "@prisma/client": "^7.2.0", "@supabase/supabase-js": "^2.39.7", diff --git a/backend/prisma/migrations/20260604_add_voip_push_token/migration.sql b/backend/prisma/migrations/20260604_add_voip_push_token/migration.sql new file mode 100644 index 0000000..cb42df7 --- /dev/null +++ b/backend/prisma/migrations/20260604_add_voip_push_token/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index dcf340f..d2963bf 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -162,6 +162,10 @@ model PushToken { platform String // "ios" | "android" deviceId String? @map("device_id") 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") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") lastUsedAt DateTime? @map("last_used_at") diff --git a/backend/server/api/calls/ring.post.ts b/backend/server/api/calls/ring.post.ts new file mode 100644 index 0000000..b64b307 --- /dev/null +++ b/backend/server/api/calls/ring.post.ts @@ -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 }; +}); diff --git a/backend/server/api/chat/dm.post.ts b/backend/server/api/chat/dm.post.ts index ff83258..ae00183 100644 --- a/backend/server/api/chat/dm.post.ts +++ b/backend/server/api/chat/dm.post.ts @@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => { attachmentName?: string; }; - if (!receiverId || (!content?.trim() && !attachmentUrl)) { + if (!receiverId || (!content?.trim() && !attachmentUrl && attachmentType !== 'call')) { throw createError({ statusCode: 400, message: "receiverId und content/Anhang erforderlich", diff --git a/backend/server/api/users/me/push-token.post.ts b/backend/server/api/users/me/push-token.post.ts index f033031..2a922a2 100644 --- a/backend/server/api/users/me/push-token.post.ts +++ b/backend/server/api/users/me/push-token.post.ts @@ -15,6 +15,9 @@ const Body = z.object({ token: z.string().min(10).max(200), // ExponentPushToken[xxx] platform: z.enum(["ios", "android"]), 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) => { @@ -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(); await db.pushToken.upsert({ @@ -38,6 +41,7 @@ export default defineEventHandler(async (event) => { token, platform, deviceId: deviceId ?? null, + voipToken: voipToken ?? null, enabled: true, lastUsedAt: new Date(), }, @@ -45,6 +49,9 @@ export default defineEventHandler(async (event) => { userId: user.id, // Token könnte das Device gewechselt haben platform, 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, lastUsedAt: new Date(), }, diff --git a/backend/server/services/push.ts b/backend/server/services/push.ts index c077690..81e5c92 100644 --- a/backend/server/services/push.ts +++ b/backend/server/services/push.ts @@ -14,6 +14,7 @@ */ import { Expo, type ExpoPushMessage } from "expo-server-sdk"; import { usePrisma } from "../utils/prisma"; +import { sendVoIPPush } from "./voip-push"; const expo = new Expo(); @@ -126,3 +127,121 @@ export function truncatePreview(text: string, max = 100): string { if (text.length <= max) return text; 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 { + 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); + } +} diff --git a/backend/server/services/voip-push.ts b/backend/server/services/voip-push.ts new file mode 100644 index 0000000..386b72c --- /dev/null +++ b/backend/server/services/voip-push.ts @@ -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 { + 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; + } +} diff --git a/ops/CALLKIT_SETUP.md b/ops/CALLKIT_SETUP.md new file mode 100644 index 0000000..2e6632e --- /dev/null +++ b/ops/CALLKIT_SETUP.md @@ -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. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2d3ee3d..491f634 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,12 @@ importers: apps/rebreak-native: 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': specifier: ^0.2.3 version: 0.2.3 @@ -267,9 +273,15 @@ importers: react-native-bottom-tabs: 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) + 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: 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) + 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: 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) @@ -294,6 +306,12 @@ importers: react-native-url-polyfill: 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)) + 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: 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) @@ -322,6 +340,9 @@ importers: backend: dependencies: + '@parse/node-apn': + specifier: ^8.1.0 + version: 8.1.0 '@prisma/adapter-pg': specifier: ^7.2.0 version: 7.8.0 @@ -957,6 +978,16 @@ packages: '@colordx/core@5.4.3': 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': resolution: {integrity: sha512-qMkBFw1OqhPuNs1tQWkRq0z0Tg49kXNu53bs59tdF4lytKupatWVnL3cpsVPqn+Q5P7A70r99BKTcm+prMtHqw==} peerDependencies: @@ -2756,6 +2787,10 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} 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': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -4385,6 +4420,10 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + assert@2.1.0: resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==} @@ -4643,6 +4682,9 @@ packages: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -4908,6 +4950,9 @@ packages: core-js-compat@3.49.0: resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==} + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -5043,6 +5088,15 @@ packages: supports-color: 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: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -5207,6 +5261,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -5455,6 +5512,10 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} 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: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -5750,6 +5811,10 @@ packages: exsolve@1.0.8: 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: resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} engines: {node: '>=8.0.0'} @@ -6479,6 +6544,16 @@ packages: resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==} 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: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -6709,12 +6784,33 @@ packages: lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: 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: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + lodash.throttle@4.1.1: resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} @@ -6999,6 +7095,9 @@ packages: ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -7946,6 +8045,11 @@ packages: react: '*' 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: resolution: {integrity: sha512-wc+JI7iUfdFBqnE18HhMTtD0q9vkhuMczToA87UdHGWwMyxdT5sCcNy+i4KInPCE855IY0Ic8kLQqecAIBWz7w==} engines: {node: '>=18'} @@ -7968,6 +8072,11 @@ packages: react: '*' 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: resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==} peerDependencies: @@ -8020,6 +8129,16 @@ packages: peerDependencies: 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: resolution: {integrity: sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==} peerDependencies: @@ -9184,6 +9303,10 @@ packages: 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 + verror@1.10.1: + resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} + engines: {node: '>=0.6.0'} + vite-dev-rpc@1.1.0: resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==} peerDependencies: @@ -10361,6 +10484,14 @@ snapshots: '@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))': dependencies: birpc: 4.0.0 @@ -12470,6 +12601,15 @@ snapshots: '@parcel/watcher-win32-ia32': 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': {} '@pkgjs/parseargs@0.11.0': @@ -14343,6 +14483,8 @@ snapshots: asap@2.0.6: {} + assert-plus@1.0.0: {} + assert@2.1.0: dependencies: call-bind: 1.0.9 @@ -14645,6 +14787,8 @@ snapshots: buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} buffer@5.7.1: @@ -14964,6 +15108,8 @@ snapshots: dependencies: browserslist: 4.28.2 + core-util-is@1.0.2: {} + core-util-is@1.0.3: {} crc-32@1.2.2: {} @@ -15100,6 +15246,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.4: + dependencies: + ms: 2.1.2 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -15235,6 +15385,10 @@ snapshots: eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} effect@3.20.0: @@ -15559,6 +15713,8 @@ snapshots: event-target-shim@5.0.1: {} + event-target-shim@6.0.2: {} + events-universal@1.0.1: dependencies: bare-events: 2.8.2 @@ -15901,6 +16057,8 @@ snapshots: exsolve@1.0.8: {} + extsprintf@1.4.1: {} + fast-check@3.23.2: dependencies: pure-rand: 6.1.0 @@ -16739,7 +16897,31 @@ snapshots: acorn: 8.16.0 eslint-visitor-keys: 3.4.3 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: dependencies: @@ -16942,10 +17124,24 @@ snapshots: lodash.defaults@4.2.0: {} + lodash.includes@4.3.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.once@4.1.1: {} + lodash.throttle@4.1.1: {} lodash.uniq@4.5.0: {} @@ -17320,6 +17516,8 @@ snapshots: ms@2.0.0: {} + ms@2.1.2: {} + ms@2.1.3: {} muggle-string@0.4.1: {} @@ -17589,7 +17787,7 @@ snapshots: node-abi@3.92.0: dependencies: - semver: 7.7.4 + semver: 7.8.1 optional: true node-addon-api@6.1.0: @@ -17633,7 +17831,7 @@ snapshots: dependencies: hosted-git-info: 7.0.2 proc-log: 4.2.0 - semver: 7.7.4 + semver: 7.8.1 validate-npm-package-name: 5.0.1 npm-run-path@5.3.0: @@ -18676,6 +18874,10 @@ snapshots: sf-symbols-typescript: 2.2.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)): dependencies: '@babel/helper-module-imports': 7.28.6 @@ -18702,6 +18904,10 @@ snapshots: 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): dependencies: 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) 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): dependencies: '@babel/core': 7.29.0 @@ -20061,6 +20280,12 @@ snapshots: - '@types/react' - '@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)): dependencies: birpc: 2.9.0