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,5 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />

View file

@ -0,0 +1,172 @@
package org.vulpes_os.preview;
import android.Manifest;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.database.ContentObserver;
import android.os.Handler;
import android.os.Looper;
import java.util.HashMap;
import java.util.Map;
import android.net.Uri;
import android.provider.CallLog;
import android.provider.ContactsContract;
import android.provider.Telephony;
import org.json.JSONArray;
import org.json.JSONObject;
/** Read-through access. Android remains the owner of contacts and telephony records. */
final class AndroidPersonalData {
private final Context context;
private final Handler main = new Handler(Looper.getMainLooper());
private final Map<String, ContentObserver> observers = new HashMap<>();
private final Map<String, Runnable> pending = new HashMap<>();
void changed(String kind) {
Runnable previous = pending.remove(kind);
if (previous != null) main.removeCallbacks(previous);
Runnable next = () -> {
pending.remove(kind);
VulpesApplication app = (VulpesApplication) context;
if (app.port == null) return;
try { app.port.postMessage(new JSONObject().put("type", "android-data-changed").put("kind", kind)); }
catch (Exception ignored) { /* The resume event refreshes detached sessions. */ }
};
pending.put(kind, next);
main.postDelayed(next, 250);
}
void observe() {
for (String kind : new String[]{"contacts", "sms", "calls"}) {
if (granted(kind) && !observers.containsKey(kind)) {
ContentObserver observer = new ContentObserver(main) {
@Override public void onChange(boolean selfChange) { changed(kind); }
};
Uri uri = kind.equals("contacts") ? ContactsContract.Contacts.CONTENT_URI :
kind.equals("sms") ? Telephony.Sms.CONTENT_URI : CallLog.Calls.CONTENT_URI;
try {
context.getContentResolver().registerContentObserver(uri, true, observer);
observers.put(kind, observer);
} catch (SecurityException ignored) { /* Permission can be revoked during registration. */ }
} else if (!granted(kind) && observers.containsKey(kind)) {
context.getContentResolver().unregisterContentObserver(observers.remove(kind));
}
}
}
AndroidPersonalData(Context context) { this.context = context; }
static String permission(String kind) {
switch (kind) {
case "contacts": return Manifest.permission.READ_CONTACTS;
case "sms": return Manifest.permission.READ_SMS;
case "calls": return Manifest.permission.READ_CALL_LOG;
default: throw new IllegalArgumentException("INVALID_DATA_KIND");
}
}
boolean granted(String kind) {
return context.getSharedPreferences("personal-data", 0).getBoolean(kind, false) &&
context.checkSelfPermission(permission(kind)) == PackageManager.PERMISSION_GRANTED;
}
void enable(String kind, boolean value) {
permission(kind);
context.getSharedPreferences("personal-data", 0).edit().putBoolean(kind, value).apply();
observe();
changed(kind);
}
JSONObject status() throws Exception {
JSONObject result = new JSONObject();
for (String kind : new String[]{"contacts", "sms", "calls"}) result.put(kind, granted(kind));
return result;
}
JSONObject page(JSONObject params) throws Exception {
String kind = params.getString("kind");
if (!granted(kind)) throw new SecurityException("ANDROID_DATA_PERMISSION_DENIED");
long after = params.optLong("after", 0);
if (after < 0) throw new IllegalArgumentException("INVALID_CURSOR");
Uri uri;
String[] columns;
switch (kind) {
case "contacts":
uri = ContactsContract.Contacts.CONTENT_URI;
columns = new String[]{"_id", "display_name", "starred", "contact_last_updated_timestamp"};
break;
case "sms":
uri = Telephony.Sms.CONTENT_URI;
columns = new String[]{"_id", "thread_id", "address", "body", "date", "type", "read", "status"};
break;
default:
uri = CallLog.Calls.CONTENT_URI;
columns = new String[]{"_id", "number", "date", "duration", "type"};
}
JSONArray rows = new JSONArray();
long last = after;
boolean more = false;
ContentResolver resolver = context.getContentResolver();
// A key cursor avoids SQL LIMIT syntax differences between Android providers.
try (Cursor cursor = resolver.query(uri, columns, "_id > ?", new String[]{Long.toString(after)}, "_id ASC")) {
if (cursor == null) throw new IllegalStateException("ANDROID_PROVIDER_UNAVAILABLE");
int bytes = 0;
while (cursor.moveToNext()) {
if (rows.length() >= 100 || bytes > 512 * 1024) { more = true; break; }
JSONObject row = new JSONObject();
for (int i = 0; i < columns.length; i++) {
row.put(columns[i], cursor.isNull(i) ? JSONObject.NULL :
cursor.getType(i) == Cursor.FIELD_TYPE_INTEGER ? cursor.getLong(i) : cursor.getString(i));
}
last = cursor.getLong(0);
if (kind.equals("contacts")) contactDetails(last, row);
rows.put(row);
bytes += row.toString().length() * 2;
}
}
return new JSONObject().put("rows", rows).put("next", more ? last : JSONObject.NULL);
}
private void contactDetails(long id, JSONObject contact) throws Exception {
JSONArray phones = new JSONArray(), emails = new JSONArray();
contact.put("tel", phones).put("email", emails);
try (Cursor c = context.getContentResolver().query(ContactsContract.Data.CONTENT_URI,
new String[]{"mimetype", "data1", "data2", "data3", "is_primary"},
"contact_id = ? AND mimetype IN (?, ?, ?)", new String[]{Long.toString(id),
ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE,
ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE,
ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE}, "_id ASC")) {
if (c == null) throw new IllegalStateException("ANDROID_PROVIDER_UNAVAILABLE");
while (c.moveToNext()) {
String mime = c.getString(0);
if (mime.equals(ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)) {
if (!contact.has("givenName")) contact.put("givenName", new JSONArray().put(c.getString(2)))
.put("familyName", new JSONArray().put(c.getString(3)));
} else {
boolean phone = mime.equals(ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE);
int type = c.getInt(2);
String label = type == 0 ? c.getString(3) : phone ?
(type == 2 ? "mobile" : type == 1 ? "home" : type == 3 ? "work" : "other") :
(type == 1 ? "home" : type == 2 ? "work" : "other");
(phone ? phones : emails).put(new JSONObject().put("value", c.getString(1))
.put("type", new JSONArray().put(label == null ? "other" : label)).put("pref", c.getInt(4) != 0));
}
}
}
}
Intent open(JSONObject params) {
String kind = params.optString("kind");
if (kind.equals("contacts")) {
String id = params.optString("id");
if (!id.matches("[1-9][0-9]{0,15}")) throw new IllegalArgumentException("INVALID_CONTACT");
if (!granted(kind)) throw new SecurityException("ANDROID_DATA_PERMISSION_DENIED");
return new Intent(Intent.ACTION_VIEW, ContentUris.withAppendedId(ContactsContract.Contacts.CONTENT_URI, Long.parseLong(id)));
}
if (kind.equals("sms")) return new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_APP_MESSAGING);
if (kind.equals("calls")) return new Intent(Intent.ACTION_VIEW, CallLog.Calls.CONTENT_URI);
throw new IllegalArgumentException("INVALID_DATA_KIND");
}
}

