Publish Vulpes Preview 15 sources for desktop, Android and Tundra

Share Gaia assets and adapters, keep private profiles and development documents local, and update both public READMEs.
This commit is contained in:
OverSu 2026-09-30 23:13:03 +02:00
commit b38dea0597
25901 changed files with 9982925 additions and 369 deletions

9
android/.gitignore vendored Normal file
View file

@ -0,0 +1,9 @@
/toolchain/
/.gradle/
/build/
/app/build/
/app/src/main/assets/
/local.properties
/dist/
/keys/
/logs/

20
android/app/build.gradle Normal file
View file

@ -0,0 +1,20 @@
plugins { id 'com.android.application' }
def vulpesRelease = new groovy.json.JsonSlurper().parse(rootProject.file('../release.json'))
def productVersion = vulpesRelease.version ?: "${vulpesRelease.gaia}-${vulpesRelease.channel}.${vulpesRelease.revision}"
android {
namespace 'org.vulpes_os.preview'
compileSdk { version = release(37) { minorApiLevel = 1 } }
buildToolsVersion '37.0.0'
defaultConfig {
applicationId 'org.vulpes_os.preview'
minSdk 26
targetSdk 37
versionCode vulpesRelease.androidVersionCode as int
versionName productVersion
ndk { abiFilters 'arm64-v8a', 'armeabi-v7a', 'x86_64' }
}
signingConfigs { debug { storeFile rootProject.file('keys/preview.keystore'); storePassword 'android'; keyAlias 'androiddebugkey'; keyPassword 'android' } }
compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 }
packaging { jniLibs { useLegacyPackaging = false } }
}
dependencies { implementation "org.mozilla.geckoview:geckoview:${vulpesRelease.geckoview}" }

View file

@ -0,0 +1,30 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera.any" android:required="false" />
<uses-permission android:name="com.android.alarm.permission.SET_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application android:name=".VulpesApplication" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher" android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:allowBackup="false" android:supportsRtl="true" android:usesCleartextTraffic="true">
<activity android:name=".MainActivity" android:exported="true"
android:launchMode="singleTop"
android:configChanges="orientation|screenSize|keyboardHidden|smallestScreenSize|uiMode"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<provider android:name=".CaptureProvider" android:authorities="${applicationId}.capture"
android:exported="false" android:grantUriPermissions="true" />
</application>
</manifest>

View file

@ -0,0 +1,161 @@
package org.vulpes_os.preview;
import android.app.*;
import android.content.Intent;
import android.content.SharedPreferences;
import java.util.HashMap;
import org.json.*;
import org.mozilla.geckoview.*;
final class AndroidNotifications implements WebNotificationDelegate {
private final VulpesApplication app;
private final NotificationManager manager;
private final HashMap<String, WebNotification> live = new HashMap<>();
private final HashMap<String, Integer> ids = new HashMap<>();
private int nextId = 1;
AndroidNotifications(VulpesApplication app) {
this.app = app;
manager = app.getSystemService(NotificationManager.class);
manager.createNotificationChannel(
new NotificationChannel("vulpes", "Vulpes OS", NotificationManager.IMPORTANCE_DEFAULT)
);
}
private String key(WebNotification n) {
return n.origin + "|" + (n.tag == null || n.tag.isEmpty() ? System.identityHashCode(n) : n.tag);
}
public void onShowNotification(WebNotification n) {
if (!manager.areNotificationsEnabled()) {
n.dismiss();
return;
}
String key = key(n);
int id = ids.computeIfAbsent(key, k -> nextId++);
live.put(key, n);
Intent open = new Intent(app, MainActivity.class)
.setAction("org.vulpes_os.preview.NOTIFICATION")
.putExtra("notificationKey", key)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent action = PendingIntent.getActivity(
app,
id,
open,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
Notification notification = new Notification.Builder(app, "vulpes")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(n.title)
.setContentText(n.text)
.setStyle(new Notification.BigTextStyle().bigText(n.text))
.setContentIntent(action)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.build();
try {
manager.notify(id, notification);
n.show();
} catch (SecurityException e) {
live.remove(key);
n.dismiss();
}
}
public void onCloseNotification(WebNotification n) {
String key = key(n);
Integer id = ids.remove(key);
live.remove(key);
if (id != null) manager.cancel(id);
}
Object local(JSONObject p) throws JSONException {
String owner = p.getString("owner"),
operation = p.getString("operation");
SharedPreferences store = app.getSharedPreferences("notifications", 0);
if (operation.equals("query")) return manager.areNotificationsEnabled() ? "granted" : "denied";
if (operation.equals("list")) {
JSONArray rows = new JSONArray();
for (android.service.notification.StatusBarNotification item : manager.getActiveNotifications()) {
String raw = store.getString("local." + item.getId(), null);
if (raw == null) continue;
JSONObject data = new JSONObject(raw);
if (data.optString("owner").equals(owner)) rows.put(data);
}
return rows;
}
if (operation.equals("close")) {
int id = p.getInt("id");
JSONObject data = new JSONObject(store.getString("local." + id, "{}"));
if (!data.optString("owner").equals(owner)) throw new SecurityException("PERMISSION_DENIED");
manager.cancel(id);
store.edit().remove("local." + id).apply();
return null;
}
if (!operation.equals("show")) throw new IllegalArgumentException(
"UNKNOWN_NOTIFICATION_OPERATION"
);
if (!manager.areNotificationsEnabled()) throw new SecurityException("NOTIFICATIONS_DENIED");
String tag = p.optString("tag");
int id = 0;
if (
!tag.isEmpty()
) for (android.service.notification.StatusBarNotification item : manager.getActiveNotifications()) {
JSONObject saved = new JSONObject(store.getString("local." + item.getId(), "{}"));
if (owner.equals(saved.optString("owner")) && tag.equals(saved.optString("tag"))) id =
item.getId();
}
if (id == 0) {
id = store.getInt("next", 10000);
store.edit().putInt("next", id + 1).apply();
}
JSONObject data = new JSONObject(p.toString()).put("id", id);
store.edit().putString("local." + id, data.toString()).apply();
Intent open = new Intent(app, MainActivity.class)
.setAction("org.vulpes_os.preview.LOCAL_NOTIFICATION")
.putExtra("localNotification", id)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
PendingIntent action = PendingIntent.getActivity(
app,
id,
open,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
Notification n = new Notification.Builder(app, "vulpes")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(p.optString("title", "Vulpes OS"))
.setContentText(p.optString("body"))
.setStyle(new Notification.BigTextStyle().bigText(p.optString("body")))
.setContentIntent(action)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.build();
manager.notify(id, n);
return id;
}
void click(Intent intent) {
if (intent != null && "org.vulpes_os.preview.LOCAL_NOTIFICATION".equals(intent.getAction())) {
int id = intent.getIntExtra("localNotification", -1);
SharedPreferences store = app.getSharedPreferences("notifications", 0);
String raw = store.getString("local." + id, null);
if (raw != null && app.port != null) try {
JSONObject data = new JSONObject(raw);
app.port.postMessage(new JSONObject().put("type", "notification-click").put("data", data));
store.edit().remove("local." + id).apply();
intent.removeExtra("localNotification");
} catch (Exception e) {
android.util.Log.w("Vulpes", "Notification click", e);
}
return;
}
if (intent == null || !"org.vulpes_os.preview.NOTIFICATION".equals(intent.getAction())) return;
String key = intent.getStringExtra("notificationKey");
WebNotification n = live.remove(key);
Integer id = ids.remove(key);
if (id != null) manager.cancel(id);
if (n != null) n.click();
intent.removeExtra("notificationKey");
}
}

View file

@ -0,0 +1,508 @@
package org.vulpes_os.preview;
import android.app.*;
import android.content.*;
import android.database.Cursor;
import android.media.AudioManager;
import android.net.Uri;
import android.os.*;
import android.provider.*;
import android.util.Base64;
import android.webkit.MimeTypeMap;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import org.json.*;
import org.mozilla.geckoview.GeckoResult;
/** Android operations reachable only from the bundled extension's authorized RPC. */
final class AndroidServices {
static final int PICK = 410,
CAPTURE = 411,
CONTACT = 412;
static final long MAX_MEDIA = 32L * 1024 * 1024;
final VulpesApplication app;
final ExecutorService io = Executors.newSingleThreadExecutor();
final Handler main = new Handler(Looper.getMainLooper());
GeckoResult<Object> pending;
String pendingType;
AndroidServices(VulpesApplication app) {
this.app = app;
}
GeckoResult<Object> request(JSONObject message) {
String op = message.optString("type");
GeckoResult<Object> result = new GeckoResult<>();
try {
JSONObject p = message.optJSONObject("params");
if (p == null) p = new JSONObject();
switch (op) {
case "android.notification":
JSONObject request = p;
if (p.optString("operation").equals("permission")) {
activity().notificationPermission(granted ->
result.complete(granted ? "granted" : "denied")
);
} else result.complete(app.notifications.local(request));
break;
case "android.settings":
openSettings(p.getString("panel"));
result.complete(null);
break;
case "android.snapshot":
result.complete(snapshot());
break;
case "android.set":
set(p);
result.complete(snapshot());
break;
case "android.pick":
pick(p.getString("type"), result);
break;
case "android.capture":
capture(result);
break;
case "android.alarms":
activity().startActivity(new Intent(AlarmClock.ACTION_SHOW_ALARMS));
result.complete(null);
break;
case "android.contact":
if (pending != null) throw new IllegalStateException("PICKER_BUSY");
activity().startActivityForResult(
new Intent(Intent.ACTION_PICK, ContactsContract.CommonDataKinds.Phone.CONTENT_URI),
CONTACT
);
pending = result;
pendingType = "contact";
break;
case "android.sms":
String numbers = p.optString("number"),
text = p.optString("body");
if (
!numbers.matches("[+0-9*# ,;()-]{0,200}") || text.length() > 10000
) throw new IllegalArgumentException("INVALID_MESSAGE");
activity().startActivity(
new Intent(Intent.ACTION_SENDTO, Uri.fromParts("smsto", numbers, null)).putExtra(
"sms_body",
text
)
);
result.complete(null);
break;
case "android.dial":
String number = p.getString("number");
if (!number.matches("[+0-9*# ,;()-]{1,80}")) throw new IllegalArgumentException(
"INVALID_NUMBER"
);
activity().startActivity(
new Intent(Intent.ACTION_DIAL, Uri.fromParts("tel", number, null))
);
result.complete(null);
break;
case "android.media":
JSONObject params = p;
io.execute(() -> {
try {
Object value = media(params);
main.post(() -> result.complete(value));
} catch (Exception e) {
fail(result, e);
}
});
break;
default:
throw new IllegalArgumentException("METHOD_NOT_SUPPORTED");
}
} catch (Exception e) {
fail(result, e);
}
return result;
}
private void fail(GeckoResult<Object> result, Exception e) {
main.post(() -> {
try {
result.complete(
new JSONObject().put(
"error",
e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage()
)
);
} catch (JSONException ignored) {}
});
}
private MainActivity activity() {
if (app.activity == null || app.activity.isFinishing()) throw new IllegalStateException(
"ACTIVITY_UNAVAILABLE"
);
return app.activity;
}
void openSettings(String panel) {
Map<String, String> actions = new HashMap<>();
actions.put("wifi", Settings.ACTION_WIFI_SETTINGS);
actions.put("bluetooth", Settings.ACTION_BLUETOOTH_SETTINGS);
actions.put("data", Settings.ACTION_WIRELESS_SETTINGS);
actions.put("airplane-mode", Settings.ACTION_AIRPLANE_MODE_SETTINGS);
actions.put("geolocation", Settings.ACTION_LOCATION_SOURCE_SETTINGS);
actions.put("display", Settings.ACTION_DISPLAY_SETTINGS);
actions.put("sound", Settings.ACTION_SOUND_SETTINGS);
actions.put("dateTime", Settings.ACTION_DATE_SETTINGS);
actions.put("screenLock", Settings.ACTION_SECURITY_SETTINGS);
actions.put("battery", Settings.ACTION_BATTERY_SAVER_SETTINGS);
actions.put("home", Settings.ACTION_HOME_SETTINGS);
actions.put("storage", Settings.ACTION_INTERNAL_STORAGE_SETTINGS);
actions.put("notifications", Settings.ACTION_APP_NOTIFICATION_SETTINGS);
actions.put("apps", Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
actions.put("root", Settings.ACTION_SETTINGS);
if (!actions.containsKey(panel)) throw new IllegalArgumentException("UNKNOWN_PANEL");
Intent intent = new Intent(actions.get(panel));
if (panel.equals("notifications")) intent.putExtra(
Settings.EXTRA_APP_PACKAGE,
app.getPackageName()
);
if (panel.equals("apps")) intent.setData(Uri.parse("package:" + app.getPackageName()));
activity().startActivity(intent);
}
JSONObject snapshot() throws Exception {
AudioManager audio = app.getSystemService(AudioManager.class);
JSONObject data = new JSONObject();
Intent battery = app.registerReceiver(null, new android.content.IntentFilter(Intent.ACTION_BATTERY_CHANGED));
if (battery != null) {
int level = battery.getIntExtra(android.os.BatteryManager.EXTRA_LEVEL, -1);
int scale = battery.getIntExtra(android.os.BatteryManager.EXTRA_SCALE, -1);
int status = battery.getIntExtra(android.os.BatteryManager.EXTRA_STATUS, -1);
if (level >= 0 && scale > 0) data.put("battery", new JSONObject()
.put("level", (double) level / scale)
.put("charging", status == android.os.BatteryManager.BATTERY_STATUS_CHARGING ||
status == android.os.BatteryManager.BATTERY_STATUS_FULL));
}
data.put(
"screen.brightness",
app
.getSharedPreferences("android", 0)
.getFloat(
"brightness",
Settings.System.getInt(app.getContentResolver(), Settings.System.SCREEN_BRIGHTNESS, 128) /
255f
)
);
data.put(
"audio.volume.content",
Math.round(
(15f * audio.getStreamVolume(AudioManager.STREAM_MUSIC)) /
audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
)
);
data.put("audio.volume.content.max", audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC));
data.put(
"airplaneMode.enabled",
Settings.Global.getInt(app.getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0) != 0
);
data.put(
"geolocation.enabled",
app
.getSystemService(android.location.LocationManager.class)
.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER)
);
data.put("wifi.enabled", Settings.Global.getInt(app.getContentResolver(), "wifi_on", 0) != 0);
data.put(
"bluetooth.enabled",
Settings.Global.getInt(app.getContentResolver(), "bluetooth_on", 0) != 0
);
data.put("deviceinfo.product_model", Build.MANUFACTURER + " " + Build.MODEL);
data.put("deviceinfo.hardware", Build.HARDWARE);
data.put("deviceinfo.firmware_revision", "Android " + Build.VERSION.RELEASE);
data.put("deviceinfo.software", "Vulpes OS " + app.getPackageManager().getPackageInfo(app.getPackageName(), 0).versionName);
data.put(
"android.notifications.enabled",
app.getSystemService(NotificationManager.class).areNotificationsEnabled()
);
return data;
}
void set(JSONObject p) throws JSONException {
String key = p.getString("key");
if (key.equals("screen.brightness")) {
float value = (float) p.getDouble("value");
if (!Float.isFinite(value) || value < 0 || value > 1) throw new IllegalArgumentException(
"INVALID_VALUE"
);
android.view.WindowManager.LayoutParams attrs = activity().getWindow().getAttributes();
attrs.screenBrightness = Math.max(.02f, value);
activity().getWindow().setAttributes(attrs);
app.getSharedPreferences("android", 0).edit().putFloat("brightness", value).apply();
} else if (key.equals("audio.volume.content")) {
AudioManager audio = app.getSystemService(AudioManager.class);
int value = p.getInt("value");
if (value < 0 || value > 15) throw new IllegalArgumentException("INVALID_VALUE");
audio.setStreamVolume(
AudioManager.STREAM_MUSIC,
Math.round((value * audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC)) / 15f),
0
);
} else throw new IllegalArgumentException("SETTING_REQUIRES_ANDROID_PANEL");
}
void pick(String type, GeckoResult<Object> result) {
checkType(type);
if (pending != null) throw new IllegalStateException("PICKER_BUSY");
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType(prefix(type) + "*")
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true);
activity().startActivityForResult(intent, PICK);
pending = result;
pendingType = type;
app
.getSharedPreferences("pending-media", 0)
.edit()
.putString("type", type)
.putInt("code", PICK)
.commit();
}
void capture(GeckoResult<Object> result) throws IOException {
if (pending != null) throw new IllegalStateException("PICKER_BUSY");
Files.deleteIfExists(new File(app.getCacheDir(), "capture.jpg").toPath());
Uri uri = Uri.parse("content://" + app.getPackageName() + ".capture/capture.jpg");
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE)
.putExtra(MediaStore.EXTRA_OUTPUT, uri)
.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setClipData(ClipData.newRawUri("Vulpes photo", uri));
activity().startActivityForResult(intent, CAPTURE);
pending = result;
pendingType = "pictures";
app
.getSharedPreferences("pending-media", 0)
.edit()
.putString("type", "pictures")
.putInt("code", CAPTURE)
.commit();
}
void onResult(int code, int status, Intent intent) {
if (code == CONTACT) {
GeckoResult<Object> result = pending;
pending = null;
pendingType = null;
if (result == null) return;
if (status != Activity.RESULT_OK || intent == null || intent.getData() == null) {
fail(result, new IOException("AbortError"));
return;
}
Uri uri = intent.getData();
io.execute(() -> {
try (
Cursor c = app
.getContentResolver()
.query(
uri,
new String[] {
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
ContactsContract.CommonDataKinds.Phone.NUMBER,
},
null,
null,
null
)
) {
if (c == null || !c.moveToFirst()) throw new IOException("NotFoundError");
JSONObject contact = new JSONObject()
.put("name", new JSONArray().put(c.getString(0)))
.put("givenName", new JSONArray().put(c.getString(0)))
.put(
"tel",
new JSONArray().put(
new JSONObject()
.put("value", c.getString(1))
.put("type", new JSONArray().put("mobile"))
)
);
main.post(() -> result.complete(contact));
} catch (Exception e) {
fail(result, e);
}
});
return;
}
if (code != PICK && code != CAPTURE) return;
SharedPreferences saved = app.getSharedPreferences("pending-media", 0);
if (pending == null && saved.getInt("code", 0) != code) return;
GeckoResult<Object> result = pending == null ? new GeckoResult<>() : pending;
String type = pendingType == null ? saved.getString("type", "pictures") : pendingType;
pending = null;
pendingType = null;
saved.edit().clear().commit();
if (status != Activity.RESULT_OK) {
fail(result, new IOException("AbortError"));
return;
}
ArrayList<Uri> uris = new ArrayList<>();
if (code == CAPTURE) uris.add(Uri.fromFile(new File(app.getCacheDir(), "capture.jpg")));
else if (intent != null && intent.getClipData() != null) {
for (int i = 0; i < intent.getClipData().getItemCount(); i++) uris.add(
intent.getClipData().getItemAt(i).getUri()
);
} else if (intent != null && intent.getData() != null) uris.add(intent.getData());
io.execute(() -> {
try {
JSONArray names = new JSONArray();
for (Uri uri : uris) {
String mime = code == CAPTURE ? "image/jpeg" : app.getContentResolver().getType(uri);
String name = code == CAPTURE
? "Photo-" + System.currentTimeMillis() + ".jpg"
: displayName(uri);
if (mime == null) mime = mime(name);
if (!mime.startsWith(prefix(type))) throw new IOException("TypeMismatchError");
name = UUID.randomUUID() + "-" + name.replaceAll("[^a-zA-Z0-9._-]", "_");
File target = path(type, name);
try (
InputStream in = code == CAPTURE
? new FileInputStream(new File(uri.getPath()))
: app.getContentResolver().openInputStream(uri)
) {
copy(in, target);
}
names.put("/sdcard/" + name);
}
main.post(() -> result.complete(names));
} catch (Exception e) {
fail(result, e);
} finally {
if (code == CAPTURE) new File(app.getCacheDir(), "capture.jpg").delete();
}
});
}
String displayName(Uri uri) {
try (
Cursor c = app
.getContentResolver()
.query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null)
) {
if (c != null && c.moveToFirst()) return c.getString(0);
} catch (Exception ignored) {}
return "media";
}
static void checkType(String type) {
if (
!Arrays.asList("pictures", "videos", "music").contains(type)
) throw new IllegalArgumentException("INVALID_MEDIA_TYPE");
}
static String prefix(String type) {
checkType(type);
return type.equals("pictures") ? "image/" : type.equals("videos") ? "video/" : "audio/";
}
static String mime(String name) {
String ext = name.substring(name.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
String type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
return type == null ? "application/octet-stream" : type;
}
File path(String type, String name) throws IOException {
checkType(type);
if (name.startsWith("/sdcard/")) name = name.substring(8);
File root = new File(app.getFilesDir(), "media/" + type);
root.mkdirs();
File file = new File(root, name);
if (
!file.getCanonicalPath().startsWith(root.getCanonicalPath() + "/") ||
name.contains("..") ||
name.startsWith("/")
) throw new IOException("SecurityError");
return file;
}
void copy(InputStream in, File file) throws IOException {
if (in == null) throw new IOException("NotFoundError");
file.getParentFile().mkdirs();
File temp = new File(file.getPath() + ".tmp");
try (OutputStream out = new FileOutputStream(temp)) {
byte[] buf = new byte[32768];
int n;
long total = 0;
while ((n = in.read(buf)) != -1) {
total += n;
if (total > MAX_MEDIA) throw new IOException("MEDIA_TOO_LARGE_32_MIB");
out.write(buf, 0, n);
}
Files.move(temp.toPath(), file.toPath());
} finally {
temp.delete();
}
}
JSONObject info(File root, File f) throws JSONException {
return new JSONObject()
.put("name", "/sdcard/" + root.toPath().relativize(f.toPath()).toString())
.put("size", f.length())
.put("lastModified", f.lastModified())
.put("type", mime(f.getName()));
}
Object media(JSONObject p) throws Exception {
String type = p.getString("type"),
op = p.getString("operation");
checkType(type);
File root = path(type, "dummy").getParentFile();
if (op.equals("available")) return "available";
if (op.equals("freeSpace")) return root.getUsableSpace();
if (op.equals("enumerate") || op.equals("usedSpace")) {
JSONArray rows = new JSONArray();
long used = 0;
try (java.util.stream.Stream<Path> paths = Files.walk(root.toPath())) {
for (Path path : (Iterable<Path>) paths.filter(Files::isRegularFile)::iterator) {
File f = path.toFile();
if (f.getName().endsWith(".tmp")) continue;
used += f.length();
String name = root.toPath().relativize(path).toString();
if (
name.startsWith(p.optString("name", "").replaceFirst("^/sdcard/", "")) &&
f.lastModified() >= p.optLong("since", 0)
) rows.put(info(root, f));
}
}
return op.equals("usedSpace") ? used : rows;
}
File f = path(type, p.getString("name"));
if (op.equals("get")) {
if (!f.isFile()) throw new IOException("NotFoundError");
if (f.length() > MAX_MEDIA) throw new IOException("MEDIA_TOO_LARGE_32_MIB");
return info(root, f).put(
"blob",
new JSONObject()
.put("__vulpesBlobV1", true)
.put("type", mime(f.getName()))
.put("base64", Base64.encodeToString(Files.readAllBytes(f.toPath()), Base64.NO_WRAP))
);
}
if (op.equals("delete")) {
if (!f.isFile() || !f.delete()) throw new IOException("NotFoundError");
return "/sdcard/" + root.toPath().relativize(f.toPath());
}
if (op.equals("add")) {
JSONObject blob = p.getJSONObject("blob");
if (
!blob.optBoolean("__vulpesBlobV1") || !blob.getString("type").startsWith(prefix(type))
) throw new IOException("TypeMismatchError");
if (f.exists()) throw new IOException("NoModificationAllowedError");
String encoded = blob.getString("base64");
if (encoded.length() > (MAX_MEDIA * 4) / 3 + 4) throw new IOException(
"MEDIA_TOO_LARGE_32_MIB"
);
copy(new ByteArrayInputStream(Base64.decode(encoded, Base64.DEFAULT)), f);
return "/sdcard/" + root.toPath().relativize(f.toPath());
}
throw new IllegalArgumentException("NotSupportedError");
}
}

