Restore Gaia settings and media workflows, extend native network adapters, and align the APK payload. Generate French/English changelogs and website entries from the simplified JSON source. Validation: shared JS and Tundra unit tests; desktop workflows and persistence; signed APK payload parity; Sargo RAM and installed startup with boot readback. Android runtime qualification is recorded separately.
218 lines
10 KiB
JavaScript
218 lines
10 KiB
JavaScript
// Desktop message store. Radio operations fail explicitly; no synthetic SMS.
|
|
export class Messages {
|
|
constructor(storage, radio = null, notify = () => {}, radioStatus = null, mms = null) {
|
|
this.storage = storage;
|
|
this.radio = radio;
|
|
this.notify = notify;
|
|
this.radioStatus = radioStatus;
|
|
this.mms = mms;
|
|
this.queue = Promise.resolve();
|
|
}
|
|
receive(incoming) {
|
|
const work = this.queue.then(async()=>{
|
|
const records=(await this.storage.run('vulpes-private-messages','get',['records'])).value || [];
|
|
const existing=records.find(m=>m.receipt===incoming.receipt);
|
|
if (existing) return existing;
|
|
const id=Math.max(Date.now(),...records.map(m=>Number(m.id)+1 || 0));
|
|
const record={id,threadId:records.find(m=>(m.sender||m.receiver)===incoming.sender)?.threadId || id+1,
|
|
receipt:incoming.receipt,type:incoming.type || 'sms',sender:incoming.sender,receiver:null,
|
|
...(incoming.type==='mms' ? {receivers:incoming.receivers || [],subject:incoming.subject || '',smil:incoming.smil || '',attachments:incoming.attachments || [],deliveryInfo:[]} : {}),
|
|
body:incoming.body,timestamp:incoming.timestamp,delivery:'received',read:false};
|
|
records.push(record);
|
|
await this.storage.run('vulpes-private-messages','put',[records,'records']);
|
|
return record;
|
|
});
|
|
this.queue=work.catch(()=>{});
|
|
return work;
|
|
}
|
|
refreshOutgoing() {
|
|
if (!this.radioStatus && !this.mms) return Promise.resolve();
|
|
const work=this.queue.then(async()=>{
|
|
const records=(await this.storage.run('vulpes-private-messages','get',['records'])).value || [];
|
|
for (const message of records.filter(m=>m.delivery==='sending' && m.radioPath)) {
|
|
const result=await (message.type==='mms' ? this.mms.status(message.radioPath) : this.radioStatus(message.radioPath));
|
|
if (!['sent','failed'].includes(result.state)) continue;
|
|
message.delivery=result.state==='sent'?'sent':'error';
|
|
message.deliveryStatus=result.state==='sent'?'not-applicable':'error';
|
|
if(result.error) message.error=result.error;
|
|
if(message.type==='mms') message.deliveryInfo=message.receivers.map(receiver=>({receiver,deliveryStatus:message.deliveryStatus,readStatus:'not-applicable'}));
|
|
await this.storage.run('vulpes-private-messages','put',[records,'records']);
|
|
this.notify(result.state,structuredClone(message));
|
|
}
|
|
});
|
|
this.queue=work.catch(()=>{});
|
|
return work;
|
|
}
|
|
request(app, params) {
|
|
const work=this.queue.then(()=>this.perform(app,params));
|
|
this.queue=work.catch(()=>{});
|
|
return work;
|
|
}
|
|
async perform(app, { operation, args = [] }) {
|
|
if (!app.manifest.permissions?.sms) throw new Error('PERMISSION_DENIED');
|
|
const records =
|
|
(await this.storage.run('vulpes-private-messages', 'get', ['records'])).value || [];
|
|
// Gaia's Threads registry coerces identifiers to numbers. Older adapter
|
|
// records used modem paths/phone numbers and became NaN in that registry.
|
|
let nextId=records.reduce((n,m)=>Math.max(n,Number.isSafeInteger(m.id)?m.id+1:0),Date.now());
|
|
const migratedThreads=new Map();
|
|
let migrated=false;
|
|
for(const message of records) {
|
|
if(!Number.isSafeInteger(message.id)) {message.id=nextId++;migrated=true;}
|
|
if(!Number.isSafeInteger(message.threadId)) {
|
|
const old=message.threadId;
|
|
if(!migratedThreads.has(old)) migratedThreads.set(old,nextId++);
|
|
message.threadId=migratedThreads.get(old);migrated=true;
|
|
}
|
|
}
|
|
if(migrated) await this.storage.run('vulpes-private-messages','put',[records,'records']);
|
|
if (operation === 'send' && this.radio) {
|
|
const recipients = Array.isArray(args[0]) ? args[0] : [args[0]];
|
|
if (recipients.length !== 1) throw new Error('MULTIPLE_RECIPIENTS_UNSUPPORTED');
|
|
const recipient = recipients[0];
|
|
const body = args[1];
|
|
const timestamp = Date.now();
|
|
const record = {
|
|
id: nextId++,
|
|
threadId: records.find(m=>(m.receiver || m.sender)===recipient)?.threadId || nextId++,
|
|
type: 'sms',
|
|
sender: null,
|
|
receiver: recipient,
|
|
body,
|
|
timestamp,
|
|
delivery: 'sending',
|
|
deliveryStatus: 'pending',
|
|
read: true,
|
|
};
|
|
records.push(record);
|
|
await this.storage.run('vulpes-private-messages', 'put', [records, 'records']);
|
|
this.notify('sending', structuredClone(record));
|
|
try {
|
|
const submitted = await this.radio({ number: recipient, body });
|
|
if (submitted?.state === 'pending') {
|
|
record.radioPath = submitted.path;
|
|
await this.storage.run('vulpes-private-messages', 'put', [records, 'records']);
|
|
return record;
|
|
}
|
|
record.delivery = 'sent';
|
|
record.deliveryStatus = 'not-applicable';
|
|
} catch (error) {
|
|
record.delivery = 'error';
|
|
record.deliveryStatus = 'error';
|
|
record.error = error.message;
|
|
await this.storage.run('vulpes-private-messages', 'put', [records, 'records']);
|
|
this.notify('failed', structuredClone(record));
|
|
throw error;
|
|
}
|
|
await this.storage.run('vulpes-private-messages', 'put', [records, 'records']);
|
|
this.notify('sent', structuredClone(record));
|
|
return record;
|
|
}
|
|
if (operation === 'sendMMS' && this.mms) {
|
|
const payload=args[0];
|
|
this.mms.validate(payload);
|
|
const receivers=payload.receivers.slice();
|
|
const participants=receivers.slice().sort().join(',');
|
|
const existing=records.find(m=>(m.type==='mms' && m.delivery!=='received' ? m.receivers.slice().sort().join(',') : m.receiver || m.sender)===participants);
|
|
const record={id:nextId++,threadId:existing?.threadId || nextId++,type:'mms',sender:null,
|
|
receivers,subject:payload.subject || '',smil:payload.smil || '',attachments:payload.attachments || [],body:payload.subject || '',
|
|
timestamp:Date.now(),delivery:'sending',deliveryStatus:'pending',read:true,
|
|
deliveryInfo:receivers.map(receiver=>({receiver,deliveryStatus:'pending',readStatus:'not-applicable'}))};
|
|
records.push(record);
|
|
await this.storage.run('vulpes-private-messages','put',[records,'records']);
|
|
this.notify('sending',structuredClone(record));
|
|
try {
|
|
const submitted=await this.mms.send(payload);
|
|
record.radioPath=submitted.path;
|
|
await this.storage.run('vulpes-private-messages','put',[records,'records']);
|
|
return record;
|
|
} catch(error) {
|
|
record.delivery='error';record.deliveryStatus='error';record.error=error.message;
|
|
record.deliveryInfo=receivers.map(receiver=>({receiver,deliveryStatus:'error',readStatus:'not-applicable'}));
|
|
await this.storage.run('vulpes-private-messages','put',[records,'records']);
|
|
this.notify('failed',structuredClone(record));throw error;
|
|
}
|
|
}
|
|
if (['sendMMS', 'retrieveMMS'].includes(operation))
|
|
throw new Error('NotSupportedError');
|
|
if (['send', 'getSmscAddress', 'setSmscAddress'].includes(operation))
|
|
throw new Error('RadioDisabledError');
|
|
if (operation === 'getThreads') {
|
|
const threads = new Map();
|
|
for (const message of records) {
|
|
const previous = threads.get(message.threadId);
|
|
if (!previous || message.timestamp > previous.timestamp)
|
|
threads.set(message.threadId, {
|
|
id: message.threadId,
|
|
body: message.body,
|
|
timestamp: message.timestamp,
|
|
participants: message.delivery==='received' ? [message.sender] : message.type==='mms' ? message.receivers : [message.receiver],
|
|
unreadCount: records.filter((m) => m.threadId === message.threadId && !m.read).length,
|
|
lastMessageType: message.type,
|
|
});
|
|
}
|
|
return [...threads.values()].sort((a, b) => b.timestamp - a.timestamp);
|
|
}
|
|
if (operation === 'getMessages')
|
|
return records
|
|
.filter((m) => {
|
|
const filter = args[0] || {};
|
|
return (filter.threadId === undefined || m.threadId === filter.threadId) &&
|
|
(filter.read === undefined || !!m.read === filter.read) &&
|
|
(filter.delivery === undefined || m.delivery === filter.delivery) &&
|
|
(filter.startDate == null || m.timestamp >= new Date(filter.startDate).getTime()) &&
|
|
(filter.endDate == null || m.timestamp <= new Date(filter.endDate).getTime()) &&
|
|
(!filter.numbers?.length || filter.numbers.some(number =>
|
|
number === m.sender || number === m.receiver || m.receivers?.includes(number)));
|
|
})
|
|
.sort((a, b) => (a.timestamp - b.timestamp) * (args[1] ? -1 : 1));
|
|
if (operation === 'getMessage') {
|
|
const message = records.find((m) => m.id === args[0]);
|
|
if (!message) throw new Error('NotFoundError');
|
|
return message;
|
|
}
|
|
if (operation === 'getSegmentInfoForText') {
|
|
const text = String(args[0]);
|
|
const basic =
|
|
'@£$¥èéùìòÇ\nØø\rÅåΔ_ΦΓΛΩΠΨΣΘΞÆæßÉ !"#¤%&\'()*+,-./0123456789:;<=>?¡ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÑܧ¿abcdefghijklmnopqrstuvwxyzäöñüà';
|
|
const extra = '\f^{}\\[~]|€';
|
|
let length = 0,
|
|
unicode = false;
|
|
for (const c of text) {
|
|
if (basic.includes(c)) length++;
|
|
else if (extra.includes(c)) length += 2;
|
|
else {
|
|
unicode = true;
|
|
break;
|
|
}
|
|
}
|
|
if (unicode) length = text.length;
|
|
const single = unicode ? 70 : 160,
|
|
multiple = unicode ? 67 : 153;
|
|
const charsPerSegment = length > single ? multiple : single,
|
|
segments = Math.max(1, Math.ceil(length / charsPerSegment));
|
|
return {
|
|
segments,
|
|
charsPerSegment,
|
|
charsAvailableInLastSegment: segments * charsPerSegment - length,
|
|
};
|
|
}
|
|
if (operation === 'delete') {
|
|
const ids = Array.isArray(args[0]) ? args[0] : [args[0]],
|
|
found = ids.map((id) => records.some((m) => m.id === id));
|
|
await this.storage.run('vulpes-private-messages', 'put', [
|
|
records.filter((m) => !ids.includes(m.id)),
|
|
'records',
|
|
]);
|
|
return Array.isArray(args[0]) ? found : found[0];
|
|
}
|
|
if (operation === 'markMessageRead') {
|
|
const m = records.find((m) => m.id === args[0]);
|
|
if (!m) throw new Error('NotFoundError');
|
|
m.read = !!args[1];
|
|
await this.storage.run('vulpes-private-messages', 'put', [records, 'records']);
|
|
return m.read;
|
|
}
|
|
throw new Error('METHOD_NOT_SUPPORTED');
|
|
}
|
|
}
|