diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bddd9b4..3bd1241 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -151,6 +151,28 @@ jobs: name: host_web path: null0_web.zip + host_android: + needs: generated + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + # the runner has the Android SDK; gradle installs the pinned NDK and CMake + - name: Build Android Host + run: npm run host:android + - name: Rename APK + run: cp android/app/build/outputs/apk/release/app-release.apk null0_android.apk + - name: Upload Android Host artifact + uses: actions/upload-artifact@v4 + with: + name: host_android + path: null0_android.apk + # Everything generated from api/*.yml - the bindings, host.c, API.md, # api.json, templates - has to be committed in sync with its generator. # Nothing rebuilds it at release time, so a stale file ships as-is: v0.0.13 @@ -424,6 +446,7 @@ jobs: - host_mac - host_windows - host_web + - host_android steps: - name: Checkout uses: actions/checkout@v4 diff --git a/AGENTS.md b/AGENTS.md index 01b53a0..6457cc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,10 @@ it the null0 API as wasm imports, and calls exported callbacks (`update`, `keyDown`, ...) as the game runs. Carts can be written in 18 languages, so most work here is "teach another language to talk to the same ABI". -Hosts: native (raylib + WAMR) and web (emscripten + browser WebAssembly). The -README mentions libretro, but there is no libretro code in the tree yet. +Hosts: native (raylib + WAMR), android (the same native host, built by the +NDK - see "The android host" below) and web (emscripten + browser +WebAssembly). The README mentions libretro, but there is no libretro code in +the tree yet. ## Repo map @@ -27,6 +29,7 @@ README mentions libretro, but there is no libretro code in the tree yet. | `tools/docker/` | One Dockerfile + one `build_.sh` per language, plus baked interpreter sources. | | `carts//` | The language's null0 header/bindings + example carts. | | `host/src/` | The engine. Mostly hand-written; `host.c` is generated. | +| `android/` | Gradle project for the android host: launcher + NativeActivity wrapper (Java). | | `webroot/` | The web player (`null0.js` loads the emscripten host, then the cart). | | `build/`, `wbuild/` | Native and web build output (gitignored). | @@ -58,7 +61,8 @@ and you race. Phase one is `gen:host` + `gen:cart_*` in parallel, phase two is Hand-written host code lives in `host_header.h` (helpers, memory copying, `add_image`/`add_font`/`add_sound`), `host.h` (the `HOST_FUNCTION` macro), -`fs.c`, `wasi_physfs.h`, `cart_wamr.c`, `cart_emscripten.c`, `main.c`. +`fs.c`, `wasi_physfs.h`, `cart_wamr.c`, `cart_emscripten.c`, `main.c`, +`android.c`. ### How `host.c` is generated @@ -247,6 +251,41 @@ If you change any of this, test it **in a browser**, not just natively - and test with more than one checkbox on screen. `carts/c/gui` has several deliberately, because a single one of each passes even when identity is broken. +## The android host + +`android/` is a gradle project whose native half is the root `CMakeLists.txt` +(`host` becomes `libnull0.so` when `ANDROID` is set). raylib runs as +`PLATFORM_ANDROID` inside a NativeActivity; everything android-specific in C +is in `host/src/android.c`, hooked in with `#ifdef __ANDROID__`. + +- **The cart runs in its own process** (`android:process=":cart"`), killed + when the cart ends. The host keeps its state in globals (WAMR runtime, + handle vectors, physfs), so it can only ever run one cart per process. +- **raylib doesn't handle rotation or a resized surface.** Orientation is fixed + per activity (`CartActivityLandscape`/`Portrait`), and fullscreen is set + before the native window exists. `main.c` redefines `InitWindow` to + `InitWindow(0, 0, ...)` on android - pntr_app's 2x window would otherwise be + letterboxed by raylib, leaving nowhere to draw the controller. +- **The on-screen controller is drawn after pntr_app renders** (`main.c` routes + `EndDrawing` through `null0_android_draw_controller`) and pressed through + `pntr_app_process_event`, so callbacks and polling both see it, as player 0. +- **Touches are mice with no hover.** pntr_app only moves its pointer by + deltas, and microui (the gui) needs the control *and* its window hovered on + earlier frames. `null0_android_filter_event` moves the pointer on touch-down + and replays the press two frames later. Touches that start on the + controller never reach the cart as mouse events. +- **WAMR's hardware bound-checks are off on android** (`Findwamr.cmake`): its + stack guard-page probe SIGSEGVs on NativeActivity's thread. +- `Findwamr.cmake` picks the platform from `CMAKE_SYSTEM_NAME`, not the host + system, so it's right when cross-compiling. +- `timespec_get` only exists from API 29; minSdk is 24. +- stdout/stderr are piped to logcat (tag `null0`). + +Testing: `adb logcat -s null0` for output, `adb shell input motionevent +DOWN x y` / `UP` to hold a touch (a plain `input tap` can land in a single +frame), `adb shell input gamepad keyevent KEYCODE_BUTTON_A` for a physical pad, +and `adb exec-out screencap -p > shot.png` to look at it. + ## Adding a cart language There are two shapes. Copy the closest existing one instead of inventing. diff --git a/README.md b/README.md index acdbd0f..2dfa3bb 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -The basic idea here is a small game-engine where you make "carts", in whatever language you like, which will run on native, libretro, or web. +The basic idea here is a small game-engine where you make "carts", in whatever language you like, which will run on native, android, libretro, or web. [Read the docs](https://notnull.games/null0) to find out more. @@ -112,6 +112,22 @@ investigating - not just "not gotten to yet". Full details in - name your cart main.null0, and rename null0 (for each platform) to whatever you want - you can merge them: `cat null0 mygame.null0 > mygame && chmod +x mygame` +### android + +`npm run host:android` builds an apk (`android/app/build/outputs/apk/`). It +needs the Android SDK and JDK 17 or newer; gradle installs the NDK and CMake it wants. +Open a `.null0` from the app (or from a file manager/browser download) and it +is kept in the app's list for next time. + +- On-screen SNES-style controller (dpad, A/B/X/Y, L/R, select/start), with + multi-touch, dpad diagonals, and B+Y style "between two buttons" presses. + Turn it off in the app's settings if you only use a real controller. +- Physical gamepads work, and the left analog stick also drives the dpad. +- Landscape (controller beside the game) or portrait (controller below it). +- Back quits the cart. `printf` from carts shows up in `adb logcat -s null0`. + +To test in an emulator, start one (`emulator -avd `, or Android Studio's Device Manager), then `adb install -r android/app/build/outputs/apk/release/app-release.apk`, `adb push mygame.null0 /sdcard/Download/`, and use "Open cart…" in the app. + ## todo/ideas You can currently do all of these things yourself, or use a library for your language, but I think it would be cool to abstract them into the engine: diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..0f20a20 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,7 @@ +.gradle/ +.cxx/ +build/ +app/build/ +app/.cxx/ +local.properties +*.apk diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..e4b92dc --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,58 @@ +import groovy.json.JsonSlurper + +plugins { + id("com.android.application") +} + +// keep the app version in step with the engine's (package.json) +val pkg = JsonSlurper().parse(rootProject.file("../package.json")) as Map<*, *> +val engineVersion = pkg["version"] as String +val engineVersionCode = engineVersion.split(".").fold(0) { acc, part -> acc * 100 + part.toInt() } + +android { + namespace = "games.notnull.null0" + compileSdk = 34 + ndkVersion = "27.2.12479018" + + defaultConfig { + applicationId = "games.notnull.null0" + minSdk = 24 + targetSdk = 34 + versionCode = engineVersionCode + versionName = engineVersion + + ndk { + // arm64 for devices, x86_64 for the emulator + abiFilters += listOf("arm64-v8a", "x86_64") + } + + externalNativeBuild { + cmake { + arguments += listOf("-DCMAKE_BUILD_TYPE=Release", "-DANDROID_STL=none") + targets += "host" + } + } + } + + // the host is the same CMake project the desktop and web builds use + externalNativeBuild { + cmake { + path = file("../../CMakeLists.txt") + version = "3.31.6" + } + } + + buildTypes { + release { + isMinifyEnabled = false + // no release keystore in the repo: sign with the debug key so the apk + // installs. swap this for a real signingConfig to publish + signingConfig = signingConfigs.getByName("debug") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..823df81 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/games/notnull/null0/CartActivity.java b/android/app/src/main/java/games/notnull/null0/CartActivity.java new file mode 100644 index 0000000..830c6be --- /dev/null +++ b/android/app/src/main/java/games/notnull/null0/CartActivity.java @@ -0,0 +1,92 @@ +package games.notnull.null0; + +import android.app.NativeActivity; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.os.Bundle; +import android.os.Process; +import android.view.View; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowInsetsController; +import android.view.WindowManager; + +// runs a cart: the engine (libnull0.so) is a NativeActivity, and android.c +// calls the getters below over JNI to find out what to run +public class CartActivity extends NativeActivity { + static final String EXTRA_CART = "cart"; + static final String EXTRA_CONTROLLER = "controller"; + + static Intent intent(Context context, String cartPath, boolean controller, boolean portrait) { + Class activity = portrait ? CartActivityPortrait.class : CartActivityLandscape.class; + return new Intent(context, activity) + .putExtra(EXTRA_CART, cartPath) + .putExtra(EXTRA_CONTROLLER, controller); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + // go fullscreen before the native window exists - raylib sizes itself + // once, and doesn't handle the surface changing size later + fullscreen(); + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + super.onCreate(savedInstanceState); + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + if (hasFocus) { + fullscreen(); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + // the engine keeps its state in globals, so this process can't run a + // second cart. it's a separate process (android:process=":cart"), so + // just end it - the launcher is unaffected + Process.killProcess(Process.myPid()); + } + + @SuppressWarnings("deprecation") + private void fullscreen() { + Window window = getWindow(); + // the insets controller hangs off the decor view, which doesn't exist yet + // in onCreate unless asked for + window.getDecorView(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + window.getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + window.setDecorFitsSystemWindows(false); + WindowInsetsController controller = window.getInsetsController(); + if (controller != null) { + controller.hide(WindowInsets.Type.systemBars()); + controller.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } + } else { + // the LAYOUT_ flags keep the surface full-size whether the bars are + // showing or not + window.getDecorView().setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); + } + } + + // called from native (android.c) + public String getCartPath() { + return getIntent().getStringExtra(EXTRA_CART); + } + + // called from native (android.c) + public boolean getShowController() { + return getIntent().getBooleanExtra(EXTRA_CONTROLLER, true); + } +} diff --git a/android/app/src/main/java/games/notnull/null0/CartActivityLandscape.java b/android/app/src/main/java/games/notnull/null0/CartActivityLandscape.java new file mode 100644 index 0000000..251aac4 --- /dev/null +++ b/android/app/src/main/java/games/notnull/null0/CartActivityLandscape.java @@ -0,0 +1,4 @@ +package games.notnull.null0; + +// a cart, locked to landscape (see AndroidManifest.xml) +public class CartActivityLandscape extends CartActivity {} diff --git a/android/app/src/main/java/games/notnull/null0/CartActivityPortrait.java b/android/app/src/main/java/games/notnull/null0/CartActivityPortrait.java new file mode 100644 index 0000000..1628636 --- /dev/null +++ b/android/app/src/main/java/games/notnull/null0/CartActivityPortrait.java @@ -0,0 +1,4 @@ +package games.notnull.null0; + +// a cart, locked to portrait (see AndroidManifest.xml) +public class CartActivityPortrait extends CartActivity {} diff --git a/android/app/src/main/java/games/notnull/null0/LauncherActivity.java b/android/app/src/main/java/games/notnull/null0/LauncherActivity.java new file mode 100644 index 0000000..11ad854 --- /dev/null +++ b/android/app/src/main/java/games/notnull/null0/LauncherActivity.java @@ -0,0 +1,203 @@ +package games.notnull.null0; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.OpenableColumns; +import android.view.View; +import android.widget.ArrayAdapter; +import android.widget.ListView; +import android.widget.RadioGroup; +import android.widget.Switch; +import android.widget.TextView; +import android.widget.Toast; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +// pick a cart to run, and the settings for running it. carts are copied into +// app storage (files/carts), so they show up here again next time +public class LauncherActivity extends Activity { + private static final int REQUEST_OPEN = 1; + private static final String PREF_CONTROLLER = "controller"; + private static final String PREF_PORTRAIT = "portrait"; + + private SharedPreferences prefs; + private ArrayAdapter adapter; + private final List carts = new ArrayList<>(); + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.launcher); + prefs = getSharedPreferences("null0", MODE_PRIVATE); + + Switch controller = findViewById(R.id.controller); + controller.setChecked(prefs.getBoolean(PREF_CONTROLLER, true)); + controller.setOnCheckedChangeListener((view, checked) -> prefs.edit().putBoolean(PREF_CONTROLLER, checked).apply()); + + RadioGroup orientation = findViewById(R.id.orientation); + orientation.check(prefs.getBoolean(PREF_PORTRAIT, false) ? R.id.portrait : R.id.landscape); + orientation.setOnCheckedChangeListener((group, id) -> prefs.edit().putBoolean(PREF_PORTRAIT, id == R.id.portrait).apply()); + + findViewById(R.id.open).setOnClickListener(view -> { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("*/*"); + startActivityForResult(intent, REQUEST_OPEN); + }); + + ListView list = findViewById(R.id.carts); + list.setEmptyView(findViewById(R.id.empty)); + adapter = new ArrayAdapter<>(this, R.layout.cart_item); + list.setAdapter(adapter); + list.setOnItemClickListener((parent, view, position, id) -> play(carts.get(position))); + list.setOnItemLongClickListener((parent, view, position, id) -> { + confirmDelete(carts.get(position)); + return true; + }); + + handleIntent(getIntent()); + } + + @Override + protected void onResume() { + super.onResume(); + refresh(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + handleIntent(intent); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQUEST_OPEN && resultCode == RESULT_OK && data != null && data.getData() != null) { + importAndPlay(data.getData()); + } + } + + // a .null0 opened from somewhere else (file manager, browser download) + private void handleIntent(Intent intent) { + if (intent != null && Intent.ACTION_VIEW.equals(intent.getAction()) && intent.getData() != null) { + importAndPlay(intent.getData()); + // don't import it again on rotation/recreate + setIntent(new Intent(this, LauncherActivity.class)); + } + } + + private File cartDir() { + File dir = new File(getFilesDir(), "carts"); + dir.mkdirs(); + return dir; + } + + private void refresh() { + File[] files = cartDir().listFiles(); + carts.clear(); + if (files != null) { + carts.addAll(Arrays.asList(files)); + } + // most recently played first + carts.sort((a, b) -> Long.compare(b.lastModified(), a.lastModified())); + adapter.clear(); + for (File cart : carts) { + adapter.add(cartName(cart)); + } + } + + private static String cartName(File cart) { + String name = cart.getName(); + int dot = name.lastIndexOf('.'); + return dot > 0 ? name.substring(0, dot) : name; + } + + private void play(File cart) { + cart.setLastModified(System.currentTimeMillis()); + boolean controller = prefs.getBoolean(PREF_CONTROLLER, true); + boolean portrait = prefs.getBoolean(PREF_PORTRAIT, false); + startActivity(CartActivity.intent(this, cart.getAbsolutePath(), controller, portrait)); + } + + private void confirmDelete(File cart) { + new AlertDialog.Builder(this) + .setTitle(getString(R.string.delete_title, cartName(cart))) + .setMessage(R.string.delete_message) + .setPositiveButton(R.string.delete, (dialog, which) -> { + cart.delete(); + refresh(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + // copy the cart into app storage (a content:// uri may not be readable + // later, and the engine wants a plain file), then run it + private void importAndPlay(Uri uri) { + String name = safeName(displayName(uri)); + File dest = new File(cartDir(), name); + new Thread(() -> { + boolean ok = copy(uri, dest); + runOnUiThread(() -> { + if (ok) { + refresh(); + play(dest); + } else { + dest.delete(); + Toast.makeText(this, getString(R.string.import_failed, name), Toast.LENGTH_LONG).show(); + } + }); + }).start(); + } + + private boolean copy(Uri uri, File dest) { + try (InputStream in = getContentResolver().openInputStream(uri); + OutputStream out = new FileOutputStream(dest)) { + if (in == null) { + return false; + } + byte[] buf = new byte[64 * 1024]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + return true; + } catch (Exception e) { + return false; + } + } + + private String displayName(Uri uri) { + if ("content".equals(uri.getScheme())) { + try (Cursor c = getContentResolver().query(uri, new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null)) { + if (c != null && c.moveToFirst() && !c.isNull(0)) { + return c.getString(0); + } + } catch (Exception e) { + // fall through to the path + } + } + String last = uri.getLastPathSegment(); + return last == null ? "cart" : last; + } + + // the name picks the cart's save dir, so keep it a plain filename + private static String safeName(String name) { + name = name.substring(name.lastIndexOf('/') + 1).replaceAll("[^A-Za-z0-9._-]", "_"); + if (!name.endsWith(".null0") && !name.endsWith(".wasm")) { + name = name + ".null0"; + } + return name; + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml new file mode 100644 index 0000000..48b0100 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/cart_item.xml b/android/app/src/main/res/layout/cart_item.xml new file mode 100644 index 0000000..155158e --- /dev/null +++ b/android/app/src/main/res/layout/cart_item.xml @@ -0,0 +1,9 @@ + + diff --git a/android/app/src/main/res/layout/launcher.xml b/android/app/src/main/res/layout/launcher.xml new file mode 100644 index 0000000..8d12c3f --- /dev/null +++ b/android/app/src/main/res/layout/launcher.xml @@ -0,0 +1,93 @@ + + + + + +