XYOracleNetwork/sdk-xyo-swift

View on GitHub
Carthage/Checkouts/sdk-ble-swift/Sources/XyBleSdk/XYDeviceConnectionManager.swift

Summary

Maintainability
F
4 days
Test Coverage
//
//  XYFinderDeviceManager.swift
//  XYBleSdk
//
//  Created by Darren Sutherland on 10/25/18.
//  Copyright © 2018 XY - The Findables Company. All rights reserved.
//

import CoreBluetooth

public final class XYDeviceConnectionManager {

    public static let instance = XYDeviceConnectionManager()
    private init() {}

    fileprivate var devices = [String: XYBluetoothDevice]()
    fileprivate var waitingDeviceIds = [String]()
    fileprivate let managerQueue = DispatchQueue(label:"com.xyfindables.sdk.XYFinderDeviceManagerQueue", attributes: .concurrent)
    fileprivate let waitQueue = DispatchQueue(label: "com.xyfindables.sdk.XYDeviceConnectionManager.WaitQueue")

    fileprivate let
    reconnectLock = GenericLock(0)

    fileprivate lazy var disconnectSubKeys = [String: UUID]()

    public var connectedDevices: [XYBluetoothDevice] {
        return self.devices.map { $1 }
    }

    func invalidate() {
        devices.forEach { $0.value.disconnect() }
    }

    // Add a tracked device and connect to it, ensuring we do not add the same device twice as this method
    // will be called multiple times over the course of a session from the location and peripheral delegates
    public func add(device: XYBluetoothDevice) {
        // Quick escape if we already have the device and it is connected or it's already connecting
        guard !isConnectedOrConnecting(for: self.devices[device.id]) else { return }

        // Check and connect
        guard self.devices[device.id] == nil else { return }
        self.devices[device.id] = device
        self.connect(to: device)
    }

    // Remove the devices from the dictionary of tracked, connected devices, and let central know to disconnect
    internal func remove(for id: String, disconnect: Bool) {
        guard let device = self.devices[id] else { return }
        self.devices.removeValue(forKey: device.id)
        self.waitingDeviceIds.removeAll(where: { $0 == device.id })
        if disconnect && (device.state != .disconnected || device.state != .disconnecting) {
            self.disconnect(from: device)
        }
    }

    // Like above, but this is a hard reset for the device, removing the peripheral so it can be rediscovered
    // Used by the firmware update
    internal func remove(device: XYBluetoothDevice) {
        guard self.devices[device.id] != nil else { return }
        self.devices.removeValue(forKey: device.id)
        self.waitingDeviceIds.removeAll(where: { $0 == device.id })
        if device.state != .disconnected || device.state != .disconnecting {
            XYCentral.instance.disconnect(from: device)
            device.detachPeripheral()
        }
    }

    // If we lose connection to a device, we can put it in the wait queue and it will automatically reconnect
    // even if the user leaves the area (as long as the app is still running in the backgound)
    func wait(for device: XYBluetoothDevice) {
        // Quick escape if we already have the device and it is connected or it's already connecting
        guard !isConnectedOrConnecting(for: device) else { return }

        // We have lost contact with the device, so we'll do a non-expiring connectiong try
        guard !waitingDeviceIds.contains(where: { $0 == device.id }) else { return }
        self.managerQueue.async(flags: .barrier) {
            guard !self.waitingDeviceIds.contains(where: { $0 == device.id }) else { return }
            print("Adding \(device.id) to wait queue...")

            self.waitingDeviceIds.append(device.id)

            XYConnectionAgent(for: device).connect(.never).then(on: self.waitQueue) {
                guard let xyDevice = device as? XYFinderDevice else {
                    self.reconnectLock.unlock()
                    return
                }

                // Check to see if we still want to connect to this
                guard self.waitingDeviceIds.contains(xyDevice.id) else {
                    xyDevice.disconnect()
                    self.reconnectLock.unlock()
                    return
                }

                self.waitingDeviceIds.removeAll(where: { $0 == device.id })
                print("\(device.id) is found again!")

                // Lock and try for a reconnection
                xyDevice.connection {
                    // If we have an XY Finder device, we report this, subscribe to the button and kick off the RSSI read loop
                    if let xyDevice = device as? XYFinderDevice {
                        if xyDevice.unlock().hasError {
                            throw XYBluetoothError.couldNotConnect
                        }

                        if xyDevice.subscribeToButtonPress().hasError {
                            throw XYBluetoothError.couldNotConnect
                        }

                        xyDevice.peripheral?.readRSSI()
                    }

                }.then(on: self.waitQueue) {
                    if let xyDevice = device as? XYFinderDevice {
                        XYFinderDeviceEventManager.report(events: [.reconnected(device: xyDevice)])
                    }
                    self.reconnectLock.unlock()

                }.always(on: self.waitQueue) {
                    self.reconnectLock.unlock()
                }
            }

            self.reconnectLock.lock()
        }
    }
}

