diff --git a/Commons/Time.qml b/Commons/Time.qml index 45afd825..dcf9d4ef 100644 --- a/Commons/Time.qml +++ b/Commons/Time.qml @@ -105,18 +105,18 @@ Singleton { return I18n.tr("notifications.time.diffM"); if (diff < 3600000) return I18n.tr("notifications.time.diffMM", { - "diff": Math.floor(diff / 60000) - }); + "diff": Math.floor(diff / 60000) + }); if (diff < 7200000) return I18n.tr("notifications.time.diffH"); if (diff < 86400000) return I18n.tr("notifications.time.diffHH", { - "diff": Math.floor(diff / 3600000) - }); - if (diff < 172800000) + "diff": Math.floor(diff / 3600000) + }); + if (diff < 172800000) return I18n.tr("notifications.time.diffD"); return I18n.tr("notifications.time.diffDD", { - "diff": Math.floor(diff / 86400000) - }); + "diff": Math.floor(diff / 86400000) + }); } } diff --git a/Services/Networking/BluetoothService.qml b/Services/Networking/BluetoothService.qml index 761c26d9..2b5451bb 100644 --- a/Services/Networking/BluetoothService.qml +++ b/Services/Networking/BluetoothService.qml @@ -10,64 +10,261 @@ import qs.Services.UI Singleton { id: root - property bool airplaneModeToggled: false - property bool lastBluetoothBlocked: false + // ============================================================================ + // Properties + // ============================================================================ + readonly property BluetoothAdapter adapter: Bluetooth.defaultAdapter readonly property int state: adapter?.state ?? 0 - readonly property bool available: (adapter !== null) + readonly property bool available: adapter !== null readonly property bool enabled: adapter?.enabled ?? false - readonly property bool blocked: (adapter?.state === BluetoothAdapterState.Blocked) - readonly property bool discovering: (adapter && adapter.discovering) ?? false - readonly property var devices: adapter ? adapter.devices : null - readonly property var pairedDevices: { - if (!adapter || !adapter.devices) { - return []; - } - return adapter.devices.values.filter(dev => { - return dev && (dev.paired || dev.trusted); - }); - } - readonly property var connectedDevices: { - if (!adapter || !adapter.devices) { - return []; - } - return adapter.devices.values.filter(dev => dev && dev.connected); - } + readonly property bool blocked: adapter?.state === BluetoothAdapterState.Blocked + readonly property bool discovering: adapter?.discovering ?? false + readonly property var devices: adapter?.devices ?? null - readonly property var allDevicesWithBattery: { - if (!adapter || !adapter.devices) { - return []; - } - return adapter.devices.values.filter(dev => { - return dev && dev.batteryAvailable && dev.battery > 0; - }); - } + readonly property var pairedDevices: _filterDevices(dev => dev.paired || dev.trusted) + readonly property var connectedDevices: _filterDevices(dev => dev.connected) + readonly property var allDevicesWithBattery: _filterDevices(dev => dev.batteryAvailable && dev.battery > 0) + + // Internal state tracking + property bool airplaneModeToggled: false + property bool lastBluetoothBlocked: false + property var devicesBeingPaired: ({}) + property var connectionAttempts: ({}) + + // ============================================================================ + // Initialization + // ============================================================================ function init() { Logger.i("Bluetooth", "Service started"); + _configureAdapter(); } - Timer { - id: discoveryTimer - interval: 1000 - repeat: false - onTriggered: adapter.discovering = true + onAdapterChanged: _configureAdapter() + + // ============================================================================ + // Public API - Device Actions + // ============================================================================ + + function connectDeviceWithTrust(device) { + if (!device) + return; + + const deviceName = _getDeviceName(device); + if (!device.trusted) { + Logger.i("Bluetooth", "Setting device as trusted:", deviceName); + device.trusted = true; + } + + if (!device.paired) { + Logger.i("Bluetooth", "Pairing device before connection:", deviceName); + devicesBeingPaired[device.address] = true; + device.pair(); + } else { + Qt.callLater(() => { + if (device && !device.connected) { + Logger.i("Bluetooth", "Connecting to paired device:", deviceName); + device.connect(); + } + }); + } } + function disconnectDevice(device) { + if (device) + device.disconnect(); + } + + function forgetDevice(device) { + if (!device) + return; + + Logger.i("Bluetooth", "Forgetting device:", _getDeviceName(device)); + _cleanupDeviceTracking(device.address); + device.trusted = false; + device.forget(); + } + + function forgetAndRepair(device) { + if (!device) + return; + + Logger.i("Bluetooth", "Force re-pairing device:", _getDeviceName(device)); + const deviceAddress = device.address; + + delete connectionAttempts[deviceAddress]; + device.trusted = false; + device.forget(); + + Qt.callLater(() => { + if (device) { + Logger.i("Bluetooth", "Starting fresh pairing for:", _getDeviceName(device)); + devicesBeingPaired[deviceAddress] = true; + device.trusted = true; + device.pair(); + } + }); + } + + function setBluetoothEnabled(state) { + if (!adapter) { + Logger.w("Bluetooth", "No adapter available"); + return; + } + Logger.i("Bluetooth", "SetBluetoothEnabled", state); + adapter.enabled = state; + } + + // ============================================================================ + // Public API - Device Info Helpers + // ============================================================================ + + function sortDevices(devices) { + return devices.sort((a, b) => { + const aName = _getDeviceName(a); + const bName = _getDeviceName(b); + const aHasRealName = aName.includes(" ") && aName.length > 3; + const bHasRealName = bName.includes(" ") && bName.length > 3; + + if (aHasRealName !== bHasRealName) + return aHasRealName ? -1 : 1; + + const aSignal = a.signalStrength > 0 ? a.signalStrength : 0; + const bSignal = b.signalStrength > 0 ? b.signalStrength : 0; + return bSignal - aSignal; + }); + } + + function getDeviceIcon(device) { + if (!device) + return "bt-device-generic"; + + const name = _getDeviceName(device).toLowerCase(); + const icon = (device.icon || "").toLowerCase(); + + const patterns = { + "bt-device-headphones": ["headset", "audio", "headphone", "airpod", "arctis"], + "bt-device-mouse": ["mouse"], + "bt-device-keyboard": ["keyboard"], + "bt-device-phone": ["phone", "iphone", "android", "samsung"], + "bt-device-watch": ["watch"], + "bt-device-speaker": ["speaker"], + "bt-device-tv": ["display", "tv"] + }; + + for (const [deviceIcon, keywords] of Object.entries(patterns)) { + if (keywords.some(keyword => icon.includes(keyword) || name.includes(keyword))) { + return deviceIcon; + } + } + return "bt-device-generic"; + } + + function canConnect(device) { + return device && !device.connected && !device.pairing && !device.blocked; + } + + function canDisconnect(device) { + return device && device.connected && !device.pairing && !device.blocked; + } + + function isDeviceBusy(device) { + return device && (device.pairing || device.state === BluetoothDeviceState.Disconnecting || device.state === BluetoothDeviceState.Connecting); + } + + function getStatusString(device) { + if (device.state === BluetoothDeviceState.Connecting) + return "Connecting..."; + if (device.pairing) + return "Pairing..."; + if (device.blocked) + return "Blocked"; + return ""; + } + + function getSignalStrength(device) { + if (!device || !device.signalStrength || device.signalStrength <= 0) { + return "Signal: Unknown"; + } + const signal = device.signalStrength; + const levels = [[80, "Excellent"], [60, "Good"], [40, "Fair"], [20, "Poor"]]; + for (const [threshold, label] of levels) { + if (signal >= threshold) + return `Signal: ${label}`; + } + return "Signal: Very poor"; + } + + function getSignalIcon(device) { + if (!device || !device.signalStrength || device.signalStrength <= 0) { + return "antenna-bars-off"; + } + const signal = device.signalStrength; + const icons = [[80, "5"], [60, "4"], [40, "3"], [20, "2"]]; + for (const [threshold, level] of icons) { + if (signal >= threshold) + return `antenna-bars-${level}`; + } + return "antenna-bars-1"; + } + + function getBattery(device) { + return `Battery: ${Math.round(device.battery * 100)}%`; + } + + // ============================================================================ + // Device Monitoring + // ============================================================================ + + Repeater { + model: root.devices + + Connections { + target: modelData + + function onPairedChanged() { + if (!modelData?.paired) + return; + _handlePairingSuccess(modelData); + } + + function onPairingChanged() { + if (!modelData) + return; + _handlePairingCancelled(modelData); + } + + function onConnectedChanged() { + if (!modelData) + return; + _handleConnectionChanged(modelData); + } + + function onStateChanged() { + if (!modelData) + return; + _handleStateChanged(modelData); + } + } + } + + // ============================================================================ + // Adapter State Monitoring + // ============================================================================ + Connections { target: adapter + function onStateChanged() { - if (!adapter) { - Logger.w("Bluetooth", "onStateChanged", "No adapter available"); - return; - } - if (adapter.state === BluetoothAdapterState.Enabling || adapter.state === BluetoothAdapterState.Disabling) { + if (!adapter || adapter.state === BluetoothAdapterState.Enabling || adapter.state === BluetoothAdapterState.Disabling) { return; } - Logger.d("Bluetooth", "onStateChanged", adapter.state); - const bluetoothBlockedToggled = (root.blocked !== lastBluetoothBlocked); - root.lastBluetoothBlocked = root.blocked; + Logger.d("Bluetooth", "Adapter state changed:", adapter.state); + const bluetoothBlockedToggled = root.blocked !== lastBluetoothBlocked; + lastBluetoothBlocked = root.blocked; + if (bluetoothBlockedToggled) { checkWifiBlocked.running = true; } else if (adapter.state === BluetoothAdapterState.Enabled) { @@ -79,176 +276,113 @@ Singleton { } } - function sortDevices(devices) { - return devices.sort((a, b) => { - var aName = a.name || a.deviceName || ""; - var bName = b.name || b.deviceName || ""; + // ============================================================================ + // Private Helper Functions + // ============================================================================ - var aHasRealName = aName.includes(" ") && aName.length > 3; - var bHasRealName = bName.includes(" ") && bName.length > 3; - - if (aHasRealName && !bHasRealName) - return -1; - if (!aHasRealName && bHasRealName) - return 1; - - var aSignal = (a.signalStrength !== undefined && a.signalStrength > 0) ? a.signalStrength : 0; - var bSignal = (b.signalStrength !== undefined && b.signalStrength > 0) ? b.signalStrength : 0; - return bSignal - aSignal; - }); + function _filterDevices(filterFn) { + if (!adapter?.devices) + return []; + return adapter.devices.values.filter(dev => dev && filterFn(dev)); } - function getDeviceIcon(device) { - if (!device) { - return "bt-device-generic"; - } - - var name = (device.name || device.deviceName || "").toLowerCase(); - var icon = (device.icon || "").toLowerCase(); - if (icon.includes("headset") || icon.includes("audio") || name.includes("headphone") || name.includes("airpod") || name.includes("headset") || name.includes("arctis")) { - return "bt-device-headphones"; - } - - if (icon.includes("mouse") || name.includes("mouse")) { - return "bt-device-mouse"; - } - if (icon.includes("keyboard") || name.includes("keyboard")) { - return "bt-device-keyboard"; - } - if (icon.includes("phone") || name.includes("phone") || name.includes("iphone") || name.includes("android") || name.includes("samsung")) { - return "bt-device-phone"; - } - if (icon.includes("watch") || name.includes("watch")) { - return "bt-device-watch"; - } - if (icon.includes("speaker") || name.includes("speaker")) { - return "bt-device-speaker"; - } - if (icon.includes("display") || name.includes("tv")) { - return "bt-device-tv"; - } - return "bt-device-generic"; + function _getDeviceName(device) { + return device?.name || device?.deviceName || "Unknown"; } - function canConnect(device) { - if (!device) - return false; - - /* - Paired - Means you’ve successfully exchanged keys with the device. - The devices remember each other and can authenticate without repeating the pairing process. - Example: once your headphones are paired, you don’t need to type a PIN every time. - Hence, instead of !device.paired, should be device.connected - */ - return !device.connected && !device.pairing && !device.blocked; + function _cleanupDeviceTracking(address) { + delete devicesBeingPaired[address]; + delete connectionAttempts[address]; } - function canDisconnect(device) { - if (!device) - return false; - return device.connected && !device.pairing && !device.blocked; - } - - function getStatusString(device) { - if (device.state === BluetoothDeviceState.Connecting) { - return "Connecting..."; - } - if (device.pairing) { - return "Pairing..."; - } - if (device.blocked) { - return "Blocked"; - } - return ""; - } - - function getSignalStrength(device) { - if (!device || device.signalStrength === undefined || device.signalStrength <= 0) { - return "Signal: Unknown"; - } - var signal = device.signalStrength; - if (signal >= 80) { - return "Signal: Excellent"; - } - if (signal >= 60) { - return "Signal: Good"; - } - if (signal >= 40) { - return "Signal: Fair"; - } - if (signal >= 20) { - return "Signal: Poor"; - } - return "Signal: Very poor"; - } - - function getBattery(device) { - return `Battery: ${Math.round(device.battery * 100)}%`; - } - - function getSignalIcon(device) { - if (!device || device.signalStrength === undefined || device.signalStrength <= 0) { - return "antenna-bars-off"; - } - var signal = device.signalStrength; - if (signal >= 80) { - return "antenna-bars-5"; - } - if (signal >= 60) { - return "antenna-bars-4"; - } - if (signal >= 40) { - return "antenna-bars-3"; - } - if (signal >= 20) { - return "antenna-bars-2"; - } - return "antenna-bars-1"; - } - - function isDeviceBusy(device) { - if (!device) { - return false; - } - - return device.pairing || device.state === BluetoothDeviceState.Disconnecting || device.state === BluetoothDeviceState.Connecting; - } - - function connectDeviceWithTrust(device) { - if (!device) { + function _configureAdapter() { + if (!adapter) return; - } - device.trusted = true; - device.connect(); + Logger.i("Bluetooth", "Configuring adapter..."); + if (!adapter.pairable) + adapter.pairable = true; + adapter.pairableTimeout = 0; } - function disconnectDevice(device) { - if (!device) { + function _handlePairingSuccess(device) { + const address = device.address; + if (!devicesBeingPaired[address]) return; - } - device.disconnect(); + Logger.i("Bluetooth", "Device paired successfully, connecting:", _getDeviceName(device)); + delete devicesBeingPaired[address]; + + Qt.callLater(() => { + if (device?.paired && !device.connected) { + Logger.i("Bluetooth", "Auto-connecting after pairing:", _getDeviceName(device)); + device.connect(); + } + }); } - function forgetDevice(device) { - if (!device) { - return; + function _handlePairingCancelled(device) { + const address = device.address; + if (!device.pairing && devicesBeingPaired[address] && !device.paired) { + Logger.w("Bluetooth", "Pairing cancelled or failed for:", _getDeviceName(device)); + delete devicesBeingPaired[address]; } - - device.trusted = false; - device.forget(); } - function setBluetoothEnabled(state) { - if (!adapter) { - Logger.w("Bluetooth", "No adapter available"); - return; - } + function _handleConnectionChanged(device) { + const name = _getDeviceName(device); + const address = device.address; - Logger.i("Bluetooth", "SetBluetoothEnabled", state); - adapter.enabled = state; + if (device.connected) { + Logger.i("Bluetooth", "Device connected:", name); + delete connectionAttempts[address]; + ToastService.showNotice(I18n.tr("bluetooth.panel.title"), `${name} connected`, "bluetooth-connected"); + } else { + Logger.i("Bluetooth", "Device disconnected:", name); + } + } + + function _handleStateChanged(device) { + const name = _getDeviceName(device); + const address = device.address; + const state = device.state; + + if (state === BluetoothDeviceState.Connecting) { + Logger.d("Bluetooth", "Device connecting:", name); + connectionAttempts[address] = { + name: name, + startTime: Date.now(), + wasConnecting: true + }; + } else if (state === BluetoothDeviceState.Disconnecting) { + Logger.d("Bluetooth", "Device disconnecting:", name); + } else if (state === BluetoothDeviceState.Disconnected) { + _checkFailedConnection(device, address, name); + } + } + + function _checkFailedConnection(device, address, name) { + const attempt = connectionAttempts[address]; + if (!attempt?.wasConnecting || device.connected) + return; + + const timeSinceAttempt = Date.now() - attempt.startTime; + if (timeSinceAttempt < 5000) { + Logger.w("Bluetooth", "Connection failed quickly for:", name, "- likely missing Bluetooth profiles"); + ToastService.showError("Bluetooth Connection Failed", `${name} - Missing audio profiles. Right-click to forget and try re-pairing, or check system Bluetooth services.`, "bluetooth-off"); + } + delete connectionAttempts[address]; + } + + // ============================================================================ + // Internal Components + // ============================================================================ + + Timer { + id: discoveryTimer + interval: 1000 + repeat: false + onTriggered: adapter.discovering = true } Process { @@ -258,18 +392,14 @@ Singleton { stdout: StdioCollector { onStreamFinished: { - const wifiBlocked = text && text.trim().includes("Soft blocked: yes"); - Logger.d("Network", "Wi-Fi adapter was detected as blocked:", blocked); + const wifiBlocked = text?.trim().includes("Soft blocked: yes") ?? false; + Logger.d("Network", "Wi-Fi adapter blocked:", wifiBlocked); - // Check if airplane mode has been toggled - if (wifiBlocked && wifiBlocked === root.blocked) { + if (wifiBlocked === root.blocked) { root.airplaneModeToggled = true; - NetworkService.setWifiEnabled(false); - ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("toast.airplane-mode.enabled"), "plane"); - } else if (!wifiBlocked && wifiBlocked === root.blocked) { - root.airplaneModeToggled = true; - NetworkService.setWifiEnabled(true); - ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr("toast.airplane-mode.disabled"), "plane-off"); + NetworkService.setWifiEnabled(!wifiBlocked); + const mode = wifiBlocked ? "enabled" : "disabled"; + ToastService.showNotice(I18n.tr("toast.airplane-mode.title"), I18n.tr(`toast.airplane-mode.${mode}`), wifiBlocked ? "plane" : "plane-off"); } else if (adapter.enabled) { ToastService.showNotice(I18n.tr("bluetooth.panel.title"), I18n.tr("toast.bluetooth.enabled"), "bluetooth"); discoveryTimer.running = true;