notification.js 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. 'use strict';
  5. const Gio = imports.gi.Gio;
  6. const GLib = imports.gi.GLib;
  7. const GObject = imports.gi.GObject;
  8. const Gtk = imports.gi.Gtk;
  9. const Components = imports.service.components;
  10. const Config = imports.config;
  11. const PluginBase = imports.service.plugin;
  12. const NotificationUI = imports.service.ui.notification;
  13. var Metadata = {
  14. label: _('Notifications'),
  15. description: _('Share notifications with the paired device'),
  16. id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.Notification',
  17. incomingCapabilities: [
  18. 'kdeconnect.notification',
  19. 'kdeconnect.notification.request',
  20. ],
  21. outgoingCapabilities: [
  22. 'kdeconnect.notification',
  23. 'kdeconnect.notification.action',
  24. 'kdeconnect.notification.reply',
  25. 'kdeconnect.notification.request',
  26. ],
  27. actions: {
  28. withdrawNotification: {
  29. label: _('Cancel Notification'),
  30. icon_name: 'preferences-system-notifications-symbolic',
  31. parameter_type: new GLib.VariantType('s'),
  32. incoming: [],
  33. outgoing: ['kdeconnect.notification'],
  34. },
  35. closeNotification: {
  36. label: _('Close Notification'),
  37. icon_name: 'preferences-system-notifications-symbolic',
  38. parameter_type: new GLib.VariantType('s'),
  39. incoming: [],
  40. outgoing: ['kdeconnect.notification.request'],
  41. },
  42. replyNotification: {
  43. label: _('Reply Notification'),
  44. icon_name: 'preferences-system-notifications-symbolic',
  45. parameter_type: new GLib.VariantType('(ssa{ss})'),
  46. incoming: ['kdeconnect.notification'],
  47. outgoing: ['kdeconnect.notification.reply'],
  48. },
  49. sendNotification: {
  50. label: _('Send Notification'),
  51. icon_name: 'preferences-system-notifications-symbolic',
  52. parameter_type: new GLib.VariantType('a{sv}'),
  53. incoming: [],
  54. outgoing: ['kdeconnect.notification'],
  55. },
  56. activateNotification: {
  57. label: _('Activate Notification'),
  58. icon_name: 'preferences-system-notifications-symbolic',
  59. parameter_type: new GLib.VariantType('(ss)'),
  60. incoming: [],
  61. outgoing: ['kdeconnect.notification.action'],
  62. },
  63. },
  64. };
  65. // A regex for our custom notificaiton ids
  66. const ID_REGEX = /^(fdo|gtk)\|([^|]+)\|(.*)$/;
  67. // A list of known SMS apps
  68. const SMS_APPS = [
  69. // Popular apps that don't contain the string 'sms'
  70. 'com.android.messaging', // AOSP
  71. 'com.google.android.apps.messaging', // Google Messages
  72. 'com.textra', // Textra
  73. 'xyz.klinker.messenger', // Pulse
  74. 'com.calea.echo', // Mood Messenger
  75. 'com.moez.QKSMS', // QKSMS
  76. 'rpkandrodev.yaata', // YAATA
  77. 'com.tencent.mm', // WeChat
  78. 'com.viber.voip', // Viber
  79. 'com.kakao.talk', // KakaoTalk
  80. 'com.concentriclivers.mms.com.android.mms', // AOSP Clone
  81. 'fr.slvn.mms', // AOSP Clone
  82. 'com.promessage.message', //
  83. 'com.htc.sense.mms', // HTC Messages
  84. // Known not to work with sms plugin
  85. 'org.thoughtcrime.securesms', // Signal Private Messenger
  86. 'com.samsung.android.messaging', // Samsung Messages
  87. ];
  88. /**
  89. * Try to determine if an notification is from an SMS app
  90. *
  91. * @param {Core.Packet} packet - A `kdeconnect.notification`
  92. * @return {boolean} Whether the notification is from an SMS app
  93. */
  94. function _isSmsNotification(packet) {
  95. const id = packet.body.id;
  96. if (id.includes('sms'))
  97. return true;
  98. for (let i = 0, len = SMS_APPS.length; i < len; i++) {
  99. if (id.includes(SMS_APPS[i]))
  100. return true;
  101. }
  102. return false;
  103. }
  104. /**
  105. * Remove a local libnotify or Gtk notification.
  106. *
  107. * @param {String|Number} id - Gtk (string) or libnotify id (uint32)
  108. * @param {String|null} application - Application Id if Gtk or null
  109. */
  110. function _removeNotification(id, application = null) {
  111. let name, path, method, variant;
  112. if (application !== null) {
  113. name = 'org.gtk.Notifications';
  114. method = 'RemoveNotification';
  115. path = '/org/gtk/Notifications';
  116. variant = new GLib.Variant('(ss)', [application, id]);
  117. } else {
  118. name = 'org.freedesktop.Notifications';
  119. path = '/org/freedesktop/Notifications';
  120. method = 'CloseNotification';
  121. variant = new GLib.Variant('(u)', [id]);
  122. }
  123. Gio.DBus.session.call(
  124. name, path, name, method, variant, null,
  125. Gio.DBusCallFlags.NONE, -1, null,
  126. (connection, res) => {
  127. try {
  128. connection.call_finish(res);
  129. } catch (e) {
  130. logError(e);
  131. }
  132. }
  133. );
  134. }
  135. /**
  136. * Notification Plugin
  137. * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/notifications
  138. * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sendnotifications
  139. */
  140. var Plugin = GObject.registerClass({
  141. GTypeName: 'GSConnectNotificationPlugin',
  142. }, class Plugin extends PluginBase.Plugin {
  143. _init(device) {
  144. super._init(device, 'notification');
  145. this._listener = Components.acquire('notification');
  146. this._session = Components.acquire('session');
  147. this._notificationAddedId = this._listener.connect(
  148. 'notification-added',
  149. this._onNotificationAdded.bind(this)
  150. );
  151. // Load application notification settings
  152. this._applicationsChangedId = this.settings.connect(
  153. 'changed::applications',
  154. this._onApplicationsChanged.bind(this)
  155. );
  156. this._onApplicationsChanged(this.settings, 'applications');
  157. this._applicationsChangedSkip = false;
  158. }
  159. connected() {
  160. super.connected();
  161. this._requestNotifications();
  162. }
  163. handlePacket(packet) {
  164. switch (packet.type) {
  165. case 'kdeconnect.notification':
  166. this._handleNotification(packet);
  167. break;
  168. // TODO
  169. case 'kdeconnect.notification.action':
  170. this._handleNotificationAction(packet);
  171. break;
  172. // No Linux/BSD desktop notifications are repliable as yet
  173. case 'kdeconnect.notification.reply':
  174. debug(`Not implemented: ${packet.type}`);
  175. break;
  176. case 'kdeconnect.notification.request':
  177. this._handleNotificationRequest(packet);
  178. break;
  179. default:
  180. debug(`Unknown notification packet: ${packet.type}`);
  181. }
  182. }
  183. _onApplicationsChanged(settings, key) {
  184. if (this._applicationsChangedSkip)
  185. return;
  186. try {
  187. const json = settings.get_string(key);
  188. this._applications = JSON.parse(json);
  189. } catch (e) {
  190. debug(e, this.device.name);
  191. this._applicationsChangedSkip = true;
  192. settings.set_string(key, '{}');
  193. this._applicationsChangedSkip = false;
  194. }
  195. }
  196. _onNotificationAdded(listener, notification) {
  197. try {
  198. const notif = notification.full_unpack();
  199. // An unconfigured application
  200. if (notif.appName && !this._applications[notif.appName]) {
  201. this._applications[notif.appName] = {
  202. iconName: 'system-run-symbolic',
  203. enabled: true,
  204. };
  205. // Store the themed icons for the device preferences window
  206. if (notif.icon === undefined) {
  207. // Keep default
  208. } else if (typeof notif.icon === 'string') {
  209. this._applications[notif.appName].iconName = notif.icon;
  210. } else if (notif.icon instanceof Gio.ThemedIcon) {
  211. const iconName = notif.icon.get_names()[0];
  212. this._applications[notif.appName].iconName = iconName;
  213. }
  214. this._applicationsChangedSkip = true;
  215. this.settings.set_string(
  216. 'applications',
  217. JSON.stringify(this._applications)
  218. );
  219. this._applicationsChangedSkip = false;
  220. }
  221. // Sending notifications forbidden
  222. if (!this.settings.get_boolean('send-notifications'))
  223. return;
  224. // Sending when the session is active is forbidden
  225. if (!this.settings.get_boolean('send-active') && this._session.active)
  226. return;
  227. // Notifications disabled for this application
  228. if (notif.appName && !this._applications[notif.appName].enabled)
  229. return;
  230. this.sendNotification(notif);
  231. } catch (e) {
  232. debug(e, this.device.name);
  233. }
  234. }
  235. /**
  236. * Handle an incoming notification or closed report.
  237. *
  238. * FIXME: upstream kdeconnect-android is tagging many notifications as
  239. * `silent`, causing them to never be shown. Since we already handle
  240. * duplicates in the Shell, we ignore that flag for now.
  241. *
  242. * @param {Core.Packet} packet - A `kdeconnect.notification`
  243. */
  244. _handleNotification(packet) {
  245. // A report that a remote notification has been dismissed
  246. if (packet.body.hasOwnProperty('isCancel'))
  247. this.device.hideNotification(packet.body.id);
  248. // A normal, remote notification
  249. else
  250. this._receiveNotification(packet);
  251. }
  252. /**
  253. * Handle an incoming request to activate a notification action.
  254. *
  255. * @param {Core.Packet} packet - A `kdeconnect.notification.action`
  256. */
  257. _handleNotificationAction(packet) {
  258. throw new GObject.NotImplementedError();
  259. }
  260. /**
  261. * Handle an incoming request to close or list notifications.
  262. *
  263. * @param {Core.Packet} packet - A `kdeconnect.notification.request`
  264. */
  265. _handleNotificationRequest(packet) {
  266. // A request for our notifications. This isn't implemented and would be
  267. // pretty hard to without communicating with GNOME Shell.
  268. if (packet.body.hasOwnProperty('request'))
  269. return;
  270. // A request to close a local notification
  271. //
  272. // TODO: kdeconnect-android doesn't send these, and will instead send a
  273. // kdeconnect.notification packet with isCancel and an id of "0".
  274. //
  275. // For clients that do support it, we report notification ids in the
  276. // form "type|application-id|notification-id" so we can close it with
  277. // the appropriate service.
  278. if (packet.body.hasOwnProperty('cancel')) {
  279. const [, type, application, id] = ID_REGEX.exec(packet.body.cancel);
  280. if (type === 'fdo')
  281. _removeNotification(parseInt(id));
  282. else if (type === 'gtk')
  283. _removeNotification(id, application);
  284. }
  285. }
  286. /**
  287. * Upload an icon from a GLib.Bytes object.
  288. *
  289. * @param {Core.Packet} packet - The packet for the notification
  290. * @param {GLib.Bytes} bytes - The icon bytes
  291. */
  292. _uploadBytesIcon(packet, bytes) {
  293. const stream = Gio.MemoryInputStream.new_from_bytes(bytes);
  294. this._uploadIconStream(packet, stream, bytes.get_size());
  295. }
  296. /**
  297. * Upload an icon from a Gio.File object.
  298. *
  299. * @param {Core.Packet} packet - A `kdeconnect.notification`
  300. * @param {Gio.File} file - A file object for the icon
  301. */
  302. async _uploadFileIcon(packet, file) {
  303. const read = file.read_async(GLib.PRIORITY_DEFAULT, null);
  304. const query = file.query_info_async('standard::size',
  305. Gio.FileQueryInfoFlags.NONE, GLib.PRIORITY_DEFAULT, null);
  306. const [stream, info] = await Promise.all([read, query]);
  307. this._uploadIconStream(packet, stream, info.get_size());
  308. }
  309. /**
  310. * A function for uploading GThemedIcons
  311. *
  312. * @param {Core.Packet} packet - The packet for the notification
  313. * @param {Gio.ThemedIcon} icon - The GIcon to upload
  314. */
  315. _uploadThemedIcon(packet, icon) {
  316. const theme = Gtk.IconTheme.get_default();
  317. let file = null;
  318. for (const name of icon.names) {
  319. // NOTE: kdeconnect-android doesn't support SVGs
  320. const size = Math.max.apply(null, theme.get_icon_sizes(name));
  321. const info = theme.lookup_icon(name, size, Gtk.IconLookupFlags.NO_SVG);
  322. // Send the first icon we find from the options
  323. if (info) {
  324. file = Gio.File.new_for_path(info.get_filename());
  325. break;
  326. }
  327. }
  328. if (file)
  329. this._uploadFileIcon(packet, file);
  330. else
  331. this.device.sendPacket(packet);
  332. }
  333. /**
  334. * All icon types end up being uploaded in this function.
  335. *
  336. * @param {Core.Packet} packet - The packet for the notification
  337. * @param {Gio.InputStream} stream - A stream to read the icon bytes from
  338. * @param {number} size - Size of the icon in bytes
  339. */
  340. async _uploadIconStream(packet, stream, size) {
  341. try {
  342. const transfer = this.device.createTransfer();
  343. transfer.addStream(packet, stream, size);
  344. await transfer.start();
  345. } catch (e) {
  346. debug(e);
  347. this.device.sendPacket(packet);
  348. }
  349. }
  350. /**
  351. * Upload an icon from a GIcon or themed icon name.
  352. *
  353. * @param {Core.Packet} packet - A `kdeconnect.notification`
  354. * @param {Gio.Icon|string|null} icon - An icon or %null
  355. * @return {Promise} A promise for the operation
  356. */
  357. _uploadIcon(packet, icon = null) {
  358. // Normalize strings into GIcons
  359. if (typeof icon === 'string')
  360. icon = Gio.Icon.new_for_string(icon);
  361. if (icon instanceof Gio.ThemedIcon)
  362. return this._uploadThemedIcon(packet, icon);
  363. if (icon instanceof Gio.FileIcon)
  364. return this._uploadFileIcon(packet, icon.get_file());
  365. if (icon instanceof Gio.BytesIcon)
  366. return this._uploadBytesIcon(packet, icon.get_bytes());
  367. return this.device.sendPacket(packet);
  368. }
  369. /**
  370. * Send a local notification to the remote device.
  371. *
  372. * @param {Object} notif - A dictionary of notification parameters
  373. * @param {string} notif.appName - The notifying application
  374. * @param {string} notif.id - The notification ID
  375. * @param {string} notif.title - The notification title
  376. * @param {string} notif.body - The notification body
  377. * @param {string} notif.ticker - The notification title & body
  378. * @param {boolean} notif.isClearable - If the notification can be closed
  379. * @param {string|Gio.Icon} notif.icon - An icon name or GIcon
  380. */
  381. async sendNotification(notif) {
  382. try {
  383. const icon = notif.icon || null;
  384. delete notif.icon;
  385. await this._uploadIcon({
  386. type: 'kdeconnect.notification',
  387. body: notif,
  388. }, icon);
  389. } catch (e) {
  390. logError(e);
  391. }
  392. }
  393. async _downloadIcon(packet) {
  394. try {
  395. if (!packet.hasPayload())
  396. return null;
  397. // Save the file in the global cache
  398. const path = GLib.build_filenamev([
  399. Config.CACHEDIR,
  400. packet.body.payloadHash || `${Date.now()}`,
  401. ]);
  402. // Check if we've already downloaded this icon
  403. // NOTE: if we reject the transfer kdeconnect-android will resend
  404. // the notification packet, which may cause problems wrt #789
  405. const file = Gio.File.new_for_path(path);
  406. if (file.query_exists(null))
  407. return new Gio.FileIcon({file: file});
  408. // Open the target path and create a transfer
  409. const transfer = this.device.createTransfer();
  410. transfer.addFile(packet, file);
  411. try {
  412. await transfer.start();
  413. return new Gio.FileIcon({file: file});
  414. } catch (e) {
  415. debug(e, this.device.name);
  416. file.delete_async(GLib.PRIORITY_DEFAULT, null, null);
  417. return null;
  418. }
  419. } catch (e) {
  420. debug(e, this.device.name);
  421. return null;
  422. }
  423. }
  424. /**
  425. * Receive an incoming notification.
  426. *
  427. * @param {Core.Packet} packet - A `kdeconnect.notification`
  428. */
  429. async _receiveNotification(packet) {
  430. try {
  431. // Set defaults
  432. let action = null;
  433. let buttons = [];
  434. let id = packet.body.id;
  435. let title = packet.body.appName;
  436. let body = `${packet.body.title}: ${packet.body.text}`;
  437. let icon = await this._downloadIcon(packet);
  438. // Repliable Notification
  439. if (packet.body.requestReplyId) {
  440. id = `${packet.body.id}|${packet.body.requestReplyId}`;
  441. action = {
  442. name: 'replyNotification',
  443. parameter: new GLib.Variant('(ssa{ss})', [
  444. packet.body.requestReplyId,
  445. '',
  446. {
  447. appName: packet.body.appName,
  448. title: packet.body.title,
  449. text: packet.body.text,
  450. },
  451. ]),
  452. };
  453. }
  454. // Notification Actions
  455. if (packet.body.actions) {
  456. buttons = packet.body.actions.map(action => {
  457. return {
  458. label: action,
  459. action: 'activateNotification',
  460. parameter: new GLib.Variant('(ss)', [id, action]),
  461. };
  462. });
  463. }
  464. // Special case for Missed Calls
  465. if (packet.body.id.includes('MissedCall')) {
  466. title = packet.body.title;
  467. body = packet.body.text;
  468. if (icon === null)
  469. icon = new Gio.ThemedIcon({name: 'call-missed-symbolic'});
  470. // Special case for SMS notifications
  471. } else if (_isSmsNotification(packet)) {
  472. title = packet.body.title;
  473. body = packet.body.text;
  474. action = {
  475. name: 'replySms',
  476. parameter: new GLib.Variant('s', packet.body.title),
  477. };
  478. if (icon === null)
  479. icon = new Gio.ThemedIcon({name: 'sms-symbolic'});
  480. // Special case where 'appName' is the same as 'title'
  481. } else if (packet.body.appName === packet.body.title) {
  482. body = packet.body.text;
  483. }
  484. // Use the device icon if we still don't have one
  485. if (icon === null)
  486. icon = new Gio.ThemedIcon({name: this.device.icon_name});
  487. // Show the notification
  488. this.device.showNotification({
  489. id: id,
  490. title: title,
  491. body: body,
  492. icon: icon,
  493. action: action,
  494. buttons: buttons,
  495. });
  496. } catch (e) {
  497. logError(e);
  498. }
  499. }
  500. /**
  501. * Request the remote notifications be sent
  502. */
  503. _requestNotifications() {
  504. this.device.sendPacket({
  505. type: 'kdeconnect.notification.request',
  506. body: {request: true},
  507. });
  508. }
  509. /**
  510. * Report that a local notification has been closed/dismissed.
  511. * TODO: kdeconnect-android doesn't handle incoming isCancel packets.
  512. *
  513. * @param {string} id - The local notification id
  514. */
  515. withdrawNotification(id) {
  516. this.device.sendPacket({
  517. type: 'kdeconnect.notification',
  518. body: {
  519. isCancel: true,
  520. id: id,
  521. },
  522. });
  523. }
  524. /**
  525. * Close a remote notification.
  526. * TODO: ignore local notifications
  527. *
  528. * @param {string} id - The remote notification id
  529. */
  530. closeNotification(id) {
  531. this.device.sendPacket({
  532. type: 'kdeconnect.notification.request',
  533. body: {cancel: id},
  534. });
  535. }
  536. /**
  537. * Reply to a notification sent with a requestReplyId UUID
  538. *
  539. * @param {string} uuid - The requestReplyId for the repliable notification
  540. * @param {string} message - The message to reply with
  541. * @param {Object} notification - The original notification packet
  542. */
  543. replyNotification(uuid, message, notification) {
  544. // If this happens for some reason, things will explode
  545. if (!uuid)
  546. throw Error('Missing UUID');
  547. // If the message has no content, open a dialog for the user to add one
  548. if (!message) {
  549. const dialog = new NotificationUI.ReplyDialog({
  550. device: this.device,
  551. uuid: uuid,
  552. notification: notification,
  553. plugin: this,
  554. });
  555. dialog.present();
  556. // Otherwise just send the reply
  557. } else {
  558. this.device.sendPacket({
  559. type: 'kdeconnect.notification.reply',
  560. body: {
  561. requestReplyId: uuid,
  562. message: message,
  563. },
  564. });
  565. }
  566. }
  567. /**
  568. * Activate a remote notification action
  569. *
  570. * @param {string} id - The remote notification id
  571. * @param {string} action - The notification action (label)
  572. */
  573. activateNotification(id, action) {
  574. this.device.sendPacket({
  575. type: 'kdeconnect.notification.action',
  576. body: {
  577. action: action,
  578. key: id,
  579. },
  580. });
  581. }
  582. destroy() {
  583. this.settings.disconnect(this._applicationsChangedId);
  584. if (this._listener !== undefined) {
  585. this._listener.disconnect(this._notificationAddedId);
  586. this._listener = Components.release('notification');
  587. }
  588. if (this._session !== undefined)
  589. this._session = Components.release('session');
  590. super.destroy();
  591. }
  592. });