feat(calls): CallKit/ConnectionService + VoIP-PushKit + EU-Ringback
Caller/Callee UX: - lib/ringback.ts + assets/sounds/ringback_eu.mp3 (EU 425Hz Festnetz-Tone) - stores/call.ts: stopRingback bei connected, hangup-reasons, logCallToChat fix - locales: 'Wird angerufen…' statt 'Ruft an…' CallKit (iOS) + ConnectionService (Android): - lib/callkit.ts: setupCallKeep, displayIncomingCall, startOutgoingCall, reportConnected/Ended (appName 'ReBreak-Audio', includesCallsInRecents=false für DSGVO/DiGA) - hooks/useCallKeepEvents.ts: native answer/end/mute → useCallStore-Actions - stores/call.ts: CallKit-Aufrufe an allen lifecycle-Punkten - app.config.ts: @config-plugins/react-native-callkeep + UIBackgroundModes voip/audio + Android-Telecom-Perms VoIP-PushKit Backend: - services/voip-push.ts: @parse/node-apn Provider mit .p12 (Topic org.rebreak.app.voip) - services/push.ts sendCallRingPush: feuert beide Pfade (VoIP iOS + Expo Android/Fallback) - prisma: push_tokens.voip_token Column + Migration 20260604 - api/users/me/push-token: optional voipToken im Body - Env (Infisical): APNS_VOIP_P12_PATH/PASSWORD/TOPIC/PRODUCTION Push-tap routing + cold-start handling: - app/_layout.tsx: type:'call' Push → useCallStore.receiveIncoming + /call Docs: ops/CALLKIT_SETUP.md (Apple-Portal-Steps für VoIP-Cert)
This commit is contained in:
parent
0cac3c9d1a
commit
822053e11e
@ -1,6 +1,25 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to rebreak-native will be documented in this file.
|
All notable changes to rebreak-native will be documented in this file.
|
||||||
|
## v0.3.13 (Build 71 / versionCode 54) — 2026-06-04\n\n### Fixes
|
||||||
|
|
||||||
|
- Voice calls: in-call speaker button now actually routes audio to the phone speaker (instead of being a no-op). The route is re-applied after WebRTC has set up its audio session, so it survives the call-connect transition
|
||||||
|
- DM screen: keyboard really stays open now when tapping the send button — the previous fix only handled the keyboard-Enter case. Send button stays mounted while the message is being uploaded so the touch target doesn't disappear underneath your finger
|
||||||
|
- DM screen: tuned the gap above the input bar so the last message sits at a comfortable middle distance (not too tight, not floating too high)
|
||||||
|
- DM info sheet: the partner avatar now renders correctly for users with a default/list avatar (not just custom photo uploads), using the same avatar component as the header. The chevron now sits inline right next to the name
|
||||||
|
- DM info sheet: tapping a shared image now opens the same full-screen viewer as in the chat (rounded corners + save button) instead of doing nothing behind the sheet
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Voice calls now show up in the chat: after every call the conversation bubbles up to the top of the chat list with "📞 Audio call" as the last-message preview, and an inline call-note row appears in the chat thread itself (Instagram-style — with duration if the call connected, or "Missed call" / "Call declined" / "No answer" if it didn't)
|
||||||
|
- Voice calls: new speaker button in the call screen between Mute and Hangup (volume-medium icon → volume-high when active). Works on both iOS (AVAudioSession route override) and Android (AudioManager speakerphone)
|
||||||
|
- Mobile dev tooling: new `./dev.sh mobile` command auto-detects connected iPhone (USB) + Android (ADB) and builds + launches dev clients on both in parallel, sharing a single Metro bundler
|
||||||
|
- DM image viewer is now a swipeable gallery — tapping any photo (in the chat or the info sheet) opens a carousel of all images shared in that conversation, starting at the one you tapped. Swipe left/right to browse, with a position counter (e.g. 2 / 6); the save button always targets the current image
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
- DM chat background is now always the clean solid style (white in light mode, black in dark) — removed the per-chat background picker again for simplicity
|
||||||
|
- DM voice notes restyled to Instagram-style waveforms: incoming notes have black bars on a light grey bubble, your own notes have white bars on a mint-green bubble. While playing, the upcoming part dims to grey and fills back in as it progresses\n
|
||||||
## v0.3.13 (Build 70 / versionCode 53) — 2026-06-03\n\n### Fixes
|
## v0.3.13 (Build 70 / versionCode 53) — 2026-06-03\n\n### Fixes
|
||||||
|
|
||||||
- DM screen: tuned the gap above the input bar so the last message sits at a comfortable middle distance (not too tight, not floating too high)
|
- DM screen: tuned the gap above the input bar so the last message sits at a comfortable middle distance (not too tight, not floating too high)
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
ios: {
|
ios: {
|
||||||
supportsTablet: true,
|
supportsTablet: true,
|
||||||
bundleIdentifier: MAIN_BUNDLE,
|
bundleIdentifier: MAIN_BUNDLE,
|
||||||
buildNumber: "70",
|
buildNumber: "73",
|
||||||
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
|
// Apple Sign-In Entitlement — Pflicht für expo-apple-authentication nativen
|
||||||
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
|
// signInAsync()-Flow. Ohne flag generiert Expo's prebuild den
|
||||||
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
|
// com.apple.developer.applesignin-Entitlement nicht in die .entitlements.
|
||||||
@ -54,12 +54,15 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
"Rebreak speichert Bilder in deine Foto-Mediathek.",
|
"Rebreak speichert Bilder in deine Foto-Mediathek.",
|
||||||
NSFaceIDUsageDescription:
|
NSFaceIDUsageDescription:
|
||||||
"Rebreak nutzt Face ID, um die App zu entsperren — damit niemand außer dir sie öffnen kann.",
|
"Rebreak nutzt Face ID, um die App zu entsperren — damit niemand außer dir sie öffnen kann.",
|
||||||
|
// CallKit + PushKit: wacht App bei eingehendem VoIP-Push auf, hält
|
||||||
|
// Audio-Session im Background. Apple verlangt 'voip' für PushKit.
|
||||||
|
UIBackgroundModes: ["audio", "voip"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
android: {
|
android: {
|
||||||
package: "org.rebreak.app",
|
package: "org.rebreak.app",
|
||||||
versionCode: 53,
|
versionCode: 56,
|
||||||
adaptiveIcon: {
|
adaptiveIcon: {
|
||||||
// Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den
|
// Foreground muss in der ~66%-Safe-Zone bleiben (Launcher-Mask clippt den
|
||||||
// Außenring) → adaptive-foreground.png ist das Logo auf transparentem
|
// Außenring) → adaptive-foreground.png ist das Logo auf transparentem
|
||||||
@ -76,6 +79,14 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
"POST_NOTIFICATIONS",
|
"POST_NOTIFICATIONS",
|
||||||
"BIND_ACCESSIBILITY_SERVICE",
|
"BIND_ACCESSIBILITY_SERVICE",
|
||||||
"RECORD_AUDIO",
|
"RECORD_AUDIO",
|
||||||
|
// CallKeep / ConnectionService
|
||||||
|
"MANAGE_OWN_CALLS",
|
||||||
|
"READ_PHONE_STATE",
|
||||||
|
"READ_PHONE_NUMBERS",
|
||||||
|
"BIND_TELECOM_CONNECTION_SERVICE",
|
||||||
|
"FOREGROUND_SERVICE_MICROPHONE",
|
||||||
|
"FOREGROUND_SERVICE_PHONE_CALL",
|
||||||
|
"USE_FULL_SCREEN_INTENT",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -84,6 +95,10 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
"expo-localization",
|
"expo-localization",
|
||||||
"expo-font",
|
"expo-font",
|
||||||
"expo-web-browser",
|
"expo-web-browser",
|
||||||
|
// WebRTC (Voice-Calls) — Config-Plugin setzt Mic/Camera-Permissions + Podfile
|
||||||
|
"@config-plugins/react-native-webrtc",
|
||||||
|
// CallKit (iOS) + ConnectionService (Android) — native Call-UI mit Wake-aus-killed-State
|
||||||
|
"@config-plugins/react-native-callkeep",
|
||||||
[
|
[
|
||||||
"expo-media-library",
|
"expo-media-library",
|
||||||
{
|
{
|
||||||
@ -110,6 +125,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
"./plugins/with-fmt-consteval-fix",
|
"./plugins/with-fmt-consteval-fix",
|
||||||
// Xcode 14+ resource-bundle-signing fix (needed because useFrameworks: static)
|
// Xcode 14+ resource-bundle-signing fix (needed because useFrameworks: static)
|
||||||
"./plugins/with-resource-bundle-signing-fix",
|
"./plugins/with-resource-bundle-signing-fix",
|
||||||
|
// SDK 54 prebuilt RN + react-native-webrtc: non-modular React-Header erlauben
|
||||||
|
"./plugins/with-allow-nonmodular-includes",
|
||||||
// Phase 5: NEFilter Extension + Family Controls Entitlements (iOS)
|
// Phase 5: NEFilter Extension + Family Controls Entitlements (iOS)
|
||||||
"./plugins/with-rebreak-protection-ios",
|
"./plugins/with-rebreak-protection-ios",
|
||||||
// Phase 5: VpnService + AccessibilityService (Android)
|
// Phase 5: VpnService + AccessibilityService (Android)
|
||||||
|
|||||||
@ -79,7 +79,8 @@ function DmItem({ conv, onPress }: { conv: DmConversation; onPress: () => void }
|
|||||||
>
|
>
|
||||||
{conv.isOwn ? `${t('chat.you')} ` : ''}
|
{conv.isOwn ? `${t('chat.you')} ` : ''}
|
||||||
{conv.lastMessage ||
|
{conv.lastMessage ||
|
||||||
(conv.lastAttachmentType === 'audio' ? t('chat.voice_message') :
|
(conv.lastAttachmentType === 'call' ? `📞 ${t('chat.call_audio')}` :
|
||||||
|
conv.lastAttachmentType === 'audio' ? t('chat.voice_message') :
|
||||||
conv.lastAttachmentType === 'image' ? t('chat.photo') :
|
conv.lastAttachmentType === 'image' ? t('chat.photo') :
|
||||||
t('chat.media_sent'))}
|
t('chat.media_sent'))}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@ -35,6 +35,9 @@ import { useDeviceApprovalRealtime } from '../hooks/useDeviceApprovalRealtime';
|
|||||||
import { useDevicesStore } from '../stores/devices';
|
import { useDevicesStore } from '../stores/devices';
|
||||||
import { OnlinePresenceProvider } from '../components/OnlinePresenceProvider';
|
import { OnlinePresenceProvider } from '../components/OnlinePresenceProvider';
|
||||||
import { usePushTokenRegistration } from '../hooks/usePushTokenRegistration';
|
import { usePushTokenRegistration } from '../hooks/usePushTokenRegistration';
|
||||||
|
import { useIncomingCalls } from '../hooks/useIncomingCalls';
|
||||||
|
import { useCallStore } from '../stores/call';
|
||||||
|
import { useCallKeepEvents } from '../hooks/useCallKeepEvents';
|
||||||
import '../lib/i18n'; // i18next-Init via Side-Effect
|
import '../lib/i18n'; // i18next-Init via Side-Effect
|
||||||
import '../global.css';
|
import '../global.css';
|
||||||
|
|
||||||
@ -78,26 +81,53 @@ function RootLayoutInner() {
|
|||||||
// Push-Token-Registration nach Login (idempotent)
|
// Push-Token-Registration nach Login (idempotent)
|
||||||
usePushTokenRegistration(user?.id);
|
usePushTokenRegistration(user?.id);
|
||||||
|
|
||||||
|
// Eingehende Voice-Calls (Ring-Channel) — zeigt den Call-Screen wenn jemand
|
||||||
|
// anruft. Foreground-only (Phase 1).
|
||||||
|
useIncomingCalls(user?.id);
|
||||||
|
|
||||||
|
// CallKit/ConnectionService Event-Bridge — mappt native UI-Actions
|
||||||
|
// (Accept/Decline/Hangup im Lockscreen-CallKit-UI) auf useCallStore.
|
||||||
|
useCallKeepEvents();
|
||||||
|
|
||||||
// Apple-Style Device-Approval Realtime — lauscht auf neue Approval-Requests
|
// Apple-Style Device-Approval Realtime — lauscht auf neue Approval-Requests
|
||||||
// für diesen User und zeigt das Incoming-Sheet wenn ein anderes Gerät
|
// für diesen User und zeigt das Incoming-Sheet wenn ein anderes Gerät
|
||||||
// sich anmelden möchte.
|
// sich anmelden möchte.
|
||||||
useDeviceApprovalRealtime(!!user?.id);
|
useDeviceApprovalRealtime(!!user?.id);
|
||||||
|
|
||||||
// Push-Tap-Deep-Link: User tippt Notification → navigate zu Chat
|
// Push-Tap-Deep-Link: User tippt Notification → navigate zu Chat / Call
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sub = Notifications.addNotificationResponseReceivedListener(
|
const handle = (response: Notifications.NotificationResponse | null | undefined) => {
|
||||||
(response) => {
|
if (!response) return;
|
||||||
const data = response.notification.request.content.data as
|
const data = response.notification.request.content.data as
|
||||||
| { type?: 'dm' | 'room'; targetId?: string }
|
| {
|
||||||
| undefined;
|
type?: 'dm' | 'room' | 'call';
|
||||||
if (!data?.type || !data.targetId) return;
|
targetId?: string;
|
||||||
if (data.type === 'dm') {
|
callId?: string;
|
||||||
router.push({ pathname: '/dm', params: { userId: data.targetId } });
|
from?: { id: string; nickname: string; avatar: string | null };
|
||||||
} else if (data.type === 'room') {
|
}
|
||||||
router.push({ pathname: '/room', params: { roomId: data.targetId } });
|
| undefined;
|
||||||
|
if (!data?.type) return;
|
||||||
|
if (data.type === 'dm' && data.targetId) {
|
||||||
|
router.push({ pathname: '/dm', params: { userId: data.targetId } });
|
||||||
|
} else if (data.type === 'room' && data.targetId) {
|
||||||
|
router.push({ pathname: '/room', params: { roomId: data.targetId } });
|
||||||
|
} else if (data.type === 'call' && data.callId && data.from) {
|
||||||
|
// Eingehender Anruf — Realtime hat (vermutlich) keine Subscription
|
||||||
|
// gehabt weil App im Background war. Wir simulieren receiveIncoming
|
||||||
|
// damit der Standard-Accept/Decline-Flow greift. Falls der Caller in
|
||||||
|
// der Zwischenzeit aufgelegt hat: ring-cancel kommt sobald Channel
|
||||||
|
// subscribed, dann teardown.
|
||||||
|
try {
|
||||||
|
useCallStore.getState().receiveIncoming(data.callId, data.from);
|
||||||
|
router.push('/call');
|
||||||
|
} catch {
|
||||||
|
// ignore — Call evtl. schon beendet
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
);
|
};
|
||||||
|
const sub = Notifications.addNotificationResponseReceivedListener(handle);
|
||||||
|
// Cold-Start: App wurde durch Notification-Tap geöffnet
|
||||||
|
Notifications.getLastNotificationResponseAsync().then(handle).catch(() => {});
|
||||||
return () => sub.remove();
|
return () => sub.remove();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -176,6 +206,15 @@ function RootLayoutInner() {
|
|||||||
animation: 'slide_from_bottom',
|
animation: 'slide_from_bottom',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="call"
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'fullScreenModal',
|
||||||
|
animation: 'slide_from_bottom',
|
||||||
|
gestureEnabled: false, // kein versehentliches Swipe-Dismiss im Call
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="dm"
|
name="dm"
|
||||||
options={{
|
options={{
|
||||||
|
|||||||
178
apps/rebreak-native/app/call.tsx
Normal file
178
apps/rebreak-native/app/call.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, Alert, StyleSheet } from 'react-native';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useCallStore } from '../stores/call';
|
||||||
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
|
|
||||||
|
function fmtDuration(ms: number) {
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const m = Math.floor(s / 60);
|
||||||
|
return `${m}:${String(s % 60).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CallScreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const status = useCallStore((s) => s.status);
|
||||||
|
const peer = useCallStore((s) => s.peer);
|
||||||
|
const muted = useCallStore((s) => s.muted);
|
||||||
|
const speaker = useCallStore((s) => s.speaker);
|
||||||
|
const startedAt = useCallStore((s) => s.startedAt);
|
||||||
|
const endReason = useCallStore((s) => s.endReason);
|
||||||
|
const acceptCall = useCallStore((s) => s.acceptCall);
|
||||||
|
const declineCall = useCallStore((s) => s.declineCall);
|
||||||
|
const hangup = useCallStore((s) => s.hangup);
|
||||||
|
const toggleMute = useCallStore((s) => s.toggleMute);
|
||||||
|
const toggleSpeaker = useCallStore((s) => s.toggleSpeaker);
|
||||||
|
const clear = useCallStore((s) => s._clear);
|
||||||
|
|
||||||
|
const [elapsed, setElapsed] = useState(0);
|
||||||
|
|
||||||
|
// Kein aktiver Call → Screen schließen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'idle') router.back();
|
||||||
|
}, [status, router]);
|
||||||
|
|
||||||
|
// Call beendet → kurz "beendet" zeigen, dann schließen + aufräumen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'ended') return;
|
||||||
|
const tm = setTimeout(() => {
|
||||||
|
clear();
|
||||||
|
router.back();
|
||||||
|
}, 1300);
|
||||||
|
return () => clearTimeout(tm);
|
||||||
|
}, [status, clear, router]);
|
||||||
|
|
||||||
|
// Gesprächsdauer-Timer.
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== 'connected' || !startedAt) return;
|
||||||
|
const id = setInterval(() => setElapsed(Date.now() - startedAt), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [status, startedAt]);
|
||||||
|
|
||||||
|
async function onAccept() {
|
||||||
|
try {
|
||||||
|
await acceptCall();
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.message === 'webrtc_unavailable') {
|
||||||
|
Alert.alert(t('call.title'), t('call.needs_rebuild'));
|
||||||
|
}
|
||||||
|
hangup('failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtitle =
|
||||||
|
status === 'outgoing'
|
||||||
|
? t('call.calling')
|
||||||
|
: status === 'incoming'
|
||||||
|
? t('call.incoming')
|
||||||
|
: status === 'connecting'
|
||||||
|
? t('call.connecting')
|
||||||
|
: status === 'connected'
|
||||||
|
? fmtDuration(elapsed)
|
||||||
|
: status === 'ended'
|
||||||
|
? endReason === 'declined'
|
||||||
|
? t('call.declined')
|
||||||
|
: endReason === 'unanswered'
|
||||||
|
? t('call.no_answer')
|
||||||
|
: endReason === 'failed'
|
||||||
|
? t('call.failed')
|
||||||
|
: t('call.ended')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<LinearGradient colors={['#0f172a', '#1e3a34', '#0f172a']} style={StyleSheet.absoluteFill} />
|
||||||
|
<SafeAreaView style={{ flex: 1, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
{/* Oben: Avatar + Name + Status */}
|
||||||
|
<View style={{ alignItems: 'center', marginTop: 72, gap: 18 }}>
|
||||||
|
<UserAvatar
|
||||||
|
userId={peer?.id ?? null}
|
||||||
|
avatar={peer?.avatar ?? null}
|
||||||
|
nickname={peer?.nickname ?? '?'}
|
||||||
|
size="xl"
|
||||||
|
/>
|
||||||
|
<Text style={{ color: '#fff', fontSize: 26, fontFamily: 'Nunito_700Bold' }} numberOfLines={1}>
|
||||||
|
{peer?.nickname ?? '…'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 16, fontFamily: 'Nunito_600SemiBold', fontVariant: ['tabular-nums'] }}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Unten: Aktions-Buttons */}
|
||||||
|
<View style={{ marginBottom: 56, width: '100%', alignItems: 'center' }}>
|
||||||
|
{status === 'incoming' ? (
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-evenly', width: '100%' }}>
|
||||||
|
<CircleBtn color="#ef4444" icon="call" rotate onPress={declineCall} label={t('call.decline')} />
|
||||||
|
<CircleBtn color="#22c55e" icon="call" onPress={onAccept} label={t('call.accept')} />
|
||||||
|
</View>
|
||||||
|
) : status === 'ended' ? null : (
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', gap: 28 }}>
|
||||||
|
{(status === 'connected' || status === 'connecting') && (
|
||||||
|
<CircleBtn
|
||||||
|
color={muted ? '#fff' : 'rgba(255,255,255,0.18)'}
|
||||||
|
iconColor={muted ? '#0f172a' : '#fff'}
|
||||||
|
icon={muted ? 'mic-off' : 'mic'}
|
||||||
|
onPress={toggleMute}
|
||||||
|
label={muted ? t('call.unmute') : t('call.mute')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{(status === 'connected' || status === 'connecting' || status === 'outgoing') && (
|
||||||
|
<CircleBtn
|
||||||
|
color={speaker ? '#fff' : 'rgba(255,255,255,0.18)'}
|
||||||
|
iconColor={speaker ? '#0f172a' : '#fff'}
|
||||||
|
icon={speaker ? 'volume-high' : 'volume-medium'}
|
||||||
|
onPress={toggleSpeaker}
|
||||||
|
label={speaker ? t('call.speaker_on') : t('call.speaker_off')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CircleBtn color="#ef4444" icon="call" rotate onPress={() => hangup('ended')} label={t('call.hang_up')} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CircleBtn({
|
||||||
|
color,
|
||||||
|
iconColor = '#fff',
|
||||||
|
icon,
|
||||||
|
rotate,
|
||||||
|
onPress,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
color: string;
|
||||||
|
iconColor?: string;
|
||||||
|
icon: keyof typeof Ionicons.glyphMap;
|
||||||
|
rotate?: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ alignItems: 'center', gap: 8 }}>
|
||||||
|
<TouchableOpacity
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onPress={onPress}
|
||||||
|
style={{
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
borderRadius: 34,
|
||||||
|
backgroundColor: color,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ionicons name={icon} size={28} color={iconColor} style={rotate ? { transform: [{ rotate: '135deg' }] } : undefined} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={{ color: 'rgba(255,255,255,0.8)', fontSize: 12, fontFamily: 'Nunito_600SemiBold' }}>{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -40,6 +40,8 @@ import { useDmRealtime } from '../hooks/useChatRealtime';
|
|||||||
import { useDmTyping } from '../hooks/useDmTyping';
|
import { useDmTyping } from '../hooks/useDmTyping';
|
||||||
import { useColors } from '../lib/theme';
|
import { useColors } from '../lib/theme';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { useCallStore, isWebRTCAvailable } from '../stores/call';
|
||||||
|
import { useMe } from '../hooks/useMe';
|
||||||
import { supabase } from '../lib/supabase';
|
import { supabase } from '../lib/supabase';
|
||||||
import { UserAvatar } from '../components/UserAvatar';
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus';
|
import { ChatHeaderStatus } from '../components/chat/ChatHeaderStatus';
|
||||||
@ -95,6 +97,7 @@ export default function DmScreen() {
|
|||||||
const styles = makeStyles(colors);
|
const styles = makeStyles(colors);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const myUserId = useAuthStore((s) => s.user?.id);
|
const myUserId = useAuthStore((s) => s.user?.id);
|
||||||
|
const { me } = useMe();
|
||||||
|
|
||||||
const { userId } = useLocalSearchParams<{ userId: string }>();
|
const { userId } = useLocalSearchParams<{ userId: string }>();
|
||||||
|
|
||||||
@ -331,10 +334,20 @@ export default function DmScreen() {
|
|||||||
const canCall = canCallData?.canCall ?? false;
|
const canCall = canCallData?.canCall ?? false;
|
||||||
|
|
||||||
function startCall() {
|
function startCall() {
|
||||||
// TODO(phase1): echte Call-Engine (WebRTC + coturn + Signaling). Bis der
|
if (!userId || !partner) return;
|
||||||
// TURN-Server steht + ein Dev-Build mit react-native-webrtc existiert, hier
|
// Native WebRTC fehlt im aktuellen Build → ehrlicher Hinweis statt Crash.
|
||||||
// nur ein ehrlicher Hinweis statt eines toten Buttons.
|
if (!isWebRTCAvailable()) {
|
||||||
Alert.alert(t('chat.call'), t('chat.call_coming_soon'));
|
Alert.alert(t('chat.call'), t('call.needs_rebuild'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useCallStore
|
||||||
|
.getState()
|
||||||
|
.startCall(
|
||||||
|
{ id: userId, nickname: partner.nickname ?? '?', avatar: partner.avatar ?? null },
|
||||||
|
{ id: me?.id ?? myUserId ?? '', nickname: me?.nickname ?? 'Du', avatar: me?.avatar ?? null },
|
||||||
|
)
|
||||||
|
.catch((e: any) => console.log('[CALL] startCall error:', e?.message ?? e));
|
||||||
|
router.push('/call' as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pickImage() {
|
async function pickImage() {
|
||||||
@ -421,9 +434,11 @@ export default function DmScreen() {
|
|||||||
setReplyTo(null);
|
setReplyTo(null);
|
||||||
setSending(true);
|
setSending(true);
|
||||||
sendStopTyping();
|
sendStopTyping();
|
||||||
// Fokus halten: das Leeren des Inputs tauscht Send→Mic-Button und kann den
|
// Fokus 1× re-assertieren reicht — die mehrfach-focus-Aufrufe waren cargo-cult.
|
||||||
// Fokus verlieren. Re-assert nach dem Re-Render → Tastatur bleibt offen.
|
// Wichtiger: Send-Button bleibt mounted solange `sending` true ist (siehe
|
||||||
requestAnimationFrame(() => inputRef.current?.focus());
|
// Render-Bedingung unten), dadurch fällt das Touch-Target nicht weg und
|
||||||
|
// die Tastatur bleibt stehen.
|
||||||
|
inputRef.current?.focus();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let attachmentMeta: { url: string; type: string; name: string } | null = null;
|
let attachmentMeta: { url: string; type: string; name: string } | null = null;
|
||||||
@ -940,20 +955,20 @@ export default function DmScreen() {
|
|||||||
// Insta/WA-Style: nach dem Senden bleibt die Tastatur offen
|
// Insta/WA-Style: nach dem Senden bleibt die Tastatur offen
|
||||||
// (Fokus bleibt am Input), bis der User woanders hin tippt.
|
// (Fokus bleibt am Input), bis der User woanders hin tippt.
|
||||||
blurOnSubmit={false}
|
blurOnSubmit={false}
|
||||||
editable={!sending && !uploading}
|
// editable NICHT auf !sending setzen — das wäre der Grund warum
|
||||||
|
// die Tastatur nach Send dismisst (non-editable TextInput → iOS
|
||||||
|
// forciert Blur → Keyboard weg). User darf während des Sendens
|
||||||
|
// schon die nächste Nachricht tippen (wie WhatsApp/Insta).
|
||||||
|
editable={!uploading}
|
||||||
/>
|
/>
|
||||||
{(inputText.trim().length > 0 || attachment) ? (
|
{(inputText.trim().length > 0 || attachment) ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.sendBtn, (sending || uploading) && styles.sendBtnDisabled]}
|
style={styles.sendBtn}
|
||||||
onPress={handleSend}
|
onPress={handleSend}
|
||||||
disabled={sending || uploading}
|
disabled={uploading}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
{sending || uploading ? (
|
<Ionicons name="send" size={16} color="#fff" />
|
||||||
<ActivityIndicator size="small" color="#fff" />
|
|
||||||
) : (
|
|
||||||
<Ionicons name="send" size={16} color="#fff" />
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
) : (
|
) : (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
BIN
apps/rebreak-native/assets/sounds/ringback_eu.mp3
Normal file
BIN
apps/rebreak-native/assets/sounds/ringback_eu.mp3
Normal file
Binary file not shown.
@ -161,7 +161,6 @@ function VoiceNoteBubble({ url, duration, isOwn }: { url: string; duration: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type MessageReaction = { emoji: string; count: number; mine: boolean };
|
export type MessageReaction = { emoji: string; count: number; mine: boolean };
|
||||||
|
|
||||||
export type ChatMsg = {
|
export type ChatMsg = {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@ -231,7 +230,51 @@ function useBubbleColors() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatBubble({
|
function CallNoteRow({ msg }: { msg: ChatMsg }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const colors = useColors();
|
||||||
|
const [, stateRaw, durRaw] = (msg.attachmentName ?? 'audio:ended:0').split(':');
|
||||||
|
const state = (stateRaw || 'ended') as 'ended' | 'unanswered' | 'declined' | 'failed' | 'busy';
|
||||||
|
const durSec = parseInt(durRaw || '0', 10) || 0;
|
||||||
|
const isMissed = state !== 'ended';
|
||||||
|
const iconColor = isMissed ? '#ef4444' : (msg.isOwn ? '#10b981' : colors.text);
|
||||||
|
let label: string;
|
||||||
|
if (!isMissed) {
|
||||||
|
const m = Math.floor(durSec / 60);
|
||||||
|
const s = (durSec % 60).toString().padStart(2, '0');
|
||||||
|
label = `${t('chat.call_audio')} · ${m}:${s}`;
|
||||||
|
} else if (state === 'declined') {
|
||||||
|
label = msg.isOwn ? t('chat.call_declined') : t('chat.call_missed');
|
||||||
|
} else if (state === 'unanswered') {
|
||||||
|
label = msg.isOwn ? t('chat.call_no_answer') : t('chat.call_missed');
|
||||||
|
} else {
|
||||||
|
label = t('chat.call_failed');
|
||||||
|
}
|
||||||
|
const time = new Date(msg.createdAt).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', paddingVertical: 6, paddingHorizontal: 16 }}>
|
||||||
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: colors.surfaceElevated, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 16 }}>
|
||||||
|
<View style={{ width: 28, height: 28, borderRadius: 14, backgroundColor: 'rgba(0,0,0,0.06)', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Ionicons name="call-outline" size={15} color={iconColor} />
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={{ fontSize: 13, fontFamily: 'Nunito_600SemiBold', color: colors.text }}>{label}</Text>
|
||||||
|
<Text style={{ fontSize: 11, color: colors.textMuted, marginTop: 1 }}>{time}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatBubble(props: Props) {
|
||||||
|
// Call-Notiz (System-Row, kein Bubble) — eigenes Render-Path, ohne Hooks-Aufwand.
|
||||||
|
if (props.msg.attachmentType === 'call') {
|
||||||
|
return <CallNoteRow msg={props.msg} />;
|
||||||
|
}
|
||||||
|
return <ChatBubbleInner {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatBubbleInner({
|
||||||
msg,
|
msg,
|
||||||
showName = false,
|
showName = false,
|
||||||
isFirstInGroup = true,
|
isFirstInGroup = true,
|
||||||
|
|||||||
@ -254,8 +254,8 @@ export function MagicSheet({
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Verbundene Macs */}
|
{/* Verbundene Ger\u00e4te */}
|
||||||
<SectionTitle text="Verbundene Macs" colors={colors} />
|
<SectionTitle text="Verbundene Ger\u00e4te" colors={colors} />
|
||||||
<View style={cardStyle(colors)}>
|
<View style={cardStyle(colors)}>
|
||||||
{devices === null ? (
|
{devices === null ? (
|
||||||
<ActivityIndicator />
|
<ActivityIndicator />
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
# ./dev.sh default: ios --device (physisches iPhone USB + Build)
|
# ./dev.sh default: ios --device (physisches iPhone USB + Build)
|
||||||
# ./dev.sh ios iOS Dev (Default: USB-Device mit Build)
|
# ./dev.sh ios iOS Dev (Default: USB-Device mit Build)
|
||||||
# ./dev.sh android Android Dev (Gradle Build + Install + Launch)
|
# ./dev.sh android Android Dev (Gradle Build + Install + Launch)
|
||||||
|
# ./dev.sh mobile Auto-detect angeschl. iPhone + Android via USB,
|
||||||
|
# baut+launcht auf BEIDEN parallel mit shared Metro
|
||||||
# ./dev.sh metro Nur Metro starten
|
# ./dev.sh metro Nur Metro starten
|
||||||
# ./dev.sh clean iOS: Nuclear clean (Pods, DerivedData, Archives)
|
# ./dev.sh clean iOS: Nuclear clean (Pods, DerivedData, Archives)
|
||||||
# ./dev.sh install ios Build Release + Install auf iPhone USB
|
# ./dev.sh install ios Build Release + Install auf iPhone USB
|
||||||
@ -27,6 +29,11 @@
|
|||||||
# --no-launch Build+Install, aber kein Auto-Launch
|
# --no-launch Build+Install, aber kein Auto-Launch
|
||||||
# --wifi Metro mit --host lan (nur in Kombi mit --no-build)
|
# --wifi Metro mit --host lan (nur in Kombi mit --no-build)
|
||||||
#
|
#
|
||||||
|
# FLAGS (mobile):
|
||||||
|
# --no-build Beide nur Metro/Install (kein Native-Rebuild)
|
||||||
|
# --ios-only Nur iOS bauen (falls Android-Device da aber ignorieren)
|
||||||
|
# --android-only Nur Android bauen
|
||||||
|
#
|
||||||
# FLAGS (metro):
|
# FLAGS (metro):
|
||||||
# --keep Cache behalten (kein --clear)
|
# --keep Cache behalten (kein --clear)
|
||||||
#
|
#
|
||||||
@ -257,6 +264,182 @@ cmd_android() {
|
|||||||
ok "Android Dev Build abgeschlossen"
|
ok "Android Dev Build abgeschlossen"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Device-Detection Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
detect_ios_device() {
|
||||||
|
# Gibt UDID des ersten ONLINE iPhone/iPad zurück, leer wenn keins
|
||||||
|
command -v xcrun >/dev/null 2>&1 || { echo ""; return 0; }
|
||||||
|
{ xcrun xctrace list devices 2>/dev/null \
|
||||||
|
| awk '/^== Devices ==/{f=1; next} /^== /{f=0} f' \
|
||||||
|
| grep -E "iPhone|iPad" \
|
||||||
|
| grep -v Simulator \
|
||||||
|
| head -1 \
|
||||||
|
| sed -nE 's/.*\(([0-9A-Fa-f-]{25,})\).*/\1/p'; } || true
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_ios_device_name() {
|
||||||
|
command -v xcrun >/dev/null 2>&1 || { echo ""; return 0; }
|
||||||
|
{ xcrun xctrace list devices 2>/dev/null \
|
||||||
|
| awk '/^== Devices ==/{f=1; next} /^== /{f=0} f' \
|
||||||
|
| grep -E "iPhone|iPad" \
|
||||||
|
| grep -v Simulator \
|
||||||
|
| head -1 \
|
||||||
|
| sed -E 's/ *\(.*//'; } || true
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_android_devices() {
|
||||||
|
# Gibt alle ADB-Device-IDs (eine pro Zeile)
|
||||||
|
command -v adb >/dev/null 2>&1 || return 0
|
||||||
|
{ adb devices 2>/dev/null | awk '/\tdevice$/ {print $1}'; } || true
|
||||||
|
}
|
||||||
|
|
||||||
|
start_shared_metro() {
|
||||||
|
local LOG="/tmp/rebreak-metro.log"
|
||||||
|
log "Killing alte Metro-Instanzen auf 8081..."
|
||||||
|
lsof -ti:8081 2>/dev/null | xargs kill -9 2>/dev/null || true
|
||||||
|
pkill -f "expo start" 2>/dev/null || true
|
||||||
|
sleep 1
|
||||||
|
log "Starte Metro im Hintergrund → $LOG"
|
||||||
|
(cd "$SCRIPT_DIR" && nohup pnpm expo start --dev-client --clear > "$LOG" 2>&1 &)
|
||||||
|
# Warte auf Metro-Bereitschaft
|
||||||
|
local tries=0
|
||||||
|
while [[ $tries -lt 30 ]]; do
|
||||||
|
if curl -s http://localhost:8081/status 2>/dev/null | grep -q packager-status:running; then
|
||||||
|
ok "Metro bereit (http://localhost:8081)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
tries=$((tries+1))
|
||||||
|
done
|
||||||
|
warn "Metro-Statuscheck nicht bestätigt nach 30s — trotzdem weiter (Log: $LOG)"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_mobile() {
|
||||||
|
local BUILD=true
|
||||||
|
local IOS_ONLY=false
|
||||||
|
local ANDROID_ONLY=false
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--no-build) BUILD=false; shift ;;
|
||||||
|
--ios-only) IOS_ONLY=true; shift ;;
|
||||||
|
--android-only) ANDROID_ONLY=true; shift ;;
|
||||||
|
*) die "Unbekannter Flag für 'mobile': $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
section "Mobile Dev (Auto-Detect iOS + Android)"
|
||||||
|
|
||||||
|
# ── Detect ────────────────────────────────────────────────
|
||||||
|
local IOS_UDID=""
|
||||||
|
local IOS_NAME=""
|
||||||
|
local ANDROID_IDS=()
|
||||||
|
|
||||||
|
# set +e block: detection-Funktionen dürfen leer zurückkommen ohne Script-Abort
|
||||||
|
set +e
|
||||||
|
if ! $ANDROID_ONLY; then
|
||||||
|
IOS_UDID="$(detect_ios_device)"
|
||||||
|
IOS_NAME="$(detect_ios_device_name)"
|
||||||
|
fi
|
||||||
|
if ! $IOS_ONLY; then
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[[ -n "$line" ]] && ANDROID_IDS+=("$line")
|
||||||
|
done < <(detect_android_devices)
|
||||||
|
fi
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
if [[ -n "$IOS_UDID" ]]; then
|
||||||
|
ok "iOS: ${IOS_NAME:-iPhone} (${IOS_UDID})"
|
||||||
|
else
|
||||||
|
warn "Kein iOS-Device via USB gefunden"
|
||||||
|
fi
|
||||||
|
if [[ ${#ANDROID_IDS[@]} -gt 0 ]]; then
|
||||||
|
for id in "${ANDROID_IDS[@]}"; do
|
||||||
|
local model
|
||||||
|
model=$(adb -s "$id" shell getprop ro.product.model 2>/dev/null | tr -d '\r')
|
||||||
|
ok "Android: ${model:-unknown} ($id)"
|
||||||
|
done
|
||||||
|
else
|
||||||
|
warn "Kein Android-Device via ADB gefunden"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [[ -z "$IOS_UDID" && ${#ANDROID_IDS[@]} -eq 0 ]]; then
|
||||||
|
die "Keine Devices erkannt — USB-Kabel checken, Trust-Dialog auf iPhone, USB-Debugging auf Android"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Shared Metro ──────────────────────────────────────────
|
||||||
|
start_shared_metro
|
||||||
|
|
||||||
|
local IOS_LOG="/tmp/rebreak-ios-build.log"
|
||||||
|
local ANDROID_LOG="/tmp/rebreak-android-build.log"
|
||||||
|
local IOS_PID=""
|
||||||
|
local ANDROID_PID=""
|
||||||
|
|
||||||
|
# ── iOS Build (parallel, im Hintergrund) ──────────────────
|
||||||
|
if [[ -n "$IOS_UDID" ]]; then
|
||||||
|
if $BUILD; then
|
||||||
|
log "iOS-Build startet → $IOS_LOG"
|
||||||
|
(
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
pnpm expo run:ios --device "$IOS_UDID" --no-bundler
|
||||||
|
) > "$IOS_LOG" 2>&1 &
|
||||||
|
IOS_PID=$!
|
||||||
|
else
|
||||||
|
log "iOS — skip Build (--no-build), App muss installiert sein"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Android Build (parallel) ──────────────────────────────
|
||||||
|
if [[ ${#ANDROID_IDS[@]} -gt 0 ]]; then
|
||||||
|
if $BUILD; then
|
||||||
|
log "Android-Build startet → $ANDROID_LOG"
|
||||||
|
(
|
||||||
|
cd "$ANDROID_DIR"
|
||||||
|
./gradlew assembleDebug --console=plain
|
||||||
|
local APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
|
||||||
|
[[ -f "$APK" ]] || { echo "APK nicht gefunden"; exit 1; }
|
||||||
|
for id in "${ANDROID_IDS[@]}"; do
|
||||||
|
echo "→ install on $id"
|
||||||
|
adb -s "$id" install -r -d "$APK"
|
||||||
|
adb -s "$id" shell monkey -p org.rebreak.app -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
) > "$ANDROID_LOG" 2>&1 &
|
||||||
|
ANDROID_PID=$!
|
||||||
|
else
|
||||||
|
log "Android — skip Build (--no-build), APK muss installiert sein"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Live-Logs follow ──────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
log "Builds laufen parallel. Live-Logs:"
|
||||||
|
[[ -n "$IOS_PID" ]] && echo " iOS : tail -f $IOS_LOG (pid $IOS_PID)"
|
||||||
|
[[ -n "$ANDROID_PID" ]] && echo " Android : tail -f $ANDROID_LOG (pid $ANDROID_PID)"
|
||||||
|
echo " Metro : tail -f /tmp/rebreak-metro.log"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
local IOS_RC=0
|
||||||
|
local ANDROID_RC=0
|
||||||
|
if [[ -n "$IOS_PID" ]]; then
|
||||||
|
log "Warte auf iOS-Build..."
|
||||||
|
wait "$IOS_PID" && IOS_RC=$? || IOS_RC=$?
|
||||||
|
if [[ $IOS_RC -eq 0 ]]; then ok "iOS-Build fertig"; else error "iOS-Build FAIL (rc=$IOS_RC) — $IOS_LOG"; fi
|
||||||
|
fi
|
||||||
|
if [[ -n "$ANDROID_PID" ]]; then
|
||||||
|
log "Warte auf Android-Build..."
|
||||||
|
wait "$ANDROID_PID" && ANDROID_RC=$? || ANDROID_RC=$?
|
||||||
|
if [[ $ANDROID_RC -eq 0 ]]; then ok "Android-Build fertig"; else error "Android-Build FAIL (rc=$ANDROID_RC) — $ANDROID_LOG"; fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "Mobile-Dev bereit. Metro läuft im Hintergrund (kill via: lsof -ti:8081 | xargs kill)."
|
||||||
|
echo "Metro-Log live: tail -f /tmp/rebreak-metro.log"
|
||||||
|
}
|
||||||
|
|
||||||
cmd_metro() {
|
cmd_metro() {
|
||||||
local CLEAR_FLAG="--clear"
|
local CLEAR_FLAG="--clear"
|
||||||
|
|
||||||
@ -518,6 +701,10 @@ case "$COMMAND" in
|
|||||||
cmd_android "$@"
|
cmd_android "$@"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
mobile)
|
||||||
|
cmd_mobile "$@"
|
||||||
|
;;
|
||||||
|
|
||||||
metro)
|
metro)
|
||||||
cmd_metro "$@"
|
cmd_metro "$@"
|
||||||
;;
|
;;
|
||||||
@ -555,6 +742,7 @@ case "$COMMAND" in
|
|||||||
echo "Verfügbare Commands:"
|
echo "Verfügbare Commands:"
|
||||||
echo " ios iOS Dev (Metro + Xcode/Simulator/Device)"
|
echo " ios iOS Dev (Metro + Xcode/Simulator/Device)"
|
||||||
echo " android Android Dev (Metro + Gradle + Install)"
|
echo " android Android Dev (Metro + Gradle + Install)"
|
||||||
|
echo " mobile Auto-detect iPhone+Android via USB, parallel-Build"
|
||||||
echo " metro Nur Metro starten"
|
echo " metro Nur Metro starten"
|
||||||
echo " clean iOS Nuclear Clean"
|
echo " clean iOS Nuclear Clean"
|
||||||
echo " install ios Release-Build auf iPhone installieren"
|
echo " install ios Release-Build auf iPhone installieren"
|
||||||
|
|||||||
62
apps/rebreak-native/hooks/useCallKeepEvents.ts
Normal file
62
apps/rebreak-native/hooks/useCallKeepEvents.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Bridge zwischen CallKit/ConnectionService-Events und unserer Call-Store.
|
||||||
|
*
|
||||||
|
* Wenn der User in der nativen Call-UI Accept/Reject/Hangup tippt, kommt das
|
||||||
|
* NICHT über unser React-UI rein — sondern via RNCallKeep-Events. Wir
|
||||||
|
* übersetzen die in store-Actions.
|
||||||
|
*
|
||||||
|
* Wird einmal app-weit im _layout.tsx aufgerufen.
|
||||||
|
*/
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import RNCallKeep from 'react-native-callkeep';
|
||||||
|
import { useCallStore } from '../stores/call';
|
||||||
|
import { setupCallKeep } from '../lib/callkit';
|
||||||
|
|
||||||
|
export function useCallKeepEvents() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void setupCallKeep();
|
||||||
|
|
||||||
|
// User tippt "Annehmen" in der CallKit-/ConnectionService-UI
|
||||||
|
const onAnswer = ({ callUUID }: { callUUID: string }) => {
|
||||||
|
console.log('[callkeep] answer', callUUID);
|
||||||
|
const st = useCallStore.getState();
|
||||||
|
if (st.status !== 'incoming') return;
|
||||||
|
// Call-Screen öffnen und Accept-Flow triggern.
|
||||||
|
router.push('/call');
|
||||||
|
void st.acceptCall();
|
||||||
|
};
|
||||||
|
|
||||||
|
// User tippt "Ablehnen" oder "Auflegen" in der nativen UI
|
||||||
|
const onEnd = ({ callUUID }: { callUUID: string }) => {
|
||||||
|
console.log('[callkeep] end', callUUID);
|
||||||
|
const st = useCallStore.getState();
|
||||||
|
if (st.status === 'idle' || st.status === 'ended') return;
|
||||||
|
if (st.status === 'incoming') {
|
||||||
|
st.declineCall();
|
||||||
|
} else {
|
||||||
|
st.hangup('ended');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// User mutet/unmutet über die native UI
|
||||||
|
const onMuted = ({ muted }: { muted: boolean; callUUID: string }) => {
|
||||||
|
const st = useCallStore.getState();
|
||||||
|
if (st.muted !== muted) st.toggleMute();
|
||||||
|
};
|
||||||
|
|
||||||
|
RNCallKeep.addEventListener('answerCall', onAnswer);
|
||||||
|
RNCallKeep.addEventListener('endCall', onEnd);
|
||||||
|
RNCallKeep.addEventListener('didPerformSetMutedCallAction', onMuted);
|
||||||
|
// didActivateAudioSession kommt nach CallKit-Audio-Activation — wir nutzen
|
||||||
|
// das (noch) nicht aktiv, weil WebRTC + InCallManager das selber regeln.
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
RNCallKeep.removeEventListener('answerCall');
|
||||||
|
RNCallKeep.removeEventListener('endCall');
|
||||||
|
RNCallKeep.removeEventListener('didPerformSetMutedCallAction');
|
||||||
|
};
|
||||||
|
}, [router]);
|
||||||
|
}
|
||||||
47
apps/rebreak-native/hooks/useIncomingCalls.ts
Normal file
47
apps/rebreak-native/hooks/useIncomingCalls.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { useCallStore, type CallPeer } from '../stores/call';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lauscht (app-weit, solange eingeloggt) auf den persönlichen Ring-Channel
|
||||||
|
* `call-ring:<myUserId>` und zeigt bei einer eingehenden Einladung den
|
||||||
|
* Call-Screen. Phase 1 = foreground-only (klingelt nur bei offener App;
|
||||||
|
* Wake-when-closed via VoIP-Push ist Phase 2).
|
||||||
|
*/
|
||||||
|
export function useIncomingCalls(myUserId: string | undefined) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!myUserId) return;
|
||||||
|
|
||||||
|
console.log('[CALL/recv] subscribing call-ring channel for', myUserId);
|
||||||
|
const chan = supabase.channel(`call-ring:${myUserId}`);
|
||||||
|
chan.on('broadcast', { event: 'ring' }, (msg: any) => {
|
||||||
|
console.log('[CALL/recv] RING received', msg?.payload);
|
||||||
|
const callId = msg?.payload?.callId as string | undefined;
|
||||||
|
const from = msg?.payload?.from as CallPeer | undefined;
|
||||||
|
if (!callId || !from) return;
|
||||||
|
// Schon in einem Call → ignorieren (MVP: kein call-waiting).
|
||||||
|
if (useCallStore.getState().status !== 'idle') return;
|
||||||
|
useCallStore.getState().receiveIncoming(callId, from);
|
||||||
|
router.push('/call');
|
||||||
|
});
|
||||||
|
chan.on('broadcast', { event: 'cancel' }, (msg: any) => {
|
||||||
|
const callId = msg?.payload?.callId as string | undefined;
|
||||||
|
const st = useCallStore.getState();
|
||||||
|
if (st.callId === callId && st.status === 'incoming') {
|
||||||
|
// Caller hat aufgelegt bevor wir annehmen konnten → verpasster Anruf.
|
||||||
|
st.hangup('unanswered');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chan.subscribe((status: string, err?: any) => {
|
||||||
|
console.log('[CALL/recv] call-ring subscribe status:', status, err ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
console.log('[CALL/recv] unsubscribing call-ring for', myUserId);
|
||||||
|
supabase.removeChannel(chan);
|
||||||
|
};
|
||||||
|
}, [myUserId, router]);
|
||||||
|
}
|
||||||
@ -41,7 +41,7 @@ export async function registerPushTokenWithBackend(): Promise<string | null> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Android-Channel (muss vor getExpoPushTokenAsync existieren)
|
// 2) Android-Channels (muss vor getExpoPushTokenAsync existieren)
|
||||||
if (Platform.OS === 'android') {
|
if (Platform.OS === 'android') {
|
||||||
await Notifications.setNotificationChannelAsync('chat', {
|
await Notifications.setNotificationChannelAsync('chat', {
|
||||||
name: 'Chat-Nachrichten',
|
name: 'Chat-Nachrichten',
|
||||||
@ -50,6 +50,16 @@ export async function registerPushTokenWithBackend(): Promise<string | null> {
|
|||||||
lightColor: '#007AFF',
|
lightColor: '#007AFF',
|
||||||
sound: 'default',
|
sound: 'default',
|
||||||
});
|
});
|
||||||
|
// Dedizierter Channel für eingehende Anrufe — MAX importance, längere
|
||||||
|
// Vibration, bypasst DND nicht (das bräuchte Critical-Alert-Permission).
|
||||||
|
await Notifications.setNotificationChannelAsync('calls', {
|
||||||
|
name: 'Anrufe',
|
||||||
|
importance: Notifications.AndroidImportance.MAX,
|
||||||
|
vibrationPattern: [0, 500, 200, 500, 200, 500],
|
||||||
|
lightColor: '#007AFF',
|
||||||
|
sound: 'default',
|
||||||
|
lockscreenVisibility: Notifications.AndroidNotificationVisibility.PUBLIC,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Token holen
|
// 3) Token holen
|
||||||
|
|||||||
134
apps/rebreak-native/lib/callkit.ts
Normal file
134
apps/rebreak-native/lib/callkit.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* CallKit (iOS) / ConnectionService (Android) Wrapper.
|
||||||
|
*
|
||||||
|
* Zentralisiert react-native-callkeep so dass stores/call.ts platform-agnostic
|
||||||
|
* bleibt. Drei Verantwortungen:
|
||||||
|
* 1. setup() einmal beim App-Start (Permission-Prompt auf Android)
|
||||||
|
* 2. displayIncomingCall() wenn Push/Realtime ein Ring signalisiert
|
||||||
|
* 3. startCall() wenn der User selbst einen Anruf initiiert
|
||||||
|
*
|
||||||
|
* Privacy für DiGA (sensible Userbasis, Art. 9 DSGVO):
|
||||||
|
* - includesCallsInRecents: false → KEIN iCloud-Sync der Anrufliste
|
||||||
|
* - handle = userId (Email-Type) → keine Telefonnummern-Style-Anzeige
|
||||||
|
* - appName "ReBreak-Audio" → erscheint im Lockscreen-Banner
|
||||||
|
*/
|
||||||
|
import { Platform, PermissionsAndroid } from 'react-native';
|
||||||
|
import RNCallKeep, { CONSTANTS as CK_CONSTANTS } from 'react-native-callkeep';
|
||||||
|
|
||||||
|
let didSetup = false;
|
||||||
|
|
||||||
|
export async function setupCallKeep(): Promise<void> {
|
||||||
|
if (didSetup) return;
|
||||||
|
try {
|
||||||
|
await RNCallKeep.setup({
|
||||||
|
ios: {
|
||||||
|
appName: 'ReBreak-Audio',
|
||||||
|
// KEIN imageName → Default-Avatar von CallKit; wir setzen den echten
|
||||||
|
// Caller-Namen via displayIncomingCall(localizedCallerName)
|
||||||
|
supportsVideo: false,
|
||||||
|
maximumCallGroups: '1',
|
||||||
|
maximumCallsPerCallGroup: '1',
|
||||||
|
// Privacy: KEINE Anrufliste in iOS-Recents (= kein iCloud-Sync, kein
|
||||||
|
// Leak an Apple). Für DiGA mit Suchterkrankungs-Zielgruppe Pflicht.
|
||||||
|
includesCallsInRecents: false,
|
||||||
|
},
|
||||||
|
android: {
|
||||||
|
alertTitle: 'Anrufe in ReBreak zulassen',
|
||||||
|
alertDescription:
|
||||||
|
'ReBreak braucht Telefon-Konto-Berechtigung, um eingehende Sprach-Anrufe wie in Telefon-Apps anzuzeigen.',
|
||||||
|
cancelButton: 'Nicht jetzt',
|
||||||
|
okButton: 'Erlauben',
|
||||||
|
// Foreground-Service für Android 11+ Mic-Background-Access
|
||||||
|
foregroundService: {
|
||||||
|
channelId: 'org.rebreak.app.calls',
|
||||||
|
channelName: 'ReBreak-Anrufe',
|
||||||
|
notificationTitle: 'ReBreak: Anruf aktiv',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
RNCallKeep.setAvailable(true);
|
||||||
|
// Android 14+: Full-Screen-Intent für Lockscreen-Call-UI braucht extra Permission
|
||||||
|
try {
|
||||||
|
await PermissionsAndroid.request(
|
||||||
|
// @ts-ignore — RN-Types haben USE_FULL_SCREEN_INTENT evtl. noch nicht
|
||||||
|
'android.permission.USE_FULL_SCREEN_INTENT' as any,
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
didSetup = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[callkeep] setup failed', e?.message ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UUID-Generator — CallKit braucht lowercase UUID (Apple-Quirk).
|
||||||
|
* Wir mappen 1:1 callId ↔ callUUID (deterministic) damit beide Apps dieselbe ID nutzen.
|
||||||
|
*/
|
||||||
|
export function callIdToUuid(callId: string): string {
|
||||||
|
// CallId hat Format: "<timestamp>-<random8>", z.B. "1717491600000-abc12345"
|
||||||
|
// Wir bauen daraus eine deterministische UUID v4-Form via simple Hex-Padding.
|
||||||
|
const clean = callId.replace(/[^a-z0-9]/gi, '').toLowerCase().padEnd(32, '0').slice(0, 32);
|
||||||
|
return `${clean.slice(0, 8)}-${clean.slice(8, 12)}-4${clean.slice(13, 16)}-8${clean.slice(17, 20)}-${clean.slice(20, 32)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayIncomingCall(callId: string, callerName: string): void {
|
||||||
|
try {
|
||||||
|
const uuid = callIdToUuid(callId);
|
||||||
|
RNCallKeep.displayIncomingCall(
|
||||||
|
uuid,
|
||||||
|
callerName, // handle — wird im iOS-Lockscreen als Untertitel angezeigt
|
||||||
|
callerName, // localizedCallerName — als Titel
|
||||||
|
'generic', // handleType: kein Phone-Number-Format
|
||||||
|
false, // hasVideo
|
||||||
|
);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[callkeep] displayIncomingCall failed', e?.message ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startOutgoingCall(callId: string, calleeName: string): void {
|
||||||
|
try {
|
||||||
|
const uuid = callIdToUuid(callId);
|
||||||
|
RNCallKeep.startCall(uuid, calleeName, calleeName, 'generic', false);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn('[callkeep] startCall failed', e?.message ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportConnected(callId: string): void {
|
||||||
|
try {
|
||||||
|
const uuid = callIdToUuid(callId);
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
RNCallKeep.setCurrentCallActive(uuid);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function endCall(callId: string): void {
|
||||||
|
try {
|
||||||
|
const uuid = callIdToUuid(callId);
|
||||||
|
RNCallKeep.endCall(uuid);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportEnded(
|
||||||
|
callId: string,
|
||||||
|
reason: 'failed' | 'declined' | 'unanswered' | 'ended' = 'ended',
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const uuid = callIdToUuid(callId);
|
||||||
|
const reasonCode =
|
||||||
|
reason === 'failed'
|
||||||
|
? CK_CONSTANTS.END_CALL_REASONS.FAILED
|
||||||
|
: reason === 'unanswered'
|
||||||
|
? CK_CONSTANTS.END_CALL_REASONS.UNANSWERED
|
||||||
|
: reason === 'declined'
|
||||||
|
? CK_CONSTANTS.END_CALL_REASONS.MISSED
|
||||||
|
: CK_CONSTANTS.END_CALL_REASONS.REMOTE_ENDED;
|
||||||
|
RNCallKeep.reportEndCallWithUUID(uuid, reasonCode);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RNCallKeep };
|
||||||
53
apps/rebreak-native/lib/ringback.ts
Normal file
53
apps/rebreak-native/lib/ringback.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Ringback-Sound für ausgehende Voice-Calls.
|
||||||
|
*
|
||||||
|
* Warum nicht InCallManager.startRingback?
|
||||||
|
* - Auf iOS nutzt InCallManager System-Ringback, der je nach Locale/User-
|
||||||
|
* Setting unterschiedlich klingt (oft fällt es auf den User-Ringtone
|
||||||
|
* zurück → verwirrend, weil das wie ein eingehender Call klingt).
|
||||||
|
* - Wir wollen einen konsistenten "tüüüt-tüüüt"-Ton (EU-Festnetz-Standard
|
||||||
|
* 425 Hz, 1s an / 4s aus, ITU-T E.180) auf BEIDEN Plattformen.
|
||||||
|
*
|
||||||
|
* Asset: `assets/sounds/ringback_eu.mp3` — selbst generiert mit ffmpeg,
|
||||||
|
* CC0 (Public Domain, kein Lizenz-Risiko).
|
||||||
|
*/
|
||||||
|
import { Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
|
||||||
|
|
||||||
|
let sound: Audio.Sound | null = null;
|
||||||
|
|
||||||
|
export async function startRingback(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Audio-Mode aktiv setzen damit der Ton im Earpiece spielt
|
||||||
|
// (NICHT laut über Speaker — wie bei echten Anrufen).
|
||||||
|
await Audio.setAudioModeAsync({
|
||||||
|
playsInSilentModeIOS: true,
|
||||||
|
allowsRecordingIOS: false,
|
||||||
|
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
||||||
|
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix,
|
||||||
|
shouldDuckAndroid: true,
|
||||||
|
playThroughEarpieceAndroid: true,
|
||||||
|
staysActiveInBackground: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sound) {
|
||||||
|
try { await sound.unloadAsync(); } catch {}
|
||||||
|
sound = null;
|
||||||
|
}
|
||||||
|
const { sound: s } = await Audio.Sound.createAsync(
|
||||||
|
require('../assets/sounds/ringback_eu.mp3'),
|
||||||
|
{ shouldPlay: true, isLooping: true, volume: 1.0 },
|
||||||
|
);
|
||||||
|
sound = s;
|
||||||
|
} catch (e: any) {
|
||||||
|
// Best-effort — falls Sound-System hängt soll der Call trotzdem weiterlaufen.
|
||||||
|
console.warn('[ringback] start failed', e?.message ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopRingback(): Promise<void> {
|
||||||
|
if (!sound) return;
|
||||||
|
const s = sound;
|
||||||
|
sound = null;
|
||||||
|
try { await s.stopAsync(); } catch {}
|
||||||
|
try { await s.unloadAsync(); } catch {}
|
||||||
|
}
|
||||||
@ -21,6 +21,15 @@ export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
|
|||||||
detectSessionInUrl: false,
|
detectSessionInUrl: false,
|
||||||
},
|
},
|
||||||
realtime: {
|
realtime: {
|
||||||
|
// WICHTIG: vsn '1.0.0' (reines JSON-Text-Protokoll) statt default '2.0.0'
|
||||||
|
// (Phoenix-V2-Binary). Unser self-hosted Realtime-Container v2.28.32 hat
|
||||||
|
// den V2-Binary-Decoder nicht — bekommt er einen Binary-Frame, crasht der
|
||||||
|
// Channel-Process mit FunctionClauseError und reißt ALLE anderen Channels
|
||||||
|
// auf demselben Socket mit (1011 / "socket closed 1011"). Spam von
|
||||||
|
// notifRealtime/approvalRealtime + Call-Ring-Drops sind genau das.
|
||||||
|
// Sobald der Realtime-Container auf >=v2.34 upgraded ist, kann dieser
|
||||||
|
// Override entfernt werden. Repo-Memory: supabase-realtime-binary-crash.md
|
||||||
|
vsn: '1.0.0',
|
||||||
params: {
|
params: {
|
||||||
apikey: supabaseAnonKey,
|
apikey: supabaseAnonKey,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -999,6 +999,11 @@
|
|||||||
"you": "أنت: ",
|
"you": "أنت: ",
|
||||||
"just_now": "الآن",
|
"just_now": "الآن",
|
||||||
"voice_message": "رسالة صوتية",
|
"voice_message": "رسالة صوتية",
|
||||||
|
"call_audio": "مكالمة صوتية",
|
||||||
|
"call_missed": "مكالمة فائتة",
|
||||||
|
"call_no_answer": "لا يوجد رد",
|
||||||
|
"call_declined": "تم رفض المكالمة",
|
||||||
|
"call_failed": "فشلت المكالمة",
|
||||||
"photo": "صورة",
|
"photo": "صورة",
|
||||||
"media_sent": "وسائط",
|
"media_sent": "وسائط",
|
||||||
"new_conversation": "محادثة جديدة",
|
"new_conversation": "محادثة جديدة",
|
||||||
@ -1398,6 +1403,24 @@
|
|||||||
"crisis_emergency_cta": "112 — الطوارئ",
|
"crisis_emergency_cta": "112 — الطوارئ",
|
||||||
"crisis_disclaimer": "هذه الجهات مستقلة عن rebreak. نحيلك إليها ولكننا لا نُقدّم الإرشاد بأنفسنا."
|
"crisis_disclaimer": "هذه الجهات مستقلة عن rebreak. نحيلك إليها ولكننا لا نُقدّم الإرشاد بأنفسنا."
|
||||||
},
|
},
|
||||||
|
"call": {
|
||||||
|
"title": "مكالمة",
|
||||||
|
"calling": "جارٍ الاتصال…",
|
||||||
|
"incoming": "مكالمة واردة",
|
||||||
|
"connecting": "جارٍ الاتصال…",
|
||||||
|
"declined": "مرفوضة",
|
||||||
|
"no_answer": "لا إجابة",
|
||||||
|
"failed": "فشلت المكالمة",
|
||||||
|
"ended": "انتهت المكالمة",
|
||||||
|
"decline": "رفض",
|
||||||
|
"accept": "قبول",
|
||||||
|
"mute": "كتم",
|
||||||
|
"unmute": "إلغاء الكتم",
|
||||||
|
"speaker_on": "مكبر الصوت",
|
||||||
|
"speaker_off": "سماعة الأذن",
|
||||||
|
"hang_up": "إنهاء",
|
||||||
|
"needs_rebuild": "المكالمات تحتاج تحديث التطبيق — متاحة بعد البناء التالي."
|
||||||
|
},
|
||||||
"presence": {
|
"presence": {
|
||||||
"online": "متصل",
|
"online": "متصل",
|
||||||
"typing": "يكتب",
|
"typing": "يكتب",
|
||||||
|
|||||||
@ -1070,6 +1070,11 @@
|
|||||||
"you": "Du: ",
|
"you": "Du: ",
|
||||||
"just_now": "gerade",
|
"just_now": "gerade",
|
||||||
"voice_message": "Sprachnachricht",
|
"voice_message": "Sprachnachricht",
|
||||||
|
"call_audio": "Audioanruf",
|
||||||
|
"call_missed": "Verpasster Anruf",
|
||||||
|
"call_no_answer": "Keine Antwort",
|
||||||
|
"call_declined": "Anruf abgelehnt",
|
||||||
|
"call_failed": "Anruf fehlgeschlagen",
|
||||||
"photo": "Foto",
|
"photo": "Foto",
|
||||||
"media_sent": "Medien",
|
"media_sent": "Medien",
|
||||||
"new_conversation": "Neue Unterhaltung",
|
"new_conversation": "Neue Unterhaltung",
|
||||||
@ -1470,6 +1475,24 @@
|
|||||||
"crisis_emergency_cta": "112 — Notruf",
|
"crisis_emergency_cta": "112 — Notruf",
|
||||||
"crisis_disclaimer": "Diese Stellen sind unabhängig von Rebreak. Wir verweisen weiter, beraten aber nicht selbst."
|
"crisis_disclaimer": "Diese Stellen sind unabhängig von Rebreak. Wir verweisen weiter, beraten aber nicht selbst."
|
||||||
},
|
},
|
||||||
|
"call": {
|
||||||
|
"title": "Anruf",
|
||||||
|
"calling": "Wird angerufen…",
|
||||||
|
"incoming": "Eingehender Anruf",
|
||||||
|
"connecting": "Verbinde…",
|
||||||
|
"declined": "Abgelehnt",
|
||||||
|
"no_answer": "Keine Antwort",
|
||||||
|
"failed": "Anruf fehlgeschlagen",
|
||||||
|
"ended": "Anruf beendet",
|
||||||
|
"decline": "Ablehnen",
|
||||||
|
"accept": "Annehmen",
|
||||||
|
"mute": "Stumm",
|
||||||
|
"unmute": "Laut",
|
||||||
|
"speaker_on": "Lautsprecher",
|
||||||
|
"speaker_off": "Hörer",
|
||||||
|
"hang_up": "Auflegen",
|
||||||
|
"needs_rebuild": "Anrufe brauchen ein App-Update — verfügbar nach dem nächsten Build."
|
||||||
|
},
|
||||||
"presence": {
|
"presence": {
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"typing": "schreibt",
|
"typing": "schreibt",
|
||||||
|
|||||||
@ -1068,6 +1068,11 @@
|
|||||||
"you": "You: ",
|
"you": "You: ",
|
||||||
"just_now": "just now",
|
"just_now": "just now",
|
||||||
"voice_message": "Voice message",
|
"voice_message": "Voice message",
|
||||||
|
"call_audio": "Audio call",
|
||||||
|
"call_missed": "Missed call",
|
||||||
|
"call_no_answer": "No answer",
|
||||||
|
"call_declined": "Call declined",
|
||||||
|
"call_failed": "Call failed",
|
||||||
"photo": "Photo",
|
"photo": "Photo",
|
||||||
"media_sent": "Media",
|
"media_sent": "Media",
|
||||||
"new_conversation": "New conversation",
|
"new_conversation": "New conversation",
|
||||||
@ -1468,6 +1473,24 @@
|
|||||||
"crisis_emergency_cta": "112 — Emergency",
|
"crisis_emergency_cta": "112 — Emergency",
|
||||||
"crisis_disclaimer": "These services are independent of Rebreak. We refer you onward but do not offer counselling ourselves."
|
"crisis_disclaimer": "These services are independent of Rebreak. We refer you onward but do not offer counselling ourselves."
|
||||||
},
|
},
|
||||||
|
"call": {
|
||||||
|
"title": "Call",
|
||||||
|
"calling": "Calling…",
|
||||||
|
"incoming": "Incoming call",
|
||||||
|
"connecting": "Connecting…",
|
||||||
|
"declined": "Declined",
|
||||||
|
"no_answer": "No answer",
|
||||||
|
"failed": "Call failed",
|
||||||
|
"ended": "Call ended",
|
||||||
|
"decline": "Decline",
|
||||||
|
"accept": "Accept",
|
||||||
|
"mute": "Mute",
|
||||||
|
"unmute": "Unmute",
|
||||||
|
"speaker_on": "Speaker",
|
||||||
|
"speaker_off": "Earpiece",
|
||||||
|
"hang_up": "End",
|
||||||
|
"needs_rebuild": "Calls need an app update — available after the next build."
|
||||||
|
},
|
||||||
"presence": {
|
"presence": {
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"typing": "typing",
|
"typing": "typing",
|
||||||
|
|||||||
@ -988,6 +988,11 @@
|
|||||||
"you": "Vous : ",
|
"you": "Vous : ",
|
||||||
"just_now": "à l'instant",
|
"just_now": "à l'instant",
|
||||||
"voice_message": "Message vocal",
|
"voice_message": "Message vocal",
|
||||||
|
"call_audio": "Appel audio",
|
||||||
|
"call_missed": "Appel manqué",
|
||||||
|
"call_no_answer": "Pas de réponse",
|
||||||
|
"call_declined": "Appel refusé",
|
||||||
|
"call_failed": "Échec de l'appel",
|
||||||
"photo": "Photo",
|
"photo": "Photo",
|
||||||
"media_sent": "Média",
|
"media_sent": "Média",
|
||||||
"new_conversation": "Nouvelle conversation",
|
"new_conversation": "Nouvelle conversation",
|
||||||
@ -1384,6 +1389,24 @@
|
|||||||
"crisis_emergency_cta": "112 — Urgences",
|
"crisis_emergency_cta": "112 — Urgences",
|
||||||
"crisis_disclaimer": "Ces services sont indépendants de Rebreak. Nous vous orientons mais n'assurons pas de conseil nous-mêmes."
|
"crisis_disclaimer": "Ces services sont indépendants de Rebreak. Nous vous orientons mais n'assurons pas de conseil nous-mêmes."
|
||||||
},
|
},
|
||||||
|
"call": {
|
||||||
|
"title": "Appel",
|
||||||
|
"calling": "Appel sortant…",
|
||||||
|
"incoming": "Appel entrant",
|
||||||
|
"connecting": "Connexion…",
|
||||||
|
"declined": "Refusé",
|
||||||
|
"no_answer": "Pas de réponse",
|
||||||
|
"failed": "Échec de l'appel",
|
||||||
|
"ended": "Appel terminé",
|
||||||
|
"decline": "Refuser",
|
||||||
|
"accept": "Accepter",
|
||||||
|
"mute": "Muet",
|
||||||
|
"unmute": "Son",
|
||||||
|
"speaker_on": "Haut-parleur",
|
||||||
|
"speaker_off": "Écouteur",
|
||||||
|
"hang_up": "Raccrocher",
|
||||||
|
"needs_rebuild": "Les appels nécessitent une mise à jour — disponibles après la prochaine build."
|
||||||
|
},
|
||||||
"presence": {
|
"presence": {
|
||||||
"online": "En ligne",
|
"online": "En ligne",
|
||||||
"typing": "écrit",
|
"typing": "écrit",
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.3.13</string>
|
<string>0.3.13</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>70</string>
|
<string>73</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.3.13</string>
|
<string>0.3.13</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>70</string>
|
<string>73</string>
|
||||||
<key>NSExtension</key>
|
<key>NSExtension</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>0.3.13</string>
|
<string>0.3.13</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>70</string>
|
<string>73</string>
|
||||||
<key>EXAppExtensionAttributes</key>
|
<key>EXAppExtensionAttributes</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>EXExtensionPointIdentifier</key>
|
<key>EXExtensionPointIdentifier</key>
|
||||||
|
|||||||
@ -12,6 +12,8 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@config-plugins/react-native-callkeep": "^12.0.0",
|
||||||
|
"@config-plugins/react-native-webrtc": "^15.0.1",
|
||||||
"@expo-google-fonts/nunito": "^0.2.3",
|
"@expo-google-fonts/nunito": "^0.2.3",
|
||||||
"@expo/metro-runtime": "~6.1.2",
|
"@expo/metro-runtime": "~6.1.2",
|
||||||
"@expo/react-native-action-sheet": "^4.1.1",
|
"@expo/react-native-action-sheet": "^4.1.1",
|
||||||
@ -61,7 +63,9 @@
|
|||||||
"react-i18next": "^15.1.0",
|
"react-i18next": "^15.1.0",
|
||||||
"react-native": "0.81.5",
|
"react-native": "0.81.5",
|
||||||
"react-native-bottom-tabs": "^1.2.0",
|
"react-native-bottom-tabs": "^1.2.0",
|
||||||
|
"react-native-callkeep": "^4.3.16",
|
||||||
"react-native-gesture-handler": "~2.28.0",
|
"react-native-gesture-handler": "~2.28.0",
|
||||||
|
"react-native-incall-manager": "^4.2.1",
|
||||||
"react-native-keyboard-controller": "^1.21.7",
|
"react-native-keyboard-controller": "^1.21.7",
|
||||||
"react-native-mmkv": "^3.1.0",
|
"react-native-mmkv": "^3.1.0",
|
||||||
"react-native-reanimated": "~4.1.7",
|
"react-native-reanimated": "~4.1.7",
|
||||||
@ -70,6 +74,8 @@
|
|||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
|
"react-native-voip-push-notification": "^3.3.3",
|
||||||
|
"react-native-webrtc": "^124.0.7",
|
||||||
"react-native-worklets": "~0.5.1",
|
"react-native-worklets": "~0.5.1",
|
||||||
"rive-react-native": "^9.0.1",
|
"rive-react-native": "^9.0.1",
|
||||||
"tailwindcss": "^3.4.14",
|
"tailwindcss": "^3.4.14",
|
||||||
|
|||||||
@ -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;
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
487
apps/rebreak-native/stores/call.ts
Normal file
487
apps/rebreak-native/stores/call.ts
Normal file
@ -0,0 +1,487 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { NativeModules } from 'react-native';
|
||||||
|
import type { RealtimeChannel } from '@supabase/supabase-js';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
import { apiFetch } from '../lib/api';
|
||||||
|
import { startRingback, stopRingback } from '../lib/ringback';
|
||||||
|
import * as callkit from '../lib/callkit';
|
||||||
|
|
||||||
|
// ─── Voice-Call-Engine (1:1, Audio-only, foreground-only / Phase 1) ──────────
|
||||||
|
//
|
||||||
|
// Signaling läuft über Supabase Realtime Broadcast — KEIN eigener Signaling-
|
||||||
|
// Server nötig:
|
||||||
|
// • Ring-Channel `call-ring:<userId>` → nur die initiale Einladung/Abbruch.
|
||||||
|
// • Call-Channel `call:<callId>` → ready/offer/answer/ice/decline/hangup.
|
||||||
|
//
|
||||||
|
// Handshake-Reihenfolge (verhindert "offer kommt bevor Callee subscribed"-Race):
|
||||||
|
// Caller: ring → join call-channel → WARTET auf `ready` → erst dann offer.
|
||||||
|
// Callee: accept → join call-channel → `ready` → wartet auf offer → answer.
|
||||||
|
//
|
||||||
|
// react-native-webrtc ist ein natives Modul → LAZY require + Guard. In einem
|
||||||
|
// Build ohne das Modul (z.B. der aktuelle Dev-Build) ist isWebRTCAvailable()
|
||||||
|
// false und der Call-Button meldet "Rebuild nötig" statt zu crashen.
|
||||||
|
|
||||||
|
export function isWebRTCAvailable(): boolean {
|
||||||
|
return !!(NativeModules as any).WebRTCModule;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CallStatus =
|
||||||
|
| 'idle'
|
||||||
|
| 'outgoing' // wir rufen an, warten auf Annahme
|
||||||
|
| 'incoming' // es klingelt bei uns
|
||||||
|
| 'connecting' // angenommen, ICE/DTLS baut auf
|
||||||
|
| 'connected' // Audio läuft
|
||||||
|
| 'ended';
|
||||||
|
|
||||||
|
export type CallPeer = { id: string; nickname: string; avatar: string | null };
|
||||||
|
export type CallEndReason = 'declined' | 'ended' | 'failed' | 'unanswered' | 'busy' | null;
|
||||||
|
|
||||||
|
const UNANSWERED_MS = 35_000;
|
||||||
|
|
||||||
|
// Nicht-reaktive Handles (gehören nicht in den Zustand-State).
|
||||||
|
let pc: any = null;
|
||||||
|
let localStream: any = null;
|
||||||
|
let callChan: RealtimeChannel | null = null;
|
||||||
|
let pendingRemoteIce: any[] = [];
|
||||||
|
let unansweredTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let selfMe: CallPeer | null = null; // für Caller-Side DM-Logging
|
||||||
|
let currentRole: 'caller' | 'callee' | null = null;
|
||||||
|
let loggedCallId: string | null = null; // Idempotenz-Guard für DM-Log
|
||||||
|
|
||||||
|
type CallState = {
|
||||||
|
status: CallStatus;
|
||||||
|
peer: CallPeer | null;
|
||||||
|
callId: string | null;
|
||||||
|
muted: boolean;
|
||||||
|
speaker: boolean;
|
||||||
|
startedAt: number | null;
|
||||||
|
endReason: CallEndReason;
|
||||||
|
|
||||||
|
startCall: (peer: CallPeer, me: CallPeer) => Promise<void>;
|
||||||
|
receiveIncoming: (callId: string, from: CallPeer) => void;
|
||||||
|
acceptCall: () => Promise<void>;
|
||||||
|
declineCall: () => void;
|
||||||
|
hangup: (reason?: CallEndReason) => void;
|
||||||
|
toggleMute: () => void;
|
||||||
|
toggleSpeaker: () => void;
|
||||||
|
_clear: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function rtc() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
return require('react-native-webrtc');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio-Routing (Earpiece vs. Lautsprecher) über AVAudioSession (iOS) /
|
||||||
|
// AudioManager (Android). react-native-webrtc exposed das nicht direkt.
|
||||||
|
function inCall() {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
return require('react-native-incall-manager').default;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diagnose-Logging (taucht in Metro-Konsole + adb logcat ReactNativeJS auf).
|
||||||
|
function clog(...args: any[]) {
|
||||||
|
console.log('[CALL]', ...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget: einem noch klingelnden Callee (noch nicht im Call-Channel)
|
||||||
|
// den Abbruch auf seinem Ring-Channel signalisieren.
|
||||||
|
function fireRingCancel(peerId: string, callId: string) {
|
||||||
|
const chan = supabase.channel(`call-ring:${peerId}`);
|
||||||
|
chan.subscribe((s: string) => {
|
||||||
|
if (s === 'SUBSCRIBED') {
|
||||||
|
chan.send({ type: 'broadcast', event: 'cancel', payload: { callId } });
|
||||||
|
setTimeout(() => supabase.removeChannel(chan), 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function teardown() {
|
||||||
|
if (unansweredTimer) { clearTimeout(unansweredTimer); unansweredTimer = null; }
|
||||||
|
try { pc?.close?.(); } catch {}
|
||||||
|
try { localStream?.getTracks?.().forEach((t: any) => t.stop()); } catch {}
|
||||||
|
if (callChan) { supabase.removeChannel(callChan); callChan = null; }
|
||||||
|
// Audio-Session beenden → normales Audio-Routing wiederherstellen.
|
||||||
|
// stopRingtone/stopRingback sind no-ops wenn nichts läuft — safe.
|
||||||
|
try { inCall().stopRingtone(); } catch {}
|
||||||
|
void stopRingback();
|
||||||
|
try { inCall().stop(); } catch {}
|
||||||
|
pc = null;
|
||||||
|
localStream = null;
|
||||||
|
pendingRemoteIce = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// DM-Log nach Call-Ende. Nur der CALLER schreibt (verhindert Duplikate).
|
||||||
|
// Format: attachmentType='call', attachmentName='<kind>:<state>:<durSec>'.
|
||||||
|
async function logCallToChat(
|
||||||
|
peerId: string,
|
||||||
|
callId: string,
|
||||||
|
state: 'ended' | 'unanswered' | 'declined' | 'failed' | 'busy',
|
||||||
|
startedAt: number | null,
|
||||||
|
) {
|
||||||
|
// Caller loggt jeden Call. Callee loggt NUR missed/declined/unanswered
|
||||||
|
// wenn der Caller dazu nicht kommt (z.B. Callee drückt 'decline' bevor der
|
||||||
|
// Caller-DM-Call durchgeht). Verbundene Calls werden vom Caller geloggt.
|
||||||
|
if (currentRole === 'callee' && state === 'ended') return;
|
||||||
|
if (loggedCallId === callId) return;
|
||||||
|
loggedCallId = callId;
|
||||||
|
const durSec = startedAt ? Math.max(0, Math.round((Date.now() - startedAt) / 1000)) : 0;
|
||||||
|
const meta = `audio:${state}:${durSec}`;
|
||||||
|
try {
|
||||||
|
await apiFetch('/api/chat/dm', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
receiverId: peerId,
|
||||||
|
content: '',
|
||||||
|
attachmentType: 'call',
|
||||||
|
attachmentName: meta,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
clog('logged call DM →', peerId, meta);
|
||||||
|
} catch (e: any) {
|
||||||
|
clog('logCallToChat FAILED', e?.message ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useCallStore = create<CallState>((set, get) => {
|
||||||
|
// ─── WebRTC-Setup gemeinsam für beide Seiten ──────────────────────────────
|
||||||
|
async function buildPeer() {
|
||||||
|
const { RTCPeerConnection } = rtc();
|
||||||
|
clog('buildPeer: fetching ice-servers…');
|
||||||
|
let ice: { iceServers: any[]; iceTransportPolicy?: string };
|
||||||
|
try {
|
||||||
|
ice = await apiFetch('/api/calls/ice-servers');
|
||||||
|
} catch (e: any) {
|
||||||
|
clog('buildPeer: ICE-FETCH FAILED', e?.message ?? e, e?.statusCode ?? '');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
clog('buildPeer: ice ok', JSON.stringify({
|
||||||
|
servers: ice.iceServers?.length,
|
||||||
|
policy: ice.iceTransportPolicy,
|
||||||
|
urls: ice.iceServers?.[0]?.urls,
|
||||||
|
}));
|
||||||
|
pc = new RTCPeerConnection({
|
||||||
|
iceServers: ice.iceServers,
|
||||||
|
iceTransportPolicy: (ice.iceTransportPolicy as any) ?? 'relay',
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.addEventListener('icecandidate', (e: any) => {
|
||||||
|
if (e.candidate && callChan) {
|
||||||
|
callChan.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'ice',
|
||||||
|
payload: {
|
||||||
|
candidate: e.candidate.candidate,
|
||||||
|
sdpMid: e.candidate.sdpMid,
|
||||||
|
sdpMLineIndex: e.candidate.sdpMLineIndex,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pc.addEventListener('icecandidateerror', (e: any) => {
|
||||||
|
clog('ICE-CANDIDATE-ERROR', e?.errorCode, e?.errorText, e?.url);
|
||||||
|
});
|
||||||
|
pc.addEventListener('iceconnectionstatechange', () => {
|
||||||
|
clog('iceConnectionState =', pc?.iceConnectionState);
|
||||||
|
});
|
||||||
|
pc.addEventListener('icegatheringstatechange', () => {
|
||||||
|
clog('iceGatheringState =', pc?.iceGatheringState);
|
||||||
|
});
|
||||||
|
|
||||||
|
pc.addEventListener('connectionstatechange', () => {
|
||||||
|
const st = pc?.connectionState;
|
||||||
|
clog('connectionState =', st);
|
||||||
|
if (st === 'connected') {
|
||||||
|
if (get().status !== 'connected') {
|
||||||
|
set({ status: 'connected', startedAt: Date.now() });
|
||||||
|
}
|
||||||
|
// Ringback aus — Verbindung steht.
|
||||||
|
void stopRingback();
|
||||||
|
// CallKit/ConnectionService: Call als aktiv markieren (Android needs this
|
||||||
|
// für Mute/Speaker-Controls).
|
||||||
|
try {
|
||||||
|
const cid = get().callId;
|
||||||
|
if (cid) callkit.reportConnected(cid);
|
||||||
|
} catch {}
|
||||||
|
// WebRTC hat seine eigene AVAudioSession aktiviert und unser früherer
|
||||||
|
// InCallManager-Call ist evtl. überschrieben worden — Speaker-Route
|
||||||
|
// jetzt erneut setzen, damit der User-Toggle wirklich greift.
|
||||||
|
try {
|
||||||
|
inCall().setForceSpeakerphoneOn(get().speaker);
|
||||||
|
clog('post-connect: speaker route applied =', get().speaker);
|
||||||
|
} catch (e: any) {
|
||||||
|
clog('post-connect setForceSpeakerphoneOn failed:', e?.message ?? e);
|
||||||
|
}
|
||||||
|
} else if (st === 'failed') {
|
||||||
|
get().hangup('failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mikrofon holen + Track anhängen.
|
||||||
|
clog('buildPeer: getUserMedia(audio)…');
|
||||||
|
const { mediaDevices } = rtc();
|
||||||
|
try {
|
||||||
|
localStream = await mediaDevices.getUserMedia({ audio: true, video: false });
|
||||||
|
} catch (e: any) {
|
||||||
|
clog('buildPeer: getUserMedia FAILED', e?.message ?? e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
localStream.getTracks().forEach((t: any) => pc.addTrack(t, localStream));
|
||||||
|
// Audio-Session in den "In-Call"-Mode bringen (richtet Routing, Bluetooth,
|
||||||
|
// Audio-Focus etc. ein). Default = Earpiece, NICHT Speaker.
|
||||||
|
try {
|
||||||
|
inCall().start({ media: 'audio', auto: false });
|
||||||
|
inCall().setForceSpeakerphoneOn(get().speaker);
|
||||||
|
} catch (e: any) {
|
||||||
|
clog('InCallManager start failed:', e?.message ?? e);
|
||||||
|
}
|
||||||
|
clog('buildPeer: done (track added)');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRemoteIce(payload: any) {
|
||||||
|
const { RTCIceCandidate } = rtc();
|
||||||
|
const cand = new RTCIceCandidate(payload);
|
||||||
|
if (pc?.remoteDescription) {
|
||||||
|
pc.addIceCandidate(cand).catch(() => {});
|
||||||
|
} else {
|
||||||
|
pendingRemoteIce.push(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drainIce() {
|
||||||
|
const { RTCIceCandidate } = rtc();
|
||||||
|
pendingRemoteIce.forEach((p) => pc?.addIceCandidate(new RTCIceCandidate(p)).catch(() => {}));
|
||||||
|
pendingRemoteIce = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-Call-Channel beitreten + Events verdrahten. role steuert offer/answer.
|
||||||
|
function joinCallChannel(callId: string, role: 'caller' | 'callee') {
|
||||||
|
const chan = supabase.channel(`call:${callId}`, {
|
||||||
|
config: { broadcast: { self: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
chan.on('broadcast', { event: 'ready' }, async () => {
|
||||||
|
clog('recv ready (role=' + role + ', pc=' + !!pc + ')');
|
||||||
|
if (role !== 'caller' || !pc) return;
|
||||||
|
const offer = await pc.createOffer({});
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
clog('caller: sending offer');
|
||||||
|
chan.send({ type: 'broadcast', event: 'offer', payload: { type: offer.type, sdp: offer.sdp } });
|
||||||
|
});
|
||||||
|
|
||||||
|
chan.on('broadcast', { event: 'offer' }, async (msg: any) => {
|
||||||
|
clog('recv offer (role=' + role + ', pc=' + !!pc + ')');
|
||||||
|
if (role !== 'callee' || !pc) return;
|
||||||
|
const { RTCSessionDescription } = rtc();
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(msg.payload));
|
||||||
|
drainIce();
|
||||||
|
const answer = await pc.createAnswer();
|
||||||
|
await pc.setLocalDescription(answer);
|
||||||
|
clog('callee: sending answer');
|
||||||
|
chan.send({ type: 'broadcast', event: 'answer', payload: { type: answer.type, sdp: answer.sdp } });
|
||||||
|
});
|
||||||
|
|
||||||
|
chan.on('broadcast', { event: 'answer' }, async (msg: any) => {
|
||||||
|
clog('recv answer (role=' + role + ')');
|
||||||
|
if (role !== 'caller' || !pc) return;
|
||||||
|
const { RTCSessionDescription } = rtc();
|
||||||
|
await pc.setRemoteDescription(new RTCSessionDescription(msg.payload));
|
||||||
|
drainIce();
|
||||||
|
});
|
||||||
|
|
||||||
|
chan.on('broadcast', { event: 'ice' }, (msg: any) => addRemoteIce(msg.payload));
|
||||||
|
chan.on('broadcast', { event: 'decline' }, () => { clog('recv decline'); get().hangup('declined'); });
|
||||||
|
chan.on('broadcast', { event: 'hangup' }, () => { clog('recv hangup'); get().hangup('ended'); });
|
||||||
|
|
||||||
|
callChan = chan;
|
||||||
|
return chan;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'idle',
|
||||||
|
peer: null,
|
||||||
|
callId: null,
|
||||||
|
muted: false,
|
||||||
|
speaker: false,
|
||||||
|
startedAt: null,
|
||||||
|
endReason: null,
|
||||||
|
|
||||||
|
// ─── Caller ──────────────────────────────────────────────────────────
|
||||||
|
startCall: async (peer, me) => {
|
||||||
|
if (get().status !== 'idle') return;
|
||||||
|
if (!isWebRTCAvailable()) throw new Error('webrtc_unavailable');
|
||||||
|
const callId = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
clog('startCall → callee', peer.id, 'callId', callId);
|
||||||
|
selfMe = me;
|
||||||
|
currentRole = 'caller';
|
||||||
|
loggedCallId = null;
|
||||||
|
set({ status: 'outgoing', peer, callId, muted: false, speaker: false, startedAt: null, endReason: null });
|
||||||
|
|
||||||
|
// 1) Callee anklingeln (ephemerer Ring-Channel des Callees).
|
||||||
|
const ring = supabase.channel(`call-ring:${peer.id}`);
|
||||||
|
await new Promise<void>((res) => ring.subscribe((s: string) => s === 'SUBSCRIBED' && res()));
|
||||||
|
clog('ring channel subscribed → sending ring');
|
||||||
|
ring.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'ring',
|
||||||
|
payload: { callId, from: { id: me.id, nickname: me.nickname, avatar: me.avatar } },
|
||||||
|
});
|
||||||
|
setTimeout(() => supabase.removeChannel(ring), 300);
|
||||||
|
|
||||||
|
// Background-Push an den Callee (deckt den Fall ab, dass die App nicht
|
||||||
|
// im Foreground ist — Realtime ist dann nicht subscribed). Fire-and-forget:
|
||||||
|
// ein fehlgeschlagener Push darf den Call nicht blocken.
|
||||||
|
apiFetch('/api/calls/ring', {
|
||||||
|
method: 'POST',
|
||||||
|
body: { peerId: peer.id, callId },
|
||||||
|
}).catch((e: any) => clog('ring-push failed', e?.message ?? e));
|
||||||
|
|
||||||
|
// CallKit: User-Seite in Recents/UI als ausgehender Call sichtbar machen.
|
||||||
|
try { callkit.startOutgoingCall(callId, peer.nickname || 'ReBreak'); } catch {}
|
||||||
|
|
||||||
|
// Ringback-Ton für den Anrufer — eigenes mp3-Asset (EU-Standard 425 Hz,
|
||||||
|
// 1s an / 4s aus). InCallManager.start() ohne `ringback`-Param läuft
|
||||||
|
// parallel für die AVAudioSession-Aktivierung, der eigentliche Ton kommt
|
||||||
|
// von expo-av.
|
||||||
|
try {
|
||||||
|
inCall().start({ media: 'audio', auto: false });
|
||||||
|
} catch (e: any) { clog('inCall start (caller) failed', e?.message ?? e); }
|
||||||
|
void startRingback();
|
||||||
|
|
||||||
|
// 2) Call-Channel join (Handler setzen) → PeerConnection bauen → ERST DANN
|
||||||
|
// subscriben. Sonst könnte ein schnelles `ready` den offer-Handler treffen,
|
||||||
|
// bevor pc existiert → kein offer → Deadlock.
|
||||||
|
const chan = joinCallChannel(callId, 'caller');
|
||||||
|
try {
|
||||||
|
await buildPeer();
|
||||||
|
} catch (e: any) {
|
||||||
|
clog('startCall: buildPeer FAILED →', e?.message ?? e);
|
||||||
|
get().hangup('failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
chan.subscribe((s: string) => clog('caller call-channel:', s));
|
||||||
|
|
||||||
|
// 3) Timeout: keine Annahme → auflegen (hangup signalisiert Ring-Cancel).
|
||||||
|
unansweredTimer = setTimeout(() => {
|
||||||
|
if (get().status === 'outgoing') get().hangup('unanswered');
|
||||||
|
}, UNANSWERED_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ─── Callee: Klingeln empfangen ───────────────────────────────────────
|
||||||
|
receiveIncoming: (callId, from) => {
|
||||||
|
// Schon im Gespräch? → ignorieren (MVP: kein call-waiting).
|
||||||
|
if (get().status !== 'idle') return;
|
||||||
|
clog('receiveIncoming from', from.id, 'callId', callId);
|
||||||
|
currentRole = 'callee';
|
||||||
|
loggedCallId = null;
|
||||||
|
set({ status: 'incoming', peer: from, callId, muted: false, speaker: false, startedAt: null, endReason: null });
|
||||||
|
// CallKit-/ConnectionService-UI hochziehen — das zeigt nativen Call-Screen
|
||||||
|
// über Lockscreen, sogar wenn die App im Background ist.
|
||||||
|
try { callkit.displayIncomingCall(callId, from.nickname || 'ReBreak'); } catch {}
|
||||||
|
// Klingelton für den Empfänger. InCallManager.start() muss VOR
|
||||||
|
// startRingtone laufen damit iOS die AVAudioSession korrekt aktiviert.
|
||||||
|
try { inCall().start({ media: 'audio', auto: false }); } catch (e: any) { clog('inCall start (callee) failed', e?.message ?? e); }
|
||||||
|
try { inCall().startRingtone('_DEFAULT_', undefined, undefined, 30); } catch (e: any) { clog('startRingtone failed', e?.message ?? e); }
|
||||||
|
},
|
||||||
|
|
||||||
|
acceptCall: async () => {
|
||||||
|
const { callId, status } = get();
|
||||||
|
if (status !== 'incoming' || !callId) return;
|
||||||
|
if (!isWebRTCAvailable()) throw new Error('webrtc_unavailable');
|
||||||
|
clog('acceptCall callId', callId);
|
||||||
|
// Klingelton stoppen — wir nehmen ab.
|
||||||
|
try { inCall().stopRingtone(); } catch {}
|
||||||
|
set({ status: 'connecting' });
|
||||||
|
const chan = joinCallChannel(callId, 'callee');
|
||||||
|
await new Promise<void>((res) => chan.subscribe((s: string) => s === 'SUBSCRIBED' && res()));
|
||||||
|
clog('callee call-channel subscribed');
|
||||||
|
try {
|
||||||
|
await buildPeer();
|
||||||
|
} catch (e: any) {
|
||||||
|
clog('acceptCall: buildPeer FAILED →', e?.message ?? e);
|
||||||
|
get().hangup('failed');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
clog('callee: sending ready');
|
||||||
|
chan.send({ type: 'broadcast', event: 'ready', payload: {} });
|
||||||
|
},
|
||||||
|
|
||||||
|
declineCall: () => {
|
||||||
|
const { callId, peer, startedAt } = get();
|
||||||
|
if (callId) {
|
||||||
|
// CallKit/ConnectionService aus dem Lockscreen-UI entfernen.
|
||||||
|
try { callkit.endCall(callId); } catch {}
|
||||||
|
// Kurz joinen um decline zu senden (Callee war noch nicht im Channel).
|
||||||
|
const chan = supabase.channel(`call:${callId}`);
|
||||||
|
chan.subscribe((s: string) => {
|
||||||
|
if (s === 'SUBSCRIBED') {
|
||||||
|
chan.send({ type: 'broadcast', event: 'decline', payload: {} });
|
||||||
|
setTimeout(() => supabase.removeChannel(chan), 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Callee logged abgelehnten Call in eigenen Chat-Verlauf.
|
||||||
|
if (peer && callId) {
|
||||||
|
logCallToChat(peer.id, callId, 'declined', startedAt);
|
||||||
|
}
|
||||||
|
set({ status: 'ended', endReason: 'declined' });
|
||||||
|
teardown();
|
||||||
|
},
|
||||||
|
|
||||||
|
hangup: (reason = 'ended') => {
|
||||||
|
const { status, peer, callId, startedAt } = get();
|
||||||
|
if (status === 'idle' || status === 'ended') {
|
||||||
|
teardown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// CallKit/ConnectionService schließen.
|
||||||
|
if (callId) {
|
||||||
|
try {
|
||||||
|
callkit.endCall(callId);
|
||||||
|
callkit.reportEnded(callId, reason as any);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
// Peer im Call-Channel informieren (falls schon verbunden).
|
||||||
|
callChan?.send({ type: 'broadcast', event: 'hangup', payload: {} });
|
||||||
|
// Klingelt der Callee noch (noch nicht im Call-Channel)? → Ring abbrechen.
|
||||||
|
if (peer && callId && (status === 'outgoing' || status === 'connecting')) {
|
||||||
|
fireRingCancel(peer.id, callId);
|
||||||
|
}
|
||||||
|
// Call als DM ins Chat-Log schreiben (nur Caller, fire-and-forget).
|
||||||
|
if (peer && callId && reason) {
|
||||||
|
const state = (reason === null ? 'ended' : reason) as 'ended' | 'unanswered' | 'declined' | 'failed' | 'busy';
|
||||||
|
logCallToChat(peer.id, callId, state, startedAt);
|
||||||
|
}
|
||||||
|
set({ status: 'ended', endReason: reason });
|
||||||
|
teardown();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMute: () => {
|
||||||
|
const next = !get().muted;
|
||||||
|
try {
|
||||||
|
localStream?.getAudioTracks?.().forEach((t: any) => { t.enabled = !next; });
|
||||||
|
} catch {}
|
||||||
|
set({ muted: next });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSpeaker: () => {
|
||||||
|
const next = !get().speaker;
|
||||||
|
try {
|
||||||
|
// Sicherheitshalber start() erneut aufrufen — falls die AVAudioSession
|
||||||
|
// von WebRTC zwischendurch deaktiviert wurde, sonst greift
|
||||||
|
// setForceSpeakerphoneOn auf iOS nicht.
|
||||||
|
inCall().start({ media: 'audio', auto: false });
|
||||||
|
inCall().setForceSpeakerphoneOn(next);
|
||||||
|
clog('toggleSpeaker →', next);
|
||||||
|
} catch (e: any) {
|
||||||
|
clog('setForceSpeakerphoneOn failed:', e?.message ?? e);
|
||||||
|
}
|
||||||
|
set({ speaker: next });
|
||||||
|
},
|
||||||
|
|
||||||
|
_clear: () => {
|
||||||
|
teardown();
|
||||||
|
set({ status: 'idle', peer: null, callId: null, muted: false, speaker: false, startedAt: null, endReason: null });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@ -44,9 +44,15 @@ Building Release AAB (gradlew bundleRelease)|272
|
|||||||
Validating IPA (App-Store Connect)|117
|
Validating IPA (App-Store Connect)|117
|
||||||
Uploading zu App-Store Connect (TestFlight)|138
|
Uploading zu App-Store Connect (TestFlight)|138
|
||||||
Building Release AAB (gradlew bundleRelease)|273
|
Building Release AAB (gradlew bundleRelease)|273
|
||||||
Building xcarchive|213
|
|
||||||
Exporting Ad-Hoc IPA|18
|
|
||||||
Exporting App-Store IPA|23
|
|
||||||
Validating IPA (App-Store Connect)|78
|
Validating IPA (App-Store Connect)|78
|
||||||
Uploading zu App-Store Connect (TestFlight)|90
|
Uploading zu App-Store Connect (TestFlight)|90
|
||||||
Building Release AAB (gradlew bundleRelease)|321
|
Building Release AAB (gradlew bundleRelease)|321
|
||||||
|
Validating IPA (App-Store Connect)|75
|
||||||
|
Uploading zu App-Store Connect (TestFlight)|80
|
||||||
|
Building Release AAB (gradlew bundleRelease)|269
|
||||||
|
Building xcarchive|265
|
||||||
|
Exporting Ad-Hoc IPA|19
|
||||||
|
Exporting App-Store IPA|26
|
||||||
|
Validating IPA (App-Store Connect)|72
|
||||||
|
Uploading zu App-Store Connect (TestFlight)|87
|
||||||
|
Building Release AAB (gradlew bundleRelease)|299
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@parse/node-apn": "^8.1.0",
|
||||||
"@prisma/adapter-pg": "^7.2.0",
|
"@prisma/adapter-pg": "^7.2.0",
|
||||||
"@prisma/client": "^7.2.0",
|
"@prisma/client": "^7.2.0",
|
||||||
"@supabase/supabase-js": "^2.39.7",
|
"@supabase/supabase-js": "^2.39.7",
|
||||||
|
|||||||
@ -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;
|
||||||
@ -162,6 +162,10 @@ model PushToken {
|
|||||||
platform String // "ios" | "android"
|
platform String // "ios" | "android"
|
||||||
deviceId String? @map("device_id")
|
deviceId String? @map("device_id")
|
||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
|
/// iOS-only: PushKit VoIP-Token (64-char hex). Rotiert unabhängig vom Expo-Token.
|
||||||
|
/// NULL → kein CallKit-Wake-Push möglich (Android, Web, alte iOS-Builds).
|
||||||
|
/// Versendet via backend/server/services/voip-push.ts mit .p12-Cert (APNs HTTP/2).
|
||||||
|
voipToken String? @map("voip_token")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
|
||||||
lastUsedAt DateTime? @map("last_used_at")
|
lastUsedAt DateTime? @map("last_used_at")
|
||||||
|
|||||||
54
backend/server/api/calls/ring.post.ts
Normal file
54
backend/server/api/calls/ring.post.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/calls/ring
|
||||||
|
*
|
||||||
|
* Triggert einen Push an den Callee bei einem eingehenden Voice-Call.
|
||||||
|
* Wird vom Caller direkt nach dem Supabase-Realtime-Broadcast aufgerufen
|
||||||
|
* (fire-and-forget). Der Push deckt den Background-/Locked-Screen-Fall ab;
|
||||||
|
* Foreground wird weiter via Realtime gehandhabt.
|
||||||
|
*
|
||||||
|
* Body: { peerId: string, callId: string }
|
||||||
|
*
|
||||||
|
* Kein VoIPPushKit/CallKit (Phase 2). Regulärer APNs/FCM Alert-Push mit
|
||||||
|
* priority=high + channelId="calls".
|
||||||
|
*/
|
||||||
|
import { requireUser } from "../../utils/auth";
|
||||||
|
import { usePrisma } from "../../utils/prisma";
|
||||||
|
import { sendCallRingPush } from "../../services/push";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const user = await requireUser(event);
|
||||||
|
const body = await readBody<{ peerId?: string; callId?: string }>(event);
|
||||||
|
|
||||||
|
const peerId = body?.peerId?.trim();
|
||||||
|
const callId = body?.callId?.trim();
|
||||||
|
if (!peerId || !callId) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "peerId_and_callId_required" });
|
||||||
|
}
|
||||||
|
if (peerId === user.id) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "cannot_ring_self" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = usePrisma();
|
||||||
|
const me = await db.profile.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { id: true, nickname: true, username: true, avatar: true },
|
||||||
|
});
|
||||||
|
if (!me) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: "caller_profile_not_found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const callerName = me.nickname || me.username || "Jemand";
|
||||||
|
|
||||||
|
// Fire-and-forget — auch wenn der Push fehlschlägt soll der Caller
|
||||||
|
// keine Verzögerung sehen. Der Realtime-Ring läuft parallel.
|
||||||
|
void sendCallRingPush({
|
||||||
|
receiverId: peerId,
|
||||||
|
callerName,
|
||||||
|
callerId: me.id,
|
||||||
|
callerNickname: me.nickname || me.username || "",
|
||||||
|
callerAvatar: me.avatar,
|
||||||
|
callId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
attachmentName?: string;
|
attachmentName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!receiverId || (!content?.trim() && !attachmentUrl)) {
|
if (!receiverId || (!content?.trim() && !attachmentUrl && attachmentType !== 'call')) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: "receiverId und content/Anhang erforderlich",
|
message: "receiverId und content/Anhang erforderlich",
|
||||||
|
|||||||
@ -15,6 +15,9 @@ const Body = z.object({
|
|||||||
token: z.string().min(10).max(200), // ExponentPushToken[xxx]
|
token: z.string().min(10).max(200), // ExponentPushToken[xxx]
|
||||||
platform: z.enum(["ios", "android"]),
|
platform: z.enum(["ios", "android"]),
|
||||||
deviceId: z.string().max(120).optional(),
|
deviceId: z.string().max(120).optional(),
|
||||||
|
/// iOS-PushKit-Token (64-char hex) für CallKit-Wake-Pushes. Optional —
|
||||||
|
/// Client kann später via separatem Call dieselbe Row updaten.
|
||||||
|
voipToken: z.string().min(32).max(200).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@ -28,7 +31,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { token, platform, deviceId } = parsed.data;
|
const { token, platform, deviceId, voipToken } = parsed.data;
|
||||||
const db = usePrisma();
|
const db = usePrisma();
|
||||||
|
|
||||||
await db.pushToken.upsert({
|
await db.pushToken.upsert({
|
||||||
@ -38,6 +41,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
token,
|
token,
|
||||||
platform,
|
platform,
|
||||||
deviceId: deviceId ?? null,
|
deviceId: deviceId ?? null,
|
||||||
|
voipToken: voipToken ?? null,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
lastUsedAt: new Date(),
|
lastUsedAt: new Date(),
|
||||||
},
|
},
|
||||||
@ -45,6 +49,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
userId: user.id, // Token könnte das Device gewechselt haben
|
userId: user.id, // Token könnte das Device gewechselt haben
|
||||||
platform,
|
platform,
|
||||||
deviceId: deviceId ?? null,
|
deviceId: deviceId ?? null,
|
||||||
|
// Wichtig: voipToken nur überschreiben wenn der Client einen mitliefert,
|
||||||
|
// sonst behalten (separate VoIP-Rotation-Calls könnten ihn schon gesetzt haben).
|
||||||
|
...(voipToken !== undefined ? { voipToken } : {}),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
lastUsedAt: new Date(),
|
lastUsedAt: new Date(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
import { Expo, type ExpoPushMessage } from "expo-server-sdk";
|
import { Expo, type ExpoPushMessage } from "expo-server-sdk";
|
||||||
import { usePrisma } from "../utils/prisma";
|
import { usePrisma } from "../utils/prisma";
|
||||||
|
import { sendVoIPPush } from "./voip-push";
|
||||||
|
|
||||||
const expo = new Expo();
|
const expo = new Expo();
|
||||||
|
|
||||||
@ -126,3 +127,121 @@ export function truncatePreview(text: string, max = 100): string {
|
|||||||
if (text.length <= max) return text;
|
if (text.length <= max) return text;
|
||||||
return text.slice(0, max - 1) + "…";
|
return text.slice(0, max - 1) + "…";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CallRingPushPayload {
|
||||||
|
/** Empfänger (Callee) — bekommt den Push */
|
||||||
|
receiverId: string;
|
||||||
|
/** Caller-Display-Name (für Titel) */
|
||||||
|
callerName: string;
|
||||||
|
/** Caller-Profil (für Show-Screen nach Tap) */
|
||||||
|
callerId: string;
|
||||||
|
callerNickname: string;
|
||||||
|
callerAvatar: string | null;
|
||||||
|
/** Call-ID — muss matchen damit Callee an die richtige Session andocken kann */
|
||||||
|
callId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push für eingehenden Voice-Call.
|
||||||
|
*
|
||||||
|
* Foreground-Calls funktionieren via Supabase Realtime; dieser Push deckt den
|
||||||
|
* Background/locked-screen-Fall ab. Beim Tap navigiert der Client direkt in
|
||||||
|
* den /call-Screen und triggert `useCall.receiveIncoming(callId, from)` —
|
||||||
|
* die normale Accept/Decline-UI greift dann.
|
||||||
|
*
|
||||||
|
* Wichtig: KEIN VoIPPushKit (Apple-only, braucht CallKit). Wir nutzen den
|
||||||
|
* regulären APNs-Alert-Push mit `priority: high` + Notifications-Sound.
|
||||||
|
* Trade-off: Auf iOS wacht ein force-quit-killed Process NICHT auf — das ist
|
||||||
|
* dasselbe Verhalten wie reguläre Chat-Pushes. Für echte CallKit-Integration
|
||||||
|
* → Phase 2 (eigene Initiative).
|
||||||
|
*/
|
||||||
|
export async function sendCallRingPush(payload: CallRingPushPayload): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = usePrisma();
|
||||||
|
|
||||||
|
const profile = await db.profile.findUnique({
|
||||||
|
where: { id: payload.receiverId },
|
||||||
|
select: { chatPushEnabled: true, deletedAt: true },
|
||||||
|
});
|
||||||
|
if (!profile || profile.deletedAt || !profile.chatPushEnabled) return;
|
||||||
|
|
||||||
|
const tokens = await db.pushToken.findMany({
|
||||||
|
where: { userId: payload.receiverId, enabled: true },
|
||||||
|
select: { id: true, token: true, voipToken: true, platform: true },
|
||||||
|
});
|
||||||
|
if (tokens.length === 0) return;
|
||||||
|
|
||||||
|
// ─── 1) VoIP-Pushes (iOS, CallKit-Wake-from-killed-State) ─────────────
|
||||||
|
// Läuft parallel zum regulären Push. Wenn voipToken NULL ist (Android,
|
||||||
|
// alte iOS-Builds ohne PushKit-Setup) wird das Device hier übersprungen
|
||||||
|
// und fällt auf den normalen Lockscreen-Banner zurück.
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (t.platform === "ios" && t.voipToken) {
|
||||||
|
void sendVoIPPush({
|
||||||
|
voipToken: t.voipToken,
|
||||||
|
callId: payload.callId,
|
||||||
|
callerName: payload.callerName,
|
||||||
|
callerId: payload.callerId,
|
||||||
|
callerNickname: payload.callerNickname,
|
||||||
|
callerAvatar: payload.callerAvatar,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2) Reguläre Expo-Pushes (Android FCM + iOS-Fallback) ────────────
|
||||||
|
const messages: ExpoPushMessage[] = [];
|
||||||
|
const validTokenIds: string[] = [];
|
||||||
|
|
||||||
|
for (const t of tokens) {
|
||||||
|
if (!Expo.isExpoPushToken(t.token)) {
|
||||||
|
await db.pushToken
|
||||||
|
.update({ where: { id: t.id }, data: { enabled: false } })
|
||||||
|
.catch(() => {});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
messages.push({
|
||||||
|
to: t.token,
|
||||||
|
sound: "default",
|
||||||
|
title: `📞 ${payload.callerName}`,
|
||||||
|
body: "Eingehender Anruf",
|
||||||
|
priority: "high",
|
||||||
|
// Android: dedizierter Calls-Channel (im Client mit Importance.MAX +
|
||||||
|
// Vibration + Bypass-DND zu konfigurieren)
|
||||||
|
channelId: "calls",
|
||||||
|
// iOS: zeigt als alert auch im Lockscreen (interruption-level)
|
||||||
|
// 'time-sensitive' wäre besser, braucht aber Critical-Alerts-Entitlement.
|
||||||
|
// 'active' (default) reicht für Phase 1.
|
||||||
|
data: {
|
||||||
|
type: "call",
|
||||||
|
callId: payload.callId,
|
||||||
|
from: {
|
||||||
|
id: payload.callerId,
|
||||||
|
nickname: payload.callerNickname,
|
||||||
|
avatar: payload.callerAvatar,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
validTokenIds.push(t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
|
const chunks = expo.chunkPushNotifications(messages);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
try {
|
||||||
|
await expo.sendPushNotificationsAsync(chunk);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[push] call-ring chunk send failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.pushToken
|
||||||
|
.updateMany({
|
||||||
|
where: { id: { in: validTokenIds } },
|
||||||
|
data: { lastUsedAt: new Date() },
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[push] sendCallRingPush failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
130
backend/server/services/voip-push.ts
Normal file
130
backend/server/services/voip-push.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* iOS-VoIP-Push via PushKit + APNs HTTP/2.
|
||||||
|
*
|
||||||
|
* Unterschied zu services/push.ts (Expo Server SDK):
|
||||||
|
* - Reguläre Pushes laufen über Expo's Proxy (token = ExponentPushToken[xxx])
|
||||||
|
* - VoIP-Pushes MÜSSEN direkt an APNs gehen (Apple verbietet Proxy für PushKit)
|
||||||
|
* - Nutzt das VoIP-Services-Cert (.p12) das wir bei Apple beantragt haben
|
||||||
|
* - Wake-from-killed-State: einziger Weg ein iOS-App aufzuwecken um CallKit
|
||||||
|
* UI anzuzeigen
|
||||||
|
*
|
||||||
|
* Env-Vars (aus Infisical):
|
||||||
|
* APNS_VOIP_P12_PATH Pfad zur .p12-Datei (z.B. /root/.secrets/rebreak-voip.p12)
|
||||||
|
* APNS_VOIP_P12_PASSWORD Cert-Password
|
||||||
|
* APNS_VOIP_TOPIC Bundle-ID + ".voip" (org.rebreak.app.voip)
|
||||||
|
* APNS_VOIP_PRODUCTION "true" für Production-APNs-Endpoint
|
||||||
|
*
|
||||||
|
* Wenn ENV-Vars fehlen: Service no-op (kein Error, nur Log-Warnung beim ersten Send).
|
||||||
|
* → Macht reguläre Pushes nicht kaputt wenn das VoIP-Setup noch unfertig ist.
|
||||||
|
*/
|
||||||
|
import apn from "@parse/node-apn";
|
||||||
|
import fs from "node:fs";
|
||||||
|
|
||||||
|
let provider: apn.Provider | null = null;
|
||||||
|
let initialized = false;
|
||||||
|
let topic: string | null = null;
|
||||||
|
|
||||||
|
function getProvider(): apn.Provider | null {
|
||||||
|
if (initialized) return provider;
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
const p12Path = process.env.APNS_VOIP_P12_PATH;
|
||||||
|
const p12Pass = process.env.APNS_VOIP_P12_PASSWORD;
|
||||||
|
const tpc = process.env.APNS_VOIP_TOPIC;
|
||||||
|
const production = process.env.APNS_VOIP_PRODUCTION === "true";
|
||||||
|
|
||||||
|
if (!p12Path || !p12Pass || !tpc) {
|
||||||
|
console.warn(
|
||||||
|
"[voip-push] disabled — missing env (APNS_VOIP_P12_PATH/PASSWORD/TOPIC)",
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!fs.existsSync(p12Path)) {
|
||||||
|
console.warn(`[voip-push] disabled — p12 file not found at ${p12Path}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
topic = tpc;
|
||||||
|
provider = new apn.Provider({
|
||||||
|
pfx: p12Path,
|
||||||
|
passphrase: p12Pass,
|
||||||
|
production,
|
||||||
|
});
|
||||||
|
console.log(`[voip-push] initialized (topic=${tpc}, production=${production})`);
|
||||||
|
return provider;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[voip-push] init failed:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoIPCallPayload {
|
||||||
|
/** VoIP-PushKit-Token des Empfänger-Devices (64-char hex, NICHT der Expo-Token) */
|
||||||
|
voipToken: string;
|
||||||
|
/** Eindeutige Call-ID (matcht callIdToUuid() im Client) */
|
||||||
|
callId: string;
|
||||||
|
/** Display-Name für CallKit-UI */
|
||||||
|
callerName: string;
|
||||||
|
/** User-ID des Anrufers — Client braucht das um peer-Profile zu fetchen */
|
||||||
|
callerId: string;
|
||||||
|
callerNickname: string;
|
||||||
|
callerAvatar: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sendet einen VoIP-Push an genau ein iOS-Device.
|
||||||
|
*
|
||||||
|
* Apple-Vorgaben (verifiziert via developer.apple.com/documentation/pushkit):
|
||||||
|
* - apns-push-type: voip
|
||||||
|
* - apns-topic: bundle-id + ".voip" (NICHT der reguläre bundle-id)
|
||||||
|
* - apns-priority: 10
|
||||||
|
* - apns-expiration: 0 (kein Storage — wenn Device offline, verworfen)
|
||||||
|
*
|
||||||
|
* @returns true bei Erfolg (oder no-op wenn Service disabled), false bei Fehler.
|
||||||
|
*/
|
||||||
|
export async function sendVoIPPush(payload: VoIPCallPayload): Promise<boolean> {
|
||||||
|
const p = getProvider();
|
||||||
|
if (!p || !topic) return true; // no-op, regulärer Push übernimmt
|
||||||
|
|
||||||
|
const note = new apn.Notification();
|
||||||
|
note.topic = topic;
|
||||||
|
note.expiry = 0; // sofort verwerfen wenn Device unreachable
|
||||||
|
note.priority = 10;
|
||||||
|
note.pushType = "voip";
|
||||||
|
note.payload = {
|
||||||
|
type: "call",
|
||||||
|
callId: payload.callId,
|
||||||
|
callerName: payload.callerName,
|
||||||
|
from: {
|
||||||
|
id: payload.callerId,
|
||||||
|
nickname: payload.callerNickname,
|
||||||
|
avatar: payload.callerAvatar,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await p.send(note, payload.voipToken);
|
||||||
|
if (result.failed.length > 0) {
|
||||||
|
const f = result.failed[0];
|
||||||
|
console.warn(
|
||||||
|
`[voip-push] failed token=${payload.voipToken.slice(0, 8)}… reason=`,
|
||||||
|
f.response ?? f.error,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[voip-push] send threw:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cleanup bei Server-Shutdown — wichtig wegen HTTP/2-Verbindung an Apple. */
|
||||||
|
export function shutdownVoIPProvider(): void {
|
||||||
|
if (provider) {
|
||||||
|
provider.shutdown();
|
||||||
|
provider = null;
|
||||||
|
initialized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
ops/CALLKIT_SETUP.md
Normal file
57
ops/CALLKIT_SETUP.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# CallKit + VoIP Setup für ReBreak
|
||||||
|
|
||||||
|
## 1. CSR generieren (Mac, 2 Min)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp
|
||||||
|
# Private Key
|
||||||
|
openssl genrsa -out rebreak-voip.key 2048
|
||||||
|
# CSR (Common Name muss eindeutig sein — App-Identifier nehmen)
|
||||||
|
openssl req -new -key rebreak-voip.key -out rebreak-voip.csr \
|
||||||
|
-subj "/emailAddress=YOUR_APPLE_DEV_EMAIL@example.com/CN=ReBreak VoIP Push/C=DE"
|
||||||
|
```
|
||||||
|
|
||||||
|
→ `rebreak-voip.csr` an mich, `rebreak-voip.key` **bei dir behalten** (private key, nie teilen).
|
||||||
|
|
||||||
|
## 2. Apple Dev Portal (5 Min)
|
||||||
|
|
||||||
|
1. https://developer.apple.com/account → Identifiers → App-Identifier `org.rebreak.app` (oder wie deiner heißt)
|
||||||
|
2. **Capabilities** → ✅ "Push Notifications" + ✅ "Voice over IP" (= das CallKit-Entitlement)
|
||||||
|
3. → Speichern
|
||||||
|
4. Certificates → "+" → "**VoIP Services Certificate**"
|
||||||
|
5. App-Identifier auswählen → CSR von oben hochladen → Download `.cer`
|
||||||
|
6. **Provisioning Profile** für die App neu generieren + downloaden (weil neue Capabilities)
|
||||||
|
|
||||||
|
## 3. Files an mich
|
||||||
|
|
||||||
|
- `rebreak-voip.cer` (Apple Output)
|
||||||
|
- Neues Provisioning Profile `.mobileprovision`
|
||||||
|
- Den `rebreak-voip.key` NICHT — den brauchen wir nur auf dem Backend (du legst ihn unter `/root/.secrets/rebreak-voip.key` auf Hetzner oder via Infisical)
|
||||||
|
|
||||||
|
## 4. .cer in .p8/.p12 konvertieren (ich mach das)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .cer (DER) → .pem
|
||||||
|
openssl x509 -inform DER -outform PEM -in rebreak-voip.cer -out rebreak-voip.pem
|
||||||
|
# .pem + .key → .p12 (für apn2/node-apn)
|
||||||
|
openssl pkcs12 -export -inkey rebreak-voip.key -in rebreak-voip.pem \
|
||||||
|
-out rebreak-voip.p12 -passout pass:CHOOSE_PASSWORD
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Was Apple beim nächsten App-Review checkt
|
||||||
|
|
||||||
|
- VoIP-Push wird NUR für Calls verwendet (kein silent-sync)
|
||||||
|
- `reportNewIncomingCall` innerhalb 5s nach Push
|
||||||
|
- `includesCallsInRecents: false` (Privacy für DiGA — Anrufe sollen NICHT in iCloud sync)
|
||||||
|
- App-Description in Store sollte Call-Feature erwähnen
|
||||||
|
|
||||||
|
Apple-Review-Dauer aktuell: ~24h. Kein Sonder-Antrag, normaler Review.
|
||||||
|
|
||||||
|
## 6. Android (kein Apple-Antrag, aber Permission im Manifest)
|
||||||
|
|
||||||
|
- `FOREGROUND_SERVICE_PHONE_CALL` (Android 11+ für Mic-Access im Background)
|
||||||
|
- `MANAGE_OWN_CALLS` (ConnectionService)
|
||||||
|
- `USE_FULL_SCREEN_INTENT` (Android 14+ für Full-Screen-Call-UI)
|
||||||
|
- `READ_PHONE_STATE` für Account-Registrierung
|
||||||
|
|
||||||
|
→ Alles automatisch via callkeep Config-Plugin, kein Manual-Step von dir.
|
||||||
231
pnpm-lock.yaml
generated
231
pnpm-lock.yaml
generated
@ -120,6 +120,12 @@ importers:
|
|||||||
|
|
||||||
apps/rebreak-native:
|
apps/rebreak-native:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@config-plugins/react-native-callkeep':
|
||||||
|
specifier: ^12.0.0
|
||||||
|
version: 12.0.0(expo@54.0.34)
|
||||||
|
'@config-plugins/react-native-webrtc':
|
||||||
|
specifier: ^15.0.1
|
||||||
|
version: 15.0.1(expo@54.0.34)
|
||||||
'@expo-google-fonts/nunito':
|
'@expo-google-fonts/nunito':
|
||||||
specifier: ^0.2.3
|
specifier: ^0.2.3
|
||||||
version: 0.2.3
|
version: 0.2.3
|
||||||
@ -267,9 +273,15 @@ importers:
|
|||||||
react-native-bottom-tabs:
|
react-native-bottom-tabs:
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
|
version: 1.2.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
|
||||||
|
react-native-callkeep:
|
||||||
|
specifier: ^4.3.16
|
||||||
|
version: 4.3.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))
|
||||||
react-native-gesture-handler:
|
react-native-gesture-handler:
|
||||||
specifier: ~2.28.0
|
specifier: ~2.28.0
|
||||||
version: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
|
version: 2.28.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
|
||||||
|
react-native-incall-manager:
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))
|
||||||
react-native-keyboard-controller:
|
react-native-keyboard-controller:
|
||||||
specifier: ^1.21.7
|
specifier: ^1.21.7
|
||||||
version: 1.21.7(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
|
version: 1.21.7(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
|
||||||
@ -294,6 +306,12 @@ importers:
|
|||||||
react-native-url-polyfill:
|
react-native-url-polyfill:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))
|
version: 2.0.0(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))
|
||||||
|
react-native-voip-push-notification:
|
||||||
|
specifier: ^3.3.3
|
||||||
|
version: 3.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))
|
||||||
|
react-native-webrtc:
|
||||||
|
specifier: ^124.0.7
|
||||||
|
version: 124.0.7(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))
|
||||||
react-native-worklets:
|
react-native-worklets:
|
||||||
specifier: ~0.5.1
|
specifier: ~0.5.1
|
||||||
version: 0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
|
version: 0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)
|
||||||
@ -322,6 +340,9 @@ importers:
|
|||||||
|
|
||||||
backend:
|
backend:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@parse/node-apn':
|
||||||
|
specifier: ^8.1.0
|
||||||
|
version: 8.1.0
|
||||||
'@prisma/adapter-pg':
|
'@prisma/adapter-pg':
|
||||||
specifier: ^7.2.0
|
specifier: ^7.2.0
|
||||||
version: 7.8.0
|
version: 7.8.0
|
||||||
@ -957,6 +978,16 @@ packages:
|
|||||||
'@colordx/core@5.4.3':
|
'@colordx/core@5.4.3':
|
||||||
resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==}
|
resolution: {integrity: sha512-kIxYSfA5T8HXjav55UaaH/o/cKivF6jCCGIb8eqtcsfI46wsvlSiT8jMDyrl779qLec3c2c2oHBZo4oAhvbjrQ==}
|
||||||
|
|
||||||
|
'@config-plugins/react-native-callkeep@12.0.0':
|
||||||
|
resolution: {integrity: sha512-IoRZ0+u8iBbmpnF9qvodQ77NI6lebm2OThAER4zOTPFKxEpKMkmCPoFk54CD5yyy4N1cLBXnbELj/GNQRDQBaQ==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: ^54
|
||||||
|
|
||||||
|
'@config-plugins/react-native-webrtc@15.0.1':
|
||||||
|
resolution: {integrity: sha512-1/RSRnMOWPqHmEJCGEhElzFvkXUiNkGYsbAIxJl/3+t9OW2l26Qsjo8Ei7v/OMJ7g0Ek6qnKkjCxYrrLSfJi2A==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: ^56
|
||||||
|
|
||||||
'@devframes/hub@0.5.2':
|
'@devframes/hub@0.5.2':
|
||||||
resolution: {integrity: sha512-qMkBFw1OqhPuNs1tQWkRq0z0Tg49kXNu53bs59tdF4lytKupatWVnL3cpsVPqn+Q5P7A70r99BKTcm+prMtHqw==}
|
resolution: {integrity: sha512-qMkBFw1OqhPuNs1tQWkRq0z0Tg49kXNu53bs59tdF4lytKupatWVnL3cpsVPqn+Q5P7A70r99BKTcm+prMtHqw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2756,6 +2787,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
|
||||||
engines: {node: '>= 10.0.0'}
|
engines: {node: '>= 10.0.0'}
|
||||||
|
|
||||||
|
'@parse/node-apn@8.1.0':
|
||||||
|
resolution: {integrity: sha512-LowcdkKPDikbbzIr3zwEdoFW5wfEbbTzpYQeen3S8kKLePG0AQK586Li+OLC7bonyLmlk//Yk3smHZOLSV6TZA==}
|
||||||
|
engines: {node: 20 || 22 || 24}
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0':
|
'@pinojs/redact@0.4.0':
|
||||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||||
|
|
||||||
@ -4385,6 +4420,10 @@ packages:
|
|||||||
asap@2.0.6:
|
asap@2.0.6:
|
||||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||||
|
|
||||||
|
assert-plus@1.0.0:
|
||||||
|
resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
|
||||||
|
engines: {node: '>=0.8'}
|
||||||
|
|
||||||
assert@2.1.0:
|
assert@2.1.0:
|
||||||
resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==}
|
resolution: {integrity: sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==}
|
||||||
|
|
||||||
@ -4643,6 +4682,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
@ -4908,6 +4950,9 @@ packages:
|
|||||||
core-js-compat@3.49.0:
|
core-js-compat@3.49.0:
|
||||||
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
|
resolution: {integrity: sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==}
|
||||||
|
|
||||||
|
core-util-is@1.0.2:
|
||||||
|
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
|
||||||
|
|
||||||
core-util-is@1.0.3:
|
core-util-is@1.0.3:
|
||||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
@ -5043,6 +5088,15 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
debug@4.3.4:
|
||||||
|
resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==}
|
||||||
|
engines: {node: '>=6.0'}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@ -5207,6 +5261,9 @@ packages:
|
|||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@ -5455,6 +5512,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
event-target-shim@6.0.2:
|
||||||
|
resolution: {integrity: sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
events-universal@1.0.1:
|
events-universal@1.0.1:
|
||||||
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
|
||||||
|
|
||||||
@ -5750,6 +5811,10 @@ packages:
|
|||||||
exsolve@1.0.8:
|
exsolve@1.0.8:
|
||||||
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
|
||||||
|
|
||||||
|
extsprintf@1.4.1:
|
||||||
|
resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==}
|
||||||
|
engines: {'0': node >=0.6.0}
|
||||||
|
|
||||||
fast-check@3.23.2:
|
fast-check@3.23.2:
|
||||||
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
|
resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==}
|
||||||
engines: {node: '>=8.0.0'}
|
engines: {node: '>=8.0.0'}
|
||||||
@ -6479,6 +6544,16 @@ packages:
|
|||||||
resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==}
|
resolution: {integrity: sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA==}
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.3:
|
||||||
|
resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==}
|
||||||
|
engines: {node: '>=12', npm: '>=6'}
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@ -6709,12 +6784,33 @@ packages:
|
|||||||
lodash.defaults@4.2.0:
|
lodash.defaults@4.2.0:
|
||||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0:
|
||||||
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
lodash.isarguments@3.1.0:
|
lodash.isarguments@3.1.0:
|
||||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3:
|
||||||
|
resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4:
|
||||||
|
resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3:
|
||||||
|
resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6:
|
||||||
|
resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1:
|
||||||
|
resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
|
||||||
|
|
||||||
lodash.memoize@4.1.2:
|
lodash.memoize@4.1.2:
|
||||||
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
|
||||||
|
|
||||||
|
lodash.once@4.1.1:
|
||||||
|
resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
|
||||||
|
|
||||||
lodash.throttle@4.1.1:
|
lodash.throttle@4.1.1:
|
||||||
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==}
|
||||||
|
|
||||||
@ -6999,6 +7095,9 @@ packages:
|
|||||||
ms@2.0.0:
|
ms@2.0.0:
|
||||||
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
|
||||||
|
|
||||||
|
ms@2.1.2:
|
||||||
|
resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==}
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@ -7946,6 +8045,11 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
react-native-callkeep@4.3.16:
|
||||||
|
resolution: {integrity: sha512-aIxn02T5zW4jNPyzRdFGTWv6xD3Vy/1AkBMB6iYvWZEHWnfmgNGF0hELqg03Vbc2BNUhfqpu17aIydos+5Hurg==}
|
||||||
|
peerDependencies:
|
||||||
|
react-native: '>=0.40.0'
|
||||||
|
|
||||||
react-native-css-interop@0.2.3:
|
react-native-css-interop@0.2.3:
|
||||||
resolution: {integrity: sha512-wc+JI7iUfdFBqnE18HhMTtD0q9vkhuMczToA87UdHGWwMyxdT5sCcNy+i4KInPCE855IY0Ic8kLQqecAIBWz7w==}
|
resolution: {integrity: sha512-wc+JI7iUfdFBqnE18HhMTtD0q9vkhuMczToA87UdHGWwMyxdT5sCcNy+i4KInPCE855IY0Ic8kLQqecAIBWz7w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@ -7968,6 +8072,11 @@ packages:
|
|||||||
react: '*'
|
react: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
react-native-incall-manager@4.2.1:
|
||||||
|
resolution: {integrity: sha512-HTdtzQ/AswUbuNhcL0gmyZLAXo8VqBO7SIh+BwbeeM1YMXXlR+Q2MvKxhD4yanjJPeyqMfuRhryCQCJhPlsdAw==}
|
||||||
|
peerDependencies:
|
||||||
|
react-native: '>=0.40.0'
|
||||||
|
|
||||||
react-native-is-edge-to-edge@1.3.1:
|
react-native-is-edge-to-edge@1.3.1:
|
||||||
resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==}
|
resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -8020,6 +8129,16 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
react-native-voip-push-notification@3.3.3:
|
||||||
|
resolution: {integrity: sha512-cyWuI9//T1IQIq4RPq0QQe0NuEwIpnE0L98H2sUH4MjFsNMD/yNE4EJzEZN4cIwfPMZaASa0gQw6B1a7VwnkMA==}
|
||||||
|
peerDependencies:
|
||||||
|
react-native: '>=0.60.0'
|
||||||
|
|
||||||
|
react-native-webrtc@124.0.7:
|
||||||
|
resolution: {integrity: sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA==}
|
||||||
|
peerDependencies:
|
||||||
|
react-native: '>=0.60.0'
|
||||||
|
|
||||||
react-native-worklets@0.5.1:
|
react-native-worklets@0.5.1:
|
||||||
resolution: {integrity: sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==}
|
resolution: {integrity: sha512-lJG6Uk9YuojjEX/tQrCbcbmpdLCSFxDK1rJlkDhgqkVi1KZzG7cdcBFQRqyNOOzR9Y0CXNuldmtWTGOyM0k0+w==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -9184,6 +9303,10 @@ packages:
|
|||||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
|
verror@1.10.1:
|
||||||
|
resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==}
|
||||||
|
engines: {node: '>=0.6.0'}
|
||||||
|
|
||||||
vite-dev-rpc@1.1.0:
|
vite-dev-rpc@1.1.0:
|
||||||
resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==}
|
resolution: {integrity: sha512-pKXZlgoXGoE8sEKiKJSng4hI1sQ4wi5YT24FCrwrLt6opmkjlqPPVmiPWWJn8M8byMxRGzp1CrFuqQs4M/Z39A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -10361,6 +10484,14 @@ snapshots:
|
|||||||
|
|
||||||
'@colordx/core@5.4.3': {}
|
'@colordx/core@5.4.3': {}
|
||||||
|
|
||||||
|
'@config-plugins/react-native-callkeep@12.0.0(expo@54.0.34)':
|
||||||
|
dependencies:
|
||||||
|
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
|
||||||
|
|
||||||
|
'@config-plugins/react-native-webrtc@15.0.1(expo@54.0.34)':
|
||||||
|
dependencies:
|
||||||
|
expo: 54.0.34(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(typescript@5.8.3)
|
||||||
|
|
||||||
'@devframes/hub@0.5.2(devframe@0.5.2(typescript@5.9.3))':
|
'@devframes/hub@0.5.2(devframe@0.5.2(typescript@5.9.3))':
|
||||||
dependencies:
|
dependencies:
|
||||||
birpc: 4.0.0
|
birpc: 4.0.0
|
||||||
@ -12470,6 +12601,15 @@ snapshots:
|
|||||||
'@parcel/watcher-win32-ia32': 2.5.6
|
'@parcel/watcher-win32-ia32': 2.5.6
|
||||||
'@parcel/watcher-win32-x64': 2.5.6
|
'@parcel/watcher-win32-x64': 2.5.6
|
||||||
|
|
||||||
|
'@parse/node-apn@8.1.0':
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
jsonwebtoken: 9.0.3
|
||||||
|
node-forge: 1.4.0
|
||||||
|
verror: 1.10.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
'@pinojs/redact@0.4.0': {}
|
'@pinojs/redact@0.4.0': {}
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
@ -14343,6 +14483,8 @@ snapshots:
|
|||||||
|
|
||||||
asap@2.0.6: {}
|
asap@2.0.6: {}
|
||||||
|
|
||||||
|
assert-plus@1.0.0: {}
|
||||||
|
|
||||||
assert@2.1.0:
|
assert@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
@ -14645,6 +14787,8 @@ snapshots:
|
|||||||
|
|
||||||
buffer-crc32@1.0.0: {}
|
buffer-crc32@1.0.0: {}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
@ -14964,6 +15108,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.2
|
browserslist: 4.28.2
|
||||||
|
|
||||||
|
core-util-is@1.0.2: {}
|
||||||
|
|
||||||
core-util-is@1.0.3: {}
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
crc-32@1.2.2: {}
|
crc-32@1.2.2: {}
|
||||||
@ -15100,6 +15246,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
debug@4.3.4:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.2
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@ -15235,6 +15385,10 @@ snapshots:
|
|||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
effect@3.20.0:
|
effect@3.20.0:
|
||||||
@ -15559,6 +15713,8 @@ snapshots:
|
|||||||
|
|
||||||
event-target-shim@5.0.1: {}
|
event-target-shim@5.0.1: {}
|
||||||
|
|
||||||
|
event-target-shim@6.0.2: {}
|
||||||
|
|
||||||
events-universal@1.0.1:
|
events-universal@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
bare-events: 2.8.2
|
bare-events: 2.8.2
|
||||||
@ -15901,6 +16057,8 @@ snapshots:
|
|||||||
|
|
||||||
exsolve@1.0.8: {}
|
exsolve@1.0.8: {}
|
||||||
|
|
||||||
|
extsprintf@1.4.1: {}
|
||||||
|
|
||||||
fast-check@3.23.2:
|
fast-check@3.23.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
pure-rand: 6.1.0
|
pure-rand: 6.1.0
|
||||||
@ -16739,7 +16897,31 @@ snapshots:
|
|||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
eslint-visitor-keys: 3.4.3
|
eslint-visitor-keys: 3.4.3
|
||||||
espree: 9.6.1
|
espree: 9.6.1
|
||||||
semver: 7.7.4
|
semver: 7.8.1
|
||||||
|
|
||||||
|
jsonwebtoken@9.0.3:
|
||||||
|
dependencies:
|
||||||
|
jws: 4.0.1
|
||||||
|
lodash.includes: 4.3.0
|
||||||
|
lodash.isboolean: 3.0.3
|
||||||
|
lodash.isinteger: 4.0.4
|
||||||
|
lodash.isnumber: 3.0.3
|
||||||
|
lodash.isplainobject: 4.0.6
|
||||||
|
lodash.isstring: 4.0.1
|
||||||
|
lodash.once: 4.1.1
|
||||||
|
ms: 2.1.3
|
||||||
|
semver: 7.8.1
|
||||||
|
|
||||||
|
jwa@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
buffer-equal-constant-time: 1.0.1
|
||||||
|
ecdsa-sig-formatter: 1.0.11
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
jws@4.0.1:
|
||||||
|
dependencies:
|
||||||
|
jwa: 2.0.1
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -16942,10 +17124,24 @@ snapshots:
|
|||||||
|
|
||||||
lodash.defaults@4.2.0: {}
|
lodash.defaults@4.2.0: {}
|
||||||
|
|
||||||
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
lodash.isarguments@3.1.0: {}
|
lodash.isarguments@3.1.0: {}
|
||||||
|
|
||||||
|
lodash.isboolean@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isinteger@4.0.4: {}
|
||||||
|
|
||||||
|
lodash.isnumber@3.0.3: {}
|
||||||
|
|
||||||
|
lodash.isplainobject@4.0.6: {}
|
||||||
|
|
||||||
|
lodash.isstring@4.0.1: {}
|
||||||
|
|
||||||
lodash.memoize@4.1.2: {}
|
lodash.memoize@4.1.2: {}
|
||||||
|
|
||||||
|
lodash.once@4.1.1: {}
|
||||||
|
|
||||||
lodash.throttle@4.1.1: {}
|
lodash.throttle@4.1.1: {}
|
||||||
|
|
||||||
lodash.uniq@4.5.0: {}
|
lodash.uniq@4.5.0: {}
|
||||||
@ -17320,6 +17516,8 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.0.0: {}
|
ms@2.0.0: {}
|
||||||
|
|
||||||
|
ms@2.1.2: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
muggle-string@0.4.1: {}
|
muggle-string@0.4.1: {}
|
||||||
@ -17589,7 +17787,7 @@ snapshots:
|
|||||||
|
|
||||||
node-abi@3.92.0:
|
node-abi@3.92.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.4
|
semver: 7.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
node-addon-api@6.1.0:
|
node-addon-api@6.1.0:
|
||||||
@ -17633,7 +17831,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hosted-git-info: 7.0.2
|
hosted-git-info: 7.0.2
|
||||||
proc-log: 4.2.0
|
proc-log: 4.2.0
|
||||||
semver: 7.7.4
|
semver: 7.8.1
|
||||||
validate-npm-package-name: 5.0.1
|
validate-npm-package-name: 5.0.1
|
||||||
|
|
||||||
npm-run-path@5.3.0:
|
npm-run-path@5.3.0:
|
||||||
@ -18676,6 +18874,10 @@ snapshots:
|
|||||||
sf-symbols-typescript: 2.2.0
|
sf-symbols-typescript: 2.2.0
|
||||||
use-latest-callback: 0.2.6(react@19.1.0)
|
use-latest-callback: 0.2.6(react@19.1.0)
|
||||||
|
|
||||||
|
react-native-callkeep@4.3.16(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)):
|
||||||
|
dependencies:
|
||||||
|
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
|
||||||
|
|
||||||
react-native-css-interop@0.2.3(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.19(yaml@2.8.4)):
|
react-native-css-interop@0.2.3(react-native-reanimated@4.1.7(react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.19(yaml@2.8.4)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-module-imports': 7.28.6
|
'@babel/helper-module-imports': 7.28.6
|
||||||
@ -18702,6 +18904,10 @@ snapshots:
|
|||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
|
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
|
||||||
|
|
||||||
|
react-native-incall-manager@4.2.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)):
|
||||||
|
dependencies:
|
||||||
|
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
|
||||||
|
|
||||||
react-native-is-edge-to-edge@1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
|
react-native-is-edge-to-edge@1.3.1(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
@ -18755,6 +18961,19 @@ snapshots:
|
|||||||
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
|
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
|
||||||
whatwg-url-without-unicode: 8.0.0-3
|
whatwg-url-without-unicode: 8.0.0-3
|
||||||
|
|
||||||
|
react-native-voip-push-notification@3.3.3(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)):
|
||||||
|
dependencies:
|
||||||
|
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
|
||||||
|
|
||||||
|
react-native-webrtc@124.0.7(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)):
|
||||||
|
dependencies:
|
||||||
|
base64-js: 1.5.1
|
||||||
|
debug: 4.3.4
|
||||||
|
event-target-shim: 6.0.2
|
||||||
|
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
|
react-native-worklets@0.5.1(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.1.0))(react@19.1.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.0
|
'@babel/core': 7.29.0
|
||||||
@ -20061,6 +20280,12 @@ snapshots:
|
|||||||
- '@types/react'
|
- '@types/react'
|
||||||
- '@types/react-dom'
|
- '@types/react-dom'
|
||||||
|
|
||||||
|
verror@1.10.1:
|
||||||
|
dependencies:
|
||||||
|
assert-plus: 1.0.0
|
||||||
|
core-util-is: 1.0.2
|
||||||
|
extsprintf: 1.4.1
|
||||||
|
|
||||||
vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)):
|
vite-dev-rpc@1.1.0(vite@7.3.3(@types/node@22.19.17)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.2)(yaml@2.8.4)):
|
||||||
dependencies:
|
dependencies:
|
||||||
birpc: 2.9.0
|
birpc: 2.9.0
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user