diff --git a/android/app/src/main/java/org/jitsi/meet/MainActivity.java b/android/app/src/main/java/org/jitsi/meet/MainActivity.java index 4e9658299..631c196c6 100644 --- a/android/app/src/main/java/org/jitsi/meet/MainActivity.java +++ b/android/app/src/main/java/org/jitsi/meet/MainActivity.java @@ -38,7 +38,7 @@ import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URL; import java.util.Collection; -import java.util.Map; +import java.util.HashMap; /** * The one and only Activity that the Jitsi Meet app needs. The @@ -183,8 +183,8 @@ public class MainActivity extends JitsiMeetActivity { } @Override - public void onConferenceTerminated(Map data) { - Log.d(TAG, "Conference terminated: " + data); + protected void onConferenceTerminated(HashMap extraData) { + Log.d(TAG, "Conference terminated: " + extraData); } // Activity lifecycle method overrides diff --git a/android/sdk/build.gradle b/android/sdk/build.gradle index c1da83d22..40cb7c8e4 100644 --- a/android/sdk/build.gradle +++ b/android/sdk/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'androidx.fragment:fragment:1.2.5' + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' //noinspection GradleDynamicVersion diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BaseReactView.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BaseReactView.java index d523dce22..a66c1bf7a 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/BaseReactView.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BaseReactView.java @@ -99,6 +99,7 @@ public abstract class BaseReactView * The listener (e.g. {@link JitsiMeetViewListener}) instance for reporting * events occurring in Jitsi Meet. */ + @Deprecated private ListenerT listener; /** @@ -167,6 +168,7 @@ public abstract class BaseReactView * * @return The listener set on this {@code BaseReactView}. */ + @Deprecated public ListenerT getListener() { return listener; } @@ -179,8 +181,10 @@ public abstract class BaseReactView * @param data - The details of the event associated with/specific to the * specified {@code name}. */ + @Deprecated protected abstract void onExternalAPIEvent(String name, ReadableMap data); + @Deprecated protected void onExternalAPIEvent( Map listenerMethods, String name, ReadableMap data) { @@ -215,6 +219,7 @@ public abstract class BaseReactView * * @param listener The listener to set on this {@code BaseReactView}. */ + @Deprecated public void setListener(ListenerT listener) { this.listener = listener; } diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastAction.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastAction.java new file mode 100644 index 000000000..6f9f0b12c --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastAction.java @@ -0,0 +1,84 @@ +package org.jitsi.meet.sdk; + +import android.content.Intent; +import android.os.Bundle; + +import com.facebook.react.bridge.WritableNativeMap; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.util.HashMap; + +/** + * Wraps the name and extra data for events that were broadcasted locally. + */ +public class BroadcastAction { + private static final String TAG = BroadcastAction.class.getSimpleName(); + + private final Type type; + private final HashMap data; + + public BroadcastAction(Intent intent) { + this.type = Type.buildTypeFromAction(intent.getAction()); + this.data = buildDataFromBundle(intent.getExtras()); + } + + public Type getType() { + return this.type; + } + + public HashMap getData() { + return this.data; + } + + public WritableNativeMap getDataAsWritableNativeMap() { + WritableNativeMap nativeMap = new WritableNativeMap(); + + for (String key : this.data.keySet()) { + try { + // TODO add support for different types of objects + nativeMap.putString(key, this.data.get(key).toString()); + } catch (Exception e) { + JitsiMeetLogger.w(TAG + " invalid extra data in event", e); + } + } + + return nativeMap; + } + + private static HashMap buildDataFromBundle(Bundle bundle) { + HashMap map = new HashMap<>(); + + if (bundle != null) { + for (String key : bundle.keySet()) { + map.put(key, bundle.get(key)); + } + } + + return map; + } + + enum Type { + SET_AUDIO_MUTED("org.jitsi.meet.SET_AUDIO_MUTED"), + HANG_UP("org.jitsi.meet.HANG_UP"); + + private final String action; + + Type(String action) { + this.action = action; + } + + public String getAction() { + return action; + } + + private static Type buildTypeFromAction(String action) { + for (Type type : Type.values()) { + if (type.action.equalsIgnoreCase(action)) { + return type; + } + } + return null; + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEmitter.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEmitter.java new file mode 100644 index 000000000..bc6ed18ea --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEmitter.java @@ -0,0 +1,30 @@ +package org.jitsi.meet.sdk; + +import android.content.Context; +import android.content.Intent; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.facebook.react.bridge.ReadableMap; + +/** + * Class used to emit events through the LocalBroadcastManager, called when events + * from JS occurred. Takes an action name from JS, builds and broadcasts the {@link BroadcastEvent} + */ +public class BroadcastEmitter { + private final LocalBroadcastManager localBroadcastManager; + + public BroadcastEmitter(Context context) { + localBroadcastManager = LocalBroadcastManager.getInstance(context); + } + + public void sendBroadcast(String name, ReadableMap data) { + BroadcastEvent event = new BroadcastEvent(name, data); + + Intent intent = event.buildIntent(); + + if (intent != null) { + localBroadcastManager.sendBroadcast(intent); + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEvent.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEvent.java new file mode 100644 index 000000000..f6851fa2f --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastEvent.java @@ -0,0 +1,130 @@ +package org.jitsi.meet.sdk; + +import android.content.Intent; +import android.os.Bundle; + +import com.facebook.react.bridge.ReadableMap; + +import org.jitsi.meet.sdk.log.JitsiMeetLogger; + +import java.util.HashMap; + +/** + * Wraps the name and extra data for the events that occur on the JS side and are + * to be broadcasted. + */ +public class BroadcastEvent { + + private static final String TAG = BroadcastEvent.class.getSimpleName(); + + private final Type type; + private final HashMap data; + + public BroadcastEvent(String name, ReadableMap data) { + this.type = Type.buildTypeFromName(name); + this.data = data.toHashMap(); + } + + public BroadcastEvent(Intent intent) { + this.type = Type.buildTypeFromAction(intent.getAction()); + this.data = buildDataFromBundle(intent.getExtras()); + } + + public Type getType() { + return this.type; + } + + public HashMap getData() { + return this.data; + } + + public Intent buildIntent() { + if (type != null && type.action != null) { + Intent intent = new Intent(type.action); + + for (String key : this.data.keySet()) { + try { + intent.putExtra(key, this.data.get(key).toString()); + } catch (Exception e) { + JitsiMeetLogger.w(TAG + " invalid extra data in event", e); + } + } + + return intent; + } + + return null; + } + + private static HashMap buildDataFromBundle(Bundle bundle) { + if (bundle != null) { + try { + HashMap map = new HashMap<>(); + + for (String key : bundle.keySet()) { + map.put(key, bundle.get(key)); + } + + return map; + } catch (Exception e) { + JitsiMeetLogger.w(TAG + " invalid extra data", e); + } + } + + return null; + } + + public enum Type { + CONFERENCE_JOINED("org.jitsi.meet.CONFERENCE_JOINED"), + CONFERENCE_TERMINATED("org.jitsi.meet.CONFERENCE_TERMINATED"), + CONFERENCE_WILL_JOIN("org.jitsi.meet.CONFERENCE_WILL_JOIN"), + AUDIO_MUTED_CHANGED("org.jitsi.meet.AUDIO_MUTED_CHANGED"), + PARTICIPANT_JOINED("org.jitsi.meet.PARTICIPANT_JOINED"), + PARTICIPANT_LEFT("org.jitsi.meet.PARTICIPANT_LEFT"); + + private static final String CONFERENCE_WILL_JOIN_NAME = "CONFERENCE_WILL_JOIN"; + private static final String CONFERENCE_JOINED_NAME = "CONFERENCE_JOINED"; + private static final String CONFERENCE_TERMINATED_NAME = "CONFERENCE_TERMINATED"; + private static final String AUDIO_MUTED_CHANGED_NAME = "AUDIO_MUTED_CHANGED"; + private static final String PARTICIPANT_JOINED_NAME = "PARTICIPANT_JOINED"; + private static final String PARTICIPANT_LEFT_NAME = "PARTICIPANT_LEFT"; + + private final String action; + + Type(String action) { + this.action = action; + } + + public String getAction() { + return action; + } + + private static Type buildTypeFromAction(String action) { + for (Type type : Type.values()) { + if (type.action.equalsIgnoreCase(action)) { + return type; + } + } + return null; + } + + private static Type buildTypeFromName(String name) { + switch (name) { + case CONFERENCE_WILL_JOIN_NAME: + return CONFERENCE_WILL_JOIN; + case CONFERENCE_JOINED_NAME: + return CONFERENCE_JOINED; + case CONFERENCE_TERMINATED_NAME: + return CONFERENCE_TERMINATED; + case AUDIO_MUTED_CHANGED_NAME: + return AUDIO_MUTED_CHANGED; + case PARTICIPANT_JOINED_NAME: + return PARTICIPANT_JOINED; + case PARTICIPANT_LEFT_NAME: + return PARTICIPANT_LEFT; + } + + return null; + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastIntentHelper.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastIntentHelper.java new file mode 100644 index 000000000..dd5ae9c15 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastIntentHelper.java @@ -0,0 +1,15 @@ +package org.jitsi.meet.sdk; + +import android.content.Intent; + +public class BroadcastIntentHelper { + public static Intent buildSetAudioMutedIntent(boolean muted) { + Intent intent = new Intent(BroadcastAction.Type.SET_AUDIO_MUTED.getAction()); + intent.putExtra("muted", muted); + return intent; + } + + public static Intent buildHangUpIntent() { + return new Intent(BroadcastAction.Type.HANG_UP.getAction()); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastReceiver.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastReceiver.java new file mode 100644 index 000000000..1d74208b7 --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/BroadcastReceiver.java @@ -0,0 +1,32 @@ +package org.jitsi.meet.sdk; + +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; + +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +/** + * Listens for {@link BroadcastAction}s on LocalBroadcastManager. When one occurs, + * it emits it to JS. + */ +public class BroadcastReceiver extends android.content.BroadcastReceiver { + + public BroadcastReceiver(Context context) { + LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(context); + + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(BroadcastAction.Type.SET_AUDIO_MUTED.getAction()); + intentFilter.addAction(BroadcastAction.Type.HANG_UP.getAction()); + + localBroadcastManager.registerReceiver(this, intentFilter); + } + + @Override + public void onReceive(Context context, Intent intent) { + BroadcastAction action = new BroadcastAction(intent); + String actionName = action.getType().getAction(); + + ReactInstanceManagerHolder.emitEvent(actionName, action.getDataAsWritableNativeMap()); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java index 46d5e91af..0312d25f8 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ExternalAPIModule.java @@ -1,5 +1,5 @@ /* - * Copyright @ 2017-present Atlassian Pty Ltd + * Copyright @ 2017-present 8x8, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,9 @@ import com.facebook.react.module.annotations.ReactModule; import org.jitsi.meet.sdk.log.JitsiMeetLogger; +import java.util.HashMap; +import java.util.Map; + /** * Module implementing an API for sending events from JavaScript to native code. */ @@ -35,6 +38,9 @@ class ExternalAPIModule private static final String TAG = NAME; + private final BroadcastEmitter broadcastEmitter; + private final BroadcastReceiver broadcastReceiver; + /** * Initializes a new module instance. There shall be a single instance of * this module throughout the lifetime of the app. @@ -44,6 +50,9 @@ class ExternalAPIModule */ public ExternalAPIModule(ReactApplicationContext reactContext) { super(reactContext); + + broadcastEmitter = new BroadcastEmitter(reactContext); + broadcastReceiver = new BroadcastReceiver(reactContext); } /** @@ -56,6 +65,22 @@ class ExternalAPIModule return NAME; } + /** + * Gets a mapping with the constants this module is exporting. + * + * @return a {@link Map} mapping the constants to be exported with their + * values. + */ + @Override + public Map getConstants() { + Map constants = new HashMap<>(); + + constants.put("SET_AUDIO_MUTED", BroadcastAction.Type.SET_AUDIO_MUTED.getAction()); + constants.put("HANG_UP", BroadcastAction.Type.HANG_UP.getAction()); + + return constants; + } + /** * Dispatches an event that occurred on the JavaScript side of the SDK to * the specified {@link BaseReactView}'s listener. @@ -79,7 +104,8 @@ class ExternalAPIModule JitsiMeetLogger.d(TAG + " Sending event: " + name + " with data: " + data); try { view.onExternalAPIEvent(name, data); - } catch(Exception e) { + broadcastEmitter.sendBroadcast(name, data); + } catch (Exception e) { JitsiMeetLogger.e(e, TAG + " onExternalAPIEvent: error sending event"); } } diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java index 01a308b05..a76f170a3 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetActivity.java @@ -16,33 +16,41 @@ package org.jitsi.meet.sdk; +import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.net.Uri; import android.os.Bundle; import androidx.annotation.Nullable; import androidx.fragment.app.FragmentActivity; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; import com.facebook.react.modules.core.PermissionListener; import org.jitsi.meet.sdk.log.JitsiMeetLogger; -import java.util.Map; - +import java.util.HashMap; /** * A base activity for SDK users to embed. It uses {@link JitsiMeetFragment} to do the heavy * lifting and wires the remaining Activity lifecycle methods so it works out of the box. */ public class JitsiMeetActivity extends FragmentActivity - implements JitsiMeetActivityInterface, JitsiMeetViewListener { + implements JitsiMeetActivityInterface { protected static final String TAG = JitsiMeetActivity.class.getSimpleName(); private static final String ACTION_JITSI_MEET_CONFERENCE = "org.jitsi.meet.CONFERENCE"; private static final String JITSI_MEET_CONFERENCE_OPTIONS = "JitsiMeetConferenceOptions"; + private final BroadcastReceiver broadcastReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + onBroadcastReceived(intent); + } + }; // Helpers for starting the activity // @@ -68,8 +76,7 @@ public class JitsiMeetActivity extends FragmentActivity setContentView(R.layout.activity_jitsi_meet); - // Listen for conference events. - getJitsiView().setListener(this); + registerForBroadcastMessages(); if (!extraInitialize()) { initialize(); @@ -91,6 +98,8 @@ public class JitsiMeetActivity extends FragmentActivity } JitsiMeetOngoingConferenceService.abort(this); + LocalBroadcastManager.getInstance(this).unregisterReceiver(broadcastReceiver); + super.onDestroy(); } @@ -113,8 +122,8 @@ public class JitsiMeetActivity extends FragmentActivity public void join(@Nullable String url) { JitsiMeetConferenceOptions options = new JitsiMeetConferenceOptions.Builder() - .setRoom(url) - .build(); + .setRoom(url) + .build(); join(options); } @@ -138,7 +147,8 @@ public class JitsiMeetActivity extends FragmentActivity } } - private @Nullable JitsiMeetConferenceOptions getConferenceOptions(Intent intent) { + private @Nullable + JitsiMeetConferenceOptions getConferenceOptions(Intent intent) { String action = intent.getAction(); if (Intent.ACTION_VIEW.equals(action)) { @@ -157,7 +167,7 @@ public class JitsiMeetActivity extends FragmentActivity * Helper function called during activity initialization. If {@code true} is returned, the * initialization is delayed and the {@link JitsiMeetActivity#initialize()} method is not * called. In this case, it's up to the subclass to call the initialize method when ready. - * + *

* This is mainly required so we do some extra initialization in the Jitsi Meet app. * * @return {@code true} if the initialization will be delayed, {@code false} otherwise. @@ -172,6 +182,37 @@ public class JitsiMeetActivity extends FragmentActivity join(getConferenceOptions(getIntent())); } + protected void onConferenceJoined(HashMap extraData) { + JitsiMeetLogger.i("Conference joined: " + extraData); + // Launch the service for the ongoing notification. + JitsiMeetOngoingConferenceService.launch(this); + } + + protected void onConferenceTerminated(HashMap extraData) { + JitsiMeetLogger.i("Conference terminated: " + extraData); + finish(); + } + + protected void onConferenceWillJoin(HashMap extraData) { + JitsiMeetLogger.i("Conference will join: " + extraData); + } + + protected void onParticipantJoined(HashMap extraData) { + try { + JitsiMeetLogger.i("Participant joined: ", extraData); + } catch (Exception e) { + JitsiMeetLogger.w("Invalid participant joined extraData", e); + } + } + + protected void onParticipantLeft(HashMap extraData) { + try { + JitsiMeetLogger.i("Participant left: ", extraData); + } catch (Exception e) { + JitsiMeetLogger.w("Invalid participant left extraData", e); + } + } + // Activity lifecycle methods // @@ -223,24 +264,38 @@ public class JitsiMeetActivity extends FragmentActivity JitsiMeetActivityDelegate.onRequestPermissionsResult(requestCode, permissions, grantResults); } - // JitsiMeetViewListener - // + private void registerForBroadcastMessages() { + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(BroadcastEvent.Type.CONFERENCE_JOINED.getAction()); + intentFilter.addAction(BroadcastEvent.Type.CONFERENCE_WILL_JOIN.getAction()); + intentFilter.addAction(BroadcastEvent.Type.CONFERENCE_TERMINATED.getAction()); + intentFilter.addAction(BroadcastEvent.Type.PARTICIPANT_JOINED.getAction()); + intentFilter.addAction(BroadcastEvent.Type.PARTICIPANT_LEFT.getAction()); - @Override - public void onConferenceJoined(Map data) { - JitsiMeetLogger.i("Conference joined: " + data); - // Launch the service for the ongoing notification. - JitsiMeetOngoingConferenceService.launch(this); + LocalBroadcastManager.getInstance(this).registerReceiver(broadcastReceiver, intentFilter); } - @Override - public void onConferenceTerminated(Map data) { - JitsiMeetLogger.i("Conference terminated: " + data); - finish(); - } + private void onBroadcastReceived(Intent intent) { + if (intent != null) { + BroadcastEvent event = new BroadcastEvent(intent); - @Override - public void onConferenceWillJoin(Map data) { - JitsiMeetLogger.i("Conference will join: " + data); + switch (event.getType()) { + case CONFERENCE_JOINED: + onConferenceJoined(event.getData()); + break; + case CONFERENCE_WILL_JOIN: + onConferenceWillJoin(event.getData()); + break; + case CONFERENCE_TERMINATED: + onConferenceTerminated(event.getData()); + break; + case PARTICIPANT_JOINED: + onParticipantJoined(event.getData()); + break; + case PARTICIPANT_LEFT: + onParticipantLeft(event.getData()); + break; + } + } } } diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java index ff8482e7f..c0fa79699 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetOngoingConferenceService.java @@ -21,11 +21,13 @@ import android.app.Service; import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.content.IntentFilter; import android.os.Build; import android.os.IBinder; -import org.jitsi.meet.sdk.log.JitsiMeetLogger; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import org.jitsi.meet.sdk.log.JitsiMeetLogger; /** * This class implements an Android {@link Service}, a foreground one specifically, and it's @@ -35,19 +37,18 @@ import org.jitsi.meet.sdk.log.JitsiMeetLogger; * See: https://developer.android.com/guide/components/services */ public class JitsiMeetOngoingConferenceService extends Service - implements OngoingConferenceTracker.OngoingConferenceListener { + implements OngoingConferenceTracker.OngoingConferenceListener { private static final String TAG = JitsiMeetOngoingConferenceService.class.getSimpleName(); - static final class Actions { - static final String START = TAG + ":START"; - static final String HANGUP = TAG + ":HANGUP"; - } + private final BroadcastReceiver broadcastReceiver = new BroadcastReceiver(); + + private boolean isAudioMuted; static void launch(Context context) { OngoingNotification.createOngoingConferenceNotificationChannel(); Intent intent = new Intent(context, JitsiMeetOngoingConferenceService.class); - intent.setAction(Actions.START); + intent.setAction(Action.START.getName()); ComponentName componentName; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -70,11 +71,16 @@ public class JitsiMeetOngoingConferenceService extends Service super.onCreate(); OngoingConferenceTracker.getInstance().addListener(this); + + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(BroadcastEvent.Type.AUDIO_MUTED_CHANGED.getAction()); + LocalBroadcastManager.getInstance(getApplicationContext()).registerReceiver(broadcastReceiver, intentFilter); } @Override public void onDestroy() { OngoingConferenceTracker.getInstance().removeListener(this); + LocalBroadcastManager.getInstance(getApplicationContext()).unregisterReceiver(broadcastReceiver); super.onDestroy(); } @@ -86,26 +92,37 @@ public class JitsiMeetOngoingConferenceService extends Service @Override public int onStartCommand(Intent intent, int flags, int startId) { - final String action = intent.getAction(); - if (Actions.START.equals(action)) { - Notification notification = OngoingNotification.buildOngoingConferenceNotification(); - if (notification == null) { + final String actionName = intent.getAction(); + final Action action = Action.fromName(actionName); + + switch (action) { + case UNMUTE: + case MUTE: + Intent muteBroadcastIntent = BroadcastIntentHelper.buildSetAudioMutedIntent(action == Action.MUTE); + LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(muteBroadcastIntent); + break; + case START: + Notification notification = OngoingNotification.buildOngoingConferenceNotification(isAudioMuted); + if (notification == null) { + stopSelf(); + JitsiMeetLogger.w(TAG + " Couldn't start service, notification is null"); + } else { + startForeground(OngoingNotification.NOTIFICATION_ID, notification); + JitsiMeetLogger.i(TAG + " Service started"); + } + break; + case HANGUP: + JitsiMeetLogger.i(TAG + " Hangup requested"); + + Intent hangupBroadcastIntent = BroadcastIntentHelper.buildHangUpIntent(); + LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(hangupBroadcastIntent); + stopSelf(); - JitsiMeetLogger.w(TAG + " Couldn't start service, notification is null"); - } else { - startForeground(OngoingNotification.NOTIFICATION_ID, notification); - JitsiMeetLogger.i(TAG + " Service started"); - } - } else if (Actions.HANGUP.equals(action)) { - JitsiMeetLogger.i(TAG + " Hangup requested"); - // Abort all ongoing calls - if (AudioModeModule.useConnectionService()) { - ConnectionService.abortConnections(); - } - stopSelf(); - } else { - JitsiMeetLogger.w(TAG + " Unknown action received: " + action); - stopSelf(); + break; + default: + JitsiMeetLogger.w(TAG + " Unknown action received: " + action); + stopSelf(); + break; } return START_NOT_STICKY; @@ -118,4 +135,46 @@ public class JitsiMeetOngoingConferenceService extends Service JitsiMeetLogger.i(TAG + "Service stopped"); } } + + public enum Action { + START(TAG + ":START"), + HANGUP(TAG + ":HANGUP"), + MUTE(TAG + ":MUTE"), + UNMUTE(TAG + ":UNMUTE"); + + private final String name; + + Action(String name) { + this.name = name; + } + + public static Action fromName(String name) { + for (Action action : Action.values()) { + if (action.name.equalsIgnoreCase(name)) { + return action; + } + } + return null; + } + + public String getName() { + return name; + } + } + + private class BroadcastReceiver extends android.content.BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + isAudioMuted = Boolean.parseBoolean(intent.getStringExtra("muted")); + Notification notification = OngoingNotification.buildOngoingConferenceNotification(isAudioMuted); + if (notification == null) { + stopSelf(); + JitsiMeetLogger.w(TAG + " Couldn't start service, notification is null"); + } else { + startForeground(OngoingNotification.NOTIFICATION_ID, notification); + JitsiMeetLogger.i(TAG + " Service started"); + } + } + } } diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java index e69004611..c8b70d097 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetView.java @@ -197,6 +197,7 @@ public class JitsiMeetView extends BaseReactView * by/associated with the specified {@code name}. */ @Override + @Deprecated protected void onExternalAPIEvent(String name, ReadableMap data) { onExternalAPIEvent(LISTENER_METHODS, name, data); } diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetViewListener.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetViewListener.java index 0d1ce0709..4d97308d3 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetViewListener.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/JitsiMeetViewListener.java @@ -21,6 +21,7 @@ import java.util.Map; /** * Interface for listening to events coming from Jitsi Meet. */ +@Deprecated public interface JitsiMeetViewListener { /** * Called when a conference was joined. diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ListenerUtils.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ListenerUtils.java index 19ad31956..cb8f9781d 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/ListenerUtils.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ListenerUtils.java @@ -32,6 +32,7 @@ import java.util.regex.Pattern; * Utility methods for helping with transforming {@link ExternalAPIModule} * events into listener methods. Used with descendants of {@link BaseReactView}. */ +@Deprecated public final class ListenerUtils { /** * Extracts the methods defined in a listener and creates a mapping of this diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/OngoingNotification.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/OngoingNotification.java index 03b3c2a75..ba0a80de0 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/OngoingNotification.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/OngoingNotification.java @@ -23,13 +23,13 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.os.Build; + import androidx.core.app.NotificationCompat; import org.jitsi.meet.sdk.log.JitsiMeetLogger; import java.util.Random; - /** * Helper class for creating the ongoing notification which is used with * {@link JitsiMeetOngoingConferenceService}. It allows the user to easily get back to the app @@ -43,7 +43,6 @@ class OngoingNotification { static final int NOTIFICATION_ID = new Random().nextInt(99999) + 10000; - static void createOngoingConferenceNotificationChannel() { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return; @@ -56,7 +55,7 @@ class OngoingNotification { } NotificationManager notificationManager - = (NotificationManager)context.getSystemService(Context.NOTIFICATION_SERVICE); + = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationChannel channel = notificationManager.getNotificationChannel(CHANNEL_ID); @@ -73,7 +72,7 @@ class OngoingNotification { notificationManager.createNotificationChannel(channel); } - static Notification buildOngoingConferenceNotification() { + static Notification buildOngoingConferenceNotification(boolean isMuted) { Context context = ReactInstanceManagerHolder.getCurrentActivity(); if (context == null) { JitsiMeetLogger.w(TAG + " Cannot create notification: no current context"); @@ -83,12 +82,7 @@ class OngoingNotification { Intent notificationIntent = new Intent(context, context.getClass()); PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, notificationIntent, 0); - NotificationCompat.Builder builder; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - builder = new NotificationCompat.Builder(context, CHANNEL_ID); - } else { - builder = new NotificationCompat.Builder(context); - } + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID); builder .setCategory(NotificationCompat.CATEGORY_CALL) @@ -99,21 +93,27 @@ class OngoingNotification { .setOngoing(true) .setAutoCancel(false) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setUsesChronometer(true) .setOnlyAlertOnce(true) .setSmallIcon(context.getResources().getIdentifier("ic_notification", "drawable", context.getPackageName())); - // Add a "hang-up" action only if we are using ConnectionService. - if (AudioModeModule.useConnectionService()) { - Intent hangupIntent = new Intent(context, JitsiMeetOngoingConferenceService.class); - hangupIntent.setAction(JitsiMeetOngoingConferenceService.Actions.HANGUP); - PendingIntent hangupPendingIntent - = PendingIntent.getService(context, 0, hangupIntent, PendingIntent.FLAG_UPDATE_CURRENT); - NotificationCompat.Action hangupAction = new NotificationCompat.Action(0, "Hang up", hangupPendingIntent); + NotificationCompat.Action hangupAction = createAction(context, JitsiMeetOngoingConferenceService.Action.HANGUP, "Hang up"); - builder.addAction(hangupAction); - } + JitsiMeetOngoingConferenceService.Action toggleAudioAction = isMuted + ? JitsiMeetOngoingConferenceService.Action.UNMUTE : JitsiMeetOngoingConferenceService.Action.MUTE; + String toggleAudioTitle = isMuted ? "Unmute" : "Mute"; + NotificationCompat.Action audioAction = createAction(context, toggleAudioAction, toggleAudioTitle); + + builder.addAction(hangupAction); + builder.addAction(audioAction); return builder.build(); } + + private static NotificationCompat.Action createAction(Context context, JitsiMeetOngoingConferenceService.Action action, String title) { + Intent intent = new Intent(context, JitsiMeetOngoingConferenceService.class); + intent.setAction(action.getName()); + PendingIntent pendingIntent + = PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + return new NotificationCompat.Action(0, title, pendingIntent); + } } diff --git a/ios/app/src/ViewController.m b/ios/app/src/ViewController.m index 6f137a04f..44db705d6 100644 --- a/ios/app/src/ViewController.m +++ b/ios/app/src/ViewController.m @@ -99,9 +99,22 @@ #if 0 - (void)enterPictureInPicture:(NSDictionary *)data { [self _onJitsiMeetViewDelegateEvent:@"ENTER_PICTURE_IN_PICTURE" withData:data]; + } #endif +- (void)participantJoined:(NSDictionary *)data { + NSLog(@"%@%@", @"Participant joined: ", data[@"participantId"]); +} + +- (void)participantLeft:(NSDictionary *)data { + NSLog(@"%@%@", @"Participant left: ", data[@"participantId"]); +} + +- (void)audioMutedChanged:(NSDictionary *)data { + NSLog(@"%@%@", @"Audio muted changed: ", data[@"muted"]); +} + #pragma mark - Helpers - (void)terminate { diff --git a/ios/sdk/sdk.xcodeproj/project.pbxproj b/ios/sdk/sdk.xcodeproj/project.pbxproj index 03dd4e2b2..8884347fa 100644 --- a/ios/sdk/sdk.xcodeproj/project.pbxproj +++ b/ios/sdk/sdk.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ C69EFA0E209A0F660027712B /* JMCallKitListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = C69EFA0B209A0F660027712B /* JMCallKitListener.swift */; }; C6A34261204EF76800E062DD /* DragGestureController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A3425E204EF76800E062DD /* DragGestureController.swift */; }; C6CC49AF207412CF000DFA42 /* PiPViewCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6CC49AE207412CF000DFA42 /* PiPViewCoordinator.swift */; }; + C81E9AB925AC5AD800B134D9 /* ExternalAPI.h in Headers */ = {isa = PBXBuildFile; fileRef = C81E9AB825AC5AD800B134D9 /* ExternalAPI.h */; }; C8AFD27F2462C613000293D2 /* InfoPlistUtil.h in Headers */ = {isa = PBXBuildFile; fileRef = C8AFD27D2462C613000293D2 /* InfoPlistUtil.h */; settings = {ATTRIBUTES = (Public, ); }; }; C8AFD2802462C613000293D2 /* InfoPlistUtil.m in Sources */ = {isa = PBXBuildFile; fileRef = C8AFD27E2462C613000293D2 /* InfoPlistUtil.m */; }; DE438CDA2350934700DD541D /* JavaScriptSandbox.m in Sources */ = {isa = PBXBuildFile; fileRef = DE438CD82350934700DD541D /* JavaScriptSandbox.m */; }; @@ -105,6 +106,7 @@ C6A3425E204EF76800E062DD /* DragGestureController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DragGestureController.swift; sourceTree = ""; }; C6CC49AE207412CF000DFA42 /* PiPViewCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PiPViewCoordinator.swift; sourceTree = ""; }; C6F99C13204DB63D0001F710 /* JitsiMeetView+Private.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "JitsiMeetView+Private.h"; sourceTree = ""; }; + C81E9AB825AC5AD800B134D9 /* ExternalAPI.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ExternalAPI.h; sourceTree = ""; }; C8AFD27D2462C613000293D2 /* InfoPlistUtil.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = InfoPlistUtil.h; sourceTree = ""; }; C8AFD27E2462C613000293D2 /* InfoPlistUtil.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = InfoPlistUtil.m; sourceTree = ""; }; DE438CD82350934700DD541D /* JavaScriptSandbox.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = JavaScriptSandbox.m; sourceTree = ""; }; @@ -228,6 +230,7 @@ 0B93EF7D1EC9DDCD0030D24D /* RCTBridgeWrapper.m */, C8AFD27D2462C613000293D2 /* InfoPlistUtil.h */, C8AFD27E2462C613000293D2 /* InfoPlistUtil.m */, + C81E9AB825AC5AD800B134D9 /* ExternalAPI.h */, ); path = src; sourceTree = ""; @@ -301,6 +304,7 @@ DE81A2D42316AC4D00AE1940 /* JitsiMeetLogger.h in Headers */, DE65AACA2317FFCD00290BEC /* LogUtils.h in Headers */, DEAD3226220C497000E93636 /* JitsiMeetConferenceOptions.h in Headers */, + C81E9AB925AC5AD800B134D9 /* ExternalAPI.h in Headers */, C8AFD27F2462C613000293D2 /* InfoPlistUtil.h in Headers */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ios/sdk/src/AppInfo.m b/ios/sdk/src/AppInfo.m index d74272917..df5f0176b 100644 --- a/ios/sdk/src/AppInfo.m +++ b/ios/sdk/src/AppInfo.m @@ -1,5 +1,5 @@ /* - * Copyright @ 2017-present Atlassian Pty Ltd + * Copyright @ 2017-present 8x8, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/ios/sdk/src/ExternalAPI.h b/ios/sdk/src/ExternalAPI.h new file mode 100644 index 000000000..558aa2113 --- /dev/null +++ b/ios/sdk/src/ExternalAPI.h @@ -0,0 +1,24 @@ +/* Copyright @ 2021-present 8x8, Inc. +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +#import +#import + +@interface ExternalAPI : RCTEventEmitter + +- (void)sendHangUp; +- (void)sendSetAudioMuted: (BOOL)muted; + +@end diff --git a/ios/sdk/src/ExternalAPI.m b/ios/sdk/src/ExternalAPI.m index cee9bcf3f..a9f9f3284 100644 --- a/ios/sdk/src/ExternalAPI.m +++ b/ios/sdk/src/ExternalAPI.m @@ -1,5 +1,5 @@ /* - * Copyright @ 2017-present Atlassian Pty Ltd + * Copyright @ 2017-present 8x8, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,17 +14,24 @@ * limitations under the License. */ -#import - +#import "ExternalAPI.h" #import "JitsiMeetView+Private.h" -@interface ExternalAPI : NSObject -@end +// Events +static NSString * const hangUpEvent = @"org.jitsi.meet.HANG_UP"; +static NSString * const setAudioMutedEvent = @"org.jitsi.meet.SET_AUDIO_MUTED"; @implementation ExternalAPI RCT_EXPORT_MODULE(); +- (NSDictionary *)constantsToExport { + return @{ + @"HANG_UP": hangUpEvent, + @"SET_AUDIO_MUTED" : setAudioMutedEvent + }; +}; + /** * Make sure all methods in this module are invoked on the main/UI thread. */ @@ -32,6 +39,14 @@ RCT_EXPORT_MODULE(); return dispatch_get_main_queue(); } ++ (BOOL)requiresMainQueueSetup { + return NO; +} + +- (NSArray *)supportedEvents { + return @[ hangUpEvent, setAudioMutedEvent ]; +} + /** * Dispatches an event that occurred on JavaScript to the view's delegate. * @@ -87,4 +102,14 @@ RCT_EXPORT_METHOD(sendEvent:(NSString *)name return methodName; } +- (void)sendHangUp { + [self sendEventWithName:hangUpEvent body:nil]; +} + +- (void)sendSetAudioMuted: (BOOL)muted { + NSDictionary *data = @{ @"muted": [NSNumber numberWithBool:muted]}; + + [self sendEventWithName:setAudioMutedEvent body:data]; +} + @end diff --git a/ios/sdk/src/JitsiMeetView.h b/ios/sdk/src/JitsiMeetView.h index 30db2719e..850e91ba3 100644 --- a/ios/sdk/src/JitsiMeetView.h +++ b/ios/sdk/src/JitsiMeetView.h @@ -37,4 +37,8 @@ */ - (void)leave; +- (void)hangUp; + +- (void)setAudioMuted:(BOOL)muted; + @end diff --git a/ios/sdk/src/JitsiMeetView.m b/ios/sdk/src/JitsiMeetView.m index 1df042050..65aaa4133 100644 --- a/ios/sdk/src/JitsiMeetView.m +++ b/ios/sdk/src/JitsiMeetView.m @@ -17,6 +17,7 @@ #include +#import "ExternalAPI.h" #import "JitsiMeet+Private.h" #import "JitsiMeetConferenceOptions+Private.h" #import "JitsiMeetView+Private.h" @@ -49,7 +50,6 @@ static NSString *const PiPEnabledFeatureFlag = @"pip.enabled"; * identifiers within the process). */ static NSMapTable *views; - /** * This gets called automagically when the program starts. */ @@ -115,6 +115,16 @@ static void initializeViewsMap() { [self setProps:@{}]; } +- (void)hangUp { + RCTBridge *bridge = [[JitsiMeet sharedInstance] getReactBridge]; + [[bridge moduleForClass:ExternalAPI.class] sendHangUp]; +} + +- (void)setAudioMuted:(BOOL)muted { + RCTBridge *bridge = [[JitsiMeet sharedInstance] getReactBridge]; + [[bridge moduleForClass:ExternalAPI.class] sendSetAudioMuted:muted]; +} + #pragma mark Private methods /** diff --git a/ios/sdk/src/JitsiMeetViewDelegate.h b/ios/sdk/src/JitsiMeetViewDelegate.h index 4c302a551..924c94efa 100644 --- a/ios/sdk/src/JitsiMeetViewDelegate.h +++ b/ios/sdk/src/JitsiMeetViewDelegate.h @@ -1,5 +1,5 @@ /* - * Copyright @ 2017-present Atlassian Pty Ltd + * Copyright @ 2017-present 8x8, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -55,4 +55,24 @@ */ - (void)enterPictureInPicture:(NSDictionary *)data; +/** + * Called when a participant has joined the conference. + * + * The `data` dictionary contains a `participantId` key with the id of the participant that has joined. + */ +- (void)participantJoined:(NSDictionary *)data; + +/** + * Called when a participant has left the conference. + * + * The `data` dictionary contains a `participantId` key with the id of the participant that has left. + */ +- (void)participantLeft:(NSDictionary *)data; + +/** + * Called when audioMuted state changed. + * + * The `data` dictionary contains a `muted` key with state of the audioMuted for the localParticipant. + */ +- (void)audioMutedChanged:(NSDictionary *)data; @end diff --git a/react/features/base/toolbox/components/AbstractAudioMuteButton.js b/react/features/base/toolbox/components/AbstractAudioMuteButton.js index a9ccea0bc..7c780cc07 100644 --- a/react/features/base/toolbox/components/AbstractAudioMuteButton.js +++ b/react/features/base/toolbox/components/AbstractAudioMuteButton.js @@ -52,7 +52,7 @@ export default class AbstractAudioMuteButton * Helper function to perform the actual setting of the audio mute / unmute * action. * - * @param {boolean} audioMuted - Whether video should be muted or not. + * @param {boolean} audioMuted - Whether audio should be muted or not. * @protected * @returns {void} */ diff --git a/react/features/mobile/external-api/middleware.js b/react/features/mobile/external-api/middleware.js index 7706338bb..4f3593c23 100644 --- a/react/features/mobile/external-api/middleware.js +++ b/react/features/mobile/external-api/middleware.js @@ -1,5 +1,9 @@ // @flow +import { NativeEventEmitter, NativeModules } from 'react-native'; + +import { appNavigate } from '../../app/actions'; +import { APP_WILL_MOUNT } from '../../base/app/actionTypes'; import { CONFERENCE_FAILED, CONFERENCE_JOINED, @@ -18,7 +22,10 @@ import { JITSI_CONNECTION_URL_KEY, getURLWithoutParams } from '../../base/connection'; +import { SET_AUDIO_MUTED } from '../../base/media/actionTypes'; +import { PARTICIPANT_JOINED, PARTICIPANT_LEFT } from '../../base/participants'; import { MiddlewareRegistry } from '../../base/redux'; +import { muteLocal } from '../../remote-video-menu/actions'; import { ENTER_PICTURE_IN_PICTURE } from '../picture-in-picture'; import { sendEvent } from './functions'; @@ -29,6 +36,9 @@ import { sendEvent } from './functions'; */ const CONFERENCE_TERMINATED = 'CONFERENCE_TERMINATED'; +const { ExternalAPI } = NativeModules; +const eventEmitter = new NativeEventEmitter(ExternalAPI); + /** * Middleware that captures Redux actions and uses the ExternalAPI module to * turn them into native events so the application knows about them. @@ -41,6 +51,9 @@ MiddlewareRegistry.register(store => next => action => { const { type } = action; switch (type) { + case APP_WILL_MOUNT: + _registerForNativeEvents(store.dispatch); + break; case CONFERENCE_FAILED: { const { error, ...data } = action; @@ -111,14 +124,56 @@ MiddlewareRegistry.register(store => next => action => { break; } + case PARTICIPANT_JOINED: + case PARTICIPANT_LEFT: { + const { participant } = action; + + sendEvent( + store, + action.type, + /* data */ { + isLocal: participant.local, + email: participant.email, + name: participant.name, + participantId: participant.id + }); + break; + } + case SET_ROOM: _maybeTriggerEarlyConferenceWillJoin(store, action); break; + + case SET_AUDIO_MUTED: + sendEvent( + store, + 'AUDIO_MUTED_CHANGED', + /* data */ { + muted: action.muted + }); + break; } return result; }); +/** + * Registers for events sent from the native side via NativeEventEmitter. + * + * @param {Dispatch} dispatch - The Redux dispatch function. + * @private + * @returns {void} + */ +function _registerForNativeEvents(dispatch) { + eventEmitter.addListener(ExternalAPI.HANG_UP, () => { + dispatch(appNavigate(undefined)); + }); + + eventEmitter.addListener(ExternalAPI.SET_AUDIO_MUTED, ({ muted }) => { + dispatch(muteLocal(muted === 'true')); + }); +} + /** * Returns a {@code String} representation of a specific error {@code Object}. *