631 lines
20 KiB
JavaScript
631 lines
20 KiB
JavaScript
|
'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}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|