fix(calls): sandbox/prod VoIP-push failover + foreground CallKit-UI suppress

- voip-push: build both APNs Provider (production+sandbox) and try each per
  token with memoization. Fixes BadDeviceToken on Xcode-Dev-Builds where the
  token is Sandbox-only.
- stores/call: only call callkit.displayIncomingCall when app NOT in foreground
  \u2014 in foreground the /call route handles ringing UI, otherwise double UI
  (system banner + fullscreen).
- patch react-native-callkeep: New-Arch TurboModule compatibility (no overloads,
  no Bundle params in @ReactMethod).
- pushTokenRegistration: more verbose [voip] diagnostics.
This commit is contained in:
chahinebrini 2026-06-04 19:42:44 +02:00
parent fb2d90b947
commit 6a907cf89b
6 changed files with 177 additions and 42 deletions

View File

@ -35,26 +35,36 @@ const lastRegisteredVoipToken: { current: string | null } = { current: null };
/** Holt iOS-VoIP-Push-Token via PushKit. Resolve mit `null` wenn nicht verfügbar. */
async function fetchVoipToken(): Promise<string | null> {
if (Platform.OS !== 'ios' || !RNVoipPushNotification) return null;
if (Platform.OS !== 'ios') {
if (__DEV__) console.log('[voip] skip (not iOS)');
return null;
}
if (!RNVoipPushNotification) {
console.warn('[voip] react-native-voip-push-notification NOT linked — PushKit token cannot be fetched. Did the with-voip-pushkit-ios.js plugin run + prebuild?');
return null;
}
return new Promise((resolve) => {
let resolved = false;
const onToken = (token: string) => {
if (resolved) return;
resolved = true;
if (__DEV__) console.log('[voip] register event fired, token:', token ? token.slice(0, 20) + '…' : '(empty)');
resolve(token || null);
};
try {
// Listener registrieren BEVOR registerVoipToken — sonst race.
RNVoipPushNotification.addEventListener('register', onToken);
RNVoipPushNotification.registerVoipToken();
if (__DEV__) console.log('[voip] registerVoipToken() called, waiting for callback…');
// Safety-Timeout: nach 4s aufgeben (Cert/Provisioning fehlt etc.).
setTimeout(() => {
if (resolved) return;
resolved = true;
console.warn('[voip] timeout after 4s — NO PushKit token received. Check: Push-Notifications-Cap + Background-Modes(voip) in Xcode entitlements, physical device, VoIP-services-certificate in Apple Portal.');
resolve(null);
}, 4000);
} catch (err) {
if (__DEV__) console.warn('[voip] register failed:', err);
console.warn('[voip] register failed:', err);
resolve(null);
}
});
@ -141,7 +151,11 @@ export async function registerPushTokenWithBackend(): Promise<string | null> {
lastRegisteredVoipToken.current = voipToken;
if (__DEV__) {
console.log('[push] token registered:', token.slice(0, 30) + '…');
if (voipToken) console.log('[voip] token registered:', voipToken.slice(0, 30) + '…');
if (voipToken) {
console.log('[voip] token registered:', voipToken.slice(0, 30) + '…');
} else if (Platform.OS === 'ios') {
console.warn('[voip] iOS without VoIP-token — incoming calls in background/killed will NOT wake the app. Backend will fall back to silent Expo-push.');
}
}
return token;
} catch (err) {

View File

@ -1,5 +1,5 @@
import { create } from 'zustand';
import { NativeModules } from 'react-native';
import { AppState, NativeModules } from 'react-native';
import type { RealtimeChannel } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
import { apiFetch } from '../lib/api';
@ -382,11 +382,16 @@ export const useCallStore = create<CallState>((set, get) => {
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. RNCallKeep
// dedupliziert intern via UUID, also safe wenn AppDelegate's
// reportNewIncomingCall (VoIP-Push-Pfad) schon dieselbe UUID gemeldet hat.
try { callkit.displayIncomingCall(callId, from.nickname || 'ReBreak'); } catch {}
// CallKit-/ConnectionService-UI hochziehen — ABER nur wenn App NICHT im
// Vordergrund. Im Foreground kümmert sich der In-App /call-Screen darum,
// sonst gibt es Doppel-UI (System-Banner + Fullscreen). VoIP-Push-Pfad
// (AppDelegate.reportNewIncomingCall) läuft eh getrennt, das deduliziert
// CallKit intern via UUID.
if (AppState.currentState !== 'active') {
try { callkit.displayIncomingCall(callId, from.nickname || 'ReBreak'); } catch {}
} else {
clog('receiveIncoming: app foreground — skipping CallKit UI (using in-app /call screen)');
}
// CallKit (iOS) + ConnectionService (Android) spielen ihren eigenen
// Ringtone — KEIN InCallManager.startRingtone() hier, sonst doppeltes
// Klingeln. InCallManager.start() bleibt aus demselben Grund weg; der

View File

@ -28,47 +28,56 @@ type ApnProvider = InstanceType<ApnModule["Provider"]>;
type ApnNotification = InstanceType<ApnModule["Notification"]>;
let apnMod: ApnModule | null = null;
let provider: ApnProvider | null = null;
let providerProd: ApnProvider | null = null;
let providerSandbox: ApnProvider | null = null;
let initialized = false;
let topic: string | null = null;
/** Token → "prod" | "sandbox" Memoization. Vermeidet 2-Round-Trip pro Push. */
const tokenEnvCache = new Map<string, "prod" | "sandbox">();
async function getProvider(): Promise<ApnProvider | null> {
if (initialized) return provider;
async function ensureInit(): Promise<boolean> {
if (initialized) return providerProd !== null || providerSandbox !== null;
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;
return false;
}
if (!fs.existsSync(p12Path)) {
console.warn(`[voip-push] disabled — p12 file not found at ${p12Path}`);
return null;
return false;
}
try {
// Dynamic import — vermeidet bundler statisches Tracing.
const mod = (await import("@parse/node-apn")) as unknown as ApnModule & {
default?: ApnModule;
};
apnMod = mod.default ?? mod;
topic = tpc;
provider = new apnMod.Provider({
// VoIP-Services-Cert ist Universal — wir bauen BEIDE Provider (Production +
// Sandbox) und wählen pro Token via Memoization. So funktioniert es für
// Xcode-Dev-Builds (Sandbox) UND TestFlight/AppStore (Production) parallel.
providerProd = new apnMod.Provider({
pfx: p12Path,
passphrase: p12Pass,
production,
production: true,
});
console.log(`[voip-push] initialized (topic=${tpc}, production=${production})`);
return provider;
providerSandbox = new apnMod.Provider({
pfx: p12Path,
passphrase: p12Pass,
production: false,
});
console.log(`[voip-push] initialized (topic=${tpc}, prod+sandbox providers ready)`);
return true;
} catch (err) {
console.error("[voip-push] init failed:", err);
return null;
return false;
}
}
@ -97,8 +106,8 @@ export interface VoIPCallPayload {
* @returns true bei Erfolg (oder no-op wenn Service disabled), false bei Fehler.
*/
export async function sendVoIPPush(payload: VoIPCallPayload): Promise<boolean> {
const p = await getProvider();
if (!p || !topic || !apnMod) return true; // no-op, regulärer Push übernimmt
const ok = await ensureInit();
if (!ok || !topic || !apnMod) return true; // no-op, regulärer Push übernimmt
const note: ApnNotification = new apnMod.Notification();
note.topic = topic;
@ -116,28 +125,56 @@ export async function sendVoIPPush(payload: VoIPCallPayload): Promise<boolean> {
},
};
try {
const result = await p.send(note, payload.voipToken);
if (result.failed.length > 0) {
// Reihenfolge: zuerst memoized Env (falls bekannt), sonst Production first →
// Sandbox als Fallback.
const cached = tokenEnvCache.get(payload.voipToken);
const order: Array<"prod" | "sandbox"> =
cached === "sandbox" ? ["sandbox", "prod"] : ["prod", "sandbox"];
for (const env of order) {
const prov = env === "prod" ? providerProd : providerSandbox;
if (!prov) continue;
try {
const result = await prov.send(note, payload.voipToken);
if (result.failed.length === 0) {
tokenEnvCache.set(payload.voipToken, env);
return true;
}
const f = result.failed[0];
const reason = (f.response as { reason?: string })?.reason;
// BadDeviceToken → nur dann Failover, sonst Abbruch (z.B. Unregistered).
if (reason === "BadDeviceToken") {
if (env === order[order.length - 1]) {
console.warn(
`[voip-push] failed token=${payload.voipToken.slice(0, 8)}… BadDeviceToken on both envs`,
);
return false;
}
// try next env
continue;
}
console.warn(
`[voip-push] failed token=${payload.voipToken.slice(0, 8)}… reason=`,
`[voip-push] failed token=${payload.voipToken.slice(0, 8)}env=${env} reason=`,
f.response ?? f.error,
);
return false;
} catch (err) {
console.error("[voip-push] send threw:", err);
return false;
}
return true;
} catch (err) {
console.error("[voip-push] send threw:", err);
return false;
}
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;
if (providerProd) {
providerProd.shutdown();
providerProd = null;
}
if (providerSandbox) {
providerSandbox.shutdown();
providerSandbox = null;
}
initialized = false;
}

View File

@ -29,6 +29,10 @@
"metro-symbolicate": "0.83.3",
"metro-transform-plugins": "0.83.3",
"metro-transform-worker": "0.83.3"
},
"patchedDependencies": {
"metro-core@0.83.3": "patches/metro-core@0.83.3.patch",
"react-native-callkeep": "patches/react-native-callkeep.patch"
}
}
}

View File

@ -0,0 +1,72 @@
diff --git a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java
index 025480ac97460cc45775ea11aa43af36522d5df6..106fbf0081b21ea02029add0aca86f55e8a55c07 100644
--- a/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java
+++ b/android/src/main/java/io/wazo/callkeep/RNCallKeepModule.java
@@ -189,7 +189,7 @@ public class RNCallKeepModule extends ReactContextBaseJavaModule implements Life
public void reportNewIncomingCall(String uuid, String number, String callerName, boolean hasVideo, String payload) {
Log.d(TAG, "[RNCallKeepModule] reportNewIncomingCall, uuid: " + uuid + ", number: " + number + ", callerName: " + callerName);
- this.displayIncomingCall(uuid, number, callerName, hasVideo);
+ this.displayIncomingCall(uuid, number, callerName, hasVideo, null);
// Send event to JS
WritableMap args = Arguments.createMap();
@@ -434,17 +434,26 @@ public class RNCallKeepModule extends ReactContextBaseJavaModule implements Life
this.hasListeners = false;
}
- @ReactMethod
- public void displayIncomingCall(String uuid, String number, String callerName) {
+ // REBREAK_PATCH: @ReactMethod entfernt — RN New Architecture TurboModule
+ // erlaubt keine overloaded methods mit gleichem JS-Namen. JS ruft immer den
+ // 5-arg-Overload (siehe RNCallKeep.js displayIncomingCall implementation).
+ public void displayIncomingCall3args(String uuid, String number, String callerName) {
this.displayIncomingCall(uuid, number, callerName, false, null);
}
+ // REBREAK_PATCH: TurboModule-Interop unterstützt android.os.Bundle nicht als
+ // @ReactMethod-Parameter. Daher: 4-arg-Variante (ohne Bundle) ist die einzige
+ // exposed @ReactMethod. JS-Wrapper ruft nie mit payload-Bundle.
@ReactMethod
public void displayIncomingCall(String uuid, String number, String callerName, boolean hasVideo) {
- this.displayIncomingCall(uuid, number, callerName, hasVideo, null);
+ this.displayIncomingCallInternal(uuid, number, callerName, hasVideo, null);
}
public void displayIncomingCall(String uuid, String number, String callerName, boolean hasVideo, @Nullable Bundle payload) {
+ this.displayIncomingCallInternal(uuid, number, callerName, hasVideo, payload);
+ }
+
+ private void displayIncomingCallInternal(String uuid, String number, String callerName, boolean hasVideo, @Nullable Bundle payload) {
if (!isConnectionServiceAvailable() || !hasPhoneAccount()) {
Log.w(TAG, "[RNCallKeepModule] displayIncomingCall ignored due to no ConnectionService or no phone account");
return;
@@ -483,17 +492,25 @@ public class RNCallKeepModule extends ReactContextBaseJavaModule implements Life
conn.onAnswer();
}
- @ReactMethod
- public void startCall(String uuid, String number, String callerName) {
+ // REBREAK_PATCH: @ReactMethod entfernt (siehe displayIncomingCall above).
+ public void startCall3args(String uuid, String number, String callerName) {
this.startCall(uuid, number, callerName, false, null);
}
+ public void startCall4args(String uuid, String number, String callerName, boolean hasVideo) {
+ this.startCall(uuid, number, callerName, hasVideo, null);
+ }
+
@ReactMethod
public void startCall(String uuid, String number, String callerName, boolean hasVideo) {
- this.startCall(uuid, number, callerName, hasVideo, null);
+ this.startCallInternal(uuid, number, callerName, hasVideo, null);
}
public void startCall(String uuid, String number, String callerName, boolean hasVideo, @Nullable Bundle payload) {
+ this.startCallInternal(uuid, number, callerName, hasVideo, payload);
+ }
+
+ private void startCallInternal(String uuid, String number, String callerName, boolean hasVideo, @Nullable Bundle payload) {
Log.d(TAG, "[RNCallKeepModule] startCall called, uuid: " + uuid + ", number: " + number + ", callerName: " + callerName + ", payload: " + payload);
if (!isConnectionServiceAvailable() || !hasPhoneAccount() || !hasPermissions() || number == null) {

17
pnpm-lock.yaml generated
View File

@ -21,6 +21,9 @@ patchedDependencies:
metro-core@0.83.3:
hash: dbd76dee4e5497574765c5986b0e889264e7251ea7b5e849e2967d2eb2efb757
path: patches/metro-core@0.83.3.patch
react-native-callkeep:
hash: cba8c2dd49745c7a4f62437ea38db7e1d457966cd037dc405c0dca06d84850dd
path: patches/react-native-callkeep.patch
importers:
@ -42,7 +45,7 @@ importers:
version: 14.3.0(vue@3.5.34(typescript@5.9.3))
'@vueuse/nuxt':
specifier: ^14.2.1
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(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))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
nuxt:
specifier: 4.1.3
version: 4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(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))(yaml@2.8.4)
@ -73,7 +76,7 @@ importers:
version: 1.2.3
'@nuxt/fonts':
specifier: ^0.11.4
version: 0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)
version: 0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)(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))
'@nuxt/icon':
specifier: ^1.10.0
version: 1.15.0(magicast@0.5.3)(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))(vue@3.5.34(typescript@5.9.3))
@ -91,7 +94,7 @@ importers:
version: 3.0.3(magicast@0.5.3)(vue@3.5.34(typescript@5.9.3))
'@vueuse/nuxt':
specifier: ^14.2.1
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
version: 14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(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))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))
chart.js:
specifier: ^4.5.1
version: 4.5.1
@ -275,7 +278,7 @@ importers:
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))
version: 4.3.16(patch_hash=cba8c2dd49745c7a4f62437ea38db7e1d457966cd037dc405c0dca06d84850dd)(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)
@ -11716,7 +11719,7 @@ snapshots:
- utf-8-validate
- vue
'@nuxt/fonts@0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)':
'@nuxt/fonts@0.11.4(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(ioredis@5.10.1)(magicast@0.5.3)(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:
'@nuxt/devtools-kit': 2.7.0(magicast@0.5.3)(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))
'@nuxt/kit': 3.21.4(magicast@0.5.3)
@ -14329,7 +14332,7 @@ snapshots:
transitivePeerDependencies:
- magicast
'@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))':
'@vueuse/nuxt@14.3.0(magicast@0.5.3)(nuxt@4.1.3(@electric-sql/pglite@0.4.1)(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)(@parcel/watcher@2.5.6)(@types/node@22.19.17)(@vue/compiler-sfc@3.5.35)(cac@6.7.14)(db0@0.3.4(@electric-sql/pglite@0.4.1)(mysql2@3.15.3))(eslint@10.3.0(jiti@2.7.0))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.3)(mysql2@3.15.3)(optionator@0.9.4)(rollup@4.60.3)(terser@5.46.2)(typescript@5.9.3)(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))(yaml@2.8.4))(vue@3.5.34(typescript@5.9.3))':
dependencies:
'@nuxt/kit': 4.4.4(magicast@0.5.3)
'@vueuse/core': 14.3.0(vue@3.5.34(typescript@5.9.3))
@ -18874,7 +18877,7 @@ 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)):
react-native-callkeep@4.3.16(patch_hash=cba8c2dd49745c7a4f62437ea38db7e1d457966cd037dc405c0dca06d84850dd)(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)