From 83fafe251169ce5cd483dd0cf19507ac14b43b36 Mon Sep 17 00:00:00 2001 From: David Shlemayev Date: Thu, 21 Apr 2022 23:23:48 +0300 Subject: [PATCH] WIP: Initial Bluetooth handoff impl --- ...e.Shell.Extensions.GSConnect.gresource.xml | 2 + ...ome.Shell.Extensions.GSConnect.gschema.xml | 2 +- data/ui/bluetooth-handoff-device-row.ui | 46 +++ data/ui/bluetooth-handoff-dialog.ui | 61 ++++ src/service/components/bluez.js | 188 ++++++++++++ src/service/plugins/bluetooth_handoff.js | 287 ++++++++++++++++++ src/service/ui/bluetoothHandoff.js | 134 ++++++++ 7 files changed, 719 insertions(+), 1 deletion(-) create mode 100644 data/ui/bluetooth-handoff-device-row.ui create mode 100644 data/ui/bluetooth-handoff-dialog.ui create mode 100644 src/service/components/bluez.js create mode 100644 src/service/plugins/bluetooth_handoff.js create mode 100644 src/service/ui/bluetoothHandoff.js diff --git a/data/org.gnome.Shell.Extensions.GSConnect.gresource.xml b/data/org.gnome.Shell.Extensions.GSConnect.gresource.xml index c2d61a239..6abdb68f4 100644 --- a/data/org.gnome.Shell.Extensions.GSConnect.gresource.xml +++ b/data/org.gnome.Shell.Extensions.GSConnect.gresource.xml @@ -11,6 +11,8 @@ ui/messaging-conversation-summary.ui ui/messaging-conversation.ui ui/messaging-window.ui + ui/bluetooth-handoff-dialog.ui + ui/bluetooth-handoff-device-row.ui ui/mousepad-input-dialog.ui ui/notification-reply-dialog.ui ui/preferences-command-editor.ui diff --git a/data/org.gnome.Shell.Extensions.GSConnect.gschema.xml b/data/org.gnome.Shell.Extensions.GSConnect.gschema.xml index b3693731a..dd986fc69 100644 --- a/data/org.gnome.Shell.Extensions.GSConnect.gschema.xml +++ b/data/org.gnome.Shell.Extensions.GSConnect.gschema.xml @@ -46,7 +46,7 @@ {} - ["sms", "ring", "mount", "commands", "share", "photo", "keyboard"] + ["sms", "ring", "mount", "commands", "share", "photo", "keyboard", "openBluetoothHandoffDialog"] "" diff --git a/data/ui/bluetooth-handoff-device-row.ui b/data/ui/bluetooth-handoff-device-row.ui new file mode 100644 index 000000000..9960beb29 --- /dev/null +++ b/data/ui/bluetooth-handoff-device-row.ui @@ -0,0 +1,46 @@ + + + + + + diff --git a/data/ui/bluetooth-handoff-dialog.ui b/data/ui/bluetooth-handoff-dialog.ui new file mode 100644 index 000000000..5299537f5 --- /dev/null +++ b/data/ui/bluetooth-handoff-dialog.ui @@ -0,0 +1,61 @@ + + + + + + diff --git a/src/service/components/bluez.js b/src/service/components/bluez.js new file mode 100644 index 000000000..a9b30e2b8 --- /dev/null +++ b/src/service/components/bluez.js @@ -0,0 +1,188 @@ +'use strict'; + +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const GObject = imports.gi.GObject; + +function log(message) { + GLib.log_structured('GSConnect', GLib.LogLevelFlags.LEVEL_MESSAGE, { + 'MESSAGE': message, + 'SYSLOG_IDENTIFIER': 'org.gnome.Shell.Extensions.GSConnect.BluetoothHandoff' + }); +} + +/** + * A class representing the local bluetooth devices. + */ +var LocalBluetoothDevices = GObject.registerClass({ + GTypeName: 'GSConnectLocalBluetoothDevices', + Signals: { + 'changed': { + flags: GObject.SignalFlags.RUN_FIRST, + }, + }, +}, class LocalBluetoothDevices extends GObject.Object { + + _init() { + try { + super._init(); + + this._cancellable = new Gio.Cancellable(); + this._proxy = null; + this._propertiesChangedId = 0; + this._localDevices = {}; + this._localDevicesJson = "{}"; + + this._loadBluez(); + } catch (e) { + log("bluez ERR: " + e.message); + } + } + + async _loadBluez() { + try { + this._proxy = new Gio.DBusProxy({ + g_bus_type: Gio.BusType.SYSTEM, + g_name: 'org.bluez', + g_object_path: '/', + g_interface_name: 'org.freedesktop.DBus.ObjectManager', + }); + + await new Promise((resolve, reject) => { + this._proxy.init_async( + GLib.PRIORITY_DEFAULT, + this._cancellable, + (proxy, res) => { + try { + resolve(proxy.init_finish(res)); + } catch (e) { + reject(e); + } + } + ); + }); + + this._fetchDevices(); + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, () => { + this._fetchDevices(); + + if (!this._cancellable.is_cancelled()) { + return GLib.SOURCE_CONTINUE; + } else { + return GLib.SOURCE_REMOVE; + } + }); + } catch (e) { + log("ERR: " + e.message); + this._proxy = null; + } + } + + async _fetchDevices() { + try { + let objects = await new Promise((resolve, reject) => { + this._proxy.call( + 'GetManagedObjects', + null, + Gio.DBusCallFlags.NO_AUTO_START, + 1000, + this._cancellable, + (proxy, res) => { + try { + const reply = proxy.call_finish(res); + resolve(reply.deepUnpack()[0]); + } catch (e) { + Gio.DBusError.strip_remote_error(e); + reject(e); + } + } + ); + }); + + const newLocalDevices = await Promise.all(Object.keys(objects) + .filter((rawObjectPath) => { + const objectPath = rawObjectPath.split('/'); + return objectPath.length == 5 && objectPath[0] == '' && objectPath[1] == 'org' && objectPath[2] == 'bluez' + && objectPath[3].startsWith('hci') && objectPath[4].startsWith('dev_'); + }).map(async (objectPath) => { + const objectProxy = new Gio.DBusProxy({ + g_bus_type: Gio.BusType.SYSTEM, + g_name: 'org.bluez', + g_object_path: objectPath, + g_interface_name: 'org.bluez.Device1', + }); + + await new Promise((resolve, reject) => { + objectProxy.init_async( + GLib.PRIORITY_DEFAULT, + null, + (proxy, res) => { + try { + proxy.init_finish(res); + resolve(); + } catch (e) { + reject(e); + } + } + ); + }); + + try { + let addr = objectProxy.get_cached_property('Address'); + if (addr) addr = addr.recursiveUnpack(); + let alias = objectProxy.get_cached_property('Alias'); + if (alias) alias = alias.recursiveUnpack(); + let name = objectProxy.get_cached_property('Name'); + if (name) name = name.recursiveUnpack(); + let adapter = objectProxy.get_cached_property('Adapter'); + if (adapter) adapter = adapter.recursiveUnpack(); + let icon = objectProxy.get_cached_property('Icon'); + if (icon) icon = icon.recursiveUnpack(); + let paired = objectProxy.get_cached_property('Paired'); + if (paired) paired = paired.recursiveUnpack(); + let connected = objectProxy.get_cached_property('Connected'); + if (connected) connected = connected.recursiveUnpack(); + + return { + addr, + alias, + name, + adapter, + icon, + paired, + connected, + } + } catch (e) { + log(" -> err - " + e); + } + })); + + const newLocalDevicesJson = JSON.stringify(newLocalDevices); + if (newLocalDevicesJson != this._localDevicesJson) { + this._localDevices = newLocalDevices; + this._localDevicesJson = newLocalDevicesJson; + this.emit('changed'); + } + } catch (e) { + log(" -> err - " + e); + } + } + + get local_devices() { + return this._localDevices; + } + + destroy() { + if (this._cancellable.is_cancelled()) + return; + + this._cancellable.cancel(); + } +}); + + +/** + * The service class for this component + */ +var Component = LocalBluetoothDevices; + diff --git a/src/service/plugins/bluetooth_handoff.js b/src/service/plugins/bluetooth_handoff.js new file mode 100644 index 000000000..f9e96b121 --- /dev/null +++ b/src/service/plugins/bluetooth_handoff.js @@ -0,0 +1,287 @@ +'use strict'; + +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const GObject = imports.gi.GObject; + +const Components = imports.service.components; +const {BluetoothHandoffDialog} = imports.service.ui.bluetoothHandoff; +const PluginBase = imports.service.plugin; + + +var Metadata = { + label: _('Bluetooth Handoff'), + description: _('Share Bluetooth devices'), + id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.BluetoothHandoff', + incomingCapabilities: [ + 'kdeconnect.bluetooth_handoff.device_list', + 'kdeconnect.bluetooth_handoff.disconnect.response', + ], + outgoingCapabilities: [ + 'kdeconnect.bluetooth_handoff.device_list.request', + 'kdeconnect.bluetooth_handoff.disconnect.request', + ], + actions: { + openBluetoothHandoffDialog: { + label: _('Bluetooth Devices'), + icon_name: 'bluetooth-active-symbolic', + + parameter_type: null, + incoming: [], + outgoing: ['kdeconnect.bluetooth_handoff.device_list.request'], + }, + }, +}; + +// TODO: Remove me +function log(message) { + GLib.log_structured('GSConnect', GLib.LogLevelFlags.LEVEL_MESSAGE, { + 'MESSAGE': message, + 'SYSLOG_IDENTIFIER': 'org.gnome.Shell.Extensions.GSConnect.BluetoothHandoff' + }); +} + + +/** + * Bluetooth Handoff Plugin + * https://invent.kde.org/network/kdeconnect-kde/-/tree/master/plugins/bluetooth-handoff + */ +var Plugin = GObject.registerClass({ + GTypeName: 'GSConnectBluetoothHandoffPlugin', + Signals: { + 'combined-state-changed': {}, + }, +}, class Plugin extends PluginBase.Plugin { + + _init(device) { + super._init(device, 'bluetooth_handoff'); + + this.local_devs_state = []; + this.remote_devs_state = []; + this.combined_devs_state = []; + this.combined_devs_state_json = "[]"; + this._bluez = null; + this._bluezId = 0; + + this._devicePollCancelable = new Gio.Cancellable(); + } + + connected() { + log('Connected'); + super.connected(); + } + + handlePacket(packet) { + switch (packet.type) { + case 'kdeconnect.bluetooth_handoff.device_list': + this._receiveRemoteState(packet); + break; + case 'kdeconnect.bluetooth_handoff.disconnect.response': + log('Disconnected: ' + JSON.stringify(packet.body)); + } + } + + openBluetoothHandoffDialog() { + log('Open dialog'); + if (this._dialog === undefined) { + this._dialog = new BluetoothHandoffDialog({ + device: this.device, + plugin: this, + }, this._dialogClosedCb.bind(this)); + } + + this._dialog.present(); + + // Force update + this.combined_devs_state_json = "[]"; + this._requestState(); + + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, () => { + this._requestState(); + + if (!this._devicePollCancelable.is_cancelled()) { + return GLib.SOURCE_CONTINUE; + } else { + return GLib.SOURCE_REMOVE; + } + }); + + if (this._bluez === null) { + this._bluez = Components.acquire('bluez'); + this._bluezId = this._bluez.connect( + 'changed', + this._receiveLocalState.bind(this) + ); + this.local_devs_state = this._bluez.local_devices; + } + } + + _dialogClosedCb() { + this._dialog = undefined; + } + + _receiveLocalState() { + this.local_devs_state = this._bluez.local_devices; + this._update_combined_state(); + } + + /** + * Handle a remote state update. + * + * @param {Core.Packet} packet - A kdeconnect.bluetooth_handoff.device_list packet + */ + _receiveRemoteState(packet) { + debug('Got device list: ' + JSON.stringify(packet.body)); + + // Update combined state + this.remote_devs_state = packet.body.devices; + this._update_combined_state(); + } + + /** + * Request the remote device's connectivity state + */ + _requestState() { + debug("Requesting remote device list"); + this.device.sendPacket({ + type: 'kdeconnect.bluetooth_handoff.device_list.request', + body: {}, + }); + } + + _update_combined_state() { + debug('--- local: ' + JSON.stringify(this.local_devs_state)); + debug('--- remote: ' + JSON.stringify(this.remote_devs_state)); + + let local_by_mac = {}; + this.local_devs_state.forEach((dev) => { + local_by_mac[dev.addr] = dev; + }); + + let remote_by_mac = {}; + this.remote_devs_state.forEach((dev) => { + remote_by_mac[dev.addr] = dev; + }); + + let combined = []; + Object.keys(local_by_mac).forEach((mac) => { + let local = local_by_mac[mac]; + let remote = remote_by_mac[mac]; + if (remote === undefined || (remote.connected === false && local.connected === false)) { + return; + } + + let name = local.alias; + if (remote.name != local.alias) { + name = local.alias + ' (' + remote.name + ')'; + } + + combined.push({ + 'addr': mac, + 'name': name, + 'icon': local.icon, + 'local_connected': local.connected, + 'loading': false, + }); + }); + + let combined_json = JSON.stringify(combined); + if (combined_json !== this.combined_devs_state_json) { + this.combined_devs_state = combined; + this.combined_devs_state_json = combined_json; + this.emit('combined-state-changed'); + } + } + + takeDevice(address) { + log('Sent dc req'); + this.device.sendPacket({ + type: 'kdeconnect.bluetooth_handoff.disconnect.request', + body: { + "addr": address, + }, + }); + + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 500, () => { + (async () => { + log('Starting local connect'); + const launcher = new Gio.SubprocessLauncher({ + flags: Gio.SubprocessFlags.NONE, + }); + const cancellable = new Gio.Cancellable(); + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 10000, () => { + cancellable.cancel(); + return GLib.SOURCE_REMOVE; + }); + + for (let i = 0; i < 3; i++) { + const proc = launcher.spawnv(['bluetoothctl', 'connect', address]); + + let success = await new Promise((resolve, reject) => { + proc.wait_check_async(cancellable, (proc, res) => { + try { + proc.wait_check_finish(res); + log('Connection successful!'); + resolve(true); + } catch (e) { + log('Connection unsuccessful: ' + e); + resolve(false); + } + }); + }); + if (success) { + break; + } + } + })(); + + return GLib.SOURCE_REMOVE; + }); + } + + giveDevice(address) { + const launcher = new Gio.SubprocessLauncher({ + flags: Gio.SubprocessFlags.NONE, + }); + const cancellable = new Gio.Cancellable(); + const proc = launcher.spawnv(['bluetoothctl', 'disconnect', address]); + + GLib.timeout_add(GLib.PRIORITY_DEFAULT, 10000, () => { + cancellable.cancel(); + return GLib.SOURCE_REMOVE; + }); + + proc.wait_check_async(cancellable, (proc, res) => { + try { + proc.wait_check_finish(res); + log('Disconnection successful!'); + } catch (e) { + log('Disconnection unsuccessful: ' + e); + return; + } + + this.device.sendPacket({ + type: 'kdeconnect.bluetooth_handoff.connect.request', + body: { + "addr": address, + }, + }); + }); + } + + destroy() { + if (this._dialog !== undefined) + this._dialog.destroy(); + + if (this._bluez !== null) { + this._bluez.disconnect(this._bluezId); + this._bluez = Components.release('bluez'); + } + + if (this._devicePollCancelable) { + this._devicePollCancelable.cancel(); + } + + super.destroy(); + } +}); diff --git a/src/service/ui/bluetoothHandoff.js b/src/service/ui/bluetoothHandoff.js new file mode 100644 index 000000000..8658c5987 --- /dev/null +++ b/src/service/ui/bluetoothHandoff.js @@ -0,0 +1,134 @@ +'use strict'; + +const Gdk = imports.gi.Gdk; +const GLib = imports.gi.GLib; +const GObject = imports.gi.GObject; +const Gtk = imports.gi.Gtk; + +const DeviceRow = GObject.registerClass({ + GTypeName: 'GSConnectBluetoothHandoffDeviceRow', + Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/bluetooth-handoff-device-row.ui', + Children: [ + 'device_icon', 'device_name', 'switch', + ], +}, class DeviceRow extends Gtk.ListBoxRow { + + _init(address, name, icon, connected, onToggle) { + super._init(); + + this._address = address; + this._name = name; + this._icon = icon; + this._connected = connected; + this._onToggle = onToggle; + + this.handleEvents = false; + this.switch.state = connected; + this.handleEvents = true; + + if (icon === 'audio-card') { + this.device_icon.icon_name = 'audio-headphones-symbolic'; + } else { + this.device_icon.icon_name = 'bluetooth-active-symbolic'; + } + + this.device_name.label = name; + } + + _onDeviceToggled(_widget, state) { + if (this.handleEvents) { + GLib.log_structured('GSConnect', GLib.LogLevelFlags.LEVEL_MESSAGE, { + 'MESSAGE': 'Device Toggled: ' + this._address + ': ' + state, + 'SYSLOG_IDENTIFIER': 'org.gnome.Shell.Extensions.GSConnect.BluetoothHandoff' + }); + this._onToggle(this._address, state); + } + } +}); + + +var BluetoothHandoffDialog = GObject.registerClass({ + GTypeName: 'GSConnectBluetoothHandoffDialog', + Properties: { + 'device': GObject.ParamSpec.object( + 'device', + 'Device', + 'The device associated with this window', + GObject.ParamFlags.READWRITE, + GObject.Object + ), + 'plugin': GObject.ParamSpec.object( + 'plugin', + 'Plugin', + 'The bluetooth handoff plugin associated with this window', + GObject.ParamFlags.READWRITE, + GObject.Object + ), + }, + Template: 'resource:///org/gnome/Shell/Extensions/GSConnect/ui/bluetooth-handoff-dialog.ui', + Children: [ + 'instruction-label', 'device_list', + ], +}, class BluetoothHandoffDialog extends Gtk.Dialog { + + _init(params, onDestroyCb) { + super._init(Object.assign({ + use_header_bar: true, + }, params)); + + const headerbar = this.get_titlebar(); + headerbar.title = _('Bluetooth Handoff'); + headerbar.subtitle = _('Take Bluetooth devices from %s').format(this.device.name); + + // Main Box + const content = this.get_content_area(); + content.border_width = 0; + + this.instruction_label.label = _('If your Bluetooth device doesn\'t show up, make sure both this PC and "%s" are near it').format(this.device.name); + + // Clear the device list + const rows = this.device_list.get_children(); + + for (let i = 0, len = rows.length; i < len; i++) { + rows[i].destroy(); + // HACK: temporary mitigator for mysterious GtkListBox leak + imports.system.gc(); + } + + this.plugin.connect('combined-state-changed', this._onStateChanged.bind(this)); + + // Cleanup on destroy + this._onDestroyCb = onDestroyCb; + + this.show_all(); + } + + _onStateChanged() { + for (const widget of this.device_list.get_children()) { + widget.destroy(); + // HACK: temporary mitigator for mysterious GtkListBox leak + imports.system.gc(); + } + + this.plugin.combined_devs_state.forEach((device, i) => { + const row = new DeviceRow(device.addr, device.name, device.icon, device.local_connected, this._onDeviceToggled.bind(this)); + this.device_list.add(row); + if (i < this.plugin.combined_devs_state.length - 1) { + this.device_list.add(new Gtk.Separator()); + } + }); + } + + _onDeviceToggled(address, state) { + if (state) { + this.plugin.takeDevice(address); + } else { + this.plugin.giveDevice(address); + } + } + + vfunc_delete_event(event) { + this._onDestroyCb(); + return false; + } +});