531 lines
15 KiB
JavaScript
531 lines
15 KiB
JavaScript
|
'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();
|
||
|
}
|
||
|
});
|
||
|
|