View file

@ -0,0 +1,61 @@
package org.vulpes_os.preview;
import android.content.*;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.OpenableColumns;
import java.io.*;
/** Grants the selected camera access to one temporary output, never the profile. */
public final class CaptureProvider extends ContentProvider {
public boolean onCreate() {
return true;
}
private File file(Uri uri) throws FileNotFoundException {
if (!"/capture.jpg".equals(uri.getPath())) throw new FileNotFoundException();
return new File(getContext().getCacheDir(), "capture.jpg");
}
public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
return ParcelFileDescriptor.open(file(uri), ParcelFileDescriptor.parseMode(mode));
}
public String getType(Uri uri) {
return "image/jpeg";
}
public Cursor query(Uri uri, String[] columns, String selection, String[] args, String order) {
try {
File f = file(uri);
if (columns == null) columns = new String[] {
OpenableColumns.DISPLAY_NAME,
OpenableColumns.SIZE,
};
MatrixCursor cursor = new MatrixCursor(columns);
Object[] row = new Object[columns.length];
for (int i = 0; i < columns.length; i++) row[i] = OpenableColumns.SIZE.equals(columns[i])
? f.length()
: f.getName();
cursor.addRow(row);
return cursor;
} catch (IOException e) {
return null;
}
}
public Uri insert(Uri uri, ContentValues values) {
throw new UnsupportedOperationException();
}
public int delete(Uri uri, String selection, String[] args) {
throw new UnsupportedOperationException();
}
public int update(Uri uri, ContentValues values, String selection, String[] args) {
throw new UnsupportedOperationException();
}
}

View file

@ -0,0 +1,161 @@
package org.vulpes_os.preview;
import android.content.Context;
import android.webkit.MimeTypeMap;
import java.io.*;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.zip.*;
/** Read-only APK assets, loopback only. Never serves profile data or native RPC. */
final class GaiaServer implements Closeable {
static final int PORT = 18765;
private final ZipFile zip;
private final ServerSocket socket;
private final ExecutorService workers = Executors.newFixedThreadPool(4);
GaiaServer(Context context) throws IOException {
File archive = new File(context.getCacheDir(), "gaia.zip");
// Atomic refresh on every process start; the APK is the source of truth.
File temp = new File(context.getCacheDir(), "gaia.zip.tmp");
try (
InputStream in = context.getAssets().open("gaia.zip");
OutputStream out = new FileOutputStream(temp)
) {
copy(in, out);
}
Files.move(temp.toPath(), archive.toPath(), StandardCopyOption.REPLACE_EXISTING);
zip = new ZipFile(archive);
socket = new ServerSocket();
socket.setReuseAddress(true);
socket.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), PORT));
Thread accept = new Thread(
() -> {
while (!socket.isClosed()) try {
Socket client = socket.accept();
workers.submit(() -> serve(client));
} catch (IOException e) {
if (!socket.isClosed()) android.util.Log.e("Vulpes", "HTTP accept", e);
}
},
"Vulpes-assets"
);
accept.setDaemon(true);
accept.start();
}
private static void copy(InputStream in, OutputStream out) throws IOException {
byte[] buf = new byte[32768];
int n;
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
}
private static String line(InputStream in) throws IOException {
ByteArrayOutputStream b = new ByteArrayOutputStream();
int c;
while ((c = in.read()) != -1 && c != '\n') {
if (b.size() > 8192) throw new IOException("Header too long");
if (c != '\r') b.write(c);
}
return b.toString("UTF-8");
}
private void serve(Socket client) {
try (Socket c = client) {
c.setSoTimeout(5000);
InputStream in = c.getInputStream();
OutputStream out = c.getOutputStream();
String[] start = line(in).split(" ");
String host = "",
origin = "";
int total = 0;
for (String h; (h = line(in)).length() > 0; ) {
total += h.length();
if (total > 32768) throw new IOException("Headers too large");
int i = h.indexOf(':');
if (i > 0) {
String key = h.substring(0, i);
String value = h.substring(i + 1).trim();
if (key.equalsIgnoreCase("Host")) host = value.toLowerCase(Locale.ROOT);
if (key.equalsIgnoreCase("Origin")) origin = value;
}
}
if (
start.length < 2 ||
!start[0].equals("GET") ||
!host.matches("[a-z0-9_-]+\\.localhost:" + PORT)
) {
error(out, 403);
return;
}
String app = host.substring(0, host.indexOf('.'));
if (app.equals("theme")) app = "default_theme";
if (zip.getEntry(app + "/manifest.webapp") == null) {
error(out, 404);
return;
}
String path = URLDecoder.decode(start[1].split("\\?", 2)[0].replace("+", "%2B"), "UTF-8");
if (
!path.startsWith("/") ||
path.contains("\\") ||
path.indexOf('\0') >= 0 ||
Arrays.asList(path.split("/")).contains("..")
) {
error(out, 400);
return;
}
if (path.endsWith("/")) path += "index.html";
String key = path.startsWith("/_vulpes/") ? path.substring(1) : app + path;
ZipEntry entry = zip.getEntry(key);
if (entry == null && path.startsWith("/shared/")) entry = zip.getEntry(
"_shared/" + path.substring(8)
);
if (entry == null || entry.isDirectory()) {
error(out, 404);
return;
}
String ext = path.substring(path.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
String mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
if (ext.equals("js") || ext.equals("mjs")) mime = "text/javascript";
if (ext.equals("webapp") || ext.equals("json")) mime = "application/json";
if (ext.equals("properties")) mime = "text/plain";
if (mime == null) mime = "application/octet-stream";
String headers =
"HTTP/1.1 200 OK\r\nContent-Type: " +
mime +
"\r\nContent-Length: " +
entry.getSize() +
"\r\nConnection: close\r\nX-Content-Type-Options: nosniff\r\nCache-Control: no-cache\r\nContent-Security-Policy: script-src 'self' 'unsafe-eval'; object-src 'none'; base-uri 'self'; worker-src 'self' blob:; frame-ancestors http://*.localhost:" +
PORT +
"\r\n";
if (
origin.matches("http://[a-z0-9_-]+\\.localhost:" + PORT) &&
zip.getEntry(new URI(origin).getHost().split("\\.")[0] + "/manifest.webapp") != null
) headers += "Access-Control-Allow-Origin: " + origin + "\r\nVary: Origin\r\n";
out.write((headers + "\r\n").getBytes(StandardCharsets.US_ASCII));
try (InputStream body = zip.getInputStream(entry)) {
copy(body, out);
}
} catch (Exception e) {
android.util.Log.d("Vulpes", "HTTP closed: " + e.getMessage());
}
}
private static void error(OutputStream out, int code) throws IOException {
out.write(
("HTTP/1.1 " + code + " Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").getBytes(
StandardCharsets.US_ASCII
)
);
}
public void close() throws IOException {
socket.close();
workers.shutdownNow();
zip.close();
}
}

