'use strict'; const Gio = imports.gi.Gio; const GLib = imports.gi.GLib; const GObject = imports.gi.GObject; const PluginsBase = imports.service.plugins.base; const Messaging = imports.service.ui.messaging; const TelephonyUI = imports.service.ui.telephony; const URI = imports.utils.uri; var Metadata = { label: _('SMS'), id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SMS', incomingCapabilities: [ 'kdeconnect.sms.messages' ], outgoingCapabilities: [ 'kdeconnect.sms.request', 'kdeconnect.sms.request_conversation', 'kdeconnect.sms.request_conversations' ], actions: { // SMS Actions sms: { label: _('Messaging'), icon_name: 'sms-symbolic', parameter_type: null, incoming: [], outgoing: ['kdeconnect.sms.request'] }, uriSms: { label: _('New SMS (URI)'), icon_name: 'sms-symbolic', parameter_type: new GLib.VariantType('s'), incoming: [], outgoing: ['kdeconnect.sms.request'] }, replySms: { label: _('Reply SMS'), icon_name: 'sms-symbolic', parameter_type: new GLib.VariantType('s'), incoming: [], outgoing: ['kdeconnect.sms.request'] }, sendMessage: { label: _('Send Message'), icon_name: 'sms-send', parameter_type: new GLib.VariantType('(aa{sv})'), incoming: [], outgoing: ['kdeconnect.sms.request'] }, sendSms: { label: _('Send SMS'), icon_name: 'sms-send', parameter_type: new GLib.VariantType('(ss)'), incoming: [], outgoing: ['kdeconnect.sms.request'] }, shareSms: { label: _('Share SMS'), icon_name: 'sms-send', parameter_type: new GLib.VariantType('s'), incoming: [], outgoing: ['kdeconnect.sms.request'] } } }; /** * SMS Message event type. Currently all events are TEXT_MESSAGE. * * TEXT_MESSAGE: Has a "body" field which contains pure, human-readable text */ var MessageEvent = { TEXT_MESSAGE: 0x1 }; /** * SMS Message status. READ/UNREAD match the 'read' field from the Android App * message packet. * * UNREAD: A message not marked as read * READ: A message marked as read */ var MessageStatus = { UNREAD: 0, READ: 1 }; /** * SMS Message direction. IN/OUT match the 'type' field from the Android App * message packet. * * See: https://developer.android.com/reference/android/provider/Telephony.TextBasedSmsColumns.html * * IN: An incoming message * OUT: An outgoing message */ var MessageBox = { ALL: 0, INBOX: 1, SENT: 2, DRAFT: 3, OUTBOX: 4, FAILED: 5 }; /** * SMS Plugin * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sms * https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SMSPlugin/ */ var Plugin = GObject.registerClass({ GTypeName: 'GSConnectSMSPlugin', Properties: { 'threads': GObject.param_spec_variant( 'threads', 'Conversation List', 'A list of threads', new GLib.VariantType('aa{sv}'), null, GObject.ParamFlags.READABLE ) } }, class Plugin extends PluginsBase.Plugin { _init(device) { super._init(device, 'sms'); this.threads = {}; this.cacheProperties(['threads']); this._version = 1; } get window() { if (this.settings.get_boolean('legacy-sms')) { return new TelephonyUI.LegacyMessagingDialog({ device: this.device, plugin: this }); } if (this._window === undefined) { this._window = new Messaging.Window({ application: this.service, device: this.device, plugin: this }); } return this._window; } handlePacket(packet) { // Currently only one incoming packet type if (packet.type === 'kdeconnect.sms.messages') { this._handleMessages(packet.body.messages); } } clearCache() { this.threads = {}; this.__cache_write(); this.notify('threads'); } cacheLoaded() { this.notify('threads'); } connected() { super.connected(); this.requestConversations(); } /** * Handle a digest of threads. * * @param {Object[]} messages - A list of message objects * @param {string[]} thread_ids - A list of thread IDs as strings */ _handleDigest(messages, thread_ids) { // Prune threads for (let thread_id of Object.keys(this.threads)) { if (!thread_ids.includes(thread_id)) { delete this.threads[thread_id]; } } // Request each new or newer thread for (let i = 0, len = messages.length; i < len; i++) { let message = messages[i]; let cache = this.threads[message.thread_id]; // If this message is marked read and it's for an existing // thread, we should mark the rest in this thread as read if (cache && message.read === MessageStatus.READ) { cache.forEach(msg => msg.read = MessageStatus.READ); } // If we don't have a thread for this message or it's newer // than the last message in the cache, request the thread if (!cache || cache[cache.length - 1].date < message.date) { this.requestConversation(message.thread_id); } } this.__cache_write(); this.notify('threads'); } /** * Handle a new single message * * @param {Object} message - A message object */ _handleMessage(message) { let conversation = null; // If the window is open, try and find an active conversation if (this._window) { conversation = this._window.getConversationForMessage(message); } // If there's an active conversation, we should log the message now if (conversation) { conversation.logNext(message); } } /** * Parse a conversation (thread of messages) and sort them * * @param {Object[]} thread - A list of sms message objects from a thread */ _handleThread(thread) { try { // If there are no addresses this will cause major problems... if (!thread[0].addresses || !thread[0].addresses[0]) return; let thread_id = thread[0].thread_id; let cache = this.threads[thread_id] || []; // Handle each message for (let i = 0, len = thread.length; i < len; i++) { let message = thread[i]; // TODO: invalid MessageBox if (message.type < 0 || message.type > 5) continue; // If the message exists, just update it let cacheMessage = cache.find(m => m.date === message.date); if (cacheMessage) { Object.assign(cacheMessage, message); } else { cache.push(message); this._handleMessage(message); } } // Sort the thread by ascending date and write to cache this.threads[thread_id] = cache.sort((a, b) => { return (a.date < b.date) ? -1 : 1; }); this.__cache_write(); this.notify('threads'); } catch (e) { logError(e); } } /** * Handle a response to telephony.request_conversation(s) * * @param {object[]} messages - A list of sms message objects */ _handleMessages(messages) { try { // If messages is empty there's nothing to do... if (messages.length === 0) return; let thread_ids = []; // Perform some modification of the messages for (let i = 0, len = messages.length; i < len; i++) { let message = messages[i]; // COERCION: thread_id's to strings message.thread_id = `${message.thread_id}`; thread_ids.push (message.thread_id); // TODO: Remove bogus `insert-address-token` entries let a = message.addresses.length; while (a--) { if (message.addresses[a].address === undefined || message.addresses[a].address === 'insert-address-token') message.addresses.splice(a, 1); } } // If there's multiple thread_id's it's a summary of threads if (thread_ids.some(id => id !== thread_ids[0])) { this._handleDigest(messages, thread_ids); // Otherwise this is single thread or new message } else { this._handleThread(messages); } } catch (e) { logError(e); } } /** * Request a list of messages from a single thread. * * @param {Number} thread_id - The id of the thread to request */ requestConversation(thread_id) { this.device.sendPacket({ type: 'kdeconnect.sms.request_conversation', body: { threadID: thread_id } }); } /** * Request a list of the last message in each unarchived thread. */ requestConversations() { this.device.sendPacket({ type: 'kdeconnect.sms.request_conversations' }); } /** * A notification action for replying to SMS messages (or missed calls). * * @param {string} hint - Could be either a contact name or phone number */ replySms(hint) { this.window.present(); // FIXME: causes problems now that non-numeric addresses are allowed //this.window.address = hint.toPhoneNumber(); } /** * Send an SMS message * * @param {string} phoneNumber - The phone number to send the message to * @param {string} messageBody - The message to send */ sendSms(phoneNumber, messageBody) { this.sendMessage([{address: phoneNumber}], messageBody, 1, true); } /** * Send a message * * @param {Array of Address} addresses - A list of address objects * @param {string} messageBody - The message text * @param {number} [event] - An event bitmask * @param {boolean} [forceSms] - Whether to force SMS * @param {number} [subId] - The SIM card to use */ sendMessage(addresses, messageBody, event = 1, forceSms = false, subId = undefined) { // TODO: waiting on support in kdeconnect-android // if (this._version === 1) { this.device.sendPacket({ type: 'kdeconnect.sms.request', body: { sendSms: true, phoneNumber: addresses[0].address, messageBody: messageBody } }); // } else if (this._version == 2) { // this.device.sendPacket({ // type: 'kdeconnect.sms.request', // body: { // version: 2, // addresses: addresses, // messageBody: messageBody, // forceSms: forceSms, // sub_id: subId // } // }); // } } /** * Share a text content by SMS message. This is used by the WebExtension to * share URLs from the browser, but could be used to initiate sharing of any * text content. * * @param {string} url - The link to be shared */ shareSms(url) { // Legacy Mode if (this.settings.get_boolean('legacy-sms')) { let window = this.window; window.present(); window.setMessage(url); // If there are active threads, show the chooser dialog } else if (Object.values(this.threads).length > 0) { let window = new Messaging.ConversationChooser({ application: this.service, device: this.device, message: url, plugin: this }); window.present(); // Otherwise show the window and wait for a contact to be chosen } else { this.window.present(); this.window.setMessage(url, true); } } /** * Open and present the messaging window */ sms() { this.window.present(); } /** * This is the sms: URI scheme handler * * @param {string} uri - The URI the handle (sms:|sms://|sms:///) */ uriSms(uri) { try { uri = new URI.SmsURI(uri); // Lookup contacts let addresses = uri.recipients.map(number => { return {address: number.toPhoneNumber()}; }); let contacts = this.device.contacts.lookupAddresses(addresses); // Present the window and show the conversation let window = this.window; window.present(); window.setContacts(contacts); // Set the outgoing message if the uri has a body variable if (uri.body) { window.setMessage(uri.body); } } catch (e) { logError(e, `${this.device.name}: "${uri}"`); } } addressesIncludesAddress(addresses, addressObj) { let number = addressObj.address.toPhoneNumber(); for (let taddressObj of addresses) { let tnumber = taddressObj.address.toPhoneNumber(); if (number.endsWith(tnumber) || tnumber.endsWith(number)) { return true; } } return false; } _threadHasAddress(thread, addressObj) { let number = addressObj.address.toPhoneNumber(); for (let taddressObj of thread[0].addresses) { let tnumber = taddressObj.address.toPhoneNumber(); if (number.endsWith(tnumber) || tnumber.endsWith(number)) { return true; } } return false; } /** * Try to find a thread_id in @smsPlugin for @addresses. * * @param {Array of Object} - a list of address objects */ getThreadIdForAddresses(addresses) { let threads = Object.values(this.threads); for (let thread of threads) { if (addresses.length !== thread[0].addresses.length) continue; if (addresses.every(addressObj => this._threadHasAddress(thread, addressObj))) { return thread[0].thread_id; } } return null; } destroy() { if (this._window) { this._window.destroy(); } super.destroy(); } });