From dca9a9c0d95de71fb8effde23af2da0ea74e1647 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:27:27 +0800 Subject: [PATCH] feat(redmi1s): add Android 4.3 GLES2 host --- apps/redmi1s-demo/app.tsx | 16 + apps/redmi1s-demo/main.tsx | 5 + apps/redmi1s-demo/pocket.json | 24 + docs/REDMI1S.md | 58 ++ hosts/android-redmi1s/AndroidManifest.xml | 30 + hosts/android-redmi1s/README.md | 18 + hosts/android-redmi1s/runtime.c | 610 ++++++++++++ hosts/android-redmi1s/rust_abort.c | 11 + package.json | 4 + tests/npm-package.test.ts | 3 + tests/platform-contracts.test.ts | 1 + tests/redmi1s-profile.test.ts | 193 ++++ tests/test-suite.test.ts | 11 + tools/cli/redmi1s-toolchain.json | 28 + tools/iphone-classic-icon.ts | 14 +- tools/redmi1s-icon.ts | 31 + tools/redmi1s-profile.ts | 48 + tools/redmi1s.ts | 1043 +++++++++++++++++++++ tools/test.ts | 1 + 19 files changed, 2148 insertions(+), 1 deletion(-) create mode 100644 apps/redmi1s-demo/app.tsx create mode 100644 apps/redmi1s-demo/main.tsx create mode 100644 apps/redmi1s-demo/pocket.json create mode 100644 docs/REDMI1S.md create mode 100644 hosts/android-redmi1s/AndroidManifest.xml create mode 100644 hosts/android-redmi1s/README.md create mode 100644 hosts/android-redmi1s/runtime.c create mode 100644 hosts/android-redmi1s/rust_abort.c create mode 100644 tests/redmi1s-profile.test.ts create mode 100644 tools/cli/redmi1s-toolchain.json create mode 100644 tools/redmi1s-icon.ts create mode 100644 tools/redmi1s-profile.ts create mode 100644 tools/redmi1s.ts diff --git a/apps/redmi1s-demo/app.tsx b/apps/redmi1s-demo/app.tsx new file mode 100644 index 00000000..24f76ea0 --- /dev/null +++ b/apps/redmi1s-demo/app.tsx @@ -0,0 +1,16 @@ +import { reportAppAction } from "@pocketjs/framework/host"; +import Hero from "../hero/app.tsx"; + +export default function Redmi1SHero() { + return ( + reportAppAction("hero_tap", count)} + presentationHz={60} + runtimeLabel="RUST + QUICKJS + GLES2" + spinnerFrameStep={6} + /> + ); +} diff --git a/apps/redmi1s-demo/main.tsx b/apps/redmi1s-demo/main.tsx new file mode 100644 index 00000000..0843ac87 --- /dev/null +++ b/apps/redmi1s-demo/main.tsx @@ -0,0 +1,5 @@ +// @title PocketJS: Redmi 1S Hero +import { mount } from "@pocketjs/framework/solid"; +import Redmi1SHero from "./app.tsx"; + +mount(() => ); diff --git a/apps/redmi1s-demo/pocket.json b/apps/redmi1s-demo/pocket.json new file mode 100644 index 00000000..886e7f38 --- /dev/null +++ b/apps/redmi1s-demo/pocket.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://pocketjs.dev/schema/pocket-2.json", + "pocket": 2, + "id": "dev.pocket-stack.redmi1s-demo", + "name": "pocketjs-redmi1s-hero", + "title": "PocketJS: Redmi 1S Hero", + "version": "0.1.0", + "engine": { + "capabilities": { + "requires": ["input.touch", "text.glyphs.baked"] + } + }, + "app": { + "entry": "apps/redmi1s-demo/main.tsx", + "output": "redmi1s-demo-main", + "framework": "solid", + "viewport": { + "fixed": { + "logical": [360, 640], + "presentation": "native" + } + } + } +} diff --git a/docs/REDMI1S.md b/docs/REDMI1S.md new file mode 100644 index 00000000..f10969e5 --- /dev/null +++ b/docs/REDMI1S.md @@ -0,0 +1,58 @@ +# Redmi 1S host + +The Redmi 1S host runs PocketJS on the 2014 `armani` handset with MIUI V5, +Android 4.3, and API 18. + +## Hardware contract + +- **The target is the ARMv7 `HM 1S` with a 720x1280 display at 320 dpi.** The + tool refuses a different model, codename, Android release, SDK level, ABI, + display size, or density. +- **The app renders a 360x640 logical viewport into a 720x1280 EGL window.** + Touch coordinates are converted back to the logical viewport before PocketJS + performs hit testing. +- **NativeActivity creates an OpenGL ES 2 context and links `libGLESv2`.** The + PocketJS DrawList backend submits geometry, textures, blending, and frames to + the Adreno 305. Initialization stops if EGL, GLES2, or the expected surface + cannot be created. +- **The APK contains an `armeabi-v7a` native library and targets API 18.** The + Rust core uses the pinned nightly toolchain and the guest runtime uses the + pinned QuickJS revision in `tools/cli/redmi1s-toolchain.json`. +- **The native link rejects unresolved symbols.** Rust panics abort before they + can unwind across the C ABI. +- **The launcher icon is generated from `hosts/iphone4s/Icon.svg`.** The build + writes 48, 72, 96, and 144 pixel Android density assets from this + high-resolution reconstruction of the classic chrome Pocket icon. + +## Build and install + +Run these commands from the repository root: + +```sh +bun redmi1s setup +bun redmi1s doctor +bun redmi1s build +bun redmi1s deploy +bun redmi1s launch +bun redmi1s accept +``` + +`setup` prepares the Rust target, pinned QuickJS source, and local debug signing +key. `doctor` checks the installed SDK/NDK tools and the exact connected phone. +`build` writes the signed APK and a hash receipt under `dist/redmi1s/`. + +## Hardware receipts + +The native host writes `status.txt` inside the app data directory. `bun redmi1s +status` reads it through `run-as` and requires all of these observations: + +- **The installed build ID matches the local build receipt.** +- **The live renderer is GLES2 on `Adreno (TM) 305`.** +- **Guest frame and EGL swap counters advance between samples.** +- **A 720x1280 `glReadPixels` capture succeeds and has a nonzero hash.** +- **The physical surface and logical viewport match the target contract.** + +`bun redmi1s accept` also injects an Android touch and requires the PocketJS +guest to report the `hero_tap` action. It writes the GPU readback to +`dist/redmi1s/device-frame.png` and the Android compositor screenshot to +`dist/redmi1s/device-screen.png` for visual inspection. diff --git a/hosts/android-redmi1s/AndroidManifest.xml b/hosts/android-redmi1s/AndroidManifest.xml new file mode 100644 index 00000000..2b76103e --- /dev/null +++ b/hosts/android-redmi1s/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/hosts/android-redmi1s/README.md b/hosts/android-redmi1s/README.md new file mode 100644 index 00000000..47a9db4f --- /dev/null +++ b/hosts/android-redmi1s/README.md @@ -0,0 +1,18 @@ +# Redmi 1S Android host + +This host packages PocketJS as an Android `NativeActivity` for the Redmi 1S +(`armani`) on MIUI V5/Android 4.3. + +- **The APK contains an ARMv7 native library and no Java runtime.** +- **EGL creates an OpenGL ES 2 context and the PocketJS DrawList backend sends + geometry, texture uploads, blending, and presentation to the Adreno 305.** +- **The app refuses to run if the surface is not 720x1280 or GLES2 setup fails.** +- **The native link rejects unresolved symbols, and Rust panics abort before + they can unwind across the C ABI.** +- **Touch coordinates are converted from the physical surface to the 360x640 + logical viewport before they enter PocketJS.** +- **The launcher icon is rasterized from `hosts/iphone4s/Icon.svg`, the + high-resolution reconstruction of the classic chrome Pocket icon.** + +Use `bun redmi1s build`, `bun redmi1s deploy`, `bun redmi1s launch`, and +`bun redmi1s accept` from the repository root. diff --git a/hosts/android-redmi1s/runtime.c b/hosts/android-redmi1s/runtime.c new file mode 100644 index 00000000..d6a42cbf --- /dev/null +++ b/hosts/android-redmi1s/runtime.c @@ -0,0 +1,610 @@ +#include "pocket_runtime.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef POCKET_BUILD_ID +#define POCKET_BUILD_ID "unknown" +#endif +#ifndef POCKET_LOGICAL_WIDTH +#error "POCKET_LOGICAL_WIDTH must come from the verified ResolvedBuildPlan" +#endif +#ifndef POCKET_LOGICAL_HEIGHT +#error "POCKET_LOGICAL_HEIGHT must come from the verified ResolvedBuildPlan" +#endif +#ifndef POCKET_PHYSICAL_WIDTH +#error "POCKET_PHYSICAL_WIDTH must come from the verified ResolvedBuildPlan" +#endif +#ifndef POCKET_PHYSICAL_HEIGHT +#error "POCKET_PHYSICAL_HEIGHT must come from the verified ResolvedBuildPlan" +#endif + +#define POCKET_TAG "PocketJS-Redmi1S" +#define POCKET_STATUS_FILE "status.txt" +#define POCKET_CAPTURE_FILE "frame.rgba" +#define POCKET_STATUS_HEARTBEAT_FRAMES 60U +#define POCKET_CAPTURE_FRAME 120U +#define POCKET_TICK_US 16667ULL +#define POCKET_PATH_CAPACITY 512 +#define POCKET_TEXT_CAPACITY 256 + +typedef struct { + struct android_app *app; + EGLDisplay display; + EGLSurface surface; + EGLContext context; + int32_t surface_width; + int32_t surface_height; + int focused; + int runtime_ready; + int gl_ready; + int failed; + int touch_down; + int touch_x; + int touch_y; + int touch_hit; + int touch_needs_hit; + int touch_was_sent; + int touch_release_after_frame; + int touch_awaiting_completion; + uint8_t *java_script; + size_t java_script_length; + uint8_t *pack; + size_t pack_length; + uint64_t guest_frames; + uint64_t swaps; + uint64_t touch_sequences; + uint64_t completed_touch_sequences; + uint64_t capture_successes; + uint64_t capture_hash; + uint64_t frame_us_total; + uint64_t swap_us_total; + uint64_t last_frame_us; + uint64_t observed_action_sequence; + char state[32]; + char error[POCKET_TEXT_CAPACITY]; + char gl_vendor[POCKET_TEXT_CAPACITY]; + char gl_renderer[POCKET_TEXT_CAPACITY]; + char gl_version[POCKET_TEXT_CAPACITY]; + char gl_shading_language[POCKET_TEXT_CAPACITY]; +} PocketHost; + +static uint64_t now_us(void) { + struct timespec value; + if (clock_gettime(CLOCK_MONOTONIC, &value) != 0) return 0; + return (uint64_t)value.tv_sec * 1000000ULL + (uint64_t)value.tv_nsec / 1000ULL; +} + +static void copy_text(char *target, size_t capacity, const char *source) { + size_t index = 0; + if (capacity == 0) return; + if (source != NULL) { + while (index + 1 < capacity && source[index] != '\0') { + char value = source[index]; + target[index] = value == '\n' || value == '\r' ? ' ' : value; + index += 1; + } + } + target[index] = '\0'; +} + +static const char *gl_text(GLenum name) { + const GLubyte *value = glGetString(name); + return value == NULL ? "unavailable" : (const char *)value; +} + +static int data_path(PocketHost *host, const char *name, char *path, size_t capacity) { + const char *root = host->app->activity->internalDataPath; + int written; + if (root == NULL) return 0; + written = snprintf(path, capacity, "%s/%s", root, name); + return written >= 0 && (size_t)written < capacity; +} + +static void write_status(PocketHost *host) { + char path[POCKET_PATH_CAPACITY]; + char temporary[POCKET_PATH_CAPACITY]; + FILE *file; + const char *action_name = host->runtime_ready ? pocket_runtime_action_name() : ""; + int action_value = host->runtime_ready ? pocket_runtime_action_value() : 0; + unsigned long action_sequence = host->runtime_ready ? pocket_runtime_action_sequence() : 0; + uint64_t mean_frame_us = host->guest_frames == 0 ? 0 : host->frame_us_total / host->guest_frames; + uint64_t mean_swap_us = host->swaps == 0 ? 0 : host->swap_us_total / host->swaps; + int written; + if (!data_path(host, POCKET_STATUS_FILE, path, sizeof(path))) return; + written = snprintf(temporary, sizeof(temporary), "%s.new", path); + if (written < 0 || (size_t)written >= sizeof(temporary)) return; + file = fopen(temporary, "wb"); + if (file == NULL) return; + fprintf(file, + "schema=1\n" + "build_id=%s\n" + "state=%s\n" + "renderer=%s\n" + "logical_viewport=%dx%d\n" + "physical_viewport=%dx%d\n" + "surface=%dx%d\n" + "guest_frames=%" PRIu64 "\n" + "swaps=%" PRIu64 "\n" + "mean_frame_us=%" PRIu64 "\n" + "mean_swap_us=%" PRIu64 "\n" + "touch_sequences=%" PRIu64 "\n" + "completed_touch_sequences=%" PRIu64 "\n" + "touch_down=%d\n" + "last_touch_x=%d\n" + "last_touch_y=%d\n" + "last_touch_hit=%d\n" + "action_name=%s\n" + "action_value=%d\n" + "action_sequence=%lu\n" + "capture_successes=%" PRIu64 "\n" + "capture_hash=%016" PRIx64 "\n" + "gl_vendor=%s\n" + "gl_renderer=%s\n" + "gl_version=%s\n" + "gl_shading_language=%s\n" + "written_at_us=%" PRIu64 "\n" + "error=%s\n", + POCKET_BUILD_ID, + host->state, + host->gl_ready ? "gles2" : "none", + POCKET_LOGICAL_WIDTH, + POCKET_LOGICAL_HEIGHT, + POCKET_PHYSICAL_WIDTH, + POCKET_PHYSICAL_HEIGHT, + host->surface_width, + host->surface_height, + host->guest_frames, + host->swaps, + mean_frame_us, + mean_swap_us, + host->touch_sequences, + host->completed_touch_sequences, + host->touch_down, + host->touch_x, + host->touch_y, + host->touch_hit, + action_name == NULL ? "" : action_name, + action_value, + action_sequence, + host->capture_successes, + host->capture_hash, + host->gl_vendor, + host->gl_renderer, + host->gl_version, + host->gl_shading_language, + now_us(), + host->error + ); + if (fclose(file) == 0) (void)rename(temporary, path); +} + +static void fail_host(PocketHost *host, const char *message) { + if (host->failed) return; + host->failed = 1; + copy_text(host->state, sizeof(host->state), "failed"); + copy_text(host->error, sizeof(host->error), message); + __android_log_print(ANDROID_LOG_ERROR, POCKET_TAG, "%s", host->error); + write_status(host); +} + +static uint8_t *load_asset( + PocketHost *host, + const char *name, + size_t *length, + int terminate +) { + AAsset *asset = AAssetManager_open(host->app->activity->assetManager, name, AASSET_MODE_BUFFER); + off_t expected; + uint8_t *bytes; + size_t offset = 0; + if (asset == NULL) return NULL; + expected = AAsset_getLength(asset); + if (expected < 0 || (uint64_t)expected > (uint64_t)SIZE_MAX - (terminate ? 1U : 0U)) { + AAsset_close(asset); + return NULL; + } + bytes = (uint8_t *)malloc((size_t)expected + (terminate ? 1U : 0U)); + if (bytes == NULL) { + AAsset_close(asset); + return NULL; + } + while (offset < (size_t)expected) { + int count = AAsset_read(asset, bytes + offset, (size_t)expected - offset); + if (count <= 0) { + free(bytes); + AAsset_close(asset); + return NULL; + } + offset += (size_t)count; + } + AAsset_close(asset); + if (terminate) bytes[offset] = 0; + *length = offset; + return bytes; +} + +static int boot_guest(PocketHost *host) { + if (host->runtime_ready) return 1; + host->java_script = load_asset(host, "app.js", &host->java_script_length, 1); + host->pack = load_asset(host, "app.pak", &host->pack_length, 0); + if (host->java_script == NULL || host->pack == NULL) { + fail_host(host, "APK is missing app.js or app.pak"); + return 0; + } + if (!pocket_runtime_boot( + (const char *)host->java_script, + host->java_script_length, + host->pack, + host->pack_length, + POCKET_LOGICAL_WIDTH, + POCKET_LOGICAL_HEIGHT + )) { + fail_host(host, pocket_runtime_error()); + return 0; + } + host->runtime_ready = 1; + return 1; +} + +static void destroy_egl(PocketHost *host) { + if (host->display != EGL_NO_DISPLAY && host->context != EGL_NO_CONTEXT) { + (void)eglMakeCurrent(host->display, host->surface, host->surface, host->context); + if (host->gl_ready) pocket_runtime_gl_shutdown(); + } + host->gl_ready = 0; + if (host->display != EGL_NO_DISPLAY) { + (void)eglMakeCurrent(host->display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + if (host->context != EGL_NO_CONTEXT) (void)eglDestroyContext(host->display, host->context); + if (host->surface != EGL_NO_SURFACE) (void)eglDestroySurface(host->display, host->surface); + (void)eglTerminate(host->display); + } + host->display = EGL_NO_DISPLAY; + host->surface = EGL_NO_SURFACE; + host->context = EGL_NO_CONTEXT; + host->surface_width = 0; + host->surface_height = 0; +} + +static int create_egl(PocketHost *host) { + const EGLint config_attributes[] = { + EGL_SURFACE_TYPE, EGL_WINDOW_BIT, + EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT, + EGL_RED_SIZE, 8, + EGL_GREEN_SIZE, 8, + EGL_BLUE_SIZE, 8, + EGL_ALPHA_SIZE, 8, + EGL_DEPTH_SIZE, 0, + EGL_NONE + }; + const EGLint context_attributes[] = { EGL_CONTEXT_CLIENT_VERSION, 2, EGL_NONE }; + EGLConfig config; + EGLint count = 0; + EGLint format = 0; + EGLint major = 0; + EGLint minor = 0; + if (host->app->window == NULL) return 0; + destroy_egl(host); + host->display = eglGetDisplay(EGL_DEFAULT_DISPLAY); + if (host->display == EGL_NO_DISPLAY || !eglInitialize(host->display, &major, &minor)) { + fail_host(host, "eglInitialize failed"); + return 0; + } + if (!eglChooseConfig(host->display, config_attributes, &config, 1, &count) || count != 1) { + fail_host(host, "no RGBA8 GLES2 window configuration"); + destroy_egl(host); + return 0; + } + if (!eglGetConfigAttrib(host->display, config, EGL_NATIVE_VISUAL_ID, &format)) { + fail_host(host, "EGL_NATIVE_VISUAL_ID is unavailable"); + destroy_egl(host); + return 0; + } + if (ANativeWindow_setBuffersGeometry(host->app->window, 0, 0, format) != 0) { + fail_host(host, "ANativeWindow buffer geometry failed"); + destroy_egl(host); + return 0; + } + host->surface = eglCreateWindowSurface(host->display, config, host->app->window, NULL); + host->context = eglCreateContext(host->display, config, EGL_NO_CONTEXT, context_attributes); + if (host->surface == EGL_NO_SURFACE || host->context == EGL_NO_CONTEXT || + !eglMakeCurrent(host->display, host->surface, host->surface, host->context)) { + fail_host(host, "GLES2 context creation failed"); + destroy_egl(host); + return 0; + } + if (!eglQuerySurface(host->display, host->surface, EGL_WIDTH, &host->surface_width) || + !eglQuerySurface(host->display, host->surface, EGL_HEIGHT, &host->surface_height)) { + fail_host(host, "EGL surface size is unavailable"); + destroy_egl(host); + return 0; + } + if (host->surface_width != POCKET_PHYSICAL_WIDTH || host->surface_height != POCKET_PHYSICAL_HEIGHT) { + fail_host(host, "EGL surface does not match the verified 720x1280 display"); + destroy_egl(host); + return 0; + } + if (!boot_guest(host)) { + destroy_egl(host); + return 0; + } + if (!pocket_runtime_gl_initialize()) { + fail_host(host, "PocketJS GLES2 DrawList backend initialization failed"); + destroy_egl(host); + return 0; + } + if (!eglSwapInterval(host->display, 1)) { + fail_host(host, "eglSwapInterval(1) failed"); + destroy_egl(host); + return 0; + } + copy_text(host->gl_vendor, sizeof(host->gl_vendor), gl_text(GL_VENDOR)); + copy_text(host->gl_renderer, sizeof(host->gl_renderer), gl_text(GL_RENDERER)); + copy_text(host->gl_version, sizeof(host->gl_version), gl_text(GL_VERSION)); + copy_text(host->gl_shading_language, sizeof(host->gl_shading_language), + gl_text(GL_SHADING_LANGUAGE_VERSION)); + host->gl_ready = 1; + host->last_frame_us = now_us(); + copy_text(host->state, sizeof(host->state), "running"); + copy_text(host->error, sizeof(host->error), ""); + __android_log_print( + ANDROID_LOG_INFO, + POCKET_TAG, + "GLES2 ready: %s / %s / %s", + host->gl_vendor, + host->gl_renderer, + host->gl_version + ); + write_status(host); + return 1; +} + +static void update_touch(PocketHost *host, AInputEvent *event, size_t index) { + float x = AMotionEvent_getX(event, index); + float y = AMotionEvent_getY(event, index); + int logical_x = host->surface_width <= 0 ? 0 : + (int)(x * (float)POCKET_LOGICAL_WIDTH / (float)host->surface_width); + int logical_y = host->surface_height <= 0 ? 0 : + (int)(y * (float)POCKET_LOGICAL_HEIGHT / (float)host->surface_height); + if (logical_x < 0) logical_x = 0; + if (logical_y < 0) logical_y = 0; + if (logical_x >= POCKET_LOGICAL_WIDTH) logical_x = POCKET_LOGICAL_WIDTH - 1; + if (logical_y >= POCKET_LOGICAL_HEIGHT) logical_y = POCKET_LOGICAL_HEIGHT - 1; + host->touch_x = logical_x; + host->touch_y = logical_y; +} + +static void begin_touch(PocketHost *host) { + host->touch_down = 1; + host->touch_sequences += 1; + host->touch_was_sent = 0; + host->touch_release_after_frame = 0; + host->touch_awaiting_completion = 0; + if (host->runtime_ready) { + host->touch_hit = pocket_runtime_hit_test_bounds((float)host->touch_x, (float)host->touch_y); + host->touch_needs_hit = 0; + } else { + host->touch_hit = 0; + host->touch_needs_hit = 1; + } +} + +static void end_touch(PocketHost *host) { + if (!host->touch_down) return; + host->touch_awaiting_completion = 1; + if (host->touch_was_sent) { + host->touch_down = 0; + host->touch_hit = 0; + host->touch_needs_hit = 0; + } else { + host->touch_release_after_frame = 1; + } +} + +static int32_t on_input(struct android_app *app, AInputEvent *event) { + PocketHost *host = (PocketHost *)app->userData; + int32_t action; + int32_t masked; + size_t index; + if (host == NULL || AInputEvent_getType(event) != AINPUT_EVENT_TYPE_MOTION) return 0; + action = AMotionEvent_getAction(event); + masked = action & AMOTION_EVENT_ACTION_MASK; + index = (size_t)((action & AMOTION_EVENT_ACTION_POINTER_INDEX_MASK) >> + AMOTION_EVENT_ACTION_POINTER_INDEX_SHIFT); + if (index >= AMotionEvent_getPointerCount(event)) index = 0; + if (masked == AMOTION_EVENT_ACTION_DOWN) { + update_touch(host, event, index); + begin_touch(host); + } else if (masked == AMOTION_EVENT_ACTION_MOVE && host->touch_down) { + update_touch(host, event, 0); + } else if ((masked == AMOTION_EVENT_ACTION_UP || masked == AMOTION_EVENT_ACTION_CANCEL) && + host->touch_down) { + update_touch(host, event, index); + end_touch(host); + } + return 1; +} + +static void on_command(struct android_app *app, int32_t command) { + PocketHost *host = (PocketHost *)app->userData; + if (host == NULL) return; + switch (command) { + case APP_CMD_INIT_WINDOW: + if (!host->failed) (void)create_egl(host); + break; + case APP_CMD_TERM_WINDOW: + destroy_egl(host); + if (!host->failed) copy_text(host->state, sizeof(host->state), "surface-lost"); + write_status(host); + break; + case APP_CMD_GAINED_FOCUS: + host->focused = 1; + break; + case APP_CMD_LOST_FOCUS: + host->focused = 0; + write_status(host); + break; + case APP_CMD_WINDOW_RESIZED: + if (host->gl_ready) { + (void)eglQuerySurface(host->display, host->surface, EGL_WIDTH, &host->surface_width); + (void)eglQuerySurface(host->display, host->surface, EGL_HEIGHT, &host->surface_height); + } + break; + default: + break; + } +} + +static uint64_t fnv1a64(const uint8_t *bytes, size_t length) { + uint64_t hash = 1469598103934665603ULL; + size_t index; + for (index = 0; index < length; index += 1) { + hash ^= bytes[index]; + hash *= 1099511628211ULL; + } + return hash; +} + +static void capture_frame(PocketHost *host) { + char path[POCKET_PATH_CAPACITY]; + size_t length; + uint8_t *pixels; + FILE *file; + size_t written; + int closed; + if (host->capture_successes != 0 || !host->gl_ready) return; + length = (size_t)host->surface_width * (size_t)host->surface_height * 4U; + pixels = (uint8_t *)malloc(length); + if (pixels == NULL) return; + glFinish(); + glReadPixels(0, 0, host->surface_width, host->surface_height, GL_RGBA, GL_UNSIGNED_BYTE, pixels); + if (glGetError() != GL_NO_ERROR || !data_path(host, POCKET_CAPTURE_FILE, path, sizeof(path))) { + free(pixels); + return; + } + file = fopen(path, "wb"); + if (file == NULL) { + free(pixels); + return; + } + written = fwrite(pixels, 1, length, file); + closed = fclose(file); + if (written == length && closed == 0) { + host->capture_hash = fnv1a64(pixels, length); + host->capture_successes = 1; + } + free(pixels); +} + +static void render_frame(PocketHost *host) { + uint64_t started = now_us(); + uint64_t elapsed = host->last_frame_us == 0 || started <= host->last_frame_us + ? POCKET_TICK_US + : started - host->last_frame_us; + unsigned int ticks = (unsigned int)((elapsed + POCKET_TICK_US / 2U) / POCKET_TICK_US); + int delivered_touch; + int completed_touch; + uint64_t swap_started; + if (ticks < 1U) ticks = 1U; + if (ticks > 4U) ticks = 4U; + host->last_frame_us = started; + if (host->touch_down && host->touch_needs_hit) { + host->touch_hit = pocket_runtime_hit_test_bounds((float)host->touch_x, (float)host->touch_y); + host->touch_needs_hit = 0; + } + delivered_touch = host->touch_down; + if (!pocket_runtime_frame_ticks( + host->touch_down, + host->touch_x, + host->touch_y, + host->touch_hit, + ticks + )) { + fail_host(host, pocket_runtime_error()); + return; + } + if (delivered_touch) host->touch_was_sent = 1; + if (host->touch_release_after_frame) { + host->touch_down = 0; + host->touch_hit = 0; + host->touch_needs_hit = 0; + host->touch_release_after_frame = 0; + } + completed_touch = !delivered_touch && host->touch_awaiting_completion; + if (completed_touch) { + host->completed_touch_sequences += 1; + host->touch_awaiting_completion = 0; + } + if (!pocket_runtime_gl_render(host->surface_width, host->surface_height)) { + fail_host(host, "PocketJS GLES2 DrawList render failed"); + return; + } + host->guest_frames += 1; + if (host->guest_frames == POCKET_CAPTURE_FRAME) capture_frame(host); + swap_started = now_us(); + if (!eglSwapBuffers(host->display, host->surface)) { + fail_host(host, "eglSwapBuffers failed"); + return; + } + host->swaps += 1; + host->frame_us_total += swap_started - started; + host->swap_us_total += now_us() - swap_started; + if (host->guest_frames == 1 || completed_touch || + pocket_runtime_action_sequence() != host->observed_action_sequence || + host->guest_frames % POCKET_STATUS_HEARTBEAT_FRAMES == 0) { + host->observed_action_sequence = pocket_runtime_action_sequence(); + write_status(host); + } +} + +void android_main(struct android_app *app) { + PocketHost host; + memset(&host, 0, sizeof(host)); + host.app = app; + host.display = EGL_NO_DISPLAY; + host.surface = EGL_NO_SURFACE; + host.context = EGL_NO_CONTEXT; + copy_text(host.state, sizeof(host.state), "starting"); + app->userData = &host; + app->onAppCmd = on_command; + app->onInputEvent = on_input; + ANativeActivity_setWindowFlags( + app->activity, + AWINDOW_FLAG_FULLSCREEN | AWINDOW_FLAG_KEEP_SCREEN_ON, + AWINDOW_FLAG_FORCE_NOT_FULLSCREEN + ); + write_status(&host); + while (!app->destroyRequested) { + int events; + struct android_poll_source *source; + int identifier; + int timeout = host.gl_ready && host.focused && !host.failed ? 0 : -1; + while ((identifier = ALooper_pollAll(timeout, NULL, &events, (void **)&source)) >= 0) { + if (source != NULL) source->process(app, source); + if (app->destroyRequested) break; + timeout = 0; + } + if (app->destroyRequested) break; + if (host.gl_ready && host.focused && !host.failed) render_frame(&host); + } + destroy_egl(&host); + if (host.runtime_ready) pocket_runtime_shutdown(); + free(host.java_script); + free(host.pack); + copy_text(host.state, sizeof(host.state), "terminated"); + write_status(&host); +} diff --git a/hosts/android-redmi1s/rust_abort.c b/hosts/android-redmi1s/rust_abort.c new file mode 100644 index 00000000..268617d7 --- /dev/null +++ b/hosts/android-redmi1s/rust_abort.c @@ -0,0 +1,11 @@ +#include + +/* + * The core is built with panic=abort. Rust's precompiled ARMv7 core library + * still references this personality from unwind metadata, but control must + * never unwind across the C ABI. Abort if an unwinder ever reaches it. + */ +__attribute__((noreturn, visibility("hidden"))) +void rust_eh_personality(void) { + abort(); +} diff --git a/package.json b/package.json index cef70d78..832e4be1 100644 --- a/package.json +++ b/package.json @@ -30,18 +30,21 @@ "apps/iphone4s-demo", "apps/ipodtouch-demo", "apps/meizu-m8-demo", + "apps/redmi1s-demo", "apps/nsengine", "hosts/apple", "hosts/iphone2g", "hosts/iphone4s", "hosts/ipodtouch", "hosts/meizu-m8", + "hosts/android-redmi1s", "hosts/web", "docs/APPLE.md", "docs/IPHONE2G.md", "docs/IPHONE4S.md", "docs/IPODTOUCH.md", "docs/MEIZU_M8.md", + "docs/REDMI1S.md", "assets/brand", "assets/fonts", "assets/images/logo.png", @@ -203,6 +206,7 @@ "iphone4s": "bun tools/iphone4s.ts", "ipodtouch": "bun tools/ipodtouch.ts", "meizu-m8": "bun tools/meizu-m8.ts", + "redmi1s": "bun tools/redmi1s.ts", "vita:art": "bun tools/generate-vita-livearea.ts", "vita:art:check": "bun tools/generate-vita-livearea.ts --check", "e2e:vita": "bun tests/e2e/vita3k.ts", diff --git a/tests/npm-package.test.ts b/tests/npm-package.test.ts index b9a2a866..da8b4e28 100644 --- a/tests/npm-package.test.ts +++ b/tests/npm-package.test.ts @@ -61,18 +61,21 @@ describe("published npm artifacts", () => { "apps/iphone4s-demo", "apps/ipodtouch-demo", "apps/meizu-m8-demo", + "apps/redmi1s-demo", "apps/nsengine", "hosts/apple", "hosts/iphone2g", "hosts/iphone4s", "hosts/ipodtouch", "hosts/meizu-m8", + "hosts/android-redmi1s", "hosts/web", "docs/APPLE.md", "docs/IPHONE2G.md", "docs/IPHONE4S.md", "docs/IPODTOUCH.md", "docs/MEIZU_M8.md", + "docs/REDMI1S.md", "assets/brand", "assets/fonts", "assets/images/logo.png", diff --git a/tests/platform-contracts.test.ts b/tests/platform-contracts.test.ts index c5be4d56..e487be32 100644 --- a/tests/platform-contracts.test.ts +++ b/tests/platform-contracts.test.ts @@ -348,6 +348,7 @@ describe("semantic resolution", () => { "iphone4s-demo": [false, false, false], // admitted only by the private iphone4s-dev profile "ipodtouch-demo": [false, false, false], // admitted only by the private ipodtouch-dev profile "meizu-m8-demo": [false, false, false], // admitted only by the private meizu-m8-dev profile + "redmi1s-demo": [false, false, false], // admitted only by the private android-redmi1s-dev profile nsengine: [false, true, false], // targets the private ios-dev profile; vita shares its touch + integer-fit contract "ipod-nano": [false, false, false], // admitted by the package-shaped macos-embedded target launcher: [true, true, false], // the Cover Flow deck (docs/LAUNCHER.md) is an ordinary console app diff --git a/tests/redmi1s-profile.test.ts b/tests/redmi1s-profile.test.ts new file mode 100644 index 00000000..3f6d2a56 --- /dev/null +++ b/tests/redmi1s-profile.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { loadImage } from "@napi-rs/canvas"; +import { POCKET_TARGETS } from "../contracts/spec/platforms.ts"; +import { checkAppTypes } from "../framework/compiler/app-check.ts"; +import { verifyPlanHash } from "../framework/src/manifest/plan.ts"; +import { + REDMI1S_DEV_CONTRACTS, + REDMI1S_DEV_HOST_ABI, + REDMI1S_DEV_TARGET_ID, + REDMI1S_LOGICAL_VIEWPORT, + REDMI1S_PHYSICAL_VIEWPORT, + REDMI1S_RASTER_DENSITY, + resolveRedmi1SBuildPlan, +} from "../tools/redmi1s-profile.ts"; +import { + bakeRedmi1SArtwork, + REDMI1S_ICON_OUTPUTS, + REDMI1S_ICON_SOURCE, +} from "../tools/redmi1s-icon.ts"; + +const repository = join(import.meta.dir, ".."); +const manifestPath = join(repository, "apps/redmi1s-demo/pocket.json"); + +function manifest(): Record { + return JSON.parse(readFileSync(manifestPath, "utf8")); +} + +describe("private Redmi 1S build profile", () => { + test("pins the Android 4.3 ARMv7 takeover surface", () => { + expect(POCKET_TARGETS).not.toHaveProperty(REDMI1S_DEV_TARGET_ID); + expect(REDMI1S_DEV_CONTRACTS.targets[REDMI1S_DEV_TARGET_ID]).toEqual({ + hostAbi: REDMI1S_DEV_HOST_ABI, + platform: "android", + form: "takeover", + display: { + physicalViewport: REDMI1S_PHYSICAL_VIEWPORT, + logicalViewports: [REDMI1S_LOGICAL_VIEWPORT], + presentations: ["native"], + rasterDensity: REDMI1S_RASTER_DENSITY, + }, + capabilities: ["input.touch", "text.glyphs.baked"], + }); + }); + + test("resolves the Hero to the exact hardware plan", () => { + const plan = resolveRedmi1SBuildPlan(manifest()); + expect(plan.target).toEqual({ + id: REDMI1S_DEV_TARGET_ID, + hostAbi: REDMI1S_DEV_HOST_ABI, + }); + expect(plan.viewport).toEqual({ + logical: REDMI1S_LOGICAL_VIEWPORT, + physical: REDMI1S_PHYSICAL_VIEWPORT, + presentation: "native", + rasterDensity: REDMI1S_RASTER_DENSITY, + }); + expect(plan.app.entry).toBe("apps/redmi1s-demo/main.tsx"); + expect(plan.app.output).toBe("redmi1s-demo-main"); + expect(verifyPlanHash(plan)).toBe(true); + }); + + test("rejects unsupported buttons and a mismatched logical viewport", () => { + const needsButtons = manifest(); + needsButtons.engine.capabilities.requires.push("input.buttons"); + expect(() => resolveRedmi1SBuildPlan(needsButtons)).toThrow( + "input.buttons", + ); + + const stretched = manifest(); + stretched.app.viewport.fixed.logical = [720, 1280]; + expect(() => resolveRedmi1SBuildPlan(stretched)).toThrow("720x1280"); + }); + + test("type-checks explicit PocketJS imports in the Solid demo", () => { + const result = checkAppTypes({ + entry: join(repository, "apps/redmi1s-demo/main.tsx"), + tsconfigPath: join(repository, "tsconfig.json"), + declarationFiles: [join(repository, "framework/src/jsx.d.ts")], + }); + expect( + result.diagnostics + .filter((diagnostic) => diagnostic.category === "error") + .map((diagnostic) => diagnostic.message), + ).toEqual([]); + expect(result.ok).toBe(true); + }); + + test("pins the connected phone and the API 18 build inputs", () => { + const toolchain = JSON.parse( + readFileSync( + join(repository, "tools/cli/redmi1s-toolchain.json"), + "utf8", + ), + ); + expect(toolchain).toMatchObject({ + toolchainVersion: "android-4.3-armv7-gles2-v1", + device: { + model: "HM 1S", + codename: "armani", + boardPlatform: "msm8226", + androidRelease: "4.3", + sdk: "18", + abi: "armeabi-v7a", + physicalViewport: [720, 1280], + density: 320, + gpuRenderer: "Adreno (TM) 305", + }, + compiler: { + compileSdk: "34", + buildToolsVersion: "34.0.0", + ndkVersion: "21.4.7075529", + minimumSdk: "18", + rustTarget: "armv7-linux-androideabi", + }, + }); + expect(toolchain.compiler.quickJsRevision).toMatch(/^[0-9a-f]{40}$/); + }); + + test("requires an accelerated GLES2 NativeActivity with no software fallback", () => { + const manifest = readFileSync( + join(repository, "hosts/android-redmi1s/AndroidManifest.xml"), + "utf8", + ); + const runtime = readFileSync( + join(repository, "hosts/android-redmi1s/runtime.c"), + "utf8", + ); + const rustAbort = readFileSync( + join(repository, "hosts/android-redmi1s/rust_abort.c"), + "utf8", + ); + const tool = readFileSync(join(repository, "tools/redmi1s.ts"), "utf8"); + expect(manifest).toContain('android:minSdkVersion="18"'); + expect(manifest).toContain( + 'android:glEsVersion="0x00020000" android:required="true"', + ); + expect(manifest).toContain('android:hardwareAccelerated="true"'); + expect(manifest).toContain('android:name="android.app.NativeActivity"'); + expect(runtime).toContain("EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT"); + expect(runtime).toContain("EGL_CONTEXT_CLIENT_VERSION, 2"); + expect(runtime).toContain("pocket_runtime_gl_initialize()"); + expect(runtime).toContain( + "pocket_runtime_gl_render(host->surface_width, host->surface_height)", + ); + expect(runtime).toContain( + 'copy_text(host->state, sizeof(host->state), "failed")', + ); + expect(runtime).toContain('snprintf(path, capacity, "%s/%s", root, name)'); + expect(runtime).not.toContain( + 'snprintf(directory, sizeof(directory), "%s/files", root)', + ); + expect(runtime).not.toContain("pocket_runtime_render("); + expect(rustAbort).toContain("void rust_eh_personality(void)"); + expect(rustAbort).toContain("abort();"); + expect(tool).toContain('\"-Wl,--no-undefined\"'); + }); + + test("bakes the high-resolution classic icon at every Android density", async () => { + const output = mkdtempSync(join(tmpdir(), "pocket-redmi1s-icons-")); + try { + expect(REDMI1S_ICON_SOURCE).toBe( + join(repository, "hosts/iphone4s/Icon.svg"), + ); + const written = await bakeRedmi1SArtwork(output); + expect(written).toHaveLength(Object.keys(REDMI1S_ICON_OUTPUTS).length); + for (const [density, size] of Object.entries(REDMI1S_ICON_OUTPUTS)) { + const icon = await loadImage(join(output, density, "icon.png")); + expect([icon.width, icon.height]).toEqual([size, size]); + } + } finally { + rmSync(output, { recursive: true, force: true }); + } + }); + + test("ties hardware acceptance to GPU, frame, capture, and touch receipts", () => { + const tool = readFileSync(join(repository, "tools/redmi1s.ts"), "utf8"); + for (const marker of [ + "verifyDeviceIdentity()", + 'second.renderer !== "gles2"', + "second.gl_renderer.includes(TOOLCHAIN.device.gpuRenderer)", + "/^OpenGL ES (\\d+)\\.(\\d+)/.exec(second.gl_version)", + 'numeric(second, "guest_frames") <= numeric(first, "guest_frames")', + 'numeric(second, "swaps") <= numeric(first, "swaps")', + 'numeric(second, "capture_successes") < 1', + 'second.action_name !== "hero_tap"', + ]) { + expect(tool).toContain(marker); + } + }); +}); diff --git a/tests/test-suite.test.ts b/tests/test-suite.test.ts index 572b9c93..bbe17a56 100644 --- a/tests/test-suite.test.ts +++ b/tests/test-suite.test.ts @@ -74,4 +74,15 @@ describe("declared test suite", () => { expect(ipodtouchTests).not.toHaveLength(0); expect(ipodtouchTests.filter((file) => !declared.has(file))).toEqual([]); }); + + test("runs every Redmi 1S test in the CI unit stage", () => { + const declared = unitTestFiles(); + const redmi1sTests = readdirSync(join(repository, "tests")) + .filter((file) => /^redmi1s-.*\.test\.ts$/.test(file)) + .map((file) => `tests/${file}`) + .sort(); + + expect(redmi1sTests).not.toHaveLength(0); + expect(redmi1sTests.filter((file) => !declared.has(file))).toEqual([]); + }); }); diff --git a/tools/cli/redmi1s-toolchain.json b/tools/cli/redmi1s-toolchain.json new file mode 100644 index 00000000..366a938d --- /dev/null +++ b/tools/cli/redmi1s-toolchain.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "toolchainVersion": "android-4.3-armv7-gles2-v1", + "device": { + "model": "HM 1S", + "codename": "armani", + "boardPlatform": "msm8226", + "androidRelease": "4.3", + "sdk": "18", + "abi": "armeabi-v7a", + "physicalViewport": [720, 1280], + "density": 320, + "gpuRenderer": "Adreno (TM) 305", + "requiredGlEsVersion": "0x00020000" + }, + "compiler": { + "androidSdkRoot": "/opt/homebrew/share/android-commandlinetools", + "compileSdk": "34", + "buildToolsVersion": "34.0.0", + "ndkVersion": "21.4.7075529", + "minimumSdk": "18", + "rustToolchain": "nightly-2026-07-02", + "rustTarget": "armv7-linux-androideabi", + "quickJsRepository": "https://github.com/pocket-stack/quickjs-rs.git", + "quickJsRevision": "ba5bdd0dc013518768e76cd9e05cd30ed53dd35b", + "quickJsVersion": "2026-06-04" + } +} diff --git a/tools/iphone-classic-icon.ts b/tools/iphone-classic-icon.ts index 273f521c..e7895fa1 100644 --- a/tools/iphone-classic-icon.ts +++ b/tools/iphone-classic-icon.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; const REPOSITORY = fileURLToPath(new URL("..", import.meta.url)); -/** The installed iPhone 2G artwork is the single source for legacy iPhone icons. */ +/** Source artwork for the classic Pocket icon and its high-resolution reconstruction. */ export const IPHONE_CLASSIC_ICON_SOURCE = resolve(REPOSITORY, "hosts/iphone2g/Icon.png"); export const IPHONE_CLASSIC_RETINA_SOURCE = resolve(REPOSITORY, "hosts/iphone4s/Icon.svg"); export const IPHONE_CLASSIC_ICON_FILE = "PocketClassic-v3.png"; @@ -64,6 +64,18 @@ async function rasterizeRetinaArtwork(width: number, height: number): Promise { + if (!Number.isInteger(size) || size <= 0) { + throw new TypeError("pocket classic artwork: icon size must be a positive integer"); + } + const artworkWidth = Math.max(1, Math.round(size * 590 / 600)); + const artwork = await rasterizeRetinaArtwork(artworkWidth, size); + const output = createCanvas(size, size); + output.getContext("2d").drawImage(artwork, Math.floor((size - artworkWidth) / 2), 0); + return output; +} + function assertOpaque(canvas: Canvas): void { const pixels = canvas.getContext("2d").getImageData(0, 0, canvas.width, canvas.height).data; for (let index = 3; index < pixels.length; index += 4) { diff --git a/tools/redmi1s-icon.ts b/tools/redmi1s-icon.ts new file mode 100644 index 00000000..d48c7edb --- /dev/null +++ b/tools/redmi1s-icon.ts @@ -0,0 +1,31 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { rasterizeClassicPocketIcon } from "./iphone-classic-icon.ts"; + +export const REDMI1S_ICON_SOURCE = resolve( + import.meta.dir, + "../hosts/iphone4s/Icon.svg", +); + +export const REDMI1S_ICON_OUTPUTS = { + "drawable-mdpi": 48, + "drawable-hdpi": 72, + "drawable-xhdpi": 96, + "drawable-xxhdpi": 144, +} as const; + +/** Bake the classic iPod-era chrome icon at Android launcher densities. */ +export async function bakeRedmi1SArtwork( + resourceDirectory: string, +): Promise { + const written: string[] = []; + for (const [density, size] of Object.entries(REDMI1S_ICON_OUTPUTS)) { + const directory = join(resourceDirectory, density); + const target = join(directory, "icon.png"); + mkdirSync(directory, { recursive: true }); + const icon = await rasterizeClassicPocketIcon(size); + writeFileSync(target, icon.toBuffer("image/png")); + written.push(target); + } + return written; +} diff --git a/tools/redmi1s-profile.ts b/tools/redmi1s-profile.ts new file mode 100644 index 00000000..84e7ab9a --- /dev/null +++ b/tools/redmi1s-profile.ts @@ -0,0 +1,48 @@ +import { + POCKET_CAPABILITIES, + definePlatformContractRegistry, + defineTargetRegistry, +} from "../contracts/spec/platforms.ts"; +import type { ResolvedBuildPlan } from "../framework/src/manifest/plan.ts"; +import { validateAndResolveBuildPlan } from "../framework/src/manifest/resolve.ts"; + +/** Private exact-device profile for the Redmi 1S running MIUI V5/Android 4.3. */ +export const REDMI1S_DEV_TARGET_ID = "android-redmi1s-dev"; +export const REDMI1S_DEV_HOST_ABI = 9; +export const REDMI1S_LOGICAL_VIEWPORT = [360, 640] as const; +export const REDMI1S_PHYSICAL_VIEWPORT = [720, 1280] as const; +export const REDMI1S_RASTER_DENSITY = 2; + +export const REDMI1S_DEV_CONTRACTS = definePlatformContractRegistry( + POCKET_CAPABILITIES, + defineTargetRegistry({ + [REDMI1S_DEV_TARGET_ID]: { + hostAbi: REDMI1S_DEV_HOST_ABI, + platform: "android", + form: "takeover", + display: { + physicalViewport: REDMI1S_PHYSICAL_VIEWPORT, + logicalViewports: [REDMI1S_LOGICAL_VIEWPORT], + presentations: ["native"], + rasterDensity: REDMI1S_RASTER_DENSITY, + }, + capabilities: ["input.touch", "text.glyphs.baked"], + }, + }), +); + +export function resolveRedmi1SBuildPlan(input: unknown): ResolvedBuildPlan { + const resolution = validateAndResolveBuildPlan( + input, + { target: REDMI1S_DEV_TARGET_ID }, + REDMI1S_DEV_CONTRACTS, + ); + if (!resolution.ok) { + throw new Error( + `pocket redmi1s: manifest did not resolve: ${resolution.diagnostics + .map((diagnostic) => `${diagnostic.path || "/"}: ${diagnostic.message}`) + .join("; ")}`, + ); + } + return resolution.plan; +} diff --git a/tools/redmi1s.ts b/tools/redmi1s.ts new file mode 100644 index 00000000..20228b09 --- /dev/null +++ b/tools/redmi1s.ts @@ -0,0 +1,1043 @@ +import { createHash } from "node:crypto"; +import { createCanvas, loadImage } from "@napi-rs/canvas"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { extractHostBuildInputs } from "../framework/src/manifest/host-build-inputs.ts"; +import { bakeRedmi1SArtwork } from "./redmi1s-icon.ts"; +import { + REDMI1S_DEV_TARGET_ID, + resolveRedmi1SBuildPlan, +} from "./redmi1s-profile.ts"; +import toolchainJson from "./cli/redmi1s-toolchain.json"; + +const REPOSITORY = fileURLToPath(new URL("..", import.meta.url)); +const PACKAGE_NAME = "dev.pocket_stack.redmi1s_demo"; +const COMPONENT = `${PACKAGE_NAME}/android.app.NativeActivity`; +const APK_NAME = "PocketJS-Redmi1S.apk"; +const BUILD_ID_PLACEHOLDER = "00000000000000000000000000000000"; + +interface ToolchainManifest { + readonly schemaVersion: 1; + readonly toolchainVersion: string; + readonly device: { + readonly model: string; + readonly codename: string; + readonly boardPlatform: string; + readonly androidRelease: string; + readonly sdk: string; + readonly abi: string; + readonly physicalViewport: readonly [number, number]; + readonly density: number; + readonly gpuRenderer: string; + readonly requiredGlEsVersion: string; + }; + readonly compiler: { + readonly androidSdkRoot: string; + readonly compileSdk: string; + readonly buildToolsVersion: string; + readonly ndkVersion: string; + readonly minimumSdk: string; + readonly rustToolchain: string; + readonly rustTarget: string; + readonly quickJsRepository: string; + readonly quickJsRevision: string; + readonly quickJsVersion: string; + }; +} + +interface CommandResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +interface BuildReceipt { + readonly schema: 1; + readonly buildId: string; + readonly packageName: string; + readonly target: string; + readonly hostAbi: number; + readonly minimumSdk: string; + readonly abi: "armeabi-v7a"; + readonly files: Readonly>; +} + +type DeviceStatus = Readonly>; + +const TOOLCHAIN = toolchainJson as ToolchainManifest; + +function run( + executable: string, + args: readonly string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv; input?: Uint8Array } = {}, +): CommandResult { + const result = Bun.spawnSync({ + cmd: [executable, ...args], + cwd: options.cwd ?? REPOSITORY, + env: options.env ?? process.env, + stdin: options.input, + stdout: "pipe", + stderr: "pipe", + }); + return { + exitCode: result.exitCode, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +function mustRun( + executable: string, + args: readonly string[], + options: { cwd?: string; env?: NodeJS.ProcessEnv; input?: Uint8Array } = {}, +): string { + const result = run(executable, args, options); + if (result.exitCode !== 0) { + const detail = [result.stdout.trim(), result.stderr.trim()] + .filter(Boolean) + .join("\n"); + throw new Error( + `pocket redmi1s: ${executable} ${args.join(" ")} failed (${result.exitCode})${ + detail ? `:\n${detail}` : "" + }`, + ); + } + return result.stdout.trim(); +} + +function commandPath(name: string): string | undefined { + return Bun.which(name) ?? undefined; +} + +function sha256(path: string): string { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +function hashInputs( + inputs: readonly { label: string; path: string }[], +): string { + const hash = createHash("sha256"); + for (const input of inputs) { + const bytes = readFileSync(input.path); + hash.update( + `${Buffer.byteLength(input.label)}:${input.label}${bytes.byteLength}:`, + ); + hash.update(bytes); + } + return hash.digest("hex").slice(0, 32); +} + +function cacheRoot(): string { + const base = process.env.POCKET_STACK_CACHE_DIR?.trim() + ? resolve(process.env.POCKET_STACK_CACHE_DIR.trim()) + : join(homedir(), ".cache/pocket-stack"); + return join(base, "redmi1s"); +} + +function sdkRoot(): string { + const configured = + process.env.ANDROID_HOME?.trim() || process.env.ANDROID_SDK_ROOT?.trim(); + return resolve(configured || TOOLCHAIN.compiler.androidSdkRoot); +} + +function ndkRoot(): string { + return join(sdkRoot(), "ndk", TOOLCHAIN.compiler.ndkVersion); +} + +function buildToolsRoot(): string { + return join(sdkRoot(), "build-tools", TOOLCHAIN.compiler.buildToolsVersion); +} + +function sdkManagerPath(): string { + return join(sdkRoot(), "cmdline-tools/latest/bin/sdkmanager"); +} + +function androidJar(): string { + return join( + sdkRoot(), + "platforms", + `android-${TOOLCHAIN.compiler.compileSdk}`, + "android.jar", + ); +} + +function ndkToolchain(): string { + return join(ndkRoot(), "toolchains/llvm/prebuilt/darwin-x86_64"); +} + +function clangPath(): string { + return join( + ndkToolchain(), + "bin", + `armv7a-linux-androideabi${TOOLCHAIN.compiler.minimumSdk}-clang`, + ); +} + +function quickJsRoot(): string { + return join( + cacheRoot(), + "sources", + `quickjs-rs-${TOOLCHAIN.compiler.quickJsRevision}`, + ); +} + +function quickJsSource(): string { + return join(quickJsRoot(), "libquickjs-sys/embed/quickjs"); +} + +function manifestPath(): string { + return join(REPOSITORY, "apps/redmi1s-demo/pocket.json"); +} + +function planPath(): string { + return join(REPOSITORY, ".pocket/redmi1s/redmi1s-demo.plan.json"); +} + +function guestDirectory(): string { + return join(REPOSITORY, "dist/redmi1s/guest"); +} + +function outputDirectory(): string { + return join(REPOSITORY, "dist/redmi1s"); +} + +function apkPath(): string { + return join(outputDirectory(), APK_NAME); +} + +function receiptPath(): string { + return join(outputDirectory(), "build-receipt.json"); +} + +function nativeBuildDirectory(): string { + return join(REPOSITORY, ".pocket-build/redmi1s/runtime"); +} + +function rustTargetDirectory(): string { + return join(REPOSITORY, ".pocket-build/redmi1s/rust-target"); +} + +function readReceipt(): BuildReceipt { + if (!existsSync(receiptPath())) { + throw new Error("pocket redmi1s: no built APK; run `bun redmi1s build`"); + } + return JSON.parse(readFileSync(receiptPath(), "utf8")) as BuildReceipt; +} + +function quickJsReady(): boolean { + const root = quickJsRoot(); + const version = join(quickJsSource(), "VERSION"); + if (!existsSync(join(root, ".git/HEAD")) || !existsSync(version)) + return false; + const revision = run("git", ["-C", root, "rev-parse", "HEAD"]); + const tracked = run("git", [ + "-C", + root, + "status", + "--porcelain=v1", + "--untracked-files=no", + ]); + return ( + revision.exitCode === 0 && + revision.stdout.trim() === TOOLCHAIN.compiler.quickJsRevision && + tracked.exitCode === 0 && + tracked.stdout.trim() === "" && + readFileSync(version, "utf8").trim() === TOOLCHAIN.compiler.quickJsVersion + ); +} + +function setupQuickJs(): void { + if (quickJsReady()) return; + const root = quickJsRoot(); + const staging = `${root}.staging`; + rmSync(staging, { recursive: true, force: true }); + mkdirSync(dirname(staging), { recursive: true }); + mustRun("git", [ + "clone", + "--no-checkout", + TOOLCHAIN.compiler.quickJsRepository, + staging, + ]); + mustRun("git", [ + "-C", + staging, + "checkout", + "--detach", + TOOLCHAIN.compiler.quickJsRevision, + ]); + if (existsSync(root)) rmSync(root, { recursive: true, force: true }); + renameSync(staging, root); + if (!quickJsReady()) + throw new Error( + "pocket redmi1s: checked-out QuickJS source failed verification", + ); +} + +function setupRustTarget(): void { + const installed = mustRun("rustup", [ + "target", + "list", + "--installed", + "--toolchain", + TOOLCHAIN.compiler.rustToolchain, + ]); + if (!installed.split("\n").includes(TOOLCHAIN.compiler.rustTarget)) { + mustRun("rustup", [ + "target", + "add", + "--toolchain", + TOOLCHAIN.compiler.rustToolchain, + TOOLCHAIN.compiler.rustTarget, + ]); + } +} + +function setupNdk(): void { + if (existsSync(clangPath())) return; + const sdkManager = sdkManagerPath(); + if (!existsSync(sdkManager)) { + throw new Error( + `pocket redmi1s: sdkmanager is unavailable at ${sdkManager}`, + ); + } + mustRun(sdkManager, ["--install", `ndk;${TOOLCHAIN.compiler.ndkVersion}`]); + if (!existsSync(clangPath())) { + throw new Error( + `pocket redmi1s: NDK ${TOOLCHAIN.compiler.ndkVersion} has no API ${TOOLCHAIN.compiler.minimumSdk} ARMv7 clang wrapper`, + ); + } +} + +function signingKeyPath(): string { + return join(cacheRoot(), "signing/pocketjs-redmi1s-debug.keystore"); +} + +function ensureSigningKey(): string { + const path = signingKeyPath(); + if (existsSync(path)) return path; + const keytool = join(process.env.JAVA_HOME || "", "bin/keytool"); + const executable = existsSync(keytool) ? keytool : commandPath("keytool"); + if (!executable) throw new Error("pocket redmi1s: keytool is unavailable"); + mkdirSync(dirname(path), { recursive: true }); + mustRun(executable, [ + "-genkeypair", + "-keystore", + path, + "-storepass", + "android", + "-keypass", + "android", + "-alias", + "pocketjs", + "-keyalg", + "RSA", + "-keysize", + "2048", + "-validity", + "10000", + "-dname", + "CN=PocketJS Redmi 1S,O=PocketJS,C=US", + ]); + return path; +} + +function connectedSerial(): string { + const requested = process.env.POCKETJS_REDMI1S_SERIAL?.trim(); + const devices = mustRun("adb", ["devices"]) + .split("\n") + .slice(1) + .map((line) => line.trim().split(/\s+/)) + .filter((parts) => parts.length >= 2 && parts[1] === "device") + .map((parts) => parts[0]); + if (requested) { + if (!devices.includes(requested)) { + throw new Error( + `pocket redmi1s: requested device ${requested} is not connected and authorized`, + ); + } + return requested; + } + if (devices.length !== 1) { + throw new Error( + `pocket redmi1s: expected exactly one authorized ADB device, found ${devices.length}; ` + + "set POCKETJS_REDMI1S_SERIAL", + ); + } + return devices[0]; +} + +function adb(serial: string, args: readonly string[]): string { + return mustRun("adb", ["-s", serial, ...args]); +} + +function deviceProperty(serial: string, key: string): string { + return adb(serial, ["shell", "getprop", key]).replace(/\r/g, "").trim(); +} + +function verifyDeviceIdentity(): string { + const serial = connectedSerial(); + const observed = { + model: deviceProperty(serial, "ro.product.model"), + codename: deviceProperty(serial, "ro.product.device"), + boardPlatform: deviceProperty(serial, "ro.board.platform"), + androidRelease: deviceProperty(serial, "ro.build.version.release"), + sdk: deviceProperty(serial, "ro.build.version.sdk"), + abi: deviceProperty(serial, "ro.product.cpu.abi"), + }; + for (const [key, expected] of Object.entries({ + model: TOOLCHAIN.device.model, + codename: TOOLCHAIN.device.codename, + boardPlatform: TOOLCHAIN.device.boardPlatform, + androidRelease: TOOLCHAIN.device.androidRelease, + sdk: TOOLCHAIN.device.sdk, + abi: TOOLCHAIN.device.abi, + })) { + if (observed[key as keyof typeof observed] !== expected) { + throw new Error( + `pocket redmi1s: refusing ${serial}; ${key}=${observed[key as keyof typeof observed]}, expected ${expected}`, + ); + } + } + const display = adb(serial, ["shell", "wm", "size"]).replace(/\r/g, ""); + const density = adb(serial, ["shell", "wm", "density"]).replace(/\r/g, ""); + const expectedViewport = TOOLCHAIN.device.physicalViewport.join("x"); + if ( + !display.includes(`Physical size: ${expectedViewport}`) || + !density.includes(`Physical density: ${TOOLCHAIN.device.density}`) + ) { + throw new Error( + `pocket redmi1s: refusing display ${display.trim()} / ${density.trim()}; ` + + `expected ${expectedViewport} @${TOOLCHAIN.device.density}`, + ); + } + return serial; +} + +function check(label: string, ok: boolean, detail: string): boolean { + console.log(`[${ok ? "ok" : "missing"}] ${label}: ${detail}`); + return ok; +} + +async function doctor(): Promise { + const rustTargets = commandPath("rustup") + ? run("rustup", [ + "target", + "list", + "--installed", + "--toolchain", + TOOLCHAIN.compiler.rustToolchain, + ]).stdout + : ""; + const tools = [ + [ + "ADB", + Boolean(commandPath("adb")), + commandPath("adb") || "install platform-tools", + ], + ["NDK clang", existsSync(clangPath()), clangPath()], + ["android.jar", existsSync(androidJar()), androidJar()], + [ + "aapt", + existsSync(join(buildToolsRoot(), "aapt")), + join(buildToolsRoot(), "aapt"), + ], + [ + "zipalign", + existsSync(join(buildToolsRoot(), "zipalign")), + join(buildToolsRoot(), "zipalign"), + ], + [ + "apksigner", + existsSync(join(buildToolsRoot(), "apksigner")), + join(buildToolsRoot(), "apksigner"), + ], + [ + "Rust target", + rustTargets.split("\n").includes(TOOLCHAIN.compiler.rustTarget), + TOOLCHAIN.compiler.rustTarget, + ], + ["QuickJS", quickJsReady(), quickJsRoot()], + ] as const; + let ready = true; + for (const [label, ok, detail] of tools) + ready = check(label, ok, detail) && ready; + try { + const serial = verifyDeviceIdentity(); + check( + "Redmi 1S", + true, + `${serial} ${TOOLCHAIN.device.codename} Android ${TOOLCHAIN.device.androidRelease}`, + ); + } catch (error) { + ready = false; + check( + "Redmi 1S", + false, + error instanceof Error ? error.message : String(error), + ); + } + if (!ready) + throw new Error( + "pocket redmi1s: doctor found missing requirements; run `bun redmi1s setup`", + ); +} + +async function setup(): Promise { + setupNdk(); + setupRustTarget(); + setupQuickJs(); + ensureSigningKey(); + console.log(`prepared ${TOOLCHAIN.toolchainVersion}`); +} + +async function build(): Promise { + setupRustTarget(); + setupQuickJs(); + const manifest = JSON.parse(readFileSync(manifestPath(), "utf8")); + const plan = resolveRedmi1SBuildPlan(manifest); + mkdirSync(dirname(planPath()), { recursive: true }); + writeFileSync(planPath(), JSON.stringify(plan, null, 2) + "\n"); + const inputs = extractHostBuildInputs(plan, { + expectedTarget: REDMI1S_DEV_TARGET_ID, + }); + + rmSync(guestDirectory(), { recursive: true, force: true }); + mkdirSync(guestDirectory(), { recursive: true }); + mustRun("bun", [ + "tools/build.ts", + `--plan=${planPath()}`, + `--project-root=${REPOSITORY}`, + `--outdir=${guestDirectory()}`, + ]); + const guestJavaScript = join(guestDirectory(), `${inputs.appOutput}.js`); + const guestPak = join(guestDirectory(), `${inputs.appOutput}.pak`); + if (!existsSync(guestJavaScript) || !existsSync(guestPak)) { + throw new Error( + "pocket redmi1s: guest build did not produce JS and pak artifacts", + ); + } + + const nativeBuild = nativeBuildDirectory(); + const staging = join(nativeBuild, "apk"); + const resources = join(staging, "res"); + const assets = join(staging, "assets"); + const libraries = join(staging, "lib/armeabi-v7a"); + rmSync(nativeBuild, { recursive: true, force: true }); + mkdirSync(resources, { recursive: true }); + mkdirSync(assets, { recursive: true }); + mkdirSync(libraries, { recursive: true }); + cpSync(guestJavaScript, join(assets, "app.js")); + cpSync(guestPak, join(assets, "app.pak")); + const icons = await bakeRedmi1SArtwork(resources); + + const clang = clangPath(); + const quickjs = quickJsSource(); + const glueDirectory = join(ndkRoot(), "sources/android/native_app_glue"); + const rustTarget = rustTargetDirectory(); + mkdirSync(rustTarget, { recursive: true }); + mustRun( + "rustup", + [ + "run", + TOOLCHAIN.compiler.rustToolchain, + "cargo", + "build", + "--release", + "--locked", + "--features", + "bare-platform", + "--target", + TOOLCHAIN.compiler.rustTarget, + ], + { + cwd: join(REPOSITORY, "engine/symbian"), + env: { + ...process.env, + CARGO_TARGET_DIR: rustTarget, + CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER: clang, + }, + }, + ); + const coreLibrary = join( + rustTarget, + TOOLCHAIN.compiler.rustTarget, + "release/libpocketjs_symbian_core.a", + ); + if (!existsSync(coreLibrary)) { + throw new Error( + `pocket redmi1s: missing Rust static library ${coreLibrary}`, + ); + } + + const buildId = hashInputs([ + { label: "plan", path: planPath() }, + { label: "guest/app.js", path: guestJavaScript }, + { label: "guest/app.pak", path: guestPak }, + { + label: "host/manifest", + path: join(REPOSITORY, "hosts/android-redmi1s/AndroidManifest.xml"), + }, + { + label: "host/runtime", + path: join(REPOSITORY, "hosts/android-redmi1s/runtime.c"), + }, + { + label: "host/rust-abort", + path: join(REPOSITORY, "hosts/android-redmi1s/rust_abort.c"), + }, + { + label: "host/pocket-runtime", + path: join(REPOSITORY, "hosts/iphone2g/pocket_runtime.c"), + }, + { + label: "host/pocket-runtime-header", + path: join(REPOSITORY, "hosts/iphone2g/pocket_runtime.h"), + }, + { + label: "host/pocket-core-header", + path: join(REPOSITORY, "hosts/iphone2g/pocket_core.h"), + }, + { label: "tool", path: join(REPOSITORY, "tools/redmi1s.ts") }, + { label: "profile", path: join(REPOSITORY, "tools/redmi1s-profile.ts") }, + { + label: "toolchain", + path: join(REPOSITORY, "tools/cli/redmi1s-toolchain.json"), + }, + { label: "icon-source", path: join(REPOSITORY, "hosts/iphone4s/Icon.svg") }, + { label: "core", path: coreLibrary }, + ]); + + const common = [ + "-std=c11", + "-O2", + "-fPIC", + "-fno-strict-aliasing", + "-fwrapv", + "-DANDROID", + "-D_GNU_SOURCE", + "-I", + join(REPOSITORY, "hosts/iphone2g"), + "-isystem", + quickjs, + "-I", + glueDirectory, + ]; + const firstPartyWarnings = ["-Wall", "-Wextra", "-Werror"]; + const compile = ( + source: string, + output: string, + extra: readonly string[] = [], + ) => mustRun(clang, [...common, ...extra, "-c", source, "-o", output]); + + const runtimeObject = join(nativeBuild, "runtime.o"); + compile(join(REPOSITORY, "hosts/android-redmi1s/runtime.c"), runtimeObject, [ + ...firstPartyWarnings, + `-DPOCKET_BUILD_ID=\"${buildId}\"`, + `-DPOCKET_LOGICAL_WIDTH=${inputs.viewport.logical[0]}`, + `-DPOCKET_LOGICAL_HEIGHT=${inputs.viewport.logical[1]}`, + `-DPOCKET_PHYSICAL_WIDTH=${inputs.viewport.physical[0]}`, + `-DPOCKET_PHYSICAL_HEIGHT=${inputs.viewport.physical[1]}`, + ]); + const guestRuntimeObject = join(nativeBuild, "pocket_runtime.o"); + compile( + join(REPOSITORY, "hosts/iphone2g/pocket_runtime.c"), + guestRuntimeObject, + [ + ...firstPartyWarnings, + `-DPOCKETJS_TARGET_ID=\"${inputs.target}\"`, + `-DPOCKETJS_HOST_ABI=${inputs.hostAbi}`, + `-DPOCKET_RASTER_DENSITY=${inputs.viewport.rasterDensity}`, + ], + ); + const rustAbortObject = join(nativeBuild, "rust_abort.o"); + compile( + join(REPOSITORY, "hosts/android-redmi1s/rust_abort.c"), + rustAbortObject, + firstPartyWarnings, + ); + const glueObject = join(nativeBuild, "android_native_app_glue.o"); + compile(join(glueDirectory, "android_native_app_glue.c"), glueObject); + + const quickJsObjects: string[] = []; + for (const source of [ + "quickjs.c", + "cutils.c", + "dtoa.c", + "libregexp.c", + "libunicode.c", + ]) { + const output = join(nativeBuild, `quickjs-${source.replace(/\.c$/, "")}.o`); + compile(join(quickjs, source), output, [ + `-DCONFIG_VERSION=\"${TOOLCHAIN.compiler.quickJsVersion}\"`, + "-Wno-implicit-const-int-float-conversion", + ]); + quickJsObjects.push(output); + } + + const sharedLibrary = join(libraries, "libpocketjs.so"); + mustRun(clang, [ + "-shared", + "-Wl,--no-undefined", + "-Wl,-soname,libpocketjs.so", + "-Wl,-z,max-page-size=4096", + "-Wl,--build-id=sha1", + "-o", + sharedLibrary, + runtimeObject, + guestRuntimeObject, + rustAbortObject, + glueObject, + ...quickJsObjects, + "-Wl,--whole-archive", + coreLibrary, + "-Wl,--no-whole-archive", + "-landroid", + "-llog", + "-lEGL", + "-lGLESv2", + "-ldl", + "-lm", + ]); + + mkdirSync(outputDirectory(), { recursive: true }); + const unsignedApk = join(nativeBuild, "unsigned.apk"); + const alignedApk = join(nativeBuild, "aligned.apk"); + const aapt = join(buildToolsRoot(), "aapt"); + const zipalign = join(buildToolsRoot(), "zipalign"); + const apksigner = join(buildToolsRoot(), "apksigner"); + mustRun(aapt, [ + "package", + "-f", + "-M", + join(REPOSITORY, "hosts/android-redmi1s/AndroidManifest.xml"), + "-S", + resources, + "-A", + assets, + "-I", + androidJar(), + "-F", + unsignedApk, + ]); + mustRun("zip", ["-q", "-r", unsignedApk, "lib"], { cwd: staging }); + mustRun(zipalign, ["-f", "4", unsignedApk, alignedApk]); + cpSync(alignedApk, apkPath()); + mustRun(apksigner, [ + "sign", + "--ks", + ensureSigningKey(), + "--ks-pass", + "pass:android", + "--key-pass", + "pass:android", + "--ks-key-alias", + "pocketjs", + "--min-sdk-version", + TOOLCHAIN.compiler.minimumSdk, + apkPath(), + ]); + mustRun(apksigner, ["verify", "--verbose", "--print-certs", apkPath()]); + const badging = mustRun(aapt, ["dump", "badging", apkPath()]); + for (const marker of [ + `package: name='${PACKAGE_NAME}'`, + `sdkVersion:'${TOOLCHAIN.compiler.minimumSdk}'`, + "native-code: 'armeabi-v7a'", + "uses-gl-es: '0x20000'", + ]) { + if (!badging.includes(marker)) + throw new Error(`pocket redmi1s: APK is missing ${marker}`); + } + const sharedLibraryInfo = mustRun("file", [sharedLibrary]); + if ( + !sharedLibraryInfo.includes("ELF 32-bit") || + !sharedLibraryInfo.includes("ARM") + ) { + throw new Error( + `pocket redmi1s: unexpected native library: ${sharedLibraryInfo}`, + ); + } + chmodSync(apkPath(), 0o644); + + const files: Record = { + [APK_NAME]: sha256(apkPath()), + "lib/armeabi-v7a/libpocketjs.so": sha256(sharedLibrary), + }; + for (const icon of icons) + files[`res/${icon.slice(resources.length + 1)}`] = sha256(icon); + const receipt: BuildReceipt = { + schema: 1, + buildId, + packageName: PACKAGE_NAME, + target: inputs.target, + hostAbi: inputs.hostAbi, + minimumSdk: TOOLCHAIN.compiler.minimumSdk, + abi: "armeabi-v7a", + files, + }; + writeFileSync(receiptPath(), JSON.stringify(receipt, null, 2) + "\n"); + console.log(`built ${apkPath()} (${buildId})`); +} + +async function deploy(): Promise { + const serial = verifyDeviceIdentity(); + const receipt = readReceipt(); + if (sha256(apkPath()) !== receipt.files[APK_NAME]) { + throw new Error("pocket redmi1s: APK no longer matches its build receipt"); + } + const installed = adb(serial, ["install", "-r", apkPath()]); + if (!installed.includes("Success")) + throw new Error(`pocket redmi1s: install failed: ${installed}`); + const packagePath = adb(serial, ["shell", "pm", "path", PACKAGE_NAME]) + .replace(/\r/g, "") + .trim(); + if (!packagePath.startsWith("package:")) { + throw new Error(`pocket redmi1s: package readback failed: ${packagePath}`); + } + console.log(`installed ${receipt.buildId} on ${serial}: ${packagePath}`); +} + +function parseStatus(text: string): DeviceStatus { + const fields: Record = {}; + for (const line of text.replace(/\r/g, "").trim().split("\n")) { + const separator = line.indexOf("="); + if (separator > 0) + fields[line.slice(0, separator)] = line.slice(separator + 1); + } + if (fields.schema !== "1" || !fields.build_id || !fields.state) { + throw new Error(`pocket redmi1s: malformed device status:\n${text}`); + } + return fields; +} + +function readDeviceStatus(serial: string): DeviceStatus { + return parseStatus( + adb(serial, ["shell", "run-as", PACKAGE_NAME, "cat", "files/status.txt"]), + ); +} + +async function launch(): Promise { + const serial = verifyDeviceIdentity(); + adb(serial, ["shell", "am", "force-stop", PACKAGE_NAME]); + const result = adb(serial, ["shell", "am", "start", "-n", COMPONENT]); + if (!result.includes("Starting: Intent")) + throw new Error(`pocket redmi1s: launch failed:\n${result}`); + const receipt = readReceipt(); + for (let attempt = 0; attempt < 80; attempt += 1) { + await Bun.sleep(250); + try { + const current = readDeviceStatus(serial); + if (current.build_id === receipt.buildId && current.state === "running") { + console.log(`launched ${receipt.buildId}: ${current.gl_renderer}`); + return; + } + if (current.build_id === receipt.buildId && current.state === "failed") { + throw new Error(`pocket redmi1s: runtime failed: ${current.error}`); + } + } catch (error) { + if (error instanceof Error && error.message.includes("runtime failed")) + throw error; + } + } + const logs = adb(serial, ["logcat", "-d", "-s", "PocketJS-Redmi1S:*", "*:S"]); + throw new Error( + `pocket redmi1s: runtime did not reach running state:\n${logs}`, + ); +} + +function numeric(status: DeviceStatus, field: string): number { + const value = Number(status[field]); + if (!Number.isFinite(value)) + throw new Error(`pocket redmi1s: status field ${field} is not numeric`); + return value; +} + +async function status(requireAction = false): Promise { + const serial = verifyDeviceIdentity(); + const receipt = readReceipt(); + const first = readDeviceStatus(serial); + await Bun.sleep(1000); + const second = readDeviceStatus(serial); + if (second.build_id !== receipt.buildId) { + throw new Error( + `pocket redmi1s: device build ${second.build_id} does not match local ${receipt.buildId}`, + ); + } + if (second.state !== "running" || second.renderer !== "gles2") { + throw new Error( + `pocket redmi1s: state=${second.state} renderer=${second.renderer} error=${second.error}`, + ); + } + if ( + second.logical_viewport !== "360x640" || + second.physical_viewport !== "720x1280" || + second.surface !== "720x1280" + ) { + throw new Error( + "pocket redmi1s: runtime viewport receipt does not match the device contract", + ); + } + if (!second.gl_renderer.includes(TOOLCHAIN.device.gpuRenderer)) { + throw new Error( + `pocket redmi1s: unexpected GPU renderer ${second.gl_renderer}`, + ); + } + const glVersion = /^OpenGL ES (\d+)\.(\d+)/.exec(second.gl_version); + if (!glVersion || Number(glVersion[1]) < 2) { + throw new Error( + `pocket redmi1s: runtime does not satisfy GLES2: ${second.gl_version}`, + ); + } + if ( + numeric(second, "guest_frames") <= numeric(first, "guest_frames") || + numeric(second, "swaps") <= numeric(first, "swaps") + ) { + throw new Error( + "pocket redmi1s: guest frames or EGL swaps did not advance", + ); + } + if ( + numeric(second, "capture_successes") < 1 || + second.capture_hash === "0000000000000000" + ) { + throw new Error("pocket redmi1s: GPU readback capture is absent"); + } + if ( + requireAction && + (second.action_name !== "hero_tap" || + numeric(second, "action_value") < 1 || + numeric(second, "completed_touch_sequences") < 1) + ) { + throw new Error("pocket redmi1s: no completed Hero touch/action receipt"); + } + console.log( + [ + `build=${second.build_id}`, + `state=${second.state}`, + `renderer=${second.renderer}`, + `gpu=${second.gl_renderer}`, + `gl=${second.gl_version}`, + `frames=${second.guest_frames}`, + `swaps=${second.swaps}`, + `mean_frame_us=${second.mean_frame_us}`, + `mean_swap_us=${second.mean_swap_us}`, + `touches=${second.completed_touch_sequences}`, + `action=${second.action_name || "none"}:${second.action_value || "0"}`, + `capture=${second.capture_hash}`, + ].join("\n"), + ); + return second; +} + +async function capture(): Promise { + const serial = verifyDeviceIdentity(); + const current = readDeviceStatus(serial); + const width = Number(current.surface?.split("x")[0]); + const height = Number(current.surface?.split("x")[1]); + const remoteRaw = "/data/local/tmp/pocketjs-redmi1s-frame.rgba"; + const localRaw = join(outputDirectory(), ".device-frame.rgba"); + adb(serial, [ + "shell", + `run-as ${PACKAGE_NAME} cat files/frame.rgba > ${remoteRaw}`, + ]); + mustRun("adb", ["-s", serial, "pull", remoteRaw, localRaw]); + adb(serial, ["shell", "rm", remoteRaw]); + const raw = readFileSync(localRaw); + rmSync(localRaw, { force: true }); + const expected = width * height * 4; + if ( + !Number.isInteger(width) || + !Number.isInteger(height) || + raw.length !== expected + ) { + throw new Error( + `pocket redmi1s: capture is ${raw.length} bytes, expected ${expected}`, + ); + } + const canvas = createCanvas(width, height); + const image = canvas.getContext("2d").createImageData(width, height); + const row = width * 4; + for (let y = 0; y < height; y += 1) { + image.data.set( + raw.subarray((height - 1 - y) * row, (height - y) * row), + y * row, + ); + } + canvas.getContext("2d").putImageData(image, 0, 0); + const framePath = join(outputDirectory(), "device-frame.png"); + writeFileSync(framePath, canvas.toBuffer("image/png")); + + const remoteScreen = "/data/local/tmp/pocketjs-redmi1s-screen.png"; + const screenPath = join(outputDirectory(), "device-screen.png"); + adb(serial, ["shell", "screencap", "-p", remoteScreen]); + mustRun("adb", ["-s", serial, "pull", remoteScreen, screenPath]); + adb(serial, ["shell", "rm", remoteScreen]); + const screen = readFileSync(screenPath); + if (screen.subarray(1, 4).toString("ascii") !== "PNG") { + throw new Error("pocket redmi1s: Android screenshot is not a PNG"); + } + const decoded = await loadImage(screenPath); + if (decoded.width !== 720 || decoded.height !== 1280) { + throw new Error( + `pocket redmi1s: screenshot is ${decoded.width}x${decoded.height}, expected 720x1280`, + ); + } + console.log(`captured ${framePath}\ncaptured ${screenPath}`); +} + +async function accept(): Promise { + const serial = verifyDeviceIdentity(); + await status(false); + await capture(); + adb(serial, ["shell", "input", "tap", "120", "1190"]); + await Bun.sleep(1000); + await status(true); + await capture(); + console.log("Redmi 1S hardware acceptance passed"); +} + +function usage(): never { + console.error(`usage: bun redmi1s + + doctor inspect the API 18 toolchain and exact connected phone + setup install the Rust target, pinned QuickJS source, and signing key + build compile the guest, ARMv7 runtime, GLES2 host, icons, and signed APK + deploy install the receipt-matched APK over ADB + launch start NativeActivity and wait for the GLES2 runtime + status verify advancing guest frames, EGL swaps, GPU identity, and capture + capture pull the GLES2 readback and Android compositor screenshot + accept verify runtime, inject a real Android touch, and verify Hero action + all build, deploy, launch, and accept`); + process.exit(2); +} + +async function main(): Promise { + const command = Bun.argv[2]; + if (command === "doctor") await doctor(); + else if (command === "setup") await setup(); + else if (command === "build") await build(); + else if (command === "deploy") await deploy(); + else if (command === "launch") await launch(); + else if (command === "status") + await status(Bun.argv.includes("--require-action")); + else if (command === "capture") await capture(); + else if (command === "accept") await accept(); + else if (command === "all") { + await build(); + await deploy(); + await launch(); + await accept(); + } else usage(); +} + +if (import.meta.main) await main(); diff --git a/tools/test.ts b/tools/test.ts index bbdb6ee5..011c64e3 100644 --- a/tools/test.ts +++ b/tools/test.ts @@ -50,6 +50,7 @@ const SUITE: readonly Stage[] = [ "tests/iphone4s-profile.test.ts", "tests/ipodtouch-profile.test.ts", "tests/meizu-m8-profile.test.ts", + "tests/redmi1s-profile.test.ts", "tests/ios-profile.test.ts", "tests/iphone2g-device-contract.test.ts", "tests/iphone2g-toolchain.test.ts",