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:
parent
1c20a0b6d5
commit
200d1fb235
43 changed files with 2271 additions and 65 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
88
android/extension/personal-data.mjs
Normal file
88
android/extension/personal-data.mjs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue