695 lines
22 KiB
JavaScript
Executable File
695 lines
22 KiB
JavaScript
Executable File
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
import Gio from 'gi://Gio';
|
|
import GLib from 'gi://GLib';
|
|
import GObject from 'gi://GObject';
|
|
import Gtk from 'gi://Gtk';
|
|
|
|
import * as Components from '../components/index.js';
|
|
import Config from '../../config.js';
|
|
import Plugin from '../plugin.js';
|
|
import ReplyDialog from '../ui/notification.js';
|
|
|
|
|
|
export const Metadata = {
|
|
label: _('Notifications'),
|
|
description: _('Share notifications with the paired device'),
|
|
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
|
|
];
|
|
|
|
|
|
/**
|
|
* Try to determine if an notification is from an SMS app
|
|
*
|
|
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
|
* @return {boolean} Whether the notification is from an SMS app
|
|
*/
|
|
function _isSmsNotification(packet) {
|
|
const id = packet.body.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;
|
|
}
|
|
|
|
|
|
/**
|
|
* Remove a local libnotify or Gtk notification.
|
|
*
|
|
* @param {String|Number} id - Gtk (string) or libnotify id (uint32)
|
|
* @param {String|null} application - Application Id if Gtk or null
|
|
*/
|
|
function _removeNotification(id, application = null) {
|
|
let name, path, method, variant;
|
|
|
|
if (application !== null) {
|
|
name = 'org.gtk.Notifications';
|
|
method = 'RemoveNotification';
|
|
path = '/org/gtk/Notifications';
|
|
variant = new GLib.Variant('(ss)', [application, id]);
|
|
} else {
|
|
name = 'org.freedesktop.Notifications';
|
|
path = '/org/freedesktop/Notifications';
|
|
method = 'CloseNotification';
|
|
variant = new GLib.Variant('(u)', [id]);
|
|
}
|
|
|
|
Gio.DBus.session.call(
|
|
name, path, name, method, variant, null,
|
|
Gio.DBusCallFlags.NONE, -1, null,
|
|
(connection, res) => {
|
|
try {
|
|
connection.call_finish(res);
|
|
} catch (e) {
|
|
logError(e);
|
|
}
|
|
}
|
|
);
|
|
}
|
|
|
|
|
|
/**
|
|
* Notification Plugin
|
|
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/notifications
|
|
* https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sendnotifications
|
|
*/
|
|
const NotificationPlugin = GObject.registerClass({
|
|
GTypeName: 'GSConnectNotificationPlugin',
|
|
}, class NotificationPlugin extends Plugin {
|
|
|
|
_init(device) {
|
|
super._init(device, 'notification');
|
|
|
|
this._listener = Components.acquire('notification');
|
|
this._session = Components.acquire('session');
|
|
|
|
this._notificationAddedId = this._listener.connect(
|
|
'notification-added',
|
|
this._onNotificationAdded.bind(this)
|
|
);
|
|
|
|
// Load application notification settings
|
|
this._applicationsChangedId = this.settings.connect(
|
|
'changed::applications',
|
|
this._onApplicationsChanged.bind(this)
|
|
);
|
|
this._onApplicationsChanged(this.settings, 'applications');
|
|
this._applicationsChangedSkip = false;
|
|
}
|
|
|
|
connected() {
|
|
super.connected();
|
|
|
|
this._requestNotifications();
|
|
}
|
|
|
|
handlePacket(packet) {
|
|
switch (packet.type) {
|
|
case 'kdeconnect.notification':
|
|
this._handleNotification(packet);
|
|
break;
|
|
|
|
// TODO
|
|
case 'kdeconnect.notification.action':
|
|
this._handleNotificationAction(packet);
|
|
break;
|
|
|
|
// No Linux/BSD desktop notifications are repliable as yet
|
|
case 'kdeconnect.notification.reply':
|
|
debug(`Not implemented: ${packet.type}`);
|
|
break;
|
|
|
|
case 'kdeconnect.notification.request':
|
|
this._handleNotificationRequest(packet);
|
|
break;
|
|
|
|
default:
|
|
debug(`Unknown notification packet: ${packet.type}`);
|
|
}
|
|
}
|
|
|
|
_onApplicationsChanged(settings, key) {
|
|
if (this._applicationsChangedSkip)
|
|
return;
|
|
|
|
try {
|
|
const json = settings.get_string(key);
|
|
this._applications = JSON.parse(json);
|
|
} catch (e) {
|
|
debug(e, this.device.name);
|
|
|
|
this._applicationsChangedSkip = true;
|
|
settings.set_string(key, '{}');
|
|
this._applicationsChangedSkip = false;
|
|
}
|
|
}
|
|
|
|
_onNotificationAdded(listener, notification) {
|
|
try {
|
|
const notif = notification.full_unpack();
|
|
|
|
// An unconfigured application
|
|
if (notif.appName && !this._applications[notif.appName]) {
|
|
this._applications[notif.appName] = {
|
|
iconName: 'system-run-symbolic',
|
|
enabled: true,
|
|
};
|
|
|
|
// Store the themed icons for the device preferences window
|
|
if (notif.icon === undefined) {
|
|
// Keep default
|
|
|
|
} else if (typeof notif.icon === 'string') {
|
|
this._applications[notif.appName].iconName = notif.icon;
|
|
|
|
} else if (notif.icon instanceof Gio.ThemedIcon) {
|
|
const iconName = notif.icon.get_names()[0];
|
|
this._applications[notif.appName].iconName = iconName;
|
|
}
|
|
|
|
this._applicationsChangedSkip = true;
|
|
this.settings.set_string(
|
|
'applications',
|
|
JSON.stringify(this._applications)
|
|
);
|
|
this._applicationsChangedSkip = false;
|
|
}
|
|
|
|
// Sending notifications forbidden
|
|
if (!this.settings.get_boolean('send-notifications'))
|
|
return;
|
|
|
|
// Sending when the session is active is forbidden
|
|
if (!this.settings.get_boolean('send-active') && this._session.active)
|
|
return;
|
|
|
|
// Notifications disabled for this application
|
|
if (notif.appName && !this._applications[notif.appName].enabled)
|
|
return;
|
|
|
|
this.sendNotification(notif);
|
|
} catch (e) {
|
|
debug(e, this.device.name);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*
|
|
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
|
*/
|
|
_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 activate a notification action.
|
|
*
|
|
* @param {Core.Packet} packet - A `kdeconnect.notification.action`
|
|
*/
|
|
_handleNotificationAction(packet) {
|
|
throw new GObject.NotImplementedError();
|
|
}
|
|
|
|
/**
|
|
* Handle an incoming request to close or list notifications.
|
|
*
|
|
* @param {Core.Packet} packet - A `kdeconnect.notification.request`
|
|
*/
|
|
_handleNotificationRequest(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.
|
|
if (packet.body.hasOwnProperty('cancel')) {
|
|
const [, type, application, id] = ID_REGEX.exec(packet.body.cancel);
|
|
|
|
if (type === 'fdo')
|
|
_removeNotification(parseInt(id));
|
|
else if (type === 'gtk')
|
|
_removeNotification(id, application);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload an icon from a GLib.Bytes object.
|
|
*
|
|
* @param {Core.Packet} packet - The packet for the notification
|
|
* @param {GLib.Bytes} bytes - The icon bytes
|
|
*/
|
|
_uploadBytesIcon(packet, bytes) {
|
|
const stream = Gio.MemoryInputStream.new_from_bytes(bytes);
|
|
this._uploadIconStream(packet, stream, bytes.get_size());
|
|
}
|
|
|
|
/**
|
|
* Upload an icon from a Gio.File object.
|
|
*
|
|
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
|
* @param {Gio.File} file - A file object for the icon
|
|
*/
|
|
async _uploadFileIcon(packet, file) {
|
|
const read = file.read_async(GLib.PRIORITY_DEFAULT, null);
|
|
const query = file.query_info_async('standard::size',
|
|
Gio.FileQueryInfoFlags.NONE, GLib.PRIORITY_DEFAULT, null);
|
|
const [stream, info] = await Promise.all([read, query]);
|
|
|
|
this._uploadIconStream(packet, stream, info.get_size());
|
|
}
|
|
|
|
/**
|
|
* A function for uploading GThemedIcons
|
|
*
|
|
* @param {Core.Packet} packet - The packet for the notification
|
|
* @param {Gio.ThemedIcon} icon - The GIcon to upload
|
|
*/
|
|
_uploadThemedIcon(packet, icon) {
|
|
const theme = Gtk.IconTheme.get_default();
|
|
let file = null;
|
|
|
|
for (const name of icon.names) {
|
|
// NOTE: kdeconnect-android doesn't support SVGs
|
|
const size = Math.max.apply(null, theme.get_icon_sizes(name));
|
|
const info = theme.lookup_icon(name, size, Gtk.IconLookupFlags.NO_SVG);
|
|
|
|
// Send the first icon we find from the options
|
|
if (info) {
|
|
file = Gio.File.new_for_path(info.get_filename());
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (file)
|
|
this._uploadFileIcon(packet, file);
|
|
else
|
|
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 {
|
|
const transfer = this.device.createTransfer();
|
|
transfer.addStream(packet, stream, size);
|
|
|
|
await transfer.start();
|
|
} catch (e) {
|
|
debug(e);
|
|
|
|
this.device.sendPacket(packet);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Upload an icon from a GIcon or themed icon name.
|
|
*
|
|
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
|
* @param {Gio.Icon|string|null} icon - An icon or %null
|
|
* @return {Promise} A promise for the operation
|
|
*/
|
|
_uploadIcon(packet, icon = null) {
|
|
// Normalize strings into GIcons
|
|
if (typeof icon === 'string')
|
|
icon = Gio.Icon.new_for_string(icon);
|
|
|
|
if (icon instanceof Gio.ThemedIcon)
|
|
return this._uploadThemedIcon(packet, icon);
|
|
|
|
if (icon instanceof Gio.FileIcon)
|
|
return this._uploadFileIcon(packet, icon.get_file());
|
|
|
|
if (icon instanceof Gio.BytesIcon)
|
|
return this._uploadBytesIcon(packet, icon.get_bytes());
|
|
|
|
return this.device.sendPacket(packet);
|
|
}
|
|
|
|
/**
|
|
* Send a local notification to the remote device.
|
|
*
|
|
* @param {Object} notif - A dictionary of notification parameters
|
|
* @param {string} notif.appName - The notifying application
|
|
* @param {string} notif.id - The notification ID
|
|
* @param {string} notif.title - The notification title
|
|
* @param {string} notif.body - The notification body
|
|
* @param {string} notif.ticker - The notification title & body
|
|
* @param {boolean} notif.isClearable - If the notification can be closed
|
|
* @param {string|Gio.Icon} notif.icon - An icon name or GIcon
|
|
*/
|
|
async sendNotification(notif) {
|
|
try {
|
|
const icon = notif.icon || null;
|
|
delete notif.icon;
|
|
|
|
await this._uploadIcon({
|
|
type: 'kdeconnect.notification',
|
|
body: notif,
|
|
}, icon);
|
|
} catch (e) {
|
|
logError(e);
|
|
}
|
|
}
|
|
|
|
async _downloadIcon(packet) {
|
|
try {
|
|
if (!packet.hasPayload())
|
|
return null;
|
|
|
|
// Save the file in the global cache
|
|
const path = GLib.build_filenamev([
|
|
Config.CACHEDIR,
|
|
packet.body.payloadHash || `${Date.now()}`,
|
|
]);
|
|
|
|
// Check if we've already downloaded this icon
|
|
// NOTE: if we reject the transfer kdeconnect-android will resend
|
|
// the notification packet, which may cause problems wrt #789
|
|
const file = Gio.File.new_for_path(path);
|
|
|
|
if (file.query_exists(null))
|
|
return new Gio.FileIcon({file: file});
|
|
|
|
// Open the target path and create a transfer
|
|
const transfer = this.device.createTransfer();
|
|
|
|
transfer.addFile(packet, file);
|
|
|
|
try {
|
|
await transfer.start();
|
|
|
|
return new Gio.FileIcon({file: file});
|
|
} catch (e) {
|
|
debug(e, this.device.name);
|
|
|
|
file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
|
|
return null;
|
|
}
|
|
} catch (e) {
|
|
debug(e, this.device.name);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Receive an incoming notification.
|
|
*
|
|
* @param {Core.Packet} packet - A `kdeconnect.notification`
|
|
*/
|
|
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);
|
|
|
|
// 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,
|
|
},
|
|
]),
|
|
};
|
|
}
|
|
|
|
// Notification Actions
|
|
if (packet.body.actions) {
|
|
buttons = packet.body.actions.map(action => {
|
|
return {
|
|
label: action,
|
|
action: 'activateNotification',
|
|
parameter: new GLib.Variant('(ss)', [id, action]),
|
|
};
|
|
});
|
|
}
|
|
|
|
// Special case for Missed Calls
|
|
if (packet.body.id.includes('MissedCall')) {
|
|
title = packet.body.title;
|
|
body = packet.body.text;
|
|
|
|
if (icon === null)
|
|
icon = new Gio.ThemedIcon({name: 'call-missed-symbolic'});
|
|
|
|
// Special case for SMS notifications
|
|
} else if (_isSmsNotification(packet)) {
|
|
title = packet.body.title;
|
|
body = packet.body.text;
|
|
action = {
|
|
name: 'replySms',
|
|
parameter: new GLib.Variant('s', packet.body.title),
|
|
};
|
|
|
|
if (icon === null)
|
|
icon = new Gio.ThemedIcon({name: 'sms-symbolic'});
|
|
|
|
// Special case where 'appName' is the same as 'title'
|
|
} else if (packet.body.appName === packet.body.title) {
|
|
body = packet.body.text;
|
|
}
|
|
|
|
// Use the device icon if we still don't have one
|
|
if (icon === null)
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Request the remote notifications be sent
|
|
*/
|
|
_requestNotifications() {
|
|
this.device.sendPacket({
|
|
type: 'kdeconnect.notification.request',
|
|
body: {request: true},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 this happens for some reason, things will explode
|
|
if (!uuid)
|
|
throw Error('Missing UUID');
|
|
|
|
// If the message has no content, open a dialog for the user to add one
|
|
if (!message) {
|
|
const dialog = new 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,
|
|
},
|
|
});
|
|
}
|
|
|
|
destroy() {
|
|
this.settings.disconnect(this._applicationsChangedId);
|
|
|
|
if (this._listener !== undefined) {
|
|
this._listener.disconnect(this._notificationAddedId);
|
|
this._listener = Components.release('notification');
|
|
}
|
|
|
|
if (this._session !== undefined)
|
|
this._session = Components.release('session');
|
|
|
|
super.destroy();
|
|
}
|
|
});
|
|
|
|
export default NotificationPlugin;
|