Skip to content

feat(android): repack and sign APKs in TypeScript, and one repack entry for the Studio's Worker - #568

Merged
doodlewind merged 1 commit into
mainfrom
feat/android-ts-repack
Oct 6, 2026
Merged

doodlewind merged 1 commit into
mainfrom
feat/android-ts-repack

Conversation

@doodlewind

Copy link
Copy Markdown
Collaborator

Phase 2 of server-side packaging for Pocket Studio: the Android repack runs with no SDK tool and signs in Web Crypto, every repack is checked to bundle for a Cloudflare Worker, and tools/repack/index.ts is the one module the Studio imports.

For the Studio: tools/repack/index.ts

import { REPACK_TARGETS } from "../vendor/pocketjs/tools/repack/index.ts";

const row = REPACK_TARGETS[device];            // "psp" | "vita" | "3ds" | "ipod-touch" | "android"
const bytes = await row.repack({
  runtime,   // Map<string, Uint8Array>: every file of `bun tools/runtime.ts ${row.runtime}`'s directory, runtime.json included
  pocket,    // Uint8Array: a .pocket holding a variant for one of row.variantTargets
  identity: { id, title, author, version, icon /* optional square PNG */ },
  signer,    // Android only: { privateKey: CryptoKey | Uint8Array /* PKCS #8 DER */, certificate: Uint8Array /* X.509 DER */ }
});
// serve as `${name}${row.extension}` with Content-Type row.contentType
row runtime variantTargets extension contentType signed
psp psp psp .zip application/zip no
vita vita vita .vpk application/octet-stream no
3ds 3ds 3ds-dev .3dsx application/octet-stream no
ipod-touch ipod ipodtouch4-dev .ipa application/octet-stream no
android android redmi-1s-dev, moto-g-play-dev .apk application/vnd.android.package-archive yes

repackTarget(name) finds a row by Studio target or runtime name. tools/repack.ts (the CLI) now calls the same rows; for Android it reads the community key's two DER files.

