// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect // // SPDX-License-Identifier: GPL-2.0-or-later import GdkPixbuf from 'gi://GdkPixbuf'; import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import GObject from 'gi://GObject'; import * as Components from '../components/index.js'; import Plugin from '../plugin.js'; export const Metadata = { label: _('Telephony'), description: _('Be notified about calls and adjust system volume during ringing/ongoing calls'), id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Telephony', incomingCapabilities: [ 'kdeconnect.telephony', ], outgoingCapabilities: [ 'kdeconnect.telephony.request', 'kdeconnect.telephony.request_mute', ], actions: { muteCall: { // TRANSLATORS: Silence the actively ringing call label: _('Mute Call'), icon_name: 'audio-volume-muted-symbolic', parameter_type: null, incoming: ['kdeconnect.telephony'], outgoing: ['kdeconnect.telephony.request_mute'], }, }, }; /** * Telephony Plugin * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/telephony * https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/TelephonyPlugin */ const TelephonyPlugin = GObject.registerClass({ GTypeName: 'GSConnectTelephonyPlugin', }, class TelephonyPlugin extends Plugin { _init(device) { super._init(device, 'telephony'); // Neither of these are crucial for the plugin to work this._mpris = Components.acquire('mpris'); this._mixer = Components.acquire('pulseaudio'); } handlePacket(packet) { switch (packet.type) { case 'kdeconnect.telephony': this._handleEvent(packet); break; } } /** * Change volume, microphone and media player state in response to an * incoming or answered call. * * @param {string} eventType - 'ringing' or 'talking' */ _setMediaState(eventType) { // Mixer Volume if (this._mixer !== undefined) { switch (this.settings.get_string(`${eventType}-volume`)) { case 'restore': this._mixer.restore(); break; case 'lower': this._mixer.lowerVolume(); break; case 'mute': this._mixer.muteVolume(); break; } if (eventType === 'talking' && this.settings.get_boolean('talking-microphone')) this._mixer.muteMicrophone(); } // Media Playback if (this._mpris && this.settings.get_boolean(`${eventType}-pause`)) this._mpris.pauseAll(); } /** * Restore volume, microphone and media player state (if changed), making * sure to unpause before raising volume. * * TODO: there's a possibility we might revert a media/mixer state set for * another device. */ _restoreMediaState() { // Media Playback if (this._mpris) this._mpris.unpauseAll(); // Mixer Volume if (this._mixer) this._mixer.restore(); } /** * Load a Gdk.Pixbuf from base64 encoded data * * @param {string} data - Base64 encoded JPEG data * @return {Gdk.Pixbuf|null} A contact photo */ _getThumbnailPixbuf(data) { const loader = new GdkPixbuf.PixbufLoader(); try { data = GLib.base64_decode(data); loader.write(data); loader.close(); } catch (e) { debug(e, this.device.name); } return loader.get_pixbuf(); } /** * Handle a telephony event (ringing, talking), showing or hiding a * notification and possibly adjusting the media/mixer state. * * @param {Core.Packet} packet - A `kdeconnect.telephony` */ _handleEvent(packet) { // Only handle 'ringing' or 'talking' events; leave the notification // plugin to handle 'missedCall' since they're often repliable if (!['ringing', 'talking'].includes(packet.body.event)) return; // This is the end of a telephony event if (packet.body.isCancel) this._cancelEvent(packet); else this._notifyEvent(packet); } _cancelEvent(packet) { // Ensure we have a sender // TRANSLATORS: No name or phone number let sender = _('Unknown Contact'); if (packet.body.contactName) sender = packet.body.contactName; else if (packet.body.phoneNumber) sender = packet.body.phoneNumber; this.device.hideNotification(`${packet.body.event}|${sender}`); this._restoreMediaState(); } _notifyEvent(packet) { let body; let buttons = []; let icon = null; let priority = Gio.NotificationPriority.NORMAL; // Ensure we have a sender // TRANSLATORS: No name or phone number let sender = _('Unknown Contact'); if (packet.body.contactName) sender = packet.body.contactName; else if (packet.body.phoneNumber) sender = packet.body.phoneNumber; // If there's a photo, use it as the notification icon if (packet.body.phoneThumbnail) icon = this._getThumbnailPixbuf(packet.body.phoneThumbnail); if (icon === null) icon = new Gio.ThemedIcon({name: 'call-start-symbolic'}); // Notify based based on the event type if (packet.body.event === 'ringing') { this._setMediaState('ringing'); // TRANSLATORS: The phone is ringing body = _('Incoming call'); buttons = [{ action: 'muteCall', // TRANSLATORS: Silence the actively ringing call label: _('Mute'), parameter: null, }]; priority = Gio.NotificationPriority.URGENT; } if (packet.body.event === 'talking') { this.device.hideNotification(`ringing|${sender}`); this._setMediaState('talking'); // TRANSLATORS: A phone call is active body = _('Ongoing call'); } this.device.showNotification({ id: `${packet.body.event}|${sender}`, title: sender, body: body, icon: icon, priority: priority, buttons: buttons, }); } /** * Silence an incoming call and restore the previous mixer/media state, if * applicable. */ muteCall() { this.device.sendPacket({ type: 'kdeconnect.telephony.request_mute', body: {}, }); this._restoreMediaState(); } destroy() { if (this._mixer !== undefined) this._mixer = Components.release('pulseaudio'); if (this._mpris !== undefined) this._mpris = Components.release('mpris'); super.destroy(); } }); export default TelephonyPlugin;