View file

@ -0,0 +1,647 @@
package org.vulpes_os.preview;
import android.app.*;
import android.content.*;
import android.content.pm.PackageManager;
import android.graphics.Color;
import android.net.Uri;
import android.os.*;
import android.text.InputType;
import android.view.*;
import android.widget.*;
import java.net.URI;
import java.util.ArrayList;
import org.mozilla.geckoview.*;
public final class MainActivity extends Activity {
private VulpesApplication app;
private GeckoSession gaia, web;
private GeckoView gaiaView, webView;
private LinearLayout root, webPanel;
private TextView notice;
private EditText address;
private Button home;
private boolean webBack;
private boolean resumed, recoverGaia, recoverWeb;
private long lastCrash;
private int crashCount;
private final java.util.Map<Integer, java.util.function.Consumer<Boolean>> permissionResults =
new java.util.HashMap<>();
private final java.util.List<java.util.function.Consumer<Boolean>> resumedCameraPermissions =
new java.util.ArrayList<>();
private int nextPermission = 500;
private GeckoResult<GeckoSession.PromptDelegate.PromptResponse> fileResult;
private GeckoSession.PromptDelegate.FilePrompt filePrompt;
@Override
public void onCreate(Bundle state) {
super.onCreate(state);
app = (VulpesApplication) getApplication();
app.activity = this;
root = new LinearLayout(this);
root.setOrientation(LinearLayout.VERTICAL);
root.setBackgroundColor(Color.rgb(18, 35, 50));
root.setOnApplyWindowInsetsListener((v, insets) -> {
if (Build.VERSION.SDK_INT >= 30) {
android.graphics.Insets bars = insets.getInsets(
WindowInsets.Type.systemBars() |
WindowInsets.Type.ime() |
WindowInsets.Type.displayCutout()
);
v.setPadding(bars.left, bars.top, bars.right, bars.bottom);
} else v.setPadding(
insets.getSystemWindowInsetLeft(),
insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(),
insets.getSystemWindowInsetBottom()
);
return insets;
});
setContentView(root);
// Respect Android's screen timeout; Gaia must not keep the phone awake indefinitely.
float brightness = app.getSharedPreferences("android", 0).getFloat("brightness", -1);
if (brightness >= 0) {
WindowManager.LayoutParams attrs = getWindow().getAttributes();
attrs.screenBrightness = Math.max(.02f, brightness);
getWindow().setAttributes(attrs);
}
notice = new TextView(this);
notice.setText("VULPES\nChargement de Firefox OS…");
notice.setTextColor(Color.WHITE);
notice.setGravity(Gravity.CENTER);
root.addView(notice, new LinearLayout.LayoutParams(-1, dp(90)));
gaiaView = new GeckoView(this);
root.addView(gaiaView, new LinearLayout.LayoutParams(-1, 0, 1));
webPanel = new LinearLayout(this);
webPanel.setOrientation(LinearLayout.VERTICAL);
webPanel.setBackgroundColor(Color.WHITE);
webPanel.setVisibility(View.GONE);
root.addView(webPanel, new LinearLayout.LayoutParams(-1, 0, 1));
LinearLayout nav = new LinearLayout(this);
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());
nav.addView(back, new LinearLayout.LayoutParams(dp(55), -1));
address = new EditText(this);
address.setSingleLine(true);
address.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI);
address.setTextSize(14);
nav.addView(address, new LinearLayout.LayoutParams(0, -1, 1));
address.setOnEditorActionListener((v, id, event) -> {
String url = address.getText().toString().trim();
openBrowser(url.contains("://") ? url : "https://" + url);
((android.view.inputmethod.InputMethodManager) getSystemService(
INPUT_METHOD_SERVICE
)).hideSoftInputFromWindow(address.getWindowToken(), 0);
return true;
});
webView = new GeckoView(this);
webPanel.addView(webView, new LinearLayout.LayoutParams(-1, 0, 1));
home = new Button(this);
home.setText("◯");
home.setTextColor(Color.WHITE);
home.setBackgroundColor(Color.rgb(18, 35, 50));
home.setContentDescription("Accueil Vulpes. Appui long : applications ouvertes.");
root.addView(home, new LinearLayout.LayoutParams(-1, dp(44)));
home.setOnClickListener(v -> {
closeBrowser();
app.home(false);
});
home.setOnLongClickListener(v -> {
closeBrowser();
app.home(true);
return true;
});
setHomeSurface(app.homeVisible);
if (Build.VERSION.SDK_INT >= 33) getOnBackInvokedDispatcher().registerOnBackInvokedCallback(
0,
this::back
);
try {
app.prepare();
boolean retained = app.gaiaSession != null;
gaia = retained
? app.gaiaSession
: new GeckoSession(
new GeckoSessionSettings.Builder()
.displayMode(GeckoSessionSettings.DISPLAY_MODE_STANDALONE)
.build()
);
app.gaiaSession = gaia;
gaia.setContentDelegate(content);
gaia.setPromptDelegate(prompts);
gaia.setPermissionDelegate(permissions);
gaia.setNavigationDelegate(
new GeckoSession.NavigationDelegate() {
@Override
public GeckoResult<AllowOrDeny> onLoadRequest(GeckoSession s, LoadRequest r) {
if (r.uri.startsWith("http://") && isGaia(r.uri)) return GeckoResult.fromValue(
AllowOrDeny.ALLOW
);
if (r.uri.equals("about:blank")) return GeckoResult.fromValue(AllowOrDeny.ALLOW);
openBrowser(r.uri);
return GeckoResult.fromValue(AllowOrDeny.DENY);
}
}
);
if (!gaia.isOpen()) gaia.open(app.runtime);
gaiaView.setSession(gaia);
app.extension.accept(
ext -> {
notice.setVisibility(View.GONE);
if (isDestroyed()) return;
if (!retained) gaia.loadUri(gaiaUrl());
app.notifications.click(getIntent());
},
e -> {
notice.setText("Impossible de démarrer Vulpes : " + e.getMessage());
}
);
} catch (Exception e) {
notice.setText("Impossible de démarrer Vulpes : " + e.getMessage());
android.util.Log.e("Vulpes", "Startup", e);
}
}
private int dp(int value) {
return Math.round(value * getResources().getDisplayMetrics().density);
}
private boolean isGaia(String url) {
try {
URI u = new URI(url);
return (
u.getHost() != null && u.getHost().endsWith(".localhost") && u.getPort() == GaiaServer.PORT
);
} catch (Exception e) {
return false;
}
}
void openBrowser(String url) {
try {
URI uri = new URI(url);
if (
!("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())) || isGaia(url)
) return;
} catch (Exception e) {
return;
}
if (web == null) {
web = new GeckoSession();
web.setContentDelegate(content);
web.setPromptDelegate(prompts);
web.setPermissionDelegate(permissions);
web.setNavigationDelegate(
new GeckoSession.NavigationDelegate() {
@Override
public void onCanGoBack(GeckoSession s, boolean can) {
webBack = can;
}
@Override
public void onLocationChange(
GeckoSession s,
String url,
java.util.List<GeckoSession.PermissionDelegate.ContentPermission> permissions,
Boolean gesture
) {
address.setText(url);
}
@Override
public GeckoResult<AllowOrDeny> onLoadRequest(GeckoSession s, LoadRequest r) {
return GeckoResult.fromValue(
(r.uri.startsWith("https://") || r.uri.startsWith("http://")) && !isGaia(r.uri)
? AllowOrDeny.ALLOW
: AllowOrDeny.DENY
);
}
@Override
public GeckoResult<GeckoSession> onNewSession(GeckoSession s, String uri) {
openBrowser(uri);
return GeckoResult.fromValue(null);
}
}
);
web.open(app.runtime);
webView.setSession(web);
}
gaiaView.setVisibility(View.GONE);
webPanel.setVisibility(View.VISIBLE);
setHomeSurface(false);
address.setText(url);
web.loadUri(url);
syncActive();
}
void setHomeSurface(boolean visible) {
runOnUiThread(() -> {
boolean overlay = visible && webPanel.getVisibility() != View.VISIBLE;
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams) home.getLayoutParams();
boolean landscape = getResources().getConfiguration().orientation ==
android.content.res.Configuration.ORIENTATION_LANDSCAPE;
root.setOrientation(landscape ? LinearLayout.HORIZONTAL : LinearLayout.VERTICAL);
gaiaView.setLayoutParams(landscape ? new LinearLayout.LayoutParams(0, -1, 1) :
new LinearLayout.LayoutParams(-1, 0, 1));
webPanel.setLayoutParams(landscape ? new LinearLayout.LayoutParams(0, -1, 1) :
new LinearLayout.LayoutParams(-1, 0, 1));
params.width = landscape ? dp(44) : -1;
params.height = landscape ? -1 : dp(44);
// Only Home overlays Gaia wallpaper. Apps keep their full usable area.
params.topMargin = !landscape && overlay ? -dp(44) : 0;
params.leftMargin = landscape && overlay ? -dp(44) : 0;
home.setLayoutParams(params);
if (overlay && app.homeAtBottom) home.setBackgroundColor(Color.TRANSPARENT);
else if (overlay) home.setBackground(
new android.graphics.drawable.GradientDrawable(
landscape ? android.graphics.drawable.GradientDrawable.Orientation.RIGHT_LEFT :
android.graphics.drawable.GradientDrawable.Orientation.BOTTOM_TOP,
new int[] { Color.argb(204, 0, 0, 0), Color.TRANSPARENT }
)
);
else home.setBackgroundColor(Color.rgb(18, 35, 50));
});
}
@Override
public void onConfigurationChanged(android.content.res.Configuration config) {
super.onConfigurationChanged(config);
setHomeSurface(app.homeVisible);
}
private void closeBrowser() {
webPanel.setVisibility(View.GONE);
gaiaView.setVisibility(View.VISIBLE);
setHomeSurface(app.homeVisible);
syncActive();
}
private void back() {
if (webPanel.getVisibility() == View.VISIBLE) {
if (webBack) web.goBack();
else closeBrowser();
} else app.home(false);
}
@Override
public void onBackPressed() {
back();
}
private String gaiaUrl() {
return "http://system.localhost:" + GaiaServer.PORT + "/index.html";
}
private void syncActive() {
boolean browsing = webPanel != null && webPanel.getVisibility() == View.VISIBLE;
if (gaia != null && gaia.isOpen()) gaia.setActive(resumed && !browsing);
if (web != null && web.isOpen()) web.setActive(resumed && browsing);
}
private void recover(GeckoSession session, boolean crashed) {
if (isDestroyed()) return;
if (crashed) {
long now = SystemClock.elapsedRealtime();
crashCount = now - lastCrash < 30000 ? crashCount + 1 : 1;
lastCrash = now;
if (crashCount > 2) {
notice.setText("Vulpes s’est arrêté. Touchez ici pour réessayer.");
notice.setVisibility(View.VISIBLE);
notice.setOnClickListener(v -> {
crashCount = 0;
recover(session, false);
});
return;
}
}
if (!resumed) {
if (session == gaia) recoverGaia = true;
else recoverWeb = true;
return;
}
try {
if (!session.isOpen()) session.open(app.runtime);
session.loadUri(session == gaia ? gaiaUrl() : address.getText().toString());
syncActive();
android.util.Log.i(
"Vulpes",
"Recovered " + (session == gaia ? "Gaia" : "browser") + " session"
);
} catch (Exception e) {
notice.setText("Reprise impossible : " + e.getMessage());
notice.setVisibility(View.VISIBLE);
}
}
private final GeckoSession.ContentDelegate content = new GeckoSession.ContentDelegate() {
public void onCrash(GeckoSession session) {
recover(session, true);
}
public void onKill(GeckoSession session) {
recover(session, false);
}
public void onFirstComposite(GeckoSession session) {
if (session == gaia) notice.setVisibility(View.GONE);
}
};
@Override
public void onResume() {
super.onResume();
resumed = true;
app.activity = this;
if (recoverGaia) {
recoverGaia = false;
recover(gaia, false);
}
if (recoverWeb && web != null) {
recoverWeb = false;
recover(web, false);
}
syncActive();
// Android delivers permission results before onResume. Start the sensor only
// after GeckoView has been made active again.
java.util.List<java.util.function.Consumer<Boolean>> grants = new java.util.ArrayList<>(
resumedCameraPermissions
);
resumedCameraPermissions.clear();
for (java.util.function.Consumer<Boolean> grant : grants) grant.accept(true);
gaiaView.requestLayout();
app.resumed();
}
@Override
public void onPause() {
resumed = false;
syncActive();
super.onPause();
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
if (intent.hasCategory(Intent.CATEGORY_HOME)) { closeBrowser(); app.home(false); }
if ("org.vulpes_os.preview.LOCAL_NOTIFICATION".equals(intent.getAction())) closeBrowser();
if (app.notifications != null) app.notifications.click(intent);
}
@Override
public void onDestroy() {
gaiaView.releaseSession();
webView.releaseSession();
if (gaia != null) {
gaia.setContentDelegate(null);
gaia.setNavigationDelegate(null);
gaia.setPromptDelegate(null);
gaia.setPermissionDelegate(null);
if (isFinishing()) {
gaia.close();
app.gaiaSession = null;
}
}
if (web != null) web.close();
if (fileResult != null) {
fileResult.complete(filePrompt.dismiss());
fileResult = null;
}
for (java.util.function.Consumer<Boolean> callback : permissionResults.values())
callback.accept(false);
permissionResults.clear();
for (java.util.function.Consumer<Boolean> grant : resumedCameraPermissions) grant.accept(false);
resumedCameraPermissions.clear();
if (app.activity == this) app.activity = null;
super.onDestroy();
}
void notificationPermission(java.util.function.Consumer<Boolean> callback) {
if (!resumed) {
callback.accept(false);
return;
}
permission(
Build.VERSION.SDK_INT >= 33
? new String[] { android.Manifest.permission.POST_NOTIFICATIONS }
: new String[0],
callback
);
}
private void finishPermission(
String[] names,
boolean granted,
java.util.function.Consumer<Boolean> callback
) {
if (
granted &&
!resumed &&
java.util.Arrays.asList(names).contains(android.Manifest.permission.CAMERA)
) resumedCameraPermissions.add(callback);
else callback.accept(granted);
}
private void permission(String[] names, java.util.function.Consumer<Boolean> callback) {
boolean granted = true;
for (String name : names)
granted &= checkSelfPermission(name) == PackageManager.PERMISSION_GRANTED;
if (granted) {
finishPermission(names, true, callback);
return;
}
int code = nextPermission++;
permissionResults.put(code, callback);
requestPermissions(names, code);
}
@Override
public void onRequestPermissionsResult(int code, String[] names, int[] grants) {
super.onRequestPermissionsResult(code, names, grants);
java.util.function.Consumer<Boolean> callback = permissionResults.remove(code);
if (callback != null) {
boolean granted = grants.length > 0;
// Approximate location is a valid choice; do not require fine location as well.
if (
java.util.Arrays.asList(names).contains(android.Manifest.permission.ACCESS_COARSE_LOCATION)
) granted =
checkSelfPermission(android.Manifest.permission.ACCESS_COARSE_LOCATION) ==
PackageManager.PERMISSION_GRANTED;
else for (int grant : grants) granted &= grant == PackageManager.PERMISSION_GRANTED;
finishPermission(names, granted, callback);
}
}
@Override
protected void onActivityResult(int code, int status, Intent intent) {
super.onActivityResult(code, status, intent);
if (code == 420 && fileResult != null) {
GeckoResult<GeckoSession.PromptDelegate.PromptResponse> result = fileResult;
fileResult = null;
ArrayList<Uri> uris = new ArrayList<>();
if (status == RESULT_OK && intent != null) {
if (intent.getClipData() != null) for (
int i = 0;
i < intent.getClipData().getItemCount();
i++
) uris.add(intent.getClipData().getItemAt(i).getUri());
else if (intent.getData() != null) uris.add(intent.getData());
}
result.complete(
uris.isEmpty() ? filePrompt.dismiss() : filePrompt.confirm(this, uris.toArray(new Uri[0]))
);
filePrompt = null;
} else if (app.services != null) app.services.onResult(code, status, intent);
}
private final GeckoSession.PermissionDelegate permissions =
new GeckoSession.PermissionDelegate() {
@Override
public void onAndroidPermissionsRequest(
GeckoSession session,
String[] names,
Callback callback
) {
permission(names, granted -> {
if (granted) callback.grant();
else callback.reject();
});
}
@Override
public void onMediaPermissionRequest(
GeckoSession session,
String uri,
MediaSource[] video,
MediaSource[] audio,
MediaCallback callback
) {
Uri origin = Uri.parse(uri);
boolean camera =
session == gaia &&
"http".equals(origin.getScheme()) &&
"camera.localhost".equals(origin.getHost()) &&
origin.getPort() == 18765;
if (!camera || (audio != null && audio.length > 0) || video == null || video.length == 0) {
callback.reject();
return;
}
permission(new String[] { android.Manifest.permission.CAMERA }, granted -> {
if (granted) callback.grant(video[0], null);
else callback.reject();
});
}
@Override
public GeckoResult<Integer> onContentPermissionRequest(
GeckoSession session,
ContentPermission permission
) {
boolean local = session == gaia && isGaia(permission.uri);
if (
permission.permission == PERMISSION_DESKTOP_NOTIFICATION ||
permission.permission == PERMISSION_GEOLOCATION
) {
GeckoResult<Integer> result = new GeckoResult<>();
Runnable ask = () -> {
String[] names = permission.permission == PERMISSION_GEOLOCATION
? new String[] {
android.Manifest.permission.ACCESS_COARSE_LOCATION,
android.Manifest.permission.ACCESS_FINE_LOCATION,
}
: Build.VERSION.SDK_INT >= 33
? new String[] { android.Manifest.permission.POST_NOTIFICATIONS }
: new String[0];
permission(names, granted ->
result.complete(
granted ? ContentPermission.VALUE_ALLOW : ContentPermission.VALUE_DENY
)
);
};
new AlertDialog.Builder(MainActivity.this)
.setTitle("Autorisation Vulpes")
.setMessage(
permission.uri +
(permission.permission == PERMISSION_GEOLOCATION
? "\nAccéder à votre position ?"
: "\nAfficher des notifications Android ?")
)
.setPositiveButton("Autoriser", (d, w) -> ask.run())
.setNegativeButton("Refuser", (d, w) -> result.complete(ContentPermission.VALUE_DENY))
.setOnCancelListener(d -> result.complete(ContentPermission.VALUE_DENY))
.show();
return result;
}
boolean allow =
local &&
(permission.permission == PERMISSION_PERSISTENT_STORAGE ||
permission.permission == PERMISSION_AUTOPLAY_INAUDIBLE ||
permission.permission == PERMISSION_AUTOPLAY_AUDIBLE);
return GeckoResult.fromValue(
allow ? ContentPermission.VALUE_ALLOW : ContentPermission.VALUE_DENY
);
}
};
private final GeckoSession.PromptDelegate prompts = new GeckoSession.PromptDelegate() {
@Override
public GeckoResult<PromptResponse> onFilePrompt(GeckoSession session, FilePrompt prompt) {
if (fileResult != null) return GeckoResult.fromValue(prompt.dismiss());
GeckoResult<PromptResponse> result = new GeckoResult<>();
Intent pick = new Intent(Intent.ACTION_OPEN_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("*/*")
.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, prompt.type == FilePrompt.Type.MULTIPLE);
if (prompt.mimeTypes != null && prompt.mimeTypes.length > 0) pick.putExtra(
Intent.EXTRA_MIME_TYPES,
prompt.mimeTypes
);
try {
startActivityForResult(pick, 420);
fileResult = result;
filePrompt = prompt;
} catch (ActivityNotFoundException e) {
result.complete(prompt.dismiss());
}
return result;
}
@Override
public GeckoResult<PromptResponse> onAlertPrompt(GeckoSession s, AlertPrompt p) {
GeckoResult<PromptResponse> result = new GeckoResult<>();
new AlertDialog.Builder(MainActivity.this)
.setTitle(p.title)
.setMessage(p.message)
.setPositiveButton("OK", (d, w) -> result.complete(p.dismiss()))
.setOnCancelListener(d -> result.complete(p.dismiss()))
.show();
return result;
}
@Override
public GeckoResult<PromptResponse> onChoicePrompt(GeckoSession s, ChoicePrompt p) {
ArrayList<ChoicePrompt.Choice> choices = new ArrayList<>();
flatten(p.choices, choices);
String[] labels = new String[choices.size()];
for (int i = 0; i < labels.length; i++) labels[i] = choices.get(i).label;
GeckoResult<PromptResponse> result = new GeckoResult<>();
new AlertDialog.Builder(MainActivity.this)
.setTitle(p.title)
.setItems(labels, (d, n) -> result.complete(p.confirm(choices.get(n))))
.setOnCancelListener(d -> result.complete(p.dismiss()))
.show();
return result;
}
private void flatten(ChoicePrompt.Choice[] items, ArrayList<ChoicePrompt.Choice> out) {
for (ChoicePrompt.Choice c : items) {
if (c.items != null) flatten(c.items, out);
else if (!c.disabled && !c.separator) out.add(c);
}
}
};
}

