2024-07-08 22:46:35 +02:00

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;