Xamarin.Forms cross-platform mobile app can advertise, can scan, cannot connect via Bluetooth APIs

DonR 26 Reputation points
2021-03-18T15:15:40.083+00:00

I am working on a Xamarin.Forms (version 4.8.0.1687) cross-platform app that needs to be able to talk to other instances of itself using Bluetooth LE. I am taking the shared interfaces/platform-specific implementations approach to Bluetooth because we plan to eventually expand the supported platforms to include some that are not all supported by any one of the well-known packages. The version I am on right now is really just a proof-of-concept, and I am not yet doing extensive error checking or handling of edge cases, so the code is still fairly simple.

I have been able to advertise on both platforms, and to see, via service UUID-filtered scan, other instances of the app that are advertising, from both platforms. But I cannot establish a connection from either. On Android, calling BluetoothSocket.Connect() consistently results in it throwing a Java.IO.IOException with the message read failed, socket might closed or timeout, read ret: -1 after a delay of several seconds. On iOS, calling CBCentralManager.ConnectPeripheral(CBPeripheral) never raises a ConnectedPeripheral event, never raises a FailedToConnectPeripheral event, and never throws an exception. If I call it in a loop waiting for the peripheral's State to change, the loop simply runs indefinitely.

The fact that I am hitting a snag at the same place on both platforms suggests to me that I'm misunderstanding something about the Bluetooth process rather than the code itself, which would not be terribly surprising since this is the first time I've done any Bluetooth API programming on any platform. But I'm just guessing. If anyone can give me a hint about where I've gone wrong with either API you'll have my heartfelt gratitude which, together with $4, will get you a cup of decent coffee.

Android code

The Android class that does the advertising (works):

public class Advertiser : IAdvertiser
{
    private AdvertiserCallback callback; // extends AdvertiseCallback

    public void Advertise(string serviceUuid, string serviceName)
    {
        AdvertiseSettings settings = new AdvertiseSettings.Builder().SetConnectable(true).Build();

        BluetoothAdapter.DefaultAdapter.SetName(serviceName);
        ParcelUuid parcelUuid = new ParcelUuid(UUID.FromString(serviceUuid));
        AdvertiseData data = new AdvertiseData.Builder().AddServiceUuid(parcelUuid).SetIncludeDeviceName(true).Build();

        this.callback = new AdvertiserCallback();
        BluetoothAdapter.DefaultAdapter.BluetoothLeAdvertiser.StartAdvertising(settings, data, callback);

        BluetoothGattDescriptor descriptor = new BluetoothGattDescriptor
        (
            UUID.FromString(BluetoothConstants.DESCRIPTOR_UUID)
            ,GattDescriptorPermission.Read | GattDescriptorPermission.Write
        );
        BluetoothGattCharacteristic characteristic = new BluetoothGattCharacteristic
        (
            UUID.FromString(BluetoothConstants.CHARACTERISTIC_UUID)
            ,GattProperty.Read | GattProperty.Write
            ,GattPermission.Read | GattPermission.Write
        );
        characteristic.AddDescriptor(descriptor);
        BluetoothGattService service = new BluetoothGattService
        (
            UUID.FromString(BluetoothConstants.SERVICE_UUID)
            ,GattServiceType.Primary
        );
        service.AddCharacteristic(characteristic);

        BluetoothManager manager = (BluetoothManager)Android.App.Application.Context.GetSystemService(Context.BluetoothService);
        BluetoothGattServer server = manager.OpenGattServer(Android.App.Application.Context, new GattServerCallback());
        server.AddService(service);
    }

    public void StopAdvertising()
    {
        if (null != this.callback)
        {
            BluetoothAdapter.DefaultAdapter.BluetoothLeAdvertiser.StopAdvertising(this.callback);
        }
    }
}

The Android class that does the scanning (works):

public class DeviceScanner : IPeripheralScanner
{
    public async Task<List<IPeripheral>> ScanForService(string serviceUuid) // IPeripheral wraps CBPeripheral or BluetoothDevice
    {
        return await this.ScanForService(serviceUuid, BluetoothConstants.DEFAULT_SCAN_TIMEOUT);
    }