View file

@ -0,0 +1,134 @@
package org.vulpes_os.preview;
import android.app.Application;
import android.util.Log;
import java.io.*;
import java.nio.charset.StandardCharsets;
import org.json.JSONObject;
import org.mozilla.geckoview.*;
public final class VulpesApplication extends Application {
GeckoRuntime runtime;
GaiaServer server;
WebExtension.Port port;
MainActivity activity;
GeckoResult<WebExtension> extension;
GeckoSession gaiaSession;
AndroidServices services;
AndroidNotifications notifications;
boolean homeVisible;
boolean homeAtBottom;
void prepare() throws IOException {
if (runtime != null) return;
server = new GaiaServer(this);
File config = new File(getFilesDir(), "geckoview.yaml");
try (FileOutputStream out = new FileOutputStream(config)) {
out.write(
("prefs:\n datareporting.policy.dataSubmissionEnabled: false\n toolkit.telemetry.enabled: false\n browser.tabs.warnOnClose: false\n" +
(android.os.Build.HARDWARE.equals("ranchu")
? " gfx.webrender.software: true\n"
: "")).getBytes(StandardCharsets.UTF_8)
);
}
runtime = GeckoRuntime.create(
this,
new GeckoRuntimeSettings.Builder()
.configFilePath(config.getAbsolutePath())
.consoleOutput(true)
.remoteDebuggingEnabled(
(getApplicationInfo().flags & android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
)
.locales(new String[] { "fr", "en-US" })
.automaticFontSizeAdjustment(false)
.fontInflation(false)
.build()
);
services = new AndroidServices(this);
notifications = new AndroidNotifications(this);
runtime.setWebNotificationDelegate(notifications);
extension = runtime
.getWebExtensionController()
.ensureBuiltIn("resource://android/assets/bridge/", "android-host@vulpes-os.org");
extension.accept(
ext ->
ext.setMessageDelegate(
new WebExtension.MessageDelegate() {
@Override
public void onConnect(WebExtension.Port p) {
port = p;
if (activity != null) notifications.click(activity.getIntent());
p.setDelegate(
new WebExtension.PortDelegate() {
@Override
public void onDisconnect(WebExtension.Port p) {
if (port == p) port = null;
}
}
);
}
@Override
public GeckoResult<Object> onMessage(
String app,
Object message,
WebExtension.MessageSender sender
) {
if (message instanceof JSONObject) {
if (sender.environmentType != WebExtension.MessageSender.ENV_TYPE_EXTENSION) {
return GeckoResult.fromException(new SecurityException("EXTENSION_REQUIRED"));
}
JSONObject json = (JSONObject) message;
if (json.optString("type").equals("homeScroll")) {
homeAtBottom = json.optBoolean("bottom");
if (activity != null) activity.setHomeSurface(homeVisible);
return GeckoResult.fromValue(null);
}
if (json.optString("type").equals("navigation")) {
homeVisible = json.optBoolean("home");
if (activity != null) activity.setHomeSurface(homeVisible);
return GeckoResult.fromValue(null);
}
if (json.optString("type").startsWith("android.")) return services
.request(json)
.map(value -> {
try {
return new JSONObject()
.put("result", value == null ? JSONObject.NULL : value)
.toString();
} catch (org.json.JSONException e) {
throw new IllegalArgumentException(e);
}
});
if (json.optString("type").equals("open") && activity != null) activity.openBrowser(
json.optString("url")
);
}
return GeckoResult.fromValue(null);
}
},
"vulpes"
),
error -> Log.e("Vulpes", "Extension failed", error)
);
}
void home(boolean held) {
if (port == null) return;
try {
port.postMessage(new JSONObject().put("type", held ? "holdhome" : "home"));
} catch (Exception e) {
Log.e("Vulpes", "Home", e);
}
}
void resumed() {
if (port == null) return;
try {
port.postMessage(new JSONObject().put("type", "android-resume"));
} catch (Exception e) {
Log.w("Vulpes", "Resume notification", e);
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/launcher_background" />
<foreground>
<inset android:inset="20%">
<bitmap android:src="@drawable/vulpes_logo" android:gravity="fill" android:filter="true" />
</inset>
</foreground>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Vulpes OS - Preview</string>
<color name="launcher_background">#10243A</color>
</resources>

26
android/build-apk.sh Executable file
View file

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
python3 tools/prepare-toolchain.py
export JAVA_HOME="$PWD/toolchain/jdk-21.0.12.1+1"
export ANDROID_HOME="$PWD/toolchain/sdk"
export GRADLE_USER_HOME="$PWD/toolchain/gradle-cache"
export PATH="$JAVA_HOME/bin:$PATH"
mkdir -p keys
if [[ ! -f keys/preview.keystore ]]; then
keytool -genkeypair -keystore keys/preview.keystore -storepass android -keypass android -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Vulpes Preview,O=Vulpes,C=FR"
chmod 600 keys/preview.keystore
fi
python3 tools/pack-assets.py "$@"
printf 'sdk.dir=%s\n' "$ANDROID_HOME" > local.properties
./toolchain/gradle-9.5.0/bin/gradle --no-daemon :app:assembleDebug
mkdir -p dist
vulpes_version=$(python3 -c 'import sys; sys.path.insert(0,"../tools"); from project import release_info; print(release_info()["version"])')
vulpes_apk="dist/vulpes-os-preview-${vulpes_version}.apk"
"$ANDROID_HOME/build-tools/37.0.0/zipalign" -f -P 16 4 app/build/outputs/apk/debug/app-debug.apk "$vulpes_apk"
"$ANDROID_HOME/build-tools/37.0.0/apksigner" sign --ks keys/preview.keystore --ks-pass pass:android --key-pass pass:android --ks-key-alias androiddebugkey "$vulpes_apk"
"$ANDROID_HOME/build-tools/37.0.0/apksigner" verify --verbose --print-certs "$vulpes_apk" | tee "dist/signature-${vulpes_version}.txt"
"$ANDROID_HOME/build-tools/37.0.0/zipalign" -c -P 16 -v 4 "$vulpes_apk" > dist/alignment.txt
(cd dist && sha256sum "vulpes-os-preview-${vulpes_version}.apk" > SHA256SUMS)
python3 ../tools/check-parity.py
printf '\nAPK : %s/%s\n' "$PWD" "$vulpes_apk"

1
android/build.gradle Normal file
View file

@ -0,0 +1 @@
plugins { id 'com.android.application' version '9.3.3' apply false }

View file

@ -0,0 +1 @@
<!doctype html><meta charset="utf-8"><script type="module" src="background.js"></script>

View file

@ -0,0 +1,438 @@
import { authorizePlatform } from './services/platform.mjs';
import { SettingsService } from './services/settings.mjs';
import { DataStoreService } from './services/datastores.mjs';
import { DataStoresIndexedDB } from './adapters/datastores-indexeddb.mjs';
import { Contacts } from './Contacts.mjs';
import { Messages } from './Messages.mjs';
import { Connections } from './Connections.mjs';
import { Alarms } from './Alarms.mjs';
const peers = new Set();
let apps, values, settings, dataStores, contacts, messages, connections, alarms, storage;
const identity = (peer) => peer.app;
function notify(peer, type, data) {
try {
peer.port.postMessage({ event: { type, data } });
} catch (_) {
peers.delete(peer);
}
}
function grants(app) {
const access = app.manifest.permissions?.settings?.access || '';
return [
access.includes('read') && 'settings.read',
access.includes('write') && 'settings.write',
].filter(Boolean);
}
function broadcast(type, data, target) {
for (const peer of peers) {
if (target && peer.app.id !== target) continue;
if (type === 'settingchange' && !grants(peer.app).includes('settings.read')) continue;
if (
type === 'contactchange' &&
!peer.app.manifest.permissions?.contacts?.access?.includes('read')
)
continue;
if (
type === 'datastorechange' &&
!dataStores.list(peer.app, data.name).some((s) => s.owner === data.owner)
)
continue;
if (
type === 'mediachange' &&
!peer.app.manifest.permissions?.['device-storage:' + data.type]?.access?.includes('read')
)
continue;
notify(peer, type, data);
}
}
function launch(target, entryPoint, path) {
path ||=
target.manifest.entry_points?.[entryPoint]?.launch_path ||
target.manifest.launch_path ||
'/index.html';
const url = new URL(path, target.origin);
if (url.origin !== target.origin) throw Error('INVALID_URL');
broadcast(
'launch',
{
manifestURL: target.manifestURL,
origin: target.origin,
url: url.href,
entryPoint,
timestamp: Date.now(),
},
'system',
);
}
const ready = (async () => {
apps = await (await fetch('registry.json')).json();
const defaults = await (await fetch('defaults.json')).json();
const saved = await browser.storage.local.get('settings');
values = { ...defaults, ...saved.settings };
if (!values['vulpes.gaia-restoration.v1']) {
values['lockscreen.enabled'] = true;
values['vulpes.gaia-restoration.v1'] = true;
await browser.storage.local.set({settings:values});
}
// Preview 1 serialized wallpaper Blobs as {}. Repair that one invalid value
// on upgrade, retaining all other user settings and valid custom wallpapers.
const wallpaper = values['wallpaper.image'];
if (wallpaper && typeof wallpaper === 'object' && !Object.keys(wallpaper).length) {
values['wallpaper.image'] = defaults['wallpaper.image'];
values['wallpaper.image.valid'] = false;
await browser.storage.local.set({ settings: values });
}
let writes = Promise.resolve();
settings = new SettingsService(
{
get: async (key) => values[key],
setMany(entries) {
const write = writes.then(async () => {
const next = { ...values, ...Object.fromEntries(entries) };
await browser.storage.local.set({ settings: next });
values = next;
for (const [key, value] of entries) broadcast('settingchange', { key, value });
});
writes = write.catch(() => {});
return write;
},
},
{ maxBytes: 32 * 1024 * 1024 },
); // Allows the bounded base64 image envelope on high-DPI Android screens.
storage = new DataStoresIndexedDB(indexedDB, IDBKeyRange, () => crypto.randomUUID());
contacts = new Contacts(storage, (data) => broadcast('contactchange', data));
await contacts.init();
messages = new Messages(storage);
dataStores = new DataStoreService(apps, storage, (data) => broadcast('datastorechange', data));
connections = new Connections(identity, notify);
alarms = new Alarms(
identity,
(id) => {
const app = apps.find((a) => a.id === id);
if (app) launch(app, undefined, app.manifest.messages?.find((m) => m.alarm)?.alarm);
},
{
load: async () => (await browser.storage.local.get('alarms')).alarms,
save: (state) => browser.storage.local.set({ alarms: state }),
},
(callback, delay) => setInterval(callback, delay),
);
await alarms.init();
console.info('VULPES Android services ready');
})();
// Java owns this port. Web pages have no access to native messaging.
let native;
let systemReady = false;
let pendingNotification;
function deliverNotification() {
if (!systemReady || !pendingNotification) return;
const data = pendingNotification;
pendingNotification = null;
const target = apps.find((a) => a.id === data.owner);
if (target) {
launch(target);
broadcast('notification-click', data, target.id);
}
}
function connectNative() {
native = browser.runtime.connectNative('vulpes');
native.onDisconnect.addListener(() => setTimeout(connectNative, 1000));
native.onMessage.addListener(async (message) => {
await ready;
if (['home', 'holdhome'].includes(message.type)) broadcast(message.type, {}, 'system');
if (message.type === 'android-resume') await refreshAndroid().catch(console.warn);
if (message.type === 'notification-click') {
pendingNotification = message.data;
deliverNotification();
}
});
}
connectNative();
async function android(type, params = {}) {
const reply = await browser.runtime.sendNativeMessage('vulpes', {
type: 'android.' + type,
params,
});
const result = typeof reply === 'string' ? JSON.parse(reply).result : reply;
if (result?.error) throw Error(result.error);
return result;
}
async function refreshAndroid() {
const snapshot = await android('snapshot');
for (const [key, value] of Object.entries(snapshot)) {
if (values[key] === value) continue;
values[key] = value;
broadcast('settingchange', { key, value });
}
return snapshot;
}
ready.then(refreshAndroid).catch(console.warn);
async function handle(peer, request) {
const app = peer.app;
if (
!request ||
request.version !== 1 ||
!Number.isSafeInteger(request.id) ||
request.id < 0 ||
typeof request.method !== 'string'
)
throw Error('INVALID_REQUEST');
const { params = {} } = request;
let method = request.method;
if (method.startsWith('platform.')) {
authorizePlatform(app, method.slice(9), params);
method = 'android.' + method.slice(9);
}
if (method === 'android.capabilities') {
const release = await (await fetch('release.json')).json();
const device = app.id === 'settings' ? await android('snapshot') : {};
return {
platform: 'android',
platformName: device['deviceinfo.firmware_revision'] || 'Android',
model: device['deviceinfo.product_model'],
adapter: { version: release.adapters?.android, device: 'android' },
version: release.version,
gaia: release.gaia,
gecko: release.androidEngine,
camera: true,
media: true,
contacts: true,
notifications: true,
dial: true,
sms: true,
alarms: true,
};
}
if (method === 'android.systemReady') {
if (app.id !== 'system') throw Error('PERMISSION_DENIED');
systemReady = true;
deliverNotification();
return null;
}
if (method === 'android.notification') {
if (!app.manifest.permissions?.['desktop-notification']) throw Error('PERMISSION_DENIED');
if (JSON.stringify(params).length > 65536) throw Error('QuotaExceededError');
return android('notification', { ...params, owner: app.id });
}
if (method === 'android.settings' || method === 'android.snapshot') {
if (!['settings', 'system', 'costcontrol'].includes(app.id)) throw Error('PERMISSION_DENIED');
return method === 'android.snapshot' ? refreshAndroid() : android('settings', params);
}
if (method === 'android.capture') {
if (app.id !== 'camera') throw Error('PERMISSION_DENIED');
const names = await android('capture');
for (const path of names)
broadcast('mediachange', { type: 'pictures', path, reason: 'modified' });
return names;
}
if (method === 'android.pick') {
const access = app.manifest.permissions?.['device-storage:' + params.type]?.access || '';
if (!access.includes('write')) throw Error('PERMISSION_DENIED');
const names = await android('pick', params);
for (const path of names)
broadcast('mediachange', { type: params.type, path, reason: 'modified' });
return names;
}
if (method === 'android.alarms') {
if (app.id !== 'clock') throw Error('PERMISSION_DENIED');
return android('alarms');
}
if (method === 'android.contact') {
if (!app.manifest.permissions?.contacts?.access?.includes('write'))
throw Error('PERMISSION_DENIED');
const contact = await android('contact');
return contacts.request(app, { operation: 'save', contact });
}
if (method === 'android.sms') {
if (app.id !== 'sms') throw Error('PERMISSION_DENIED');
return android('sms', params);
}
if (method === 'android.dial') {
if (app.id !== 'communications') throw Error('PERMISSION_DENIED');
return android('dial', params);
}
if (method === 'settings.set' && params.values && typeof params.values === 'object') {
const validation = await new SettingsService(
{ setMany: async () => {} },
{ maxBytes: 32 * 1024 * 1024 },
).bindSession(
app.id,
grants(app),
)(request);
if (validation.error) throw Error(validation.error.code);
for (const [key, value] of Object.entries(params.values)) {
if (app.id === 'settings' && ['screen.brightness', 'audio.volume.content'].includes(key))
await android('set', { key, value });
const panel = {
'wifi.enabled': 'wifi',
'bluetooth.enabled': 'bluetooth',
'airplaneMode.enabled': 'airplane-mode',
'geolocation.enabled': 'geolocation',
'ril.data.enabled': 'data',
}[key];
if (panel) {
// UI clicks open the native panel. Gaia startup writes must not open Android activities.
// Android controls these values; never persist a fictitious toggle state.
delete params.values[key];
}
}
if (!Object.keys(params.values).length) return null;
}
if (method === 'settings.snapshot') {
if (!grants(app).includes('settings.read')) throw Error('PERMISSION_DENIED');
return { ...values };
}
if (method.startsWith('settings.')) {
const reply = await settings.bindSession(app.id, grants(app))(request);
if (reply.error) throw Error(reply.error.code);
return reply.result;
}
if (method === 'apps.self') {
console.info('VULPES app ready', app.id);
return app;
}
const manage =
app.manifest.permissions?.['webapps-manage'] ||
app.manifest.permissions?.['homescreen-webapps-manage'];
if (method === 'apps.list') {
if (!manage) throw Error('PERMISSION_DENIED');
return apps;
}
if (method === 'apps.launch') {
const target = apps.find((a) => a.manifestURL === params.manifestURL);
if (!target || (!manage && app.id !== target.id)) throw Error('PERMISSION_DENIED');
launch(target, params.entryPoint);
return null;
}
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 === 'alarms.request') return alarms.request(peer, app, params);
if (method === 'iac.subscribe') {
connections.subscribe(peer);
return null;
}
if (method === 'iac.connect') return connections.connect(peer, params.keyword, apps);
if (method === 'iac.send') return connections.send(peer, params);
if (method === 'iac.close') {
connections.close(peer, params.id);
return null;
}
if (method === 'activities.start') {
if (params.name === 'record' && params.data?.type === 'photos') {
if (app.id === 'gallery') {
const camera = apps.find((a) => a.id === 'camera');
if (!camera) throw Error('ACTIVITY_NOT_SUPPORTED');
launch(camera);
return null;
}
if (!app.manifest.permissions?.['device-storage:pictures']?.access?.includes('write'))
throw Error('PERMISSION_DENIED');
const names = await android('capture');
for (const path of names)
broadcast('mediachange', { type: 'pictures', path, reason: 'modified' });
return { filenames: names };
}
if (params.name !== 'view' || params.data?.type !== 'url')
throw Error('ACTIVITY_NOT_SUPPORTED');
return openWeb(params.data.url);
}
if (method === 'views.request') {
if (!app.manifest.permissions?.browser && !app.manifest.permissions?.['embed-apps'])
throw Error('PERMISSION_DENIED');
if (params.operation === 'open') {
await openWeb(params.url);
notify(peer, 'view', {
contextId: params.contextId,
type: 'loadend',
detail: { backgroundColor: 'white' },
});
return null;
}
if (['geometry', 'cancelTouch', 'close'].includes(params.operation)) return null;
if (['canGoBack', 'canGoForward'].includes(params.operation)) return false;
throw Error('NotSupportedError');
}
if (method === 'desktop.idle') {
if (!app.manifest.permissions?.idle) throw Error('PERMISSION_DENIED');
return null;
}
if (method === 'desktop.homeScroll') {
if (app.id !== 'homescreen' || typeof params?.bottom !== 'boolean') throw Error('PERMISSION_DENIED');
return browser.runtime.sendNativeMessage('vulpes', { type: 'homeScroll', bottom: params.bottom });
}
if (method === 'desktop.navigation') {
if (app.id !== 'system' || typeof params?.home !== 'boolean') throw Error('PERMISSION_DENIED');
return browser.runtime.sendNativeMessage('vulpes', { type: 'navigation', home: params.home });
}
if (method === 'desktop.screen') {
if (!app.manifest.permissions?.power) throw Error('PERMISSION_DENIED');
return null;
}
if (method === 'desktop.wakelock') return null; // Android Activity owns the visible screen.
if (method === 'media.request') return media(app, params);
throw Error('METHOD_NOT_SUPPORTED');
}
async function openWeb(url) {
const parsed = new URL(url);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.hostname.endsWith('.localhost'))
throw Error('URL_SCHEME_DENIED');
return browser.runtime.sendNativeMessage('vulpes', { type: 'open', url: parsed.href });
}
async function media(app, p) {
const access = app.manifest.permissions?.['device-storage:' + p.type]?.access || '';
if (
!['pictures', 'videos', 'music'].includes(p.type) ||
!access.includes(['add', 'delete'].includes(p.operation) ? 'write' : 'read')
)
throw Error('PERMISSION_DENIED');
const result = await android('media', p);
if (['add', 'delete'].includes(p.operation))
broadcast('mediachange', {
type: p.type,
path: result,
reason: p.operation === 'add' ? 'modified' : 'deleted',
});
return result;
}
browser.runtime.onConnect.addListener((port) => {
if (port.name !== 'gaia') return;
let peer;
const attached = ready.then(() => {
const url = new URL(port.sender.url);
const app = apps.find((a) => a.origin === url.origin);
if (!app) throw Error('PERMISSION_DENIED');
peer = {
port,
app,
manager: { documentURI: { spec: url.href } },
sendAsyncMessage: (_, event) => notify(peer, event.type, event.data),
};
peers.add(peer);
});
port.onMessage.addListener(async (message) => {
let reply;
try {
await attached;
reply = { result: await handle(peer, message.request) };
} catch (e) {
reply = { error: { code: e.message || 'HOST_ERROR' } };
console.warn('VULPES RPC', message.request?.method, e.message);
}
try {
port.postMessage({ id: message.id, reply });
} catch (_) {}
});
port.onDisconnect.addListener(() => {
attached
.then(() => {
peers.delete(peer);
if (peer.app.id === 'system' && ![...peers].some((p) => p.app.id === 'system'))
systemReady = false;
connections.cleanup(peer);
alarms.cleanup(peer);
})
.catch(() => {});
});
});

View file

@ -0,0 +1,76 @@
/* Extension world: the browser provides origin/tab/frame identity, never Gaia. */
(() => {
if (location.port !== '18765') return;
const win = window.wrappedJSObject;
console.info('VULPES Android bridge', location.origin);
let port;
let reconnect;
function connect() {
port = browser.runtime.connect({ name: 'gaia' });
port.onMessage.addListener(onMessage);
port.onDisconnect.addListener(onDisconnect);
}
const pending = new Map();
let seq = 0;
function request(data) {
return new window.Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
try {
port.postMessage({ id, request: JSON.parse(JSON.stringify(data)) });
} catch (error) {
pending.delete(id);
reject(new window.Error('VULPES_HOST_CLOSED'));
}
});
}
function onMessage(message) {
if (message.event) {
win.dispatchEvent(
new window.CustomEvent('vulpes-service-event', { detail: cloneInto(message.event, win) }),
);
return;
}
const callback = pending.get(message.id);
if (!callback) return;
pending.delete(message.id);
callback.resolve(cloneInto(message.reply, win));
}
function onDisconnect() {
for (const callback of pending.values())
callback.reject(new window.Error('VULPES_HOST_CLOSED'));
pending.clear();
clearTimeout(reconnect);
reconnect = setTimeout(() => {
try {
connect();
} catch (_) {
onDisconnect();
}
}, 1000);
}
connect();
addEventListener('unload', () => clearTimeout(reconnect));
exportFunction(request, win.navigator, { defineAs: 'vulpesRequest' });
const frames = new Map();
let nextFrame = 0;
exportFunction(
(frame, data) => {
if (frame.ownerDocument !== window.document || frame.localName !== 'iframe')
throw new window.Error('INVALID_VIEW');
let id = [...frames].find(([, f]) => f === frame)?.[0];
if (!id) {
id = ++nextFrame;
frames.set(id, frame);
}
const params = JSON.parse(JSON.stringify(data));
params.contextId = id;
return request({ version: 1, id: 0, method: 'views.request', params });
},
win.navigator,
{ defineAs: 'vulpesFrameRequest', allowCrossOriginArguments: true },
);
exportFunction((id) => frames.get(id) || null, win.navigator, {
defineAs: 'vulpesFrameForContext',
});
})();

