diff --git a/CHANGELOG.md b/CHANGELOG.md index 81a014cd..8341b14d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,5 @@ ## 3.1.1 +* Apple: a read, write or subscription the peripheral refuses now reports its ATT error as the matching `UniversalBleErrorCode` (`insufficientEncryption`, `insufficientAuthentication`, `readNotPermitted`, ...), as Android does, instead of `unknownError`. The error code is still in `details`. Codes in `CBErrorDomain` are unchanged: its numbers overlap ATT's with other meanings (#348). * Bump `bluez` to `^0.9.0`. * Bump the `pigeon` dev dependency to `^29.0.0`. diff --git a/darwin/universal_ble/Sources/universal_ble/UniversalBleHelper.swift b/darwin/universal_ble/Sources/universal_ble/UniversalBleHelper.swift index b407ba9a..2c5031f2 100644 --- a/darwin/universal_ble/Sources/universal_ble/UniversalBleHelper.swift +++ b/darwin/universal_ble/Sources/universal_ble/UniversalBleHelper.swift @@ -124,6 +124,31 @@ func mapErrorCodeToEnum(_ code: String) -> UniversalBleErrorCode { } } +/// Maps an ATT error code (`CBATTError.Code` in `CBATTErrorDomain`) to UniversalBleErrorCode, +/// matching Android's `gattStatusToUniversalBleErrorCode` so a refusal reads the same everywhere. +func attErrorToUniversalBleErrorCode(_ code: Int) -> UniversalBleErrorCode { + switch CBATTError.Code(rawValue: code) { + case .invalidHandle: return .invalidHandle + case .readNotPermitted: return .readNotPermitted + case .writeNotPermitted: return .writeNotPermitted + case .invalidPdu: return .invalidPdu + case .insufficientAuthentication: return .insufficientAuthentication + case .requestNotSupported: return .operationNotSupported + case .invalidOffset: return .invalidOffset + case .insufficientAuthorization: return .insufficientAuthorization + case .prepareQueueFull: return .operationInProgress + case .attributeNotFound: return .serviceNotFound + case .attributeNotLong: return .invalidAttributeLength + case .insufficientEncryptionKeySize: return .insufficientKeySize + case .invalidAttributeValueLength: return .invalidAttributeLength + case .unlikelyError: return .failed + case .insufficientEncryption: return .insufficientEncryption + case .unsupportedGroupType: return .operationNotSupported + case .insufficientResources: return .failed + default: return .unknownError + } +} + /// Creates a PigeonError with the error code enum in details func createFlutterError( code: UniversalBleErrorCode, @@ -143,7 +168,12 @@ extension Error { let nsError = self as NSError let errorCode: String = .init(nsError.code) let errorDescription: String = nsError.localizedDescription - let mappedCode = mapErrorCodeToEnum(errorCode) + // A GATT operation the peripheral refused carries its ATT error code, which no name lookup + // matches. CBErrorDomain reuses the same small numbers for other meanings, so the domain + // decides which table applies. + let mappedCode = nsError.domain == CBATTErrorDomain + ? attErrorToUniversalBleErrorCode(nsError.code) + : mapErrorCodeToEnum(errorCode) return createFlutterError(code: mappedCode, message: errorDescription, details: errorCode) } diff --git a/example/ios/RunnerTests/RunnerTests.swift b/example/ios/RunnerTests/RunnerTests.swift index 344ed3e0..a8036dd9 100644 --- a/example/ios/RunnerTests/RunnerTests.swift +++ b/example/ios/RunnerTests/RunnerTests.swift @@ -1,26 +1,61 @@ -import Flutter -import UIKit +import CoreBluetooth import XCTest @testable import universal_ble -// This demonstrates a simple unit test of the Swift portion of this plugin's implementation. -// -// See https://developer.apple.com/documentation/xctest for more information about using XCTest. +// Unit tests of the Swift portion of this plugin. Run from Xcode, or with +// `xcodebuild test -workspace Runner.xcworkspace -scheme Runner -destination ''`. class RunnerTests: XCTestCase { - func testGetPlatformVersion() { - let plugin = UniversalBlePlugin() + func testARefusedOperationCarriesItsAttErrorCode() { + let refused = NSError( + domain: CBATTErrorDomain, + code: CBATTError.Code.insufficientEncryption.rawValue, + userInfo: [NSLocalizedDescriptionKey: "Encryption is insufficient."] + ) - let call = FlutterMethodCall(methodName: "getPlatformVersion", arguments: []) + let error = refused.toFlutterError() - let resultExpectation = expectation(description: "result block must be called.") - plugin.handle(call) { result in - XCTAssertEqual(result as! String, "iOS " + UIDevice.current.systemVersion) - resultExpectation.fulfill() + XCTAssertEqual(error.code, UniversalBleErrorCode.insufficientEncryption.rawValue.description) + XCTAssertEqual(error.message, "Encryption is insufficient.") + XCTAssertEqual(error.details as? String, "15") + } + + func testEveryAttErrorCodeMapsAsOnAndroid() { + let expected: [(CBATTError.Code, UniversalBleErrorCode)] = [ + (.invalidHandle, .invalidHandle), + (.readNotPermitted, .readNotPermitted), + (.writeNotPermitted, .writeNotPermitted), + (.invalidPdu, .invalidPdu), + (.insufficientAuthentication, .insufficientAuthentication), + (.requestNotSupported, .operationNotSupported), + (.invalidOffset, .invalidOffset), + (.insufficientAuthorization, .insufficientAuthorization), + (.prepareQueueFull, .operationInProgress), + (.attributeNotFound, .serviceNotFound), + (.attributeNotLong, .invalidAttributeLength), + (.insufficientEncryptionKeySize, .insufficientKeySize), + (.invalidAttributeValueLength, .invalidAttributeLength), + (.unlikelyError, .failed), + (.insufficientEncryption, .insufficientEncryption), + (.unsupportedGroupType, .operationNotSupported), + (.insufficientResources, .failed), + ] + + for (attCode, code) in expected { + XCTAssertEqual(attErrorToUniversalBleErrorCode(attCode.rawValue), code, "ATT code \(attCode.rawValue)") } - waitForExpectations(timeout: 1) + XCTAssertEqual(attErrorToUniversalBleErrorCode(0x80), .unknownError, "an application-defined ATT code") } + func testACoreBluetoothErrorWithTheSameNumberIsNotReadAsAnAttCode() { + // CBError 15 is "encryption timed out", not ATT's "insufficient encryption". + let timedOut = NSError(domain: CBErrorDomain, code: CBError.Code.encryptionTimedOut.rawValue) + + let error = timedOut.toFlutterError() + + XCTAssertEqual(error.code, UniversalBleErrorCode.unknownError.rawValue.description) + XCTAssertEqual(error.details as? String, "15") + } }