react-native-bluetooth-classic icon indicating copy to clipboard operation
react-native-bluetooth-classic copied to clipboard

[Help needed for iOS] A2DP support, currently only Android.

Open jasonrichdarmawan opened this issue 4 years ago • 4 comments

After some considerations:

  1. I have limited knowledge of reading a Android or iOS Native module. So, it's a bit hard to add A2DP support directly to this library.
  2. My barrier to market require Android 4.1 support, although the MVP does not require one.

I decided to write an Android Native Module for BluetoothProfile.SPP and BluetoothProfile.A2DP. I hope this helps the development for this library.

Mobile Device Environment

  • Device: Android 4.1
  • OS: API 16

Is your feature request related to a problem? Please describe. My goal is to make a Walkie Talkie app with Bluetooth Microphone. BluetoothProfile.SPP will emit event to the React Native Bridge while BluetoothProfile.A2DP will call AudioManager.startBluetoothSco().

Describe the solution you'd like I would like to use this library with react-native-recording. The steps:

  1. This library connects to a Bluetooth Microphone.
  2. This library listens to BluetoothSocket.getInputStream(), this library emits the event to the React Native bridge.
  3. The React Native bridge calls the AudioManager.startBluetoothSco() to start recording audio from the Bluetooth Microphone.
  4. react-native-recording will start getting input from the Audio Manager.

Additional context This is only for Android Native Module, I hope this can help.

Know bugs to fix:

  • [ ] The AudioManager refuse to record audio from the Bluetooth Microphone if the app went to the Background Mode after 2 minutes.

RNBluetoothNMPackage.java

package com.satpam.RNBluetoothNM;

import android.app.Activity;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothProfile;
import android.content.Intent;
import android.util.Log;

import androidx.annotation.NonNull;

import com.facebook.react.bridge.ActivityEventListener;
import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.BaseActivityEventListener;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableArray;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.satpam.RNBluetoothNM.A2DP.A2DPService;
import com.satpam.RNBluetoothNM.SPP.SPPService;

import java.util.Set;

public class RNBluetoothNMPackage extends ReactContextBaseJavaModule {

    @NonNull
    @Override
    public String getName() {
        return "RNBluetoothNM";
    }

    private static final int getBondedDeviceRequestCode = 0;
    private Promise mPromise;

    private final ActivityEventListener activityListener = new BaseActivityEventListener() {

        @Override
        public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) {
            super.onActivityResult(activity, requestCode, resultCode, data);
            if (requestCode == getBondedDeviceRequestCode && resultCode == activity.RESULT_OK) {
                getBondedDevices(mPromise);
                mPromise = null;
            }
        }
    };

    public RNBluetoothNMPackage(ReactApplicationContext reactContext) {
        super(reactContext);

        // Add a listener for `onActivityResult`
        reactContext.addActivityEventListener(activityListener);
    }

    private void _requestEnableBluetooth(Promise promise) {
        Intent enableAdapter = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);
        Activity activity = getCurrentActivity();

        // Store the promise to resolve/reject when onActivityResult returns value;
        mPromise = promise;
        activity.startActivityForResult(enableAdapter, getBondedDeviceRequestCode);
    }

    @ReactMethod
    public void getBondedDevices(final Promise promise) {
        BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();

        if (bluetoothAdapter == null) {
            promise.reject("getBondedDevices", "BluetoothAdapter is not supported");
        } else {
            if (!bluetoothAdapter.isEnabled()) {
                _requestEnableBluetooth(promise);
            }

            Set<BluetoothDevice> devices = bluetoothAdapter.getBondedDevices();
            if (devices.size() > 0) {
                WritableArray array = Arguments.createArray();
                for (BluetoothDevice device : devices) {
                    WritableMap writableMap = Arguments.createMap();
                    writableMap.putString("name", device.getName());
                    writableMap.putString("address", device.getAddress());
                    writableMap.putInt("bondState", device.getBondState());

                    array.pushMap(writableMap);
                }

                promise.resolve(array);
            }
        }
    }

    private A2DPService a2DPService;
    private SPPService sppService;

    @ReactMethod
    public void connectDevice(String address, final Promise promise) {
        BluetoothAdapter bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
        if (bluetoothAdapter == null) {
            promise.reject("connectDevice", "BluetoothAdapter is not supported");
        } else {
            if (!bluetoothAdapter.isEnabled()) {
                _requestEnableBluetooth(promise);
            }

            BluetoothDevice bluetoothDevice = bluetoothAdapter.getRemoteDevice(address);
            a2DPService = new A2DPService(getReactApplicationContext());
            boolean a2dpIsBonded = a2DPService.createBond(bluetoothDevice);

            if (a2dpIsBonded == false) {
                promise.reject("A2DPService", "A2DP failed to bond");
            } else {
                boolean a2dpIsConnected = a2DPService.connectA2DP(bluetoothDevice);
                if (a2dpIsConnected == false) {
                    promise.reject("A2DP", "A2DP failed to connect");
                } else {

                    sppService = new SPPService(getReactApplicationContext());
                    boolean sppIsConnected = sppService.connectSPP(bluetoothDevice);
                    if (sppIsConnected == false) {
                        promise.reject("SPPService", "A2DP failed to bond");
                    } else {
                        promise.resolve(true);
                        Thread listener = new Thread(sppService);
                        listener.start();
                    }
                }
            }
        }
    }


    @ReactMethod
    public void startBluetoothSco(final Promise promise) {
        if (a2DPService == null) {
            promise.reject("startBluetoothSco", "A2DPService is null");
        } else {
            boolean isBluetoothScoStarted = a2DPService.startBluetoothSco();
            if (isBluetoothScoStarted == false) {
                promise.reject("startBluetoothSco", "A2DPService is not connected");
            } else {
                promise.resolve(true);
            }
        }
    }

    @ReactMethod
    public void stopBluetoothSco(final Promise promise) {
        if (a2DPService == null) {
            promise.reject("stopBluetoothSco", "A2DPService is null");
        } else {
            boolean isBluetoothScoStopped = a2DPService.stopBluetoothSco();
            if (isBluetoothScoStopped == false) {
                promise.reject("stopBluetoothSco", "A2DPService is not connected");
            } else {
                promise.resolve(true);
            }
        }
    }
}