View file

@ -0,0 +1 @@
{"manifest_version":2,"name":"Vulpes Android host","version":"1.1","browser_specific_settings":{"gecko":{"id":"android-host@vulpes-os.org"}},"permissions":["storage","unlimitedStorage","nativeMessaging","geckoViewAddons","http://*.localhost/*"],"background":{"page":"background.html","persistent":true},"content_scripts":[{"matches":["http://*.localhost/*"],"js":["content.js"],"all_frames":true,"run_at":"document_start"}]}

View file

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8
org.gradle.workers.max=4
android.useAndroidX=true

7
android/settings.gradle Normal file
View file

@ -0,0 +1,7 @@
pluginManagement { repositories { google(); mavenCentral(); gradlePluginPortal() } }
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { google(); mavenCentral(); maven { url = uri('https://maven.mozilla.org/maven2/') } }
}
rootProject.name = 'VulpesPreview'
include ':app'

View file

@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import vm from 'node:vm';
import { SettingsService } from '../../services/settings.mjs';
const context = vm.createContext({ window: {}, Blob, Uint8Array, btoa, atob });
vm.runInContext(
await readFile(new URL('../ui/settings-codec.js', import.meta.url), 'utf8'),
context,
);
const { encode, decode } = context.window.VulpesAndroidSettingsCodec;
// A binary wallpaper larger than the original 1 MiB JSON ceiling, including
// every byte value. Exercise the actual service, JSON storage and observer path.
const pixels = Uint8Array.from({ length: 1200000 }, (_, i) => i % 256);
const image = new Blob([pixels], { type: 'image/png' });
let stored;
const backend = {
get: async (key) => stored[key],
setMany: async (entries) => {
stored = JSON.parse(JSON.stringify(Object.fromEntries(entries)));
},
};
const payload = JSON.parse(
JSON.stringify(await encode({ 'wallpaper.image': image, 'wallpaper.image.valid': true })),
);
const request = { version: 1, id: 1, method: 'settings.set', params: { values: payload } };
const service = new SettingsService(backend, { maxBytes: 32 * 1024 * 1024 });
const session = service.bindSession('system', ['settings.read', 'settings.write']);
assert.equal((await session(request)).result.committed, true);
const reply = await session({
version: 1,
id: 2,
method: 'settings.get',
params: { key: 'wallpaper.image' },
});
for (const value of [
decode(reply.result).value,
decode(stored)['wallpaper.image'],
decode(stored['wallpaper.image']),
]) {
assert.ok(value instanceof Blob);
assert.equal(value.type, image.type);
assert.equal(value.size, image.size); // Gaia's savedBlobSize loop guard now works.
assert.deepEqual(new Uint8Array(await value.arrayBuffer()), pixels);
}
assert.equal(
(await service.bindSession('unprivileged', [])(request)).error.code,
'PERMISSION_DENIED',
);
assert.equal(
(await new SettingsService(backend).bindSession('desktop', ['settings.write'])(request)).error
.code,
'VALUE_TOO_LARGE',
);
assert.equal(
JSON.stringify(
decode(await encode({ language: 'fr', enabled: true, unset: null, list: [1, 'x'] })),
),
JSON.stringify({ language: 'fr', enabled: true, unset: null, list: [1, 'x'] }),
);
await assert.rejects(
encode(new Blob([new Uint8Array(16 * 1024 * 1024 + 1)])),
/SETTING_BLOB_TOO_LARGE/,
);
console.log(
'PASS: binary settings survive JSON persistence, reads and observers; limits and permissions retained.',
);

