537 lines
15 KiB
JavaScript
Executable File
537 lines
15 KiB
JavaScript
Executable File
// SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
|
|
//
|
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
|
|
|
import Gio from 'gi://Gio';
|
|
import GLib from 'gi://GLib';
|
|
import GObject from 'gi://GObject';
|
|
|
|
import Plugin from '../plugin.js';
|
|
import LegacyMessagingDialog from '../ui/legacyMessaging.js';
|
|
import * as Messaging from '../ui/messaging.js';
|
|
import SmsURI from '../utils/uri.js';
|
|
|
|
|
|
export const Metadata = {
|
|
label: _('SMS'),
|
|
description: _('Send and read SMS of the paired device and be notified of new 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
|
|
*/
|
|
export const MessageEventType = {
|
|
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
|
|
*/
|
|
export const MessageStatus = {
|
|
UNREAD: 0,
|
|
READ: 1,
|
|
};
|
|
|
|
|
|
/**
|
|
* SMS Message type, set from the 'type' field in the Android App
|
|
* message packet.
|
|
*
|
|
* See: https://developer.android.com/reference/android/provider/Telephony.TextBasedSmsColumns.html
|
|
*
|
|
* ALL: all messages
|
|
* INBOX: Received messages
|
|
* SENT: Sent messages
|
|
* DRAFT: Message drafts
|
|
* OUTBOX: Outgoing messages
|
|
* FAILED: Failed outgoing messages
|
|
* QUEUED: Messages queued to send later
|
|
*/
|
|
export const MessageBox = {
|
|
ALL: 0,
|
|
INBOX: 1,
|
|
SENT: 2,
|
|
DRAFT: 3,
|
|
OUTBOX: 4,
|
|
FAILED: 5,
|
|
QUEUED: 6,
|
|
};
|
|
|
|
|
|
/**
|
|
* 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/
|
|
*/
|
|
const SMSPlugin = 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 SMSPlugin extends Plugin {
|
|
|
|
_init(device) {
|
|
super._init(device, 'sms');
|
|
|
|
this.cacheProperties(['_threads']);
|
|
}
|
|
|
|
get threads() {
|
|
if (this._threads === undefined)
|
|
this._threads = {};
|
|
|
|
return this._threads;
|
|
}
|
|
|
|
get window() {
|
|
if (this.settings.get_boolean('legacy-sms')) {
|
|
return new LegacyMessagingDialog({
|
|
device: this.device,
|
|
plugin: this,
|
|
});
|
|
}
|
|
|
|
if (this._window === undefined) {
|
|
this._window = new Messaging.Window({
|
|
application: Gio.Application.get_default(),
|
|
device: this.device,
|
|
plugin: this,
|
|
});
|
|
|
|
this._window.connect('destroy', () => {
|
|
this._window = undefined;
|
|
});
|
|
}
|
|
|
|
return this._window;
|
|
}
|
|
|
|
clearCache() {
|
|
this._threads = {};
|
|
this.notify('threads');
|
|
}
|
|
|
|
cacheLoaded() {
|
|
this.notify('threads');
|
|
}
|
|
|
|
connected() {
|
|
super.connected();
|
|
this._requestConversations();
|
|
}
|
|
|
|
handlePacket(packet) {
|
|
switch (packet.type) {
|
|
case 'kdeconnect.sms.messages':
|
|
this._handleMessages(packet.body.messages);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 (const 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++) {
|
|
const message = messages[i];
|
|
const cache = this.threads[message.thread_id];
|
|
|
|
if (cache === undefined) {
|
|
this._requestConversation(message.thread_id);
|
|
continue;
|
|
}
|
|
|
|
// If this message is marked read, mark the rest as read
|
|
if (message.read === MessageStatus.READ) {
|
|
for (const msg of cache)
|
|
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.length || cache[cache.length - 1].date < message.date)
|
|
this._requestConversation(message.thread_id);
|
|
}
|
|
|
|
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) {
|
|
// If there are no addresses this will cause major problems...
|
|
if (!thread[0].addresses || !thread[0].addresses[0])
|
|
return;
|
|
|
|
const thread_id = thread[0].thread_id;
|
|
const cache = this.threads[thread_id] || [];
|
|
|
|
// Handle each message
|
|
for (let i = 0, len = thread.length; i < len; i++) {
|
|
const message = thread[i];
|
|
|
|
// TODO: We only cache messages of a known MessageBox since we
|
|
// have no reliable way to determine its direction, let alone
|
|
// what to do with it.
|
|
if (message.type < 0 || message.type > 6)
|
|
continue;
|
|
|
|
// If the message exists, just update it
|
|
const 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 notify
|
|
this.threads[thread_id] = cache.sort((a, b) => a.date - b.date);
|
|
this.notify('threads');
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
const thread_ids = [];
|
|
|
|
// Perform some modification of the messages
|
|
for (let i = 0, len = messages.length; i < len; i++) {
|
|
const 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) {
|
|
debug(e, this.device.name);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.device.sendPacket({
|
|
type: 'kdeconnect.sms.request',
|
|
body: {
|
|
sendSms: true,
|
|
phoneNumber: phoneNumber,
|
|
messageBody: messageBody,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Send a message
|
|
*
|
|
* @param {Object[]} 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')) {
|
|
const 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) {
|
|
const window = new Messaging.ConversationChooser({
|
|
application: Gio.Application.get_default(),
|
|
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 SmsURI(uri);
|
|
|
|
// Lookup contacts
|
|
const addresses = uri.recipients.map(number => {
|
|
return {address: number.toPhoneNumber()};
|
|
});
|
|
const contacts = this.device.contacts.lookupAddresses(addresses);
|
|
|
|
// Present the window and show the conversation
|
|
const 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) {
|
|
debug(e, `${this.device.name}: "${uri}"`);
|
|
}
|
|
}
|
|
|
|
_threadHasAddress(thread, addressObj) {
|
|
const number = addressObj.address.toPhoneNumber();
|
|
|
|
for (const taddressObj of thread[0].addresses) {
|
|
const 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 {Object[]} addresses - a list of address objects
|
|
* @return {string|null} a thread ID
|
|
*/
|
|
getThreadIdForAddresses(addresses = []) {
|
|
const threads = Object.values(this.threads);
|
|
|
|
for (const 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 !== undefined)
|
|
this._window.destroy();
|
|
|
|
super.destroy();
|
|
}
|
|
});
|
|
|
|
export default SMSPlugin;
|