View file

@ -24,6 +24,7 @@ final class AndroidServices {
CONTACT = 412;
static final long MAX_MEDIA = 32L * 1024 * 1024;
final VulpesApplication app;
final AndroidPersonalData personal;
final ExecutorService io = Executors.newSingleThreadExecutor();
final Handler main = new Handler(Looper.getMainLooper());
GeckoResult<Object> pending;
@ -31,6 +32,7 @@ final class AndroidServices {
AndroidServices(VulpesApplication app) {
this.app = app;
personal = new AndroidPersonalData(app);
}
GeckoResult<Object> request(JSONObject message) {
@ -40,6 +42,30 @@ final class AndroidServices {
JSONObject p = message.optJSONObject("params");
if (p == null) p = new JSONObject();
switch (op) {
case "android.personal":
JSONObject personalParams = p;
String operation = p.optString("operation");
if (operation.equals("permission")) {
String kind = p.getString("kind");
activity().permission(new String[]{AndroidPersonalData.permission(kind)}, granted -> {
try { personal.enable(kind, granted); result.complete(personal.status()); } catch (Exception e) { fail(result, e); }
});
} else if (operation.equals("status")) result.complete(personal.status());
else if (operation.equals("disconnect")) {
personal.enable(p.getString("kind"), false);
result.complete(personal.status());
}
else if (operation.equals("open")) {
activity().startActivity(personal.open(p));
result.complete(null);
} else if (operation.equals("page")) io.execute(() -> {
try {
JSONObject data = personal.page(personalParams);
main.post(() -> result.complete(data));
} catch (Exception e) { fail(result, e); }
});
else throw new IllegalArgumentException("METHOD_NOT_SUPPORTED");
break;
case "android.notification":
JSONObject request = p;
if (p.optString("operation").equals("permission")) {

View file

@ -82,8 +82,8 @@ public final class MainActivity extends Activity {
webPanel.addView(nav, new LinearLayout.LayoutParams(-1, dp(52)));
Button back = new Button(this);
back.setText("‹");
back.setContentDescription("Retour au bureau");
back.setOnClickListener(v -> closeBrowser());
back.setContentDescription("Retour");
back.setOnClickListener(v -> back());
nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1));
address = new EditText(this);
address.setSingleLine(true);
@ -99,6 +99,9 @@ public final class MainActivity extends Activity {
return true;
});
webView = new GeckoView(this);
// The web panel is layered over Gaia and hidden when returning to an app.
// A TextureView follows that visibility without leaving a separate surface.
webView.setViewBackend(GeckoView.BACKEND_TEXTURE_VIEW);
webPanel.addView(webView, new LinearLayout.LayoutParams(-1, 0, 1));
home = new Button(this);
home.setText("◯");
@ -297,10 +300,12 @@ public final class MainActivity extends Activity {
}
void closeBrowser() {
boolean wasVisible = webPanel.getVisibility() == View.VISIBLE;
webPanel.setVisibility(View.GONE);
gaiaView.setVisibility(View.VISIBLE);
setHomeSurface(app.homeVisible);
syncActive();
if (wasVisible) app.browserClosed();
}
private void back() {
@ -470,7 +475,7 @@ public final class MainActivity extends Activity {
else callback.accept(granted);
}
private void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
boolean granted = true;
for (String name : names)
granted &= checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;

View file

@ -150,6 +150,15 @@ public final class VulpesApplication extends Application {
);
}
void browserClosed() {
if (port == null) return;
try {
port.postMessage(new JSONObject().put("type", "browser-closed"));
} catch (Exception e) {
Log.w("Vulpes", "Browser close notification", e);
}
}
void home(boolean held) {
if (port == null) return;
try {
@ -160,6 +169,7 @@ public final class VulpesApplication extends Application {
}
void resumed() {
if (services != null) services.personal.observe();
sendScreenLock();
if (port == null) return;
try {

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);
}
}

View file

@ -3,14 +3,15 @@
import json,sys,time
from rdp import RDP
def evaluate(host, script):
r=RDP()
def evaluate(host, script, connection=None):
hostname, _, path = host.partition('/')
r=connection or RDP()
try:
for process in r.call('root','listProcesses')['processes']:
if process['isParent']: continue
target=r.call(process['actor'],'getTarget')['process']
if 'webIsolated' not in target.get('remoteType',''): continue
text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(host+'.localhost'),json.dumps(script))
text='''(() => { const w=[...Services.ww.getWindowEnumerator()].find(w=>w.location.hostname===%s && (!%s || w.location.pathname.startsWith(%s))); if(!w)return null; return w.wrappedJSObject.eval(%s); })()''' % (json.dumps(hostname+'.localhost'),json.dumps(path),json.dumps('/'+path),json.dumps(script))
response=r.call(target['consoleActor'],'evaluateJSAsync',text=text)
if 'resultID' not in response: continue
while True:
@ -20,24 +21,32 @@ def evaluate(host, script):
if value.get('result') is not None and value.get('result') != {'type':'null'}: return value.get('result')
break
raise RuntimeError('App not open: '+host)
finally:r.s.close()
finally:
if connection is None:r.s.close()
def run(host,expression,timeout=30):
def _run(host,expression,timeout,connection):
start=time.monotonic()+timeout
while True:
try:
evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"')
evaluate(host,'window.__androidProbe=null; Promise.resolve().then(async()=>('+expression+')).then(value=>window.__androidProbe=JSON.stringify({value}),error=>window.__androidProbe=JSON.stringify({error:String(error),stack:error.stack})); "started"',connection)
break
except (RuntimeError, EOFError, OSError) as e:
except RuntimeError as e:
if time.monotonic()>=start:raise
if isinstance(e, RuntimeError) and not str(e).startswith('App not open:'):raise
if not str(e).startswith('App not open:'):raise
time.sleep(.3)
until=time.monotonic()+timeout
while time.monotonic()<until:
result=evaluate(host,'window.__androidProbe || "pending"')
result=evaluate(host,'window.__androidProbe || "pending"',connection)
if isinstance(result,str) and result != 'pending':return json.loads(result)
time.sleep(.3)
raise TimeoutError(host)
def run(host,expression,timeout=30):
connection=RDP()
try:
return _run(host,expression,timeout,connection)
finally:
connection.s.close()
if __name__=='__main__':
print(json.dumps(run(sys.argv[1],sys.argv[2]),ensure_ascii=False,indent=2))

View file

@ -29,6 +29,8 @@ class RDP:
while True:
r = self.recv()
self.events.append(r)
if str(r.get("type", "")).endswith("ListChanged"):
continue
if r.get("from") == to and (r.get("type") not in ["consoleAPICall", "pageError"]):
return r

View file

@ -1 +1,202 @@
// Home layout recovery now lives in the shared host/platform-ui.js.
// Android-owned data stays in its provider; Gaia keeps its local data separately.
(() => {
const app = location.hostname.split('.')[0];
const fr = () => (document.documentElement.lang || navigator.language).startsWith('fr');
const text = (f,e) => fr() ? f : e;
const call = params => VulpesCompat.call('android.personal', params);
const notice = message => window.VulpesGaiaUI?.notice(message);
const readOnly = () => notice(text('Ces données appartiennent à Android. Modifiez-les dans l’application Android.',
'Android owns these records. Edit them in the Android app.'));
const failure = () => notice(text('Accès aux données Android indisponible. Vérifiez les autorisations dans Paramètres.',
'Android data is unavailable. Check permissions in Settings.'));
let dirty = false;
let selectedContact;
const pendingCallChanges = new Set();
function refreshCallLog() {
if (document.hidden || !window.CallLog?.sticky) return;
if (pendingCallChanges.has('calls')) {
CallLog.callLogContainer.textContent = '';
CallLog.render();
} else if (pendingCallChanges.has('contacts')) {
CallLog._contactCache = false;
CallLog.updateListWithContactInfo('update');
}
pendingCallChanges.clear();
}
if(app==='communications' && location.pathname.startsWith('/contacts')) {
// Gaia's first-paint HTML cache must not retain Android names after revocation.
localStorage.removeItem('firstChunk');
document.addEventListener('click',event=>{
const row=event.target.closest?.('[data-uuid]');
if(row) selectedContact=row.dataset.uuid;
},true);
let cache;
Object.defineProperty(window,'Cache',{configurable:true,get:()=>cache,set:value=>{
cache=value;
Object.defineProperties(cache,{
enabled:{get:()=>false},active:{get:()=>false},firstChunk:{set:()=>{}}
});
cache.apply=()=>Promise.reject();
}});
}
function canRefresh() {
if (document.hidden) return false;
if (app === 'sms') return window.Navigation?.isCurrentPanel('thread-list');
if (location.pathname.startsWith('/contacts')) return window.MainNavigation?.currentView() === 'view-contacts-list';
return false;
}
let refreshingMessages = false;
async function refreshMessages() {
const view=window.ConversationView, id=view?.activeThread?.id;
if(refreshingMessages || !(id < 0) || document.hidden || !window.Navigation?.isCurrentPanel('thread'))return;
refreshingMessages=true;
try {
const rows=await VulpesCompat.call('messages.request',{operation:'getMessages',args:[{threadId:id}]});
if(view.activeThread?.id!==id || document.hidden)return;
const ids=new Set(rows.map(row=>String(row.id)));
for(const node of document.querySelectorAll('[data-message-id]')) {
if(Number(node.dataset.messageId)<0 && !ids.has(node.dataset.messageId))view.removeMessageDOM(node);
}
for(const row of rows) {
const key=JSON.stringify(row),old=document.getElementById('message-'+row.id);
if(old?.dataset.androidRecord===key)continue;
const message={...row,timestamp:new Date(row.timestamp)};
window.Threads?.registerMessage(message);
await view.appendMessage(message,false);
const node=document.getElementById('message-'+row.id);if(node)node.dataset.androidRecord=key;
}
dirty=false;
} catch(error){failure();}finally{refreshingMessages=false;}
}
function refresh() {
if (app === 'communications' && location.pathname.startsWith('/dialer')) {
refreshCallLog();
return;
}
if (dirty && selectedContact && !document.hidden && window.MainNavigation?.currentView()==='view-contact-details') {
dirty = false;
const id = selectedContact;
VulpesCompat.call('contacts.request',{operation:'find',options:{filterBy:['id'],filterOp:'equals',filterValue:id}})
.then(rows=>{if(rows.length)Contacts.updateContactDetail(id);else Contacts.goBack();}).catch(failure);
return;
}
if(dirty && app==='sms')refreshMessages();
if (!dirty || !canRefresh()) return;
dirty = false;
if(app==='sms' && window.InboxView?.container && !InboxView.inEditMode) InboxView.renderThreads();
else if(window.contacts?.List?.load) contacts.List.load(undefined,true);
}
addEventListener('visibilitychange', refresh);
addEventListener('hashchange', refresh);
addEventListener('transitionend', refresh);
addEventListener('vulpes-service-event', ({detail}) => {
if (detail.type !== 'android-data-changed') return;
if (app === 'communications' && location.pathname.startsWith('/dialer')) {
pendingCallChanges.add(detail.data.kind);
refreshCallLog();
return;
}
dirty = true;
refresh();
});
// Keep Gaia's call grouping/rendering while reading the current Android log.
if (app === 'communications' && location.pathname.startsWith('/dialer')) {
let value;
Object.defineProperty(window, 'CallLogDBManager', {configurable:true,
get:()=>value, set:db=>{
value=db;
const original = Object.fromEntries(['getGroupList','getGroup','getGroupAtPosition'].map(k=>[k,db[k].bind(db)]));
const groups = async () => {
const rows = await call({operation:'page',kind:'calls'});
const list = new Map();
for (const row of rows) {
const recent = {date:row.date,number:row.number || '',type:row.type===2 ? 'dialing':'incoming',
status:row.type===1 ? 'connected':null};
const id=db._getGroupId(recent), key=JSON.stringify(id);
if(!list.has(key)) list.set(key,{id,number:recent.number,serviceId:0,lastEntryDate:row.date,retryCount:0,calls:[]});
const g=list.get(key);g.lastEntryDate=Math.max(g.lastEntryDate,row.date);g.retryCount++;
g.calls.push({date:row.date,duration:row.duration*1000});
}
return [...list.values()].map(g=>{g.calls.sort((a,b)=>b.date-a.date);return db._getGroupObject(g);});
};
let nativeCalls=false;
const active=async()=> (nativeCalls=!!(await call({operation:'status',kind:'calls'})).calls);
db.getGroupList=async(callback,sortedBy,prev,cursor,limit)=>{
try {
if(!await active()) return original.getGroupList(callback,sortedBy,prev,cursor,limit);
// Native groups do not live in Gaia's IndexedDB contact cache.
if(window.CallLog)CallLog._contactCache=false;
let list=(await groups()).sort((a,b)=>(a.lastEntryDate-b.lastEntryDate)*(prev?-1:1));
if(limit>0)list=list.slice(0,limit);
if(!cursor)return callback(list);
let index=0;
const next=()=>queueMicrotask(()=>callback({value:list[index++] || null,continue:next}));next();
} catch(error) { failure(); callback(cursor?{value:null}:[]); }
};
db.getGroup=async(number,date,type,status)=>{
if(!await active())return original.getGroup(number,date,type,status);
const key=db._getGroupId({number,date,type,status}).join('-');
const found=(await groups()).find(g=>g.id===key);
if(!found)throw Error('NotFoundError');return found;
};
db.getGroupAtPosition=async(position,sortedBy,prev,type,callback)=>{
if(!await active())return original.getGroupAtPosition(position,sortedBy,prev,type,callback);
try {const list=(await groups()).filter(g=>!type||g.type===type).sort((a,b)=>(a.lastEntryDate-b.lastEntryDate)*(prev?-1:1));callback(list[position-1]||null);}
catch(error){failure();callback(null);}
};
document.addEventListener('click', event=>{
if(!nativeCalls || !event.target.closest?.('#call-log-icon-edit'))return;
// Editing Android logs is intentionally delegated in this first integration.
event.preventDefault();event.stopImmediatePropagation();readOnly();
call({operation:'open',kind:'calls'}).catch(failure);
},true);
}});
}
if(app==='communications' && location.pathname.startsWith('/contacts')) {
const timer=setInterval(()=>{
if(!window.Contacts?.showForm)return;
clearInterval(timer);
const original=Contacts.showForm;
Contacts.showForm=function(edit,contact) {
if(edit && String(contact?.id).startsWith('android:')) {
selectedContact=contact.id;
call({operation:'open',kind:'contacts',id:contact.id.slice(8)}).catch(failure);
return;
}
return original.apply(this,arguments);
};
},100);
addEventListener('pagehide',()=>clearInterval(timer),{once:true});
}
function initialize() {
if (app !== 'settings') return;
const root=document.querySelector('#root > div');
if(!root || document.querySelector(".vulpes-android-data"))return;
const header=document.createElement('header');header.className='vulpes-android-data';
const title=document.createElement('h2');title.textContent=text('Données Android','Android data');header.append(title);
const list=document.createElement('ul');list.className='vulpes-android-data';
const hint=document.createElement('p');hint.style.cssText='margin:1rem 1.5rem;font-size:1.4rem;line-height:1.5';
hint.textContent=text('Lecture des contacts, SMS (hors MMS) et appels Android. Envoi, modification et suppression dans Android. Les contacts créés dans Gaia restent locaux.',
'Read Android contacts, SMS (not MMS) and calls. Send, edit and delete in Android. Contacts created in Gaia stay local.');
root.append(header,hint,list);
for(const [kind,french,english] of [['contacts','Contacts','Contacts'],['sms','SMS','SMS'],['calls','Journal d’appels','Call history']]) {
const li=document.createElement('li'),button=document.createElement('button');
li.append(button);list.append(li);
let enabled=false;
const update=state=>{enabled=state[kind];button.textContent=text(french,english)+' — '+(enabled?text('Déconnecter','Disconnect'):text('Autoriser la lecture','Allow reading'));};
call({operation:'status'}).then(update).catch(failure);
button.onclick=async()=>{
button.disabled=true;
try {
const disconnect=enabled;
const state=await call({operation:disconnect?'disconnect':'permission',kind});update(state);
if(!disconnect && !state[kind])notice(text('Autorisation refusée ou restreinte par Android. L’application Android reste disponible.',
'Permission denied or restricted by Android. Your Android app is still available.'));
} catch(error){failure();} finally{button.disabled=false;}
};
}
}
if(app==='settings') new MutationObserver(initialize).observe(document.documentElement,{childList:true,subtree:true});
if(document.readyState==='loading')addEventListener('DOMContentLoaded',initialize,{once:true});else initialize();
})();