sms.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  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 Plugin from '../plugin.js';
  8. import LegacyMessagingDialog from '../ui/legacyMessaging.js';
  9. import * as Messaging from '../ui/messaging.js';
  10. import SmsURI from '../utils/uri.js';
  11. export const Metadata = {
  12. label: _('SMS'),
  13. description: _('Send and read SMS of the paired device and be notified of new SMS'),
  14. id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SMS',
  15. incomingCapabilities: [
  16. 'kdeconnect.sms.messages',
  17. ],
  18. outgoingCapabilities: [
  19. 'kdeconnect.sms.request',
  20. 'kdeconnect.sms.request_conversation',
  21. 'kdeconnect.sms.request_conversations',
  22. ],
  23. actions: {
  24. // SMS Actions
  25. sms: {
  26. label: _('Messaging'),
  27. icon_name: 'sms-symbolic',
  28. parameter_type: null,
  29. incoming: [],
  30. outgoing: ['kdeconnect.sms.request'],
  31. },
  32. uriSms: {
  33. label: _('New SMS (URI)'),
  34. icon_name: 'sms-symbolic',
  35. parameter_type: new GLib.VariantType('s'),
  36. incoming: [],
  37. outgoing: ['kdeconnect.sms.request'],
  38. },
  39. replySms: {
  40. label: _('Reply SMS'),
  41. icon_name: 'sms-symbolic',
  42. parameter_type: new GLib.VariantType('s'),
  43. incoming: [],
  44. outgoing: ['kdeconnect.sms.request'],
  45. },
  46. sendMessage: {
  47. label: _('Send Message'),
  48. icon_name: 'sms-send',
  49. parameter_type: new GLib.VariantType('(aa{sv})'),
  50. incoming: [],
  51. outgoing: ['kdeconnect.sms.request'],
  52. },
  53. sendSms: {
  54. label: _('Send SMS'),
  55. icon_name: 'sms-send',
  56. parameter_type: new GLib.VariantType('(ss)'),
  57. incoming: [],
  58. outgoing: ['kdeconnect.sms.request'],
  59. },
  60. shareSms: {
  61. label: _('Share SMS'),
  62. icon_name: 'sms-send',
  63. parameter_type: new GLib.VariantType('s'),
  64. incoming: [],
  65. outgoing: ['kdeconnect.sms.request'],
  66. },
  67. },
  68. };
  69. /**
  70. * SMS Message event type. Currently all events are TEXT_MESSAGE.
  71. *
  72. * TEXT_MESSAGE: Has a "body" field which contains pure, human-readable text
  73. */
  74. export const MessageEventType = {
  75. TEXT_MESSAGE: 0x1,
  76. };
  77. /**
  78. * SMS Message status. READ/UNREAD match the 'read' field from the Android App
  79. * message packet.
  80. *
  81. * UNREAD: A message not marked as read
  82. * READ: A message marked as read
  83. */
  84. export const MessageStatus = {
  85. UNREAD: 0,
  86. READ: 1,
  87. };
  88. /**
  89. * SMS Message type, set from the 'type' field in the Android App
  90. * message packet.
  91. *
  92. * See: https://developer.android.com/reference/android/provider/Telephony.TextBasedSmsColumns.html
  93. *
  94. * ALL: all messages
  95. * INBOX: Received messages
  96. * SENT: Sent messages
  97. * DRAFT: Message drafts
  98. * OUTBOX: Outgoing messages
  99. * FAILED: Failed outgoing messages
  100. * QUEUED: Messages queued to send later
  101. */
  102. export const MessageBox = {
  103. ALL: 0,
  104. INBOX: 1,
  105. SENT: 2,
  106. DRAFT: 3,
  107. OUTBOX: 4,
  108. FAILED: 5,
  109. QUEUED: 6,
  110. };
  111. /**
  112. * SMS Plugin
  113. * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sms
  114. * https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SMSPlugin/
  115. */
  116. const SMSPlugin = GObject.registerClass({
  117. GTypeName: 'GSConnectSMSPlugin',
  118. Properties: {
  119. 'threads': GObject.param_spec_variant(
  120. 'threads',
  121. 'Conversation List',
  122. 'A list of threads',
  123. new GLib.VariantType('aa{sv}'),
  124. null,
  125. GObject.ParamFlags.READABLE
  126. ),
  127. },
  128. }, class SMSPlugin extends Plugin {
  129. _init(device) {
  130. super._init(device, 'sms');
  131. this.cacheProperties(['_threads']);
  132. }
  133. get threads() {
  134. if (this._threads === undefined)
  135. this._threads = {};
  136. return this._threads;
  137. }
  138. get window() {
  139. if (this.settings.get_boolean('legacy-sms')) {
  140. return new LegacyMessagingDialog({
  141. device: this.device,
  142. plugin: this,
  143. });
  144. }
  145. if (this._window === undefined) {
  146. this._window = new Messaging.Window({
  147. application: Gio.Application.get_default(),
  148. device: this.device,
  149. plugin: this,
  150. });
  151. this._window.connect('destroy', () => {
  152. this._window = undefined;
  153. });
  154. }
  155. return this._window;
  156. }
  157. clearCache() {
  158. this._threads = {};
  159. this.notify('threads');
  160. }
  161. cacheLoaded() {
  162. this.notify('threads');
  163. }
  164. connected() {
  165. super.connected();
  166. this._requestConversations();
  167. }
  168. handlePacket(packet) {
  169. switch (packet.type) {
  170. case 'kdeconnect.sms.messages':
  171. this._handleMessages(packet.body.messages);
  172. break;
  173. }
  174. }
  175. /**
  176. * Handle a digest of threads.
  177. *
  178. * @param {object[]} messages - A list of message objects
  179. * @param {string[]} thread_ids - A list of thread IDs as strings
  180. */
  181. _handleDigest(messages, thread_ids) {
  182. // Prune threads
  183. for (const thread_id of Object.keys(this.threads)) {
  184. if (!thread_ids.includes(thread_id))
  185. delete this.threads[thread_id];
  186. }
  187. // Request each new or newer thread
  188. for (let i = 0, len = messages.length; i < len; i++) {
  189. const message = messages[i];
  190. const cache = this.threads[message.thread_id];
  191. if (cache === undefined) {
  192. this._requestConversation(message.thread_id);
  193. continue;
  194. }
  195. // If this message is marked read, mark the rest as read
  196. if (message.read === MessageStatus.READ) {
  197. for (const msg of cache)
  198. msg.read = MessageStatus.READ;
  199. }
  200. // If we don't have a thread for this message or it's newer
  201. // than the last message in the cache, request the thread
  202. if (!cache.length || cache[cache.length - 1].date < message.date)
  203. this._requestConversation(message.thread_id);
  204. }
  205. this.notify('threads');
  206. }
  207. /**
  208. * Handle a new single message
  209. *
  210. * @param {object} message - A message object
  211. */
  212. _handleMessage(message) {
  213. let conversation = null;
  214. // If the window is open, try and find an active conversation
  215. if (this._window)
  216. conversation = this._window.getConversationForMessage(message);
  217. // If there's an active conversation, we should log the message now
  218. if (conversation)
  219. conversation.logNext(message);
  220. }
  221. /**
  222. * Parse a conversation (thread of messages) and sort them
  223. *
  224. * @param {object[]} thread - A list of sms message objects from a thread
  225. */
  226. _handleThread(thread) {
  227. // If there are no addresses this will cause major problems...
  228. if (!thread[0].addresses || !thread[0].addresses[0])
  229. return;
  230. const thread_id = thread[0].thread_id;
  231. const cache = this.threads[thread_id] || [];
  232. // Handle each message
  233. for (let i = 0, len = thread.length; i < len; i++) {
  234. const message = thread[i];
  235. // TODO: We only cache messages of a known MessageBox since we
  236. // have no reliable way to determine its direction, let alone
  237. // what to do with it.
  238. if (message.type < 0 || message.type > 6)
  239. continue;
  240. // If the message exists, just update it
  241. const cacheMessage = cache.find(m => m.date === message.date);
  242. if (cacheMessage) {
  243. Object.assign(cacheMessage, message);
  244. } else {
  245. cache.push(message);
  246. this._handleMessage(message);
  247. }
  248. }
  249. // Sort the thread by ascending date and notify
  250. this.threads[thread_id] = cache.sort((a, b) => a.date - b.date);
  251. this.notify('threads');
  252. }
  253. /**
  254. * Handle a response to telephony.request_conversation(s)
  255. *
  256. * @param {object[]} messages - A list of sms message objects
  257. */
  258. _handleMessages(messages) {
  259. try {
  260. // If messages is empty there's nothing to do...
  261. if (messages.length === 0)
  262. return;
  263. const thread_ids = [];
  264. // Perform some modification of the messages
  265. for (let i = 0, len = messages.length; i < len; i++) {
  266. const message = messages[i];
  267. // COERCION: thread_id's to strings
  268. message.thread_id = `${message.thread_id}`;
  269. thread_ids.push(message.thread_id);
  270. // TODO: Remove bogus `insert-address-token` entries
  271. let a = message.addresses.length;
  272. while (a--) {
  273. if (message.addresses[a].address === undefined ||
  274. message.addresses[a].address === 'insert-address-token')
  275. message.addresses.splice(a, 1);
  276. }
  277. }
  278. // If there's multiple thread_id's it's a summary of threads
  279. if (thread_ids.some(id => id !== thread_ids[0]))
  280. this._handleDigest(messages, thread_ids);
  281. // Otherwise this is single thread or new message
  282. else
  283. this._handleThread(messages);
  284. } catch (e) {
  285. debug(e, this.device.name);
  286. }
  287. }
  288. /**
  289. * Request a list of messages from a single thread.
  290. *
  291. * @param {number} thread_id - The id of the thread to request
  292. */
  293. _requestConversation(thread_id) {
  294. this.device.sendPacket({
  295. type: 'kdeconnect.sms.request_conversation',
  296. body: {
  297. threadID: thread_id,
  298. },
  299. });
  300. }
  301. /**
  302. * Request a list of the last message in each unarchived thread.
  303. */
  304. _requestConversations() {
  305. this.device.sendPacket({
  306. type: 'kdeconnect.sms.request_conversations',
  307. });
  308. }
  309. /**
  310. * A notification action for replying to SMS messages (or missed calls).
  311. *
  312. * @param {string} hint - Could be either a contact name or phone number
  313. */
  314. replySms(hint) {
  315. this.window.present();
  316. // FIXME: causes problems now that non-numeric addresses are allowed
  317. // this.window.address = hint.toPhoneNumber();
  318. }
  319. /**
  320. * Send an SMS message
  321. *
  322. * @param {string} phoneNumber - The phone number to send the message to
  323. * @param {string} messageBody - The message to send
  324. */
  325. sendSms(phoneNumber, messageBody) {
  326. this.device.sendPacket({
  327. type: 'kdeconnect.sms.request',
  328. body: {
  329. sendSms: true,
  330. phoneNumber: phoneNumber,
  331. messageBody: messageBody,
  332. },
  333. });
  334. }
  335. /**
  336. * Send a message
  337. *
  338. * @param {object[]} addresses - A list of address objects
  339. * @param {string} messageBody - The message text
  340. * @param {number} [event] - An event bitmask
  341. * @param {boolean} [forceSms] - Whether to force SMS
  342. * @param {number} [subId] - The SIM card to use
  343. */
  344. sendMessage(addresses, messageBody, event = 1, forceSms = false, subId = undefined) {
  345. // TODO: waiting on support in kdeconnect-android
  346. // if (this._version === 1) {
  347. this.device.sendPacket({
  348. type: 'kdeconnect.sms.request',
  349. body: {
  350. sendSms: true,
  351. phoneNumber: addresses[0].address,
  352. messageBody: messageBody,
  353. },
  354. });
  355. // } else if (this._version === 2) {
  356. // this.device.sendPacket({
  357. // type: 'kdeconnect.sms.request',
  358. // body: {
  359. // version: 2,
  360. // addresses: addresses,
  361. // messageBody: messageBody,
  362. // forceSms: forceSms,
  363. // sub_id: subId
  364. // }
  365. // });
  366. // }
  367. }
  368. /**
  369. * Share a text content by SMS message. This is used by the WebExtension to
  370. * share URLs from the browser, but could be used to initiate sharing of any
  371. * text content.
  372. *
  373. * @param {string} url - The link to be shared
  374. */
  375. shareSms(url) {
  376. // Legacy Mode
  377. if (this.settings.get_boolean('legacy-sms')) {
  378. const window = this.window;
  379. window.present();
  380. window.setMessage(url);
  381. // If there are active threads, show the chooser dialog
  382. } else if (Object.values(this.threads).length > 0) {
  383. const window = new Messaging.ConversationChooser({
  384. application: Gio.Application.get_default(),
  385. device: this.device,
  386. message: url,
  387. plugin: this,
  388. });
  389. window.present();
  390. // Otherwise show the window and wait for a contact to be chosen
  391. } else {
  392. this.window.present();
  393. this.window.setMessage(url, true);
  394. }
  395. }
  396. /**
  397. * Open and present the messaging window
  398. */
  399. sms() {
  400. this.window.present();
  401. }
  402. /**
  403. * This is the sms: URI scheme handler
  404. *
  405. * @param {string} uri - The URI the handle (sms:|sms://|sms:///)
  406. */
  407. uriSms(uri) {
  408. try {
  409. uri = new SmsURI(uri);
  410. // Lookup contacts
  411. const addresses = uri.recipients.map(number => {
  412. return {address: number.toPhoneNumber()};
  413. });
  414. const contacts = this.device.contacts.lookupAddresses(addresses);
  415. // Present the window and show the conversation
  416. const window = this.window;
  417. window.present();
  418. window.setContacts(contacts);
  419. // Set the outgoing message if the uri has a body variable
  420. if (uri.body)
  421. window.setMessage(uri.body);
  422. } catch (e) {
  423. debug(e, `${this.device.name}: "${uri}"`);
  424. }
  425. }
  426. _threadHasAddress(thread, addressObj) {
  427. const number = addressObj.address.toPhoneNumber();
  428. for (const taddressObj of thread[0].addresses) {
  429. const tnumber = taddressObj.address.toPhoneNumber();
  430. if (number.endsWith(tnumber) || tnumber.endsWith(number))
  431. return true;
  432. }
  433. return false;
  434. }
  435. /**
  436. * Try to find a thread_id in @smsPlugin for @addresses.
  437. *
  438. * @param {object[]} addresses - a list of address objects
  439. * @returns {string|null} a thread ID
  440. */
  441. getThreadIdForAddresses(addresses = []) {
  442. const threads = Object.values(this.threads);
  443. for (const thread of threads) {
  444. if (addresses.length !== thread[0].addresses.length)
  445. continue;
  446. if (addresses.every(addressObj => this._threadHasAddress(thread, addressObj)))
  447. return thread[0].thread_id;
  448. }
  449. return null;
  450. }
  451. destroy() {
  452. if (this._window !== undefined)
  453. this._window.destroy();
  454. super.destroy();
  455. }
  456. });
  457. export default SMSPlugin;