diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index b48fb6f..4269151 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -3157,6 +3157,15 @@ } } }, + "Battery charging" : { + "comment" : "Battery charging without estimate" + }, + "Battery charging, %@ remaining" : { + "comment" : "Charging time remaining" + }, + "Battery fully charged" : { + "comment" : "Battery is full" + }, "Battery Information" : { "localizations" : { "ar" : { @@ -3464,6 +3473,17 @@ "comment" : "A section header that lists Bluetooth audio devices.", "isCommentAutoGenerated" : true }, + "Bluetooth device %@ at %d percent" : { + "comment" : "Bluetooth device battery", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bluetooth device %1$@ at %2$d percent" + } + } + } + }, "Boost your productivity with Clipboard Manager" : { "localizations" : { "ar" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index 9aa2bee..8c5ee35 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -11,12 +11,13 @@ struct LockScreenWeatherWidget: View { Text(snapshot.temperatureText) .font(.system(size: 24, weight: .semibold, design: .rounded)) .kerning(-0.4) - if let locationName = snapshot.locationName, !locationName.isEmpty { - Text("•") - .font(.system(size: 22, weight: .semibold, design: .rounded)) - Text(locationName) - .font(.system(size: 20, weight: .medium, design: .rounded)) - .foregroundStyle(Color.white.opacity(0.65)) + + ForEach(Array(infoSegments.enumerated()), id: \.offset) { _, segment in + HStack(spacing: 4) { + Text("•") + .font(.system(size: 22, weight: .semibold, design: .rounded)) + segmentView(for: segment) + } } } .foregroundStyle(Color.white.opacity(0.65)) @@ -28,20 +29,119 @@ struct LockScreenWeatherWidget: View { .accessibilityLabel(accessibilityLabel) } + private enum InfoSegment: Equatable { + case location(String) + case charging(String) + case bluetooth(String, Int) + } + + private var infoSegments: [InfoSegment] { + var segments: [InfoSegment] = [] + + if snapshot.showsLocation, let locationName = snapshot.locationName, !locationName.isEmpty { + segments.append(.location(locationName)) + } + + if let charging = snapshot.charging { + let iconName = charging.iconName + if !iconName.isEmpty { + segments.append(.charging(iconName)) + } + } + + if let bluetooth = snapshot.bluetooth { + segments.append(.bluetooth(bluetooth.iconName, bluetooth.batteryLevel)) + } + + return segments + } + + @ViewBuilder + private func segmentView(for segment: InfoSegment) -> some View { + switch segment { + case .location(let value): + Text(value) + .font(.system(size: 20, weight: .medium, design: .rounded)) + .lineLimit(1) + .truncationMode(.tail) + case .charging(let icon): + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + case .bluetooth(let icon, let value): + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 18, weight: .semibold)) + .symbolRenderingMode(.hierarchical) + Text("\(clampedBatteryLevel(value))%") + .font(.system(size: 20, weight: .medium, design: .rounded)) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + private func clampedBatteryLevel(_ level: Int) -> Int { + min(max(level, 0), 100) + } + private var accessibilityLabel: String { - if let locationName = snapshot.locationName, !locationName.isEmpty { - return String( - format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"), - snapshot.description, - snapshot.temperatureText, - locationName + var components: [String] = [] + + if snapshot.showsLocation, let locationName = snapshot.locationName, !locationName.isEmpty { + components.append( + String( + format: NSLocalizedString("Weather: %@ %@ in %@", comment: "Weather description, temperature, and location"), + snapshot.description, + snapshot.temperatureText, + locationName + ) + ) + } else { + components.append( + String( + format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"), + snapshot.description, + snapshot.temperatureText + ) ) } + if let charging = snapshot.charging { + components.append(accessibilityChargingText(for: charging)) + } + + if let bluetooth = snapshot.bluetooth { + components.append(accessibilityBluetoothText(for: bluetooth)) + } + + return components.joined(separator: ". ") + } + + private func accessibilityChargingText(for charging: LockScreenWeatherSnapshot.ChargingInfo) -> String { + if let minutes = charging.minutesRemaining, minutes > 0 { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] + formatter.unitsStyle = .full + let duration = formatter.string(from: TimeInterval(minutes * 60)) ?? "\(minutes) minutes" + return String( + format: NSLocalizedString("Battery charging, %@ remaining", comment: "Charging time remaining"), + duration + ) + } + + if charging.isPluggedIn && !charging.isCharging { + return NSLocalizedString("Battery fully charged", comment: "Battery is full") + } + + return NSLocalizedString("Battery charging", comment: "Battery charging without estimate") + } + + private func accessibilityBluetoothText(for bluetooth: LockScreenWeatherSnapshot.BluetoothInfo) -> String { return String( - format: NSLocalizedString("Weather: %@ %@", comment: "Weather description and temperature"), - snapshot.description, - snapshot.temperatureText + format: NSLocalizedString("Bluetooth device %@ at %d percent", comment: "Bluetooth device battery"), + bluetooth.deviceName, + bluetooth.batteryLevel ) } } diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 684fcca..d7d7677 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -178,6 +178,10 @@ struct GeneralSettings: View { @Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon @Default(.lockScreenPanelShowsBorder) var lockScreenPanelShowsBorder @Default(.lockScreenPanelUsesBlur) var lockScreenPanelUsesBlur + @Default(.enableLockScreenWeatherWidget) var enableLockScreenWeatherWidget + @Default(.lockScreenWeatherShowsLocation) var lockScreenWeatherShowsLocation + @Default(.lockScreenWeatherShowsCharging) var lockScreenWeatherShowsCharging + @Default(.lockScreenWeatherShowsBluetooth) var lockScreenWeatherShowsBluetooth var body: some View { Form { @@ -324,6 +328,11 @@ struct GeneralSettings: View { Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur) Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget) Defaults.Toggle("Show lock screen weather", key: .enableLockScreenWeatherWidget) + if enableLockScreenWeatherWidget { + Defaults.Toggle("Show location label", key: .lockScreenWeatherShowsLocation) + Defaults.Toggle("Show charging status", key: .lockScreenWeatherShowsCharging) + Defaults.Toggle("Show Bluetooth battery", key: .lockScreenWeatherShowsBluetooth) + } Button("Copy Latest Crash Report") { copyLatestCrashReport() diff --git a/DynamicIsland/managers/BluetoothAudioManager.swift b/DynamicIsland/managers/BluetoothAudioManager.swift index 857d5b7..9896fce 100644 --- a/DynamicIsland/managers/BluetoothAudioManager.swift +++ b/DynamicIsland/managers/BluetoothAudioManager.swift @@ -12,6 +12,7 @@ import AppKit import Defaults import SwiftUI import IOBluetooth +import IOKit /// Manages detection and monitoring of Bluetooth audio device connections class BluetoothAudioManager: ObservableObject { @@ -27,6 +28,15 @@ class BluetoothAudioManager: ObservableObject { private var cancellables = Set() private let coordinator = DynamicIslandViewCoordinator.shared private var pollingTimer: Timer? + private let bluetoothPreferencesSuite = "/Library/Preferences/com.apple.Bluetooth" + + @Published private(set) var batteryStatus: [String: String] = [:] + + private var batteryStatusByAddress: [String: Int] = [:] + private var batteryStatusByName: [String: Int] = [:] + private var missingBatteryLog: Set = [] + private var lastBatteryStatusUpdate: Date? + private let batteryStatusUpdateInterval: TimeInterval = 20 // MARK: - Initialization private init() { @@ -145,6 +155,8 @@ class BluetoothAudioManager: ObservableObject { // Update connection state isBluetoothAudioConnected = !connectedDevices.isEmpty + refreshBatteryLevelsForConnectedDevices() + if let lastDevice = connectedDevices.last { lastConnectedDevice = lastDevice print("🎧 [BluetoothAudioManager] ✅ Bluetooth audio connected: \(lastDevice.name)") @@ -201,9 +213,15 @@ class BluetoothAudioManager: ObservableObject { connectedDevices.append(audioDevice) lastConnectedDevice = audioDevice isBluetoothAudioConnected = true + + refreshBatteryLevelsForConnectedDevices() // Show HUD for new connection - showDeviceConnectedHUD(audioDevice) + if let refreshedDevice = connectedDevices.last { + showDeviceConnectedHUD(refreshedDevice) + } else { + showDeviceConnectedHUD(audioDevice) + } } } } @@ -229,6 +247,8 @@ class BluetoothAudioManager: ObservableObject { } isBluetoothAudioConnected = !connectedDevices.isEmpty + + refreshBatteryLevelsForConnectedDevices() } /// Handles Bluetooth device connection event (legacy - kept for compatibility) @@ -326,11 +346,29 @@ class BluetoothAudioManager: ObservableObject { /// Extracts battery level from Bluetooth device private func getBatteryLevel(from device: IOBluetoothDevice) -> Int? { - // Try to read battery level from device - // Note: This may require additional permissions or may not be available for all devices - - // Placeholder: Return nil if not available - // TODO: Implement actual battery level reading if device supports it + updateBatteryStatuses() + + if let level = batteryLevelFromRegistry(forAddress: device.addressString) { + clearMissingBatteryInfo(for: device) + return level + } + + if let name = device.name, let level = batteryLevelFromRegistry(forName: name) { + clearMissingBatteryInfo(for: device) + return level + } + + if let level = batteryLevelFromDefaults(forAddress: device.addressString) { + clearMissingBatteryInfo(for: device) + return level + } + + if let name = device.name, let level = batteryLevelFromDefaults(forName: name) { + clearMissingBatteryInfo(for: device) + return level + } + + logMissingBatteryInfo(for: device) return nil } @@ -372,6 +410,590 @@ class BluetoothAudioManager: ObservableObject { default: return .generic } } + + private func refreshBatteryLevelsForConnectedDevices() { + updateBatteryStatuses(force: true) + + 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 updatedDevice = device.withBatteryLevel(refreshedLevel) + updatedDevices.append(updatedDevice) + + if let refreshedLevel { + clearMissingBatteryInfo(forName: device.name, address: device.address) + } else { + logMissingBatteryInfo(forName: device.name, address: device.address) + } + } + + connectedDevices = updatedDevices + if let last = connectedDevices.last { + lastConnectedDevice = last + } + } + + private func batteryLevelFromDefaults(forAddress address: String?) -> Int? { + guard let address, !address.isEmpty else { return nil } + guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil } + guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil } + + let normalizedTarget = normalizeBluetoothIdentifier(address) + var bestMatch: Int? + + for (key, value) in deviceCache { + guard let payload = value as? [String: Any] else { continue } + if matchesBluetoothIdentifier(normalizedTarget, key: key, payload: payload) { + if let level = extractBatteryPercentage(from: payload) { + let clamped = clampBatteryPercentage(level) + bestMatch = max(bestMatch ?? clamped, clamped) + } + } + } + + return bestMatch + } + + private func batteryLevelFromDefaults(forName name: String) -> Int? { + guard !name.isEmpty else { return nil } + guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite) else { return nil } + guard let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { return nil } + + var bestMatch: Int? + + for value in deviceCache.values { + guard let payload = value as? [String: Any] else { continue } + let candidateName = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) + if let candidateName, candidateName.caseInsensitiveCompare(name) == .orderedSame { + if let level = extractBatteryPercentage(from: payload) { + let clamped = clampBatteryPercentage(level) + bestMatch = max(bestMatch ?? clamped, clamped) + } + } + } + + return bestMatch + } + + private func batteryLevelFromRegistry(forName name: String) -> Int? { + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + let normalized = normalizeProductName(trimmed) + guard !normalized.isEmpty else { return nil } + if let value = batteryStatusByName[normalized] { + return clampBatteryPercentage(value) + } + return nil + } + + private func updateBatteryStatuses(force: Bool = false) { + let now = Date() + if !force, let lastBatteryStatusUpdate, + now.timeIntervalSince(lastBatteryStatusUpdate) < batteryStatusUpdateInterval { + return + } + + var combinedAddressPercentages: [String: Int] = [:] + var combinedNamePercentages: [String: Int] = [:] + + let registry = collectRegistryBatteryLevels() + mergeBatteryLevels(into: &combinedAddressPercentages, from: registry.addresses) + mergeBatteryLevels(into: &combinedNamePercentages, from: registry.names) + + let defaults = collectDefaultsBatteryLevels() + mergeBatteryLevels(into: &combinedAddressPercentages, from: defaults.addresses) + mergeBatteryLevels(into: &combinedNamePercentages, from: defaults.names) + + let profiler = collectSystemProfilerBatteryLevels() + mergeBatteryLevels(into: &combinedAddressPercentages, from: profiler.addresses) + mergeBatteryLevels(into: &combinedNamePercentages, from: profiler.names) + + var statuses: [String: String] = [:] + for (key, value) in combinedAddressPercentages { + statuses[key] = String(clampBatteryPercentage(value)) + } + + let applyUpdates = { + self.batteryStatus = statuses + self.batteryStatusByAddress = combinedAddressPercentages + self.batteryStatusByName = combinedNamePercentages + self.lastBatteryStatusUpdate = now + } + + if Thread.isMainThread { + applyUpdates() + } else { + DispatchQueue.main.sync(execute: applyUpdates) + } + } + + private func mergeBatteryLevels(into target: inout [String: Int], from source: [String: Int]) { + guard !source.isEmpty else { return } + for (key, value) in source { + guard !key.isEmpty else { continue } + if let existing = target[key] { + target[key] = max(existing, value) + } else { + target[key] = value + } + } + } + + private func collectRegistryBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) { + var addressPercentages: [String: Int] = [:] + var namePercentages: [String: Int] = [:] + + var iterator = io_iterator_t() + let matchingDict: CFDictionary = IOServiceMatching("AppleDeviceManagementHIDEventService") + + let servicePort: mach_port_t + if #available(macOS 12.0, *) { + servicePort = kIOMainPortDefault + } else { + servicePort = kIOMasterPortDefault + } + + let kernResult = IOServiceGetMatchingServices(servicePort, matchingDict, &iterator) + + if kernResult == KERN_SUCCESS { + var entry: io_object_t = IOIteratorNext(iterator) + while entry != 0 { + if let percent = IORegistryEntryCreateCFProperty(entry, "BatteryPercent" as CFString, kCFAllocatorDefault, 0)?.takeRetainedValue() as? Int { + let normalizedPercent = clampBatteryPercentage(percent) + + let identifierKeys = ["DeviceAddress", "SerialNumber", "BD_ADDR"] + for key in identifierKeys { + if let identifier = stringValue(forKey: key, entry: entry) { + let normalizedIdentifier = normalizeBluetoothIdentifier(identifier) + if !normalizedIdentifier.isEmpty { + if let existing = addressPercentages[normalizedIdentifier] { + addressPercentages[normalizedIdentifier] = max(existing, normalizedPercent) + } else { + addressPercentages[normalizedIdentifier] = normalizedPercent + } + } + } + } + + let nameKeys = [ + "Product", + "ProductName", + "DeviceName", + "Name", + "USB Product Name", + "Bluetooth Product Name" + ] + + for key in nameKeys { + if let product = stringValue(forKey: key, entry: entry) { + let normalizedName = normalizeProductName(product) + if !normalizedName.isEmpty { + if let existing = namePercentages[normalizedName] { + namePercentages[normalizedName] = max(existing, normalizedPercent) + } else { + namePercentages[normalizedName] = normalizedPercent + } + } + } + } + } + + IOObjectRelease(entry) + entry = IOIteratorNext(iterator) + } + } + + IOObjectRelease(iterator) + + return (addressPercentages, namePercentages) + } + + private func collectDefaultsBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) { + guard let preferences = UserDefaults(suiteName: bluetoothPreferencesSuite), + let deviceCache = preferences.object(forKey: "DeviceCache") as? [String: Any] else { + return ([:], [:]) + } + + var addressPercentages: [String: Int] = [:] + var namePercentages: [String: Int] = [:] + + for (key, value) in deviceCache { + guard let payload = value as? [String: Any] else { continue } + guard let level = extractBatteryPercentage(from: payload) else { continue } + let clamped = clampBatteryPercentage(level) + + let normalizedKey = normalizeBluetoothIdentifier(key) + if !normalizedKey.isEmpty { + addressPercentages[normalizedKey] = max(addressPercentages[normalizedKey] ?? clamped, clamped) + } + + for identifier in identifiersFromDeviceCachePayload(payload) { + addressPercentages[identifier] = max(addressPercentages[identifier] ?? clamped, clamped) + } + + if let name = (payload["Name"] as? String) ?? (payload["DeviceName"] as? String) { + let normalizedName = normalizeProductName(name) + if !normalizedName.isEmpty { + namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped) + } + } + } + + return (addressPercentages, namePercentages) + } + + private func collectSystemProfilerBatteryLevels() -> (addresses: [String: Int], names: [String: Int]) { + guard let root = systemProfilerBluetoothDictionary() else { + return ([:], [:]) + } + + var addressPercentages: [String: Int] = [:] + var namePercentages: [String: Int] = [:] + + if let connectedList = root["device_connected"] as? [[String: [String: Any]]] { + for deviceGroup in connectedList { + for (rawName, payload) in deviceGroup { + guard let percent = extractSystemProfilerBatteryPercentage(from: payload) else { continue } + let clamped = clampBatteryPercentage(percent) + + let normalizedName = normalizeProductName(rawName) + if !normalizedName.isEmpty { + namePercentages[normalizedName] = max(namePercentages[normalizedName] ?? clamped, clamped) + } + + for address in profilerAddressCandidates(from: payload) { + let normalizedAddress = normalizeBluetoothIdentifier(address) + if !normalizedAddress.isEmpty { + addressPercentages[normalizedAddress] = max(addressPercentages[normalizedAddress] ?? clamped, clamped) + } + } + } + } + } + + return (addressPercentages, namePercentages) + } + + private func identifiersFromDeviceCachePayload(_ payload: [String: Any]) -> [String] { + var identifiers: Set = [] + let candidateKeys = ["DeviceAddress", "Address", "BD_ADDR", "SerialNumber"] + + for key in candidateKeys { + if let value = payload[key] as? String { + let normalized = normalizeBluetoothIdentifier(value) + if !normalized.isEmpty { + identifiers.insert(normalized) + } + } else if let data = payload[key] as? Data, + let ascii = String(data: data, encoding: .utf8) { + let normalized = normalizeBluetoothIdentifier(ascii) + if !normalized.isEmpty { + identifiers.insert(normalized) + } + } + } + + return Array(identifiers) + } + + private func systemProfilerBluetoothDictionary() -> [String: Any]? { + let process = Process() + process.launchPath = "/usr/sbin/system_profiler" + process.arguments = ["SPBluetoothDataType", "-json"] + + let outputPipe = Pipe() + process.standardOutput = outputPipe + let errorPipe = Pipe() + process.standardError = errorPipe + + do { + try process.run() + } catch { + return nil + } + + let data = outputPipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + return nil + } + guard !data.isEmpty else { return nil } + + guard let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let entries = jsonObject["SPBluetoothDataType"] as? [[String: Any]], + let root = entries.first else { + return nil + } + + return root + } + + private func extractSystemProfilerBatteryPercentage(from payload: [String: Any]) -> Int? { + let preferredKeys = [ + "device_batteryLevelCase", + "device_batteryLevelLeft", + "device_batteryLevelRight", + "device_batteryLevelMain", + "device_batteryLevel", + "device_batteryLevelCombined", + "device_batteryPercentCombined", + "Left Battery Level", + "Right Battery Level", + "Battery Level", + "BatteryPercent" + ] + + var values: [Int] = [] + + for key in preferredKeys { + if let raw = payload[key], let converted = convertToBatteryPercentage(raw) { + values.append(converted) + } + } + + if values.isEmpty { + for (key, raw) in payload where key.lowercased().contains("battery") { + if let converted = convertToBatteryPercentage(raw) { + values.append(converted) + } + } + } + + let validValues = values.filter { $0 >= 0 } + return validValues.max() + } + + private func profilerAddressCandidates(from payload: [String: Any]) -> [String] { + var addresses: Set = [] + let keys = [ + "device_address", + "device_mac_address", + "device_serial_num", + "device_serialNumber", + "device_serial_number" + ] + + for key in keys { + if let value = payload[key] as? String, !value.isEmpty { + addresses.insert(value) + } else if let data = payload[key] as? Data, + let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty { + addresses.insert(ascii) + } + } + + return Array(addresses) + } + + private func batteryLevelFromRegistry(forAddress address: String?) -> Int? { + guard let address, !address.isEmpty else { return nil } + let normalized = normalizeBluetoothIdentifier(address) + if let value = batteryStatusByAddress[normalized] { + return clampBatteryPercentage(value) + } + if let storedValue = batteryStatus[normalized], let value = Int(storedValue) { + return clampBatteryPercentage(value) + } + return nil + } + + private func extractBatteryPercentage(from payload: [String: Any]) -> Int? { + let keys = [ + "BatteryPercent", + "BatteryPercentCase", + "BatteryPercentLeft", + "BatteryPercentRight", + "BatteryPercentSingle", + "BatteryPercentCombined", + "BatteryPercentMain", + "device_batteryLevelLeft", + "device_batteryLevelRight", + "device_batteryLevelMain", + "Left Battery Level", + "Right Battery Level" + ] + + var values: [Int] = [] + + for key in keys { + guard let raw = payload[key] else { continue } + if let converted = convertToBatteryPercentage(raw) { + values.append(converted) + } + } + + if values.isEmpty, + let services = payload["Services"] as? [[String: Any]] { + for service in services { + if let serviceValues = service["BatteryPercentages"] as? [String: Any] { + for value in serviceValues.values { + if let converted = convertToBatteryPercentage(value) { + values.append(converted) + } + } + } + } + } + + return values.max() + } + + private func convertToBatteryPercentage(_ value: Any) -> Int? { + if let number = value as? Int { + if number == 1 { + return 100 + } + return number + } + if let number = value as? Double { + if number <= 1.0 { + return Int(number * 100) + } + return Int(number) + } + if let string = value as? String { + let trimmed = string.replacingOccurrences(of: "%", with: "") + if let doubleValue = Double(trimmed) { + if doubleValue <= 1.0 { + return Int(doubleValue * 100) + } + return Int(doubleValue) + } + } + + return nil + } + + private func clampBatteryPercentage(_ value: Int) -> Int { + min(max(value, 0), 100) + } + + private func matchesBluetoothIdentifier(_ normalizedTarget: String, key: String, payload: [String: Any]) -> Bool { + if normalizeBluetoothIdentifier(key) == normalizedTarget { + return true + } + + let candidateFields: [String?] = [ + payload["DeviceAddress"] as? String, + payload["Address"] as? String, + payload["BD_ADDR"] as? String, + payload["SerialNumber"] as? String + ] + + for field in candidateFields { + if let field, normalizeBluetoothIdentifier(field) == normalizedTarget { + return true + } + } + + if let deviceAddressData = payload["DeviceAddress"] as? Data, + let ascii = String(data: deviceAddressData, encoding: .utf8), + normalizeBluetoothIdentifier(ascii) == normalizedTarget { + return true + } + + if let addressData = payload["BD_ADDR"] as? Data, + let ascii = String(data: addressData, encoding: .utf8), + normalizeBluetoothIdentifier(ascii) == normalizedTarget { + return true + } + + if let serialData = payload["SerialNumber"] as? Data, + let ascii = String(data: serialData, encoding: .utf8), + normalizeBluetoothIdentifier(ascii) == normalizedTarget { + return true + } + + return false + } + + private func logMissingBatteryInfo(for device: IOBluetoothDevice) { + let name = device.name ?? "" + let address = device.addressString ?? "" + logMissingBatteryInfo(forName: name, address: address) + } + + private func clearMissingBatteryInfo(for device: IOBluetoothDevice) { + let name = device.name ?? "" + let address = device.addressString ?? "" + clearMissingBatteryInfo(forName: name, address: address) + } + + private func logMissingBatteryInfo(forName name: String, address: String) { + let key = missingBatteryKey(name: name, address: address) + guard !missingBatteryLog.contains(key) else { return } + missingBatteryLog.insert(key) + + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) + + let displayName = trimmedName.isEmpty ? "unknown device" : trimmedName + let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame + let displayAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "N/A" : trimmedAddress + print("🎧 [BluetoothAudioManager] ⚠️ Battery percentage unavailable for \(displayName) (\(displayAddress))") + } + + private func clearMissingBatteryInfo(forName name: String, address: String) { + let key = missingBatteryKey(name: name, address: address) + missingBatteryLog.remove(key) + } + + private func missingBatteryKey(name: String, address: String) -> String { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedAddress = address.trimmingCharacters(in: .whitespacesAndNewlines) + + let normalizedName = normalizeProductName(trimmedName) + + let isUnknownAddress = trimmedAddress.caseInsensitiveCompare("unknown") == .orderedSame + let normalizedAddress = (trimmedAddress.isEmpty || isUnknownAddress) ? "" : normalizeBluetoothIdentifier(trimmedAddress) + + if normalizedName.isEmpty && normalizedAddress.isEmpty { + return "unknown" + } + + return normalizedName + "#" + normalizedAddress + } + + private func stringValue(forKey key: String, entry: io_object_t) -> String? { + guard let unmanaged = IORegistryEntryCreateCFProperty(entry, key as CFString, kCFAllocatorDefault, 0) else { + return nil + } + + let value = unmanaged.takeRetainedValue() + + if let string = value as? String, !string.isEmpty { + return string + } + + if let data = value as? Data, let ascii = String(data: data, encoding: .utf8), !ascii.isEmpty { + return ascii + } + + return nil + } + + private func normalizeBluetoothIdentifier(_ value: String) -> String { + return value + .lowercased() + .replacingOccurrences(of: ":", with: "") + .replacingOccurrences(of: "-", with: "") + .replacingOccurrences(of: " ", with: "") + } + + private func normalizeProductName(_ name: String) -> String { + let components = name + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + + return components.joined() + } // MARK: - HUD Display @@ -382,8 +1004,14 @@ class BluetoothAudioManager: ObservableObject { print("🎧 [BluetoothAudioManager] 📱 Showing device connected HUD") // Convert battery percentage to 0.0-1.0 value - let batteryValue: CGFloat = if let battery = device.batteryLevel { - CGFloat(battery) / 100.0 + let reportedBattery = device.batteryLevel + ?? batteryLevelFromRegistry(forAddress: device.address) + ?? batteryLevelFromRegistry(forName: device.name) + ?? batteryLevelFromDefaults(forAddress: device.address) + ?? batteryLevelFromDefaults(forName: device.name) + + let batteryValue: CGFloat = if let battery = reportedBattery { + CGFloat(clampBatteryPercentage(battery)) / 100.0 } else { 0.0 // 0 means battery info not available } @@ -411,16 +1039,47 @@ class BluetoothAudioManager: ObservableObject { observers.removeAll() cancellables.removeAll() } + + @MainActor + func refreshConnectedDeviceBatteries() { + refreshBatteryLevelsForConnectedDevices() + } } // MARK: - Models struct BluetoothAudioDevice: Identifiable { - let id = UUID() + let id: UUID let name: String let address: String let batteryLevel: Int? // 0-100, nil if not available let deviceType: BluetoothAudioDeviceType + + init( + id: UUID = UUID(), + name: String, + address: String, + batteryLevel: Int?, + deviceType: BluetoothAudioDeviceType + ) { + self.id = id + self.name = name + self.address = address + self.batteryLevel = batteryLevel + self.deviceType = deviceType + } +} + +extension BluetoothAudioDevice { + func withBatteryLevel(_ batteryLevel: Int?) -> BluetoothAudioDevice { + BluetoothAudioDevice( + id: id, + name: name, + address: address, + batteryLevel: batteryLevel, + deviceType: deviceType + ) + } } enum BluetoothAudioDeviceType { diff --git a/DynamicIsland/managers/LockScreenWeatherManager.swift b/DynamicIsland/managers/LockScreenWeatherManager.swift index cb0c272..f16539e 100644 --- a/DynamicIsland/managers/LockScreenWeatherManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherManager.swift @@ -1,6 +1,7 @@ import Foundation import Defaults import CoreLocation +import Combine @MainActor final class LockScreenWeatherManager: ObservableObject { @@ -11,8 +12,12 @@ final class LockScreenWeatherManager: ObservableObject { private let provider = LockScreenWeatherProvider() private let locationProvider = LockScreenWeatherLocationProvider() private var lastFetchDate: Date? + private var latestWeatherPayload: LockScreenWeatherSnapshot? + private var cancellables = Set() - private init() {} + private init() { + observeAccessoryChanges() + } func prepareLocationAccess() { locationProvider.prepareAuthorization() @@ -28,9 +33,15 @@ final class LockScreenWeatherManager: ObservableObject { Task { @MainActor [weak self] in guard let self else { return } - await self.refresh(force: self.snapshot == nil) - if let snapshot = self.snapshot { - LockScreenWeatherPanelManager.shared.show(with: snapshot) + let snapshot = await self.refresh(force: self.snapshot == nil) + + guard LockScreenManager.shared.currentLockStatus else { + LockScreenWeatherPanelManager.shared.hide() + return + } + + if let snapshot { + self.deliver(snapshot, forceShow: true) } else { LockScreenWeatherPanelManager.shared.hide() } @@ -41,32 +52,207 @@ final class LockScreenWeatherManager: ObservableObject { LockScreenWeatherPanelManager.shared.hide() } - func refresh(force: Bool = false) async { + @discardableResult + func refresh(force: Bool = false) async -> LockScreenWeatherSnapshot? { let interval = Defaults[.lockScreenWeatherRefreshInterval] if !force, let lastFetchDate, Date().timeIntervalSince(lastFetchDate) < interval { - if let snapshot = snapshot { - LockScreenWeatherPanelManager.shared.update(with: snapshot) + if let payload = latestWeatherPayload { + if Defaults[.lockScreenWeatherShowsBluetooth] { + BluetoothAudioManager.shared.refreshConnectedDeviceBatteries() + } + let snapshot = makeSnapshot(from: payload) + self.snapshot = snapshot + deliver(snapshot, forceShow: false) + return snapshot + } else if let snapshot = snapshot { + deliver(snapshot, forceShow: false) + return snapshot } - return + return snapshot } do { let location = await locationProvider.currentLocation() - let snapshot = try await provider.fetchSnapshot(location: location) + let payload = try await provider.fetchSnapshot(location: location) + latestWeatherPayload = payload + if Defaults[.lockScreenWeatherShowsBluetooth] { + BluetoothAudioManager.shared.refreshConnectedDeviceBatteries() + } + let snapshot = makeSnapshot(from: payload) self.snapshot = snapshot lastFetchDate = Date() - LockScreenWeatherPanelManager.shared.update(with: snapshot) + deliver(snapshot, forceShow: false) + return snapshot } catch { NSLog("LockScreenWeatherManager: failed to fetch weather - \(error.localizedDescription)") + return snapshot } } + + private func observeAccessoryChanges() { + let bluetoothManager = BluetoothAudioManager.shared + + bluetoothManager.$connectedDevices + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.handleAccessoryUpdate(triggerBluetoothRefresh: false) + } + .store(in: &cancellables) + + bluetoothManager.$lastConnectedDevice + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.handleAccessoryUpdate(triggerBluetoothRefresh: false) + } + .store(in: &cancellables) + + let battery = BatteryStatusViewModel.shared + let batteryPublishers: [AnyPublisher] = [ + battery.$isCharging.map { _ in () }.eraseToAnyPublisher(), + battery.$isPluggedIn.map { _ in () }.eraseToAnyPublisher(), + battery.$timeToFullCharge.map { _ in () }.eraseToAnyPublisher() + ] + + Publishers.MergeMany(batteryPublishers) + .debounce(for: .milliseconds(150), scheduler: RunLoop.main) + .sink { [weak self] in + self?.handleAccessoryUpdate(triggerBluetoothRefresh: false) + } + .store(in: &cancellables) + + let defaultsPublishers: [AnyPublisher] = [ + Defaults.publisher(.lockScreenWeatherShowsLocation, options: []) + .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenWeatherShowsCharging, options: []) + .map { _ in () }.eraseToAnyPublisher(), + Defaults.publisher(.lockScreenWeatherShowsBluetooth, options: []) + .map { _ in () }.eraseToAnyPublisher() + ] + + Publishers.MergeMany(defaultsPublishers) + .sink { [weak self] in + self?.handleAccessoryUpdate(triggerBluetoothRefresh: true) + } + .store(in: &cancellables) + } + + private func handleAccessoryUpdate(triggerBluetoothRefresh: Bool) { + guard let payload = latestWeatherPayload else { return } + + if triggerBluetoothRefresh { + BluetoothAudioManager.shared.refreshConnectedDeviceBatteries() + } + + let snapshot = makeSnapshot(from: payload) + self.snapshot = snapshot + deliver(snapshot, forceShow: false) + } + + private func deliver(_ snapshot: LockScreenWeatherSnapshot, forceShow: Bool) { + guard LockScreenManager.shared.currentLockStatus else { return } + + if forceShow { + LockScreenWeatherPanelManager.shared.show(with: snapshot) + } else { + LockScreenWeatherPanelManager.shared.update(with: snapshot) + } + } + + private func makeSnapshot(from payload: LockScreenWeatherSnapshot) -> LockScreenWeatherSnapshot { + let locationName = payload.locationName + let shouldShowLocation = Defaults[.lockScreenWeatherShowsLocation] && !(locationName?.isEmpty ?? true) + let chargingInfo = Defaults[.lockScreenWeatherShowsCharging] ? makeChargingInfo() : nil + let bluetoothInfo = Defaults[.lockScreenWeatherShowsBluetooth] ? makeBluetoothInfo() : nil + + return LockScreenWeatherSnapshot( + temperatureText: payload.temperatureText, + symbolName: payload.symbolName, + description: payload.description, + locationName: locationName, + charging: chargingInfo, + bluetooth: bluetoothInfo, + showsLocation: shouldShowLocation + ) + } + + private func makeChargingInfo() -> LockScreenWeatherSnapshot.ChargingInfo? { + let battery = BatteryStatusViewModel.shared + let isPluggedIn = battery.isPluggedIn + let isCharging = battery.isCharging + + guard isPluggedIn || isCharging else { + return nil + } + + let minutes = battery.timeToFullCharge + let remaining = minutes > 0 ? minutes : nil + + return LockScreenWeatherSnapshot.ChargingInfo( + minutesRemaining: remaining, + isCharging: isCharging, + isPluggedIn: isPluggedIn + ) + } + + private func makeBluetoothInfo() -> LockScreenWeatherSnapshot.BluetoothInfo? { + let manager = BluetoothAudioManager.shared + + guard manager.isBluetoothAudioConnected else { + return nil + } + + let device = manager.connectedDevices.last ?? manager.lastConnectedDevice + + guard let device else { + return nil + } + + guard let batteryLevel = device.batteryLevel else { + return nil + } + + return LockScreenWeatherSnapshot.BluetoothInfo( + deviceName: device.name, + batteryLevel: clampBluetoothBatteryLevel(batteryLevel), + iconName: device.deviceType.sfSymbol + ) + } + + private func clampBluetoothBatteryLevel(_ level: Int) -> Int { + min(max(level, 0), 100) + } } struct LockScreenWeatherSnapshot: Equatable { + struct ChargingInfo: Equatable { + let minutesRemaining: Int? + let isCharging: Bool + let isPluggedIn: Bool + + var iconName: String { + if isCharging { + return "bolt.fill" + } + if isPluggedIn { + return "powerplug.fill" + } + return "" + } + } + + struct BluetoothInfo: Equatable { + let deviceName: String + let batteryLevel: Int + let iconName: String + } + let temperatureText: String let symbolName: String let description: String let locationName: String? + let charging: ChargingInfo? + let bluetooth: BluetoothInfo? + let showsLocation: Bool } private actor LockScreenWeatherProvider { @@ -122,7 +308,10 @@ private actor LockScreenWeatherProvider { temperatureText: temperatureText, symbolName: symbol, description: description, - locationName: locationName + locationName: locationName, + charging: nil, + bluetooth: nil, + showsLocation: true ) } } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index b4c3eef..8b16715 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -339,6 +339,9 @@ extension Defaults.Keys { static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true) static let enableLockScreenWeatherWidget = Key("enableLockScreenWeatherWidget", default: true) static let lockScreenWeatherRefreshInterval = Key("lockScreenWeatherRefreshInterval", default: 30 * 60) + static let lockScreenWeatherShowsLocation = Key("lockScreenWeatherShowsLocation", default: true) + static let lockScreenWeatherShowsCharging = Key("lockScreenWeatherShowsCharging", default: true) + static let lockScreenWeatherShowsBluetooth = Key("lockScreenWeatherShowsBluetooth", default: true) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true)