View file

@ -0,0 +1,29 @@
{
"jdk": "Temurin 21.0.12.1+1",
"gradle": "9.5.0",
"androidGradlePlugin": "9.3.3",
"geckoview": "156.0.20260921121718",
"sdk": [
"platforms;android-37.1",
"build-tools;37.0.0"
],
"archives": {
"jdk.tar.gz": {
"sha256": "ce79869e1307ed8ee1e2baa86a412b1eb5b75d10a01006d788a6f968bcfaee94",
"url": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.12.1%2B1/OpenJDK21U-jdk_x64_linux_hotspot_21.0.12.1_1.tar.gz"
},
"gradle-9.5.zip": {
"sha256": "553c78f50dafcd54d65b9a444649057857469edf836431389695608536d6b746",
"url": "https://services.gradle.org/distributions/gradle-9.5.0-bin.zip"
},
"geckoview.aar": {
"sha256": "42f80fcccbbe72c17f67bc7be3044cae097e4cb61a9084b406ec935f66e8edca",
"url": "https://maven.mozilla.org/maven2/org/mozilla/geckoview/geckoview/156.0.20260921121718/geckoview-156.0.20260921121718.aar"
},
"commandlinetools.zip": {
"url": "https://dl.google.com/android/repository/commandlinetools-linux-13114758_latest.zip",
"sha1": "5fdcc763663eefb86a5b8879697aa6088b041e70",
"checksumSource": "https://dl.google.com/android/repository/repository2-1.xml"
}
}
}

View file