    public async Task<List<IPeripheral>> ScanForService(string serviceUuid, int duration)
    {
        List<ScanFilter> filters = new List<ScanFilter>();
        filters.Add(new ScanFilter.Builder().SetServiceUuid(new ParcelUuid(UUID.FromString(BluetoothConstants.SERVICE_UUID))).Build());

        ScannerCallback callback = new ScannerCallback(); // extends ScanCallback

        BluetoothLeScanner scanner = BluetoothAdapter.DefaultAdapter.BluetoothLeScanner;

        Devices.Instance.DeviceList.Clear(); // Devices is a singleton for passing a List of found IPeripherals across threads

        scanner.StartScan(filters, new ScanSettings.Builder().Build(), callback);
        await Task.Delay(duration);
        scanner.StopScan(callback);

        return Devices.Instance.DeviceList;
    }
}

The Android class that does the connecting (never works):

public class DeviceConnector : IPeripheralConnector
{
    public void Connect(IPeripheral peripheral)
    {
        BluetoothDevice device = (BluetoothDevice)peripheral.Peripheral;
        if (device.BondState != Bond.Bonded)
        {
            device.CreateBond();
        }

        BluetoothAdapter.DefaultAdapter.CancelDiscovery();
        BluetoothSocket socket;
        try
        {
            socket = device.CreateRfcommSocketToServiceRecord(UUID.FromString(BluetoothConstants.SERVICE_UUID));
            if (null == socket)
            {
                throw new System.Exception("Failed to create socket");
            }
            socket.Connect(); // IOException is consistently thrown here after several seconds
        }
        catch (IOException x)
        {
            Method method = device.Class.GetMethod("createRfcommSocket", Integer.Type);
            socket = (BluetoothSocket)method.Invoke(device, 1);
            if (null == socket)
            {
                throw new System.Exception("Failed to create socket");
            }
            socket.Connect(); // IOException is consistently thrown here after several seconds
        }
        if (false == socket.IsConnected)
        {
            throw new System.Exception(string.Format("Failed to connect to service {0}", device.Name));
        }
    }
}

iOS Code

The iOS class that does the advertising (works):

public class Advertiser : IAdvertiser
{
    private readonly CBPeripheralManager manager;

    public Advertiser()
    {
        this.manager = new CBPeripheralManager();
        this.manager.StateUpdated += this.StateUpdated;
    }

    public async void Advertise(string serviceUuid, string serviceName)
    {
        // The state needs to be polled in separate threads because the update event is being raised
        // and handled in a different thread than this code; we'll never see the update directly
        // PeripheralManagerState is a singleton for tracking the state between classes and threads
        while (false == await Task.Run(() => PeripheralManagerState.Instance.IsPoweredOn)) { }

        CBUUID svcUuid = CBUUID.FromString(serviceUuid);

        CBMutableCharacteristic characteristic = new CBMutableCharacteristic
        (
            CBUUID.FromString(BluetoothConstants.CHARACTERISTIC_UUID)
            ,CBCharacteristicProperties.Read | CBCharacteristicProperties.Write
            ,null
            ,CBAttributePermissions.Readable | CBAttributePermissions.Writeable
        );
        CBMutableService vitlService = new CBMutableService(svcUuid, true);
        vitlService.Characteristics = new[] { characteristic };

        StartAdvertisingOptions options = new StartAdvertisingOptions();
        options.ServicesUUID = new[] { svcUuid };
        options.LocalName = serviceName;

        this.manager.AddService(vitlService);
        this.manager.StartAdvertising(options);
    }

    public void StopAdvertising()
    {
        if (this.manager.Advertising)
        {
            this.manager.StopAdvertising();
        }
    }

    internal void StateUpdated(object sender, EventArgs args)
    {
        CBPeripheralManagerState state = ((CBPeripheralManager)sender).State;
        if (CBPeripheralManagerState.PoweredOn == state)
        {
            PeripheralManagerState.Instance.IsPoweredOn = true;
        }
        else
        {
            throw new Exception(state.ToString());
        }
    }
}

The iOS class that does the scanning (works):

public class PeripheralScanner : IPeripheralScanner
{
    private readonly CBCentralManager manager;
    private List<IPeripheral> foundPeripherals; // IPeripheral wraps CBPeripheral or BluetoothDevice

    public PeripheralScanner()
    {
        this.foundPeripherals = new List<IPeripheral>();

        this.manager = new CBCentralManager();
        this.manager.DiscoveredPeripheral += this.discoveredPeripheral;
        this.manager.UpdatedState += this.updatedState;
    }

    public async Task<List<IPeripheral>> ScanForService(string serviceUuid)
    {
        return await this.ScanForService(serviceUuid, BluetoothConstants.DEFAULT_SCAN_TIMEOUT);
    }

