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