The Android signer is the Pocket Studio community key. REPACK_TARGETS.android.repack refuses a certificate whose SHA-256 is not POCKET_STUDIO_COMMUNITY_SIGNER (54c2abe6…dff6095c); repackAndroid(input, { certificateSha256 }) takes another certificate only when the caller names it (the tests' throwaway keys). A Worker keeps the two files as secrets (base64) and passes { privateKey: bytes, certificate: bytes }, or a CryptoKey it imported once (importKey("pkcs8", …, { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, false, ["sign"])). The repack verifies its v2 signature with the certificate's public key, so a key that is not the certificate's is refused.

Android: template APK + TypeScript patch + Web Crypto signatures

Ported from exp/android-pure-ts-repack (83fb0b8) onto main and made the default.

  • Runtime (bun tools/runtime.ts android): tools/android.ts runtime builds the libraries and classes.dex into a scratch directory, tools/android-link.ts links them with aapt2 into template.apk (identity dev.pocket-nexus.runtime.template, default icon, empty assets, unsigned). The runtime directory is template.apk + the two templates it was linked from + runtime.json: 3 files, 1 759 075 bytes (was 10 files, 4 727 681). Built in 33.2 s at efb39e9.
  • Repack (tools/repack/android.ts): keeps the template's entries in order, rewrites the compiled AndroidManifest.xml (package, versionCode, versionName) and resources.arsc (package name, string/app_name) in tools/repack/shared/android-resources.ts, swaps icons and assets/app.js / assets/app.pak, aligns stored entries as zipalign -p 4 does, and signs v1 + v2 in tools/repack/shared/apk-sign.ts.
  • What it took to match aapt2 byte for byte, beyond the experiment: aapt2 sorts each string pool (the manifest's attribute names with resource ids first, then every other string by UTF-8 bytes; the table's global pool by UTF-8 bytes), so a new package name or label moves and every reference is renumbered; and aapt2 writes a UTF-8 pool's characters past U+FFFF as modified UTF-8 (two 3-byte surrogates). Strings are deduplicated and unreferenced ones dropped, as aapt2 does.
  • The aapt2 + zipalign + apksigner path is now the test oracle only (tools/repack/android-native.ts is gone).

Package name rule (for the Studio's docs)

Each - becomes _; a dot-separated segment that then starts with a digit gets x in front. dev.pocket-nexus.studio.2pac.8-ball → dev.pocket_nexus.studio.x2pac.x8_ball. Each segment is then a letter followed by letters, digits and _; the name holds at most 127 characters (the resource table's limit), else the repack refuses. Two ids collide only when one has a segment x<digit>… where the other has <digit>… (…studio.x2pac and …studio.2pac); the Studio can refuse handles and slugs that start with x and a digit, or check androidPackageName(id) (exported from index.ts) for uniqueness. versionCode stays major × 1 000 000 + minor × 1 000 + patch; the label is the title with ASCII whitespace trimmed and inner runs as one space (what aapt2 makes of a string resource), control characters refused.

Other targets, for the same docs: the iPod bundle id is the app id unchanged (CFBundleIdentifier, and the URL scheme is the id lowercased); the Vita title id is P + the first 8 hex digits of SHA-256(app id), uppercase; the 3DS state folder is sdmc:/pocketjs/runtime/apps/<first 16 lowercase hex digits of SHA-256(app id)>/state.

The community key as a Worker secret takes it

bun tools/community-key.ts reads ~/.config/pocket-nexus/signing/pocket-studio-community.p12 with OpenSSL (-passin file: the .password beside it; the password is on no command line and in no output, the key goes from OpenSSL's stdout to the file without being printed) and writes pocket-studio-community.pkcs8.der (2 374 bytes) and pocket-studio-community.cert.der (1 343 bytes) beside it, mode 600. It writes nothing unless the certificate is 54c2abe6… and the key signs what the certificate verifies, and it refuses a keystore whose name says release. Run once on this Mac. The Pocket Nexus release key was not read.

Every repack runs in a Worker

  • tests/repack-worker.test.ts bundles tools/repack/index.ts and each of psp.ts, vita.ts, 3ds.ts, ipod.ts, android.ts with Bun.build({ target: "browser" }), fails on any Node or Bun built-in module (a resolve plugin over node:*, bun:* and builtinModules) and on any Bun., process., require(, __dirname, import.meta.dir; a control case shows the check sees node:fs and Bun.file. It runs the bundled Android module and requires the same APK as the source module.
  • workerd itself: the bundled entry ran in workerd 2026-10-01 (the Studio checkout's @cloudflare/workerd-darwin-arm64, navigator.userAgent = Cloudflare-Workers) behind a fetch handler: Twenty48 TS Remix's APK with a 4096-bit throwaway key, 99–152 ms per request (3.97 MB request), passing apksigner verify v1 + v2 and zipalign -c -p 4. Its uncompressed entries and icon pixels equal the Bun-made APK's; the deflate streams differ (each runtime's CompressionStream), so byte-identical output holds within one JavaScript runtime.

Verified

aapt2 oracle (tests/repack-android.test.ts, SDK present): a corpus of six games (hyphens, digit segments, Tom's "Test" & <Game> 100%, @home \ ?, Japanese with U+3000, an emoji label, whitespace runs, versions 0.0.1 to 1999.999.999, with and without an icon) on the fixture template and on dist/runtime/android: the TypeScript APK holds the same entries in the same order with the same bytes outside META-INF/ as aapt2 + zipalign + apksigner's, passes apksigner verify (v1, v2, the test certificate) and zipalign -c -p 4, and aapt2 dump badging reads the package and label.

Redmi 1S (HM 1S, MIUI V5, Android 4.3 / SDK 18, adb f5f4c0ab), lease android:f5f4c0ab via tools/device-lease.ts. The adb server listed no device although the phone was on USB; adb kill-server + start-server brought it back (as #563 saw).

  • Starter twenty48 copied from pocket-studio-ui starters/ (not edited there) with id dev.pocket-nexus.studio.2048-remix → package dev.pocket_nexus.studio.x2048_remix (the digit rule, and no collision with Studio's release-signed twenty48 or feat(android): a generic runtime and a repack that signs games with the community key #563's community-signed twenty48_remix, both left untouched), title "Twenty48 TS Remix", a 512 px two-colour icon.
  • bun tools/repack.ts --target android … -o x2048-remix.apk with the real community key: 186 ms, 1 928 590 bytes, SHA-256 59d25415…0b860955. apksigner verify --print-certs: v1 true, v2 true, CN=Pocket Studio Community, O=Pocket Nexus Inc., C=US, SHA-256 54c2abe6c53e5ead78b7d6c20ebcf23ed03b61d6d7ff081256e155fcdff6095c, no warnings; zipalign -c -p 4 passes.
  • adb install -r: Success in 7.5 s (one MIUI "Install" screen answered through uiautomator); dumpsys package: versionCode 1000, versionName 0.1.0, no DEBUGGABLE flag. am start: displayed in 503 ms, title card; tap to start and twelve input swipes: tiles slide and merge, score 0 → 32. The launcher shows the scaled icon and the label; launching from the icon opens the game. No crash in logcat. Uninstalled afterwards; nothing of this PR is left on the phone.

Twenty48 TS Remix on the Redmi 1S after twelve swipes
MIUI launcher with the repacked icon and label

Timings (this Mac, Bun 1.3.14)

step time
Android repack, single-variant .pocket, community key (RSA 4096) 186 ms
same, 4-variant .pocket (5.8 MB) 311 ms, of which 207 ms is decoding the .pocket (its footer hash)
inflating the template's entries for the v1 digests 35 ms
two RSA 4096 signatures 7 ms
workerd, per request 99–152 ms

Tests

bun test tests/repack-*.test.ts                                  # 88 pass (SDK present: the aapt2 corpus runs)
POCKETJS_ANDROID_SDK_ROOT=/nonexistent bun test tests/repack-android.test.ts   # 31 pass, 3 skip (CI's view)
bunx tsc --noEmit                                                # no errors in changed files
  • tests/repack-android.test.ts (34): package-name rule and refusals, versionCode, label, escaping; binary manifest/table read-back, rewrites that depend on the identity alone (A then B equals B), sorted pools, a label equal to a pool path sharing its entry, modified UTF-8; the APK's entries, contents, alignment, v1 + v2 verified by a separate TypeScript verifier (tests/helpers/apk-test-signer.ts), determinism from PKCS Track clean-main PocketJS naming cleanup #8 bytes and from a CryptoKey, the default icon; refusals (no signer, another certificate, a key that is not the certificate's, a bad key, no or a signed template, and phase 1's target/ABI/footer/file/viewport/icon/identity checks); the SDK-gated fixture check and corpus.
  • tests/fixtures/repack-android/template.apk (6 438 bytes): aapt2's link of hosts/android/app with stand-in classes.dex and libraries; the SDK-gated test relinks it and compares (POCKETJS_WRITE_ANDROID_FIXTURE=1 rewrites it).
  • tools/repack/shared/zip.ts: align no longer forces an entry stored; it aligns whichever entry ends up stored. admitPocket encodes the thinned .pocket on first read of bytes (Android never ships it).

Not done here

  • The .pocket footer hash costs 207 ms on a 5.8 MB package (contracts/spec/pocket-package.ts); a faster FNV implementation would cut every target's repack in a Worker. Left for a separate change.
  • The moto-g-play runtime (--profile=moto-g-play) links its template the same way; it was not built or run on a phone here.

🤖 Generated with Claude Code

…ry for the Studio's Worker

The Android repack runs with no SDK tool: the runtime build links one
unsigned template.apk with aapt2, and tools/repack/android.ts rewrites its
binary manifest and resource table (string pools sorted and renumbered as
aapt2 writes them), swaps the icons and the game's assets, aligns stored
entries as zipalign -p 4 does, and signs v1 + v2 with Web Crypto. The signer
is { privateKey, certificate }; a certificate other than the Pocket Studio
community certificate is refused unless the caller names it. aapt2, zipalign
and apksigner stay as the test oracle where the SDK is installed.

A segment of an app id that starts with a digit becomes `x` + the segment in
the Android package name.

tools/repack/index.ts gives the Studio one row per target (repack, runtime,
variantTargets, extension, contentType, signed); tests/repack-worker.test.ts
bundles it and every target's module for the browser and fails on any Node
or Bun API. tools/community-key.ts writes the community key as PKCS #8 and
X.509 DER files beside its keystore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@doodlewind
doodlewind merged commit 2f6b09c into main Oct 6, 2026
9 checks passed
@doodlewind
doodlewind deleted the feat/android-ts-repack branch October 6, 2026 20:00
doodlewind added a commit that referenced this pull request Oct 7, 2026
…eads to (#570)

QuickJS's JS_Eval reads its input up to input[length], which must be
'\0'. PocketActivity reads assets/app.js into a byte array without a
terminator and copy_java_bytes copied exactly that many bytes, so the
byte after the script was whatever the heap held. A Studio-made Package
Check APK and the twenty48 remix APK from #568's runtime both stopped at
boot on the Redmi 1S with a SyntaxError at the script's end; with the
terminated copy the same game starts and plays.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant