Skip to content

feat: labelle android build/run (#134) - #144

Merged
apotema merged 2 commits into
mainfrom
feat/android-cli
Apr 13, 2026
Merged

apotema merged 2 commits into
mainfrom
feat/android-cli

Conversation

@apotema

@apotema apotema commented Apr 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • New labelle android build [--emulator] [--release] command — cross-compiles shared library for Android
  • New labelle android run [--emulator] — builds + packages APK + deploys via ADB
  • AndroidConfig struct for project.labelle (package_name, min/target SDK, orientation)
  • AndroidManifest.xml generation for NativeActivity
  • APK packaging pipeline: aapt → zipalign → apksigner (no Gradle needed)
  • Auto-generated debug keystore at ~/.labelle/android-debug.keystore
  • ADB install + launch integration
  • SDK/NDK tool detection via ANDROID_HOME / PATH

Depends on labelle-toolkit/labelle-assembler#3 for build.zig template generation.
Closes #134

Test plan

  • zig build + zig build test pass
  • Desktop labelle run regression — still works
  • labelle android build on a project with Android SDK installed
  • labelle android run --emulator deploys to emulator

🤖 Generated with Claude Code

#134)

Add `labelle android` subcommand:
- `labelle android build [--emulator] [--release]` — cross-compile .so
- `labelle android run [--emulator]` — build + package APK + deploy via ADB

Includes:
- AndroidConfig struct (package_name, min/target SDK, orientation)
- AndroidManifest.xml generation for NativeActivity
- APK packaging via aapt + zipalign + apksigner
- Auto-generated debug keystore at ~/.labelle/android-debug.keystore
- ADB install + launch integration
- SDK/NDK tool detection (ANDROID_HOME, PATH)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cursor

cursor Bot commented Apr 13, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Adds a new end-to-end Android build/package/deploy flow that shells out to external SDK tools (adb, aapt, zipalign, apksigner, keytool) and writes staging artifacts, so failures will be environment-dependent and could affect run behavior when targeting Android.

Overview
Adds an AndroidConfig section to project.labelle (app/package name, min/target SDK, orientation) and exposes it through the generator public API.

Introduces labelle android build/run and wires CLI dispatch so labelle android forces sokol + android, can build for device or emulator, and can deploy on run by packaging an APK (manifest generation + asset staging + aapt/zipalign/apksigner) then installing/launching via adb with an auto-generated debug keystore in the CLI cache.

Reviewed by Cursor Bugbot for commit 8232939. Bugbot is set up for automated code reviews on this repo. Configure here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces Android support to the CLI, including configuration structures, command-line integration, and build/deployment logic via ADB. The review identified several issues: silent error handling during asset copying and directory cleanup, an unused variable for the application name that should be used in the Android manifest, a memory leak in the ADB path discovery logic, and unreliable logic for selecting the latest Android build-tools version.

Comment thread src/cli/android.zig Outdated
defer allocator.free(assets_src);
const assets_dst = try std.fs.path.join(allocator, &.{ staging_dir, "assets" });
defer allocator.free(assets_dst);
copyDirectory(allocator, assets_src, assets_dst) catch {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Errors during asset copying are silently ignored. If assets fail to copy to the staging directory, the resulting APK will be missing game data, which will likely cause runtime crashes on the device. This error should be propagated to the caller.

    try copyDirectory(allocator, assets_src, assets_dst);

Comment thread src/cli/android.zig Outdated
Comment on lines +85 to +86
const app_name = if (android_cfg.app_name.len > 0) android_cfg.app_name else cfg.title;
_ = app_name;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The app_name variable is calculated but marked as unused. It should be passed to generateAndroidManifest and used as the android:label in the manifest so the application shows the correct title on the Android home screen instead of the package identifier.

Comment thread src/cli/android.zig
// Create APK staging directory
const staging_dir = try std.fs.path.join(allocator, &.{ target_dir, "apk-staging" });
defer allocator.free(staging_dir);
std.fs.cwd().deleteTree(staging_dir) catch {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Ignoring errors when deleting the staging directory can lead to build issues if the directory is locked or contains files that cannot be removed. This might result in a mix of old and new artifacts in the APK. It is safer to use try here.

    try std.fs.cwd().deleteTree(staging_dir);

Comment thread src/cli/android.zig Outdated
\\ </application>
\\</manifest>
\\
, .{ package_name, cfg.min_sdk_version, cfg.target_sdk_version, package_name, orientation });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The android:label attribute is currently being set to the package_name (e.g., com.labelle.mygame) instead of the human-readable application name. This should be updated to use app_name (which should be passed into this function).

Comment thread src/cli/android.zig Outdated
Comment on lines +344 to +346
defer {
if (result.stdout.len == 0) allocator.free(result.stdout);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a memory leak here. result.stdout is only freed if its length is zero, but util.runCmd (which uses std.process.Child.run) always allocates this buffer. It should be unconditionally freed. The subsequent allocator.dupe at line 349 ensures the returned path remains valid even after result.stdout is freed.

    defer allocator.free(result.stdout);

Comment thread src/cli/android.zig
Comment on lines +379 to +385
var iter = dir.iterate();
while (try iter.next()) |entry| {
if (entry.kind == .directory) {
if (latest) |prev| allocator.free(prev);
latest = try allocator.dupe(u8, entry.name);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for finding the 'latest' build-tools version is unreliable because it simply picks the last entry returned by the directory iterator. Directory iteration order is not guaranteed to be sorted by version. If multiple versions are installed (e.g., 30.0.3 and 34.0.0), this might pick an older one depending on the filesystem and OS.

Comment thread src/cli/android.zig Outdated
Comment thread src/cli/android.zig
Comment thread src/cli/android.zig
Comment thread src/cli/android.zig
- propagate asset copy errors with try instead of catch {}
- pass app_name to generateAndroidManifest and use it as android:label
- add comment explaining why staging dir delete errors are silently ignored
- fix memory leak: defer-free result.stdout unconditionally in findAdb
- findBuildTools: pick highest version via std.mem.order comparison
- check keytool exit code before returning keystore path
@apotema
apotema merged commit 9e31d4a into main Apr 13, 2026
6 checks passed
@apotema
apotema deleted the feat/android-cli branch April 13, 2026 16:11

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8232939. Configure here.

Comment thread src/cli/android.zig
defer allocator.free(assets_src);
const assets_dst = try std.fs.path.join(allocator, &.{ staging_dir, "assets" });
defer allocator.free(assets_dst);
try copyDirectory(allocator, assets_src, assets_dst);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing assets directory fails Android deployment

High Severity

copyDirectory is called with try, which propagates the error and aborts deployment if the assets/ directory doesn't exist. The equivalent iOS code in ios.zig explicitly catches this error with catch {} and a comment explaining "No assets directory is fine — not all projects have assets." Projects without an assets/ directory will fail to deploy to Android even though the build is otherwise valid.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8232939. Configure here.

Comment thread src/cli/android.zig
// Locate the built .so
const lib_name = if (emulator) "x86_64-linux-android" else "aarch64-linux-android";
_ = lib_name;
const so_path = try std.fs.path.join(allocator, &.{ target_dir, "zig-out", "lib", "libgame.so" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Computed lib_name immediately discarded, unused in path

Medium Severity

lib_name is computed based on the emulator flag to distinguish between x86_64-linux-android and aarch64-linux-android, but is then immediately silenced with _ = lib_name. The so_path on the next line uses a hardcoded path without any architecture-specific component. This means the .so lookup path is identical for both emulator and device builds, which will likely fail to locate the correct binary if the build system outputs architecture-specific paths.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8232939. Configure here.

Comment thread src/cli/android.zig
defer allocator.free(build_tools_dir);

const aapt2 = try std.fs.path.join(allocator, &.{ build_tools_dir, "aapt2" });
defer allocator.free(aapt2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused aapt2 variable allocated but never referenced

Low Severity

aapt2 is allocated via std.fs.path.join and freed via defer, but is never used anywhere in the function. The comment on line 211 explicitly states "Use aapt (v1) for simplicity", and only the aapt variable (line 212) is actually used in the packaging command. This is dead code that performs an unnecessary allocation.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8232939. Configure here.

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.

Android Phase 1: CLI commands + APK packaging + ADB deployment

1 participant