You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

middleware.js 17KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. // @flow
  2. import { Alert, NativeModules, Platform } from 'react-native';
  3. import { v4 as uuidv4 } from 'uuid';
  4. import { createTrackMutedEvent } from '../../analytics/AnalyticsEvents';
  5. import { sendAnalytics } from '../../analytics/functions';
  6. import { appNavigate } from '../../app/actions';
  7. import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../base/app/actionTypes';
  8. import { SET_AUDIO_ONLY } from '../../base/audio-only/actionTypes';
  9. import {
  10. CONFERENCE_FAILED,
  11. CONFERENCE_JOINED,
  12. CONFERENCE_LEFT,
  13. CONFERENCE_WILL_JOIN,
  14. CONFERENCE_WILL_LEAVE
  15. } from '../../base/conference/actionTypes';
  16. import {
  17. getConferenceName,
  18. getCurrentConference
  19. } from '../../base/conference/functions';
  20. import { getInviteURL } from '../../base/connection/functions';
  21. import { setAudioMuted } from '../../base/media/actions';
  22. import { MEDIA_TYPE } from '../../base/media/constants';
  23. import { isVideoMutedByAudioOnly } from '../../base/media/functions';
  24. import MiddlewareRegistry from '../../base/redux/MiddlewareRegistry';
  25. import {
  26. TRACK_ADDED,
  27. TRACK_REMOVED,
  28. TRACK_UPDATED
  29. } from '../../base/tracks/actionTypes';
  30. import { isLocalTrackMuted } from '../../base/tracks/functions.any';
  31. import CallKit from './CallKit';
  32. import ConnectionService from './ConnectionService';
  33. import { _SET_CALL_INTEGRATION_SUBSCRIPTIONS } from './actionTypes';
  34. import { isCallIntegrationEnabled } from './functions';
  35. const { AudioMode } = NativeModules;
  36. const CallIntegration = CallKit || ConnectionService;
  37. /**
  38. * Middleware that captures system actions and hooks up CallKit.
  39. *
  40. * @param {Store} store - The redux store.
  41. * @returns {Function}
  42. */
  43. CallIntegration && MiddlewareRegistry.register(store => next => action => {
  44. switch (action.type) {
  45. case _SET_CALL_INTEGRATION_SUBSCRIPTIONS:
  46. return _setCallKitSubscriptions(store, next, action);
  47. case APP_WILL_MOUNT:
  48. return _appWillMount(store, next, action);
  49. case APP_WILL_UNMOUNT:
  50. store.dispatch({
  51. type: _SET_CALL_INTEGRATION_SUBSCRIPTIONS,
  52. subscriptions: undefined
  53. });
  54. break;
  55. case CONFERENCE_FAILED:
  56. return _conferenceFailed(store, next, action);
  57. case CONFERENCE_JOINED:
  58. return _conferenceJoined(store, next, action);
  59. // If a conference is being left in a graceful manner then
  60. // the CONFERENCE_WILL_LEAVE fires as soon as the conference starts
  61. // disconnecting. We need to destroy the call on the native side as soon
  62. // as possible, because the disconnection process is asynchronous and
  63. // Android not always supports two simultaneous calls at the same time
  64. // (even though it should according to the spec).
  65. case CONFERENCE_LEFT:
  66. case CONFERENCE_WILL_LEAVE:
  67. return _conferenceLeft(store, next, action);
  68. case CONFERENCE_WILL_JOIN:
  69. return _conferenceWillJoin(store, next, action);
  70. case SET_AUDIO_ONLY:
  71. return _setAudioOnly(store, next, action);
  72. case TRACK_ADDED:
  73. case TRACK_REMOVED:
  74. case TRACK_UPDATED:
  75. return _syncTrackState(store, next, action);
  76. }
  77. return next(action);
  78. });
  79. /**
  80. * Notifies the feature callkit that the action {@link APP_WILL_MOUNT} is being
  81. * dispatched within a specific redux {@code store}.
  82. *
  83. * @param {Store} store - The redux store in which the specified {@code action}
  84. * is being dispatched.
  85. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  86. * specified {@code action} in the specified {@code store}.
  87. * @param {Action} action - The redux action {@code APP_WILL_MOUNT} which is
  88. * being dispatched in the specified {@code store}.
  89. * @private
  90. * @returns {*} The value returned by {@code next(action)}.
  91. */
  92. function _appWillMount({ dispatch, getState }, next, action) {
  93. const result = next(action);
  94. const context = {
  95. dispatch,
  96. getState
  97. };
  98. const delegate = {
  99. _onPerformSetMutedCallAction,
  100. _onPerformEndCallAction
  101. };
  102. if (isCallIntegrationEnabled(getState)) {
  103. const subscriptions = CallIntegration.registerSubscriptions(context, delegate);
  104. subscriptions && dispatch({
  105. type: _SET_CALL_INTEGRATION_SUBSCRIPTIONS,
  106. subscriptions
  107. });
  108. }
  109. return result;
  110. }
  111. /**
  112. * Notifies the feature callkit that the action {@link CONFERENCE_FAILED} is
  113. * being dispatched within a specific redux {@code store}.
  114. *
  115. * @param {Store} store - The redux store in which the specified {@code action}
  116. * is being dispatched.
  117. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  118. * specified {@code action} in the specified {@code store}.
  119. * @param {Action} action - The redux action {@code CONFERENCE_FAILED} which is
  120. * being dispatched in the specified {@code store}.
  121. * @private
  122. * @returns {*} The value returned by {@code next(action)}.
  123. */
  124. function _conferenceFailed({ getState }, next, action) {
  125. const result = next(action);
  126. if (!isCallIntegrationEnabled(getState)) {
  127. return result;
  128. }
  129. // XXX Certain CONFERENCE_FAILED errors are recoverable i.e. they have
  130. // prevented the user from joining a specific conference but the app may be
  131. // able to eventually join the conference.
  132. if (!action.error.recoverable) {
  133. const { callUUID } = action.conference;
  134. if (callUUID) {
  135. delete action.conference.callUUID;
  136. CallIntegration.reportCallFailed(callUUID);
  137. }
  138. }
  139. return result;
  140. }
  141. /**
  142. * Notifies the feature callkit that the action {@link CONFERENCE_JOINED} is
  143. * being dispatched within a specific redux {@code store}.
  144. *
  145. * @param {Store} store - The redux store in which the specified {@code action}
  146. * is being dispatched.
  147. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  148. * specified {@code action} in the specified {@code store}.
  149. * @param {Action} action - The redux action {@code CONFERENCE_JOINED} which is
  150. * being dispatched in the specified {@code store}.
  151. * @private
  152. * @returns {*} The value returned by {@code next(action)}.
  153. */
  154. function _conferenceJoined({ getState }, next, action) {
  155. const result = next(action);
  156. if (!isCallIntegrationEnabled(getState)) {
  157. return result;
  158. }
  159. const { callUUID } = action.conference;
  160. if (callUUID) {
  161. CallIntegration.reportConnectedOutgoingCall(callUUID)
  162. .then(() => {
  163. // iOS 13 doesn't like the mute state to be false before the call is started
  164. // so we update it here in case the user selected startWithAudioMuted.
  165. if (Platform.OS === 'ios') {
  166. _updateCallIntegrationMuted(action.conference, getState());
  167. }
  168. })
  169. .catch(() => {
  170. // Currently errors here are only emitted by Android.
  171. //
  172. // Some Samsung devices will fail to fully engage ConnectionService if no SIM card
  173. // was ever installed on the device. We could check for it, but it would require
  174. // the CALL_PHONE permission, which is not something we want to do, so fallback to
  175. // not using ConnectionService.
  176. _handleConnectionServiceFailure(getState());
  177. });
  178. }
  179. return result;
  180. }
  181. /**
  182. * Notifies the feature callkit that the action {@link CONFERENCE_LEFT} is being
  183. * dispatched within a specific redux {@code store}.
  184. *
  185. * @param {Store} store - The redux store in which the specified {@code action}
  186. * is being dispatched.
  187. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  188. * specified {@code action} in the specified {@code store}.
  189. * @param {Action} action - The redux action {@code CONFERENCE_LEFT} which is
  190. * being dispatched in the specified {@code store}.
  191. * @private
  192. * @returns {*} The value returned by {@code next(action)}.
  193. */
  194. function _conferenceLeft({ getState }, next, action) {
  195. const result = next(action);
  196. if (!isCallIntegrationEnabled(getState)) {
  197. return result;
  198. }
  199. const { callUUID } = action.conference;
  200. if (callUUID) {
  201. delete action.conference.callUUID;
  202. CallIntegration.endCall(callUUID);
  203. }
  204. return result;
  205. }
  206. /**
  207. * Notifies the feature callkit that the action {@link CONFERENCE_WILL_JOIN} is
  208. * being dispatched within a specific redux {@code store}.
  209. *
  210. * @param {Store} store - The redux store in which the specified {@code action}
  211. * is being dispatched.
  212. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  213. * specified {@code action} in the specified {@code store}.
  214. * @param {Action} action - The redux action {@code CONFERENCE_WILL_JOIN} which
  215. * is being dispatched in the specified {@code store}.
  216. * @private
  217. * @returns {*} The value returned by {@code next(action)}.
  218. */
  219. function _conferenceWillJoin({ dispatch, getState }, next, action) {
  220. const result = next(action);
  221. if (!isCallIntegrationEnabled(getState)) {
  222. return result;
  223. }
  224. const { conference } = action;
  225. const state = getState();
  226. const { callHandle, callUUID } = state['features/base/config'];
  227. const url = getInviteURL(state);
  228. const handle = callHandle || url.toString();
  229. const hasVideo = !isVideoMutedByAudioOnly(state);
  230. // If we already have a callUUID set, don't start a new call.
  231. if (conference.callUUID) {
  232. return result;
  233. }
  234. // When assigning the call UUID, do so in upper case, since iOS will return
  235. // it upper cased.
  236. conference.callUUID = (callUUID || uuidv4()).toUpperCase();
  237. CallIntegration.startCall(conference.callUUID, handle, hasVideo)
  238. .then(() => {
  239. const displayName = getConferenceName(state);
  240. CallIntegration.updateCall(
  241. conference.callUUID,
  242. {
  243. displayName,
  244. hasVideo
  245. });
  246. // iOS 13 doesn't like the mute state to be false before the call is started
  247. // so delay it until the conference was joined.
  248. if (Platform.OS !== 'ios') {
  249. _updateCallIntegrationMuted(conference, state);
  250. }
  251. })
  252. .catch(error => {
  253. // Currently this error codes are emitted only by Android.
  254. //
  255. if (error.code === 'CREATE_OUTGOING_CALL_FAILED') {
  256. // We're not tracking the call anymore - it doesn't exist on
  257. // the native side.
  258. delete conference.callUUID;
  259. dispatch(appNavigate(undefined));
  260. Alert.alert(
  261. 'Call aborted',
  262. 'There\'s already another call in progress.'
  263. + ' Please end it first and try again.',
  264. [
  265. { text: 'OK' }
  266. ],
  267. { cancelable: false });
  268. } else {
  269. // Some devices fail because the CALL_PHONE permission is not granted, which is
  270. // nonsense, because it's not needed for self-managed connections.
  271. // Some other devices fail because ConnectionService is not supported.
  272. // Be that as it may, fallback to non-ConnectionService audio device handling.
  273. _handleConnectionServiceFailure(state);
  274. }
  275. });
  276. return result;
  277. }
  278. /**
  279. * Handles a ConnectionService fatal error by falling back to non-ConnectionService device management.
  280. *
  281. * @param {Object} state - Redux store.
  282. * @returns {void}
  283. */
  284. function _handleConnectionServiceFailure(state: Object) {
  285. const conference = getCurrentConference(state);
  286. if (conference) {
  287. // We're not tracking the call anymore.
  288. delete conference.callUUID;
  289. // ConnectionService has fatally failed. Alas, this also means audio device management would be broken, so
  290. // fallback to not using ConnectionService.
  291. // NOTE: We are not storing this in Settings, in case it's a transient issue, as far fetched as
  292. // that may be.
  293. if (AudioMode.setUseConnectionService) {
  294. AudioMode.setUseConnectionService(false);
  295. const hasVideo = !isVideoMutedByAudioOnly(state);
  296. // Set the desired audio mode, since we just reset the whole thing.
  297. AudioMode.setMode(hasVideo ? AudioMode.VIDEO_CALL : AudioMode.AUDIO_CALL);
  298. }
  299. }
  300. }
  301. /**
  302. * Handles CallKit's event {@code performEndCallAction}.
  303. *
  304. * @param {Object} event - The details of the CallKit event
  305. * {@code performEndCallAction}.
  306. * @returns {void}
  307. */
  308. function _onPerformEndCallAction({ callUUID }) {
  309. const { dispatch, getState } = this; // eslint-disable-line no-invalid-this
  310. const conference = getCurrentConference(getState);
  311. if (conference && conference.callUUID === callUUID) {
  312. // We arrive here when a call is ended by the system, for example, when
  313. // another incoming call is received and the user selects "End &
  314. // Accept".
  315. delete conference.callUUID;
  316. dispatch(appNavigate(undefined));
  317. }
  318. }
  319. /**
  320. * Handles CallKit's event {@code performSetMutedCallAction}.
  321. *
  322. * @param {Object} event - The details of the CallKit event
  323. * {@code performSetMutedCallAction}.
  324. * @returns {void}
  325. */
  326. function _onPerformSetMutedCallAction({ callUUID, muted }) {
  327. const { dispatch, getState } = this; // eslint-disable-line no-invalid-this
  328. const conference = getCurrentConference(getState);
  329. if (conference && conference.callUUID === callUUID) {
  330. muted = Boolean(muted); // eslint-disable-line no-param-reassign
  331. sendAnalytics(
  332. createTrackMutedEvent('audio', 'call-integration', muted));
  333. dispatch(setAudioMuted(muted, /* ensureTrack */ true));
  334. }
  335. }
  336. /**
  337. * Update CallKit with the audio only state of the conference. When a conference
  338. * is in audio only mode we will tell CallKit the call has no video. This
  339. * affects how the call is saved in the recent calls list.
  340. *
  341. * XXX: Note that here we are taking the `audioOnly` value straight from the
  342. * action, instead of examining the state. This is intentional, as setting the
  343. * audio only involves multiple actions which will be reflected in the state
  344. * later, but we are just interested in knowing if the mode is going to be
  345. * set or not.
  346. *
  347. * @param {Store} store - The redux store in which the specified {@code action}
  348. * is being dispatched.
  349. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  350. * specified {@code action} in the specified {@code store}.
  351. * @param {Action} action - The redux action which is being dispatched in the
  352. * specified {@code store}.
  353. * @private
  354. * @returns {*} The value returned by {@code next(action)}.
  355. */
  356. function _setAudioOnly({ getState }, next, action) {
  357. const result = next(action);
  358. const state = getState();
  359. if (!isCallIntegrationEnabled(state)) {
  360. return result;
  361. }
  362. const conference = getCurrentConference(state);
  363. if (conference && conference.callUUID) {
  364. CallIntegration.updateCall(
  365. conference.callUUID,
  366. { hasVideo: !action.audioOnly });
  367. }
  368. return result;
  369. }
  370. /**
  371. * Notifies the feature callkit that the action
  372. * {@link _SET_CALL_INTEGRATION_SUBSCRIPTIONS} is being dispatched within
  373. * a specific redux {@code store}.
  374. *
  375. * @param {Store} store - The redux store in which the specified {@code action}
  376. * is being dispatched.
  377. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  378. * specified {@code action} in the specified {@code store}.
  379. * @param {Action} action - The redux action
  380. * {@code _SET_CALL_INTEGRATION_SUBSCRIPTIONS} which is being dispatched in
  381. * the specified {@code store}.
  382. * @private
  383. * @returns {*} The value returned by {@code next(action)}.
  384. */
  385. function _setCallKitSubscriptions({ getState }, next, action) {
  386. const { subscriptions } = getState()['features/call-integration'];
  387. if (subscriptions) {
  388. for (const subscription of subscriptions) {
  389. subscription.remove();
  390. }
  391. }
  392. return next(action);
  393. }
  394. /**
  395. * Synchronize the muted state of tracks with CallKit.
  396. *
  397. * @param {Store} store - The redux store in which the specified {@code action}
  398. * is being dispatched.
  399. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the
  400. * specified {@code action} in the specified {@code store}.
  401. * @param {Action} action - The redux action which is being dispatched in the
  402. * specified {@code store}.
  403. * @private
  404. * @returns {*} The value returned by {@code next(action)}.
  405. */
  406. function _syncTrackState({ getState }, next, action) {
  407. const result = next(action);
  408. if (!isCallIntegrationEnabled(getState)) {
  409. return result;
  410. }
  411. const { jitsiTrack } = action.track;
  412. const state = getState();
  413. const conference = getCurrentConference(state);
  414. if (jitsiTrack.isLocal() && conference && conference.callUUID) {
  415. switch (jitsiTrack.getType()) {
  416. case 'audio': {
  417. _updateCallIntegrationMuted(conference, state);
  418. break;
  419. }
  420. case 'video': {
  421. CallIntegration.updateCall(
  422. conference.callUUID,
  423. { hasVideo: !isVideoMutedByAudioOnly(state) });
  424. break;
  425. }
  426. }
  427. }
  428. return result;
  429. }
  430. /**
  431. * Update the muted state in the native side.
  432. *
  433. * @param {Object} conference - The current active conference.
  434. * @param {Object} state - The redux store state.
  435. * @private
  436. * @returns {void}
  437. */
  438. function _updateCallIntegrationMuted(conference, state) {
  439. const muted = isLocalTrackMuted(state['features/base/tracks'], MEDIA_TYPE.AUDIO);
  440. CallIntegration.setMuted(conference.callUUID, muted);
  441. }