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