Publish Vulpes Preview 15 sources for desktop, Android and Tundra

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View file

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

View file

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