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.
This commit is contained in:
OverSu 2026-10-14 13:13:46 +02:00
commit 200d1fb235
43 changed files with 2271 additions and 65 deletions

View file

@ -1,3 +1,4 @@
import {AndroidPersonalData, authorizePersonal} from './personal-data.mjs';
import {Input} from './services/input.mjs';
import { Packages, packageURL } from './services/packages.mjs';
import { Activities, isGaiaActivity } from './services/activities.mjs';
@ -15,7 +16,7 @@ const input = new Input({identity:peer=>peer.app,broadcast});
let editSequence=0;
const nativePackages=(operation,data={})=>browser.runtime.sendNativeMessage('vulpes',{type:'packages.'+operation,...data}).then(JSON.parse);
async function refreshInstalled(){const installed=await nativePackages('list');apps.splice(0,apps.length,...apps.filter(a=>!a.installed),...installed);}
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage;
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage, personal;
const identity = (peer) => peer.app;
function notify(peer, type, data) {
try {
@ -121,6 +122,7 @@ const ready = (async () => {
contacts = new Contacts(storage, (data) => broadcast('contactchange', data));
await contacts.init();
messages = new Messages(storage);
personal = new AndroidPersonalData(params=>android("personal",params), contacts, messages, storage);
dataStores = new DataStoreService(apps, storage, (data) => broadcast('datastorechange', data));
connections = new Connections(identity, notify);
alarms = new Alarms(
@ -140,6 +142,7 @@ const ready = (async () => {
})();
// Java owns this port. Web pages have no access to native messaging.
let native;
let activeWebView;
let systemReady = false;
let pendingScreenLock = false;
function deliverScreenLock() {
@ -163,15 +166,26 @@ function connectNative() {
native.onDisconnect.addListener(() => setTimeout(connectNative, 1000));
native.onMessage.addListener(async (message) => {
await ready;
if (message.type === 'browser-closed' && activeWebView) {
const {peer, contextId} = activeWebView;
activeWebView = null;
notify(peer, 'view', {contextId, type:'close', detail:{}});
}
if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system');
if (message.type === 'android-screen-off') {
pendingScreenLock = true;
deliverScreenLock();
}
if (message.type === 'android-data-changed') {
broadcast('android-data-changed', {kind:message.kind}, message.kind === 'sms' ? 'sms' : 'communications');
}
if (message.type === 'android-resume') {
deliverScreenLock();
broadcast('screen-on', {}, 'system');
await refreshAndroid().catch(console.warn);
broadcast('android-data-changed', {kind:'contacts'}, 'communications');
broadcast('android-data-changed', {kind:'calls'}, 'communications');
broadcast('android-data-changed', {kind:'sms'}, 'sms');
}
if (message.type === 'notification-click') {
pendingNotification = message.data;
@ -215,6 +229,12 @@ async function handle(peer, request) {
authorizePlatform(app, method.slice(9), params);
method = 'android.' + method.slice(9);
}
if (method === 'android.personal') {
authorizePersonal(app, params.kind, params.operation);
if (params.operation === 'page') return personal.rows(params.kind);
if (!['status','permission','disconnect','open'].includes(params.operation)) throw Error('METHOD_NOT_SUPPORTED');
return android('personal', params);
}
if (method === 'android.capabilities') {
const release = await (await fetch('release.json')).json();
const device = app.id === 'settings' ? await android('snapshot') : {};
@ -355,8 +375,8 @@ async function handle(peer, request) {
}
if (method === 'datastores.list') return dataStores.list(app, params.name);
if (method === 'datastores.run') return dataStores.run(app, params);
if (method === 'contacts.request') return contacts.request(app, params);
if (method === 'messages.request') return messages.request(app, params);
if (method === 'contacts.request') return personal.contactRequest(app, params);
if (method === 'messages.request') return personal.messageRequest(app, params);
if (method === 'alarms.request') return alarms.request(peer, app, params);
if (method === 'iac.subscribe') {
connections.subscribe(peer);
@ -397,6 +417,7 @@ async function handle(peer, request) {
throw Error('PERMISSION_DENIED');
if (params.operation === 'open') {
await openWeb(params.url);
activeWebView = {peer, contextId:params.contextId};
notify(peer, 'view', {
contextId: params.contextId,
type: 'loadend',
@ -502,6 +523,7 @@ browser.runtime.onConnect.addListener((port) => {
attached
.then(() => {
peers.delete(peer);
if (activeWebView?.peer === peer) activeWebView = null;
input.cleanup(peer);
for(const edit of peer.edits.values()) {clearTimeout(edit.timer);edit.reject(Error('INPUT_CONTEXT_EXPIRED'));}
peer.edits.clear();

View file

@ -37,10 +37,8 @@
}
if (message.event) {
if(message.event.type==='input-mode') {
gaiaKeyboard=message.event.data.enabled===true;
if(!gaiaKeyboard) restoreMode();
win.VulpesNativeInput=gaiaKeyboard;
win.dispatchEvent(new window.Event('vulpes-input-configured'));
inputConfigurationRevision++;
configureInput(message.event.data.enabled===true);
}
win.dispatchEvent(
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
@ -53,6 +51,8 @@
callback.resolve(cloneInto(message.reply, win));
}
function onDisconnect() {
inputConfigurationRevision++;
configureInput(false);
for (const callback of pending.values())
callback.reject(new window.Error('VULPES_HOST_CLOSED'));
pending.clear();
@ -60,14 +60,15 @@
reconnect = setTimeout(() => {
try {
connect();
refreshInputConfiguration();
} catch (_) {
onDisconnect();
}
}, 1000);
}
connect();
const editor = new InputEditor(window);
let gaiaKeyboard = false;
let inputConfigurationRevision = 0;
const originalModes = new WeakMap();
function restoreMode() {
const field = editor.inputElement;
@ -77,21 +78,27 @@
else field.setAttribute('inputmode', value);
originalModes.delete(field);
}
request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
gaiaKeyboard = reply.result === true;
function configureInput(enabled) {
gaiaKeyboard = enabled;
if(!enabled) restoreMode();
win.VulpesNativeInput = gaiaKeyboard;
win.dispatchEvent(new window.Event('vulpes-input-configured'));
}).catch(error => console.error('Gaia input configuration:', error));
for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => {
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return;
let element = event.target;
if(enabled && !document.hidden && document.hasFocus()) reportFocus(document.activeElement);
}
function refreshInputConfiguration() {
const revision = inputConfigurationRevision;
request({version:1,id:0,method:'input.config',params:{}}).then(reply => {
if(revision===inputConfigurationRevision) configureInput(reply.result===true);
}).catch(error => console.error('Gaia input configuration:', error));
}
function reportFocus(element) {
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return false;
if (element?.isContentEditable) element=element.closest('[contenteditable=true]') || element;
const editable = element?.isContentEditable ||
(['input','textarea'].includes(element?.localName) &&
!['button','submit','range','checkbox','radio','date','time','datetime-local','file'].includes(element.type));
if (!editable || element.disabled || element.readOnly) {
if (type === 'click' && !element?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null});
return;
return false;
}
if (element !== editor.inputElement) restoreMode();
editor.focus(element);
@ -101,9 +108,18 @@
const state=editor.state();
state.inputMode=originalModes.get(element) || (element.classList.contains('recipient')?'tel':'');
port.postMessage({inputFocus:state});
return true;
}
connect();
refreshInputConfiguration();
for (const type of ['focusin', 'pointerdown', 'click']) addEventListener(type, event => {
if (!gaiaKeyboard || editor.editing || location.hostname === 'keyboard.localhost') return;
if (!reportFocus(event.target) && type === 'click' &&
!event.target?.closest?.('[data-vulpes-keep-input]')) port.postMessage({inputFocus:null});
}, true);
addEventListener('visibilitychange', () => {
if (document.hidden && gaiaKeyboard) { restoreMode(); port.postMessage({inputFocus:null}); }
else if (gaiaKeyboard && document.hasFocus()) reportFocus(document.activeElement);
});
addEventListener('unload', () => clearTimeout(reconnect));
exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' });

View file

@ -0,0 +1,88 @@
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);
}
}