
package kjd.reactnative.bluetooth;

import android.Manifest;
import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothSocket;
import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageManager;
import android.os.Build;
import android.provider.Settings;
import android.util.Base64;
import android.util.Log;

import androidx.annotation.NonNull;

import com.facebook.react.BuildConfig;
import com.facebook.react.bridge.ActivityEventListener;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.LifecycleEventListener;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;

import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;

import javax.annotation.Nullable;

import kjd.reactnative.android.BiConsumer;
import kjd.reactnative.bluetooth.conn.AcceptFailedException;
import kjd.reactnative.bluetooth.conn.ConnectionAcceptor;
import kjd.reactnative.bluetooth.conn.ConnectionAcceptorFactory;
import kjd.reactnative.bluetooth.conn.ConnectionConnector;
import kjd.reactnative.bluetooth.conn.ConnectionConnectorFactory;
import kjd.reactnative.bluetooth.conn.ConnectionFailedException;
import kjd.reactnative.bluetooth.conn.StandardOption;
import kjd.reactnative.bluetooth.event.BluetoothDeviceEvent;
import kjd.reactnative.bluetooth.event.BluetoothStateEvent;
import kjd.reactnative.bluetooth.event.EventType;
import kjd.reactnative.bluetooth.conn.DeviceConnection;
import kjd.reactnative.bluetooth.conn.DeviceConnectionFactory;
import kjd.reactnative.bluetooth.device.NativeDevice;
import kjd.reactnative.bluetooth.receiver.ActionACLReceiver;
import kjd.reactnative.bluetooth.receiver.DiscoveryReceiver;
import kjd.reactnative.bluetooth.receiver.PairingReceiver;
import kjd.reactnative.bluetooth.receiver.StateChangeReceiver;

/**
 * Provides bridge between native Android functionality and React Native
 * javascript. Provides
 * {@code @ReactMethod} methods to Javascript to allow controlling/monitoring:
 * <ul>
 * <li>the Android Bluetooth configuration status</li>
 * <li>connecting/disconnecting to specific devices</li>
 * <li>sending and receiving manual data</li>
 * <li>receiving pushed messages from a connected device</li>
 * </ul>
 * For more details on React Native modules see:
 * <ul>
 * <li>https://facebook.github.io/react-native/docs/native-modules-setup</li>
 * <li>https://facebook.github.io/react-native/docs/native-modules-android</li>
 * </ul>
 * The {@link RNBluetoothClassicModule} is configured with a number of different
 * connection
 * types (this is done through {@link DeviceConnectionFactory}(s). The standard
 * factories are
 * ACCEPT and CONNECT which use delimited data handlers.
 *
 * @author kendavidson
 */
