VulpesOS/android/extension/personal-data.mjs
OverSu 200d1fb235 Release Android Preview 21 and bilingual changelog
Add permission-based reading of Android contacts, SMS and call history. Include shared Gaia fixes and document device validation limits. Pixel image qualification remains pending.
2026-10-14 13:13:46 +02:00

88 lines
4.6 KiB
JavaScript

import {Contacts} from './Contacts.mjs';
import {Messages} from './Messages.mjs';
export function authorizePersonal(app, kind, operation) {
if (app.installed || !['settings', 'communications', 'sms', 'system'].includes(app.id))
throw Error('PERMISSION_DENIED');
if (operation === 'status' && app.id === 'settings') return;
if (!['contacts', 'sms', 'calls'].includes(kind)) throw Error('INVALID_DATA_KIND');
if (['permission', 'disconnect'].includes(operation)) {
if (app.id !== 'settings') throw Error('PERMISSION_DENIED');
return;
}
if (kind === 'contacts' && !app.manifest.permissions?.contacts?.access?.includes('read')) throw Error('PERMISSION_DENIED');
if (kind === 'sms' && app.id !== 'sms') throw Error('PERMISSION_DENIED');
if (kind === 'calls' && app.id !== 'communications') throw Error('PERMISSION_DENIED');
}
export function contactFromAndroid(row) {
return {id:'android:'+row._id, name:[row.display_name || ''], givenName:row.givenName || [],
familyName:row.familyName || [], tel:row.tel || [], email:row.email || [],
category:row.starred ? ['favorite'] : [], updated:new Date(row.contact_last_updated_timestamp || 0).toISOString()};
}
export function smsFromAndroid(row) {
// Negative IDs keep Android provider rows separate from existing Vulpes messages.
if (![row._id, row.thread_id].every(n => Number.isSafeInteger(n) && n > 0)) throw Error('INVALID_ANDROID_ID');
const incoming = row.type === 1;
return {id:-row._id, threadId:-row.thread_id, type:'sms',
sender:incoming ? row.address : null, receiver:incoming ? null : row.address,
body:row.body || '', timestamp:row.date, read:!!row.read,
delivery:incoming ? 'received' : row.type === 2 ? 'sent' : row.type === 5 ? 'error' : 'sending',
deliveryStatus:row.status === 0 ? 'success' : row.status >= 64 ? 'error' : 'not-applicable'};
}
export class AndroidPersonalData {
constructor(native, contacts, messages, storage) {
Object.assign(this, {native, contacts, messages, storage});
}
async rows(kind) {
const status = await this.native({operation:'status'});
if (!status[kind]) return [];
const rows = [];
let after = 0;
do {
const page = await this.native({operation:'page', kind, after});
rows.push(...page.rows);
if (rows.length > 100000) throw Error('ANDROID_DATA_TOO_LARGE');
if (page.next === null) break;
if (!Number.isSafeInteger(page.next) || page.next <= after) throw Error('INVALID_CURSOR');
after = page.next;
} while (true);
return rows;
}
async contactRequest(app, params) {
const write = ['save', 'remove', 'clear'].includes(params.operation);
const access = app.manifest.permissions?.contacts?.access || '';
if (!access.includes(write ? 'write' : 'read')) throw Error('PERMISSION_DENIED');
// Third-party apps keep the local address book and never inherit Android's grant.
if (app.installed || !['communications','sms','system'].includes(app.id)) return this.contacts.request(app, params);
if (write) {
if (String(params.contact?.id || params.id || '').startsWith('android:') ||
params.operation === 'clear' && (await this.native({operation:'status'})).contacts)
throw Error('ANDROID_DATA_READ_ONLY');
return this.contacts.request(app, params);
}
const remote = (await this.rows('contacts')).map(contactFromAndroid);
const local = await this.contacts.request(app, {operation:'all'});
const view = new Contacts(null, ()=>{});
let revision = this.contacts.data.revision;
for (const c of remote) for (const character of JSON.stringify(c)) revision = (revision * 31 + character.charCodeAt(0)) >>> 0;
view.data = {revision, contacts:[...local, ...remote]};
return view.request(app, params);
}
async messageRequest(app, params) {
if (!app.manifest.permissions?.sms) throw Error('PERMISSION_DENIED');
if (app.installed || app.id !== 'sms') return this.messages.request(app, params);
const {operation, args=[]} = params;
if (['delete','markMessageRead'].includes(operation) && [args[0]].flat().some(id => id < 0))
throw Error('ANDROID_DATA_READ_ONLY');
if (!['getMessages','getThreads','getMessage'].includes(operation)) return this.messages.request(app, params);
const remote = (await this.rows('sms')).filter(row => row.type !== 3).map(smsFromAndroid);
const local = await this.messages.request(app, {operation:'getMessages'});
const view = new Messages({run:async(_store, op)=>{
if (op !== 'get') throw Error('ANDROID_DATA_READ_ONLY');
return {value:[...local, ...remote]};
}});
return view.request(app, params);
}
}