@ethora/chat-component-rn 26.10.6 → 26.10.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -1
- package/README.md +6 -3
- package/lib/commonjs/components/MessageBubble/Message.js +16 -6
- package/lib/commonjs/components/MessageBubble/Message.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js +16 -2
- package/lib/commonjs/components/VideoCalls/VideoCallOverlay.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/callActions.js +32 -2
- package/lib/commonjs/components/VideoCalls/callActions.js.map +1 -1
- package/lib/commonjs/components/VideoCalls/systemCall.js +83 -0
- package/lib/commonjs/components/VideoCalls/systemCall.js.map +1 -0
- package/lib/commonjs/components/VideoCalls/useCallKeep.js +83 -66
- package/lib/commonjs/components/VideoCalls/useCallKeep.js.map +1 -1
- package/lib/commonjs/helpers/incomingCallFromPush.js +95 -21
- package/lib/commonjs/helpers/incomingCallFromPush.js.map +1 -1
- package/lib/commonjs/helpers/secureStoreRuntime.js +10 -1
- package/lib/commonjs/helpers/secureStoreRuntime.js.map +1 -1
- package/lib/commonjs/main.js +114 -0
- package/lib/commonjs/main.js.map +1 -1
- package/lib/commonjs/networking/api-requests/push.api.js +22 -7
- package/lib/commonjs/networking/api-requests/push.api.js.map +1 -1
- package/lib/commonjs/networking/callTokenStanza.js +6 -0
- package/lib/commonjs/networking/callTokenStanza.js.map +1 -1
- package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js +22 -6
- package/lib/commonjs/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
- package/lib/commonjs/networking/xmppClient.js.map +1 -1
- package/lib/commonjs/push/pushKeyStore.js +169 -0
- package/lib/commonjs/push/pushKeyStore.js.map +1 -0
- package/lib/commonjs/push/sealedBox.js +106 -0
- package/lib/commonjs/push/sealedBox.js.map +1 -0
- package/lib/commonjs/push/sealedPush.js +135 -0
- package/lib/commonjs/push/sealedPush.js.map +1 -0
- package/lib/commonjs/push/sealedPushTask.js +221 -0
- package/lib/commonjs/push/sealedPushTask.js.map +1 -0
- package/lib/commonjs/services/pushRegistration.js +12 -7
- package/lib/commonjs/services/pushRegistration.js.map +1 -1
- package/lib/commonjs/version.js +1 -1
- package/lib/module/components/MessageBubble/Message.js +16 -6
- package/lib/module/components/MessageBubble/Message.js.map +1 -1
- package/lib/module/components/VideoCalls/VideoCallOverlay.js +17 -3
- package/lib/module/components/VideoCalls/VideoCallOverlay.js.map +1 -1
- package/lib/module/components/VideoCalls/callActions.js +29 -0
- package/lib/module/components/VideoCalls/callActions.js.map +1 -1
- package/lib/module/components/VideoCalls/systemCall.js +73 -0
- package/lib/module/components/VideoCalls/systemCall.js.map +1 -0
- package/lib/module/components/VideoCalls/useCallKeep.js +77 -64
- package/lib/module/components/VideoCalls/useCallKeep.js.map +1 -1
- package/lib/module/helpers/incomingCallFromPush.js +92 -22
- package/lib/module/helpers/incomingCallFromPush.js.map +1 -1
- package/lib/module/helpers/secureStoreRuntime.js +10 -1
- package/lib/module/helpers/secureStoreRuntime.js.map +1 -1
- package/lib/module/main.js +9 -0
- package/lib/module/main.js.map +1 -1
- package/lib/module/networking/api-requests/push.api.js +21 -6
- package/lib/module/networking/api-requests/push.api.js.map +1 -1
- package/lib/module/networking/callTokenStanza.js +6 -0
- package/lib/module/networking/callTokenStanza.js.map +1 -1
- package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js +21 -6
- package/lib/module/networking/xmpp/sendMessageReaction.xmpp.js.map +1 -1
- package/lib/module/networking/xmppClient.js.map +1 -1
- package/lib/module/push/pushKeyStore.js +159 -0
- package/lib/module/push/pushKeyStore.js.map +1 -0
- package/lib/module/push/sealedBox.js +97 -0
- package/lib/module/push/sealedBox.js.map +1 -0
- package/lib/module/push/sealedPush.js +127 -0
- package/lib/module/push/sealedPush.js.map +1 -0
- package/lib/module/push/sealedPushTask.js +212 -0
- package/lib/module/push/sealedPushTask.js.map +1 -0
- package/lib/module/services/pushRegistration.js +12 -7
- package/lib/module/services/pushRegistration.js.map +1 -1
- package/lib/module/version.js +1 -1
- package/lib/typescript/components/MessageBubble/Message.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/VideoCallOverlay.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/callActions.d.ts +6 -0
- package/lib/typescript/components/VideoCalls/callActions.d.ts.map +1 -1
- package/lib/typescript/components/VideoCalls/systemCall.d.ts +25 -0
- package/lib/typescript/components/VideoCalls/systemCall.d.ts.map +1 -0
- package/lib/typescript/components/VideoCalls/useCallKeep.d.ts +8 -23
- package/lib/typescript/components/VideoCalls/useCallKeep.d.ts.map +1 -1
- package/lib/typescript/helpers/incomingCallFromPush.d.ts +5 -13
- package/lib/typescript/helpers/incomingCallFromPush.d.ts.map +1 -1
- package/lib/typescript/helpers/secureStoreRuntime.d.ts +17 -3
- package/lib/typescript/helpers/secureStoreRuntime.d.ts.map +1 -1
- package/lib/typescript/main.d.ts +11 -0
- package/lib/typescript/main.d.ts.map +1 -1
- package/lib/typescript/networking/api-requests/push.api.d.ts +14 -2
- package/lib/typescript/networking/api-requests/push.api.d.ts.map +1 -1
- package/lib/typescript/networking/callTokenStanza.d.ts.map +1 -1
- package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts +13 -1
- package/lib/typescript/networking/xmpp/sendMessageReaction.xmpp.d.ts.map +1 -1
- package/lib/typescript/networking/xmppClient.d.ts +2 -4
- package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
- package/lib/typescript/push/pushKeyStore.d.ts +35 -0
- package/lib/typescript/push/pushKeyStore.d.ts.map +1 -0
- package/lib/typescript/push/sealedBox.d.ts +24 -0
- package/lib/typescript/push/sealedBox.d.ts.map +1 -0
- package/lib/typescript/push/sealedPush.d.ts +67 -0
- package/lib/typescript/push/sealedPush.d.ts.map +1 -0
- package/lib/typescript/push/sealedPushTask.d.ts +44 -0
- package/lib/typescript/push/sealedPushTask.d.ts.map +1 -0
- package/lib/typescript/services/pushRegistration.d.ts +8 -0
- package/lib/typescript/services/pushRegistration.d.ts.map +1 -1
- package/lib/typescript/types/models/xmpp.model.d.ts +2 -4
- package/lib/typescript/types/models/xmpp.model.d.ts.map +1 -1
- package/lib/typescript/types/types.d.ts +6 -0
- package/lib/typescript/types/types.d.ts.map +1 -1
- package/lib/typescript/version.d.ts +1 -1
- package/package.json +11 -1
- package/plugins/notification-service/NotificationService.swift +113 -0
- package/plugins/notification-service/SealedBox.swift +355 -0
- package/plugins/notification-service/check/gen-vectors.mjs +24 -0
- package/plugins/notification-service/check/main.swift +37 -0
- package/plugins/notification-service/check/run.sh +12 -0
- package/plugins/notification-service/check/vectors.json +1 -0
- package/plugins/withNotificationServiceExtension.js +70 -18
- package/src/components/MessageBubble/Message.tsx +16 -5
- package/src/components/VideoCalls/VideoCallOverlay.tsx +18 -2
- package/src/components/VideoCalls/callActions.ts +28 -0
- package/src/components/VideoCalls/systemCall.ts +109 -0
- package/src/components/VideoCalls/useCallKeep.ts +89 -97
- package/src/helpers/incomingCallFromPush.ts +97 -26
- package/src/helpers/secureStoreRuntime.ts +21 -3
- package/src/main.ts +38 -0
- package/src/networking/api-requests/push.api.ts +29 -4
- package/src/networking/callTokenStanza.ts +10 -0
- package/src/networking/xmpp/sendMessageReaction.xmpp.ts +38 -6
- package/src/networking/xmppClient.ts +2 -2
- package/src/push/pushKeyStore.ts +163 -0
- package/src/push/sealedBox.ts +104 -0
- package/src/push/sealedPush.ts +138 -0
- package/src/push/sealedPushTask.ts +221 -0
- package/src/services/pushRegistration.ts +25 -5
- package/src/types/models/xmpp.model.ts +2 -1
- package/src/types/types.ts +6 -0
- package/src/version.ts +1 -1
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Expo config plugin — adds an iOS Notification Service Extension
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
2
|
+
* Expo config plugin — adds an iOS Notification Service Extension for Ethora
|
|
3
|
+
* chat pushes. The extension (plugins/notification-service/NotificationService.swift)
|
|
4
|
+
* - decrypts encrypted (sealed) pushes with the device key the app keeps in
|
|
5
|
+
* the keychain and shows the real title and text in place of the
|
|
6
|
+
* "New message" placeholder;
|
|
7
|
+
* - shows the attachment thumbnail (photo / video preview) the push service
|
|
8
|
+
* sends as `previewUrl`.
|
|
9
9
|
*
|
|
10
10
|
* An extension is a separate target signed with the app, so it cannot ship
|
|
11
11
|
* inside the npm package as code: this plugin writes it into ios/ on every
|
|
12
12
|
* `expo prebuild` (the generated ios/ folder is gitignored in Expo apps).
|
|
13
|
+
* For the key to be readable from the extension, the app and the extension
|
|
14
|
+
* share a keychain access group (`$(AppIdentifierPrefix)<bundle id>`, the
|
|
15
|
+
* app's own default group, listed first so items the app writes without a
|
|
16
|
+
* group — expo-secure-store's — land in it). The sealed box is opened in
|
|
17
|
+
* plain Swift (SealedBox.swift: CryptoKit for X25519, the rest ported from
|
|
18
|
+
* tweetnacl), so the extension needs no pod.
|
|
13
19
|
*
|
|
14
20
|
* Host app.json:
|
|
15
21
|
* "plugins": ["@ethora/chat-component-rn"]
|
|
@@ -23,25 +29,39 @@
|
|
|
23
29
|
|
|
24
30
|
const fs = require('fs');
|
|
25
31
|
const path = require('path');
|
|
26
|
-
const { withDangerousMod, withXcodeProject } = require('expo/config-plugins');
|
|
32
|
+
const { withDangerousMod, withEntitlementsPlist, withXcodeProject } = require('expo/config-plugins');
|
|
27
33
|
|
|
28
34
|
const TARGET_NAME = 'EthoraNotificationService';
|
|
29
35
|
const SOURCE_DIR = path.join(__dirname, 'notification-service');
|
|
30
|
-
const
|
|
36
|
+
const SOURCES = ['NotificationService.swift', 'SealedBox.swift'];
|
|
37
|
+
const FILES = [...SOURCES, 'Info.plist'];
|
|
38
|
+
const ENTITLEMENTS_FILE = `${TARGET_NAME}.entitlements`;
|
|
39
|
+
const KEYCHAIN_GROUPS_KEY = 'keychain-access-groups';
|
|
31
40
|
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
if (!
|
|
41
|
+
const appBundleId = (config) => {
|
|
42
|
+
const bundleId = config.ios && config.ios.bundleIdentifier;
|
|
43
|
+
if (!bundleId) {
|
|
35
44
|
throw new Error(
|
|
36
45
|
'[@ethora/chat-component-rn] ios.bundleIdentifier is required for the notification service extension'
|
|
37
46
|
);
|
|
38
47
|
}
|
|
39
|
-
return
|
|
48
|
+
return bundleId;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const extensionBundleId = (config) => `${appBundleId(config)}.${TARGET_NAME}`;
|
|
52
|
+
|
|
53
|
+
/** The keychain access group shared by the app and the extension. */
|
|
54
|
+
const keychainGroup = (config) => `$(AppIdentifierPrefix)${appBundleId(config)}`;
|
|
55
|
+
|
|
56
|
+
const withSharedGroupFirst = (groups, group) => {
|
|
57
|
+
const list = Array.isArray(groups) ? groups.filter((g) => g !== group) : [];
|
|
58
|
+
return [group, ...list];
|
|
40
59
|
};
|
|
41
60
|
|
|
42
61
|
// EAS Build creates signing credentials only for targets it knows about.
|
|
43
62
|
const withEasAppExtension = (config) => {
|
|
44
63
|
const bundleIdentifier = extensionBundleId(config);
|
|
64
|
+
const group = keychainGroup(config);
|
|
45
65
|
config.extra = config.extra || {};
|
|
46
66
|
config.extra.eas = config.extra.eas || {};
|
|
47
67
|
config.extra.eas.build = config.extra.eas.build || {};
|
|
@@ -49,12 +69,42 @@ const withEasAppExtension = (config) => {
|
|
|
49
69
|
const ios = (config.extra.eas.build.experimental.ios =
|
|
50
70
|
config.extra.eas.build.experimental.ios || {});
|
|
51
71
|
const extensions = (ios.appExtensions = ios.appExtensions || []);
|
|
52
|
-
|
|
53
|
-
|
|
72
|
+
let entry = extensions.find((ext) => ext.targetName === TARGET_NAME);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
entry = { targetName: TARGET_NAME, bundleIdentifier, entitlements: {} };
|
|
75
|
+
extensions.push(entry);
|
|
54
76
|
}
|
|
77
|
+
entry.entitlements = entry.entitlements || {};
|
|
78
|
+
entry.entitlements[KEYCHAIN_GROUPS_KEY] = withSharedGroupFirst(
|
|
79
|
+
entry.entitlements[KEYCHAIN_GROUPS_KEY],
|
|
80
|
+
group
|
|
81
|
+
);
|
|
55
82
|
return config;
|
|
56
83
|
};
|
|
57
84
|
|
|
85
|
+
// The app's side of the shared keychain group (Keychain Sharing capability).
|
|
86
|
+
const withAppKeychainGroup = (config) =>
|
|
87
|
+
withEntitlementsPlist(config, (cfg) => {
|
|
88
|
+
const group = keychainGroup(cfg);
|
|
89
|
+
cfg.modResults[KEYCHAIN_GROUPS_KEY] = withSharedGroupFirst(
|
|
90
|
+
cfg.modResults[KEYCHAIN_GROUPS_KEY],
|
|
91
|
+
group
|
|
92
|
+
);
|
|
93
|
+
return cfg;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const entitlementsPlist = (group) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
97
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
98
|
+
<plist version="1.0">
|
|
99
|
+
<dict>
|
|
100
|
+
<key>${KEYCHAIN_GROUPS_KEY}</key>
|
|
101
|
+
<array>
|
|
102
|
+
<string>${group}</string>
|
|
103
|
+
</array>
|
|
104
|
+
</dict>
|
|
105
|
+
</plist>
|
|
106
|
+
`;
|
|
107
|
+
|
|
58
108
|
// Copied on every prebuild so an SDK update reaches the app.
|
|
59
109
|
const withExtensionFiles = (config) =>
|
|
60
110
|
withDangerousMod(config, [
|
|
@@ -65,6 +115,7 @@ const withExtensionFiles = (config) =>
|
|
|
65
115
|
for (const file of FILES) {
|
|
66
116
|
fs.copyFileSync(path.join(SOURCE_DIR, file), path.join(dest, file));
|
|
67
117
|
}
|
|
118
|
+
fs.writeFileSync(path.join(dest, ENTITLEMENTS_FILE), entitlementsPlist(keychainGroup(cfg)));
|
|
68
119
|
return cfg;
|
|
69
120
|
},
|
|
70
121
|
]);
|
|
@@ -97,14 +148,14 @@ const withExtensionTarget = (config, { appleTeamId } = {}) =>
|
|
|
97
148
|
objects.PBXTargetDependency = objects.PBXTargetDependency || {};
|
|
98
149
|
objects.PBXContainerItemProxy = objects.PBXContainerItemProxy || {};
|
|
99
150
|
|
|
100
|
-
const group = project.addPbxGroup(FILES, TARGET_NAME, TARGET_NAME);
|
|
151
|
+
const group = project.addPbxGroup([...FILES, ENTITLEMENTS_FILE], TARGET_NAME, TARGET_NAME);
|
|
101
152
|
const mainGroup = project.getFirstProject().firstProject.mainGroup;
|
|
102
153
|
project.addToPbxGroup(group.uuid, mainGroup);
|
|
103
154
|
|
|
104
155
|
// 'app_extension' also embeds the .appex into the app (Copy Files to
|
|
105
156
|
// PlugIns) and makes the app depend on it.
|
|
106
157
|
const target = project.addTarget(TARGET_NAME, 'app_extension', TARGET_NAME, bundleIdentifier);
|
|
107
|
-
project.addBuildPhase(
|
|
158
|
+
project.addBuildPhase(SOURCES, 'PBXSourcesBuildPhase', 'Sources', target.uuid);
|
|
108
159
|
project.addBuildPhase([], 'PBXResourcesBuildPhase', 'Resources', target.uuid);
|
|
109
160
|
project.addBuildPhase([], 'PBXFrameworksBuildPhase', 'Frameworks', target.uuid);
|
|
110
161
|
targetUuid = target.uuid;
|
|
@@ -116,6 +167,7 @@ const withExtensionTarget = (config, { appleTeamId } = {}) =>
|
|
|
116
167
|
PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`,
|
|
117
168
|
INFOPLIST_FILE: `"${TARGET_NAME}/Info.plist"`,
|
|
118
169
|
GENERATE_INFOPLIST_FILE: 'NO',
|
|
170
|
+
CODE_SIGN_ENTITLEMENTS: `"${TARGET_NAME}/${ENTITLEMENTS_FILE}"`,
|
|
119
171
|
SWIFT_VERSION: appSettings.SWIFT_VERSION || '5.0',
|
|
120
172
|
IPHONEOS_DEPLOYMENT_TARGET: appSettings.IPHONEOS_DEPLOYMENT_TARGET || '15.1',
|
|
121
173
|
TARGETED_DEVICE_FAMILY: appSettings.TARGETED_DEVICE_FAMILY || '"1,2"',
|
|
@@ -132,6 +184,6 @@ const withExtensionTarget = (config, { appleTeamId } = {}) =>
|
|
|
132
184
|
});
|
|
133
185
|
|
|
134
186
|
const withNotificationServiceExtension = (config, props = {}) =>
|
|
135
|
-
withExtensionTarget(withExtensionFiles(withEasAppExtension(config)), props);
|
|
187
|
+
withExtensionTarget(withExtensionFiles(withAppKeychainGroup(withEasAppExtension(config))), props);
|
|
136
188
|
|
|
137
189
|
module.exports = withNotificationServiceExtension;
|
|
@@ -407,12 +407,23 @@ const Message: React.FC<MessageProps> = ({ message, isUser, isReply }) => {
|
|
|
407
407
|
|
|
408
408
|
const handleReactionMessage = (emoji: string) => {
|
|
409
409
|
if (!reactionsEnabled(config) || config?.disableInteractions) {return;}
|
|
410
|
-
const sender = { firstName: user.firstName, lastName: user.lastName };
|
|
411
410
|
const own = message.reaction?.[user.xmppUsername || '']?.emoji || [];
|
|
412
|
-
const
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
411
|
+
const adding = !own.includes(emoji);
|
|
412
|
+
const next = adding ? [...own, emoji] : own.filter((reaction) => reaction !== emoji);
|
|
413
|
+
// The author gets a push about an added emoji (see sendMessageReaction);
|
|
414
|
+
// nothing for a removed one or for one's own message.
|
|
415
|
+
const author = message.user?.id || '';
|
|
416
|
+
client?.sendMessageReactionStanza(message.id, message.roomJid, next, {
|
|
417
|
+
firstName: user.firstName,
|
|
418
|
+
lastName: user.lastName,
|
|
419
|
+
photoURL: user.profileImage,
|
|
420
|
+
push: adding && !!author && author !== user.xmppUsername,
|
|
421
|
+
messageAuthor: author,
|
|
422
|
+
messageText: message.body,
|
|
423
|
+
isMediafile: message.isMediafile,
|
|
424
|
+
mimetype: message.mimetype,
|
|
425
|
+
fileName: message.originalName || message.fileName,
|
|
426
|
+
});
|
|
416
427
|
};
|
|
417
428
|
|
|
418
429
|
// Long-press → capture the bubble's on-screen bounding box. The actual
|
|
@@ -26,6 +26,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
|
26
26
|
import { RootState } from '../../roomStore';
|
|
27
27
|
import {
|
|
28
28
|
acceptIncomingCall,
|
|
29
|
+
declineIncomingCall,
|
|
29
30
|
resetCall,
|
|
30
31
|
setCallError,
|
|
31
32
|
} from '../../roomStore/callSlice';
|
|
@@ -64,6 +65,12 @@ const ACCEPT = '#10B981';
|
|
|
64
65
|
// PSTN ring window.
|
|
65
66
|
const OUTGOING_CALL_TIMEOUT_MS = 30000;
|
|
66
67
|
|
|
68
|
+
// Stop ringing an incoming call nobody cancelled. The caller gives up after
|
|
69
|
+
// OUTGOING_CALL_TIMEOUT_MS and says so, but that signal is lost when the
|
|
70
|
+
// call came in by push and the socket never came up (the system call
|
|
71
|
+
// screen of a VoIP push would otherwise ring on).
|
|
72
|
+
const INCOMING_CALL_TIMEOUT_MS = 45000;
|
|
73
|
+
|
|
67
74
|
// Ring pattern for an incoming call: wait, buzz, pause, buzz... Android
|
|
68
75
|
// loops this from index 0, iOS ignores the pattern and just vibrates.
|
|
69
76
|
const RING_VIBRATION_PATTERN = [0, 700, 900];
|
|
@@ -507,8 +514,6 @@ const VideoCallOverlayInner: FC = () => {
|
|
|
507
514
|
}, [enabled, canRenderSession, call.token, livekitUrl, call.kind]);
|
|
508
515
|
|
|
509
516
|
// Give up on an unanswered outgoing dial. Unlike web (which only shows
|
|
510
|
-
// the error), also tell the callee, who would otherwise keep ringing:
|
|
511
|
-
// there is no incoming ring timeout on either platform.
|
|
512
517
|
useEffect(() => {
|
|
513
518
|
if (call.phase !== 'requesting' || call.direction !== 'outgoing') {
|
|
514
519
|
return;
|
|
@@ -520,6 +525,17 @@ const VideoCallOverlayInner: FC = () => {
|
|
|
520
525
|
return () => clearTimeout(timer);
|
|
521
526
|
}, [call.phase, call.direction, call.startedAt, dispatch, t]);
|
|
522
527
|
|
|
528
|
+
useEffect(() => {
|
|
529
|
+
if (call.phase !== 'ringing-incoming' || call.direction !== 'incoming') {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const elapsed = Date.now() - (call.startedAt || Date.now());
|
|
533
|
+
const timer = setTimeout(() => {
|
|
534
|
+
dispatch(declineIncomingCall());
|
|
535
|
+
}, Math.max(0, INCOMING_CALL_TIMEOUT_MS - elapsed));
|
|
536
|
+
return () => clearTimeout(timer);
|
|
537
|
+
}, [call.phase, call.direction, call.startedAt, dispatch]);
|
|
538
|
+
|
|
523
539
|
// Buzz while an incoming call is ringing. The ring screen is already on
|
|
524
540
|
// top, so this is the only alert needed when the app is in the
|
|
525
541
|
// foreground; a backgrounded/killed app is handled by the call push.
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
sendCallStateSignal,
|
|
8
8
|
} from '../../networking/callTokenStanza';
|
|
9
9
|
import { translateKey } from '../../i18n/strings';
|
|
10
|
+
import { getGlobalXmppClient } from '../../utils/clientRegistry';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Call teardown that doesn't need a mounted component.
|
|
@@ -51,6 +52,33 @@ export const declineActiveCall = (): void => {
|
|
|
51
52
|
store.dispatch(declineIncomingCall());
|
|
52
53
|
};
|
|
53
54
|
|
|
55
|
+
const DECLINE_SIGNAL_WAIT_MS = 30_000;
|
|
56
|
+
const DECLINE_SIGNAL_POLL_MS = 500;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decline the ringing call, telling the caller as soon as the socket is
|
|
60
|
+
* up. A call declined on the lock screen right after a push woke the app
|
|
61
|
+
* gets here while XMPP is still connecting, and the signal would be lost.
|
|
62
|
+
*/
|
|
63
|
+
export const declineActiveCallWhenOnline = (): void => {
|
|
64
|
+
const call = store.getState().call;
|
|
65
|
+
const signal = {
|
|
66
|
+
peerXmppUsername: call.peerXmppUsername,
|
|
67
|
+
callId: call.callId,
|
|
68
|
+
roomBareName: call.roomBareName,
|
|
69
|
+
};
|
|
70
|
+
store.dispatch(declineIncomingCall());
|
|
71
|
+
const deadline = Date.now() + DECLINE_SIGNAL_WAIT_MS;
|
|
72
|
+
const attempt = () => {
|
|
73
|
+
if (getGlobalXmppClient()?.status === 'online') {
|
|
74
|
+
sendCallStateSignal('declined', signal);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (Date.now() < deadline) {setTimeout(attempt, DECLINE_SIGNAL_POLL_MS);}
|
|
78
|
+
};
|
|
79
|
+
attempt();
|
|
80
|
+
};
|
|
81
|
+
|
|
54
82
|
/** `t` for code outside React, resolved by the same rule as useT. */
|
|
55
83
|
export const translateCallString = (
|
|
56
84
|
key: string,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
export interface CallKeepModule {
|
|
5
|
+
setup: (options: any) => Promise<void>;
|
|
6
|
+
displayIncomingCall: (
|
|
7
|
+
uuid: string,
|
|
8
|
+
handle: string,
|
|
9
|
+
localizedCallerName?: string,
|
|
10
|
+
handleType?: string,
|
|
11
|
+
hasVideo?: boolean
|
|
12
|
+
) => void;
|
|
13
|
+
endCall: (uuid: string) => void;
|
|
14
|
+
endAllCalls: () => void;
|
|
15
|
+
startCall?: (
|
|
16
|
+
uuid: string,
|
|
17
|
+
handle: string,
|
|
18
|
+
contactIdentifier?: string,
|
|
19
|
+
handleType?: string,
|
|
20
|
+
hasVideo?: boolean
|
|
21
|
+
) => void;
|
|
22
|
+
answerIncomingCall?: (uuid: string) => void;
|
|
23
|
+
reportConnectingOutgoingCallWithUUID?: (uuid: string) => void;
|
|
24
|
+
reportConnectedOutgoingCallWithUUID?: (uuid: string) => void;
|
|
25
|
+
reportEndCallWithUUID?: (uuid: string, reason: number) => void;
|
|
26
|
+
setMutedCall?: (uuid: string, muted: boolean) => void;
|
|
27
|
+
addEventListener: (event: string, handler: (payload: any) => void) => void;
|
|
28
|
+
removeEventListener: (event: string) => void;
|
|
29
|
+
setAvailable?: (available: boolean) => void;
|
|
30
|
+
backToForeground?: () => void;
|
|
31
|
+
clearInitialEvents?: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let cachedCallKeep: CallKeepModule | null | undefined;
|
|
35
|
+
|
|
36
|
+
export const loadCallKeep = (): CallKeepModule | null => {
|
|
37
|
+
if (cachedCallKeep !== undefined) {
|
|
38
|
+
return cachedCallKeep;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const { NativeModules } = require('react-native');
|
|
42
|
+
if (!NativeModules?.RNCallKeep) {
|
|
43
|
+
cachedCallKeep = null;
|
|
44
|
+
return cachedCallKeep;
|
|
45
|
+
}
|
|
46
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
47
|
+
const mod = require('react-native-callkeep');
|
|
48
|
+
cachedCallKeep = (mod?.default || mod) as CallKeepModule;
|
|
49
|
+
} catch {
|
|
50
|
+
cachedCallKeep = null;
|
|
51
|
+
}
|
|
52
|
+
return cachedCallKeep;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const callKitUuid = (seed: string): string => {
|
|
56
|
+
let hash = 0x811c9dc5;
|
|
57
|
+
const bytes: number[] = [];
|
|
58
|
+
for (let i = 0; i < 32; i++) {
|
|
59
|
+
const ch = seed.charCodeAt(i % Math.max(1, seed.length)) + i;
|
|
60
|
+
hash ^= ch;
|
|
61
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
62
|
+
bytes.push(hash & 0xff);
|
|
63
|
+
}
|
|
64
|
+
const hex = bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
65
|
+
return [
|
|
66
|
+
hex.slice(0, 8),
|
|
67
|
+
hex.slice(8, 12),
|
|
68
|
+
'4' + hex.slice(13, 16),
|
|
69
|
+
((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16) + hex.slice(17, 20),
|
|
70
|
+
hex.slice(20, 32),
|
|
71
|
+
].join('-');
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type SystemCallAction = 'answer' | 'end';
|
|
75
|
+
|
|
76
|
+
const SYSTEM_ACTION_TTL_MS = 2 * 60 * 1000;
|
|
77
|
+
const pendingActions = new Map<string, { action: SystemCallAction; at: number }>();
|
|
78
|
+
|
|
79
|
+
const key = (uuid: string) => uuid.trim().toLowerCase();
|
|
80
|
+
|
|
81
|
+
export const rememberSystemCallAction = (
|
|
82
|
+
uuid: string,
|
|
83
|
+
action: SystemCallAction
|
|
84
|
+
): void => {
|
|
85
|
+
if (!uuid) {return;}
|
|
86
|
+
pendingActions.set(key(uuid), { action, at: Date.now() });
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const takeSystemCallAction = (uuid: string): SystemCallAction | null => {
|
|
90
|
+
const entry = pendingActions.get(key(uuid));
|
|
91
|
+
pendingActions.delete(key(uuid));
|
|
92
|
+
if (!entry || Date.now() - entry.at > SYSTEM_ACTION_TTL_MS) {return null;}
|
|
93
|
+
return entry.action;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const endSystemIncomingCall = (callId: string | null | undefined): void => {
|
|
97
|
+
if (Platform.OS !== 'ios' || !callId) {return;}
|
|
98
|
+
const callKeep = loadCallKeep();
|
|
99
|
+
if (!callKeep?.reportEndCallWithUUID) {return;}
|
|
100
|
+
try {
|
|
101
|
+
callKeep.reportEndCallWithUUID(callKitUuid(callId), 3);
|
|
102
|
+
} catch {
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const __resetSystemCallForTests = (): void => {
|
|
107
|
+
pendingActions.clear();
|
|
108
|
+
cachedCallKeep = undefined;
|
|
109
|
+
};
|
|
@@ -4,10 +4,16 @@ import { useDispatch, useSelector } from 'react-redux';
|
|
|
4
4
|
import { RootState, store } from '../../roomStore';
|
|
5
5
|
import { acceptIncomingCall, declineIncomingCall } from '../../roomStore/callSlice';
|
|
6
6
|
import { sendCallStateSignal } from '../../networking/callTokenStanza';
|
|
7
|
-
import { terminateActiveCall } from './callActions';
|
|
7
|
+
import { declineActiveCallWhenOnline, terminateActiveCall } from './callActions';
|
|
8
8
|
import { setCallMicEnabled, useCallSession } from './callSessionController';
|
|
9
9
|
import { loadLiveKit } from './livekitRuntime';
|
|
10
10
|
import { setCallSystemRinging } from './callUiStore';
|
|
11
|
+
import {
|
|
12
|
+
callKitUuid,
|
|
13
|
+
loadCallKeep,
|
|
14
|
+
rememberSystemCallAction,
|
|
15
|
+
takeSystemCallAction,
|
|
16
|
+
} from './systemCall';
|
|
11
17
|
|
|
12
18
|
/**
|
|
13
19
|
* Native incoming-call UI (Android ConnectionService / iOS CallKit) via
|
|
@@ -27,95 +33,15 @@ import { setCallSystemRinging } from './callUiStore';
|
|
|
27
33
|
* Platform reality, stated plainly:
|
|
28
34
|
* - Android: full value. A ConnectionService call shows the system
|
|
29
35
|
* incoming-call screen over the lock screen.
|
|
30
|
-
* - iOS: this only
|
|
31
|
-
* a killed app
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
36
|
+
* - iOS: this hook only runs while `<Chat>` is mounted. A CallKit screen
|
|
37
|
+
* from a killed app comes from the host: its PushKit handler reports
|
|
38
|
+
* the VoIP push to CallKit natively under `callKitUuid(callId)` and
|
|
39
|
+
* hands the payload to `handlePushPayload`. This hook then adopts that
|
|
40
|
+
* call (same UUID, CallKit answers "CallUUIDAlreadyExists" to our own
|
|
41
|
+
* report) and replays what the user did on it before JS was up.
|
|
35
42
|
*/
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
setup: (options: any) => Promise<void>;
|
|
39
|
-
displayIncomingCall: (
|
|
40
|
-
uuid: string,
|
|
41
|
-
handle: string,
|
|
42
|
-
localizedCallerName?: string,
|
|
43
|
-
handleType?: string,
|
|
44
|
-
hasVideo?: boolean
|
|
45
|
-
) => void;
|
|
46
|
-
endCall: (uuid: string) => void;
|
|
47
|
-
endAllCalls: () => void;
|
|
48
|
-
startCall?: (
|
|
49
|
-
uuid: string,
|
|
50
|
-
handle: string,
|
|
51
|
-
contactIdentifier?: string,
|
|
52
|
-
handleType?: string,
|
|
53
|
-
hasVideo?: boolean
|
|
54
|
-
) => void;
|
|
55
|
-
answerIncomingCall?: (uuid: string) => void;
|
|
56
|
-
reportConnectingOutgoingCallWithUUID?: (uuid: string) => void;
|
|
57
|
-
reportConnectedOutgoingCallWithUUID?: (uuid: string) => void;
|
|
58
|
-
reportEndCallWithUUID?: (uuid: string, reason: number) => void;
|
|
59
|
-
setMutedCall?: (uuid: string, muted: boolean) => void;
|
|
60
|
-
addEventListener: (event: string, handler: (payload: any) => void) => void;
|
|
61
|
-
removeEventListener: (event: string) => void;
|
|
62
|
-
setAvailable?: (available: boolean) => void;
|
|
63
|
-
backToForeground?: () => void;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
let cachedCallKeep: CallKeepModule | null | undefined;
|
|
67
|
-
|
|
68
|
-
export const loadCallKeep = (): CallKeepModule | null => {
|
|
69
|
-
if (cachedCallKeep !== undefined) {
|
|
70
|
-
return cachedCallKeep;
|
|
71
|
-
}
|
|
72
|
-
try {
|
|
73
|
-
// The JS package being installed does NOT mean the native side is
|
|
74
|
-
// linked: a host can `npm i react-native-callkeep` and skip
|
|
75
|
-
// `pod install`, or (very common in this repo) node_modules can carry
|
|
76
|
-
// it over from another branch whose build did link it. In that state
|
|
77
|
-
// requiring the package builds a `new NativeEventEmitter(null)` at
|
|
78
|
-
// module scope, which throws an invariant that RN surfaces as an
|
|
79
|
-
// uncaught redbox on every render of the call overlay — the whole app
|
|
80
|
-
// becomes unusable over a feature the host never switched on.
|
|
81
|
-
// Probing NativeModules first keeps the optional dependency genuinely
|
|
82
|
-
// optional: no native module => behave exactly as if it isn't installed.
|
|
83
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
84
|
-
const { NativeModules } = require('react-native');
|
|
85
|
-
if (!NativeModules?.RNCallKeep) {
|
|
86
|
-
cachedCallKeep = null;
|
|
87
|
-
return cachedCallKeep;
|
|
88
|
-
}
|
|
89
|
-
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
90
|
-
const mod = require('react-native-callkeep');
|
|
91
|
-
cachedCallKeep = (mod?.default || mod) as CallKeepModule;
|
|
92
|
-
} catch {
|
|
93
|
-
cachedCallKeep = null;
|
|
94
|
-
}
|
|
95
|
-
return cachedCallKeep;
|
|
96
|
-
};
|
|
97
|
-
|
|
98
|
-
// CallKeep identifies calls by UUID. Our callIds aren't UUIDs, so derive a
|
|
99
|
-
// stable v4-shaped string from the callId: same call always maps to the
|
|
100
|
-
// same UUID, which is what endCall() needs to dismiss the right screen.
|
|
101
|
-
const toUuid = (seed: string): string => {
|
|
102
|
-
let hash = 0x811c9dc5;
|
|
103
|
-
const bytes: number[] = [];
|
|
104
|
-
for (let i = 0; i < 32; i++) {
|
|
105
|
-
const ch = seed.charCodeAt(i % Math.max(1, seed.length)) + i;
|
|
106
|
-
hash ^= ch;
|
|
107
|
-
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
108
|
-
bytes.push(hash & 0xff);
|
|
109
|
-
}
|
|
110
|
-
const hex = bytes.map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
111
|
-
return [
|
|
112
|
-
hex.slice(0, 8),
|
|
113
|
-
hex.slice(8, 12),
|
|
114
|
-
'4' + hex.slice(13, 16),
|
|
115
|
-
((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16) + hex.slice(17, 20),
|
|
116
|
-
hex.slice(20, 32),
|
|
117
|
-
].join('-');
|
|
118
|
-
};
|
|
44
|
+
export { loadCallKeep };
|
|
119
45
|
|
|
120
46
|
const trace = (...args: unknown[]) => {
|
|
121
47
|
if (__DEV__) {console.log('[calls] CallKit:', ...args);}
|
|
@@ -192,7 +118,25 @@ export const useCallKeep = (): void => {
|
|
|
192
118
|
useEffect(() => {
|
|
193
119
|
if (!callKeep) {return;}
|
|
194
120
|
|
|
195
|
-
const
|
|
121
|
+
const isOtherCall = (payload: any): boolean => {
|
|
122
|
+
const uuid = String(payload?.callUUID || '').toLowerCase();
|
|
123
|
+
if (!uuid) {return false;}
|
|
124
|
+
const active = activeUuidRef.current?.toLowerCase() || null;
|
|
125
|
+
if (active === uuid) {return false;}
|
|
126
|
+
const current = store.getState().call;
|
|
127
|
+
const ringingUuid =
|
|
128
|
+
current.direction === 'incoming' && current.callId
|
|
129
|
+
? callKitUuid(current.callId)
|
|
130
|
+
: null;
|
|
131
|
+
return ringingUuid !== uuid;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const onAnswer = (payload?: any) => {
|
|
135
|
+
if (isOtherCall(payload)) {
|
|
136
|
+
trace('answer before the call arrived', payload?.callUUID);
|
|
137
|
+
rememberSystemCallAction(String(payload.callUUID), 'answer');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
196
140
|
answeredRef.current = true;
|
|
197
141
|
// The user answered from the system screen, which on Android may
|
|
198
142
|
// mean the app was not even in the foreground.
|
|
@@ -206,7 +150,12 @@ export const useCallKeep = (): void => {
|
|
|
206
150
|
}
|
|
207
151
|
};
|
|
208
152
|
|
|
209
|
-
const onEnd = () => {
|
|
153
|
+
const onEnd = (payload?: any) => {
|
|
154
|
+
if (isOtherCall(payload)) {
|
|
155
|
+
trace('declined before the call arrived', payload?.callUUID);
|
|
156
|
+
rememberSystemCallAction(String(payload.callUUID), 'end');
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
210
159
|
const snapshot = store.getState().call;
|
|
211
160
|
if (snapshot.phase === 'idle') {return;}
|
|
212
161
|
if (
|
|
@@ -262,16 +211,46 @@ export const useCallKeep = (): void => {
|
|
|
262
211
|
callKeep.addEventListener('didResetProvider', onReset);
|
|
263
212
|
// The system's verdict on displayIncomingCall ("Unentitled" means the
|
|
264
213
|
// app lacks the voip background mode).
|
|
265
|
-
const onDisplayed = ({
|
|
266
|
-
|
|
214
|
+
const onDisplayed = ({
|
|
215
|
+
error,
|
|
216
|
+
errorCode,
|
|
217
|
+
fromPushKit,
|
|
218
|
+
}: {
|
|
219
|
+
error?: string;
|
|
220
|
+
errorCode?: string;
|
|
221
|
+
fromPushKit?: string;
|
|
222
|
+
}) => {
|
|
223
|
+
trace(
|
|
224
|
+
`incoming call shown${fromPushKit === '1' ? ' (PushKit)' : ''}`,
|
|
225
|
+
error ? `failed: ${errorCode || ''} ${error}` : 'ok'
|
|
226
|
+
);
|
|
227
|
+
if (errorCode === 'CallUUIDAlreadyExists') {return;}
|
|
267
228
|
// CallKit refused it (Do Not Disturb, unentitled...): bring the
|
|
268
|
-
|
|
269
|
-
if (error) {setCallSystemRinging(false);}
|
|
229
|
+
if (error && fromPushKit !== '1') {setCallSystemRinging(false);}
|
|
270
230
|
};
|
|
271
231
|
callKeep.addEventListener('didDisplayIncomingCall', onDisplayed);
|
|
272
232
|
callKeep.addEventListener('didDeactivateAudioSession', onAudioDeactivated);
|
|
273
233
|
|
|
234
|
+
const replay: Record<string, (data: any) => void> = {
|
|
235
|
+
RNCallKeepPerformAnswerCallAction: onAnswer,
|
|
236
|
+
RNCallKeepPerformEndCallAction: onEnd,
|
|
237
|
+
RNCallKeepDidDisplayIncomingCall: onDisplayed,
|
|
238
|
+
RNCallKeepDidActivateAudioSession: onAudioActivated,
|
|
239
|
+
RNCallKeepDidDeactivateAudioSession: onAudioDeactivated,
|
|
240
|
+
RNCallKeepDidPerformSetMutedCallAction: onMuted,
|
|
241
|
+
};
|
|
242
|
+
const onLoadWithEvents = (events: { name: string; data: any }[]) => {
|
|
243
|
+
if (!Array.isArray(events) || events.length === 0) {return;}
|
|
244
|
+
trace('replaying', events.map((e) => e.name).join(', '));
|
|
245
|
+
callKeep.clearInitialEvents?.();
|
|
246
|
+
for (const event of events) {
|
|
247
|
+
replay[event.name]?.(event.data);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
callKeep.addEventListener('didLoadWithEvents', onLoadWithEvents);
|
|
251
|
+
|
|
274
252
|
return () => {
|
|
253
|
+
callKeep.removeEventListener('didLoadWithEvents');
|
|
275
254
|
callKeep.removeEventListener('answerCall');
|
|
276
255
|
callKeep.removeEventListener('endCall');
|
|
277
256
|
callKeep.removeEventListener('didPerformSetMutedCallAction');
|
|
@@ -313,11 +292,23 @@ export const useCallKeep = (): void => {
|
|
|
313
292
|
|
|
314
293
|
if (isRinging) {
|
|
315
294
|
if (activeUuidRef.current) {return;}
|
|
316
|
-
const uuid =
|
|
295
|
+
const uuid = callKitUuid(call.callId || call.roomJid || String(Date.now()));
|
|
317
296
|
activeUuidRef.current = uuid;
|
|
318
297
|
answeredRef.current = false;
|
|
298
|
+
const earlier = takeSystemCallAction(uuid);
|
|
299
|
+
if (earlier === 'answer') {
|
|
300
|
+
trace('answered earlier on the system screen', uuid);
|
|
301
|
+
answeredRef.current = true;
|
|
302
|
+
dispatch(acceptIncomingCall());
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (earlier === 'end') {
|
|
306
|
+
trace('declined earlier on the system screen', uuid);
|
|
307
|
+
activeUuidRef.current = null;
|
|
308
|
+
declineActiveCallWhenOnline();
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
319
311
|
// iOS: the system banner is the ring UI (Telegram-style); the in-app
|
|
320
|
-
// card would only cover the screen under it.
|
|
321
312
|
if (isIOS) {setCallSystemRinging(true);}
|
|
322
313
|
try {
|
|
323
314
|
callKeep.displayIncomingCall(
|
|
@@ -339,7 +330,7 @@ export const useCallKeep = (): void => {
|
|
|
339
330
|
if (isIOS && isOutgoing && !activeUuidRef.current && callKeep.startCall) {
|
|
340
331
|
// Unique per call: CallKit refuses a UUID it has seen before, and
|
|
341
332
|
// the callId only arrives with the token.
|
|
342
|
-
const uuid =
|
|
333
|
+
const uuid = callKitUuid(`${call.roomJid || ''}|${call.startedAt || Date.now()}`);
|
|
343
334
|
activeUuidRef.current = uuid;
|
|
344
335
|
outgoingReportedRef.current = 'none';
|
|
345
336
|
trace('startCall', uuid);
|
|
@@ -382,6 +373,7 @@ export const useCallKeep = (): void => {
|
|
|
382
373
|
}, [
|
|
383
374
|
callKeep,
|
|
384
375
|
ready,
|
|
376
|
+
dispatch,
|
|
385
377
|
isIOS,
|
|
386
378
|
isRinging,
|
|
387
379
|
isLive,
|