A2DPService

package com.satpam.RNBluetoothNM.A2DP;

import android.bluetooth.BluetoothA2dp;
import android.bluetooth.BluetoothAdapter;
import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothProfile;
import android.content.Context;
import android.media.AudioManager;

import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class A2DPService {
    private BluetoothDevice mBluetoothDevice;
    private BluetoothA2dp mBluetoothA2dp;
    private AudioManager mAudioManager;

    public A2DPService(ReactApplicationContext reactContext) {
        mAudioManager = (AudioManager) reactContext.getSystemService(Context.AUDIO_SERVICE);

        BluetoothAdapter
                .getDefaultAdapter()
                .getProfileProxy(reactContext, new BluetoothProfile.ServiceListener() {
                    @Override
                    public void onServiceConnected(int i, BluetoothProfile bluetoothProfile) {
                        if (i == BluetoothProfile.A2DP) {
                            mBluetoothA2dp = (BluetoothA2dp) bluetoothProfile;
                        }
                    }

                    @Override
                    public void onServiceDisconnected(int i) {
                        if (i == BluetoothProfile.A2DP) {
                            mBluetoothA2dp = null;
                        }
                    }
                }, BluetoothProfile.A2DP);
    }

    public boolean createBond(BluetoothDevice bluetoothDevice) {
        mBluetoothDevice = bluetoothDevice;
        return mBluetoothDevice.createBond();
    }

    public boolean connectA2DP(BluetoothDevice bluetoothDevice) {
        if (mBluetoothDevice == null) {
            return false;
        } else {
            try {
                Method method = BluetoothA2dp.class.getMethod("connect", BluetoothDevice.class);
                method.invoke(mBluetoothA2dp, bluetoothDevice);
                return true;
            } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
                return false;
            }
        }
    }

    public int getConnectedState() {
        return mBluetoothA2dp.getConnectionState(mBluetoothDevice);
    }

    public boolean startBluetoothSco() {
        if (getConnectedState() != BluetoothProfile.STATE_CONNECTED) {
            return false;
        } else {
            mAudioManager.setBluetoothScoOn(true);
            mAudioManager.startBluetoothSco();
            return true;
        }
    }

    public boolean stopBluetoothSco() {
        if (getConnectedState() != BluetoothProfile.STATE_CONNECTED) {
            return false;
        } else {
            mAudioManager.setBluetoothScoOn(false);
            mAudioManager.stopBluetoothSco();
            return true;
        }
    }
}

