diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b8ee36b..740c142 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6e10ca48df0618916616b81379994d6c30d5716d19178aefa050c983082272ce", + "originHash" : "ac662b7c30c44a46342d90fd2f894c1a70161e39d60b85657ca39c859e894c44", "pins" : [ { "identity" : "defaults", diff --git a/DynamicIsland/components/Live activities/InlineHUD.swift b/DynamicIsland/components/Live activities/InlineHUD.swift index 671e20f..b2fbc54 100644 --- a/DynamicIsland/components/Live activities/InlineHUD.swift +++ b/DynamicIsland/components/Live activities/InlineHUD.swift @@ -24,6 +24,7 @@ struct InlineHUD: View { @Default(.useCircularBluetoothBatteryIndicator) var useCircularBluetoothBatteryIndicator @Default(.showBluetoothBatteryPercentageText) var showBluetoothBatteryPercentageText @Default(.showBluetoothDeviceNameMarquee) var showBluetoothDeviceNameMarquee + @Default(.enableMinimalisticUI) var enableMinimalisticUI @ObservedObject var bluetoothManager = BluetoothAudioManager.shared @State private var displayName: String = "" @@ -33,35 +34,54 @@ struct InlineHUD: View { let hasBatteryLevel = value > 0 let baseInfoWidth: CGFloat = { - if type == .bluetoothAudio { - return showBluetoothDeviceNameMarquee ? 140 : 88 + guard type == .bluetoothAudio else { return 100 } + if showBluetoothDeviceNameMarquee { + return enableMinimalisticUI ? 128 : 140 } - return 100 + return enableMinimalisticUI ? 76 : 88 }() + let infoWidth: CGFloat = { var width = baseInfoWidth + gestureProgress / 2 if !hoverAnimation { width -= 8 } - let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothDeviceNameMarquee ? 120 : 82) : 88 + let minimum: CGFloat = { + guard type == .bluetoothAudio else { return 88 } + if showBluetoothDeviceNameMarquee { + return enableMinimalisticUI ? 112 : 120 + } + return enableMinimalisticUI ? 70 : 82 + }() return max(width, minimum) }() - let baseTrailingWidth: CGFloat = { - if type == .bluetoothAudio { - if !hasBatteryLevel { - return showBluetoothDeviceNameMarquee ? 118 : 88 - } - if useCircularIndicator { - return showBluetoothBatteryPercentageText ? 120 : 96 - } else { - return showBluetoothBatteryPercentageText ? 136 : 108 - } + let baseTrailingWidth: CGFloat = { + guard type == .bluetoothAudio else { return 100 } + if !hasBatteryLevel { + return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 104 : 118) : (enableMinimalisticUI ? 74 : 88) } - return 100 + + if useCircularIndicator { + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 108 : 120) : (enableMinimalisticUI ? 84 : 96) + } + + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 118 : 136) : (enableMinimalisticUI ? 92 : 108) }() + let trailingWidth: CGFloat = { var width = baseTrailingWidth + gestureProgress / 2 if !hoverAnimation { width -= 8 } - let minimum: CGFloat = type == .bluetoothAudio ? (showBluetoothBatteryPercentageText ? 110 : 92) : 90 + let minimum: CGFloat = { + guard type == .bluetoothAudio else { return 90 } + if !hasBatteryLevel { + return showBluetoothDeviceNameMarquee ? (enableMinimalisticUI ? 96 : 110) : (enableMinimalisticUI ? 70 : 92) + } + + if useCircularIndicator { + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 92 : 110) : (enableMinimalisticUI ? 68 : 80) + } + + return showBluetoothBatteryPercentageText ? (enableMinimalisticUI ? 104 : 120) : (enableMinimalisticUI ? 82 : 98) + }() return max(width, minimum) }() @@ -161,7 +181,14 @@ struct InlineHUD: View { .contentTransition(.interpolate) } else if (type == .bluetoothAudio) { if hasBatteryLevel { - HStack(spacing: useCircularIndicator ? 8 : 6) { + let indicatorSpacing: CGFloat = { + if useCircularIndicator { + return showBluetoothBatteryPercentageText ? 8 : 2 + } + return showBluetoothBatteryPercentageText ? 6 : 4 + }() + + HStack(spacing: indicatorSpacing) { if useCircularIndicator { CircularBatteryIndicator( value: value, @@ -221,7 +248,7 @@ struct InlineHUD: View { .frame(maxWidth: .infinity, alignment: .trailing) } } - .padding(.trailing, 4) + .padding(.trailing, trailingWidth > 0 ? 4 : 0) .frame(width: trailingWidth, height: vm.closedNotchSize.height - (hoverAnimation ? 0 : 12), alignment: .center) } .frame(height: vm.closedNotchSize.height + (hoverAnimation ? 8 : 0), alignment: .center) diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index 5ee4e7e..ba03a9d 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -13,6 +13,7 @@ import Defaults import SwiftUI import IOBluetooth import IOKit +import CoreBluetooth /// Manages detection and monitoring of Bluetooth audio device connections class BluetoothAudioManager: ObservableObject { @@ -29,6 +30,8 @@ class BluetoothAudioManager: ObservableObject { private let coordinator = DynamicIslandViewCoordinator.shared private var pollingTimer: Timer? private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth" + private let batteryReader = BluetoothLEBatteryReader() + private var isLiveBatteryRefreshInFlight = false @Published private(set) var batteryStatus: [String: String] = [:] @@ -411,17 +414,24 @@ class BluetoothAudioManager: ObservableObject { } } - private func refreshBatteryLevelsForConnectedDevices() { - updateBatteryStatuses(force: true) + private func refreshBatteryLevelsForConnectedDevices(forceCacheRefresh: Bool = true) { + if forceCacheRefresh { + updateBatteryStatuses(force: true) + } + + applyConnectedDeviceBatteryLevels() + triggerLiveBatteryRefreshIfNeeded() + } + + private func applyConnectedDeviceBatteryLevels() { + guard !connectedDevices.isEmpty else { + lastConnectedDevice = nil + return + } var updatedDevices: [BluetoothAudioDevice] = [] for device in connectedDevices { - let refreshedLevel = - batteryLevelFromRegistry(forAddress: device.address) - ?? batteryLevelFromRegistry(forName: device.name) - ?? batteryLevelFromDefaults(forAddress: device.address) - ?? batteryLevelFromDefaults(forName: device.name) - ?? device.batteryLevel + let refreshedLevel = bestBatteryLevel(for: device) let updatedDevice = device.withBatteryLevel(refreshedLevel) updatedDevices.append(updatedDevice) @@ -433,11 +443,189 @@ class BluetoothAudioManager: ObservableObject { } connectedDevices = updatedDevices - if let last = connectedDevices.last { + if let last = updatedDevices.last { lastConnectedDevice = last } } + private func bestBatteryLevel(for device: BluetoothAudioDevice) -> Int? { + batteryLevelFromRegistry(forAddress: device.address) + ?? batteryLevelFromRegistry(forName: device.name) + ?? batteryLevelFromDefaults(forAddress: device.address) + ?? batteryLevelFromDefaults(forName: device.name) + ?? device.batteryLevel + } + + private func triggerLiveBatteryRefreshIfNeeded() { + guard !connectedDevices.isEmpty else { return } + guard connectedDevices.contains(where: { $0.batteryLevel == nil }) else { return } + guard !isLiveBatteryRefreshInFlight else { return } + + let lookups = coreBluetoothLookups(for: connectedDevices) + guard !lookups.isEmpty else { return } + + isLiveBatteryRefreshInFlight = true + batteryReader.fetchBatteryLevels(for: lookups) { [weak self] results in + guard let self else { return } + DispatchQueue.main.async { + self.isLiveBatteryRefreshInFlight = false + self.handleLiveBatteryResults(results) + } + } + } + + private func coreBluetoothLookups(for devices: [BluetoothAudioDevice]) -> [BluetoothLEBatteryReader.Lookup] { + let snapshot = coreBluetoothCacheSnapshot() + guard snapshot.hasEntries else { return [] } + + var lookups: [BluetoothLEBatteryReader.Lookup] = [] + var seenUUIDs: Set = [] + + for device in devices { + let normalizedAddress = normalizeBluetoothIdentifier(device.address) + let normalizedName = normalizeProductName(device.name) + guard !normalizedAddress.isEmpty || !normalizedName.isEmpty else { continue } + + let uuid = snapshot.byAddress[normalizedAddress] + ?? snapshot.byName[normalizedName] + + guard let uuid, !seenUUIDs.contains(uuid) else { continue } + seenUUIDs.insert(uuid) + + let canonicalName = snapshot.namesByUUID[uuid] ?? normalizedName + lookups.append( + .init( + uuid: uuid, + addressKey: normalizedAddress.isEmpty ? nil : normalizedAddress, + nameKey: canonicalName.isEmpty ? nil : canonicalName + ) + ) + } + + return lookups + } + + private func handleLiveBatteryResults(_ results: [BluetoothLEBatteryReader.Result]) { + guard !results.isEmpty else { return } + + var didUpdate = false + + for result in results { + let level = clampBatteryPercentage(result.level) + + if let addressKey = result.addressKey, !addressKey.isEmpty { + let previous = batteryStatusByAddress[addressKey] ?? -1 + if level > previous { + batteryStatusByAddress[addressKey] = level + batteryStatus[addressKey] = String(level) + didUpdate = true + } + } + + if let nameKey = result.nameKey, !nameKey.isEmpty { + let previous = batteryStatusByName[nameKey] ?? -1 + if level > previous { + batteryStatusByName[nameKey] = level + didUpdate = true + } + } + } + + guard didUpdate else { return } + + applyConnectedDeviceBatteryLevels() + if let lastLevel = lastConnectedDevice?.batteryLevel { + updateActiveBluetoothHUDBattery(with: lastLevel) + } else if let fallbackLevel = connectedDevices.last(where: { $0.batteryLevel != nil })?.batteryLevel { + updateActiveBluetoothHUDBattery(with: fallbackLevel) + } + } + + private func updateActiveBluetoothHUDBattery(with level: Int?) { + guard let level else { return } + DispatchQueue.main.async { + guard self.coordinator.sneakPeek.show, + self.coordinator.sneakPeek.type == .bluetoothAudio else { return } + self.coordinator.sneakPeek.value = CGFloat(level) / 100.0 + } + } + + private func coreBluetoothCacheSnapshot() -> CoreBluetoothCacheSnapshot { + guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite), + let coreCache = preferences.object(forKey: "CoreBluetoothCache") as? [String: [String: Any]] else { + return .empty + } + + var byAddress: [String: UUID] = [:] + var byName: [String: UUID] = [:] + var namesByUUID: [UUID: String] = [:] + + for (uuidString, payload) in coreCache { + guard let uuid = UUID(uuidString: uuidString) else { continue } + + let addressKeys = ["DeviceAddress", "Address", "BD_ADDR", "device_address"] + for key in addressKeys { + if let value = payload[key], let normalized = normalizeBluetoothIdentifier(from: value) { + byAddress[normalized] = uuid + } + } + + if let serialValue = payload["SerialNumber"], let normalizedSerial = normalizeBluetoothIdentifier(from: serialValue) { + byAddress[normalizedSerial] = uuid + } + + let nameKeys = ["Name", "DeviceName", "ProductName", "Product", "device_name"] + for key in nameKeys { + if let value = payload[key], let normalizedName = normalizeProductName(from: value) { + byName[normalizedName] = uuid + namesByUUID[uuid] = normalizedName + } + } + } + + return CoreBluetoothCacheSnapshot(byAddress: byAddress, byName: byName, namesByUUID: namesByUUID) + } + + private func normalizeBluetoothIdentifier(from value: Any) -> String? { + if let string = value as? String { + let normalized = normalizeBluetoothIdentifier(string) + return normalized.isEmpty ? nil : normalized + } + + if let data = value as? Data, + let ascii = String(data: data, encoding: .utf8) { + let normalized = normalizeBluetoothIdentifier(ascii) + return normalized.isEmpty ? nil : normalized + } + + return nil + } + + private func normalizeProductName(from value: Any) -> String? { + if let string = value as? String { + let normalized = normalizeProductName(string) + return normalized.isEmpty ? nil : normalized + } + if let data = value as? Data, + let ascii = String(data: data, encoding: .utf8) { + let normalized = normalizeProductName(ascii) + return normalized.isEmpty ? nil : normalized + } + return nil + } + + private struct CoreBluetoothCacheSnapshot { + let byAddress: [String: UUID] + let byName: [String: UUID] + let namesByUUID: [UUID: String] + + var hasEntries: Bool { + !byAddress.isEmpty || !byName.isEmpty + } + + static let empty = CoreBluetoothCacheSnapshot(byAddress: [:], byName: [:], namesByUUID: [:]) + } + private func batteryLevelFromDefaults(forAddress address: String?) -> Int? { guard let address, !address.isEmpty else { return nil } guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil } @@ -1004,11 +1192,7 @@ class BluetoothAudioManager: ObservableObject { print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD") // Convert battery percentage to 0.0-1.0 value - let reportedBattery = device.batteryLevel - ?? batteryLevelFromRegistry(forAddress: device.address) - ?? batteryLevelFromRegistry(forName: device.name) - ?? batteryLevelFromDefaults(forAddress: device.address) - ?? batteryLevelFromDefaults(forName: device.name) + let reportedBattery = bestBatteryLevel(for: device) let batteryValue: CGFloat = if let battery = reportedBattery { CGFloat(clampBatteryPercentage(battery)) / 100.0 @@ -1048,6 +1232,262 @@ class BluetoothAudioManager: ObservableObject { } } +// MARK: - CoreBluetooth Battery Reader + +private final class BluetoothLEBatteryReader: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate { + struct Lookup { + let uuid: UUID + let addressKey: String? + let nameKey: String? + } + + struct Result { + let uuid: UUID + let level: Int + let addressKey: String? + let nameKey: String? + } + + private enum State { + case idle + case requesting + } + + private static let batteryServiceUUID = CBUUID(string: "180F") + private static let batteryCharacteristicUUID = CBUUID(string: "2A19") + + private let timeoutInterval: TimeInterval = 6.0 + + private var central: CBCentralManager! + private var state: State = .idle + private var pendingLookups: [Lookup] = [] + private var lookupByUUID: [UUID: Lookup] = [:] + private var completion: (([Result]) -> Void)? + private var pendingPeripherals: [UUID: CBPeripheral] = [:] + private var results: [UUID: Result] = [:] + private var missingUUIDs: Set = [] + private var timeoutWorkItem: DispatchWorkItem? + + override init() { + super.init() + central = CBCentralManager(delegate: self, queue: nil) + } + + func fetchBatteryLevels(for lookups: [Lookup], completion: @escaping ([Result]) -> Void) { + guard !lookups.isEmpty else { + completion([]) + return + } + + guard state == .idle else { + completion([]) + return + } + + state = .requesting + pendingLookups = lookups + lookupByUUID = Dictionary(uniqueKeysWithValues: lookups.map { ($0.uuid, $0) }) + self.completion = completion + results.removeAll() + pendingPeripherals.removeAll() + missingUUIDs = Set(lookups.map { $0.uuid }) + + switch central.state { + case .poweredOn: + startRequest() + case .unauthorized, .unsupported, .poweredOff: + complete(with: []) + default: + break + } + } + + // MARK: - CBCentralManagerDelegate + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + guard state == .requesting else { return } + + switch central.state { + case .poweredOn: + startRequest() + case .unauthorized, .unsupported, .poweredOff: + complete(with: []) + default: + break + } + } + + func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { + peripheral.discoverServices([Self.batteryServiceUUID]) + } + + func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { + markPeripheralFinished(peripheral.identifier) + } + + func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { + guard state == .requesting else { return } + markPeripheralFinished(peripheral.identifier) + } + + func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { + guard state == .requesting else { return } + guard missingUUIDs.contains(peripheral.identifier) else { return } + + missingUUIDs.remove(peripheral.identifier) + configurePeripheral(peripheral) + } + + // MARK: - CBPeripheralDelegate + + func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { + guard state == .requesting else { return } + + if let error { + print("🎧 [BluetoothLEBatteryReader] Service discovery failed: \(error.localizedDescription)") + markPeripheralFinished(peripheral.identifier) + return + } + + guard let service = peripheral.services?.first(where: { $0.uuid == Self.batteryServiceUUID }) else { + markPeripheralFinished(peripheral.identifier) + return + } + + peripheral.discoverCharacteristics([Self.batteryCharacteristicUUID], for: service) + } + + func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { + guard state == .requesting else { return } + + if let error { + print("🎧 [BluetoothLEBatteryReader] Characteristic discovery failed: \(error.localizedDescription)") + markPeripheralFinished(peripheral.identifier) + return + } + + guard let characteristic = service.characteristics?.first(where: { $0.uuid == Self.batteryCharacteristicUUID }) else { + markPeripheralFinished(peripheral.identifier) + return + } + + peripheral.readValue(for: characteristic) + } + + func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { + guard state == .requesting else { return } + + defer { markPeripheralFinished(peripheral.identifier) } + + if let error { + print("🎧 [BluetoothLEBatteryReader] Battery read failed: \(error.localizedDescription)") + return + } + + guard let data = characteristic.value, let byte = data.first, let lookup = lookupByUUID[peripheral.identifier] else { + return + } + + let level = Int(byte) + results[peripheral.identifier] = Result( + uuid: peripheral.identifier, + level: level, + addressKey: lookup.addressKey, + nameKey: lookup.nameKey + ) + } + + // MARK: - Helpers + + private func startRequest() { + central.stopScan() + + let identifiers = Array(missingUUIDs) + if !identifiers.isEmpty { + let peripherals = central.retrievePeripherals(withIdentifiers: identifiers) + for peripheral in peripherals { + missingUUIDs.remove(peripheral.identifier) + configurePeripheral(peripheral) + } + } + + let connectedPeripherals = central.retrieveConnectedPeripherals(withServices: [Self.batteryServiceUUID]) + for peripheral in connectedPeripherals where missingUUIDs.contains(peripheral.identifier) { + missingUUIDs.remove(peripheral.identifier) + configurePeripheral(peripheral) + } + + if !missingUUIDs.isEmpty { + central.scanForPeripherals(withServices: [Self.batteryServiceUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]) + } + + if pendingPeripherals.isEmpty && missingUUIDs.isEmpty { + complete(with: Array(results.values)) + return + } + + scheduleTimeout() + } + + private func configurePeripheral(_ peripheral: CBPeripheral) { + pendingPeripherals[peripheral.identifier] = peripheral + peripheral.delegate = self + + switch peripheral.state { + case .connected: + peripheral.discoverServices([Self.batteryServiceUUID]) + default: + central.connect(peripheral, options: nil) + } + } + + private func markPeripheralFinished(_ identifier: UUID) { + pendingPeripherals.removeValue(forKey: identifier) + missingUUIDs.remove(identifier) + + if missingUUIDs.isEmpty { + central.stopScan() + } + + if pendingPeripherals.isEmpty && missingUUIDs.isEmpty { + complete(with: Array(results.values)) + } + } + + private func scheduleTimeout() { + cancelTimeout() + let workItem = DispatchWorkItem { [weak self] in + guard let self else { return } + self.complete(with: Array(self.results.values)) + } + timeoutWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + timeoutInterval, execute: workItem) + } + + private func cancelTimeout() { + timeoutWorkItem?.cancel() + timeoutWorkItem = nil + } + + private func complete(with results: [Result]) { + guard state == .requesting else { return } + cancelTimeout() + central.stopScan() + state = .idle + + pendingPeripherals.removeAll() + missingUUIDs.removeAll() + pendingLookups.removeAll() + lookupByUUID.removeAll() + + let completion = self.completion + self.completion = nil + self.results.removeAll() + + completion?(results) + } +} + // MARK: - Models struct BluetoothAudioDevice: Identifiable {