VulpesOS/tests/android-personal-data.test.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

59 lines
4.8 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {Contacts} from '../host/Contacts.sys.mjs';
import {Messages} from '../host/Messages.sys.mjs';
const source=readFileSync('android/extension/personal-data.mjs','utf8')
.replace("'./Contacts.mjs'",JSON.stringify(new URL('../host/Contacts.sys.mjs',import.meta.url).href))
.replace("'./Messages.mjs'",JSON.stringify(new URL('../host/Messages.sys.mjs',import.meta.url).href));
const {AndroidPersonalData,authorizePersonal,smsFromAndroid}=await import('data:text/javascript;base64,'+Buffer.from(source).toString('base64'));
const app={id:'communications',manifest:{permissions:{contacts:{access:'readwrite'}}}};
const smsApp={id:'sms',manifest:{permissions:{sms:{},contacts:{access:'readonly'}}}};
async function setup(){
const data=new Map([['vulpes-private-contacts',{revision:1,contacts:[{id:'local-1',name:['Local']}]}],['vulpes-private-messages',[{id:100,threadId:200,timestamp:1,body:'Local',delivery:'sent',receiver:'5550100',type:'sms',read:true}]]]);
const storage={run:async(name,op,args)=>{if(op==='get')return {value:structuredClone(data.get(name))};data.set(name,structuredClone(args[0]));}};
const contacts=new Contacts(storage,()=>{});await contacts.init();
const calls=[];let allowed=true;
const service=new AndroidPersonalData(async p=>{calls.push(p);if(p.operation==='status')return {contacts:allowed,sms:allowed};return {rows:p.kind==='contacts'?[{_id:1,display_name:'Android',tel:[{value:'5550199'}]}]:[{_id:1,thread_id:2,address:'5550199',body:'Android SMS',date:2,type:1,read:0,status:-1}],next:null};},contacts,new Messages(storage),storage);
return {service,data,calls,revoke(){allowed=false;}};
}
test('native contact reads preserve the local address book and search phone numbers',async()=>{
const s=await setup();const rows=await s.service.contactRequest(app,{operation:'all'});
assert.deepEqual(rows.map(r=>r.id),['local-1','android:1']);
assert.equal((await s.service.contactRequest(app,{operation:'find',options:{filterBy:['tel'],filterOp:'match',filterValue:'0199'}}))[0].id,'android:1');
assert.equal(s.data.get('vulpes-private-contacts').contacts.length,1);
s.revoke();assert.equal((await s.service.contactRequest(app,{operation:'all'})).length,1);
});
test('native messages and threads remain distinct from local IDs and are not copied to storage',async()=>{
const s=await setup();const rows=await s.service.messageRequest(smsApp,{operation:'getThreads'});
assert.deepEqual(rows.map(r=>r.id),[-2,200]);
const [sms]=await s.service.messageRequest(smsApp,{operation:'getMessages',args:[{threadId:-2}]});assert.equal(sms.id,-1);assert.equal(sms.body,'Android SMS');
assert.equal(s.data.get('vulpes-private-messages').length,1);
s.revoke();assert.equal((await s.service.messageRequest(smsApp,{operation:'getThreads'})).length,1);
});
test('Android writes never fall through to the local store or report false success',async()=>{
const s=await setup();
for(const params of [{operation:'save',contact:{id:'android:1'}},{operation:'remove',id:'android:1'},{operation:'clear'}]) await assert.rejects(s.service.contactRequest(app,params),/READ_ONLY/);
for(const params of [{operation:'delete',args:[[100,-1]]},{operation:'markMessageRead',args:[-1,true]}])await assert.rejects(s.service.messageRequest(smsApp,params),/READ_ONLY/);
assert.equal(s.data.get('vulpes-private-messages').length,1);
});
test('untrusted installed apps cannot reuse the Android permission granted to Gaia',async()=>{
const s=await setup();
assert.equal((await s.service.contactRequest({...app,installed:true},{operation:'all'})).length,1);
assert.equal((await s.service.messageRequest({...smsApp,installed:true},{operation:'getMessages'})).length,1);
assert.equal(s.calls.length,0);
assert.throws(()=>authorizePersonal({...app,installed:true},'contacts','page'),/PERMISSION/);
assert.throws(()=>authorizePersonal(smsApp,'calls','page'),/PERMISSION/);
assert.throws(()=>authorizePersonal(app,'contacts','permission'),/PERMISSION/);
assert.doesNotThrow(()=>authorizePersonal({id:'settings'},'sms','permission'));
});
test('provider errors and stalled page cursors are surfaced, not presented as an empty book',async()=>{
const s=await setup();let n=0;
s.service.native=async p=>p.operation==='status'?{contacts:true}:{rows:[],next:++n===1?1:1};
await assert.rejects(s.service.rows('contacts'),/INVALID_CURSOR/);
s.service.native=async()=>{throw Error('denied');};await assert.rejects(s.service.rows('contacts'),/denied/);
});
test('SMS mapping does not turn pending or failed sends into delivered messages',()=>{
for(const [type,delivery] of [[1,'received'],[2,'sent'],[4,'sending'],[5,'error'],[6,'sending']])assert.equal(smsFromAndroid({_id:1,thread_id:1,type}).delivery,delivery);
assert.throws(()=>smsFromAndroid({_id:Infinity,thread_id:1}),/INVALID_ANDROID_ID/);
});