@ -0,0 +1,110 @@
"""Exercise alarms and Android permission boundaries in the packaged bridge."""
import functools
import json
import os
import threading
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from playwright.sync_api import sync_playwright
# This harness mocks extension storage/native messaging, not the Gaia service code.
root = Path(__file__).resolve().parents[2]
class Handler(SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
def do_GET(self):
if self.path == "/test.html":
body = b'<!doctype html><html><head><script type="module" src="/background.js"></script></head><body>Bridge test</body></html>'
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(body)
else:
super().do_GET()
server = ThreadingHTTPServer(
("127.0.0.1", 0),
functools.partial(Handler, directory=str(root / "android/app/src/main/assets/bridge")),
)
threading.Thread(target=server.serve_forever, daemon=True).start()
try:
with sync_playwright() as p:
b = p.chromium.launch(executable_path=os.environ.get("PLAYWRIGHT_CHROMIUM_EXECUTABLE"))
page = b.new_page()
messages = []
errors = []
page.on("console", lambda msg: messages.append(msg.text))
page.on("pageerror", lambda e: errors.append(str(e)))
page.add_init_script(
"""
const values={};window.nativeCalls=[];window.events=[];
window.browser={storage:{local:{get:async key=>({[key]:structuredClone(values[key])}),set:async data=>Object.assign(values,structuredClone(data))}},runtime:{
connectNative:()=>({onMessage:{addListener(fn){window.nativeEvent=fn;}},onDisconnect:{addListener(){}},postMessage(){}}),
sendNativeMessage:async(app,message)=>{nativeCalls.push(message);return message.type==='android.snapshot'?{}:null;},onConnect:{addListener(fn){window.connectPort=fn;}}
}};
window.makePort=url=>{
let receive,seq=0;const waiting=new Map();
connectPort({name:'gaia',sender:{url},onMessage:{addListener(fn){receive=fn;}},onDisconnect:{addListener(){}},postMessage(msg){if(msg.event)events.push({url,...msg.event});if(waiting.has(msg.id)){waiting.get(msg.id)(msg.reply);waiting.delete(msg.id);}}});
return (method,params)=>new Promise(resolve=>{const id=++seq;waiting.set(id,resolve);receive({id,request:{version:1,id,method,params}});});
};
"""
)
page.goto(f"http://127.0.0.1:{server.server_port}/test.html")
page.wait_for_function('typeof connectPort === "function"')
results = page.evaluate(
"""async()=>{
const clock=makePort('http://clock.localhost:18765/onring.html');
const before=await clock('alarms.request',{operation:'getAll'});
if(before.error)throw Error(JSON.stringify(before));
const add=await clock('alarms.request',{operation:'add',date:Date.now()+60000,respectTimezone:'honorTimezone',local:[2026,8,23,9,0,0,0],data:{label:'test'}});
if(add.error)throw Error(JSON.stringify(add));
const list=await clock('alarms.request',{operation:'getAll'});
await clock('alarms.request',{operation:'remove',id:add.result});
const empty=await clock('alarms.request',{operation:'getAll'});
const gallery=makePort('http://gallery.localhost:18765/index.html');
const denied=await gallery('android.settings',{panel:'wifi'});
const cameraDenied=await gallery('android.capture',{});
const settingsDenied=await gallery('settings.set',{values:{'screen.brightness':.4}});
const settings=makePort('http://settings.localhost:18765/index.html');
nativeCalls.length=0;
const malformed=await settings('settings.set',{values:{'screen.brightness':.5,'':true}});
const noSideEffect=malformed.error?.code==='INVALID_KEY' && nativeCalls.length===0;
const notificationDenied=await gallery('android.notification',{operation:'show',owner:'settings',title:'Spoof'});
const smsDenied=await clock('android.sms',{number:'123',body:'test'});
const foreign=makePort('https://example.com/');
const foreignDenied=await foreign('android.snapshot',{});
const system=makePort('http://system.localhost:18765/index.html');
events.length=0;
await nativeEvent({type:'notification-click',data:{owner:'settings',id:1}});
const queued=!events.some(e=>e.type==='launch');
const forbiddenReady=await gallery('android.systemReady',{});
await system('android.systemReady',{});
const replayed=events.some(e=>e.type==='launch' && e.data.manifestURL==='http://settings.localhost:18765/manifest.webapp');
return {coldNotificationWaitsForGaia:queued && replayed,systemReadinessPermission:forbiddenReady.error?.code==='PERMISSION_DENIED',invalidSettingNoNativeSideEffect:noSideEffect,notificationPermissions:notificationDenied.error?.code==='PERMISSION_DENIED',smsPermissions:smsDenied.error?.code==='PERMISSION_DENIED',started:true,stored:list.result.length===1,removed:empty.result.length===0,
hardwarePermissions:denied.error?.code==='PERMISSION_DENIED' && cameraDenied.error?.code==='PERMISSION_DENIED',
settingsPermissions:settingsDenied.error?.code==='PERMISSION_DENIED',
externalOriginDenied:foreignDenied.error?.code==='PERMISSION_DENIED'};
}"""
)
assert all(results.values()), results
assert not errors, errors
(root / "logs/repository-preparation").mkdir(parents=True, exist_ok=True)
(root / "logs/repository-preparation/android-bridge-test.json").write_text(
json.dumps(
{
"results": results,
"scope": "Packaged extension in a browser; native messaging and extension storage mocked. Not an Android device test.",
},
indent=2,
)
)
print(results)
b.close()
finally:
server.shutdown()
server.server_close()

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Reproduce first camera consent on the test emulator, without restarting afterward."""
import importlib.util
import json
import subprocess
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
ADB = str(ROOT / "android/toolchain/sdk/platform-tools/adb")
spec = importlib.util.spec_from_file_location("probe", Path(__file__).with_name("probe-android.py"))
probe = importlib.util.module_from_spec(spec)
spec.loader.exec_module(probe)
def adb(*args):
return subprocess.check_output([ADB, "-s", "emulator-5554", *args], text=True).strip()
assert adb("shell", "getprop", "ro.kernel.qemu") == "1"
adb("shell", "pm", "revoke", "org.vulpes_os.preview", "android.permission.CAMERA")
adb("shell", "am", "start", "-n", "org.vulpes_os.preview/.MainActivity")
adb("forward", "tcp:6088", "localabstract:org.vulpes_os.preview/firefox-debugger-socket")
for _ in range(60):
if probe.run("system", 'document.body?.getAttribute("ready-state")==="fullyLoaded"').get(
"value"
):
break
time.sleep(0.3)
else:
raise TimeoutError("Gaia boot")
probe.run(
"system",
'VulpesCompat.call("apps.launch",{manifestURL:"http://camera.localhost:18765/manifest.webapp"})',
)
subprocess.run(
["python3", str(ROOT / "android/tools/ui-android.py"), "While using the app"],
check=True,
timeout=30,
)
for _ in range(60):
state = probe.run(
"camera",
'({live:!!app.camera.mozCamera && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live"),loaded:!!app.loaded,hidden:document.hidden,busy:app.camera.isBusy,width:document.querySelector(".viewfinder video")?.videoWidth,text:document.body.innerText})',
).get("value", {})
if state.get("live") and state.get("width") and state.get("loaded") and not state.get("busy"):
break
time.sleep(0.3)
else:
raise AssertionError(state)
result = {"first_consent_without_restart": state, "scope": "Android 16 x86_64 emulator"}
(ROOT / "android/logs/camera-first-permission.json").write_text(
json.dumps(result, indent=2, ensure_ascii=False) + "\n"
)
print(json.dumps(result, indent=2, ensure_ascii=False))

View file

@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Exercise Gaia Photo/Gallery on an Android emulator with its virtual sensor."""
import importlib.util
import json
import subprocess
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
spec = importlib.util.spec_from_file_location("probe", Path(__file__).with_name("probe-android.py"))
probe = importlib.util.module_from_spec(spec)
spec.loader.exec_module(probe)
ADB = str(ROOT / "android/toolchain/sdk/platform-tools/adb")
def adb(*args):
return subprocess.check_output([ADB, "-s", "emulator-5554", *args], text=True).strip()
assert (
adb("shell", "getprop", "ro.kernel.qemu") == "1"
), "Use a virtual sensor, not a personal camera."
def ev(app, code):
reply = probe.run(app, code)
if "error" in reply:
raise AssertionError(reply)
return reply.get("value")
def wait(app, code):
for _ in range(60):
value = ev(app, code)
if value:
return value
time.sleep(0.3)
raise TimeoutError(app + ": " + code)
def launch(app):
ev(
"system",
'VulpesCompat.call("apps.launch",{manifestURL:"http://'
+ app
+ '.localhost:18765/manifest.webapp"})',
)
wait(app, 'document.readyState==="complete" && !document.hidden')
result = {"scope": "Android 16 x86_64 emulator, virtual camera; physical phones untested."}
wait("system", 'document.body?.getAttribute("ready-state")==="fullyLoaded"')
ev("system", "(ScreenManager.turnScreenOn(true),true)")
time.sleep(.5)
ev("system", "(lockScreen.unlock(true),true)")
wait("system", "!lockScreen.locked")
time.sleep(.5)
launch("camera")
wait(
"camera",
'!!app.loaded && !!app.camera.mozCamera && !app.camera.isBusy && !!document.querySelector(".viewfinder video")?.videoWidth',
)
result["preview"] = ev(
"camera",
'(()=>{const v=document.querySelector(".viewfinder video");return {width:v.videoWidth,height:v.videoHeight,cameras:navigator.mozCameras.getListOfCameras()}})()',
)
result["original_gaia_ui"] = ev(
"camera",
'!!document.querySelector(".js-capture") && !document.querySelector(".vulpes-capability")',
)
assert result["original_gaia_ui"]
ev(
"camera",
'(async()=>{window.__beforeCapture=(await VulpesCompat.call("media.request",{type:"pictures",operation:"enumerate"})).map(f=>f.name);window.__streamBefore=app.camera.mozCamera;return true})()',
)
point = ev(
"camera",
'(()=>{const r=document.querySelector(".js-capture").getBoundingClientRect();return {x:(mozInnerScreenX+r.x+r.width/2)*devicePixelRatio,y:(mozInnerScreenY+r.y+r.height/2)*devicePixelRatio}})()',
)
adb("shell", "input", "tap", str(round(point["x"])), str(round(point["y"])))
photo = wait(
"camera",
'(async()=>(await VulpesCompat.call("media.request",{type:"pictures",operation:"enumerate"})).find(f=>!__beforeCapture.includes(f.name)))()',
)
assert photo["size"] > 0 and photo["type"] == "image/jpeg"
result["photo"] = photo
launch("gallery")
result["camera_stops_when_hidden"] = wait(
"camera", '__streamBefore.getTracks().every(t=>t.readyState==="ended")'
)
name = json.dumps(photo["name"])
result["gallery_indexes_photo"] = wait(
"gallery",
'typeof files!=="undefined" && files.some(f=>f.name==='
+ name
+ " && f.metadata.width>0 && f.metadata.thumbnail)",
)
ev(
"gallery",
'(()=>{const e=[...document.querySelectorAll(".thumbnailImage")].find(e=>e.dataset.filename==='
+ name
+ ");e.click();return true})()",
)
result["gallery_opens_photo"] = wait(
"gallery", 'document.body.classList.contains("fullscreenView")'
)
result["gallery_import_button"] = ev(
"gallery", '!!document.querySelector("gaia-header [data-vulpes-import]") && !document.querySelector(".vulpes-import")'
)
assert result["gallery_import_button"]
ev("gallery", "(launchCameraApp(),true)")
wait("camera", "!document.hidden")
result["camera_resumes"] = wait(
"camera",
'!!app.camera.mozCamera && app.camera.mozCamera!==__streamBefore && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live") && !app.camera.isBusy',
)
ev("camera", "(window.__streamBefore=app.camera.mozCamera,true)")
adb("shell", "input", "keyevent", "KEYCODE_HOME")
result["camera_stops_in_android_background"] = wait(
"camera", '__streamBefore.getTracks().every(t=>t.readyState==="ended")'
)
adb("shell", "am", "start", "-n", "org.vulpes_os.preview/.MainActivity")
result["camera_resumes_from_android_background"] = wait(
"camera",
'!document.hidden && !!app.camera.mozCamera && app.camera.mozCamera!==__streamBefore && app.camera.mozCamera.getVideoTracks().every(t=>t.readyState==="live") && !app.camera.isBusy',
)
result["unavailable_controls"] = ev(
"camera",
'[".js-flash",".mode-switch"].every(s=>document.querySelector(s).getAttribute("aria-disabled")==="true")',
)
assert result["unavailable_controls"]
result["errors"] = ev(
"camera", '__vulpesDiagnostics.filter(d=>d.kind==="error" || d.kind==="rejection")'
)
assert not result["errors"], result
launch("gallery")
(ROOT / "android/logs/camera-results.json").write_text(
json.dumps(result, indent=2, ensure_ascii=False) + "\n"
)
print(json.dumps(result, indent=2, ensure_ascii=False))

View file

@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Exercise Android background/resume and renderer termination on a test device."""
import importlib.util,json,subprocess,sys,time
from pathlib import Path
ROOT=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(ROOT/'android/tools'))
spec=importlib.util.spec_from_file_location('probe',ROOT/'android/tools/probe-android.py');probe=importlib.util.module_from_spec(spec);spec.loader.exec_module(probe)
from rdp import RDP
ADB=str(ROOT/'android/toolchain/sdk/platform-tools/adb')
def adb(*args):return subprocess.check_output([ADB,*args],text=True).strip()
def wait_app():
end=time.monotonic()+30
while time.monotonic()<end:
try:
result=probe.run('system','({ready:document.readyState,marker:window.__lifecycleMarker,frames:[...document.querySelectorAll("iframe")].map(f=>f.src)})')
if result.get('value',{}).get('ready')=='complete':return result['value']
except (Exception,):pass
time.sleep(.5)
raise TimeoutError('Gaia did not recover')
report={}
adb('shell','am','start','-n','org.vulpes_os.preview/.MainActivity')
wait_app();probe.run('system','window.__lifecycleMarker="lifecycle-20260924"')
cycles=[]
for i in range(10):
adb('shell','input','keyevent','KEYCODE_HOME')
adb('shell','am','start','-a','android.settings.SETTINGS')
time.sleep(.5)
adb('shell','am','start','-n','org.vulpes_os.preview/.MainActivity')
state=wait_app();cycles.append(state['marker']=='lifecycle-20260924')
print('Resume cycle',i+1,cycles[-1],flush=True)
assert all(cycles),cycles
report['ten_switches_preserve_document']=cycles
r=RDP();tab=next(t for t in r.call('root','listTabs')['tabs'] if 'system.localhost' in t.get('url',''));target=r.call(tab['actor'],'getTarget')['frame'];r.s.close()
pid=str(target['processID']);name=adb('shell','ps','-p',pid,'-o','NAME')
assert 'org.vulpes_os.preview:tab' in name,name
adb('shell','input','keyevent','KEYCODE_HOME');adb('shell','run-as','org.vulpes_os.preview','kill','-9',pid)
time.sleep(.5);adb('shell','am','start','-n','org.vulpes_os.preview/.MainActivity')
state=wait_app();assert state.get('marker') is None,state
for _ in range(30):
try:
home=probe.run('homescreen','({icons:document.querySelectorAll("gaia-app-icon").length,ready:document.readyState})')['value']
if home['icons']>10:break
except Exception:pass
time.sleep(.5)
assert home['icons']>10,home
report['renderer_killed_in_background_recovers']=home
report['snapshot']=probe.run('system','VulpesCompat.call("android.snapshot",{})')['value']
report['scope']='Android 16 x86_64 emulator; not a physical-device validation.'
(ROOT/'android/logs/integration/lifecycle.json').write_text(json.dumps(report,indent=2))
print(json.dumps(report,indent=2),flush=True)

View file

@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Observe real Gaia wallpaper events over an ADB-forwarded debug connection."""
import json
import sys
import time
from rdp import RDP
def evaluate(source):
r = RDP()
try:
result = r.eval(source)
if result.get("hasException"):
raise RuntimeError(result)
return result.get("result")
finally:
r.s.close()
duration = int(sys.argv[1]) if len(sys.argv) > 1 else 30
evaluate(
"""window.__wallpaperProbe={changes:0,settings:0,start:Date.now()};
addEventListener('wallpaperchange',()=>__wallpaperProbe.changes++);
navigator.mozSettings.addObserver('wallpaper.image',()=>__wallpaperProbe.settings++);
navigator.mozSettings.createLock().get('wallpaper.image').onsuccess=e=>{
const v=e.target.result['wallpaper.image'];
__wallpaperProbe.image={blob:v instanceof Blob,size:v.size,type:v.type};
}; 'probe-started';"""
)
time.sleep(duration)
result = json.loads(
evaluate(
"""JSON.stringify({...__wallpaperProbe,
elapsed:Date.now()-__wallpaperProbe.start,ready:applications.ready,
background:document.getElementById('screen').style.backgroundImage})"""
)
)
print(json.dumps(result, indent=2))
assert result["ready"], "Gaia not ready"
assert result.get("image", {}).get("blob"), "Wallpaper was not restored as a Blob"
assert result["image"]["size"] > 0, "Empty wallpaper"
assert (
result["changes"] == 0 and result["settings"] == 0
), "Wallpaper is still changing without user action"
assert "blob:" in result["background"], "Wallpaper missing from the screen"

View file

@ -0,0 +1,19 @@
import sys
import json
from rdp import RDP
r = RDP()
targets = r.call("root", "listProcesses")["processes"]
for p in targets:
if p["isParent"]:
continue
t = r.call(p["actor"], "getTarget")["process"]
if sys.argv[1] not in t.get("remoteType", ""):
continue
a = r.call(t["consoleActor"], "evaluateJSAsync", text=sys.argv[2])
while True:
q = r.recv()
if q.get("type") == "evaluationResult":
print(json.dumps(q, indent=2))
break
r.s.close()

View file

@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Snapshot the same Gaia assets/overlays as the desktop, without changing them."""
import hashlib
import json
import re
import shutil
import subprocess
import zipfile
import sys
import base64
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "tools"))
from project import build_info, validate_assets, release_info
validate_assets()
OUT = ROOT / "android/app/src/main/assets"
OUT.mkdir(parents=True, exist_ok=True)
PORT = 18765
pattern = re.compile(
r"app://([a-zA-Z0-9_-]+)\.gaiamobile\.org|http://([a-zA-Z0-9_-]+)\.localhost:8765"
)
def translate(text):
return pattern.sub(lambda m: f"http://{m[1] or m[2]}.localhost:{PORT}", text)
APPS = ROOT / "assets/webapps"
registry = []
for manifest in sorted(APPS.glob("*/manifest.webapp")):
name = manifest.parent.name.removesuffix(".gaiamobile.org")
if not re.fullmatch("[a-z0-9_-]+", name):
continue
origin = f"http://{name}.localhost:{PORT}"
registry.append(
dict(
id=name,
origin=origin,
manifestURL=origin + "/manifest.webapp",
manifest=json.loads(translate(manifest.read_text())),
)
)
info = build_info()
release = release_info()
revision = info["revision"]
commit_time = str(info["timestamp"])
source_overrides = {
"system": [
"js/launcher.js",
"js/gaia_scheduler.js",
"js/keyboard_manager.js",
"js/core.js",
"js/rocketbar.js",
]
}
textsuffix = {".html", ".js", ".css", ".json", ".webapp", ".properties", ".svg"}
head = b'<head><script src="/_vulpes/platform-config.js"></script><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"><link rel="stylesheet" href="/_vulpes/desktop.css"><link rel="stylesheet" href="/_vulpes/android.css"><script src="/_vulpes/settings-codec.js"></script><script src="/_vulpes/gaia-compat.js"></script><script src="/_vulpes/gaia-wifi.js"></script><script src="/_vulpes/gaia-services.js"></script><script src="/_vulpes/gaia-styles.js"></script><script src="/_vulpes/android-ui.js"></script><script src="/_vulpes/gaia-restoration.js"></script><script src="/_vulpes/platform-ui.js"></script><script src="/_vulpes/gaia-camera.js"></script>'
if "--bridge-only" not in sys.argv or not (OUT / "gaia.zip").exists():
with zipfile.ZipFile(OUT / "gaia.zip", "w", zipfile.ZIP_DEFLATED, compresslevel=6) as z:
hashes = {}
def put(name, data):
z.writestr(name, data)
hashes[name] = hashlib.sha256(data).hexdigest()
for app in registry:
folder = APPS / (app["id"] + ".gaiamobile.org")
files = {p.relative_to(folder).as_posix(): p for p in folder.rglob("*") if p.is_file()}
common = ROOT / "overrides/shared"
if common.is_dir():
for source in common.rglob("*"):
if source.is_file():
files["shared/" + source.relative_to(common).as_posix()] = source
overlay = ROOT / "overrides" / folder.name
if overlay.exists():
files.update(
{
p.relative_to(overlay).as_posix(): p
for p in overlay.rglob("*")
if p.is_file() and p.name != "manifest.webapp"
}
)
for name in source_overrides.get(app["id"], []):
files[name] = ROOT / "gaia/apps" / app["id"] / name
for name, p in files.items():
data = p.read_bytes()
if p.suffix in textsuffix:
text = translate(data.decode("utf8"))
if p.suffix in {".html", ".css", ".js"}:
for a, b in [
("-moz-calc(", "calc("),
("-moz-box-sizing:", "box-sizing:"),
("-moz-plaintext", "plaintext"),
(":-moz-dir(", ":dir("),
("offset-inline-start:", "inset-inline-start:"),
("offset-inline-end:", "inset-inline-end:"),
]:
text = text.replace(a, b)
# Clearing a media URL with null requests the literal /null path.
if app["id"] == "music" and p.suffix == ".js":
text = text.replace("audio.src=null;", "audio.removeAttribute('src');")
if name == "resources/gaia_commit.txt":
text = revision + "\n" + commit_time + "\n"
data = text.encode()
if p.suffix == ".html" and not (app["id"] == "search" and name.startswith("home/")):
data = data.replace(b"<head>", head, 1)
if app["id"] == "communications" and name.startswith("dialer/"):
data = data.replace(
b"<head>",
b'<head><script src="/shared/js/accessibility_helper.js"></script>',
1,
)
put(app["id"] + "/" + name, data)
for p in (ROOT / "gaia/shared").rglob("*"):
if p.is_file():
data = p.read_bytes()
if p.suffix in textsuffix:
data = translate(data.decode("utf8")).encode()
put("_shared/" + p.relative_to(ROOT / "gaia/shared").as_posix(), data)
put("_vulpes/platform-config.js", b'window.VulpesPlatform="android";')
for name in ["gaia-compat.js", "gaia-services.js", "gaia-styles.js", "gaia-restoration.js", "gaia-wifi.js", "desktop.css", "platform-ui.js", "gaia-camera.js"]:
text = (
(ROOT / "host" / name)
.read_text()
)
put("_vulpes/" + name, translate(text).encode())
put("_vulpes/settings-codec.js", (ROOT / "android/ui/settings-codec.js").read_bytes())
put("_vulpes/android-ui.js", (ROOT / "android/ui/android-ui.js").read_bytes())
put(
"_vulpes/android.css",
b'html,body{overscroll-behavior:none} html[data-vulpes-app="system"] #keyboard-frame{display:none!important} html[data-vulpes-app="system"] #screen{height:100%!important} input,textarea{touch-action:manipulation}',
)
(OUT / "payload-checksums.json").write_text(json.dumps(hashes, sort_keys=True))
ext = OUT / "bridge"
shutil.copytree(ROOT / "android/extension", ext, dirs_exist_ok=True)
for folder in ["services", "adapters"]:
shutil.copytree(ROOT / folder, ext / folder, dirs_exist_ok=True)
for name in ["Connections", "Contacts", "Messages"]:
text = (
(ROOT / "host" / f"{name}.sys.mjs")
.read_text()
.replace("Services.uuid.generateUUID().toString()", "crypto.randomUUID()")
)
(ext / f"{name}.mjs").write_text(text)
shutil.copy2(ROOT / "host/Alarms.sys.mjs", ext / "Alarms.mjs")
(ext / "release.json").write_text(json.dumps(release))
(ext / "registry.json").write_text(json.dumps(registry, ensure_ascii=False))
settings = json.loads(translate((ROOT / "assets/settings-defaults.json").read_text()))
wallpaper = ROOT / "gaia/apps/default_theme/wallpapers/Illus_Flow2_Blue@2x.jpg"
if not wallpaper.is_file():
raise RuntimeError("Default wallpaper is missing: " + str(wallpaper))
settings["wallpaper.image"] = (
"data:image/jpeg;base64," + base64.b64encode(wallpaper.read_bytes()).decode()
)
settings.update(
{
"ftu.manifestURL": "",
"lockscreen.enabled": True,
"screen.timeout": 0,
"language.current": "fr",
"software-button.enabled": False,
"metrics.selectedMetrics.level": "None",
"ftu.pingEnabled": False,
"search.suggestions.enabled": False,
"search.marketplace.url": "",
"gaia.system.checkForUpdates": False,
"deviceinfo.os": release["gaia"],
"deviceinfo.software": "Vulpes OS " + release["version"],
"deviceinfo.product_model": "Vulpes Android Preview",
"deviceinfo.product_device": "android",
"deviceinfo.platform_version": release["androidEngine"],
"deviceinfo.platform_build_id": release["androidEngineBuildID"],
}
)
(ext / "defaults.json").write_text(json.dumps(settings, ensure_ascii=False))
(OUT / "build-info.json").write_text(
json.dumps(
{
"revision": revision,
"geckoview": release["geckoview"],
"version": release["version"],
"minSdk": 26,
"targetSdk": 37,
}
)
)
print(
f'Packaged {len(registry)} Gaia apps, {round((OUT/"gaia.zip").stat().st_size/1024/1024,1)} MiB'
)

View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Install the locked Linux x86_64 Android build tools in the ignored toolchain/."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import shutil
import subprocess
import tarfile
import tempfile
import urllib.request
import zipfile
ROOT = Path(__file__).resolve().parents[1]
TOOLS = ROOT / "toolchain"
def fetch(name, entry):
path = TOOLS / "downloads" / name
path.parent.mkdir(parents=True, exist_ok=True)
algorithm = "sha256" if "sha256" in entry else "sha1"
if not path.exists():
partial = path.with_suffix(path.suffix + ".partial")
with urllib.request.urlopen(entry["url"], timeout=60) as source, partial.open("wb") as out:
shutil.copyfileobj(source, out)
partial.replace(path)
with path.open("rb") as stream:
digest = hashlib.file_digest(stream, algorithm).hexdigest()
if digest != entry[algorithm]:
raise RuntimeError(f"Checksum mismatch; archive left unextracted: {path}")
return path
def extract(archive, member_root, target):
if target.exists():
raise RuntimeError(f"Incomplete installation left untouched: {target}")
target.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".install-", dir=TOOLS) as temporary:
stage = Path(temporary)
if archive.name.endswith(".tar.gz"):
with tarfile.open(archive) as package:
package.extractall(stage, filter="data")
else:
with zipfile.ZipFile(archive) as package:
for info in package.infolist():
path = stage / info.filename
if not path.resolve().is_relative_to(stage) or "\\" in info.filename:
raise RuntimeError("Unsafe toolchain archive entry")
if (info.external_attr >> 16) & 0o170000 == 0o120000:
raise RuntimeError("Symlink in toolchain ZIP archive")
package.extract(info, stage)
mode = (info.external_attr >> 16) & 0o777
if mode and path.is_file():
path.chmod(mode)
(stage / member_root).rename(target)
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
"--check", action="store_true", help="Check installed tools without downloading"
)
args = parser.parse_args()
if platform.system() != "Linux" or platform.machine() not in ("x86_64", "amd64"):
raise RuntimeError("This bootstrap currently supports Linux x86_64 only")
lock = json.loads((ROOT / "toolchain-lock.json").read_text())
jdk = TOOLS / "jdk-21.0.12.1+1"
gradle = TOOLS / "gradle-9.5.0"
sdk = TOOLS / "sdk"
installs = [
("jdk.tar.gz", "jdk-21.0.12.1+1", jdk, jdk / "bin/java"),
("gradle-9.5.zip", "gradle-9.5.0", gradle, gradle / "bin/gradle"),
(
"commandlinetools.zip",
"cmdline-tools",
sdk / "cmdline-tools/latest",
sdk / "cmdline-tools/latest/bin/sdkmanager",
),
]
missing = [
str(executable.relative_to(ROOT))
for _, _, _, executable in installs
if not executable.is_file()
]
packages = [sdk / "platforms/android-37.1/android.jar", sdk / "build-tools/37.0.0/apksigner"]
if args.check:
missing += [str(path.relative_to(ROOT)) for path in packages if not path.is_file()]
if missing:
raise RuntimeError("Missing tools: " + ", ".join(missing))
else:
TOOLS.mkdir(exist_ok=True)
for name, member, target, executable in installs:
if not executable.is_file():
extract(fetch(name, lock["archives"][name]), member, target)
if not all(path.is_file() for path in packages):
env = {**os.environ, "JAVA_HOME": str(jdk)}
manager = sdk / "cmdline-tools/latest/bin/sdkmanager"
# sdkmanager asks the user to review any licenses not already accepted.
subprocess.run(
[str(manager), "--sdk_root=" + str(sdk), *lock["sdk"]], env=env, check=True
)
print("Android build tools available in", TOOLS)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Evaluate a diagnostic in one Gaia app through the ADB-only debugger."""
import json,sys,time
from rdp import RDP
def evaluate(host, script):
r=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))
response=r.call(target['consoleActor'],'evaluateJSAsync',text=text)
if 'resultID' not in response: continue
while True:
value=r.recv()
if value.get('type')=='evaluationResult' and value.get('resultID')==response['resultID']:
if value.get('hasException'): raise RuntimeError(value.get('exceptionMessage',str(value)))
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()
def run(host,expression,timeout=30):
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"')
break
except (RuntimeError, EOFError, OSError) as e:
if time.monotonic()>=start:raise
if isinstance(e, RuntimeError) and 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"')
if isinstance(result,str) and result != 'pending':return json.loads(result)
time.sleep(.3)
raise TimeoutError(host)
if __name__=='__main__':
print(json.dumps(run(sys.argv[1],sys.argv[2]),ensure_ascii=False,indent=2))

