Skip to content

🐛 Device shown as "Connected" in Android Bluetooth Settings but API returns "not connected" #1288

@olalonde

Description

@olalonde

Prerequisites

  • I checked the documentation and FAQ without finding a solution
  • I checked to make sure that this issue has not already been filed

Expected Behavior

The library should be able to disconnect a connected device.

Current Behavior

I hit a weird bug where the device is shown as "Connected" in Android Bluetooth Settings but the API returns "not connected" and there seems to be no way to connect/disconnect the device from the library. Restarting the app doesn't fix the issue. The only solution seems to be to disable / re-enabling Bluetooth in Android settings. Any idea what might cause the OS to enter that state or what I can do to prevent/recover from it?

Library version

3.5.0

Device

Android Pixel 8 Pro , Anrdoid 15

Environment info

System:
  OS: macOS 15.3.1
  CPU: (8) arm64 Apple M1
  Memory: 116.64 MB / 16.00 GB
  Shell:
    version: "5.9"
    path: /bin/zsh
Binaries:
  Node:
    version: 23.9.0
    path: ~/.local/state/fnm_multishells/17502_1744143714559/bin/node
  Yarn: Not Found
  npm:
    version: 10.9.2
    path: ~/.local/state/fnm_multishells/17502_1744143714559/bin/npm
  Watchman:
    version: 2025.03.03.00
    path: /opt/homebrew/bin/watchman
Managers:
  CocoaPods:
    version: 1.16.2
    path: /opt/homebrew/bin/pod
SDKs:
  iOS SDK:
    Platforms:
      - DriverKit 24.4
      - iOS 18.4
      - macOS 15.4
      - tvOS 18.4
      - visionOS 2.4
      - watchOS 11.4
  Android SDK:
    API Levels:
      - "21"
      - "26"
      - "27"
      - "31"
      - "32"
      - "34"
      - "35"
    Build Tools:
      - 30.0.2
      - 30.0.3
      - 31.0.0
      - 34.0.0
      - 35.0.0
    System Images:
      - android-28 | Google ARM64-V8a Play ARM 64 v8a
      - android-31 | Google APIs ARM 64 v8a
      - android-34 | Google Play ARM 64 v8a
    Android NDK: 26.1.10909125
IDEs:
  Android Studio: 2024.1 AI-241.18034.62.2411.12071903
  Xcode:
    version: 16.3/16E140
    path: /usr/bin/xcodebuild
Languages:
  Java:
    version: 17.0.11
    path: /usr/bin/javac
  Ruby:
    version: 2.6.10
    path: /usr/bin/ruby
npmPackages:
  "@react-native-community/cli":
    installed: 18.0.0
    wanted: ^18.0.0
  react:
    installed: 18.3.1
    wanted: 18.3.1
  react-native:
    installed: 0.76.7
    wanted: 0.76.7
  react-native-macos: Not Found
npmGlobalPackages:
  "*react-native*": Not Found
Android:
  hermesEnabled: true
  newArchEnabled: true
iOS:
  hermesEnabled: false
  newArchEnabled: true

info React Native v0.79.0 is now available (your project is running on v0.76.7).
info Changelog: https://github.com/facebook/react-native/releases/tag/v0.79.0
info Diff: https://react-native-community.github.io/upgrade-helper/?from=0.76.7&to=0.79.0
info For more info, check out "https://reactnative.dev/docs/upgrading?os=macos".

Steps to reproduce

Difficult to reproduce, I'm not exactly sure how to voluntarily enter that state. It occasionally happens but not sure which conditions to trigger it. It seems to usually happen when I'm in a re-connection loop.

Formatted code sample or link to a repository

import ble from './bleManager';
import { wait } from './utils';
import { EventEmitter } from 'events';
import TypedEmitter from 'typed-emitter'
import { BleError, Device } from 'react-native-ble-plx';
import bleManager from './bleManager';

type ConnectionInfo = {
  rssi: number
}
export type ConnectionState = 'connecting' | 'connected' | 'disconnecting' | 'disconnected';

export type DisconnectReason = 'user' | 'timeout' | 'error' | 'unknown';

function deviceToConnectionInfo(device: Device): ConnectionInfo {
  // TODO: when is rssi null?
  if (!device.rssi === null) {
    console.error('device.rssi is null, this should not happen');
  }
  return {
    rssi: device.rssi ?? 0,
  };
}

export type ConnectionEvents = {
  error: (error: Error) => void,
  connecting: () => void,
  connected: (connectionInfo: ConnectionInfo) => void,
  disconnecting: (reason: DisconnectReason) => void,
  disconnected: (reason: DisconnectReason) => void,
  stateChanged: (newState: ConnectionState, oldState: ConnectionState) => void,
}

export class Connection extends (EventEmitter as new () => TypedEmitter<ConnectionEvents>) {
  deviceId: string;
  state: ConnectionState = 'disconnected';
  autoReconnect = true;