@SuppressWarnings({ "WeakerAccess" })
public class RNBluetoothClassicModule
        extends ReactContextBaseJavaModule
        implements ActivityEventListener,
        LifecycleEventListener,
        StateChangeReceiver.StateChangeCallback,
        ActionACLReceiver.ActionACLCallback {

    /**
     * Name of the module when provided to React Native {@code NativeModules}.
     */
    public static final String MODULE_NAME = "RNBluetoothClassic";

    /**
     * Logging definition.
     */
    private static final String TAG = RNBluetoothClassicModule.class.getSimpleName();

    /**
     * Local access to the default {@link BluetoothAdapter}. Generally we just need
     * to check things
     * like:
     * <ul>
     * <li>Is bluetooth enabled?</li>
     * <li>Is bluetooth searching / advertising?</li>
     * <li>Etc.</li>
     * </ul>
     */
    private final BluetoothAdapter mAdapter;

    /**
     * Provides {@link ConnectionAcceptorFactory}(s) to {@link #accept} method.
     */
    private final Map<String, ConnectionAcceptorFactory> mAcceptorFactories;

    /**
     * Provides a map of all available {@link ConnectionConnectorFactory} available
     * to the
     * {@link #connectToDevice} method. A {@link ConnectionConnector} is first
     * started, then upon
     * completion the {@link BluetoothSocket} is passed into the requested
     * {@link DeviceConnection}.
     */
    private final Map<String, ConnectionConnectorFactory> mConnectorFactories;

    /**
     * Provides a map of all the available {@link DeviceConnectionFactory} available
     * to the
     * {@link #connectToDevice} method.
     */
    private final Map<String, DeviceConnectionFactory> mConnectionFactories;

    /**
     * Manages {@link DeviceConnection} wrapping {@link BluetoothDevice} by
     * {@link BluetoothDevice#getAddress()}. Currently the initial capacity is 1,
     * since the main
     * goal of this was a simple connection. This may need to be updated to have the
     * default
     * size updated during package creation.
     */
    private Map<String, DeviceConnection> mConnections;

    /**
     * Maintains a map of {@link ConnectionConnector}(s) keyed on
     * {@link BluetoothDevice} address.
     * Connectors are added during the {@link #connectToDevice} request and removed
     * when either
     * successful or failed.
     */
    private Map<String, ConnectionConnector> mConnecting;

    /**
     * Manages intents while the application and {@link BluetoothAdapter} are in
     * discovery mode.
     * This will be cancelled when the application is paused or ends discovery.
     */
    private BroadcastReceiver mDiscoveryReceiver;

    /**
     * Intent receiver responsible for handling changes to BluetoothAdapter state
     * (on/off). Fires
     * an event to the ReactNative emitter based on the new state.
     * https://developer.android.com/reference/android/bluetooth/BluetoothAdapter#ACTION_STATE_CHANGED
     */
    private BroadcastReceiver mStateChangeReceiver;

    /**
     * Intent receiver responsible for handling changes to Bluetooth connections.
     * This Intent is
     * fired when the BluetoothAdapter connection state to any device changes. It
     * fires an event
     * to the ReactNative emitter containing the state and deviceId which was
     * connected.
     * https://developer.android.com/reference/android/bluetooth/BluetoothAdapter#ACTION_CONNECTION_STATE_CHANGED
     */
    private BroadcastReceiver mActionACLReceiver;

    /**
     * Promise must be maintained across Activity requests for managing the enabled
     * request
     * status.
     */
    private Promise mEnabledPromise;

    /**
     * Manage the number of listeners of a specific type - these event types are
     * that of
     * the bluetooth mAdapter in general (connect, disconnect, etc.) and not those
     * which are
     * reading. Those are managed separately within the device itself.
     */
    private Map<String, AtomicInteger> mListenerCounts;

    /**
     * Maintains the {@link ConnectionAcceptor} when the module has been placed into
     * accept
     * mode. Only one type of {@link ConnectionAcceptor} is allowed at one time,
     * regardless
     * of how many are configured. Current accepting should be cancelled and
     * restarted in order
     * to change the type.
     */
    private AtomicReference<ConnectionAcceptor> mAcceptor = new AtomicReference(null);

    // region: Constructors

    /**
     * Creates the RNBlutoothClassicModule. As a final step of initialization the
     * appropriate
     * {@link EventType#BLUETOOTH_ENABLED}/{@link EventType#BLUETOOTH_DISABLED} is
     * sent and the activity and lifecyle listeners are register.
     *
     * @param context   React application context
     * @param factories {@link DeviceConnection} factories
     */
    public RNBluetoothClassicModule(ReactApplicationContext context,
            Map<String, ConnectionAcceptorFactory> acceptFactories,
            Map<String, ConnectionConnectorFactory> connectFactories,
            Map<String, DeviceConnectionFactory> factories) {
        super(context);

        this.mAdapter = BluetoothAdapter.getDefaultAdapter();

        this.mAcceptorFactories = Collections.unmodifiableMap(acceptFactories);
        this.mConnectorFactories = Collections.unmodifiableMap(connectFactories);
        this.mConnectionFactories = Collections.unmodifiableMap(factories);

        this.mConnections = new ConcurrentHashMap<>(1);
        this.mConnecting = new ConcurrentHashMap<>(1);
        this.mListenerCounts = new ConcurrentHashMap<>();

        if (mAdapter != null && mAdapter.isEnabled()) {
            sendEvent(EventType.BLUETOOTH_ENABLED,
                    new BluetoothStateEvent(BluetoothState.ENABLED).map());
        } else {
            sendEvent(EventType.BLUETOOTH_DISABLED,
                    new BluetoothStateEvent(BluetoothState.DISABLED).map());
        }

        getReactApplicationContext().addActivityEventListener(this);
        getReactApplicationContext().addLifecycleEventListener(this);
    }
    // endregion

    // region: ReactContextBaseJavaModule methods
    @Override
    @NonNull
    public String getName() {
        return MODULE_NAME;
    }

    public boolean hasConstants() {
        return true;
    }

    /**
     * Previously this returned the Bluetooth events and common character sets that
     * were available
     * on the Android system. This wasn't testable, and I've decided to open it up
     * to make things
     * more generic, and let users handle their own issues.
     *
     * @return constants provided by
     *
     */
    @Nullable
    @Override
    public Map<String, Object> getConstants() {
        return Collections.emptyMap();
    }
    // endregion

    // region: Helper/Utility Methods
    private boolean checkBluetoothAdapter() {
        return (mAdapter != null && mAdapter.isEnabled());
    }

    /**
     * Check if BLUETOOTH_SCAN permission is granted for Android 12+ (API 31+).
     * For devices running Android 11 and below, this will return true as the permission is not required.
     *
     * @return true if permission is granted or not required, false otherwise
     */
    private boolean hasBluetoothScanPermission() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            ReactApplicationContext context = getReactApplicationContext();
            if (context == null) {
                return false;
            }
            return context.checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN)
                    == PackageManager.PERMISSION_GRANTED;
        }
        return true;
    }
    // endregion

    // region: ActivityEventListener
    /**
     * Handles results from the requested Android Intents. Currently there are only
     * two activities
     * started for result:
     * <ul>
     * <li><strong>ENABLE_BLUETOOTH</strong> requests the user to enable Bluetooth
     * from settings.</li>
     * <li><strong>PAIR_DEVICE</strong> after a user has completed pairing the
     * device.</li>
     * </ul>
     * This sends a {@link EventType#BLUETOOTH_ENABLED} event. It probably shouldn't
     * duplicate
     * the promise but this gives the opportunity to do both things.
     *
     * @param activity    the activity which is returning the result
     * @param requestCode request code provided to the outgoing intent
     * @param resultCode  result of the requested Intent
     * @param data        the intent which triggered this result
     */
    @Override
    public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
        if (BuildConfig.DEBUG)
            Log.d(TAG, String.format("onActivityResult requestCode: %d resultCode: %d", requestCode, resultCode));

        if (requestCode == BluetoothRequest.ENABLE_BLUETOOTH.code) {
            if (resultCode == Activity.RESULT_OK) {
                if (BuildConfig.DEBUG)
                    Log.d(TAG, "User enabled Bluetooth");

                if (mEnabledPromise != null) {
                    mEnabledPromise.resolve(true);
                    sendEvent(EventType.BLUETOOTH_ENABLED,
                            new BluetoothStateEvent(BluetoothState.ENABLED).map());
                }
            } else {
                if (BuildConfig.DEBUG)
                    Log.d(TAG, "User did *NOT* enable Bluetooth");

                if (mEnabledPromise != null) {
                    mEnabledPromise.reject(new Exception("User did not enable Bluetooth"));
                }
            }
            mEnabledPromise = null;
        }
    }

    @Override
    public void onNewIntent(Intent intent) {
        if (BuildConfig.DEBUG)
            Log.d(TAG, "onNewIntent: " + intent.getAction());
    }
    // endregion

    // region: LifecycleEventListener
    @Override
    public void onHostResume() {
        if (BuildConfig.DEBUG)
            Log.d(TAG, "onHostResume: register Application receivers");

        registerBluetoothReceivers();
    }

    @Override
    public void onHostPause() {
        if (BuildConfig.DEBUG)
            Log.d(TAG, "onHostPause: unregister receivers");

        unregisterBluetoothReceivers();
    }

    @Override
    public void onHostDestroy() {
        if (BuildConfig.DEBUG)
            Log.d(TAG, "onHostDestroy: stop discovery, connections and unregister receivers");

        if (mAdapter != null && hasBluetoothScanPermission()) {
            try {
                mAdapter.cancelDiscovery();
            } catch (SecurityException e) {
                Log.e(TAG, "Failed to cancel discovery due to missing permission: " + e.getMessage());
            }
        }
    }
    // endregion

    /**
     * Requests that the Android Bluetooth mAdapter be enabled. If the mAdapter is
     * already enabled
     * then the promise is returned true. If the mAdapter is not enabled, an Intent
     * request is sent
     * to Android (promised saved for use upon result).
     * <p>
     * Note that this does not inherently fire a state change event, as the manual
     * act seems to
     * skip the StateChangeReceiver functionality.
     *
     * @param promise resolves <strong>true</strong> if Bluetooth is already enabled
     *                or when
     *                Bluetooth becomes enabled. Rejects if anything else
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void requestBluetoothEnabled(Promise promise) {
        if (checkBluetoothAdapter()) {
            promise.resolve(true);
        } else {
            Activity activity = getCurrentActivity();
            mEnabledPromise = promise;
            
            if (activity != null) {            
                Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
                activity.startActivityForResult(intent, BluetoothRequest.ENABLE_BLUETOOTH.code);
            } else {
                ActivityNotFoundException e = new ActivityNotFoundException();
                mEnabledPromise.reject(e);
                mEnabledPromise = null;
            }
        }
    }

    /**
     * Determine whether Bluetooth is available. The promise is never rejected, only
     * resolved with the
     * appropriate boolean flag.
     *
     * @param promise resolve based on Bluetooth being available on the device.
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void isBluetoothAvailable(Promise promise) {
        promise.resolve(mAdapter != null);
    }

    /**
     * Determine whether Bluetooth is enabled. The promise is never rejected, only
     * resolved with the
     * appropriate boolean flag.
     *
     * @param promise resolve based on Bluetooth status
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void isBluetoothEnabled(Promise promise) {
        promise.resolve(checkBluetoothAdapter());
    }

    /**
     * Retrieves the currently bonded devices. Bonded devices may or may not be
     * connected. This
     * method was refactored from <strong>list</strong> as there was a bunch of
     * confusion with bonded
     * and connected devices.
     *
     * @param promise resolves the list of bonded devices.
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void getBondedDevices(Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else {
            WritableArray bonded = Arguments.createArray();
            for (BluetoothDevice device : mAdapter.getBondedDevices()) {
                NativeDevice nativeDevice = new NativeDevice(device);
                bonded.pushMap(nativeDevice.map());
            }

            promise.resolve(bonded);
        }
    }

    /**
     * Lists all the currently connected devices. Provides a {@link WritableArray}
     * of {@link BluetoothDevice}(s) which currently have an active/open connection.
     * Please note
     * that this does NOT list PAIRED devices, only those that are actually
     * connected!
     *
     * @param promise resolves with the currently connected devices, may be empty.
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void getConnectedDevices(Promise promise) {
        WritableArray connected = Arguments.createArray();
        for (DeviceConnection connection : mConnections.values()) {
            connected.pushMap(new NativeDevice(connection.getDevice()).map());
        }

        Log.d(TAG, "getConnectedDevices: " + connected.toString());

        promise.resolve(connected);
    }

    /**
     * Registers a {@link DiscoveryReceiver} and starts discovery.
     *
     * @param promise resolve or reject the request to discoverDevices
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void startDiscovery(final Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else if (mDiscoveryReceiver != null) {
            promise.reject(Exceptions.BLUETOOTH_IN_DISCOVERY.name(),
                    Exceptions.BLUETOOTH_IN_DISCOVERY.message());
        } else {
            mDiscoveryReceiver = new DiscoveryReceiver(new DiscoveryReceiver.DiscoveryCallback() {
                @Override
                public void onDeviceDiscovered(NativeDevice device) {
                    // This wasn't previously an event, but now we can send out and request them
                    Log.d(TAG, String.format("Discovered device %s", device.getAddress()));
                    BluetoothDeviceEvent event = new BluetoothDeviceEvent(EventType.DEVICE_DISCOVERED, device);
                    sendEvent(EventType.DEVICE_DISCOVERED, event.map());
                }

                @Override
                public void onDiscoveryFinished(Collection<NativeDevice> devices) {
                    WritableArray array = Arguments.createArray();
                    for (NativeDevice device : devices) {
                        array.pushMap(device.map());
                    }

                    promise.resolve(array);
                    mDiscoveryReceiver = null;
                }

                @Override
                public void onDiscoveryFailed(Throwable e) {
                    promise.reject(Exceptions.DISCOVERY_FAILED.name(),
                            Exceptions.DISCOVERY_FAILED.message(e.getMessage()));
                    mDiscoveryReceiver = null;
                }
            });

            getReactApplicationContext().registerReceiver(mDiscoveryReceiver,
                    DiscoveryReceiver.intentFilter());

            mAdapter.startDiscovery();
        }
    }

    /**
     * Opens Android's Bluetooth Settings activity.
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void openBluetoothSettings() {
        Intent intent = new Intent(Settings.ACTION_BLUETOOTH_SETTINGS);
        if (intent.resolveActivity(getReactApplicationContext().getPackageManager()) != null) {
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            getReactApplicationContext().startActivity(intent);
        }
    }

    /**
     * Attempts to cancel the discovery process. Cancel request is always resolved
     * as true at this
     * point, which may need to be changed, but for now whether anything happens or
     * not it's
     * seen as successful.
     * <p>
     * Note - the Discovery promise will be resolved with any devices that were
     * found during the
     * discovery period, so effectively cancelling resolves two promises.
     *
     * @param promise resolves cancel request
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void cancelDiscovery(final Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else {
            promise.resolve(mAdapter.cancelDiscovery());
        }
    }

    /**
     * Attempts to pair/bond with the device specified by address,
     * {@link BluetoothDevice#createBond()}
     * is only available after SDK v19.
     *
     * @param address the address of the BluetoothDevice to which we attempt pairing
     * @param promise resolves when the BluetoothDevice is paired, rejects if the
     *                SDK version is
     *                less than 19, Bluetooth is not enabled, or an Exception
     *                occurs.
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void pairDevice(String address, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else if (Build.VERSION.SDK_INT < 19) {
            promise.reject(Exceptions.BONDING_UNAVAILABLE_API.name(),
                    Exceptions.BONDING_UNAVAILABLE_API.message());
        } else {
            if (BuildConfig.DEBUG)
                Log.d(TAG, String.format("Attempting to pair with device %s", address));

            final PairingReceiver pr = new PairingReceiver(getReactApplicationContext(),
                    new PairingReceiver.PairingCallback() {
                        @Override
                        public void onPairingSuccess(NativeDevice device) {
                            promise.resolve(device.map());
                        }

                        @Override
                        public void onPairingFailure(Exception cause) {
                            promise.reject(new DevicePairingException(null, cause));
                        }
                    });
            getReactApplicationContext().registerReceiver(pr, PairingReceiver.intentFilter());
            try {
                BluetoothDevice device = mAdapter.getRemoteDevice(address);
                Method m = device.getClass().getMethod("createBond", (Class[]) null);
                m.invoke(device, (Object[]) null);
            } catch (IllegalAccessException e) {
                getReactApplicationContext().unregisterReceiver(pr);
                promise.reject(Exceptions.BONDING_UNAVAILABLE_API.name(),
                        Exceptions.BONDING_UNAVAILABLE_API.message());
            } catch (InvocationTargetException e) {
                getReactApplicationContext().unregisterReceiver(pr);
                promise.reject(Exceptions.BONDING_UNAVAILABLE_API.name(),
                        Exceptions.BONDING_UNAVAILABLE_API.message());
            } catch (NoSuchMethodException e) {
                getReactApplicationContext().unregisterReceiver(pr);
                promise.reject(Exceptions.BONDING_UNAVAILABLE_API.name(),
                        Exceptions.BONDING_UNAVAILABLE_API.message());
            }
        }
    }

    /**
     * Request that a device be unpaired. The device Id is required - looked up
     * using the
     * BluetoothAdapter and unpaired.
     *
     * @param address the address of the BluetoothDevice to which we attempt pairing
     * @param promise resolves when the BluetoothDevice is paired, rejects if there
     *                are any issues
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void unpairDevice(String address, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else if (Build.VERSION.SDK_INT < 19) {
            promise.reject(Exceptions.BONDING_UNAVAILABLE_API.name(),
                    Exceptions.BONDING_UNAVAILABLE_API.message());
        } else {
            if (BuildConfig.DEBUG)
                Log.d(TAG, String.format("Attempting to pair with device %s", address));

            try {
                BluetoothDevice device = mAdapter.getRemoteDevice(address);
                Method m = device.getClass().getMethod("removeBond", (Class[]) null);
                m.invoke(device, (Object[]) null);

                IntentFilter intentFilter = new IntentFilter();
                intentFilter.addAction(BluetoothDevice.ACTION_BOND_STATE_CHANGED);

                PairingReceiver pr = new PairingReceiver(getReactApplicationContext(),
                        new PairingReceiver.PairingCallback() {
                            @Override
                            public void onPairingSuccess(NativeDevice device) {
                                promise.resolve(device.map());
                            }

                            @Override
                            public void onPairingFailure(Exception cause) {
                                promise.reject(new DevicePairingException(new NativeDevice(device), cause));
                            }
                        });

                getReactApplicationContext().registerReceiver(pr, intentFilter);
            } catch (IllegalAccessException e) {
                promise.reject(Exceptions.BONDING_UNAVAILABLE_API.name(),
                        Exceptions.BONDING_UNAVAILABLE_API.message());
            } catch (InvocationTargetException e) {
                promise.reject(Exceptions.BONDING_UNAVAILABLE_API.name(),
                        Exceptions.BONDING_UNAVAILABLE_API.message());
            } catch (NoSuchMethodException e) {
                promise.reject(Exceptions.BONDING_UNAVAILABLE_API.name(),
                        Exceptions.BONDING_UNAVAILABLE_API.message());
            }
        }
    }

    /**
     * Puts the {@link BluetoothAdapter} into an accept mode using the provided
     * accept type
     * configured on the module.
     *
     * @param promise resolve or reject the requested listening
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void accept(ReadableMap parameters, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else if (mAcceptor.get() != null) {
            promise.reject(Exceptions.BLUETOOTH_IN_ACCEPTING.name(),
                    Exceptions.BLUETOOTH_IN_ACCEPTING.message());
        } else {
            Properties properties = Utilities.mapToProperties(parameters);

            try {
                String connectorType = StandardOption.ACCEPTOR_TYPE.get(properties);
                if (!mAcceptorFactories.containsKey(connectorType))
                    throw new IllegalStateException(
                            String.format("No ConnectionAcceptorFactory configured for type %s", connectorType));

                ConnectionAcceptorFactory acceptorFactory = mAcceptorFactories.get(connectorType);
                ConnectionAcceptor acceptor = acceptorFactory.create(mAdapter, properties);
                acceptor.addListener(new ConnectionAcceptor.AcceptorListener<BluetoothSocket>() {
                    @Override
                    public void success(BluetoothSocket bluetoothSocket) {
                        BluetoothDevice device = bluetoothSocket.getRemoteDevice();
                        NativeDevice nativeDevice = new NativeDevice(device);

                        try {
                            // Create the appropriate Connection type and add it to the connected list

                            String connectionType = StandardOption.CONNECTION_TYPE.get(properties);
                            DeviceConnectionFactory connectionFactory = mConnectionFactories.get(connectionType);
                            DeviceConnection connection = connectionFactory.create(bluetoothSocket, properties);
                            connection.onDisconnect(onDisconnect);
                            mConnections.put(device.getAddress(), connection);

                            // Now start the connection and let React Native know
                            Thread ct = new Thread(connection);
                            ct.start();

                            promise.resolve(nativeDevice.map());

                        } catch (IOException e) {
                            promise.reject(new ConnectionFailedException(nativeDevice, e));
                        } finally {
                            // Clear the connection acceptor, as the connection has been successfully
                            // established
                            if (mAcceptor.get() != null) {
                                mAcceptor.get().cancel();
                                mAcceptor.set(null);
                            }
                        }
                    }

                    @Override
                    public void failure(Exception e) {
                        promise.reject(new AcceptFailedException(e.getMessage(), e));
                    }
                });

                this.mAcceptor.set(acceptor);
                this.mAcceptor.get().start();

            } catch (IOException e) {
                promise.reject(new AcceptFailedException(e.getMessage(), e));
            } catch (IllegalStateException e) {
                promise.reject(e);
            }
        }
    }

    /**
     * Attempts to cancel the Accepting thread.
     * <p>
     * If the {@link BluetoothAdapter} is unavailable then the promise will be
     * rejected with a
     * Bluetooth not enabled message.
     * <p>
     * Otherwise the promise will be resolved {@code true}. This was changed as
     * previously an
     * reject would also occur if the Device was not in accept mode. This has been
     * changed as it
     * made more sense to just let React Native app set {@code accepting: false}
     * (kind of like a
     * status check without {@code isAccepting} being required.
     *
     * @param promise the {@link Promise} resolved/rejected based on cancel success
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void cancelAccept(Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else {
            if (mAcceptor.get() != null) {
                mAcceptor.get().cancel();
                mAcceptor.set(null);
            }

            promise.resolve(true);
        }
    }

    /**
     * Attempts to connect to the device with the provided Id. While the connection
     * request is
     * active the Cancellable request will be found in the connecting map; once
     * completed the
     * connection will be moved to the connections map.
     * <p>
     * The default client connection type will be used. If you've provided
     * customized {@link DeviceConnection}(s) then it'll be used.
     *
     * @param address    the address to which we want to connect
     * @param parameters the parameters controlling the type of connection to make
     * @param promise    resolve or reject the requested connection
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void connectToDevice(String address, ReadableMap parameters, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else if (mConnecting.containsKey(address)) {
            promise.reject(Exceptions.ALREADY_CONNECTING.name(),
                    Exceptions.ALREADY_CONNECTING.message(address));
        } else if (mConnections.containsKey(address)) {
            // If it's already connected just return the device now.
            DeviceConnection connection = mConnections.get(address);
            promise.resolve(new NativeDevice(connection.getDevice()).map());
        } else {
            final BluetoothDevice device = mAdapter.getRemoteDevice(address);
            final NativeDevice nativeDevice = new NativeDevice(device);

            try {
                // Issue/84 just in case the React Native side gets circumvented somehow
                // this matches the IOS side of a new parameters being added to a NSDictionary
                Properties properties = parameters == null
                        ? new Properties()
                        : Utilities.mapToProperties(parameters);

                final String connectorType = StandardOption.CONNECTOR_TYPE.get(properties);
                if (!mConnectorFactories.containsKey(connectorType)) {
                    promise.reject(Exceptions.INVALID_CONNECTOR_TYPE.name(),
                            Exceptions.INVALID_CONNECTOR_TYPE.message(connectorType));
                    return;
                }

                final String connectionType = StandardOption.CONNECTION_TYPE.get(properties);
                if (!mConnectionFactories.containsKey(connectionType)) {
                    promise.reject(Exceptions.INVALID_CONNECTION_TYPE.name(),
                            Exceptions.INVALID_CONNECTION_TYPE.message(connectorType));
                    return;
                }

                ConnectionConnectorFactory connectorFactory = mConnectorFactories.get(connectorType);
                ConnectionConnector connector = connectorFactory.create(device, properties);
                connector.addListener(new ConnectionConnector.ConnectorListener<BluetoothSocket>() {
                    @Override
                    public void success(BluetoothSocket bluetoothSocket) {
                        // Remove from connecting and add to connected
                        mConnecting.remove(address);

                        try {
                            // Create the appropriate Connection type and add it to the connected list
                            DeviceConnectionFactory connectionFactory = mConnectionFactories.get(connectionType);
                            DeviceConnection connection = connectionFactory.create(bluetoothSocket, properties);
                            connection.onDisconnect(onDisconnect);
                            mConnections.put(address, connection);

                            // Now start the connection and let React Native know
                            new Thread(connection).start();
                            promise.resolve(nativeDevice.map());
                        } catch (IOException e) {
                            promise.reject(new ConnectionFailedException(nativeDevice, e));
                        }
                    }

                    @Override
                    public void failure(Exception e) {
                        // Remove from connecting and notify of failure
                        mConnecting.remove(address);
                        promise.reject(new ConnectionFailedException(nativeDevice, e));
                    }
                });

                mConnecting.put(address, connector);
                connector.start();
            } catch (IOException e) {
                promise.reject(new ConnectionFailedException(nativeDevice, e));
            } catch (IllegalStateException e) {
                promise.reject(e);
            }
        }
    }

    /**
     * Disconnect the BluetoothService from the currently connected device.
     *
     * @param address address of the device from which we disconnect
     * @param promise resolve or reject the disconnect request
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void disconnectFromDevice(String address, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else if (!mConnections.containsKey(address)) {
            promise.reject(Exceptions.NOT_CURRENTLY_CONNECTED.name(),
                    Exceptions.NOT_CURRENTLY_CONNECTED.message(address));
        } else {
            DeviceConnection connection = mConnections.remove(address);
            connection.disconnect();

            promise.resolve(true);
        }
    }

    /**
     * Check to see whether the requested device has a currently established
     * connection. Note that
     * this is NOT paired, the connection is specific to an RFCOMM socket being
     * open.
     *
     * @param address the address of the device which is being queried
     * @param promise resolved with the connected status
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void isDeviceConnected(String address, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else {
            promise.resolve(mConnections.containsKey(address));
        }
    }

    /**
     * Attempt to get the connection representing the device address requested.
     *
     * @param address the address of the device which is being queried
     * @param promise resolved with the connected status
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void getConnectedDevice(String address, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else {
            if (!mConnections.containsKey(address)) {
                promise.reject(new BluetoothException(address + " is not currently connected"));
            } else {
                DeviceConnection connection = mConnections.get(address);
                promise.resolve(new NativeDevice(connection.getDevice()).map());
            }
        }
    }

    /**
     * Attempts to write to the device. I'm not sure if there is a better way to do
     * this, but all
     * communication needs to come through the module. It would be awesome if we
     * could
     * dynamically add a BluetoothConnectionModuleXXXX to the Application in order
     * to allow
     * each device to be it's own module for communication.
     *
     * @param address address of the device to which we will write the data
     * @param message base64 encoded message to be sent
     * @param promise resolved once the message has been written.
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void writeToDevice(String address, String message, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else if (!mConnections.containsKey(address)) {
            promise.reject(Exceptions.NOT_CURRENTLY_CONNECTED.name(),
                    Exceptions.NOT_CURRENTLY_CONNECTED.message(address));
        } else {
            // Decode the Base64 value provided by React Native module. At this point it
            // gets
            // left in it's raw byte[] and it's up to the DeviceConnection to handle/write
            // accordingly
            byte[] data = Base64.decode(message, Base64.DEFAULT);

            try {
                mConnections.get(address).write(data);
                promise.resolve(true);
            } catch (IOException e) {
                promise.reject(Exceptions.WRITE_FAILED.name(),
                        Exceptions.WRITE_FAILED.message(e.getMessage()));
            }
        }
    }

    /**
     * Attempts to read from the device. The full buffer is read (then cleared)
     * without using the
     * mDelimiter. Note - there will never be data within the buffer if the
     * application is currently
     * registered to receive read events.
     * <p>
     * Might be configurable to reject when there is no data, instead of resolve
     * null.
     *
     * @param address device address to which we wish to read
     * @param promise resolves with data, could be null or 0 length
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void readFromDevice(String address, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else if (!mConnections.containsKey(address)) {
            promise.reject(Exceptions.NOT_CURRENTLY_CONNECTED.name(),
                    Exceptions.NOT_CURRENTLY_CONNECTED.message(address));
        } else {
            String message = mConnections.get(address).read();
            promise.resolve(message);
        }
    }

    /**
     * Clears the buffer.
     *
     * @param address the address of the device whose buffer is to be cleared
     * @param promise resolves true
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void clearFromDevice(String address, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else if (!mConnections.containsKey(address)) {
            promise.reject(Exceptions.NOT_CURRENTLY_CONNECTED.name(),
                    Exceptions.NOT_CURRENTLY_CONNECTED.message(address));
        } else {
            mConnections.get(address).clear();
            promise.resolve(true);
        }
    }

    /**
     * Gets the available information within the buffer. There is no concept of the
     * delimiter in
     * this request - which may need to be changed - since in most cases I can see a
     * full message
     * needing to be available.
     *
     * @param address device address for which the client wishes to read
     * @param promise resolves with length of buffer, could be 0
     */
    @ReactMethod
    @SuppressWarnings("unused")
    public void availableFromDevice(String address, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else if (!mConnections.containsKey(address)) {
            promise.reject(Exceptions.NOT_CURRENTLY_CONNECTED.name(),
                    Exceptions.NOT_CURRENTLY_CONNECTED.message(address));
        } else {
            promise.resolve(mConnections.get(address).available());
        }
    }

    /**
     * Attempts to set the BluetoothAdapter name.
     *
     * @param newName the name to which the mAdapter will be changed
     * @param promise resolves true
     * @deprecated unsure if this is really required from the application. Not a fan
     *             of having
     *             extra functionality in here that may never get called and isn't
     *             available on IOS
     */
    @Deprecated
    @ReactMethod
    @SuppressWarnings("unused")
    public void setBluetoothAdapterName(String newName, Promise promise) {
        if (!checkBluetoothAdapter()) {
            promise.reject(Exceptions.BLUETOOTH_NOT_ENABLED.name(),
                    Exceptions.BLUETOOTH_NOT_ENABLED.message());
        } else {
            mAdapter.setName(newName);
            promise.resolve(true);
        }
    }

    /**
     * Registers the module wide {@link BroadcastReceiver}(s). These include:
     * <ul>
     * <li>{@link BluetoothAdapter} state changes</li>
     * <li>{@link BluetoothDevice} ACL action changes - these include device
     * connections and
     * disconnections</li>
     * </ul>
     */
    private void registerBluetoothReceivers() {
        if (mStateChangeReceiver == null) {
            mStateChangeReceiver = new StateChangeReceiver(this);
            getReactApplicationContext()
                    .registerReceiver(mStateChangeReceiver, StateChangeReceiver.intentFilter());
        }

        if (mActionACLReceiver == null) {
            mActionACLReceiver = new ActionACLReceiver(this);
            getReactApplicationContext()
                    .registerReceiver(mActionACLReceiver, ActionACLReceiver.intentFilter());
        }
    }

    /**
     * Unregister receivers. "Global" receivers need to always be removed, they are
     * not configured
     * to be removed on their own.
     */
    private void unregisterBluetoothReceivers() {
        if (mStateChangeReceiver != null) {
            getReactApplicationContext().unregisterReceiver(mStateChangeReceiver);
            mStateChangeReceiver = null;
        }

        if (mActionACLReceiver != null) {
            getReactApplicationContext().unregisterReceiver(mActionACLReceiver);
            mActionACLReceiver = null;
        }

        if (mDiscoveryReceiver != null) {
            if (mAdapter != null && hasBluetoothScanPermission()) {
                try {
                    mAdapter.cancelDiscovery();
                } catch (SecurityException e) {
                    Log.e(TAG, "Failed to cancel discovery due to missing permission: " + e.getMessage());
                }
            }
            getReactApplicationContext().unregisterReceiver(mDiscoveryReceiver);
            mDiscoveryReceiver = null;
        }
    }

    private BiConsumer<BluetoothDevice, Exception> onDisconnect = (BluetoothDevice device, Exception e) -> {
        Log.d(TAG, String.format("Disconnected from device %s due to %s", device.getName(), e.getMessage()));

        // At this point just remove the connection, the DEVICE_DISCONNECTED should have
        // been
        // sent from the ACL message already.
        mConnections.remove(device.getAddress());
        sendEvent(EventType.DEVICE_DISCONNECTED, new NativeDevice(device),
                new BluetoothException(e.getMessage()).map());
    };

    private BiConsumer<BluetoothDevice, String> onReceivedData = (BluetoothDevice device, String data) -> {
        Log.d(TAG, String.format("Received translated data from the device: %s", data));

        NativeDevice nativeDevice = new NativeDevice(device);
        BluetoothMessage bluetoothMessage = new BluetoothMessage<>(nativeDevice.map(), data);
        sendEvent(EventType.DEVICE_READ, nativeDevice, bluetoothMessage.asMap());
    };

    /**
     * Adds a new listener for the {@link EventType} provided.
     * <p>
     * Listeners can be provided with or without a device context. A device context
     * is applied
     * by sending the event name followed by a device's address. An example of this
     * would be
     * {@code READ@12:34:56:78:90}.
     *
     * @param requestedEvent {@link EventType} name for which the client wishes to
     *                       listen
     */
    @ReactMethod
    @SuppressWarnings({ "unused" })
    public void addListener(String requestedEvent) {
        String eventType = requestedEvent,
                eventDevice = null;

        if (requestedEvent.contains("@")) {
            String[] context = requestedEvent.split("@");
            eventType = context[0];
            eventDevice = context[1];
        }

        if (!EventType.eventNames().hasKey(eventType)) {
            throw new InvalidBluetoothEventException(requestedEvent);
        }

        EventType event = EventType.valueOf(eventType);

        if (EventType.DEVICE_READ == event) {
            if (!mConnections.containsKey(eventDevice)) {
                throw new IllegalStateException(
                        String.format("Cannot read from %s, not currently connected", requestedEvent));
            }

            DeviceConnection connection = mConnections.get(eventDevice);
            connection.onDataReceived(onReceivedData);
        }

        // Now we can increment the listener as appropriate
        AtomicInteger listenerCount = mListenerCounts.get(requestedEvent);
        if (listenerCount == null) {
            listenerCount = new AtomicInteger(0);
            if (mListenerCounts.containsKey(requestedEvent))
                mListenerCounts.put(requestedEvent, listenerCount);
        }
        int currentCount = listenerCount.incrementAndGet();

        Log.d(TAG, String.format("Adding listener to %s, currently have %d listeners",
                requestedEvent, currentCount));
    }

    /**
     * Removes the specified {@link EventType}. If this is a
     * {@link EventType#DEVICE_READ}
     * the device address must be supplied (separated by an @) in the same way as
     * when the
     * listener was applied.
     *
     * @param requestedEvent name of the {@link EventType} for which the client
     *                       wishes to remove
     *                       listener.
     */
    @ReactMethod
    @SuppressWarnings({ "unused" })
    public void removeListener(String requestedEvent) {
        String eventType = requestedEvent,
                eventDevice = null;

        if (requestedEvent.contains("@")) {
            String[] context = requestedEvent.split("@");
            eventType = context[0];
            eventDevice = context[1];
        }

        if (!EventType.eventNames().hasKey(eventType)) {
            return;
        }

        EventType event = EventType.valueOf(eventType);

        if (EventType.DEVICE_READ == event && mConnections.containsKey(eventDevice)) {
            // #139 Originally if there was no current connection (ie. the device had
            // already been disconnected) this would
            // throw an exception. At this point we don't really care, but if the connection
            // does exist we need to
            // remove it and clear it.
            DeviceConnection connection = mConnections.get(eventDevice);
            connection.clearOnDataReceived();
        }

        // Only remove the listener if it currently exists. If you're attemping to
        // remove a listener
        // which hasn't been added, just let it go.
        if (mListenerCounts.containsKey(eventType)) {
            AtomicInteger listenerCount = mListenerCounts.get(eventType);
            int currentCount = listenerCount.decrementAndGet();

            Log.d(TAG,
                    String.format("Removing listener to %s, currently have %d listeners",
                            eventType, currentCount));
        }
    }

    /**
     * Remove all the listeners for the provided eventName. Removing all listeners
     * also has a
     * context (prefixed with @) which will remove all the listeners for that
     * specified device.
     *
     * @param requestedEvent for which all listeners will be removed
     */
    @ReactMethod
    @SuppressWarnings({ "unused" })
    public void removeAllListeners(String requestedEvent) {
        String eventType = requestedEvent,
                eventDevice = null;

        if (requestedEvent.contains("@")) {
            String[] context = requestedEvent.split("@");
            eventType = context[0];
            eventDevice = context[1];
        }

        if (!EventType.eventNames().hasKey(eventType)) {
            return;
        }

        EventType event = EventType.valueOf(eventType);

        if (EventType.DEVICE_READ == event) {
            if (!mConnections.containsKey(eventDevice)) {
                throw new IllegalStateException(
                        String.format("Cannot read from %s, not currently connected", eventType));
            }

            DeviceConnection connection = mConnections.get(eventDevice);
            connection.clearOnDataReceived();
        }

        // Only remove the listener if it currently exists. If you're attemping to
        // remove a listener
        // which hasn't been added, just let it go.
        if (mListenerCounts.containsKey(eventType)) {
            AtomicInteger listenerCount = mListenerCounts.get(eventType);
            listenerCount.set(0);

            Log.d(TAG,
                    String.format("Removing listener to %s, currently have %d listeners",
                            eventType, 0));
        }
    }
    // endregion

    /**
     * Called from the {@link StateChangeReceiver} when the {@link BluetoothAdapter}
     * state
     * is changed. Fires the appropriate event to any listeners.
     *
     * @param newState the new {@link BluetoothState}
     * @param oldState the previous {@link BluetoothState}
     */
    @Deprecated
    @Override
    public void onStateChange(BluetoothState newState, BluetoothState oldState) {
        Log.d(TAG, "onStateChange from " + oldState.name() + "  to " + newState.name());

        EventType event = (BluetoothState.ENABLED == newState)
                ? EventType.BLUETOOTH_ENABLED
                : EventType.BLUETOOTH_DISABLED;

        sendEvent(event, new BluetoothStateEvent(newState).map());
    }

    /**
     * Not sure whether this provides any actual info.
     *
     * @param device the {@link NativeDevice} which just requested disconnect
     *
     * @deprecated may be removed at some point unless it is found useful (opposed
     *             to ACLDisconnected)
     */
    @Deprecated
    @Override
    public void onACLDisconnectRequest(NativeDevice device) {
        Log.d(TAG, "onACLDisconnectRequest to " + device.getAddress());
    }

    /**
     * Called when we get an ACL level device disconnection. This happens when a
     * socket is closed,
     * for whatever reason. If there is a currently active connection (which there
     * should be)
     * it's disconnected, then a DEVICE_DISCONNECTED event is fired.
     * <p>
     * This also sends an event to the {@code DEVICE_DISCONNECTED@address} making
     * the Device more
     * responsible for it's own connectivity.
     *
     * @param device the {@link NativeDevice} which was just disconnected
     */
    @Override
    public void onACLDisconnected(NativeDevice device) {
        Log.d(TAG, "onACLDisconnected to " + device.getAddress());

        mConnections.remove(device.getAddress());
        BluetoothDeviceEvent event = new BluetoothDeviceEvent(EventType.DEVICE_DISCONNECTED, device);
        sendEvent(EventType.DEVICE_DISCONNECTED, event.map());
    }

    /**
     * Sends a {@link EventType} to the React Native JS module
     * {@link com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter}.
     * <p>
     * Currently having no active {@link com.facebook.react.bridge.CatalystInstance}
     * will not cause
     * the application to crash, although I'm not sure if it should.
     *
     * @param event the {@link EventType} being sent
     * @param body  the content of the event
     */
    private void sendEvent(EventType event, WritableMap body) {
        ReactContext context = getReactApplicationContext();

        if (context.hasActiveCatalystInstance()) {
            context
                    .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
                    .emit(event.name(), body);
        } else {
            Log.e(TAG, "There is currently no active Catalyst instance");
        }
    }

    /**
     * Sends a {@link EventType} to the React Native JS module
     * {@link com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter}.
     * This
     * version allows for sending events do a specific Device (providing the address
     * along side
     * the event name separated by a semi-colon).
     * <p>
     * This should generally only be used for {@link EventType#DEVICE_READ} events,
     * but nothing
     * stops it from providing other types.
     *
     * @param event  the {@link EventType} being sent to React Native JS
     * @param device the {@link NativeDevice} which caused/receiving the event
     * @param body   the event content
     */
    synchronized private void sendEvent(EventType event, NativeDevice device, WritableMap body) {
        ReactContext context = getReactApplicationContext();

        if (context.hasActiveCatalystInstance()) {
            context
                    .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
                    .emit(String.format("%s@%s", event.name(), device.getAddress()), body);
        } else {
            Log.e(TAG, "There is currently no active Catalyst instance");
        }
    }

}