60
android/tools/rdp.py Normal file
View file

@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""ADB-forwarded Firefox debugger helper for local development APKs."""
import socket
import json
import sys
class RDP:
def __init__(self, port=6088):
self.events = []
self.s = socket.create_connection(("127.0.0.1", port), timeout=20)
self.recv()
def recv(self):
n = b""
while not n.endswith(b":"):
c = self.s.recv(1)
if not c:
raise EOFError()
n += c
data = b""
while len(data) < int(n[:-1]):
data += self.s.recv(int(n[:-1]) - len(data))
return json.loads(data)
def call(self, to, kind, **args):
data = json.dumps(dict(to=to, type=kind, **args)).encode()
self.s.sendall(str(len(data)).encode() + b":" + data)
while True:
r = self.recv()
self.events.append(r)
if r.get("from") == to and (r.get("type") not in ["consoleAPICall", "pageError"]):
return r
def eval(self, text):
tabs = self.call("root", "listTabs")["tabs"]
tab = next(t for t in tabs if "system.localhost" in t.get("url", ""))
frame = self.call(tab["actor"], "getTarget")["frame"]
reply = self.call(frame["consoleActor"], "evaluateJSAsync", text=text)
if "resultID" not in reply:
return reply
while True:
result = self.recv()
if (
result.get("type") == "evaluationResult"
and result.get("resultID") == reply["resultID"]
):
return result
if __name__ == "__main__":
c = RDP()
print(
json.dumps(
c.eval(sys.argv[1]) if len(sys.argv) > 1 else c.call("root", "listTabs"),
ensure_ascii=False,
indent=2,
)
)
c.s.close()

View file

@ -0,0 +1,17 @@
#!/usr/bin/env python3
"""Read or tap a labeled Android accessibility node on the connected test device."""
import re,sys,subprocess,xml.etree.ElementTree as E
from pathlib import Path
adb=str(Path(__file__).resolve().parents[1]/'toolchain/sdk/platform-tools/adb')
subprocess.run([adb,'shell','uiautomator','dump','/sdcard/vulpes-ui.xml'],stdout=subprocess.DEVNULL,check=True)
nodes=list(E.fromstring(subprocess.check_output([adb,'shell','cat','/sdcard/vulpes-ui.xml'],text=True)).iter('node'))
if len(sys.argv)>1:
needle=sys.argv[1]
for n in nodes:
if needle in (n.get('text'),n.get('content-desc'),n.get('resource-id')):
x1,y1,x2,y2=map(int,re.findall(r'\d+',n.get('bounds')))
subprocess.run([adb,'shell','input','tap',str((x1+x2)//2),str((y1+y2)//2)],check=True);print('Tapped:',needle);break
else:raise SystemExit('Node not found: '+needle)
else:
for n in nodes:
if n.get('text') or n.get('clickable')=='true':print(n.get('text'),n.get('content-desc'),n.get('resource-id'),n.get('bounds'))

1
android/ui/android-ui.js Normal file
View file

@ -0,0 +1 @@
// Home layout recovery now lives in the shared host/platform-ui.js.

View file

@ -0,0 +1,40 @@
// Android's extension storage/RPC is JSON. Preserve binary Gaia settings across
// both boundaries and reconstruct Blobs in the application's own JS realm.
(() => {
const marker = '__vulpesBlobV1';
async function encode(value) {
if (value instanceof Blob) {
if (value.size > 16 * 1024 * 1024) throw new Error('SETTING_BLOB_TOO_LARGE');
const bytes = new Uint8Array(await value.arrayBuffer());
const chunks = [];
for (let i = 0; i < bytes.length; i += 16384) {
chunks.push(String.fromCharCode(...bytes.subarray(i, i + 16384)));
}
return { [marker]: true, type: value.type, base64: btoa(chunks.join('')) };
}
if (Array.isArray(value)) return Promise.all(value.map(encode));
if (value && typeof value === 'object') {
return Object.fromEntries(
await Promise.all(
Object.entries(value).map(async ([key, item]) => [key, await encode(item)]),
),
);
}
return value;
}
function decode(value) {
if (!value || typeof value !== 'object') return value;
if (
value[marker] === true &&
typeof value.type === 'string' &&
typeof value.base64 === 'string' &&
Object.keys(value).length === 3
) {
const bytes = Uint8Array.from(atob(value.base64), (c) => c.charCodeAt(0));
return new Blob([bytes], { type: value.type });
}
if (Array.isArray(value)) return value.map(decode);
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, decode(item)]));
}
window.VulpesAndroidSettingsCodec = Object.freeze({ encode, decode });
})();