  isConnecting(): boolean {
    return this.state === 'connecting';
  }
  isConnected(): boolean {
    return this.state === 'connected';
  }

  isDisconnecting(): boolean {
    return this.state === 'disconnecting';
  }

  isDisconnected(): boolean {
    return this.state === 'disconnected';
  }

  isDisconnectable(): boolean {
    return this.isConnecting() || this.isConnected();
  }

  isConnectable(): boolean {
    return this.isDisconnected();
  }

  constructor(deviceId: string, autoReconnect = true) {
    super();
    this.deviceId = deviceId;
    this.autoReconnect = autoReconnect;
    bleManager.onDeviceDisconnected(deviceId, (error: BleError | null) => {
      // Expected disconnection 
      if (this.isDisconnecting()) {
        return;
      }
      // Ignore disconnection if we are connecting, retries are already handled in the connect function
      if (this.isConnecting()) {
        return;
      }
      this.handleUnexpectedDisconnection(error);
    });
  }

  private handleUnexpectedDisconnection(error: BleError | null) {
      console.log(`Device ${this.deviceId} unexexpectedly disconnected: ${error}`);
      const reason = error ? 'error' : 'unknown';
      this.changeStateDisconnecting(reason);
      this.changeStateDisconnected(reason);
      // TODO: distinguish between retriable and non-retriable errors?
      // Unexpected disconnection
      if (this.autoReconnect) {
        console.log(`Unexpectedly disconnected, trying to reconnect...`, error);
        this.connect();
        return
      }
  }

  private changeState(newState: ConnectionState) {
    const oldState = this.state;
    this.state = newState;
    this.emit('stateChanged', newState, oldState);
    // this.callbacks.onStateChange(newState, oldState);
  }

  private changeStateConnecting() {
    this.changeState('connecting');
    this.emit('connecting');
    // this.callbacks.onConnecting();
  }

  private changeStateConnected(connectionInfo: ConnectionInfo) {
    this.changeState('connected');
    this.emit('connected', connectionInfo);
    // this.callbacks.onConnected();
  }

  private changeStateDisconnecting(reason: DisconnectReason) {
    this.changeState('disconnecting');
    this.emit('disconnecting', reason);
    // this.callbacks.onDisconnecting();
  }

  private changeStateDisconnected(reason: DisconnectReason) {
    this.changeState('disconnected');
    this.emit('disconnected', reason);
    // this.callbacks.onDisconnected(reason);
  }

  async connect(maxRetries: number = Infinity) {
    try {
      this.changeStateConnecting();
      let device;
      let retries = 0;
      while (true) {
        // TODO: implement maxRetries and exponential backoff
        if (!this.isConnecting()) {
          // State changed while we are retrying (e.g. user called .disconnect())
          // Abort...
          return;
        }
        try {
          if (retries > 0) {
            console.log(`Retrying #${retries}...`);
          }
          // TODO: implement a timeout?
          // Ensure scanner is stopped to prevent android bug
          await ble.stopDeviceScan();

          // first check if we are already connected (android bug...)
          // console.log(this.deviceId)
          // const [maybeDevice] = await ble.connectedDevices([]);
          // console.log({ maybeDevice })
          // const isConnected = await ble.isDeviceConnected(this.deviceId);
          // console.log({ isConnected });
          // make sure we are not connected
          // await ble.cancelDeviceConnection(this.deviceId).catch(console.error);

          device = await ble.connectToDevice(this.deviceId, {
            autoConnect: false,
          });
          // break out of retry loop since we connected
          break;
        } catch (error) {
          console.log("ble.connectToDevice error: ", error);
          // TODO: use proper error code / reason instead of matching message string
          if (error instanceof BleError && error.message.match(/is already connected/)) {
            // TODO: figure out why this happens
            console.log("Already connected device", error);
            console.log({ ble });
            try {
              await ble.cancelDeviceConnection(this.deviceId);
            } catch (err) {
              console.error('Unexpected error while cancelling connection: ', err);
            }
            // Device is already connected, we are done
            // TODO: fix me
          }
          retries += 1;
          if (retries > maxRetries) {
            throw error;
          }
          // TODO: distinguish between retriable and non-retriable errors
          console.log('Error connecting: ', error);
          await wait(500);
        }
      }
      this.changeStateConnected(deviceToConnectionInfo(device));
    } catch (error) {
      this.changeStateDisconnecting('error');
      this.changeStateDisconnected('error');
      this.emit('error', error as Error)
      throw error;
    }
  }

  async disconnect(reason: DisconnectReason = "user") {
    if (!this.isDisconnectable()) return;
    this.changeStateDisconnecting(reason);
    try {
      await ble.cancelDeviceConnection(this.deviceId);
    } catch (error) {
      console.error('Unexpected error while disconnecting: ', error);
      this.emit('error', error as Error);
      // this.callbacks.onError(error as Error);
      throw error;
    } finally {
      this.changeStateDisconnected(reason);
    }
  }
}

Relevant log output

N/A

Additional information

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions