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.
88 lines
4.6 KiB
JavaScript
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);
|
|
}
|
|
}
|