    public async Task<List<IPeripheral>> ScanForService(string serviceUuid, int duration)
    {
        // The state needs to be polled in separate threads because the update event is being raised
        // and handled in a different thread than this code; we'll never see the update directly
        // CentralManagerState is a singleton for tracking the state between classes and threads
        while (false == await Task.Run(() => CentralManagerState.Instance.IsPoweredOn)) { }

        if (this.manager.IsScanning)
        {
            this.manager.StopScan();
        }
        this.manager.ScanForPeripherals(CBUUID.FromString(serviceUuid));
        await Task.Delay(duration);
        this.manager.StopScan();

        return this.foundPeripherals;
    }

    private void discoveredPeripheral(object sender, CBDiscoveredPeripheralEventArgs args)
    {
        CBPeripheral cbperipheral = args.Peripheral;
        bool isDiscovered = false;
        foreach (IPeripheral peripheral in this.foundPeripherals)
        {
            if (((CBPeripheral)peripheral.Peripheral).Identifier == cbperipheral.Identifier)
            {
                isDiscovered = true;
                break;
            }
        }
        if (false == isDiscovered)
        {
            this.foundPeripherals.Add(new CPeripheral(cbperipheral));
        }
    }

    private void updatedState(object sender, EventArgs args)
    {
        CBCentralManagerState state = ((CBCentralManager)sender).State;
        if (CBCentralManagerState.PoweredOn == state)
        {
            CentralManagerState.Instance.IsPoweredOn = true;
        }
        else
        {
            throw new Exception(state.ToString());
        }
    }
}

The iOS class that does the connecting (never works):

public class PeripheralConnector : IPeripheralConnector
{
    private readonly CBCentralManager manager;
    private CBPeripheral peripheral;

    public PeripheralConnector()
    {
        this.manager = new CBCentralManager();
        this.manager.ConnectedPeripheral += this.connectedPeripheral;
        this.manager.FailedToConnectPeripheral += this.failedToConnectPeripheral;
    }

    public void Connect(IPeripheral peripheral)
    {
        this.peripheral = (CBPeripheral)peripheral.Peripheral;
        while (CBPeripheralState.Connected != this.peripheral.State) // this loop runs until I kill the app
        {
            this.manager.ConnectPeripheral(this.peripheral);
        }
    }

    private void connectedPeripheral(object sender, CBPeripheralEventArgs args)
    {
        throw new Exception(args.Peripheral.Name);
    }

    private void failedToConnectPeripheral(object sender, CBPeripheralErrorEventArgs args)
    {
        throw new Exception(args.Error.LocalizedFailureReason);
    }
}
Developer technologies | .NET | Xamarin
0 comments No comments
{count} votes

1 answer

Sort by: Most helpful
  1. DonR 26 Reputation points
    2021-03-30T14:36:02.077+00:00

    In the Android case, it turns out my connection code was calling a method that is part of Bluetooth Classic, not BLE. To make a connection to a BLE Device call ConnectGatt() on the BluetoothDevice instance:

    public class DeviceConnector : IPeripheralConnector 
    { 
        private GattCallback callback; // GattCallback inherits from BluetoothGattCallback 
        private bool isConnected = false; 
        public bool IsConnected { get { return this.isConnected; } } 
    
        public void Connect(IPeripheral peripheral) 
        { 
            BluetoothDevice device = (BluetoothDevice)peripheral.Peripheral; 
            if (device.BondState != Bond.Bonded) 
            { 
                device.CreateBond(); 
            } 
    
            BluetoothAdapter.DefaultAdapter.CancelDiscovery(); 
            this.callback = new GattCallback(); 
            device.ConnectGatt(Android.App.Application.Context, true, this.callback); 
            while (false == this.callback.IsConnected) { } 
            this.isConnected = this.callback.IsConnected; 
        } 
    } 
    

    In the Apple case, I was stumped for a week. I spent days reading doc and working through tutorials (Swift is a very nice language, btw), and eventually realized what I was doing wrong was keeping the CBPeripheral instance representing the found peripheral, but not necessarily the CBCentralManager instance that discovered it. In iOS you must call ConnectPeripheral() on the same instance of CBCentralManager that scanned and discovered the peripheral. I refactored the shared project in a way that permitted preserving the instance, and now I can connect from either OS to either OS.

    1 person found this answer helpful.
    0 comments No comments

Your answer

Answers can be marked as Accepted Answers by the question author, which helps users to know the answer solved the author's problem.