From d00a1db1d58bd5e6ab6a6d218c1fd1ece1faf821 Mon Sep 17 00:00:00 2001 From: Alvyn Fasuyi Date: Fri, 16 Oct 2026 11:14:47 +0200 Subject: [PATCH] Map ATT errors to UniversalBleErrorCode on Apple (#348) A GATT operation the peripheral refuses fails with an NSError in CBATTErrorDomain. toFlutterError() looked its numeric code up in mapErrorCodeToEnum, which matches names only, so every refusal reached Dart as unknownError. ATT codes now map as Android's gattStatusToUniversalBleErrorCode maps them; other domains are unchanged. The example's RunnerTests held a template test that no longer compiled (UniversalBlePlugin has no handle method since the move to Pigeon); it now tests this mapping. --- CHANGELOG.md | 1 + .../universal_ble/UniversalBleHelper.swift | 32 +++++++++- example/ios/RunnerTests/RunnerTests.swift | 61 +++++++++++++++---- 3 files changed, 80 insertions(+), 14 deletions(-) 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") + } }