'use strict'; const Gio = imports.gi.Gio; const GLib = imports.gi.GLib; const GObject = imports.gi.GObject; const Gtk = imports.gi.Gtk; const PluginsBase = imports.service.plugins.base; const NotificationUI = imports.service.ui.notification; var Metadata = { label: _('Notifications'), id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Notification', incomingCapabilities: [ 'kdeconnect.notification', 'kdeconnect.notification.request' ], outgoingCapabilities: [ 'kdeconnect.notification', 'kdeconnect.notification.action', 'kdeconnect.notification.reply', 'kdeconnect.notification.request' ], actions: { withdrawNotification: { label: _('Cancel Notification'), icon_name: 'preferences-system-notifications-symbolic', parameter_type: new GLib.VariantType('s'), incoming: [], outgoing: ['kdeconnect.notification'] }, closeNotification: { label: _('Close Notification'), icon_name: 'preferences-system-notifications-symbolic', parameter_type: new GLib.VariantType('s'), incoming: [], outgoing: ['kdeconnect.notification.request'] }, replyNotification: { label: _('Reply Notification'), icon_name: 'preferences-system-notifications-symbolic', parameter_type: new GLib.VariantType('(ssa{ss})'), incoming: ['kdeconnect.notification'], outgoing: ['kdeconnect.notification.reply'] }, sendNotification: { label: _('Send Notification'), icon_name: 'preferences-system-notifications-symbolic', parameter_type: new GLib.VariantType('a{sv}'), incoming: [], outgoing: ['kdeconnect.notification'] }, activateNotification: { label: _('Activate Notification'), icon_name: 'preferences-system-notifications-symbolic', parameter_type: new GLib.VariantType('(ss)'), incoming: [], outgoing: ['kdeconnect.notification.action'] } } }; // A regex for our custom notificaiton ids const ID_REGEX = /^(fdo|gtk)\|([^|]+)\|(.*)$/; // A list of known SMS apps const SMS_APPS = [ // Popular apps that don't contain the string 'sms' 'com.android.messaging', // AOSP 'com.google.android.apps.messaging', // Google Messages 'com.textra', // Textra 'xyz.klinker.messenger', // Pulse 'com.calea.echo', // Mood Messenger 'com.moez.QKSMS', // QKSMS 'rpkandrodev.yaata', // YAATA 'com.tencent.mm', // WeChat 'com.viber.voip', // Viber 'com.kakao.talk', // KakaoTalk 'com.concentriclivers.mms.com.android.mms', // AOSP Clone 'fr.slvn.mms', // AOSP Clone 'com.promessage.message', // 'com.htc.sense.mms', // HTC Messages // Known not to work with sms plugin 'org.thoughtcrime.securesms', // Signal Private Messenger 'com.samsung.android.messaging' // Samsung Messages ]; /** * Notification Plugin * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/notifications * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sendnotifications */ var Plugin = GObject.registerClass({ GTypeName: 'GSConnectNotificationPlugin' }, class Plugin extends PluginsBase.Plugin { _init(device) { super._init(device, 'notification'); this._session = this.service.components.get('session'); } handlePacket(packet) { switch (packet.type) { case 'kdeconnect.notification': return this._handleNotification(packet); case 'kdeconnect.notification.request': return this._handleRequest(packet); // We don't support *incoming* replies (yet) case 'kdeconnect.notification.reply': debug(`Not implemented: ${packet.type}`); return; default: debug(`Unknown notification packet: ${packet.type}`); } } connected() { super.connected(); this.requestNotifications(); } /** * Handle an incoming notification or closed report. * * FIXME: upstream kdeconnect-android is tagging many notifications as * `silent`, causing them to never be shown. Since we already handle * duplicates in the Shell, we ignore that flag for now. */ _handleNotification(packet) { // A report that a remote notification has been dismissed if (packet.body.hasOwnProperty('isCancel')) { this.device.hideNotification(packet.body.id); // A normal, remote notification } else { this.receiveNotification(packet); } } /** * Handle an incoming request to close or list notifications. */ _handleRequest(packet) { // A request for our notifications. This isn't implemented and would be // pretty hard to without communicating with GNOME Shell. if (packet.body.hasOwnProperty('request')) { return; // A request to close a local notification // // TODO: kdeconnect-android doesn't send these, and will instead send a // kdeconnect.notification packet with isCancel and an id of "0". // // For clients that do support it, we report notification ids in the // form "type|application-id|notification-id" so we can close it with // the appropriate service. } else if (packet.body.hasOwnProperty('cancel')) { let [, type, application, id] = ID_REGEX.exec(packet.body.cancel); switch (type) { case 'fdo': this.service.remove_notification(parseInt(id)); break; case 'gtk': this.service.remove_notification(id, application); break; default: debug(`Unknown notification type ${this.device.name}`); } } } /** * Check an internal id for evidence that it's from an SMS app * * @param {string} - Internal notification id * @return {boolean} - Whether the id has evidence it's from an SMS app */ _isSms(id) { if (id.includes('sms')) return true; for (let i = 0, len = SMS_APPS.length; i < len; i++) { if (id.includes(SMS_APPS[i])) return true; } return false; } /** * Sending Notifications */ async _uploadIcon(packet, icon) { try { // Normalize strings into GIcons if (typeof icon === 'string') { icon = Gio.Icon.new_for_string(icon); } switch (true) { // GBytesIcon case (icon instanceof Gio.BytesIcon): return this._uploadBytesIcon(packet, icon.get_bytes()); // GFileIcon case (icon instanceof Gio.FileIcon): return this._uploadFileIcon(packet, icon.get_file()); // GThemedIcon case (icon instanceof Gio.ThemedIcon): return this._uploadThemedIcon(packet, icon); default: return this.device.sendPacket(packet); } } catch (e) { logError(e); return this.device.sendPacket(packet); } } /** * A function for uploading named icons from a GLib.Bytes object. * * @param {Core.Packet} packet - The packet for the notification * @param {GLib.Bytes} bytes - The themed icon name */ _uploadBytesIcon(packet, bytes) { return this._uploadIconStream( packet, Gio.MemoryInputStream.new_from_bytes(bytes), bytes.get_size() ); } /** * A function for uploading icons as Gio.File objects * * @param {Core.Packet} packet - The packet for the notification * @param {Gio.File} file - A Gio.File object for the icon */ async _uploadFileIcon(packet, file) { let stream; try { stream = await new Promise((resolve, reject) => { file.read_async(GLib.PRIORITY_DEFAULT, null, (file, res) => { try { resolve(file.read_finish(res)); } catch (e) { reject(e); } }); }); return this._uploadIconStream( packet, stream, file.query_info('standard::size', 0, null).get_size() ); } catch (e) { logError(e); this.device.sendPacket(packet); } } /** * A function for uploading GThemedIcons * * @param {Core.Packet} packet - The packet for the notification * @param {Gio.ThemedIcon} file - The GIcon to upload */ _uploadThemedIcon(packet, icon) { let theme = Gtk.IconTheme.get_default(); for (let name of icon.names) { // kdeconnect-android doesn't support SVGs so find the largest other let info = theme.lookup_icon( name, Math.max.apply(null, theme.get_icon_sizes(name)), Gtk.IconLookupFlags.NO_SVG ); // Send the first icon we find from the options if (info) { return this._uploadFileIcon( packet, Gio.File.new_for_path(info.get_filename()) ); } } // Fallback to icon-less notification return this.device.sendPacket(packet); } /** * All icon types end up being uploaded in this function. * * @param {Core.Packet} packet - The packet for the notification * @param {Gio.InputStream} stream - A stream to read the icon bytes from * @param {number} size - Size of the icon in bytes */ async _uploadIconStream(packet, stream, size) { try { let transfer = this.device.createTransfer({ input_stream: stream, size: size }); let success = await transfer.upload(packet); if (!success) { this.device.sendPacket(packet); } } catch (e) { debug(e); this.device.sendPacket(packet); } } /** * This is called by the notification listener. * See Notification.Listener._sendNotification() */ async sendNotification(notif) { try { // Sending notifications is forbidden if (!this.settings.get_boolean('send-notifications')) { return; } // Sending when the session is active is forbidden if (this._session.active && !this.settings.get_boolean('send-active')) { return; } // TODO: revisit application notification settings let applications = JSON.parse(this.settings.get_string('applications')); // An unknown application if (!applications.hasOwnProperty(notif.appName)) { applications[notif.appName] = { iconName: 'system-run-symbolic', enabled: true }; // Only catch icons for strings and GThemedIcon if (typeof notif.icon === 'string') { applications[notif.appName].iconName = notif.icon; } else if (notif.icon instanceof Gio.ThemedIcon) { applications[notif.appName].iconName = notif.icon.names[0]; } this.settings.set_string( 'applications', JSON.stringify(applications) ); } // An enabled application if (applications[notif.appName].enabled) { let icon = notif.icon || null; delete notif.icon; let packet = { type: 'kdeconnect.notification', body: notif }; await this._uploadIcon(packet, icon); } } catch (e) { logError(e); } } /** * Receiving Notifications */ async _downloadIcon(packet) { let file, path, stream, success, transfer; try { if (!packet.hasPayload()) { return null; } // Save the file in the global cache path = GLib.build_filenamev([ gsconnect.cachedir, packet.body.payloadHash || `${Date.now()}` ]); file = Gio.File.new_for_path(path); // Check if we've already downloaded this icon if (file.query_exists(null)) { return new Gio.FileIcon({file: file}); } // Open the file stream = await new Promise((resolve, reject) => { file.replace_async(null, false, 2, 0, null, (file, res) => { try { resolve(file.replace_finish(res)); } catch (e) { reject(e); } }); }); // Download the icon transfer = this.device.createTransfer(Object.assign({ output_stream: stream, size: packet.payloadSize }, packet.payloadTransferInfo)); success = await transfer.download( packet.payloadTransferInfo.port || packet.payloadTransferInfo.uuid ); // Return the icon if successful, delete on failure if (success) { return new Gio.FileIcon({file: file}); } await new Promise((resolve, reject) => { file.delete_async(GLib.PRIORITY_DEFAULT, null, (file, res) => { try { file.delete_finish(res); } catch (e) { } resolve(); }); }); return null; } catch (e) { debug(e, this.device.name); return null; } } /** * Receive an incoming notification * * @param {kdeconnect.notification} packet - The notification packet */ async receiveNotification(packet) { try { // Set defaults let action = null; let buttons = []; let id = packet.body.id; let title = packet.body.appName; let body = `${packet.body.title}: ${packet.body.text}`; let icon = await this._downloadIcon(packet); // Check if this is a repliable notification if (packet.body.requestReplyId) { id = `${packet.body.id}|${packet.body.requestReplyId}`; action = { name: 'replyNotification', parameter: new GLib.Variant('(ssa{ss})', [ packet.body.requestReplyId, '', { appName: packet.body.appName, title: packet.body.title, text: packet.body.text } ]) }; } // Check if the notification has actions if (packet.body.actions) { buttons = packet.body.actions.map(action => { return { label: action, action: 'activateNotification', parameter: new GLib.Variant('(ss)', [id, action]) }; }); } switch (true) { // Special case for Missed Calls case packet.body.id.includes('MissedCall'): title = packet.body.title; body = packet.body.text; icon = icon || new Gio.ThemedIcon({name: 'call-missed-symbolic'}); break; // Special case for SMS notifications case this._isSms(packet.body.id): title = packet.body.title; body = packet.body.text; action = { name: 'replySms', parameter: new GLib.Variant('s', packet.body.title) }; icon = icon || new Gio.ThemedIcon({name: 'sms-symbolic'}); break; // Ignore 'appName' if it's the same as 'title' case (packet.body.appName === packet.body.title): body = packet.body.text; break; } // If we still don't have an icon use the device icon icon = icon || new Gio.ThemedIcon({name: this.device.icon_name}); // Show the notification this.device.showNotification({ id: id, title: title, body: body, icon: icon, action: action, buttons: buttons }); } catch (e) { logError(e); } } /** * Report that a local notification has been closed/dismissed. * TODO: kdeconnect-android doesn't handle incoming isCancel packets. * * @param {string} id - The local notification id */ withdrawNotification(id) { this.device.sendPacket({ type: 'kdeconnect.notification', body: { isCancel: true, id: id } }); } /** * Close a remote notification. * TODO: ignore local notifications * * @param {string} id - The remote notification id */ closeNotification(id) { this.device.sendPacket({ type: 'kdeconnect.notification.request', body: {cancel: id} }); } /** * Reply to a notification sent with a requestReplyId UUID * * @param {string} uuid - The requestReplyId for the repliable notification * @param {string} message - The message to reply with * @param {object} notification - The original notification packet */ replyNotification(uuid, message, notification) { // If the message has no content, open a dialog for the user to add one if (!message) { let dialog = new NotificationUI.ReplyDialog({ device: this.device, uuid: uuid, notification: notification, plugin: this }); dialog.present(); // Otherwise just send the reply } else { this.device.sendPacket({ type: 'kdeconnect.notification.reply', body: { requestReplyId: uuid, message: message } }); } } /** * Activate a remote notification action * * @param {string} id - The remote notification id * @param {string} action - The notification action (label) */ activateNotification(id, action) { this.device.sendPacket({ type: 'kdeconnect.notification.action', body: { action: action, key: id } }); } /** * Request the remote notifications be sent */ requestNotifications() { this.device.sendPacket({ type: 'kdeconnect.notification.request', body: {request: true} }); } });