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:
parent
93e3a48004
commit
b38dea0597
25901 changed files with 9982925 additions and 369 deletions
9
android/.gitignore
vendored
Normal file
9
android/.gitignore
vendored
Normal 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
20
android/app/build.gradle
Normal 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}" }
|
||||
30
android/app/src/main/AndroidManifest.xml
Normal file
30
android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
161
android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
Normal file
161
android/app/src/main/java/org/vulpes_os/preview/GaiaServer.java
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
android/app/src/main/res/drawable-nodpi/vulpes_logo.png
Normal file
BIN
android/app/src/main/res/drawable-nodpi/vulpes_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
|
|
@ -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>
|
||||
5
android/app/src/main/res/values/strings.xml
Normal file
5
android/app/src/main/res/values/strings.xml
Normal 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
26
android/build-apk.sh
Executable 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
1
android/build.gradle
Normal file
|
|
@ -0,0 +1 @@
|
|||
plugins { id 'com.android.application' version '9.3.3' apply false }
|
||||
1
android/extension/background.html
Normal file
1
android/extension/background.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<!doctype html><meta charset="utf-8"><script type="module" src="background.js"></script>
|
||||
438
android/extension/background.js
Normal file
438
android/extension/background.js
Normal 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(() => {});
|
||||
});
|
||||
});
|
||||
76
android/extension/content.js
Normal file
76
android/extension/content.js
Normal 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',
|
||||
});
|
||||
})();
|
||||
1
android/extension/manifest.json
Normal file
1
android/extension/manifest.json
Normal 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"}]}
|
||||
3
android/gradle.properties
Normal file
3
android/gradle.properties
Normal 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
7
android/settings.gradle
Normal 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'
|
||||
67
android/tests/settings-codec.test.mjs
Normal file
67
android/tests/settings-codec.test.mjs
Normal 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.',
|
||||
);
|
||||
29
android/toolchain-lock.json
Normal file
29
android/toolchain-lock.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
110
android/tools/check-bridge.py
Normal file
110
android/tools/check-bridge.py
Normal 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()
|
||||
56
android/tools/check-camera-permission.py
Normal file
56
android/tools/check-camera-permission.py
Normal 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))
|
||||
141
android/tools/check-camera.py
Normal file
141
android/tools/check-camera.py
Normal 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))
|
||||
50
android/tools/check-lifecycle.py
Normal file
50
android/tools/check-lifecycle.py
Normal 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)
|
||||
45
android/tools/check-wallpaper.py
Normal file
45
android/tools/check-wallpaper.py
Normal 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"
|
||||
19
android/tools/inspect-process.py
Normal file
19
android/tools/inspect-process.py
Normal 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()
|
||||
191
android/tools/pack-assets.py
Normal file
191
android/tools/pack-assets.py
Normal 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'
|
||||
)
|
||||
108
android/tools/prepare-toolchain.py
Normal file
108
android/tools/prepare-toolchain.py
Normal 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()
|
||||
43
android/tools/probe-android.py
Normal file
43
android/tools/probe-android.py
Normal 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
60
android/tools/rdp.py
Normal 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()
|
||||
17
android/tools/ui-android.py
Normal file
17
android/tools/ui-android.py
Normal 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
1
android/ui/android-ui.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
// Home layout recovery now lives in the shared host/platform-ui.js.
|
||||
40
android/ui/settings-codec.js
Normal file
40
android/ui/settings-codec.js
Normal 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 });
|
||||
})();
|
||||
Loading…
Reference in a new issue