SPPService.java

package com.satpam.RNBluetoothNM.SPP;

import android.bluetooth.BluetoothDevice;
import android.bluetooth.BluetoothSocket;
import android.util.Log;

import com.facebook.react.bridge.Arguments;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.modules.core.DeviceEventManagerModule;

import java.io.IOException;
import java.util.Arrays;
import java.util.UUID;

public class SPPService implements Runnable {
    private static final UUID BLUETOOTH_SPP = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB");

    private ReactApplicationContext mReactContext;

    public SPPService(ReactApplicationContext reactContext) {
        mReactContext = reactContext;
    }

    private BluetoothSocket bluetoothSocket;

    public boolean connectSPP(BluetoothDevice bluetoothDevice) {
        try {
            bluetoothSocket = bluetoothDevice.createRfcommSocketToServiceRecord(BLUETOOTH_SPP);
            bluetoothSocket.connect();
            return bluetoothSocket.isConnected();
        } catch (IOException e) {
            return false;
        }
    }

    @Override
    public void run() {
        byte[] buffer = new byte[1024];
        int len;

        while (bluetoothSocket.isConnected()) {
            try {
                len = bluetoothSocket.getInputStream().read(buffer);
                byte[] data = Arrays.copyOf(buffer, len);
                if (len > 0) {
                    mReactContext
                            .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
                            .emit("SPPService", new String(data));
                }

            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

jasonrichdarmawan avatar Dec 18 '20 16:12 jasonrichdarmawan

Hey, this looks good, but I don't think there is anything I need to add in the library. The current library gives you the ability to:

  • Connect to SPP
  • Listen to SPP
  • Disconnect from SPP

which you're essentially replicating in your version. You also have the A2DP service which you're using to control the AudoManager. I don't see any real reason why you couldn't continue to use this library for your SPP management, and use your own custom library solely for the A2DP/AudioManager logic? Unless I'm missing something, the only reason I see to merge them is just so you can use the same nativeModules.BluetoothNMModule in order to communicate.

All this should be possible by implementing your own custom ConnectionConnector and DeviceConnection classes.

kenjdavidson avatar Dec 18 '20 16:12 kenjdavidson

This will probably never work on IOS using Bluetooth Classic, as the framework locks down all audio communication by the operating system. You'd also need to work with the Bluetooth vendor to get the MFi codes, which is an extremely annoying/expensive process.

In no way do I expect this library to be the be-all and end-all to Bluetooth communication, but in my opinion for the lengths that you're using it and your requirements, you should definitely be using a 100% custom module - specialized to your process.

kenjdavidson avatar Dec 18 '20 16:12 kenjdavidson

I get your point. Thank you for the earlier explanation regarding Bluetooth on different issue thread. It helps me to write my first Android Native Module.

I will start writing a custom module for the Bluetooth and audio recorder & player.

Do you think we should close this issue or leave it open for someone else looking for A2DP support?

jasonrichdarmawan avatar Dec 18 '20 17:12 jasonrichdarmawan

Feel free to leave it open.

I honestly love the idea of helping you get this project completed, and will do my best to answer any questions / provide any feedback that I can. Some of the stuff in here I see positives to, I just need to figure out the best way to manage the additions/changes.

At this point I feel like creating a new react-native-bluetooth-profiles project would be better suited for this. From what I've read there is no reason why the two cannot work together (from your sample code) it's definitely possible. For example, in your sample code you:

connectToDevice

First you do this

BluetoothDevice bluetoothDevice = bluetoothAdapter.getRemoteDevice(address);
            a2DPService = new A2DPService(getReactApplicationContext());
            boolean a2dpIsBonded = a2DPService.createBond(bluetoothDevice)

which is an async action, and would just returns true if the process begun, and not actually if the device was paired. Therefore it's possible you call the connect method before it's even paired. These two things should be separated in my mind. For example, you should only try connecting if the device is already paired/bonded.

Then you connect to SPP. Does the order matter? Does A2DP need to be connected before SPP in order for this to work? If not, this could be done just as easily (as mentioned) with a customized SPPAndA2dpDeviceConnection since they are managed together, and a connection is only valid when both of them are complete.

Anyhow, definitely keep posting details and questions.

kenjdavidson avatar Dec 18 '20 18:12 kenjdavidson