// MARK: Connect and disconnection
private extension XYDeviceConnectionManager {

    func isConnectedOrConnecting(for device: XYBluetoothDevice?) -> Bool {
        if let xyDevice = device as? XYFinderDevice {
            if xyDevice.state == .connecting { return true }
            if xyDevice.state == .connected {
                XYFinderDeviceEventManager.report(events: [.alreadyConnected(device: xyDevice)])
                return true
            }
        }

        return false
    }

    // Connect to the device using the connection agent, then subscribe to the button press and
    // start the readRSSI recursive loop. Use a 0-based sempahore to ensure only once device
    // can be in the connection state at one time
    func connect(to device: XYBluetoothDevice) {
        let deviceId = device.id

        print("STEP 1: Trying to connect to \(deviceId.shortId)...")

        // If we disconnect at any point in the connection, we remove the device so it can be tried again and unlock the connection semaphore
        // We also try to run the disconnect call in case we are watching the notifications
        self.disconnectSubKeys[deviceId] = XYFinderDeviceEventManager.subscribe(to: [.disconnected]) { [weak self] event in
            XYFinderDeviceEventManager.unsubscribe(to: [.disconnected], referenceKey: self?.disconnectSubKeys[deviceId])
            guard let finder = device as? XYFinderDevice, finder == event.device else { return }
            self?.devices[finder.id] = nil
        }

        let connectionQueue = DispatchQueue(label: "com.xyfindables.sdk.ConnectionManagerQueueFor\(deviceId.shortId)")

        device.connection {
            // If we have an XY Finder device, we report this, subscribe to the button and kick off the RSSI read loop
            if let xyDevice = device as? XYFinderDevice {
                if xyDevice.unlock().hasError {
                    throw XYBluetoothError.couldNotConnect
                }

                if xyDevice.subscribeToButtonPress().hasError {
                    throw XYBluetoothError.couldNotConnect
                }

                xyDevice.peripheral?.readRSSI()
            }

        }.then(on: connectionQueue) {
            if let xyDevice = device as? XYFinderDevice {
                XYFinderDeviceEventManager.report(events: [.connected(device: xyDevice)])
            }

            if self.waitingDeviceIds.contains(deviceId) {
                self.waitingDeviceIds.removeAll(where: { $0 == deviceId })
            }

        }.catch(on: connectionQueue) { error in
            guard let xyError = error as? XYBluetoothError, let xyDevice = device as? XYFinderDevice else { return }
            switch xyError {
            case .timedOut:
                XYFinderDeviceEventManager.report(events: [.timedOut(device: xyDevice, type: .connection)])
            default:
                XYFinderDeviceEventManager.report(events: [.connectionError(device: xyDevice, error: xyError)])
            }

            print("STEP 6: ERROR for \((error as! XYBluetoothError).toString) for device \(deviceId)")

            // Completely disconnect so we can retry if there is any connection issue
            XYCentral.instance.disconnect(from: device)
            XYBluetoothDeviceFactory.remove(device: xyDevice)
            self.devices.removeValue(forKey: deviceId)
            self.waitingDeviceIds.removeAll(where: { $0 == deviceId })

        }.always(on: connectionQueue) {
            XYFinderDeviceEventManager.unsubscribe(to: [.disconnected], referenceKey: self.disconnectSubKeys[deviceId])
        }
    }

    func disconnect(from device: XYBluetoothDevice) {
        print("STEP 1: Trying to DISCONNECT from \(device.id.shortId)...")

        let disconnectQueue = DispatchQueue(label: "com.xyfindables.sdk.ConnectionManagerDisconnectQueueFor\(device.id)")

        device.connection {
            // If we have an XY Finder device of a particular family, we unsubscribe from the button press and disconnect
            if let xyDevice = device as? XYFinderDevice, (
                    xyDevice.family.id == XY3BluetoothDevice.id ||
                    xyDevice.family.id == XY4BluetoothDevice.id ||
                    xyDevice.family.id == XYGPSBluetoothDevice.id) {
                
                if xyDevice.unlock().hasError {
                    throw XYBluetoothError.couldNotConnect
                }

                if xyDevice.unsubscribeToButtonPress(for: nil).hasError {
                    throw XYBluetoothError.couldNotConnect
                }
            }
            
        }.always(on: disconnectQueue) {
            print("STEP 2: Always on DISCONNECT from \(device.id.shortId)")
            XYCentral.instance.disconnect(from: device)
            device.detachPeripheral()
            if let xyDevice = device as? XYFinderDevice {
                XYBluetoothDeviceFactory.remove(device: xyDevice)
            }
        }
    }
}